1use 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
17const GITHUB_RAW_BASE: &str =
21 "https://raw.githubusercontent.com/shaneholloman/savecontext-mono/main/savecontext/server";
22
23const 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
40const 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
55const HOOK_FILES: &[&str] = &[
57 "scripts/statusline.py",
58 "scripts/update-status-cache.py",
59 "scripts/statusline.json",
60];
61
62const KNOWN_TOOLS: &[(&str, &str)] = &[
64 ("claude-code", ".claude/skills"),
65 ("codex", ".codex/skills"),
66 ("gemini", ".gemini/skills"),
67 ("factory-ai", ".factory/skills"),
68];
69
70struct DetectedTool {
74 name: String,
75 skills_dir: PathBuf,
76}
77
78#[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#[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
113pub 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 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 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 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 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 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 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_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 install(tool, "both", None, json)
292}
293
294fn 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 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 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 let dest = tool.skills_dir.join(
400 relative_path
401 .strip_prefix("skills/")
402 .unwrap_or(relative_path),
403 );
404
405 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 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
433fn 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 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 #[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 if version.contains("Python 3") {
492 return Some(cmd.to_string());
493 }
494 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 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 settings["statusLine"] = serde_json::json!({
533 "command": format!("{python_cmd} {}", statusline_dest.display()),
534 "refreshSeconds": 3,
535 });
536
537 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 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 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 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 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}