diff --git a/Cargo.toml b/Cargo.toml
index c304e82..ea1f02b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -27,6 +27,7 @@ fgp-workflow = { git = "https://github.com/fast-gateway-protocol/workflow.git" }
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+serde_yaml = "0.9"
# Error handling
anyhow = "1"
@@ -51,6 +52,12 @@ crossterm = "0.28"
# Async runtime for TUI events
tokio = { version = "1", features = ["sync", "time", "rt-multi-thread"] }
+# Date/time
+chrono = "0.4"
+
+# Home directory
+dirs = "5"
+
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
diff --git a/logs/user_prompt_submit.json b/logs/user_prompt_submit.json
index 9cca033..605a982 100644
--- a/logs/user_prompt_submit.json
+++ b/logs/user_prompt_submit.json
@@ -30,5 +30,149 @@
"permission_mode": "bypassPermissions",
"hook_event_name": "UserPromptSubmit",
"prompt": "Create GitHub repos for dashboard and workflow crates"
+ },
+ {
+ "session_id": "0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae",
+ "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae.jsonl",
+ "cwd": "/Users/wolfgangschoenberger/Projects/fgp/cli",
+ "permission_mode": "bypassPermissions",
+ "hook_event_name": "UserPromptSubmit",
+ "prompt": "test the dashboard locally"
+ },
+ {
+ "session_id": "0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae",
+ "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae.jsonl",
+ "cwd": "/Users/wolfgangschoenberger/Projects/fgp/cli",
+ "permission_mode": "bypassPermissions",
+ "hook_event_name": "UserPromptSubmit",
+ "prompt": "Create a pull request for the TUI changes and then set a reminder for 10 minutes to check back."
+ },
+ {
+ "session_id": "0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae",
+ "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae.jsonl",
+ "cwd": "/Users/wolfgangschoenberger/Projects/fgp/cli",
+ "permission_mode": "bypassPermissions",
+ "hook_event_name": "UserPromptSubmit",
+ "prompt": "check if CI passed on PR 2"
+ },
+ {
+ "session_id": "0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae",
+ "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae.jsonl",
+ "cwd": "/Users/wolfgangschoenberger/Projects/fgp/cli",
+ "permission_mode": "bypassPermissions",
+ "hook_event_name": "UserPromptSubmit",
+ "prompt": "merge the TUI PR"
+ },
+ {
+ "session_id": "0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae",
+ "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae.jsonl",
+ "cwd": "/Users/wolfgangschoenberger/Projects/fgp/cli",
+ "permission_mode": "bypassPermissions",
+ "hook_event_name": "UserPromptSubmit",
+ "prompt": "How do I launch the terminal UI?"
+ },
+ {
+ "session_id": "0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae",
+ "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae.jsonl",
+ "cwd": "/Users/wolfgangschoenberger/Projects/fgp/cli",
+ "permission_mode": "bypassPermissions",
+ "hook_event_name": "UserPromptSubmit",
+ "prompt": "Last login: Wed Jan 14 19:47:20 on ttys023\nwolfgangschoenberger@Wolfgangs-MacBook-Pro fgp % fgp tui\nzsh: command not found: fgp\nwolfgangschoenberger@Wolfgangs-MacBook-Pro fgp % cd cli\nwolfgangschoenberger@Wolfgangs-MacBook-Pro cli % fgp tui\nzsh: command not found: fgp\nwolfgangschoenberger@Wolfgangs-MacBook-Pro cli %\n\n"
+ },
+ {
+ "session_id": "0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae",
+ "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae.jsonl",
+ "cwd": "/Users/wolfgangschoenberger/Projects/fgp/cli",
+ "permission_mode": "bypassPermissions",
+ "hook_event_name": "UserPromptSubmit",
+ "prompt": "install fgp globally"
+ },
+ {
+ "session_id": "b084daa3-6131-406b-a78c-8246feeb00fe",
+ "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/b084daa3-6131-406b-a78c-8246feeb00fe.jsonl",
+ "cwd": "/Users/wolfgangschoenberger/Projects/fgp/cli",
+ "permission_mode": "bypassPermissions",
+ "hook_event_name": "UserPromptSubmit",
+ "prompt": "\nb0027bc\n/tmp/claude/-Users-wolfgangschoenberger-Projects-fgp/tasks/b0027bc.output\ncompleted\nBackground command \"Wait and check release workflows\" completed (exit code 0)\n\nRead the output file to retrieve the result: /tmp/claude/-Users-wolfgangschoenberger-Projects-fgp/tasks/b0027bc.output"
+ },
+ {
+ "session_id": "0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae",
+ "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae.jsonl",
+ "cwd": "/Users/wolfgangschoenberger/Projects/fgp/cli",
+ "permission_mode": "bypassPermissions",
+ "hook_event_name": "UserPromptSubmit",
+ "prompt": "start a service to test the tui"
+ },
+ {
+ "session_id": "0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae",
+ "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae.jsonl",
+ "cwd": "/Users/wolfgangschoenberger/Projects/fgp/cli",
+ "permission_mode": "bypassPermissions",
+ "hook_event_name": "UserPromptSubmit",
+ "prompt": "This is what I see. This is what I see. Does this look correct?"
+ },
+ {
+ "session_id": "0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae",
+ "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae.jsonl",
+ "cwd": "/Users/wolfgangschoenberger/Projects/fgp/cli",
+ "permission_mode": "bypassPermissions",
+ "hook_event_name": "UserPromptSubmit",
+ "prompt": "Okay, I pasted a screenshot of what it actually looks like as well as what I see when I try to start the browser via the TUI."
+ },
+ {
+ "session_id": "0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae",
+ "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae.jsonl",
+ "cwd": "/Users/wolfgangschoenberger/Projects/fgp/cli",
+ "permission_mode": "bypassPermissions",
+ "hook_event_name": "UserPromptSubmit",
+ "prompt": "test the tui fix now"
+ },
+ {
+ "session_id": "0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae",
+ "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae.jsonl",
+ "cwd": "/Users/wolfgangschoenberger/Projects/fgp/cli",
+ "permission_mode": "bypassPermissions",
+ "hook_event_name": "UserPromptSubmit",
+ "prompt": "it works, what should we build next"
+ },
+ {
+ "session_id": "0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae",
+ "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/0c6dbfa3-3098-4071-8a4e-c5dd9f1118ae.jsonl",
+ "cwd": "/Users/wolfgangschoenberger/Projects/fgp/cli",
+ "permission_mode": "plan",
+ "hook_event_name": "UserPromptSubmit",
+ "prompt": "I want to eventually add pretty much all of these (except remote daemons), let's start going through the list - Let's plan out T0 and T1 first. I think we can do that all in one go. Please create a comprehensive plan. Once that's done, we can plan out service metrics and lastly, the plugin registry."
+ },
+ {
+ "session_id": "6be261ac-4a50-4c29-b5e1-a1edb3ce813d",
+ "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/6be261ac-4a50-4c29-b5e1-a1edb3ce813d.jsonl",
+ "cwd": "/Users/wolfgangschoenberger/Projects/fgp/cli",
+ "permission_mode": "bypassPermissions",
+ "hook_event_name": "UserPromptSubmit",
+ "prompt": "commit the codex detection fix"
+ },
+ {
+ "session_id": "6be261ac-4a50-4c29-b5e1-a1edb3ce813d",
+ "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/6be261ac-4a50-4c29-b5e1-a1edb3ce813d.jsonl",
+ "cwd": "/Users/wolfgangschoenberger/Projects/fgp/cli",
+ "permission_mode": "plan",
+ "hook_event_name": "UserPromptSubmit",
+ "prompt": "what about google's antigravity, as well as the gemini CLI? can we configure those too?"
+ },
+ {
+ "session_id": "c696a6ba-089c-47d9-95c7-3ba087b1e3a7",
+ "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/c696a6ba-089c-47d9-95c7-3ba087b1e3a7.jsonl",
+ "cwd": "/Users/wolfgangschoenberger/Projects/fgp/cli",
+ "permission_mode": "bypassPermissions",
+ "hook_event_name": "UserPromptSubmit",
+ "prompt": "commit these changes and push"
+ },
+ {
+ "session_id": "c696a6ba-089c-47d9-95c7-3ba087b1e3a7",
+ "transcript_path": "/Users/wolfgangschoenberger/.claude/projects/-Users-wolfgangschoenberger-Projects-fgp/c696a6ba-089c-47d9-95c7-3ba087b1e3a7.jsonl",
+ "cwd": "/Users/wolfgangschoenberger/Projects/fgp/cli",
+ "permission_mode": "bypassPermissions",
+ "hook_event_name": "UserPromptSubmit",
+ "prompt": "create a PR for these changes and set a reminder for me to check back in in 10 minutes"
}
]
\ No newline at end of file
diff --git a/src/commands/agents.rs b/src/commands/agents.rs
index 046f190..49c2f13 100644
--- a/src/commands/agents.rs
+++ b/src/commands/agents.rs
@@ -7,6 +7,13 @@ use std::path::Path;
/// Known AI agent configurations.
const AGENT_PATHS: &[(&str, &str, &str)] = &[
("Claude Code", "~/.claude/skills", "SKILL.md files"),
+ ("Codex", "~/.codex/skills", "SKILL.md files"),
+ (
+ "Gemini CLI",
+ "~/.gemini/extensions",
+ "Extension directories",
+ ),
+ ("Antigravity", "~/.gemini/antigravity", "MCP config"),
("Cursor", "~/.cursor", ".mdc rules"),
("Windsurf", "~/.windsurf", "Workflow files"),
(
@@ -15,6 +22,13 @@ const AGENT_PATHS: &[(&str, &str, &str)] = &[
"MCP config",
),
("Continue", "~/.continue", "YAML config"),
+ // Additional AI coding tools
+ ("Aider", "~/.aider.conf.yml", "YAML config"),
+ ("Zed AI", "~/.config/zed", "JSON settings"),
+ ("GitHub Copilot", "~/.config/github-copilot", "JSON config"),
+ ("Sourcegraph Cody", "~/.sourcegraph", "JSON config"),
+ ("Amazon Q", "~/.aws/amazonq", "YAML/JSON config"),
+ ("Opencode", "~/.config/opencode", "JSON config"),
];
pub fn run() -> Result<()> {
diff --git a/src/commands/generate.rs b/src/commands/generate.rs
new file mode 100644
index 0000000..67e5014
--- /dev/null
+++ b/src/commands/generate.rs
@@ -0,0 +1,131 @@
+//! Generate command - scaffolds new FGP daemons from templates.
+//!
+//! Uses the Python generator script from the generator/ directory.
+
+use anyhow::{bail, Context, Result};
+use colored::Colorize;
+use std::path::PathBuf;
+use std::process::Command;
+
+/// Get the path to the generator script.
+fn generator_script_path() -> Result {
+ // Try relative to the CLI binary first (installed location)
+ let exe_path = std::env::current_exe().context("Failed to get executable path")?;
+ let exe_dir = exe_path.parent().unwrap();
+
+ // Check various possible locations
+ let candidates = [
+ // Relative to binary (installed)
+ exe_dir.join("../lib/fgp/generator/generate.py"),
+ exe_dir.join("../../generator/generate.py"),
+ // Development location
+ PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../generator/generate.py"),
+ // Absolute fallback
+ PathBuf::from(shellexpand::tilde("~/.fgp/generator/generate.py").as_ref()),
+ ];
+
+ for path in &candidates {
+ if path.exists() {
+ return Ok(path.canonicalize().unwrap_or_else(|_| path.clone()));
+ }
+ }
+
+ bail!(
+ "Generator script not found. Looked in:\n{}",
+ candidates
+ .iter()
+ .map(|p| format!(" - {}", p.display()))
+ .collect::>()
+ .join("\n")
+ )
+}
+
+/// List all available service presets.
+pub fn list() -> Result<()> {
+ let script_path = generator_script_path()?;
+
+ let output = Command::new("python3")
+ .arg(&script_path)
+ .arg("--list-presets")
+ .output()
+ .context("Failed to run generator script")?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ bail!("Generator failed:\n{}", stderr);
+ }
+
+ // Print the output directly
+ print!("{}", String::from_utf8_lossy(&output.stdout));
+
+ Ok(())
+}
+
+/// Generate a new daemon from a service preset.
+pub fn new_daemon(
+ service: &str,
+ preset: bool,
+ display_name: Option<&str>,
+ api_url: Option<&str>,
+ env_token: Option<&str>,
+ output_dir: Option<&str>,
+ author: &str,
+) -> Result<()> {
+ let script_path = generator_script_path()?;
+
+ println!();
+ println!(
+ "{} Generating FGP daemon: {}",
+ "→".blue(),
+ service.bold()
+ );
+
+ // Build command arguments
+ let mut args = vec![script_path.to_string_lossy().to_string(), service.to_string()];
+
+ if preset {
+ args.push("--preset".to_string());
+ }
+
+ if let Some(name) = display_name {
+ args.push("--display-name".to_string());
+ args.push(name.to_string());
+ }
+
+ if let Some(url) = api_url {
+ args.push("--api-url".to_string());
+ args.push(url.to_string());
+ }
+
+ if let Some(token) = env_token {
+ args.push("--env-token".to_string());
+ args.push(token.to_string());
+ }
+
+ if let Some(dir) = output_dir {
+ args.push("--output-dir".to_string());
+ args.push(dir.to_string());
+ }
+
+ args.push("--author".to_string());
+ args.push(author.to_string());
+
+ let output = Command::new("python3")
+ .args(&args[..])
+ .output()
+ .context("Failed to run generator script")?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ if !stdout.is_empty() {
+ print!("{}", stdout);
+ }
+ bail!("Generator failed:\n{}", stderr);
+ }
+
+ // Print the output directly
+ print!("{}", String::from_utf8_lossy(&output.stdout));
+
+ Ok(())
+}
diff --git a/src/commands/mod.rs b/src/commands/mod.rs
index d27ca72..ae4f612 100644
--- a/src/commands/mod.rs
+++ b/src/commands/mod.rs
@@ -3,10 +3,15 @@
pub mod agents;
pub mod call;
pub mod dashboard;
+pub mod generate;
pub mod health;
pub mod install;
pub mod methods;
pub mod new;
+pub mod skill;
+pub mod skill_export;
+pub mod skill_tap;
+pub mod skill_validate;
pub mod start;
pub mod status;
pub mod stop;
diff --git a/src/commands/skill.rs b/src/commands/skill.rs
new file mode 100644
index 0000000..8e82f68
--- /dev/null
+++ b/src/commands/skill.rs
@@ -0,0 +1,2234 @@
+//! FGP skill management - install, update, and manage FGP skills from marketplaces.
+//!
+//! This module provides Claude Code plugin-like functionality for FGP daemons.
+//! Skills are distributed via git-based marketplaces with automatic updates.
+//!
+//! # Directory Structure
+//!
+//! ```text
+//! ~/.fgp/
+//! ├── skills/
+//! │ ├── installed_skills.json # Track installed skills + versions
+//! │ ├── known_marketplaces.json # Track marketplace sources
+//! │ ├── cache/ # Installed skill files
+//! │ │ └── ///
+//! │ └── marketplaces/ # Cloned marketplace repos
+//! │ └── /
+//! └── services/ # Running daemon sockets
+//! ```
+
+use anyhow::{bail, Context, Result};
+use colored::Colorize;
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use super::skill_tap;
+
+/// Skill manifest format (skill.json)
+#[derive(Debug, Serialize, Deserialize)]
+pub struct SkillManifest {
+ pub name: String,
+ pub version: String,
+ pub description: String,
+ pub author: Author,
+ #[serde(default)]
+ pub repository: Option,
+ #[serde(default)]
+ pub homepage: Option,
+ #[serde(default)]
+ pub license: Option,
+ #[serde(default)]
+ pub keywords: Vec,
+ #[serde(default)]
+ pub category: Option,
+ #[serde(default)]
+ pub binary: Option,
+ #[serde(default)]
+ pub distribution: Option,
+ #[serde(default)]
+ pub daemon: Option,
+ #[serde(default)]
+ pub methods: Vec,
+ #[serde(default)]
+ pub mcp_bridge: Option,
+ #[serde(default)]
+ pub requirements: HashMap,
+ /// Multi-ecosystem export configuration
+ #[serde(default)]
+ pub exports: Option,
+}
+
+/// Multi-ecosystem export configuration
+#[derive(Debug, Default, Serialize, Deserialize)]
+pub struct ExportsConfig {
+ #[serde(default)]
+ pub mcp: Option,
+ #[serde(default)]
+ pub claude: Option,
+ #[serde(default)]
+ pub cursor: Option,
+ #[serde(default)]
+ pub continue_dev: Option,
+ #[serde(default)]
+ pub windsurf: Option,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct McpExportConfig {
+ #[serde(default = "default_true")]
+ pub enabled: bool,
+ #[serde(default)]
+ pub tools_prefix: Option,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct ClaudeExportConfig {
+ #[serde(default)]
+ pub enabled: bool,
+ /// Skill name in Claude Code (defaults to -fgp)
+ #[serde(default)]
+ pub skill_name: Option,
+ /// Keywords that trigger this skill
+ #[serde(default)]
+ pub triggers: Vec,
+ /// Tools required by the skill (default: ["Bash"])
+ #[serde(default = "default_bash_tools")]
+ pub tools: Vec,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct CursorExportConfig {
+ #[serde(default)]
+ pub enabled: bool,
+ /// Server name in mcp.json (defaults to fgp-)
+ #[serde(default)]
+ pub server_name: Option,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct ContinueExportConfig {
+ #[serde(default)]
+ pub enabled: bool,
+ /// Provider type (command, custom)
+ #[serde(default = "default_command")]
+ pub provider_type: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct WindsurfExportConfig {
+ #[serde(default)]
+ pub enabled: bool,
+}
+
+fn default_true() -> bool {
+ true
+}
+
+fn default_bash_tools() -> Vec {
+ vec!["Bash".to_string()]
+}
+
+fn default_command() -> String {
+ "command".to_string()
+}
+
+/// Export target types for multi-ecosystem registration
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ExportTarget {
+ Mcp,
+ Claude,
+ Cursor,
+ ContinueDev,
+ Windsurf,
+ All,
+}
+
+impl ExportTarget {
+ pub fn from_str(s: &str) -> Option {
+ match s.to_lowercase().as_str() {
+ "mcp" => Some(Self::Mcp),
+ "claude" | "claude-code" => Some(Self::Claude),
+ "cursor" => Some(Self::Cursor),
+ "continue" | "continue-dev" => Some(Self::ContinueDev),
+ "windsurf" => Some(Self::Windsurf),
+ "all" => Some(Self::All),
+ _ => None,
+ }
+ }
+
+ pub fn all_targets() -> Vec {
+ vec![Self::Mcp, Self::Claude, Self::Cursor, Self::ContinueDev, Self::Windsurf]
+ }
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct Author {
+ pub name: String,
+ #[serde(default)]
+ pub email: Option,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct BinaryConfig {
+ #[serde(rename = "type")]
+ pub binary_type: String,
+ #[serde(default)]
+ pub cargo_package: Option,
+ #[serde(default)]
+ pub build_command: Option,
+ #[serde(default)]
+ pub executable: Option,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct DistributionConfig {
+ #[serde(default)]
+ pub prebuilt: HashMap,
+ #[serde(default)]
+ pub homebrew: Option,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct HomebrewConfig {
+ pub tap: String,
+ pub formula: String,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct DaemonConfig {
+ pub name: String,
+ #[serde(default)]
+ pub socket_path: Option,
+ #[serde(default)]
+ pub pid_file: Option,
+ #[serde(default)]
+ pub log_file: Option,
+ #[serde(default)]
+ pub start_command: Vec,
+ #[serde(default)]
+ pub stop_command: Vec,
+ #[serde(default)]
+ pub health_method: Option,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct MethodDef {
+ pub name: String,
+ #[serde(default)]
+ pub description: Option,
+ #[serde(default)]
+ pub params: HashMap,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct ParamDef {
+ #[serde(rename = "type")]
+ pub param_type: String,
+ #[serde(default)]
+ pub required: bool,
+ #[serde(default)]
+ pub description: Option,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct McpBridgeConfig {
+ #[serde(default)]
+ pub enabled: bool,
+ #[serde(default)]
+ pub command: Option,
+ #[serde(default)]
+ pub args: Vec,
+ #[serde(default)]
+ pub tools_prefix: Option,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct Requirement {
+ #[serde(rename = "type")]
+ pub req_type: String,
+ #[serde(default)]
+ pub names: Vec,
+ #[serde(default)]
+ pub min_version: Option,
+ #[serde(default)]
+ pub install_hint: Option,
+}
+
+/// Marketplace manifest format (marketplace.json)
+#[derive(Debug, Serialize, Deserialize)]
+pub struct MarketplaceManifest {
+ pub name: String,
+ pub description: String,
+ pub owner: Author,
+ #[serde(default)]
+ pub homepage: Option,
+ #[serde(default)]
+ pub repository: Option,
+ pub skills: Vec,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct MarketplaceSkill {
+ pub name: String,
+ pub description: String,
+ pub version: String,
+ pub author: Author,
+ pub source: String,
+ #[serde(default)]
+ pub category: Option,
+ #[serde(default)]
+ pub tags: Vec,
+ #[serde(default)]
+ pub platforms: Vec,
+ #[serde(default)]
+ pub homepage: Option,
+}
+
+/// Installed skills tracking
+#[derive(Debug, Serialize, Deserialize)]
+pub struct InstalledSkills {
+ pub version: u32,
+ pub skills: HashMap>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct InstalledSkill {
+ pub scope: String,
+ #[serde(rename = "installPath")]
+ pub install_path: String,
+ pub version: String,
+ #[serde(rename = "installedAt")]
+ pub installed_at: String,
+ #[serde(rename = "lastUpdated")]
+ pub last_updated: String,
+ #[serde(rename = "gitCommitSha")]
+ pub git_commit_sha: Option,
+ #[serde(rename = "binaryPath")]
+ pub binary_path: Option,
+}
+
+/// Known marketplaces tracking
+#[derive(Debug, Serialize, Deserialize)]
+pub struct KnownMarketplaces {
+ #[serde(flatten)]
+ pub marketplaces: HashMap,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct MarketplaceEntry {
+ pub source: MarketplaceSource,
+ #[serde(rename = "installLocation")]
+ pub install_location: Option,
+ #[serde(rename = "lastUpdated")]
+ pub last_updated: Option,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+pub struct MarketplaceSource {
+ #[serde(rename = "type")]
+ pub source_type: String,
+ pub repo: String,
+}
+
+/// Get the FGP home directory
+fn fgp_home() -> PathBuf {
+ dirs::home_dir()
+ .expect("Could not find home directory")
+ .join(".fgp")
+}
+
+/// Get the skills directory
+fn skills_dir() -> PathBuf {
+ fgp_home().join("skills")
+}
+
+/// Get the installed skills file path
+fn installed_skills_path() -> PathBuf {
+ skills_dir().join("installed_skills.json")
+}
+
+/// Get the known marketplaces file path
+fn known_marketplaces_path() -> PathBuf {
+ skills_dir().join("known_marketplaces.json")
+}
+
+/// Get the marketplaces directory
+fn marketplaces_dir() -> PathBuf {
+ skills_dir().join("marketplaces")
+}
+
+/// Get the cache directory
+fn cache_dir() -> PathBuf {
+ skills_dir().join("cache")
+}
+
+/// Load installed skills
+fn load_installed_skills() -> Result {
+ let path = installed_skills_path();
+ if !path.exists() {
+ return Ok(InstalledSkills {
+ version: 1,
+ skills: HashMap::new(),
+ });
+ }
+ let content = fs::read_to_string(&path)?;
+ Ok(serde_json::from_str(&content)?)
+}
+
+/// Save installed skills
+fn save_installed_skills(skills: &InstalledSkills) -> Result<()> {
+ let path = installed_skills_path();
+ fs::create_dir_all(path.parent().unwrap())?;
+ let content = serde_json::to_string_pretty(skills)?;
+ fs::write(&path, content)?;
+ Ok(())
+}
+
+/// Load known marketplaces
+fn load_known_marketplaces() -> Result {
+ let path = known_marketplaces_path();
+ if !path.exists() {
+ return Ok(KnownMarketplaces {
+ marketplaces: HashMap::new(),
+ });
+ }
+ let content = fs::read_to_string(&path)?;
+ Ok(serde_json::from_str(&content)?)
+}
+
+/// Save known marketplaces
+fn save_known_marketplaces(marketplaces: &KnownMarketplaces) -> Result<()> {
+ let path = known_marketplaces_path();
+ fs::create_dir_all(path.parent().unwrap())?;
+ let content = serde_json::to_string_pretty(marketplaces)?;
+ fs::write(&path, content)?;
+ Ok(())
+}
+
+/// List all installed skills
+pub fn list() -> Result<()> {
+ let installed = load_installed_skills()?;
+
+ if installed.skills.is_empty() {
+ println!("{}", "No skills installed.".yellow());
+ println!();
+ println!("Install a skill with:");
+ println!(" fgp skill install browser-gateway");
+ println!();
+ println!("Or add a marketplace first:");
+ println!(" fgp skill marketplace add https://github.com/fast-gateway-protocol/fgp");
+ return Ok(());
+ }
+
+ println!("{}", "Installed FGP Skills".bold());
+ println!();
+
+ for (skill_key, entries) in &installed.skills {
+ for entry in entries {
+ let status = if check_daemon_running(&skill_key.split('@').next().unwrap_or(skill_key))
+ {
+ "● running".green()
+ } else {
+ "○ stopped".dimmed()
+ };
+
+ println!(
+ " {} {} {} {}",
+ skill_key.cyan(),
+ format!("v{}", entry.version).dimmed(),
+ status,
+ format!("({})", entry.scope).dimmed()
+ );
+ }
+ }
+
+ Ok(())
+}
+
+/// Check if a daemon is running
+fn check_daemon_running(service: &str) -> bool {
+ let socket_path = fgp_home()
+ .join("services")
+ .join(service)
+ .join("daemon.sock");
+ socket_path.exists()
+}
+
+/// Search for skills in taps and marketplaces
+pub fn search(query: &str) -> Result<()> {
+ println!(
+ "{} {}",
+ "Searching for:".bold(),
+ query.cyan()
+ );
+ println!();
+
+ let mut found = false;
+
+ // First search taps (new skill.yaml format)
+ match skill_tap::search_taps(query) {
+ Ok(results) => {
+ if !results.is_empty() {
+ println!("{}", "From taps:".bold().underline());
+ for (tap_name, _path, manifest) in &results {
+ found = true;
+ println!(
+ " {} {} (from {})",
+ manifest.name.cyan().bold(),
+ format!("v{}", manifest.version).dimmed(),
+ tap_name.dimmed()
+ );
+ println!(" {}", manifest.description);
+ if !manifest.keywords.is_empty() {
+ println!(" Keywords: {}", manifest.keywords.join(", ").dimmed());
+ }
+ if !manifest.daemons.is_empty() {
+ let daemon_names: Vec<_> = manifest.daemons.iter().map(|d| d.name.as_str()).collect();
+ println!(" Daemons: {}", daemon_names.join(", ").dimmed());
+ }
+ println!();
+ }
+ }
+ }
+ Err(_) => {} // Ignore tap search errors, continue with marketplaces
+ }
+
+ // Also search legacy marketplaces
+ let marketplaces = load_known_marketplaces()?;
+ if !marketplaces.marketplaces.is_empty() {
+ let mut marketplace_found = false;
+ for (name, entry) in &marketplaces.marketplaces {
+ if let Some(ref location) = entry.install_location {
+ let manifest_path = Path::new(location).join(".fgp").join("marketplace.json");
+ if manifest_path.exists() {
+ let content = fs::read_to_string(&manifest_path)?;
+ let manifest: MarketplaceManifest = serde_json::from_str(&content)?;
+
+ for skill in &manifest.skills {
+ let query_lower = query.to_lowercase();
+ if skill.name.to_lowercase().contains(&query_lower)
+ || skill.description.to_lowercase().contains(&query_lower)
+ || skill.tags.iter().any(|t| t.to_lowercase().contains(&query_lower))
+ {
+ if !marketplace_found {
+ println!("{}", "From marketplaces (legacy):".bold().underline());
+ marketplace_found = true;
+ }
+ found = true;
+ println!(
+ " {} {} (from {})",
+ skill.name.cyan().bold(),
+ format!("v{}", skill.version).dimmed(),
+ name.dimmed()
+ );
+ println!(" {}", skill.description);
+ if !skill.tags.is_empty() {
+ println!(" Tags: {}", skill.tags.join(", ").dimmed());
+ }
+ println!();
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if !found {
+ println!("{}", "No skills found matching your query.".yellow());
+ println!();
+ println!("Add a tap to search more skills:");
+ println!(
+ " {}",
+ "fgp skill tap add fast-gateway-protocol/official-skills".cyan()
+ );
+ }
+
+ Ok(())
+}
+
+/// Install a skill
+pub fn install(name: &str, from_marketplace: Option<&str>) -> Result<()> {
+ println!(
+ "{} {}...",
+ "Installing skill:".bold(),
+ name.cyan()
+ );
+
+ // First, try to find the skill in taps (new skill.yaml format)
+ if from_marketplace.is_none() {
+ if let Ok(Some((tap_name, skill_path, manifest))) = skill_tap::find_skill(name) {
+ return install_from_tap(&tap_name, &skill_path, &manifest);
+ }
+ }
+
+ // Fall back to legacy marketplaces
+ let marketplaces = load_known_marketplaces()?;
+ let mut skill_info: Option<(String, MarketplaceSkill, PathBuf)> = None;
+
+ for (mp_name, entry) in &marketplaces.marketplaces {
+ // Skip if specific marketplace requested and this isn't it
+ if let Some(req_mp) = from_marketplace {
+ if mp_name != req_mp {
+ continue;
+ }
+ }
+
+ if let Some(ref location) = entry.install_location {
+ let manifest_path = Path::new(location).join(".fgp").join("marketplace.json");
+ if manifest_path.exists() {
+ let content = fs::read_to_string(&manifest_path)?;
+ let manifest: MarketplaceManifest = serde_json::from_str(&content)?;
+
+ for skill in manifest.skills {
+ if skill.name == name {
+ let source_path = Path::new(location).join(&skill.source);
+ skill_info = Some((mp_name.clone(), skill, source_path));
+ break;
+ }
+ }
+ }
+ }
+
+ if skill_info.is_some() {
+ break;
+ }
+ }
+
+ let (marketplace_name, skill, source_path) = match skill_info {
+ Some(info) => info,
+ None => {
+ bail!(
+ "Skill '{}' not found. Add a tap first:\n fgp skill tap add fast-gateway-protocol/official-skills",
+ name
+ );
+ }
+ };
+
+ println!(" Found in marketplace: {}", marketplace_name.green());
+ println!(" Version: {}", skill.version);
+ println!(" Source: {}", source_path.display());
+
+ // Check for skill.json in the source
+ let skill_manifest_path = source_path.join(".fgp").join("skill.json");
+ if !skill_manifest_path.exists() {
+ bail!(
+ "Skill manifest not found at {}",
+ skill_manifest_path.display()
+ );
+ }
+
+ let skill_content = fs::read_to_string(&skill_manifest_path)?;
+ let skill_manifest: SkillManifest = serde_json::from_str(&skill_content)?;
+
+ // Create cache directory for this skill
+ let cache_path = cache_dir()
+ .join(&marketplace_name)
+ .join(&skill.name)
+ .join(&skill.version);
+ fs::create_dir_all(&cache_path)?;
+
+ // Copy skill files to cache (or symlink for development)
+ println!(" Copying to cache...");
+
+ // For now, just symlink for faster development iteration
+ let source_link = cache_path.join("source");
+ if source_link.exists() {
+ fs::remove_file(&source_link)?;
+ }
+ std::os::unix::fs::symlink(&source_path, &source_link)?;
+
+ // Build the binary if needed
+ let binary_path = if let Some(ref binary) = skill_manifest.binary {
+ if binary.binary_type == "rust" {
+ println!(" Building Rust binary...");
+
+ let build_cmd = binary
+ .build_command
+ .as_deref()
+ .unwrap_or("cargo build --release");
+
+ let status = Command::new("sh")
+ .arg("-c")
+ .arg(build_cmd)
+ .current_dir(&source_path)
+ .status()
+ .context("Failed to run build command")?;
+
+ if !status.success() {
+ bail!("Build failed with exit code: {:?}", status.code());
+ }
+
+ if let Some(ref exe) = binary.executable {
+ let exe_path = source_path.join(exe);
+ if exe_path.exists() {
+ // Copy binary to cache
+ let dest_bin = cache_path.join(skill.name.clone());
+ fs::copy(&exe_path, &dest_bin)?;
+ println!(" Binary: {}", dest_bin.display());
+ Some(dest_bin.to_string_lossy().to_string())
+ } else {
+ println!(
+ " {}",
+ format!("Warning: executable not found at {}", exe_path.display()).yellow()
+ );
+ None
+ }
+ } else {
+ None
+ }
+ } else {
+ None
+ }
+ } else {
+ None
+ };
+
+ // Get git commit SHA if available
+ let git_sha = Command::new("git")
+ .args(["rev-parse", "HEAD"])
+ .current_dir(&source_path)
+ .output()
+ .ok()
+ .and_then(|o| {
+ if o.status.success() {
+ Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
+ } else {
+ None
+ }
+ });
+
+ // Update installed_skills.json
+ let mut installed = load_installed_skills()?;
+ let skill_key = format!("{}@{}", skill.name, marketplace_name);
+ let now = chrono::Utc::now().to_rfc3339();
+
+ let entry = InstalledSkill {
+ scope: "user".to_string(),
+ install_path: cache_path.to_string_lossy().to_string(),
+ version: skill.version.clone(),
+ installed_at: now.clone(),
+ last_updated: now,
+ git_commit_sha: git_sha,
+ binary_path,
+ };
+
+ installed.skills.insert(skill_key.clone(), vec![entry.clone()]);
+ save_installed_skills(&installed)?;
+
+ // Auto-register with ecosystems based on exports config
+ println!(" Registering with ecosystems...");
+ let daemon_name = skill_manifest
+ .daemon
+ .as_ref()
+ .map(|d| d.name.clone())
+ .unwrap_or_else(|| skill.name.replace("-gateway", ""));
+
+ // Always register with MCP (core FGP functionality)
+ if let Some(ref bin_path) = entry.binary_path {
+ let manifest = skill_to_daemon_manifest(&skill_manifest, bin_path);
+ let services_dir = fgp_home().join("services").join(&daemon_name);
+ fs::create_dir_all(&services_dir)?;
+ let manifest_path = services_dir.join("manifest.json");
+ let manifest_json = serde_json::to_string_pretty(&manifest)?;
+ fs::write(&manifest_path, &manifest_json)?;
+ println!(" {} MCP: {}", "✓".green(), manifest_path.display());
+ }
+
+ // Auto-register with other ecosystems based on exports config
+ if let Some(ref exports) = skill_manifest.exports {
+ // Claude Code
+ if exports.claude.as_ref().map(|c| c.enabled).unwrap_or(false) {
+ match export_to_claude(&skill_manifest) {
+ Ok(()) => {}
+ Err(e) => println!(" {} Claude: {}", "✗".red(), e),
+ }
+ }
+
+ // Cursor
+ if exports.cursor.as_ref().map(|c| c.enabled).unwrap_or(false) {
+ match export_to_cursor(&skill_manifest) {
+ Ok(()) => {}
+ Err(e) => println!(" {} Cursor: {}", "✗".red(), e),
+ }
+ }
+
+ // Windsurf
+ if exports.windsurf.as_ref().map(|w| w.enabled).unwrap_or(false) {
+ match export_to_windsurf(&skill_manifest) {
+ Ok(()) => {}
+ Err(e) => println!(" {} Windsurf: {}", "✗".red(), e),
+ }
+ }
+ }
+
+ println!();
+ println!(
+ "{} {} installed successfully!",
+ "✓".green().bold(),
+ skill.name.cyan()
+ );
+ println!();
+ println!("Start the daemon with:");
+ println!(
+ " {}",
+ format!("fgp start {}", daemon_name)
+ );
+ println!();
+ println!("To register with additional ecosystems:");
+ println!(
+ " {}",
+ format!("fgp skill mcp register {} --target=claude,cursor", skill.name)
+ );
+
+ Ok(())
+}
+
+/// Install a skill from a tap (skill.yaml format)
+fn install_from_tap(
+ tap_name: &str,
+ skill_path: &Path,
+ manifest: &super::skill_validate::SkillManifest,
+) -> Result<()> {
+ println!(" Found in tap: {}", tap_name.green());
+ println!(" Version: {}", manifest.version);
+ println!(" Path: {}", skill_path.display());
+
+ // Check daemon dependencies
+ if !manifest.daemons.is_empty() {
+ println!();
+ println!(" {}:", "Required daemons".bold());
+ for daemon in &manifest.daemons {
+ let optional = if daemon.optional { " (optional)" } else { "" };
+ println!(" - {}{}", daemon.name.cyan(), optional.dimmed());
+ }
+ }
+
+ // Create skills directory
+ let skills_install_dir = skills_dir().join("installed").join(&manifest.name);
+ fs::create_dir_all(&skills_install_dir)?;
+
+ // Symlink skill to installed directory
+ let source_link = skills_install_dir.join("source");
+ if source_link.exists() || source_link.read_link().is_ok() {
+ let _ = fs::remove_file(&source_link);
+ }
+ std::os::unix::fs::symlink(skill_path, &source_link)?;
+
+ // Get git commit SHA if available
+ let git_sha = Command::new("git")
+ .args(["rev-parse", "HEAD"])
+ .current_dir(skill_path)
+ .output()
+ .ok()
+ .and_then(|o| {
+ if o.status.success() {
+ Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
+ } else {
+ None
+ }
+ });
+
+ // Update installed_skills.json
+ let mut installed = load_installed_skills()?;
+ let skill_key = format!("{}@{}", manifest.name, tap_name);
+ let now = chrono::Utc::now().to_rfc3339();
+
+ let entry = InstalledSkill {
+ scope: "tap".to_string(),
+ install_path: skills_install_dir.to_string_lossy().to_string(),
+ version: manifest.version.clone(),
+ installed_at: now.clone(),
+ last_updated: now,
+ git_commit_sha: git_sha,
+ binary_path: None, // skill.yaml packages typically don't have binaries
+ };
+
+ installed.skills.insert(skill_key.clone(), vec![entry]);
+ save_installed_skills(&installed)?;
+
+ // Export to agents if instructions are available
+ println!();
+ println!(" {}:", "Exporting to agents".bold());
+
+ if let Some(ref instructions) = manifest.instructions {
+ // Claude Code
+ if instructions.claude_code.is_some() || instructions.core.is_some() {
+ export_tap_skill_to_claude(skill_path, manifest)?;
+ }
+
+ // Cursor
+ if instructions.cursor.is_some() {
+ export_tap_skill_to_cursor(skill_path, manifest)?;
+ }
+
+ // Codex
+ if instructions.codex.is_some() {
+ println!(" {} Codex: available (use 'fgp skill export codex {}')", "○".dimmed(), manifest.name);
+ }
+
+ // MCP
+ if instructions.mcp.is_some() {
+ println!(" {} MCP: available (use 'fgp skill export mcp {}')", "○".dimmed(), manifest.name);
+ }
+ }
+
+ println!();
+ println!(
+ "{} {} installed successfully!",
+ "✓".green().bold(),
+ manifest.name.cyan()
+ );
+ println!();
+ println!("Use the skill by invoking its triggers:");
+ if let Some(ref triggers) = manifest.triggers {
+ if !triggers.keywords.is_empty() {
+ println!(" Keywords: {}", triggers.keywords.join(", ").cyan());
+ }
+ }
+
+ Ok(())
+}
+
+/// Export a tap skill to Claude Code
+fn export_tap_skill_to_claude(
+ skill_path: &Path,
+ manifest: &super::skill_validate::SkillManifest,
+) -> Result<()> {
+ let claude_skills_dir = dirs::home_dir()
+ .context("Could not find home directory")?
+ .join(".claude")
+ .join("skills")
+ .join(format!("{}-fgp", manifest.name));
+
+ fs::create_dir_all(&claude_skills_dir)?;
+ let skill_md_path = claude_skills_dir.join("SKILL.md");
+
+ // Try to read instruction file or generate from manifest
+ let content = if let Some(ref instructions) = manifest.instructions {
+ if let Some(ref claude_file) = instructions.claude_code {
+ let src_path = skill_path.join(claude_file);
+ if src_path.exists() {
+ fs::read_to_string(&src_path)?
+ } else if let Some(ref core_file) = instructions.core {
+ let core_path = skill_path.join(core_file);
+ if core_path.exists() {
+ fs::read_to_string(&core_path)?
+ } else {
+ generate_skill_md_from_manifest(manifest)
+ }
+ } else {
+ generate_skill_md_from_manifest(manifest)
+ }
+ } else if let Some(ref core_file) = instructions.core {
+ let core_path = skill_path.join(core_file);
+ if core_path.exists() {
+ fs::read_to_string(&core_path)?
+ } else {
+ generate_skill_md_from_manifest(manifest)
+ }
+ } else {
+ generate_skill_md_from_manifest(manifest)
+ }
+ } else {
+ generate_skill_md_from_manifest(manifest)
+ };
+
+ fs::write(&skill_md_path, &content)?;
+ println!(" {} Claude: {}", "✓".green(), skill_md_path.display());
+
+ Ok(())
+}
+
+/// Export a tap skill to Cursor
+fn export_tap_skill_to_cursor(
+ skill_path: &Path,
+ manifest: &super::skill_validate::SkillManifest,
+) -> Result<()> {
+ if let Some(ref instructions) = manifest.instructions {
+ if let Some(ref cursor_file) = instructions.cursor {
+ let src_path = skill_path.join(cursor_file);
+ if src_path.exists() {
+ // Read and copy to .cursorrules in current project
+ // or to a global location
+ println!(" {} Cursor: {} (copy to project)", "✓".green(), cursor_file);
+ } else {
+ println!(" {} Cursor: file not found ({})", "⚠".yellow(), cursor_file);
+ }
+ }
+ }
+ Ok(())
+}
+
+/// Generate SKILL.md content from manifest
+fn generate_skill_md_from_manifest(manifest: &super::skill_validate::SkillManifest) -> String {
+ let mut md = String::new();
+
+ // Frontmatter
+ md.push_str("---\n");
+ md.push_str(&format!("name: {}-fgp\n", manifest.name));
+ md.push_str(&format!("description: {}\n", manifest.description));
+ md.push_str("tools: [\"Bash\"]\n");
+ if let Some(ref triggers) = manifest.triggers {
+ if !triggers.keywords.is_empty() {
+ md.push_str("triggers:\n");
+ for kw in &triggers.keywords {
+ md.push_str(&format!(" - \"{}\"\n", kw));
+ }
+ }
+ }
+ md.push_str("---\n\n");
+
+ // Title
+ md.push_str(&format!("# {} - FGP Skill\n\n", manifest.name));
+ md.push_str(&format!("{}\n\n", manifest.description));
+
+ // Daemons
+ if !manifest.daemons.is_empty() {
+ md.push_str("## Required Daemons\n\n");
+ for daemon in &manifest.daemons {
+ let optional = if daemon.optional { " (optional)" } else { "" };
+ md.push_str(&format!("- `{}`{}\n", daemon.name, optional));
+ }
+ md.push_str("\n");
+ }
+
+ // Triggers
+ if let Some(ref triggers) = manifest.triggers {
+ md.push_str("## Trigger Detection\n\n");
+ md.push_str("When user mentions:\n");
+ for kw in &triggers.keywords {
+ md.push_str(&format!("- \"{}\"\n", kw));
+ }
+ md.push_str("\n");
+ }
+
+ // Workflows
+ if !manifest.workflows.is_empty() {
+ md.push_str("## Workflows\n\n");
+ for (name, workflow) in &manifest.workflows {
+ md.push_str(&format!("### {}\n", name));
+ if let Some(ref desc) = workflow.description {
+ md.push_str(&format!("{}\n", desc));
+ }
+ md.push_str(&format!("```bash\nfgp workflow run {} --file {}\n```\n\n", name, workflow.file));
+ }
+ }
+
+ md
+}
+
+/// Update marketplaces (git pull)
+pub fn marketplace_update() -> Result<()> {
+ let mut marketplaces = load_known_marketplaces()?;
+
+ if marketplaces.marketplaces.is_empty() {
+ println!("{}", "No marketplaces configured.".yellow());
+ println!();
+ println!("Add a marketplace first:");
+ println!(" fgp skill marketplace add https://github.com/fast-gateway-protocol/fgp");
+ return Ok(());
+ }
+
+ println!("{}", "Updating marketplaces...".bold());
+ println!();
+
+ for (name, entry) in marketplaces.marketplaces.iter_mut() {
+ print!(" {} ", name.cyan());
+
+ if let Some(ref location) = entry.install_location {
+ // Git pull
+ let output = Command::new("git")
+ .args(["pull", "--quiet"])
+ .current_dir(location)
+ .output()?;
+
+ if output.status.success() {
+ // Get new commit SHA
+ let sha = Command::new("git")
+ .args(["rev-parse", "--short", "HEAD"])
+ .current_dir(location)
+ .output()
+ .ok()
+ .and_then(|o| {
+ if o.status.success() {
+ Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
+ } else {
+ None
+ }
+ })
+ .unwrap_or_default();
+
+ entry.last_updated = Some(chrono::Utc::now().to_rfc3339());
+ println!("{} ({})", "✓ updated".green(), sha.dimmed());
+ } else {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ println!("{} {}", "✗ failed:".red(), stderr.trim());
+ }
+ } else {
+ println!("{}", "not cloned yet".yellow());
+ }
+ }
+
+ save_known_marketplaces(&marketplaces)?;
+
+ Ok(())
+}
+
+/// Add a marketplace
+pub fn marketplace_add(url: &str) -> Result<()> {
+ println!(
+ "{} {}",
+ "Adding marketplace:".bold(),
+ url.cyan()
+ );
+
+ // Parse URL to get repo name
+ let repo_name = url
+ .trim_end_matches('/')
+ .split('/')
+ .last()
+ .unwrap_or("marketplace")
+ .trim_end_matches(".git");
+
+ // Check if already exists
+ let mut marketplaces = load_known_marketplaces()?;
+ if marketplaces.marketplaces.contains_key(repo_name) {
+ println!(
+ "{}",
+ format!("Marketplace '{}' already exists.", repo_name).yellow()
+ );
+ return Ok(());
+ }
+
+ // Clone the repository
+ let install_location = marketplaces_dir().join(repo_name);
+ fs::create_dir_all(&install_location.parent().unwrap())?;
+
+ println!(" Cloning repository...");
+ let status = Command::new("git")
+ .args(["clone", "--depth", "1", url])
+ .arg(&install_location)
+ .status()?;
+
+ if !status.success() {
+ bail!("Failed to clone repository");
+ }
+
+ // Extract owner/repo from URL
+ let repo = url
+ .trim_end_matches('/')
+ .trim_end_matches(".git")
+ .split("github.com/")
+ .last()
+ .unwrap_or(url)
+ .to_string();
+
+ // Add to known marketplaces
+ marketplaces.marketplaces.insert(
+ repo_name.to_string(),
+ MarketplaceEntry {
+ source: MarketplaceSource {
+ source_type: "github".to_string(),
+ repo,
+ },
+ install_location: Some(install_location.to_string_lossy().to_string()),
+ last_updated: Some(chrono::Utc::now().to_rfc3339()),
+ },
+ );
+
+ save_known_marketplaces(&marketplaces)?;
+
+ println!();
+ println!(
+ "{} Marketplace '{}' added successfully!",
+ "✓".green().bold(),
+ repo_name.cyan()
+ );
+
+ // Show available skills
+ let manifest_path = install_location.join(".fgp").join("marketplace.json");
+ if manifest_path.exists() {
+ let content = fs::read_to_string(&manifest_path)?;
+ let manifest: MarketplaceManifest = serde_json::from_str(&content)?;
+
+ println!();
+ println!("Available skills:");
+ for skill in &manifest.skills {
+ println!(
+ " {} - {}",
+ skill.name.cyan(),
+ skill.description.dimmed()
+ );
+ }
+ }
+
+ Ok(())
+}
+
+/// List marketplaces
+pub fn marketplace_list() -> Result<()> {
+ let marketplaces = load_known_marketplaces()?;
+
+ if marketplaces.marketplaces.is_empty() {
+ println!("{}", "No marketplaces configured.".yellow());
+ println!();
+ println!("Add a marketplace:");
+ println!(" fgp skill marketplace add https://github.com/fast-gateway-protocol/fgp");
+ return Ok(());
+ }
+
+ println!("{}", "FGP Skill Marketplaces".bold());
+ println!();
+
+ for (name, entry) in &marketplaces.marketplaces {
+ let status = if entry.install_location.is_some() {
+ "● cloned".green()
+ } else {
+ "○ not cloned".dimmed()
+ };
+
+ println!(
+ " {} {}",
+ name.cyan().bold(),
+ status
+ );
+ println!(" Source: {}", entry.source.repo.dimmed());
+ if let Some(ref updated) = entry.last_updated {
+ println!(" Last updated: {}", updated.dimmed());
+ }
+ println!();
+ }
+
+ Ok(())
+}
+
+/// Check for skill updates
+pub fn check_updates() -> Result<()> {
+ println!("{}", "Checking for skill updates...".bold());
+ println!();
+
+ let installed = load_installed_skills()?;
+ let marketplaces = load_known_marketplaces()?;
+
+ let mut updates_available = false;
+
+ for (skill_key, entries) in &installed.skills {
+ let parts: Vec<&str> = skill_key.split('@').collect();
+ if parts.len() != 2 {
+ continue;
+ }
+ let skill_name = parts[0];
+ let marketplace_name = parts[1];
+
+ // Find marketplace
+ if let Some(mp_entry) = marketplaces.marketplaces.get(marketplace_name) {
+ if let Some(ref location) = mp_entry.install_location {
+ let manifest_path = Path::new(location).join(".fgp").join("marketplace.json");
+ if manifest_path.exists() {
+ let content = fs::read_to_string(&manifest_path)?;
+ let manifest: MarketplaceManifest = serde_json::from_str(&content)?;
+
+ for skill in &manifest.skills {
+ if skill.name == skill_name {
+ if let Some(entry) = entries.first() {
+ // Compare git SHA if available
+ let current_sha = entry.git_commit_sha.as_deref().unwrap_or("");
+
+ // Get latest SHA
+ let source_path = Path::new(location).join(&skill.source);
+ let latest_sha = Command::new("git")
+ .args(["rev-parse", "HEAD"])
+ .current_dir(&source_path)
+ .output()
+ .ok()
+ .and_then(|o| {
+ if o.status.success() {
+ Some(
+ String::from_utf8_lossy(&o.stdout)
+ .trim()
+ .to_string(),
+ )
+ } else {
+ None
+ }
+ })
+ .unwrap_or_default();
+
+ if current_sha != latest_sha && !latest_sha.is_empty() {
+ updates_available = true;
+ println!(
+ " {} {} → {}",
+ skill_name.cyan(),
+ format!("({})", ¤t_sha[..7.min(current_sha.len())])
+ .dimmed(),
+ format!("({})", &latest_sha[..7.min(latest_sha.len())])
+ .green()
+ );
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if !updates_available {
+ println!("{}", "All skills are up to date.".green());
+ } else {
+ println!();
+ println!("Run 'fgp skill upgrade' to update all skills.");
+ }
+
+ Ok(())
+}
+
+/// Upgrade all skills
+pub fn upgrade(skill_name: Option<&str>) -> Result<()> {
+ let installed = load_installed_skills()?;
+
+ if installed.skills.is_empty() {
+ println!("{}", "No skills installed.".yellow());
+ return Ok(());
+ }
+
+ let skills_to_upgrade: Vec<_> = if let Some(name) = skill_name {
+ installed
+ .skills
+ .keys()
+ .filter(|k| k.starts_with(&format!("{}@", name)))
+ .cloned()
+ .collect()
+ } else {
+ installed.skills.keys().cloned().collect()
+ };
+
+ if skills_to_upgrade.is_empty() {
+ println!(
+ "{}",
+ format!("Skill '{}' not found.", skill_name.unwrap_or("")).yellow()
+ );
+ return Ok(());
+ }
+
+ println!("{}", "Upgrading skills...".bold());
+ println!();
+
+ for skill_key in skills_to_upgrade {
+ let parts: Vec<&str> = skill_key.split('@').collect();
+ if parts.len() != 2 {
+ continue;
+ }
+ let skill_name = parts[0];
+ let marketplace_name = parts[1];
+
+ print!(" {} ", skill_name.cyan());
+
+ // Re-install the skill
+ match install(skill_name, Some(marketplace_name)) {
+ Ok(()) => println!("{}", "✓ upgraded".green()),
+ Err(e) => println!("{} {}", "✗ failed:".red(), e),
+ }
+ }
+
+ Ok(())
+}
+
+/// Remove a skill
+pub fn remove(name: &str) -> Result<()> {
+ let mut installed = load_installed_skills()?;
+
+ // Find the skill key
+ let skill_key = installed
+ .skills
+ .keys()
+ .find(|k| k.starts_with(&format!("{}@", name)))
+ .cloned();
+
+ match skill_key {
+ Some(key) => {
+ if let Some(entries) = installed.skills.remove(&key) {
+ // Remove cache directory
+ if let Some(entry) = entries.first() {
+ let cache_path = Path::new(&entry.install_path);
+ if cache_path.exists() {
+ fs::remove_dir_all(cache_path)?;
+ }
+ }
+ }
+
+ save_installed_skills(&installed)?;
+
+ println!(
+ "{} Skill '{}' removed successfully.",
+ "✓".green().bold(),
+ name.cyan()
+ );
+ }
+ None => {
+ println!(
+ "{}",
+ format!("Skill '{}' not found.", name).yellow()
+ );
+ }
+ }
+
+ Ok(())
+}
+
+/// Show skill info
+pub fn info(name: &str) -> Result<()> {
+ let installed = load_installed_skills()?;
+ let marketplaces = load_known_marketplaces()?;
+
+ // First check installed skills
+ for (skill_key, entries) in &installed.skills {
+ if skill_key.starts_with(&format!("{}@", name)) {
+ if let Some(entry) = entries.first() {
+ let parts: Vec<&str> = skill_key.split('@').collect();
+ let marketplace_name = parts.get(1).unwrap_or(&"unknown");
+
+ println!("{}", name.cyan().bold());
+ println!();
+ println!(" Installed: {}", "yes".green());
+ println!(" Version: {}", entry.version);
+ println!(" Scope: {}", entry.scope);
+ println!(" From: {}", marketplace_name);
+ println!(" Path: {}", entry.install_path.dimmed());
+ if let Some(ref sha) = entry.git_commit_sha {
+ println!(" Git SHA: {}", sha.dimmed());
+ }
+ if let Some(ref bin) = entry.binary_path {
+ println!(" Binary: {}", bin.dimmed());
+ }
+ println!(" Installed: {}", entry.installed_at.dimmed());
+ println!(" Updated: {}", entry.last_updated.dimmed());
+
+ // Try to load skill manifest for more info
+ let manifest_path = Path::new(&entry.install_path)
+ .join("source")
+ .join(".fgp")
+ .join("skill.json");
+ if manifest_path.exists() {
+ let content = fs::read_to_string(&manifest_path)?;
+ let manifest: SkillManifest = serde_json::from_str(&content)?;
+ println!();
+ println!(" Description:");
+ println!(" {}", manifest.description.dimmed());
+ println!();
+ println!(" Methods: {}", manifest.methods.len());
+ for method in &manifest.methods {
+ println!(" - {}", method.name);
+ }
+ }
+
+ return Ok(());
+ }
+ }
+ }
+
+ // Check marketplaces for uninstalled skills
+ for (mp_name, entry) in &marketplaces.marketplaces {
+ if let Some(ref location) = entry.install_location {
+ let manifest_path = Path::new(location).join(".fgp").join("marketplace.json");
+ if manifest_path.exists() {
+ let content = fs::read_to_string(&manifest_path)?;
+ let manifest: MarketplaceManifest = serde_json::from_str(&content)?;
+
+ for skill in &manifest.skills {
+ if skill.name == name {
+ println!("{}", name.cyan().bold());
+ println!();
+ println!(" Installed: {}", "no".yellow());
+ println!(" Version: {}", skill.version);
+ println!(" From: {}", mp_name);
+ println!();
+ println!(" Description:");
+ println!(" {}", skill.description.dimmed());
+ if !skill.tags.is_empty() {
+ println!();
+ println!(" Tags: {}", skill.tags.join(", ").dimmed());
+ }
+ println!();
+ println!("Install with:");
+ println!(" fgp skill install {}", name);
+ return Ok(());
+ }
+ }
+ }
+ }
+ }
+
+ println!(
+ "{}",
+ format!("Skill '{}' not found.", name).yellow()
+ );
+
+ Ok(())
+}
+
+// ============================================================================
+// MCP Bridge Registration
+// ============================================================================
+
+/// FGP daemon manifest format (for MCP server compatibility)
+#[derive(Debug, Serialize, Deserialize)]
+struct DaemonManifest {
+ name: String,
+ #[serde(default)]
+ version: String,
+ #[serde(default)]
+ description: String,
+ #[serde(default = "default_protocol")]
+ protocol: String,
+ #[serde(default)]
+ author: String,
+ #[serde(default)]
+ license: Option,
+ #[serde(default)]
+ repository: Option,
+ daemon: DaemonManifestConfig,
+ #[serde(default)]
+ methods: Vec,
+ #[serde(skip_serializing_if = "Option::is_none", default)]
+ auth: Option,
+ #[serde(skip_serializing_if = "Vec::is_empty", default)]
+ platforms: Vec,
+}
+
+fn default_protocol() -> String {
+ "fgp@1".to_string()
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+struct DaemonManifestConfig {
+ entrypoint: String,
+ socket: String,
+ #[serde(default)]
+ dependencies: Vec,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+struct DaemonManifestMethod {
+ name: String,
+ description: String,
+ #[serde(default)]
+ params: Vec,
+}
+
+#[derive(Debug, Serialize, Deserialize)]
+struct DaemonManifestParam {
+ name: String,
+ #[serde(rename = "type")]
+ param_type: String,
+ required: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ default: Option,
+}
+
+/// Convert skill.json to manifest.json format for MCP server
+fn skill_to_daemon_manifest(skill: &SkillManifest, binary_path: &str) -> DaemonManifest {
+ let daemon_name = skill
+ .daemon
+ .as_ref()
+ .map(|d| d.name.clone())
+ .unwrap_or_else(|| skill.name.replace("-gateway", ""));
+
+ let methods: Vec = skill
+ .methods
+ .iter()
+ .map(|m| {
+ let params: Vec = m
+ .params
+ .iter()
+ .map(|(name, def)| DaemonManifestParam {
+ name: name.clone(),
+ param_type: def.param_type.clone(),
+ required: def.required,
+ default: None,
+ })
+ .collect();
+
+ DaemonManifestMethod {
+ name: m.name.clone(),
+ description: m.description.clone().unwrap_or_default(),
+ params,
+ }
+ })
+ .collect();
+
+ DaemonManifest {
+ name: daemon_name.clone(),
+ version: skill.version.clone(),
+ description: skill.description.clone(),
+ protocol: "fgp@1".to_string(),
+ author: skill.author.name.clone(),
+ license: skill.license.clone(),
+ repository: skill.repository.clone(),
+ daemon: DaemonManifestConfig {
+ entrypoint: binary_path.to_string(),
+ socket: format!("{}/daemon.sock", daemon_name),
+ dependencies: vec![],
+ },
+ methods,
+ auth: None,
+ platforms: vec!["darwin".to_string(), "linux".to_string()],
+ }
+}
+
+/// Register an installed skill with the MCP server by creating manifest.json
+pub fn mcp_register(name: &str) -> Result<()> {
+ let installed = load_installed_skills()?;
+
+ // Find the installed skill
+ let skill_key = installed
+ .skills
+ .keys()
+ .find(|k| k.starts_with(&format!("{}@", name)))
+ .cloned();
+
+ let (_key, entry) = match skill_key {
+ Some(k) => {
+ let entries = installed.skills.get(&k).unwrap();
+ let entry = entries.first().context("No installation entry found")?;
+ (k, entry)
+ }
+ None => {
+ bail!("Skill '{}' is not installed. Install it first with: fgp skill install {}", name, name);
+ }
+ };
+
+ // Load skill.json
+ let skill_manifest_path = Path::new(&entry.install_path)
+ .join("source")
+ .join(".fgp")
+ .join("skill.json");
+
+ if !skill_manifest_path.exists() {
+ bail!("Skill manifest not found at {}", skill_manifest_path.display());
+ }
+
+ let skill_content = fs::read_to_string(&skill_manifest_path)?;
+ let skill_manifest: SkillManifest = serde_json::from_str(&skill_content)?;
+
+ // Get the daemon name
+ let daemon_name = skill_manifest
+ .daemon
+ .as_ref()
+ .map(|d| d.name.clone())
+ .unwrap_or_else(|| skill_manifest.name.replace("-gateway", ""));
+
+ // Get binary path
+ let binary_path = entry
+ .binary_path
+ .as_ref()
+ .context("No binary path found. Was the skill built correctly?")?;
+
+ // Create manifest.json for MCP server
+ let daemon_manifest = skill_to_daemon_manifest(&skill_manifest, binary_path);
+
+ // Write to services directory
+ let services_dir = fgp_home().join("services").join(&daemon_name);
+ fs::create_dir_all(&services_dir)?;
+
+ let manifest_path = services_dir.join("manifest.json");
+ let manifest_content = serde_json::to_string_pretty(&daemon_manifest)?;
+ fs::write(&manifest_path, &manifest_content)?;
+
+ println!(
+ "{} Registered '{}' with MCP server",
+ "✓".green().bold(),
+ daemon_name.cyan()
+ );
+ println!(" Manifest: {}", manifest_path.display());
+ println!();
+ println!("The skill is now available via the FGP MCP server.");
+ println!("Tools will be named: {}", format!("fgp_{}_*", daemon_name).cyan());
+
+ Ok(())
+}
+
+/// Register all installed skills with MCP server
+pub fn mcp_register_all() -> Result<()> {
+ let installed = load_installed_skills()?;
+
+ if installed.skills.is_empty() {
+ println!("{}", "No skills installed.".yellow());
+ return Ok(());
+ }
+
+ println!("{}", "Registering all skills with MCP server...".bold());
+ println!();
+
+ for skill_key in installed.skills.keys() {
+ let parts: Vec<&str> = skill_key.split('@').collect();
+ if parts.is_empty() {
+ continue;
+ }
+ let skill_name = parts[0];
+
+ print!(" {} ", skill_name.cyan());
+ match mcp_register(skill_name) {
+ Ok(()) => {} // Already prints success
+ Err(e) => println!("{} {}", "✗ failed:".red(), e),
+ }
+ }
+
+ Ok(())
+}
+
+/// List MCP-registered skills
+pub fn mcp_list() -> Result<()> {
+ let services_dir = fgp_home().join("services");
+
+ if !services_dir.exists() {
+ println!("{}", "No services directory found.".yellow());
+ return Ok(());
+ }
+
+ println!("{}", "MCP-Registered FGP Skills".bold());
+ println!();
+
+ let mut found = false;
+ for entry in fs::read_dir(&services_dir)? {
+ let entry = entry?;
+ let manifest_path = entry.path().join("manifest.json");
+
+ if manifest_path.exists() {
+ found = true;
+ let content = fs::read_to_string(&manifest_path)?;
+ let manifest: DaemonManifest = serde_json::from_str(&content)?;
+
+ let socket_path = services_dir.join(&manifest.daemon.socket);
+ let is_running = socket_path.exists();
+
+ let status = if is_running {
+ "● running".green()
+ } else {
+ "○ stopped".dimmed()
+ };
+
+ println!(
+ " {} {} {}",
+ manifest.name.cyan().bold(),
+ format!("v{}", manifest.version).dimmed(),
+ status
+ );
+ println!(" {}", manifest.description.dimmed());
+ println!(" Methods: {} | Tools: fgp_{}_*", manifest.methods.len(), manifest.name);
+ println!();
+ }
+ }
+
+ if !found {
+ println!("{}", "No skills registered with MCP server.".yellow());
+ println!();
+ println!("Register an installed skill with:");
+ println!(" fgp skill mcp-register ");
+ }
+
+ Ok(())
+}
+
+// ============================================================================
+// Multi-Ecosystem Export Functions
+// ============================================================================
+
+/// Export a skill to multiple ecosystems
+pub fn export_skill(name: &str, targets: &[ExportTarget], binary_path: Option<&str>) -> Result<()> {
+ let installed = load_installed_skills()?;
+
+ // Find the installed skill
+ let skill_key = installed
+ .skills
+ .keys()
+ .find(|k| k.starts_with(&format!("{}@", name)))
+ .cloned();
+
+ let entry = match skill_key {
+ Some(k) => {
+ let entries = installed.skills.get(&k).unwrap();
+ entries.first().context("No installation entry found")?
+ }
+ None => {
+ bail!("Skill '{}' is not installed. Install it first with: fgp skill install {}", name, name);
+ }
+ };
+
+ // Load skill.json
+ let skill_manifest_path = Path::new(&entry.install_path)
+ .join("source")
+ .join(".fgp")
+ .join("skill.json");
+
+ if !skill_manifest_path.exists() {
+ bail!("Skill manifest not found at {}", skill_manifest_path.display());
+ }
+
+ let skill_content = fs::read_to_string(&skill_manifest_path)?;
+ let skill: SkillManifest = serde_json::from_str(&skill_content)?;
+
+ let bin_path = binary_path.map(|s| s.to_string()).or(entry.binary_path.clone());
+
+ // Expand 'All' target
+ let actual_targets: Vec = if targets.contains(&ExportTarget::All) {
+ ExportTarget::all_targets()
+ } else {
+ targets.to_vec()
+ };
+
+ for target in actual_targets {
+ match target {
+ ExportTarget::Mcp => {
+ if let Some(ref bp) = bin_path {
+ export_to_mcp(&skill, bp)?;
+ }
+ }
+ ExportTarget::Claude => export_to_claude(&skill)?,
+ ExportTarget::Cursor => export_to_cursor(&skill)?,
+ ExportTarget::ContinueDev => export_to_continue(&skill)?,
+ ExportTarget::Windsurf => export_to_windsurf(&skill)?,
+ ExportTarget::All => {} // Already expanded
+ }
+ }
+
+ Ok(())
+}
+
+/// Export to MCP (FGP daemon manifest)
+fn export_to_mcp(skill: &SkillManifest, binary_path: &str) -> Result<()> {
+ let daemon_name = skill
+ .daemon
+ .as_ref()
+ .map(|d| d.name.clone())
+ .unwrap_or_else(|| skill.name.replace("-gateway", ""));
+
+ let manifest = skill_to_daemon_manifest(skill, binary_path);
+ let services_dir = fgp_home().join("services").join(&daemon_name);
+ fs::create_dir_all(&services_dir)?;
+ let manifest_path = services_dir.join("manifest.json");
+ let manifest_json = serde_json::to_string_pretty(&manifest)?;
+ fs::write(&manifest_path, &manifest_json)?;
+
+ println!(" {} MCP: {}", "✓".green(), manifest_path.display());
+ Ok(())
+}
+
+/// Export to Claude Code (SKILL.md)
+fn export_to_claude(skill: &SkillManifest) -> Result<()> {
+ let daemon_name = skill
+ .daemon
+ .as_ref()
+ .map(|d| d.name.clone())
+ .unwrap_or_else(|| skill.name.replace("-gateway", ""));
+
+ // Get Claude-specific config or use defaults
+ let (skill_name, triggers, tools) = if let Some(ref exports) = skill.exports {
+ if let Some(ref claude) = exports.claude {
+ if !claude.enabled {
+ println!(" {} Claude: disabled in skill.json", "○".dimmed());
+ return Ok(());
+ }
+ (
+ claude.skill_name.clone().unwrap_or_else(|| format!("{}-fgp", daemon_name)),
+ claude.triggers.clone(),
+ claude.tools.clone(),
+ )
+ } else {
+ (format!("{}-fgp", daemon_name), vec![], vec!["Bash".to_string()])
+ }
+ } else {
+ (format!("{}-fgp", daemon_name), vec![], vec!["Bash".to_string()])
+ };
+
+ // Generate SKILL.md content
+ let skill_md = generate_claude_skill_md(skill, &skill_name, &triggers, &tools);
+
+ // Write to ~/.claude/skills//SKILL.md
+ let claude_skills_dir = dirs::home_dir()
+ .context("Could not find home directory")?
+ .join(".claude")
+ .join("skills")
+ .join(&skill_name);
+
+ fs::create_dir_all(&claude_skills_dir)?;
+ let skill_md_path = claude_skills_dir.join("SKILL.md");
+ fs::write(&skill_md_path, &skill_md)?;
+
+ println!(" {} Claude: {}", "✓".green(), skill_md_path.display());
+ Ok(())
+}
+
+/// Generate Claude Code SKILL.md content
+fn generate_claude_skill_md(skill: &SkillManifest, skill_name: &str, triggers: &[String], tools: &[String]) -> String {
+ let daemon_name = skill
+ .daemon
+ .as_ref()
+ .map(|d| d.name.clone())
+ .unwrap_or_else(|| skill.name.replace("-gateway", ""));
+
+ // Build triggers from keywords if not specified
+ let trigger_list = if triggers.is_empty() {
+ skill.keywords.clone()
+ } else {
+ triggers.to_vec()
+ };
+
+ let tools_json = serde_json::to_string(&tools).unwrap_or_else(|_| "[\"Bash\"]".to_string());
+
+ let mut md = String::new();
+
+ // Frontmatter
+ md.push_str("---\n");
+ md.push_str(&format!("name: {}\n", skill_name));
+ md.push_str(&format!("description: {}\n", skill.description));
+ md.push_str(&format!("tools: {}\n", tools_json));
+ if !trigger_list.is_empty() {
+ md.push_str("triggers:\n");
+ for trigger in &trigger_list {
+ md.push_str(&format!(" - \"{}\"\n", trigger));
+ }
+ }
+ md.push_str("---\n\n");
+
+ // Title
+ md.push_str(&format!("# {} FGP Skill\n\n", daemon_name.to_uppercase()));
+ md.push_str(&format!("{}\n\n", skill.description));
+
+ // Prerequisites
+ md.push_str("## Prerequisites\n\n");
+ md.push_str(&format!("1. **FGP daemon running**: `fgp start {}` or daemon auto-starts on first call\n", daemon_name));
+
+ if !skill.requirements.is_empty() {
+ for (name, req) in &skill.requirements {
+ if let Some(ref hint) = req.install_hint {
+ md.push_str(&format!("2. **{}**: {}\n", name, hint));
+ }
+ }
+ }
+ md.push_str("\n");
+
+ // Available Methods
+ md.push_str("## Available Methods\n\n");
+ md.push_str("| Method | Description |\n");
+ md.push_str("|--------|-------------|\n");
+ for method in &skill.methods {
+ let desc = method.description.as_deref().unwrap_or("");
+ md.push_str(&format!("| `{}` | {} |\n", method.name, desc));
+ }
+ md.push_str("\n---\n\n");
+
+ // Method details
+ for method in &skill.methods {
+ let desc = method.description.as_deref().unwrap_or("");
+ md.push_str(&format!("### {} - {}\n\n", method.name, desc));
+
+ // Parameters table
+ if !method.params.is_empty() {
+ md.push_str("**Parameters:**\n");
+ md.push_str("| Parameter | Type | Required | Description |\n");
+ md.push_str("|-----------|------|----------|-------------|\n");
+ for (name, param) in &method.params {
+ let param_desc = param.description.as_deref().unwrap_or("-");
+ md.push_str(&format!(
+ "| `{}` | {} | {} | {} |\n",
+ name,
+ param.param_type,
+ if param.required { "Yes" } else { "No" },
+ param_desc
+ ));
+ }
+ md.push_str("\n");
+ }
+
+ // Example command
+ md.push_str("```bash\n");
+ if method.params.is_empty() {
+ md.push_str(&format!("fgp call {}\n", method.name));
+ } else {
+ // Build example params
+ let example_params: Vec = method.params.iter()
+ .filter(|(_, p)| p.required)
+ .map(|(name, p)| {
+ let val = match p.param_type.as_str() {
+ "string" => format!("\"<{}>\"", name),
+ "integer" | "number" => "0".to_string(),
+ "boolean" => "true".to_string(),
+ _ => "null".to_string(),
+ };
+ format!("\"{}\": {}", name, val)
+ })
+ .collect();
+
+ if example_params.is_empty() {
+ md.push_str(&format!("fgp call {}\n", method.name));
+ } else {
+ md.push_str(&format!("fgp call {} -p '{{{}}}'\n", method.name, example_params.join(", ")));
+ }
+ }
+ md.push_str("```\n\n---\n\n");
+ }
+
+ // Performance note
+ md.push_str("## Performance\n\n");
+ md.push_str("- Cold start: ~50ms\n");
+ md.push_str("- Warm call: ~10-30ms (10x faster than MCP stdio)\n");
+
+ md
+}
+
+/// Export to Cursor (mcp.json entry)
+fn export_to_cursor(skill: &SkillManifest) -> Result<()> {
+ let daemon_name = skill
+ .daemon
+ .as_ref()
+ .map(|d| d.name.clone())
+ .unwrap_or_else(|| skill.name.replace("-gateway", ""));
+
+ // Get Cursor-specific config or use defaults
+ let server_name = if let Some(ref exports) = skill.exports {
+ if let Some(ref cursor) = exports.cursor {
+ if !cursor.enabled {
+ println!(" {} Cursor: disabled in skill.json", "○".dimmed());
+ return Ok(());
+ }
+ cursor.server_name.clone().unwrap_or_else(|| format!("fgp-{}", daemon_name))
+ } else {
+ format!("fgp-{}", daemon_name)
+ }
+ } else {
+ format!("fgp-{}", daemon_name)
+ };
+
+ // Read existing mcp.json or create new
+ let cursor_dir = dirs::home_dir()
+ .context("Could not find home directory")?
+ .join(".cursor");
+
+ fs::create_dir_all(&cursor_dir)?;
+ let mcp_json_path = cursor_dir.join("mcp.json");
+
+ let mut mcp_config: serde_json::Value = if mcp_json_path.exists() {
+ let content = fs::read_to_string(&mcp_json_path)?;
+ serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({"mcpServers": {}}))
+ } else {
+ serde_json::json!({"mcpServers": {}})
+ };
+
+ // Add FGP server entry
+ let server_entry = serde_json::json!({
+ "command": "fgp",
+ "args": ["mcp", "--service", &daemon_name],
+ "env": {}
+ });
+
+ if let Some(servers) = mcp_config.get_mut("mcpServers") {
+ if let Some(obj) = servers.as_object_mut() {
+ obj.insert(server_name.clone(), server_entry);
+ }
+ }
+
+ // Write back
+ let mcp_json = serde_json::to_string_pretty(&mcp_config)?;
+ fs::write(&mcp_json_path, &mcp_json)?;
+
+ println!(" {} Cursor: {} in {}", "✓".green(), server_name, mcp_json_path.display());
+ Ok(())
+}
+
+/// Export to Continue.dev (config.yaml provider)
+fn export_to_continue(skill: &SkillManifest) -> Result<()> {
+ // Check if enabled
+ if let Some(ref exports) = skill.exports {
+ if let Some(ref continue_cfg) = exports.continue_dev {
+ if !continue_cfg.enabled {
+ println!(" {} Continue: disabled in skill.json", "○".dimmed());
+ return Ok(());
+ }
+ } else {
+ println!(" {} Continue: not configured in skill.json", "○".dimmed());
+ return Ok(());
+ }
+ } else {
+ println!(" {} Continue: not configured in skill.json", "○".dimmed());
+ return Ok(());
+ }
+
+ let daemon_name = skill
+ .daemon
+ .as_ref()
+ .map(|d| d.name.clone())
+ .unwrap_or_else(|| skill.name.replace("-gateway", ""));
+
+ // Continue.dev doesn't have a stable format yet - log as TODO
+ println!(" {} Continue: format TBD (daemon: {})", "⚠".yellow(), daemon_name);
+ Ok(())
+}
+
+/// Export to Windsurf (markdown skill)
+fn export_to_windsurf(skill: &SkillManifest) -> Result<()> {
+ // Check if enabled
+ if let Some(ref exports) = skill.exports {
+ if let Some(ref windsurf) = exports.windsurf {
+ if !windsurf.enabled {
+ println!(" {} Windsurf: disabled in skill.json", "○".dimmed());
+ return Ok(());
+ }
+ } else {
+ println!(" {} Windsurf: not configured in skill.json", "○".dimmed());
+ return Ok(());
+ }
+ } else {
+ println!(" {} Windsurf: not configured in skill.json", "○".dimmed());
+ return Ok(());
+ }
+
+ let daemon_name = skill
+ .daemon
+ .as_ref()
+ .map(|d| d.name.clone())
+ .unwrap_or_else(|| skill.name.replace("-gateway", ""));
+
+ // Generate similar markdown to Claude (Windsurf format is similar)
+ let skill_md = generate_claude_skill_md(skill, &format!("{}-fgp", daemon_name), &[], &["Bash".to_string()]);
+
+ let windsurf_skills_dir = dirs::home_dir()
+ .context("Could not find home directory")?
+ .join(".windsurf")
+ .join("skills")
+ .join(&format!("{}-fgp", daemon_name));
+
+ fs::create_dir_all(&windsurf_skills_dir)?;
+ let skill_md_path = windsurf_skills_dir.join("SKILL.md");
+ fs::write(&skill_md_path, &skill_md)?;
+
+ println!(" {} Windsurf: {}", "✓".green(), skill_md_path.display());
+ Ok(())
+}
+
+/// Register skill with multiple targets (CLI entry point)
+pub fn register_with_targets(name: &str, target_str: &str) -> Result<()> {
+ println!(
+ "{} {} to {}...",
+ "Registering".bold(),
+ name.cyan(),
+ target_str.green()
+ );
+ println!();
+
+ // Parse targets
+ let targets: Vec = target_str
+ .split(',')
+ .filter_map(|s| ExportTarget::from_str(s.trim()))
+ .collect();
+
+ if targets.is_empty() {
+ bail!("No valid targets specified. Valid targets: mcp, claude, cursor, continue, windsurf, all");
+ }
+
+ export_skill(name, &targets, None)?;
+
+ println!();
+ println!("{} Registration complete!", "✓".green().bold());
+ Ok(())
+}
+
+/// Show registration status for a skill
+pub fn registration_status(name: &str) -> Result<()> {
+ let installed = load_installed_skills()?;
+
+ // Find the installed skill
+ let skill_key = installed
+ .skills
+ .keys()
+ .find(|k| k.starts_with(&format!("{}@", name)))
+ .cloned();
+
+ let entry = match skill_key {
+ Some(k) => {
+ let entries = installed.skills.get(&k).unwrap();
+ entries.first().context("No installation entry found")?
+ }
+ None => {
+ bail!("Skill '{}' is not installed", name);
+ }
+ };
+
+ // Load skill.json
+ let skill_manifest_path = Path::new(&entry.install_path)
+ .join("source")
+ .join(".fgp")
+ .join("skill.json");
+
+ let skill: SkillManifest = if skill_manifest_path.exists() {
+ let content = fs::read_to_string(&skill_manifest_path)?;
+ serde_json::from_str(&content)?
+ } else {
+ bail!("Skill manifest not found");
+ };
+
+ let daemon_name = skill
+ .daemon
+ .as_ref()
+ .map(|d| d.name.clone())
+ .unwrap_or_else(|| skill.name.replace("-gateway", ""));
+
+ println!("{} v{}", name.cyan().bold(), skill.version);
+ println!();
+
+ // Check MCP
+ let mcp_manifest = fgp_home().join("services").join(&daemon_name).join("manifest.json");
+ if mcp_manifest.exists() {
+ println!(" ├─ mcp: {} {}", "✓".green(), mcp_manifest.display());
+ } else {
+ println!(" ├─ mcp: {} not registered", "○".dimmed());
+ }
+
+ // Check Claude
+ let claude_skill = dirs::home_dir()
+ .unwrap()
+ .join(".claude")
+ .join("skills")
+ .join(format!("{}-fgp", daemon_name))
+ .join("SKILL.md");
+ if claude_skill.exists() {
+ println!(" ├─ claude: {} {}", "✓".green(), claude_skill.display());
+ } else {
+ println!(" ├─ claude: {} not registered", "○".dimmed());
+ }
+
+ // Check Cursor
+ let cursor_mcp = dirs::home_dir().unwrap().join(".cursor").join("mcp.json");
+ let cursor_registered = if cursor_mcp.exists() {
+ let content = fs::read_to_string(&cursor_mcp).unwrap_or_default();
+ content.contains(&format!("fgp-{}", daemon_name))
+ } else {
+ false
+ };
+ if cursor_registered {
+ println!(" ├─ cursor: {} fgp-{}", "✓".green(), daemon_name);
+ } else {
+ println!(" ├─ cursor: {} not registered", "○".dimmed());
+ }
+
+ // Check Continue
+ println!(" ├─ continue: {} not supported yet", "○".dimmed());
+
+ // Check Windsurf
+ let windsurf_skill = dirs::home_dir()
+ .unwrap()
+ .join(".windsurf")
+ .join("skills")
+ .join(format!("{}-fgp", daemon_name))
+ .join("SKILL.md");
+ if windsurf_skill.exists() {
+ println!(" └─ windsurf: {} {}", "✓".green(), windsurf_skill.display());
+ } else {
+ println!(" └─ windsurf: {} not registered", "○".dimmed());
+ }
+
+ Ok(())
+}
diff --git a/src/commands/skill_export.rs b/src/commands/skill_export.rs
new file mode 100644
index 0000000..31a1cbe
--- /dev/null
+++ b/src/commands/skill_export.rs
@@ -0,0 +1,366 @@
+//! Export FGP skills to agent-specific formats.
+//!
+//! Supported targets:
+//! - claude-code: Generates SKILL.md for ~/.claude/skills/
+//! - cursor: Generates .cursorrules and commands
+//! - codex: Generates tool spec and prompts
+//! - mcp: Generates MCP tool schema
+//! - windsurf: Generates cascade rules
+
+use anyhow::{bail, Context, Result};
+use colored::Colorize;
+use std::fs;
+use std::path::Path;
+
+use super::skill_validate::SkillManifest;
+
+/// Export a skill for a specific agent.
+pub fn export(target: &str, skill: &str, output: Option<&str>) -> Result<()> {
+ println!(
+ "{} Exporting skill for {}...",
+ "→".blue().bold(),
+ target.cyan()
+ );
+
+ // Load the skill manifest
+ let skill_path = Path::new(skill);
+ let skill_dir = if skill_path.is_dir() {
+ skill_path.to_path_buf()
+ } else {
+ skill_path.parent().unwrap_or(Path::new(".")).to_path_buf()
+ };
+
+ let manifest_path = if skill_path.is_dir() {
+ skill_path.join("skill.yaml")
+ } else if skill_path.extension().map(|e| e == "yaml" || e == "yml").unwrap_or(false) {
+ skill_path.to_path_buf()
+ } else {
+ // Assume it's a skill name, look in installed skills
+ let installed_path = shellexpand::tilde("~/.fgp/skills").to_string();
+ Path::new(&installed_path).join(skill).join("skill.yaml")
+ };
+
+ if !manifest_path.exists() {
+ bail!(
+ "Skill manifest not found: {}\n\
+ Provide a path to a skill directory or skill.yaml file.",
+ manifest_path.display()
+ );
+ }
+
+ let content = fs::read_to_string(&manifest_path)
+ .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
+
+ let manifest: SkillManifest = serde_yaml::from_str(&content)
+ .with_context(|| "Invalid skill.yaml")?;
+
+ // Determine output directory
+ let output_dir = match output {
+ Some(dir) => Path::new(dir).to_path_buf(),
+ None => std::env::current_dir()?,
+ };
+
+ // Export based on target
+ match target {
+ "claude-code" | "claude" => export_claude_code(&manifest, &skill_dir, &output_dir),
+ "cursor" => export_cursor(&manifest, &skill_dir, &output_dir),
+ "codex" => export_codex(&manifest, &skill_dir, &output_dir),
+ "mcp" => export_mcp(&manifest, &skill_dir, &output_dir),
+ "windsurf" => export_windsurf(&manifest, &skill_dir, &output_dir),
+ _ => bail!(
+ "Unknown export target: {}\n\
+ Valid targets: claude-code, cursor, codex, mcp, windsurf",
+ target
+ ),
+ }
+}
+
+/// Export for Claude Code (generates SKILL.md).
+fn export_claude_code(manifest: &SkillManifest, skill_dir: &Path, output_dir: &Path) -> Result<()> {
+ // Create output directory
+ let skill_output_dir = output_dir.join(&manifest.name);
+ fs::create_dir_all(&skill_output_dir)?;
+
+ // Build SKILL.md content
+ let mut skill_md = String::new();
+
+ // YAML front matter
+ skill_md.push_str("---\n");
+ skill_md.push_str(&format!("name: {}\n", manifest.name));
+ skill_md.push_str(&format!("description: {}\n", manifest.description));
+ skill_md.push_str(&format!("version: {}\n", manifest.version));
+
+ // Add triggers
+ if let Some(ref triggers) = manifest.triggers {
+ if !triggers.keywords.is_empty() {
+ skill_md.push_str("triggers:\n");
+ for keyword in &triggers.keywords {
+ skill_md.push_str(&format!(" - \"{}\"\n", keyword));
+ }
+ }
+ }
+
+ skill_md.push_str("---\n\n");
+
+ // Read core/claude-code instructions if they exist
+ let claude_instructions = manifest
+ .instructions
+ .as_ref()
+ .and_then(|i| i.claude_code.as_ref())
+ .or_else(|| manifest.instructions.as_ref().and_then(|i| i.core.as_ref()));
+
+ if let Some(instruction_path) = claude_instructions {
+ let full_path = skill_dir.join(instruction_path);
+ if full_path.exists() {
+ let instructions = fs::read_to_string(&full_path)?;
+ skill_md.push_str(&instructions);
+ }
+ } else {
+ // Generate default instructions
+ skill_md.push_str(&format!("# {}\n\n", manifest.name));
+ skill_md.push_str(&format!("{}\n\n", manifest.description));
+
+ // Add daemon usage
+ if !manifest.daemons.is_empty() {
+ skill_md.push_str("## Dependencies\n\n");
+ skill_md.push_str("This skill requires the following FGP daemons:\n\n");
+ for daemon in &manifest.daemons {
+ let optional = if daemon.optional { " (optional)" } else { "" };
+ skill_md.push_str(&format!("- **{}**{}\n", daemon.name, optional));
+ }
+ skill_md.push_str("\n");
+ }
+
+ // Add usage examples
+ if let Some(ref triggers) = manifest.triggers {
+ if !triggers.patterns.is_empty() {
+ skill_md.push_str("## Usage\n\n");
+ for pattern in &triggers.patterns {
+ skill_md.push_str(&format!("- `{}`\n", pattern));
+ }
+ skill_md.push_str("\n");
+ }
+ }
+
+ // Add workflow info
+ if !manifest.workflows.is_empty() {
+ skill_md.push_str("## Workflows\n\n");
+ for (name, workflow) in &manifest.workflows {
+ let default = if workflow.default { " (default)" } else { "" };
+ let desc = workflow.description.as_deref().unwrap_or("");
+ skill_md.push_str(&format!("- **{}**{}: {}\n", name, default, desc));
+ }
+ skill_md.push_str("\n");
+ }
+ }
+
+ // Write SKILL.md
+ let skill_md_path = skill_output_dir.join("SKILL.md");
+ fs::write(&skill_md_path, &skill_md)?;
+
+ println!(
+ "{} Exported Claude Code skill to: {}",
+ "✓".green().bold(),
+ skill_md_path.display()
+ );
+
+ // Provide install hint
+ println!();
+ println!("{}:", "Install".cyan().bold());
+ println!(
+ " cp -r {} ~/.claude/skills/",
+ skill_output_dir.display()
+ );
+
+ Ok(())
+}
+
+/// Export for Cursor (generates .cursorrules).
+fn export_cursor(manifest: &SkillManifest, skill_dir: &Path, output_dir: &Path) -> Result<()> {
+ let mut rules = String::new();
+
+ rules.push_str(&format!("# {} - FGP Skill\n\n", manifest.name));
+ rules.push_str(&format!("{}\n\n", manifest.description));
+
+ // Add trigger detection
+ if let Some(ref triggers) = manifest.triggers {
+ rules.push_str("## Trigger Detection\n\n");
+ rules.push_str("When user mentions:\n");
+ for keyword in &triggers.keywords {
+ rules.push_str(&format!("- \"{}\"\n", keyword));
+ }
+ rules.push_str("\n");
+ }
+
+ // Read cursor-specific instructions
+ let cursor_instructions = manifest
+ .instructions
+ .as_ref()
+ .and_then(|i| i.cursor.as_ref());
+
+ if let Some(instruction_path) = cursor_instructions {
+ let full_path = skill_dir.join(instruction_path);
+ if full_path.exists() {
+ let instructions = fs::read_to_string(&full_path)?;
+ rules.push_str(&instructions);
+ }
+ } else {
+ // Generate default
+ rules.push_str("## Execution\n\n");
+ rules.push_str("Use FGP daemons for fast execution:\n\n");
+ rules.push_str("```bash\n");
+ for daemon in &manifest.daemons {
+ for method in &daemon.methods {
+ rules.push_str(&format!(
+ "fgp call {}.{} -p '{{\"param\": \"value\"}}'\n",
+ daemon.name, method
+ ));
+ }
+ }
+ rules.push_str("```\n");
+ }
+
+ // Write file
+ let rules_path = output_dir.join(format!("{}.cursorrules", manifest.name));
+ fs::write(&rules_path, &rules)?;
+
+ println!(
+ "{} Exported Cursor rules to: {}",
+ "✓".green().bold(),
+ rules_path.display()
+ );
+
+ Ok(())
+}
+
+/// Export for Codex (generates tool spec).
+fn export_codex(manifest: &SkillManifest, _skill_dir: &Path, output_dir: &Path) -> Result<()> {
+ // Generate a simple tool specification for Codex
+ let mut spec = serde_json::json!({
+ "name": manifest.name,
+ "description": manifest.description,
+ "version": manifest.version,
+ "tools": []
+ });
+
+ // Add tools from daemon methods
+ let tools = spec["tools"].as_array_mut().unwrap();
+ for daemon in &manifest.daemons {
+ for method in &daemon.methods {
+ tools.push(serde_json::json!({
+ "name": format!("{}.{}", daemon.name, method),
+ "description": format!("{} {} operation", daemon.name, method),
+ "invocation": format!("fgp call {}.{} -p '{{...}}'", daemon.name, method)
+ }));
+ }
+ }
+
+ // Write file
+ let spec_path = output_dir.join(format!("{}.codex.json", manifest.name));
+ fs::write(&spec_path, serde_json::to_string_pretty(&spec)?)?;
+
+ println!(
+ "{} Exported Codex spec to: {}",
+ "✓".green().bold(),
+ spec_path.display()
+ );
+
+ Ok(())
+}
+
+/// Export for MCP (generates tool schema).
+fn export_mcp(manifest: &SkillManifest, _skill_dir: &Path, output_dir: &Path) -> Result<()> {
+ let prefix = manifest
+ .exports
+ .as_ref()
+ .and_then(|e| e.mcp.as_ref())
+ .and_then(|m| m.tools_prefix.as_ref())
+ .map(|s| s.as_str())
+ .unwrap_or(&manifest.name);
+
+ let mut mcp_tools = Vec::new();
+
+ for daemon in &manifest.daemons {
+ for method in &daemon.methods {
+ mcp_tools.push(serde_json::json!({
+ "name": format!("{}_{}", prefix, method),
+ "description": format!("{} via FGP {} daemon", method, daemon.name),
+ "inputSchema": {
+ "type": "object",
+ "properties": {},
+ "required": []
+ }
+ }));
+ }
+ }
+
+ let mcp_spec = serde_json::json!({
+ "name": manifest.name,
+ "version": manifest.version,
+ "description": manifest.description,
+ "tools": mcp_tools
+ });
+
+ // Write file
+ let mcp_path = output_dir.join(format!("{}.mcp.json", manifest.name));
+ fs::write(&mcp_path, serde_json::to_string_pretty(&mcp_spec)?)?;
+
+ println!(
+ "{} Exported MCP schema to: {}",
+ "✓".green().bold(),
+ mcp_path.display()
+ );
+
+ Ok(())
+}
+
+/// Export for Windsurf (generates cascade rules).
+fn export_windsurf(manifest: &SkillManifest, skill_dir: &Path, output_dir: &Path) -> Result<()> {
+ let mut rules = String::new();
+
+ rules.push_str(&format!("# {} - FGP Skill for Windsurf\n\n", manifest.name));
+ rules.push_str(&format!("{}\n\n", manifest.description));
+
+ // Read windsurf-specific instructions
+ let windsurf_instructions = manifest
+ .instructions
+ .as_ref()
+ .and_then(|i| i.windsurf.as_ref());
+
+ if let Some(instruction_path) = windsurf_instructions {
+ let full_path = skill_dir.join(instruction_path);
+ if full_path.exists() {
+ let instructions = fs::read_to_string(&full_path)?;
+ rules.push_str(&instructions);
+ }
+ } else {
+ // Generate default
+ rules.push_str("## When to Use\n\n");
+ if let Some(ref triggers) = manifest.triggers {
+ for keyword in &triggers.keywords {
+ rules.push_str(&format!("- User mentions \"{}\"\n", keyword));
+ }
+ }
+ rules.push_str("\n## Commands\n\n");
+ for daemon in &manifest.daemons {
+ for method in &daemon.methods {
+ rules.push_str(&format!(
+ "- `fgp call {}.{}`\n",
+ daemon.name, method
+ ));
+ }
+ }
+ }
+
+ // Write file
+ let rules_path = output_dir.join(format!("{}.windsurf.md", manifest.name));
+ fs::write(&rules_path, &rules)?;
+
+ println!(
+ "{} Exported Windsurf rules to: {}",
+ "✓".green().bold(),
+ rules_path.display()
+ );
+
+ Ok(())
+}
diff --git a/src/commands/skill_tap.rs b/src/commands/skill_tap.rs
new file mode 100644
index 0000000..776bd91
--- /dev/null
+++ b/src/commands/skill_tap.rs
@@ -0,0 +1,672 @@
+//! FGP skill tap management - GitHub-based skill repositories.
+//!
+//! Taps are Git repositories containing skill.yaml packages.
+//! Similar to Homebrew taps, they enable community distribution.
+//!
+//! # Directory Structure
+//!
+//! ```text
+//! ~/.fgp/
+//! └── taps/
+//! ├── taps.json # Track configured taps
+//! └── repos/
+//! ├── fast-gateway-protocol/
+//! │ └── official-skills/ # Cloned tap repo
+//! │ ├── skills/
+//! │ │ ├── research-assistant/
+//! │ │ │ └── skill.yaml
+//! │ │ └── email-triage/
+//! │ │ └── skill.yaml
+//! │ └── tap.yaml # Tap metadata
+//! └── user/
+//! └── my-skills/
+//! ```
+
+use anyhow::{bail, Result};
+use colored::Colorize;
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
+use std::fs;
+use std::path::{Path, PathBuf};
+use std::process::Command;
+
+use super::skill_validate::SkillManifest;
+
+/// Tap configuration stored in taps.json
+#[derive(Debug, Serialize, Deserialize)]
+pub struct TapsConfig {
+ pub version: u32,
+ pub taps: HashMap,
+}
+
+impl Default for TapsConfig {
+ fn default() -> Self {
+ Self {
+ version: 1,
+ taps: HashMap::new(),
+ }
+ }
+}
+
+/// Individual tap entry
+#[derive(Debug, Serialize, Deserialize)]
+pub struct TapEntry {
+ /// GitHub owner/repo format
+ pub repo: String,
+ /// Full GitHub URL
+ pub url: String,
+ /// Local path to cloned repo
+ pub path: String,
+ /// When the tap was added
+ pub added_at: String,
+ /// Last update timestamp
+ pub updated_at: Option,
+ /// Number of skills in this tap
+ pub skill_count: usize,
+}
+
+/// Tap metadata (tap.yaml in the repo root)
+#[derive(Debug, Serialize, Deserialize)]
+#[allow(dead_code)]
+pub struct TapMetadata {
+ pub name: String,
+ #[serde(default)]
+ pub description: Option,
+ #[serde(default)]
+ pub author: Option,
+ #[serde(default)]
+ pub homepage: Option,
+}
+
+/// Get the taps directory
+fn taps_dir() -> PathBuf {
+ dirs::home_dir()
+ .expect("Could not find home directory")
+ .join(".fgp")
+ .join("taps")
+}
+
+/// Get the taps config file path
+fn taps_config_path() -> PathBuf {
+ taps_dir().join("taps.json")
+}
+
+/// Get the repos directory
+fn repos_dir() -> PathBuf {
+ taps_dir().join("repos")
+}
+
+/// Load taps configuration
+fn load_taps_config() -> Result {
+ let path = taps_config_path();
+ if !path.exists() {
+ return Ok(TapsConfig::default());
+ }
+ let content = fs::read_to_string(&path)?;
+ Ok(serde_json::from_str(&content)?)
+}
+
+/// Save taps configuration
+fn save_taps_config(config: &TapsConfig) -> Result<()> {
+ let path = taps_config_path();
+ fs::create_dir_all(path.parent().unwrap())?;
+ let content = serde_json::to_string_pretty(config)?;
+ fs::write(&path, content)?;
+ Ok(())
+}
+
+/// Convert owner/repo to tap name
+fn repo_to_tap_name(repo: &str) -> String {
+ repo.replace('/', "-")
+}
+
+/// Add a new tap
+pub fn add(repo: &str) -> Result<()> {
+ // Parse repo format (owner/repo or full URL)
+ let (owner, repo_name, url) = parse_repo_input(repo)?;
+ let tap_name = format!("{}-{}", owner, repo_name);
+
+ println!(
+ "{} {}",
+ "→".blue().bold(),
+ format!("Adding tap {}...", tap_name.cyan())
+ );
+
+ // Check if already exists
+ let mut config = load_taps_config()?;
+ if config.taps.contains_key(&tap_name) {
+ println!(
+ "{} Tap '{}' already exists. Use 'fgp skill tap update' to refresh.",
+ "⚠".yellow(),
+ tap_name
+ );
+ return Ok(());
+ }
+
+ // Create directory structure
+ let tap_path = repos_dir().join(&owner).join(&repo_name);
+ fs::create_dir_all(tap_path.parent().unwrap())?;
+
+ // Clone the repository
+ println!(" Cloning {}...", url);
+ let status = Command::new("git")
+ .args(["clone", "--depth", "1", &url])
+ .arg(&tap_path)
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::inherit())
+ .status()?;
+
+ if !status.success() {
+ bail!("Failed to clone repository: {}", url);
+ }
+
+ // Count skills in the tap
+ let skill_count = count_skills(&tap_path)?;
+
+ // Add to config
+ let now = chrono::Utc::now().to_rfc3339();
+ config.taps.insert(
+ tap_name.clone(),
+ TapEntry {
+ repo: format!("{}/{}", owner, repo_name),
+ url: url.clone(),
+ path: tap_path.to_string_lossy().to_string(),
+ added_at: now.clone(),
+ updated_at: Some(now),
+ skill_count,
+ },
+ );
+
+ save_taps_config(&config)?;
+
+ println!(
+ "{} Added tap '{}' with {} skill(s)",
+ "✓".green().bold(),
+ tap_name.cyan(),
+ skill_count
+ );
+
+ // Show available skills
+ if skill_count > 0 {
+ println!();
+ println!("{}:", "Available skills".bold());
+ list_tap_skills(&tap_path, 5)?;
+ }
+
+ Ok(())
+}
+
+/// Remove a tap
+pub fn remove(name: &str) -> Result<()> {
+ let mut config = load_taps_config()?;
+
+ // Find the tap (allow partial match)
+ let tap_name = find_tap_name(&config, name)?;
+
+ let entry = config.taps.get(&tap_name).unwrap();
+ let tap_path = PathBuf::from(&entry.path);
+
+ println!(
+ "{} {}",
+ "→".blue().bold(),
+ format!("Removing tap {}...", tap_name.cyan())
+ );
+
+ // Remove the directory
+ if tap_path.exists() {
+ fs::remove_dir_all(&tap_path)?;
+
+ // Clean up empty parent directories
+ if let Some(parent) = tap_path.parent() {
+ if parent.read_dir()?.next().is_none() {
+ let _ = fs::remove_dir(parent);
+ }
+ }
+ }
+
+ // Remove from config
+ config.taps.remove(&tap_name);
+ save_taps_config(&config)?;
+
+ println!("{} Removed tap '{}'", "✓".green().bold(), tap_name);
+
+ Ok(())
+}
+
+/// List all configured taps
+pub fn list() -> Result<()> {
+ let config = load_taps_config()?;
+
+ if config.taps.is_empty() {
+ println!("{}", "No taps configured.".yellow());
+ println!();
+ println!("Add a tap with:");
+ println!(
+ " {}",
+ "fgp skill tap add fast-gateway-protocol/official-skills".cyan()
+ );
+ return Ok(());
+ }
+
+ println!("{}", "Configured Taps".bold());
+ println!();
+
+ for (name, entry) in &config.taps {
+ let updated = entry
+ .updated_at
+ .as_ref()
+ .map(|s| format_relative_time(s))
+ .unwrap_or_else(|| "never".to_string());
+
+ println!(
+ " {} {}",
+ name.cyan().bold(),
+ format!("({} skills)", entry.skill_count).dimmed()
+ );
+ println!(" {} {}", "repo:".dimmed(), entry.repo);
+ println!(" {} {}", "updated:".dimmed(), updated);
+ }
+
+ Ok(())
+}
+
+/// Update all taps (git pull)
+pub fn update() -> Result<()> {
+ let mut config = load_taps_config()?;
+
+ if config.taps.is_empty() {
+ println!("{}", "No taps configured.".yellow());
+ return Ok(());
+ }
+
+ println!("{}", "Updating taps...".bold());
+ println!();
+
+ for (name, entry) in config.taps.iter_mut() {
+ let tap_path = PathBuf::from(&entry.path);
+
+ if !tap_path.exists() {
+ println!(
+ " {} {} (path missing, re-add with 'fgp skill tap add {}')",
+ "✗".red(),
+ name,
+ entry.repo
+ );
+ continue;
+ }
+
+ print!(" {} {}... ", "→".blue(), name);
+
+ let output = Command::new("git")
+ .args(["pull", "--ff-only"])
+ .current_dir(&tap_path)
+ .output()?;
+
+ if output.status.success() {
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ if stdout.contains("Already up to date") {
+ println!("{}", "up to date".dimmed());
+ } else {
+ // Recount skills
+ let skill_count = count_skills(&tap_path)?;
+ entry.skill_count = skill_count;
+ entry.updated_at = Some(chrono::Utc::now().to_rfc3339());
+ println!("{} ({} skills)", "updated".green(), skill_count);
+ }
+ } else {
+ println!("{}", "failed".red());
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ if !stderr.is_empty() {
+ println!(" {}", stderr.trim().dimmed());
+ }
+ }
+ }
+
+ save_taps_config(&config)?;
+
+ Ok(())
+}
+
+/// Show skills in a specific tap
+pub fn show(name: &str) -> Result<()> {
+ let config = load_taps_config()?;
+ let tap_name = find_tap_name(&config, name)?;
+ let entry = config.taps.get(&tap_name).unwrap();
+ let tap_path = PathBuf::from(&entry.path);
+
+ if !tap_path.exists() {
+ bail!("Tap directory not found. Re-add with 'fgp skill tap add {}'", entry.repo);
+ }
+
+ println!("{} {}", "Tap:".bold(), tap_name.cyan());
+ println!(" {} {}", "repo:".dimmed(), entry.repo);
+ println!(" {} {}", "path:".dimmed(), entry.path);
+ println!();
+ println!("{}:", "Skills".bold());
+
+ list_tap_skills(&tap_path, usize::MAX)?;
+
+ Ok(())
+}
+
+// ============================================================================
+// Helper functions
+// ============================================================================
+
+/// Parse repo input (owner/repo or full URL)
+fn parse_repo_input(input: &str) -> Result<(String, String, String)> {
+ let input = input.trim();
+
+ // Handle full GitHub URL
+ if input.starts_with("https://") || input.starts_with("git@") {
+ let cleaned = input
+ .trim_end_matches('/')
+ .trim_end_matches(".git");
+
+ // Extract owner/repo from URL
+ let parts: Vec<&str> = if cleaned.contains("github.com/") {
+ cleaned.split("github.com/").last().unwrap_or("").split('/').collect()
+ } else if cleaned.contains("github.com:") {
+ cleaned.split("github.com:").last().unwrap_or("").split('/').collect()
+ } else {
+ bail!("Could not parse GitHub URL: {}", input);
+ };
+
+ if parts.len() < 2 {
+ bail!("Invalid GitHub URL format: {}", input);
+ }
+
+ let owner = parts[0].to_string();
+ let repo = parts[1].to_string();
+ let url = format!("https://github.com/{}/{}.git", owner, repo);
+
+ return Ok((owner, repo, url));
+ }
+
+ // Handle owner/repo format
+ let parts: Vec<&str> = input.split('/').collect();
+ if parts.len() != 2 {
+ bail!(
+ "Invalid tap format '{}'. Use 'owner/repo' format (e.g., 'fast-gateway-protocol/official-skills')",
+ input
+ );
+ }
+
+ let owner = parts[0].to_string();
+ let repo = parts[1].to_string();
+ let url = format!("https://github.com/{}/{}.git", owner, repo);
+
+ Ok((owner, repo, url))
+}
+
+/// Find tap name with partial matching
+fn find_tap_name(config: &TapsConfig, partial: &str) -> Result {
+ // Exact match first
+ if config.taps.contains_key(partial) {
+ return Ok(partial.to_string());
+ }
+
+ // Try with repo format conversion
+ let normalized = repo_to_tap_name(partial);
+ if config.taps.contains_key(&normalized) {
+ return Ok(normalized);
+ }
+
+ // Partial match
+ let matches: Vec<&String> = config
+ .taps
+ .keys()
+ .filter(|k| k.contains(partial))
+ .collect();
+
+ match matches.len() {
+ 0 => bail!("Tap '{}' not found. Use 'fgp skill tap list' to see configured taps.", partial),
+ 1 => Ok(matches[0].clone()),
+ _ => bail!(
+ "Ambiguous tap name '{}'. Matches: {}",
+ partial,
+ matches.iter().map(|s| s.as_str()).collect::>().join(", ")
+ ),
+ }
+}
+
+/// Count skills in a tap directory
+fn count_skills(tap_path: &Path) -> Result {
+ let skills_dir = tap_path.join("skills");
+ if !skills_dir.exists() {
+ // Try root level skill.yaml files
+ return count_skills_in_dir(tap_path);
+ }
+ count_skills_in_dir(&skills_dir)
+}
+
+/// Count skill.yaml files in a directory
+fn count_skills_in_dir(dir: &Path) -> Result {
+ let mut count = 0;
+
+ if !dir.exists() {
+ return Ok(0);
+ }
+
+ for entry in fs::read_dir(dir)? {
+ let entry = entry?;
+ let path = entry.path();
+
+ if path.is_dir() {
+ let skill_yaml = path.join("skill.yaml");
+ let skill_yml = path.join("skill.yml");
+ if skill_yaml.exists() || skill_yml.exists() {
+ count += 1;
+ }
+ }
+ }
+
+ Ok(count)
+}
+
+/// List skills in a tap directory
+fn list_tap_skills(tap_path: &Path, limit: usize) -> Result<()> {
+ let skills_dir = tap_path.join("skills");
+ let search_dir = if skills_dir.exists() {
+ skills_dir
+ } else {
+ tap_path.to_path_buf()
+ };
+
+ let mut skills = Vec::new();
+
+ for entry in fs::read_dir(&search_dir)? {
+ let entry = entry?;
+ let path = entry.path();
+
+ if path.is_dir() {
+ let skill_yaml = path.join("skill.yaml");
+ let skill_yml = path.join("skill.yml");
+ let manifest_path = if skill_yaml.exists() {
+ skill_yaml
+ } else if skill_yml.exists() {
+ skill_yml
+ } else {
+ continue;
+ };
+
+ // Try to read the manifest
+ if let Ok(content) = fs::read_to_string(&manifest_path) {
+ if let Ok(manifest) = serde_yaml::from_str::(&content) {
+ skills.push((manifest.name.clone(), manifest.version.clone(), manifest.description.clone()));
+ }
+ }
+ }
+ }
+
+ if skills.is_empty() {
+ println!(" {}", "No skills found".dimmed());
+ return Ok(());
+ }
+
+ for (i, (name, version, description)) in skills.iter().enumerate() {
+ if i >= limit {
+ let remaining = skills.len() - limit;
+ println!(" {} more skill(s)...", format!("... and {}", remaining).dimmed());
+ break;
+ }
+ println!(
+ " {} {}",
+ name.cyan(),
+ format!("v{}", version).dimmed()
+ );
+ println!(" {}", description.dimmed());
+ }
+
+ Ok(())
+}
+
+/// Format a timestamp as relative time
+fn format_relative_time(timestamp: &str) -> String {
+ if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(timestamp) {
+ let now = chrono::Utc::now();
+ let duration = now.signed_duration_since(dt);
+
+ if duration.num_minutes() < 1 {
+ "just now".to_string()
+ } else if duration.num_hours() < 1 {
+ format!("{} minutes ago", duration.num_minutes())
+ } else if duration.num_days() < 1 {
+ format!("{} hours ago", duration.num_hours())
+ } else if duration.num_days() < 7 {
+ format!("{} days ago", duration.num_days())
+ } else {
+ dt.format("%Y-%m-%d").to_string()
+ }
+ } else {
+ timestamp.to_string()
+ }
+}
+
+// ============================================================================
+// Public functions for skill search/install integration
+// ============================================================================
+
+/// Search all taps for a skill by name
+pub fn search_taps(query: &str) -> Result> {
+ let config = load_taps_config()?;
+ let query_lower = query.to_lowercase();
+ let mut results = Vec::new();
+
+ for (tap_name, entry) in &config.taps {
+ let tap_path = PathBuf::from(&entry.path);
+ let skills_dir = tap_path.join("skills");
+ let search_dir = if skills_dir.exists() {
+ skills_dir
+ } else {
+ tap_path.clone()
+ };
+
+ if !search_dir.exists() {
+ continue;
+ }
+
+ for dir_entry in fs::read_dir(&search_dir)? {
+ let dir_entry = dir_entry?;
+ let path = dir_entry.path();
+
+ if !path.is_dir() {
+ continue;
+ }
+
+ let skill_yaml = path.join("skill.yaml");
+ let skill_yml = path.join("skill.yml");
+ let manifest_path = if skill_yaml.exists() {
+ skill_yaml
+ } else if skill_yml.exists() {
+ skill_yml
+ } else {
+ continue;
+ };
+
+ if let Ok(content) = fs::read_to_string(&manifest_path) {
+ if let Ok(manifest) = serde_yaml::from_str::(&content) {
+ // Match against name, description, or keywords
+ let matches = manifest.name.to_lowercase().contains(&query_lower)
+ || manifest.description.to_lowercase().contains(&query_lower)
+ || manifest.keywords.iter().any(|k| k.to_lowercase().contains(&query_lower));
+
+ if matches {
+ results.push((tap_name.clone(), path, manifest));
+ }
+ }
+ }
+ }
+ }
+
+ Ok(results)
+}
+
+/// Find a skill by exact name across all taps
+pub fn find_skill(name: &str) -> Result