Skip to main content

sc/cli/commands/
skills.rs

1//! Skills install and management commands.
2//!
3//! Downloads skills, hooks, and status line scripts from GitHub
4//! and installs them for detected AI coding tools (Claude Code, Codex, Gemini, Factory AI).
5//!
6//! This enables Rust CLI users to get full skill/hook support without
7//! needing npm/bun or cloning the repository.
8
9use crate::cli::SkillsCommands;
10use crate::error::{Error, Result};
11use serde::{Deserialize, Serialize};
12use std::fs;
13use std::path::{Path, PathBuf};
14use std::process::Command;
15use tracing::{debug, info, warn};
16
17// ── Constants ────────────────────────────────────────────────
18
19/// GitHub raw content base URL for skill files.
20const GITHUB_RAW_BASE: &str =
21    "https://raw.githubusercontent.com/shaneholloman/savecontext-mono/main/savecontext/server";
22
23/// Skill files relative to the server directory.
24/// CLI mode skills.
25const CLI_SKILL_FILES: &[&str] = &[
26    "skills/SaveContext-CLI/SKILL.md",
27    "skills/SaveContext-CLI/Workflows/QuickSave.md",
28    "skills/SaveContext-CLI/Workflows/SessionStart.md",
29    "skills/SaveContext-CLI/Workflows/Resume.md",
30    "skills/SaveContext-CLI/Workflows/WrapUp.md",
31    "skills/SaveContext-CLI/Workflows/Compaction.md",
32    "skills/SaveContext-CLI/Workflows/FeatureLifecycle.md",
33    "skills/SaveContext-CLI/Workflows/IssueTracking.md",
34    "skills/SaveContext-CLI/Workflows/Planning.md",
35    "skills/SaveContext-CLI/Workflows/AdvancedWorkflows.md",
36    "skills/SaveContext-CLI/Workflows/Reference.md",
37    "skills/SaveContext-CLI/Workflows/Prime.md",
38];
39
40/// MCP mode skills.
41const MCP_SKILL_FILES: &[&str] = &[
42    "skills/SaveContext-MCP/SKILL.md",
43    "skills/SaveContext-MCP/Workflows/QuickSave.md",
44    "skills/SaveContext-MCP/Workflows/SessionStart.md",
45    "skills/SaveContext-MCP/Workflows/Resume.md",
46    "skills/SaveContext-MCP/Workflows/WrapUp.md",
47    "skills/SaveContext-MCP/Workflows/Compaction.md",
48    "skills/SaveContext-MCP/Workflows/FeatureLifecycle.md",
49    "skills/SaveContext-MCP/Workflows/IssueTracking.md",
50    "skills/SaveContext-MCP/Workflows/Planning.md",
51    "skills/SaveContext-MCP/Workflows/AdvancedWorkflows.md",
52    "skills/SaveContext-MCP/Workflows/Reference.md",
53];
54
55/// Hook and status line scripts.
56const HOOK_FILES: &[&str] = &[
57    "scripts/statusline.py",
58    "scripts/update-status-cache.py",
59    "scripts/statusline.json",
60];
61
62/// Known tool directories and their skill installation paths.
63const KNOWN_TOOLS: &[(&str, &str)] = &[
64    ("claude-code", ".claude/skills"),
65    ("codex", ".codex/skills"),
66    ("gemini", ".gemini/skills"),
67    ("factory-ai", ".factory/skills"),
68];
69
70// ── Types ────────────────────────────────────────────────────
71
72/// A detected AI coding tool on this machine.
73struct DetectedTool {
74    name: String,
75    skills_dir: PathBuf,
76}
77
78/// Tracks installed skills (compatible with TypeScript skill-sync.json).
79#[derive(Debug, Serialize, Deserialize, Default)]
80struct SkillSyncConfig {
81    installations: Vec<SkillInstallation>,
82}
83
84#[derive(Debug, Serialize, Deserialize)]
85struct SkillInstallation {
86    tool: String,
87    path: String,
88    #[serde(rename = "installedAt")]
89    installed_at: u64,
90    mode: String,
91}
92
93/// Result of an install operation for JSON output.
94#[derive(Debug, Serialize)]
95struct InstallResult {
96    success: bool,
97    tools: Vec<ToolInstallResult>,
98    hooks_installed: bool,
99    settings_configured: bool,
100    python_found: Option<String>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    error: Option<String>,
103}
104
105#[derive(Debug, Serialize)]
106struct ToolInstallResult {
107    tool: String,
108    path: String,
109    files_installed: usize,
110    modes: Vec<String>,
111}
112
113// ── Entry Points ─────────────────────────────────────────────
114
115/// Execute skills commands.
116pub fn execute(command: &SkillsCommands, json: bool) -> Result<()> {
117    match command {
118        SkillsCommands::Install { tool, mode, path } => install(tool.as_deref(), mode, path.as_deref(), json),
119        SkillsCommands::Status => status(json),
120        SkillsCommands::Update { tool } => update(tool.as_deref(), json),
121    }
122}
123
124fn install(tool: Option<&str>, mode: &str, path_override: Option<&Path>, json: bool) -> Result<()> {
125    let modes = parse_modes(mode)?;
126    let home = home_dir()?;
127
128    // Detect or filter tools
129    let mut tools = if let Some(name) = tool {
130        let t = resolve_tool(name, &home)?;
131        vec![t]
132    } else {
133        detect_tools(&home)
134    };
135
136    // Apply custom path override if provided
137    if let Some(override_path) = path_override {
138        for detected_tool in &mut tools {
139            detected_tool.skills_dir = override_path.to_path_buf();
140        }
141    }
142
143    if tools.is_empty() {
144        if json {
145            let output = serde_json::json!({
146                "success": false,
147                "error": "No AI coding tools detected. Install Claude Code, Codex, Gemini, or Factory AI first.",
148                "tools": []
149            });
150            println!("{}", serde_json::to_string(&output)?);
151            return Ok(());
152        }
153        return Err(Error::SkillInstall(
154            "No AI coding tools detected. Install Claude Code, Codex, Gemini, or Factory AI first."
155                .to_string(),
156        ));
157    }
158
159    if !json {
160        println!("Installing SaveContext skills...");
161        println!();
162    }
163
164    let rt = tokio::runtime::Runtime::new()
165        .map_err(|e| Error::SkillInstall(format!("Failed to create async runtime: {e}")))?;
166
167    let mut result = InstallResult {
168        success: true,
169        tools: Vec::new(),
170        hooks_installed: false,
171        settings_configured: false,
172        python_found: None,
173        error: None,
174    };
175
176    // Create a shared HTTP client for connection reuse across all downloads
177    let client = reqwest::Client::builder()
178        .timeout(std::time::Duration::from_secs(30))
179        .build()
180        .map_err(|e| Error::Download(format!("HTTP client error: {e}")))?;
181
182    // Download and install skills for each tool
183    for detected in &tools {
184        match rt.block_on(install_skills_for_tool(detected, &modes, &client)) {
185            Ok(tool_result) => {
186                if !json {
187                    println!(
188                        "  {} — {} files installed ({})",
189                        detected.name,
190                        tool_result.files_installed,
191                        tool_result.modes.join(", ")
192                    );
193                }
194                result.tools.push(tool_result);
195            }
196            Err(e) => {
197                if !json {
198                    eprintln!("  {} — failed: {e}", detected.name);
199                }
200                result.success = false;
201                result.error = Some(e.to_string());
202            }
203        }
204    }
205
206    // Install hooks
207    match rt.block_on(install_hooks(&home, &client)) {
208        Ok(()) => {
209            result.hooks_installed = true;
210            if !json {
211                println!("  Hooks installed to ~/.savecontext/hooks/");
212            }
213        }
214        Err(e) => {
215            if !json {
216                eprintln!("  Hooks failed: {e}");
217            }
218        }
219    }
220
221    // Configure Claude Code settings (if Claude Code is among the tools)
222    let python = find_python();
223    result.python_found = python.clone();
224
225    let has_claude = tools.iter().any(|t| t.name == "claude-code");
226    if has_claude {
227        if let Some(ref py) = python {
228            match configure_claude_settings(py, &home) {
229                Ok(()) => {
230                    result.settings_configured = true;
231                    if !json {
232                        println!("  Claude Code settings.json updated (statusline + hooks)");
233                    }
234                }
235                Err(e) => {
236                    if !json {
237                        eprintln!("  Claude Code settings update failed: {e}");
238                    }
239                }
240            }
241        } else if !json {
242            println!("  Warning: Python not found. Hooks require Python 3.");
243            println!("  Install Python and re-run: sc skills install");
244        }
245    }
246
247    // Update skill-sync.json tracking
248    update_sync_config(&tools, &modes);
249
250    if json {
251        println!("{}", serde_json::to_string(&result)?);
252    } else {
253        println!();
254        if result.success {
255            println!("Skills installed successfully.");
256        } else {
257            println!("Skills installed with errors. Check output above.");
258        }
259    }
260
261    Ok(())
262}
263
264fn status(json: bool) -> Result<()> {
265    let config = load_sync_config();
266
267    if json {
268        let output = serde_json::json!({
269            "installations": config.installations,
270        });
271        println!("{}", serde_json::to_string(&output)?);
272    } else if config.installations.is_empty() {
273        println!("No skills installed.");
274        println!("Run: sc skills install");
275    } else {
276        println!("Installed skills:");
277        println!();
278        for inst in &config.installations {
279            let ts = chrono::DateTime::from_timestamp_millis(inst.installed_at as i64)
280                .map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
281                .unwrap_or_else(|| "unknown".to_string());
282            println!("  {} — mode: {}, installed: {}", inst.tool, inst.mode, ts);
283            println!("    {}", inst.path);
284        }
285    }
286    Ok(())
287}
288
289fn update(tool: Option<&str>, json: bool) -> Result<()> {
290    // Update is just re-install
291    install(tool, "both", None, json)
292}
293
294// ── Helpers ──────────────────────────────────────────────────
295
296fn home_dir() -> Result<PathBuf> {
297    directories::BaseDirs::new()
298        .map(|b| b.home_dir().to_path_buf())
299        .ok_or_else(|| Error::SkillInstall("Could not determine home directory".to_string()))
300}
301
302fn parse_modes(mode: &str) -> Result<Vec<String>> {
303    match mode.to_lowercase().as_str() {
304        "both" => Ok(vec!["cli".to_string(), "mcp".to_string()]),
305        "cli" => Ok(vec!["cli".to_string()]),
306        "mcp" => Ok(vec!["mcp".to_string()]),
307        other => Err(Error::InvalidArgument(format!(
308            "Invalid mode '{other}'. Use: cli, mcp, or both"
309        ))),
310    }
311}
312
313fn detect_tools(home: &Path) -> Vec<DetectedTool> {
314    let mut tools = Vec::new();
315    for (name, rel_path) in KNOWN_TOOLS {
316        // Check if the tool's config directory exists (e.g., ~/.claude/)
317        let config_dir = home.join(rel_path.split('/').next().unwrap_or(""));
318        if config_dir.exists() {
319            let skills_dir = home.join(rel_path);
320            debug!(tool = name, path = %skills_dir.display(), "Detected tool");
321            tools.push(DetectedTool {
322                name: name.to_string(),
323                skills_dir,
324            });
325        }
326    }
327    tools
328}
329
330fn resolve_tool(name: &str, home: &Path) -> Result<DetectedTool> {
331    // Normalize tool name
332    let normalized = match name.to_lowercase().as_str() {
333        "claude" | "claude-code" | "claudecode" => "claude-code",
334        "codex" | "codex-cli" => "codex",
335        "gemini" | "gemini-cli" => "gemini",
336        "factory" | "factory-ai" | "factoryai" => "factory-ai",
337        other => {
338            return Err(Error::InvalidArgument(format!(
339                "Unknown tool '{other}'. Supported: claude-code, codex, gemini, factory-ai"
340            )));
341        }
342    };
343
344    let (_, rel_path) = KNOWN_TOOLS
345        .iter()
346        .find(|(n, _)| *n == normalized)
347        .unwrap();
348
349    Ok(DetectedTool {
350        name: normalized.to_string(),
351        skills_dir: home.join(rel_path),
352    })
353}
354
355async fn download_file(relative_path: &str, client: &reqwest::Client) -> Result<String> {
356    let url = format!("{GITHUB_RAW_BASE}/{relative_path}");
357    debug!(url = %url, "Downloading");
358
359    let response = client
360        .get(&url)
361        .header("User-Agent", "savecontext-cli")
362        .send()
363        .await
364        .map_err(|e| Error::Download(format!("Failed to fetch {url}: {e}")))?;
365
366    if !response.status().is_success() {
367        return Err(Error::Download(format!(
368            "HTTP {} for {url}",
369            response.status()
370        )));
371    }
372
373    response
374        .text()
375        .await
376        .map_err(|e| Error::Download(format!("Failed to read response from {url}: {e}")))
377}
378
379async fn install_skills_for_tool(
380    tool: &DetectedTool,
381    modes: &[String],
382    client: &reqwest::Client,
383) -> Result<ToolInstallResult> {
384    let mut files_installed = 0;
385    let mut installed_modes = Vec::new();
386
387    for mode in modes {
388        let file_list = match mode.as_str() {
389            "cli" => CLI_SKILL_FILES,
390            "mcp" => MCP_SKILL_FILES,
391            _ => continue,
392        };
393
394        for relative_path in file_list {
395            let content = download_file(relative_path, client).await?;
396
397            // Map skill path: "skills/SaveContext-CLI/SKILL.md"
398            // → ~/.claude/skills/SaveContext-CLI/SKILL.md
399            let dest = tool.skills_dir.join(
400                relative_path
401                    .strip_prefix("skills/")
402                    .unwrap_or(relative_path),
403            );
404
405            // Create parent directories
406            if let Some(parent) = dest.parent() {
407                fs::create_dir_all(parent).map_err(|e| {
408                    Error::SkillInstall(format!("Failed to create directory {}: {e}", parent.display()))
409                })?;
410            }
411
412            fs::write(&dest, content).map_err(|e| {
413                Error::SkillInstall(format!("Failed to write {}: {e}", dest.display()))
414            })?;
415
416            files_installed += 1;
417        }
418
419        installed_modes.push(mode.clone());
420    }
421
422    // Clean up legacy skill directories (same as TypeScript setup)
423    cleanup_legacy_skills(&tool.skills_dir);
424
425    Ok(ToolInstallResult {
426        tool: tool.name.clone(),
427        path: tool.skills_dir.display().to_string(),
428        files_installed,
429        modes: installed_modes,
430    })
431}
432
433/// Remove old-style skill directories that predate the CLI/MCP split.
434fn cleanup_legacy_skills(skills_dir: &Path) {
435    for legacy_name in &["savecontext", "SaveContext"] {
436        let legacy_path = skills_dir.join(legacy_name);
437        if legacy_path.exists() {
438            info!(path = %legacy_path.display(), "Removing legacy skill directory");
439            let _ = fs::remove_dir_all(&legacy_path);
440        }
441    }
442}
443
444async fn install_hooks(home: &Path, client: &reqwest::Client) -> Result<()> {
445    let hooks_dir = home.join(".savecontext").join("hooks");
446    let sc_dir = home.join(".savecontext");
447
448    fs::create_dir_all(&hooks_dir)
449        .map_err(|e| Error::SkillInstall(format!("Failed to create hooks dir: {e}")))?;
450
451    for relative_path in HOOK_FILES {
452        let content = download_file(relative_path, client).await?;
453
454        // "scripts/statusline.py" → ~/.savecontext/statusline.py (status line at root)
455        // "scripts/update-status-cache.py" → ~/.savecontext/hooks/update-status-cache.py
456        // "scripts/statusline.json" → ~/.savecontext/statusline.json (at root)
457        let filename = Path::new(relative_path)
458            .file_name()
459            .unwrap_or_default()
460            .to_str()
461            .unwrap_or_default();
462
463        let dest = if filename == "update-status-cache.py" {
464            hooks_dir.join(filename)
465        } else {
466            sc_dir.join(filename)
467        };
468
469        fs::write(&dest, &content).map_err(|e| {
470            Error::SkillInstall(format!("Failed to write {}: {e}", dest.display()))
471        })?;
472
473        // Make Python scripts executable on Unix
474        #[cfg(unix)]
475        if filename.ends_with(".py") {
476            use std::os::unix::fs::PermissionsExt;
477            let _ = fs::set_permissions(&dest, fs::Permissions::from_mode(0o755));
478        }
479    }
480
481    Ok(())
482}
483
484fn find_python() -> Option<String> {
485    for cmd in &["python3", "python"] {
486        if let Ok(output) = Command::new(cmd).arg("--version").output() {
487            if output.status.success() {
488                let version = String::from_utf8_lossy(&output.stdout);
489                let version = version.trim();
490                // Ensure it's Python 3
491                if version.contains("Python 3") {
492                    return Some(cmd.to_string());
493                }
494                // Check stderr too (some Python versions print to stderr)
495                let stderr_ver = String::from_utf8_lossy(&output.stderr);
496                if stderr_ver.trim().contains("Python 3") {
497                    return Some(cmd.to_string());
498                }
499            }
500        }
501    }
502    warn!("Python 3 not found in PATH");
503    None
504}
505
506fn configure_claude_settings(python_cmd: &str, home: &Path) -> Result<()> {
507    let settings_path = home.join(".claude").join("settings.json");
508    let hook_dest = home
509        .join(".savecontext")
510        .join("hooks")
511        .join("update-status-cache.py");
512    let statusline_dest = home.join(".savecontext").join("statusline.py");
513
514    // Read existing settings or start fresh
515    let mut settings: serde_json::Value = if settings_path.exists() {
516        let content = fs::read_to_string(&settings_path)
517            .map_err(|e| Error::Config(format!("Failed to read settings.json: {e}")))?;
518        match serde_json::from_str(&content) {
519            Ok(v) => v,
520            Err(e) => {
521                return Err(Error::Config(format!(
522                    "Cannot parse existing settings.json: {e}. \
523                     Fix the JSON syntax and re-run: sc skills install"
524                )));
525            }
526        }
527    } else {
528        serde_json::json!({})
529    };
530
531    // Set statusLine (matches Claude Code expected format)
532    settings["statusLine"] = serde_json::json!({
533        "command": format!("{python_cmd} {}", statusline_dest.display()),
534        "refreshSeconds": 3,
535    });
536
537    // Configure hooks — preserve existing, add/update SaveContext hook
538    if settings.get("hooks").is_none() {
539        settings["hooks"] = serde_json::json!({});
540    }
541    if settings["hooks"].get("PostToolUse").is_none() {
542        settings["hooks"]["PostToolUse"] = serde_json::json!([]);
543    }
544
545    // Remove any existing SaveContext hook
546    if let Some(arr) = settings["hooks"]["PostToolUse"].as_array_mut() {
547        arr.retain(|hook| {
548            hook.get("matcher")
549                .and_then(|m| m.as_str())
550                .map_or(true, |m| m != "mcp__savecontext__.*")
551        });
552
553        // Add SaveContext hook
554        arr.push(serde_json::json!({
555            "matcher": "mcp__savecontext__.*",
556            "hooks": [{
557                "type": "command",
558                "command": format!("{python_cmd} {}", hook_dest.display()),
559                "timeout": 10
560            }]
561        }));
562    }
563
564    // Write settings back
565    if let Some(parent) = settings_path.parent() {
566        fs::create_dir_all(parent)
567            .map_err(|e| Error::Config(format!("Failed to create .claude dir: {e}")))?;
568    }
569
570    let json_str = serde_json::to_string_pretty(&settings)?;
571    fs::write(&settings_path, format!("{json_str}\n"))
572        .map_err(|e| Error::Config(format!("Failed to write settings.json: {e}")))?;
573
574    debug!(path = %settings_path.display(), "Updated Claude Code settings");
575    Ok(())
576}
577
578fn sync_config_path() -> PathBuf {
579    directories::BaseDirs::new()
580        .map(|b| b.home_dir().join(".savecontext").join("skill-sync.json"))
581        .unwrap_or_else(|| PathBuf::from(".savecontext/skill-sync.json"))
582}
583
584fn load_sync_config() -> SkillSyncConfig {
585    let path = sync_config_path();
586    if path.exists() {
587        fs::read_to_string(&path)
588            .ok()
589            .and_then(|s| serde_json::from_str(&s).ok())
590            .unwrap_or_default()
591    } else {
592        SkillSyncConfig::default()
593    }
594}
595
596fn update_sync_config(tools: &[DetectedTool], modes: &[String]) {
597    let mut config = load_sync_config();
598    let now = std::time::SystemTime::now()
599        .duration_since(std::time::UNIX_EPOCH)
600        .unwrap_or_default()
601        .as_millis() as u64;
602
603    let mode_str = if modes.len() > 1 {
604        "both".to_string()
605    } else {
606        modes.first().cloned().unwrap_or_else(|| "both".to_string())
607    };
608
609    for tool in tools {
610        // Remove existing entry for this tool
611        config
612            .installations
613            .retain(|i| i.tool != tool.name);
614
615        config.installations.push(SkillInstallation {
616            tool: tool.name.clone(),
617            path: tool.skills_dir.display().to_string(),
618            installed_at: now,
619            mode: mode_str.clone(),
620        });
621    }
622
623    let path = sync_config_path();
624    if let Some(parent) = path.parent() {
625        let _ = fs::create_dir_all(parent);
626    }
627    if let Ok(json_str) = serde_json::to_string_pretty(&config) {
628        let _ = fs::write(&path, format!("{json_str}\n"));
629    }
630}