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> { + let config = load_taps_config()?; + + 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; + } + + // Direct path match + let skill_path = search_dir.join(name); + if skill_path.exists() { + let skill_yaml = skill_path.join("skill.yaml"); + let skill_yml = skill_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) { + return Ok(Some((tap_name.clone(), skill_path, manifest))); + } + } + } + + // Search all skills for name match + 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) { + if manifest.name == name { + return Ok(Some((tap_name.clone(), path, manifest))); + } + } + } + } + } + + Ok(None) +} diff --git a/src/commands/skill_validate.rs b/src/commands/skill_validate.rs new file mode 100644 index 0000000..a63390b --- /dev/null +++ b/src/commands/skill_validate.rs @@ -0,0 +1,515 @@ +//! Validate FGP skill manifests (skill.yaml). +//! +//! This module validates the composed skill package format, +//! which bundles daemon dependencies, instructions, and triggers. + +use anyhow::{bail, Context, Result}; +use colored::Colorize; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +/// Skill manifest (skill.yaml) - the composed skill format. +#[derive(Debug, Serialize, Deserialize)] +pub struct SkillManifest { + /// Skill name (lowercase, alphanumeric with hyphens) + pub name: String, + /// Semantic version + pub version: String, + /// Brief description + pub description: String, + /// Author information + pub author: Author, + /// SPDX license identifier + #[serde(default)] + pub license: Option, + /// Repository URL + #[serde(default)] + pub repository: Option, + /// Homepage URL + #[serde(default)] + pub homepage: Option, + /// Keywords for discovery + #[serde(default)] + pub keywords: Vec, + /// Daemon dependencies + #[serde(default)] + pub daemons: Vec, + /// Agent-specific instruction files + #[serde(default)] + pub instructions: Option, + /// Trigger conditions + #[serde(default)] + pub triggers: Option, + /// Named workflows + #[serde(default)] + pub workflows: HashMap, + /// User configuration options + #[serde(default)] + pub config: HashMap, + /// Authentication requirements + #[serde(default)] + pub auth: Option, + /// Permission declarations + #[serde(default)] + pub permissions: Option, + /// Export configuration + #[serde(default)] + pub exports: Option, +} + +/// Author information - can be string or object. +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum Author { + String(String), + Object { + name: String, + #[serde(default)] + email: Option, + #[serde(default)] + url: Option, + }, +} + +/// Daemon dependency. +#[derive(Debug, Serialize, Deserialize)] +pub struct DaemonDependency { + /// Daemon name (e.g., "browser", "gmail") + pub name: String, + /// Version requirement (e.g., ">=1.0.0") + #[serde(default)] + pub version: Option, + /// Whether this daemon is optional + #[serde(default)] + pub optional: bool, + /// Specific methods used (for permissions) + #[serde(default)] + pub methods: Vec, +} + +/// Agent-specific instruction files. +#[derive(Debug, Serialize, Deserialize)] +pub struct Instructions { + #[serde(default)] + pub core: Option, + #[serde(rename = "claude-code", default)] + pub claude_code: Option, + #[serde(default)] + pub cursor: Option, + #[serde(default)] + pub codex: Option, + #[serde(default)] + pub windsurf: Option, + #[serde(default)] + pub mcp: Option, +} + +/// Trigger conditions. +#[derive(Debug, Serialize, Deserialize)] +pub struct Triggers { + #[serde(default)] + pub keywords: Vec, + #[serde(default)] + pub patterns: Vec, + #[serde(default)] + pub commands: Vec, +} + +/// Workflow reference. +#[derive(Debug, Serialize, Deserialize)] +pub struct WorkflowRef { + pub file: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub default: bool, +} + +/// Configuration option. +#[derive(Debug, Serialize, Deserialize)] +pub struct ConfigOption { + #[serde(rename = "type")] + pub config_type: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub default: Option, + #[serde(default)] + pub required: bool, + #[serde(default)] + pub options: Vec, +} + +/// Authentication configuration. +#[derive(Debug, Serialize, Deserialize)] +pub struct AuthConfig { + #[serde(default)] + pub daemons: HashMap, + #[serde(default)] + pub secrets: Vec, +} + +/// Secret configuration. +#[derive(Debug, Serialize, Deserialize)] +pub struct SecretConfig { + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub required: bool, +} + +/// Permission declarations. +#[derive(Debug, Serialize, Deserialize)] +pub struct Permissions { + #[serde(default)] + pub daemons: HashMap, + #[serde(default)] + pub network: bool, + #[serde(default)] + pub subprocess: bool, + #[serde(default)] + pub env_vars: Vec, +} + +/// Daemon permission - can be "all", "deny", or list of methods. +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub enum DaemonPermission { + All(String), + Methods(Vec), +} + +/// Export configuration. +#[derive(Debug, Serialize, Deserialize)] +pub struct Exports { + #[serde(rename = "claude-code", default)] + pub claude_code: Option, + #[serde(default)] + pub cursor: Option, + #[serde(default)] + pub mcp: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct ClaudeExport { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub skill_name: Option, + #[serde(default)] + pub triggers: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CursorExport { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub rules_file: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct McpExport { + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub tools_prefix: Option, +} + +fn default_true() -> bool { + true +} + +/// Validate a skill manifest. +pub fn validate(path: &str) -> Result<()> { + println!("{} Validating skill manifest...", "→".blue().bold()); + + let skill_path = Path::new(path); + + // Check if path exists + if !skill_path.exists() { + bail!("Path not found: {}", path); + } + + // Find skill.yaml + let manifest_path = if skill_path.is_dir() { + skill_path.join("skill.yaml") + } else { + skill_path.to_path_buf() + }; + + if !manifest_path.exists() { + // Also check for skill.yml + let alt_path = if skill_path.is_dir() { + skill_path.join("skill.yml") + } else { + skill_path.with_extension("yml") + }; + + if alt_path.exists() { + return validate_manifest(&alt_path, skill_path); + } + + bail!( + "Skill manifest not found. Expected: {}\n\ + Create a skill.yaml file with name, version, description, and author.", + manifest_path.display() + ); + } + + validate_manifest(&manifest_path, skill_path) +} + +fn validate_manifest(manifest_path: &Path, skill_dir: &Path) -> Result<()> { + // Read and parse + let content = fs::read_to_string(manifest_path) + .with_context(|| format!("Failed to read {}", manifest_path.display()))?; + + let skill: SkillManifest = serde_yaml::from_str(&content) + .with_context(|| "Invalid YAML or schema mismatch")?; + + // Validation checks + validate_name(&skill.name)?; + validate_version(&skill.version)?; + validate_description(&skill.description)?; + + let mut warnings = Vec::new(); + + // Validate daemon dependencies + validate_daemons(&skill.daemons)?; + + // Validate instruction files exist + if let Some(ref instructions) = skill.instructions { + validate_instructions(instructions, skill_dir, &mut warnings)?; + } + + // Validate workflow files exist + validate_workflows(&skill.workflows, skill_dir, &mut warnings)?; + + // Validate config options + validate_config(&skill.config)?; + + // Validate auth config + if let Some(ref auth) = skill.auth { + validate_auth(auth)?; + } + + // Success output + println!("{} Skill manifest is valid!", "✓".green().bold()); + println!(); + + // Print summary + println!("{}:", "Skill Info".cyan().bold()); + println!(" Name: {}", skill.name.white().bold()); + println!(" Version: {}", skill.version); + println!(" Description: {}", skill.description); + println!( + " Author: {}", + match &skill.author { + Author::String(s) => s.clone(), + Author::Object { name, .. } => name.clone(), + } + ); + + if !skill.daemons.is_empty() { + println!(); + println!("{}:", "Daemon Dependencies".cyan().bold()); + for daemon in &skill.daemons { + let optional = if daemon.optional { " (optional)" } else { "" }; + let version = daemon.version.as_deref().unwrap_or("any"); + println!(" - {}{} [{}]", daemon.name, optional, version); + } + } + + if !skill.workflows.is_empty() { + println!(); + println!("{}:", "Workflows".cyan().bold()); + for (name, workflow) in &skill.workflows { + let default = if workflow.default { " (default)" } else { "" }; + println!(" - {}{}", name, default); + } + } + + if let Some(ref triggers) = skill.triggers { + let total = triggers.keywords.len() + triggers.patterns.len() + triggers.commands.len(); + if total > 0 { + println!(); + println!("{}:", "Triggers".cyan().bold()); + println!( + " {} keywords, {} patterns, {} commands", + triggers.keywords.len(), + triggers.patterns.len(), + triggers.commands.len() + ); + } + } + + if let Some(ref exports) = skill.exports { + println!(); + println!("{}:", "Export Targets".cyan().bold()); + if exports.claude_code.as_ref().map(|e| e.enabled).unwrap_or(false) { + println!(" - Claude Code"); + } + if exports.cursor.as_ref().map(|e| e.enabled).unwrap_or(false) { + println!(" - Cursor"); + } + if exports.mcp.as_ref().map(|e| e.enabled).unwrap_or(false) { + println!(" - MCP"); + } + } + + // Print warnings + if !warnings.is_empty() { + println!(); + println!("{}:", "Warnings".yellow().bold()); + for warning in warnings { + println!(" {} {}", "⚠".yellow(), warning); + } + } + + Ok(()) +} + +fn validate_name(name: &str) -> Result<()> { + if name.len() < 2 { + bail!("Skill name must be at least 2 characters"); + } + if name.len() > 64 { + bail!("Skill name must be at most 64 characters"); + } + if !name.chars().next().map(|c| c.is_ascii_lowercase()).unwrap_or(false) { + bail!("Skill name must start with a lowercase letter"); + } + if !name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') { + bail!("Skill name must contain only lowercase letters, numbers, and hyphens"); + } + Ok(()) +} + +fn validate_version(version: &str) -> Result<()> { + // Simple semver check + let parts: Vec<&str> = version.split('-').next().unwrap_or(version).split('.').collect(); + if parts.len() != 3 { + bail!("Version must be semver format (e.g., 1.0.0)"); + } + for part in parts { + if part.parse::().is_err() { + bail!("Version components must be numbers"); + } + } + Ok(()) +} + +fn validate_description(description: &str) -> Result<()> { + if description.len() < 10 { + bail!("Description must be at least 10 characters"); + } + if description.len() > 500 { + bail!("Description must be at most 500 characters"); + } + Ok(()) +} + +fn validate_daemons(daemons: &[DaemonDependency]) -> Result<()> { + for daemon in daemons { + if daemon.name.is_empty() { + bail!("Daemon name cannot be empty"); + } + // Known daemons (could be expanded or loaded from registry) + let known_daemons = [ + "browser", "gmail", "calendar", "github", "imessage", "fly", "neon", "vercel", "slack", "travel", + ]; + if !known_daemons.contains(&daemon.name.as_str()) { + eprintln!( + " {} Unknown daemon '{}' - may not be available", + "⚠".yellow(), + daemon.name + ); + } + } + Ok(()) +} + +fn validate_instructions( + instructions: &Instructions, + skill_dir: &Path, + warnings: &mut Vec, +) -> Result<()> { + let mut check_file = |path: &Option, name: &str| { + if let Some(ref p) = path { + let full_path = skill_dir.join(p); + if !full_path.exists() { + warnings.push(format!("{} instruction file not found: {}", name, p)); + } + } + }; + + check_file(&instructions.core, "Core"); + check_file(&instructions.claude_code, "Claude Code"); + check_file(&instructions.cursor, "Cursor"); + check_file(&instructions.codex, "Codex"); + check_file(&instructions.windsurf, "Windsurf"); + check_file(&instructions.mcp, "MCP"); + + Ok(()) +} + +fn validate_workflows( + workflows: &HashMap, + skill_dir: &Path, + warnings: &mut Vec, +) -> Result<()> { + for (name, workflow) in workflows { + let workflow_path = skill_dir.join(&workflow.file); + if !workflow_path.exists() { + warnings.push(format!("Workflow '{}' file not found: {}", name, workflow.file)); + } + } + Ok(()) +} + +fn validate_config(config: &HashMap) -> Result<()> { + let valid_types = ["string", "number", "boolean", "enum", "array"]; + for (name, opt) in config { + if !valid_types.contains(&opt.config_type.as_str()) { + bail!( + "Invalid config type '{}' for '{}'. Valid types: {:?}", + opt.config_type, + name, + valid_types + ); + } + if opt.config_type == "enum" && opt.options.is_empty() { + bail!("Enum config '{}' must have options", name); + } + } + Ok(()) +} + +fn validate_auth(auth: &AuthConfig) -> Result<()> { + let valid_auth_values = ["required", "optional"]; + for (daemon, value) in &auth.daemons { + if !valid_auth_values.contains(&value.as_str()) { + bail!( + "Invalid auth value '{}' for daemon '{}'. Use 'required' or 'optional'", + value, + daemon + ); + } + } + + for secret in &auth.secrets { + // Validate secret name format (UPPER_SNAKE_CASE) + if !secret.name.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') { + bail!( + "Secret name '{}' must be UPPER_SNAKE_CASE", + secret.name + ); + } + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs index 11dbc51..d9a0e90 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ //! //! ```bash //! fgp agents # Detect installed AI agents +//! fgp generate # Generate a new daemon from template //! fgp new # Create a new FGP package from template //! fgp start # Start a daemon //! fgp stop # Stop a daemon @@ -35,6 +36,12 @@ enum Commands { /// Detect installed AI agents on this machine Agents, + /// Generate a new daemon from template (67 service presets available) + Generate { + #[command(subcommand)] + action: GenerateAction, + }, + /// Create a new FGP package from template New { /// Package name (e.g., "my-service") @@ -135,6 +142,12 @@ enum Commands { #[command(subcommand)] action: WorkflowAction, }, + + /// Manage FGP skills (install, update, search) + Skill { + #[command(subcommand)] + action: SkillAction, + }, } #[derive(Subcommand)] @@ -156,11 +169,215 @@ enum WorkflowAction { }, } +#[derive(Subcommand)] +enum SkillAction { + /// List installed skills + List, + + /// Search for skills in marketplaces + Search { + /// Search query + query: String, + }, + + /// Install a skill from marketplace + Install { + /// Skill name (e.g., "browser-gateway") + name: String, + + /// Specific marketplace to install from + #[arg(short, long)] + from: Option, + }, + + /// Check for skill updates + Update, + + /// Upgrade installed skills + Upgrade { + /// Specific skill to upgrade (all if not specified) + skill: Option, + }, + + /// Remove an installed skill + Remove { + /// Skill name to remove + name: String, + }, + + /// Show detailed info about a skill + Info { + /// Skill name + name: String, + }, + + /// Validate a skill manifest (skill.yaml) + Validate { + /// Path to skill directory or skill.yaml file + path: String, + }, + + /// Export skill for a specific agent (claude-code, cursor, mcp, windsurf) + Export { + /// Target agent: claude-code, cursor, codex, mcp, windsurf + target: String, + + /// Skill name or path to skill directory + skill: String, + + /// Output directory (default: current directory) + #[arg(short, long)] + output: Option, + }, + + /// Manage skill taps (GitHub-based skill repositories) + Tap { + #[command(subcommand)] + action: TapAction, + }, + + /// Manage skill marketplaces (legacy) + Marketplace { + #[command(subcommand)] + action: MarketplaceAction, + }, + + /// Manage MCP bridge registration + Mcp { + #[command(subcommand)] + action: McpAction, + }, +} + +#[derive(Subcommand)] +enum TapAction { + /// Add a GitHub tap (e.g., fast-gateway-protocol/official-skills) + Add { + /// GitHub owner/repo (e.g., "fast-gateway-protocol/official-skills") + repo: String, + }, + + /// Remove a tap + Remove { + /// Tap name to remove + name: String, + }, + + /// List all configured taps + List, + + /// Update all taps (git pull) + Update, + + /// Show skills available in a specific tap + Show { + /// Tap name + name: String, + }, +} + +#[derive(Subcommand)] +enum McpAction { + /// Register an installed skill with MCP server (and optionally other ecosystems) + Register { + /// Skill name to register + name: String, + + /// Target ecosystems (comma-separated): mcp, claude, cursor, continue, windsurf, all + #[arg(short, long, default_value = "mcp")] + target: String, + }, + + /// Register all installed skills with MCP server + RegisterAll, + + /// List MCP-registered skills + List, + + /// Show registration status for a skill across all ecosystems + Status { + /// Skill name to check + name: String, + }, +} + +#[derive(Subcommand)] +enum MarketplaceAction { + /// List configured marketplaces + List, + + /// Add a new marketplace + Add { + /// GitHub URL or marketplace name + url: String, + }, + + /// Update all marketplaces (git pull) + Update, +} + +#[derive(Subcommand)] +enum GenerateAction { + /// List all available service presets + List, + + /// Create a new daemon from a service preset + #[command(name = "new")] + NewDaemon { + /// Service name (e.g., "slack", "linear", "notion") + service: String, + + /// Use preset configuration for known services + #[arg(short, long)] + preset: bool, + + /// Human-readable display name + #[arg(long)] + display_name: Option, + + /// Base URL for the API + #[arg(long)] + api_url: Option, + + /// Environment variable name for API token + #[arg(long)] + env_token: Option, + + /// Output directory (default: current directory) + #[arg(short, long)] + output: Option, + + /// Author name for changelog entries + #[arg(long, default_value = "Claude")] + author: String, + }, +} + fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { Commands::Agents => commands::agents::run(), + Commands::Generate { action } => match action { + GenerateAction::List => commands::generate::list(), + GenerateAction::NewDaemon { + service, + preset, + display_name, + api_url, + env_token, + output, + author, + } => commands::generate::new_daemon( + &service, + preset, + display_name.as_deref(), + api_url.as_deref(), + env_token.as_deref(), + output.as_deref(), + &author, + ), + }, Commands::New { name, description, @@ -188,5 +405,44 @@ fn main() -> Result<()> { WorkflowAction::Run { file, verbose } => commands::workflow::run(&file, verbose), WorkflowAction::Validate { file } => commands::workflow::validate(&file), }, + Commands::Skill { action } => match action { + SkillAction::List => commands::skill::list(), + SkillAction::Search { query } => commands::skill::search(&query), + SkillAction::Install { name, from } => { + commands::skill::install(&name, from.as_deref()) + } + SkillAction::Update => commands::skill::check_updates(), + SkillAction::Upgrade { skill } => commands::skill::upgrade(skill.as_deref()), + SkillAction::Remove { name } => commands::skill::remove(&name), + SkillAction::Info { name } => commands::skill::info(&name), + SkillAction::Validate { path } => commands::skill_validate::validate(&path), + SkillAction::Export { target, skill, output } => { + commands::skill_export::export(&target, &skill, output.as_deref()) + } + SkillAction::Tap { action } => match action { + TapAction::Add { repo } => commands::skill_tap::add(&repo), + TapAction::Remove { name } => commands::skill_tap::remove(&name), + TapAction::List => commands::skill_tap::list(), + TapAction::Update => commands::skill_tap::update(), + TapAction::Show { name } => commands::skill_tap::show(&name), + }, + SkillAction::Marketplace { action } => match action { + MarketplaceAction::List => commands::skill::marketplace_list(), + MarketplaceAction::Add { url } => commands::skill::marketplace_add(&url), + MarketplaceAction::Update => commands::skill::marketplace_update(), + }, + SkillAction::Mcp { action } => match action { + McpAction::Register { name, target } => { + if target == "mcp" { + commands::skill::mcp_register(&name) + } else { + commands::skill::register_with_targets(&name, &target) + } + } + McpAction::RegisterAll => commands::skill::mcp_register_all(), + McpAction::List => commands::skill::mcp_list(), + McpAction::Status { name } => commands::skill::registration_status(&name), + }, + }, } }