use agent_client_protocol as acp; use serde::{Deserialize, Serialize}; use crate::util::config as cli_config; use kigi_agent::prompt::skills::{CompatConfig, SkillInfo, SkillsConfig, list_skills_with_plugins}; use super::ExtResult; /// Generic params for methods that only need an optional `cwd`. #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct CwdParams { #[serde(default)] cwd: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillsAddRequest { /// Path to add (directory or SKILL.md file). Supports `~` expansion. pub path: String, /// Working directory for skill discovery context. #[serde(default)] pub cwd: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SkillsAddResponse { /// Number of skills discovered at the added path. pub added_count: usize, /// Total number of skills loaded across all sources. pub total: usize, /// The path that was added to config. pub path: String, /// Full updated skill list after reload. pub skills: Vec, /// Human-readable message. pub message: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillsRemoveRequest { /// Path to remove from config paths. pub path: String, /// Working directory for skill discovery context. #[serde(default)] pub cwd: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SkillsRemoveResponse { /// The path that was removed. pub path: String, /// Full updated skill list after reload. pub skills: Vec, /// Human-readable message. pub message: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SkillsResetResponse { /// Full updated skill list after reload. pub skills: Vec, /// Human-readable message. pub message: String, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillsToggleRequest { /// Skill name to toggle. pub name: String, /// Whether to enable (`true`) or disable (`false`) the skill. pub enabled: bool, /// Working directory for skill discovery context. #[serde(default)] pub cwd: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillsListRequest { /// Working directory for skill discovery context. pub cwd: String, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SkillsListResponse { /// All discovered skills. pub skills: Vec, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct SkillsConfigResponse { /// Configured paths from `[skills].paths`. pub paths: Vec, /// Ignored paths from `[skills].ignore`. pub ignore: Vec, /// Total loaded skill count. pub total_skills: usize, /// Human-readable summary. pub message: String, /// Full updated skill list. pub skills: Vec, } /// Reload skills using the current config for the given working directory. #[tracing::instrument(skip_all, fields(cwd))] async fn reload_skills( cwd: &str, plugin_registry: Option<&kigi_agent::plugins::PluginRegistry>, compat: CompatConfig, ) -> Vec { let config = cli_config::load_config().await.skills; let discovery = list_skills_with_plugins(Some(cwd), &config, plugin_registry, compat); match tokio::time::timeout(std::time::Duration::from_secs(5), discovery).await { Ok(skills) => skills, Err(_) => { tracing::warn!("Skills reload timed out"); vec![] } } } /// Count how many skills have paths starting with the given prefix. fn count_skills_from(skills: &[SkillInfo], dir: &std::path::Path) -> usize { let prefix = dir.to_str().unwrap_or(""); skills.iter().filter(|s| s.path.starts_with(prefix)).count() } /// Resolve a skill path to an absolute path. /// /// Handles `~` expansion and relative path resolution against `cwd`. /// Falls back to the original string if canonicalization fails. fn resolve_skill_path(raw: &str, cwd: &str) -> String { use std::path::PathBuf; // Expand ~ to $HOME let expanded = if let Some(rest) = raw.strip_prefix("~/") { std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) .map(|home| PathBuf::from(home).join(rest)) .unwrap_or_else(|| PathBuf::from(raw)) } else if raw == "~" { std::env::var_os("HOME") .or_else(|| std::env::var_os("USERPROFILE")) .map(PathBuf::from) .unwrap_or_else(|| PathBuf::from(raw)) } else { PathBuf::from(raw) }; // If already absolute, canonicalize to resolve `..` etc. // If relative, join with cwd first. let absolute = if expanded.is_absolute() { expanded } else { PathBuf::from(cwd).join(&expanded) }; // canonicalize resolves symlinks and `..` — fall back to the joined path if it fails // (e.g. path doesn't exist yet) dunce::canonicalize(&absolute) .unwrap_or(absolute) .to_string_lossy() .to_string() } /// Collect auto-discovered skill source directories and their counts. fn discover_auto_sources(cwd: &str, skills: &[SkillInfo]) -> Vec<(String, usize)> { let cwd_path = std::path::PathBuf::from(cwd); let kigi_home = kigi_tools::util::kigi_home::kigi_home(); let git_root = git2::Repository::discover(&cwd_path) .ok() .and_then(|repo| repo.workdir().map(|p| p.to_path_buf())); // Once the user has imported, stop scanning hardcoded // .claude/skills/ paths. Equivalent locations should be opted in via // [paths] extra_skill_dirs in config.toml (written by /import-claude). let imported = crate::claude_import::is_claude_import_marked(); let local_dir_names: &[&str] = if imported { &[".kigi", ".agents"] } else { &[".kigi", ".agents", ".claude"] }; let mut sources: Vec<(String, usize)> = Vec::new(); let subdirs = ["skills", "commands"]; let mut try_add_source = |dir: std::path::PathBuf, seen: Option<&[std::path::PathBuf]>| { if dir.is_dir() && !seen.is_some_and(|s| s.contains(&dir)) { let count = count_skills_from(skills, &dir); if count > 0 { sources.push((dir.to_string_lossy().to_string(), count)); } } }; let mut local_dirs: Vec = Vec::new(); for dir_name in local_dir_names { for subdir in &subdirs { let dir = cwd_path.join(dir_name).join(subdir); try_add_source(dir.clone(), None); local_dirs.push(dir); } } if let Some(ref root) = git_root { for dir_name in local_dir_names { for subdir in &subdirs { try_add_source(root.join(dir_name).join(subdir), Some(&local_dirs)); } } } for subdir in &subdirs { try_add_source(kigi_home.join(subdir), None); } let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")); if let Some(ref h) = home { let home_path = std::path::PathBuf::from(h); for subdir in &subdirs { try_add_source(home_path.join(".agents").join(subdir), None); } if !imported { for subdir in &subdirs { try_add_source(home_path.join(".claude").join(subdir), None); } } } // [paths] extra_skill_dirs from config.toml. These supplement the built-in // scan locations. Used both standalone and as the migration target after // /import-claude when the runtime .claude/skills/ scan is disabled. for dir in extra_skill_dirs_from_config() { let path = crate::claude_import::expand_home(&dir); if path.is_dir() && !sources .iter() .any(|(s, _)| s.as_str() == path.to_string_lossy().as_ref()) { sources.push(( path.to_string_lossy().to_string(), count_skills_from(skills, &path), )); } } sources } /// Read `[paths] extra_skill_dirs` from the effective config. Returns empty /// on any read/parse failure so misconfiguration never breaks listing. fn extra_skill_dirs_from_config() -> Vec { let Ok(root) = crate::config::load_effective_config() else { return Vec::new(); }; root.get("paths") .and_then(|v| v.get("extra_skill_dirs")) .and_then(|v| v.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str().map(|s| s.to_string())) .collect() }) .unwrap_or_default() } #[tracing::instrument(skip_all, fields(method = %args.method))] pub async fn handle( args: &acp::ExtRequest, plugin_registry: Option<&kigi_agent::plugins::PluginRegistry>, compat: CompatConfig, ) -> ExtResult { match args.method.as_ref() { "x.ai/skills/add" => { let req: SkillsAddRequest = serde_json::from_str(args.params.get())?; let cwd = req.cwd.as_deref().unwrap_or("."); // Resolve to absolute path so config entries work from any cwd. let resolved = resolve_skill_path(&req.path, cwd); let p = resolved.clone(); if let Err(e) = cli_config::update_config(|cfg| { cfg.skills.ignore.retain(|i| { !(i == &p || p.starts_with(i.as_str()) || i.starts_with(p.as_str())) }); if !cfg.skills.paths.contains(&p) { cfg.skills.paths.push(p); } }) .await { return super::to_ext_response(Err::(anyhow::anyhow!( "Failed to save config: {e}" ))); } let skills = reload_skills(cwd, plugin_registry, compat).await; let added_count = skills .iter() .filter(|s| s.path.starts_with(&resolved)) .count(); let total = skills.len(); let message = format!( "Added path {}. {} new skill{} found ({} total).", resolved, added_count, if added_count == 1 { "" } else { "s" }, total, ); super::to_ext_response(Ok(SkillsAddResponse { added_count, total, path: resolved, skills, message, })) } "x.ai/skills/remove" => { let req: SkillsRemoveRequest = serde_json::from_str(args.params.get())?; let cwd = req.cwd.as_deref().unwrap_or("."); // Resolve so relative/tilde paths match what was saved by add. let resolved = resolve_skill_path(&req.path, cwd); let p = resolved.clone(); if let Err(e) = cli_config::update_config(|cfg| { cfg.skills.paths.retain(|i| i != &p); }) .await { return super::to_ext_response(Err::(anyhow::anyhow!( "Failed to save config: {e}" ))); } let skills = reload_skills(cwd, plugin_registry, compat).await; let total = skills.len(); let message = format!( "Removed path {}. {} skill{} remaining.", resolved, total, if total == 1 { "" } else { "s" }, ); super::to_ext_response(Ok(SkillsRemoveResponse { path: resolved, skills, message, })) } "x.ai/skills/reset" => { let params: CwdParams = serde_json::from_str(args.params.get()).unwrap_or(CwdParams { cwd: None }); let cwd = params.cwd.as_deref().unwrap_or("."); if let Err(e) = cli_config::update_config(|cfg| { cfg.skills = SkillsConfig::default(); }) .await { return super::to_ext_response(Err::(anyhow::anyhow!( "Failed to save config: {e}" ))); } let skills = reload_skills(cwd, plugin_registry, compat).await; let message = "Custom skills config reset".to_string(); super::to_ext_response(Ok(SkillsResetResponse { skills, message })) } "x.ai/skills/list" => { let req: SkillsListRequest = serde_json::from_str(args.params.get())?; let skills = reload_skills(&req.cwd, plugin_registry, compat).await; super::to_ext_response(Ok(SkillsListResponse { skills })) } "x.ai/skills/config" => { let params: CwdParams = serde_json::from_str(args.params.get()).unwrap_or(CwdParams { cwd: None }); let cwd = params.cwd.as_deref().unwrap_or("."); let config = cli_config::load_config().await.skills; let paths = config.paths.clone(); let ignore = config.ignore.clone(); let skills = reload_skills(cwd, plugin_registry, compat).await; let total_skills = skills.len(); let auto_sources = discover_auto_sources(cwd, &skills); let mut msg = String::new(); msg.push_str("Skill discovery sources:\n"); for (source, count) in &auto_sources { msg.push_str(&format!( " • {} ({} skill{})\n", source, count, if *count == 1 { "" } else { "s" } )); } if auto_sources.is_empty() { msg.push_str(" (no auto-discovered directories found)\n"); } if !paths.is_empty() { msg.push_str("\nCustom paths:\n"); for p in &paths { let count = skills .iter() .filter(|s| s.path.starts_with(p.as_str())) .count(); msg.push_str(&format!( " • {} ({} skill{})\n", p, count, if count == 1 { "" } else { "s" } )); } } if !ignore.is_empty() { msg.push_str("\nIgnored:\n"); for p in &ignore { msg.push_str(&format!(" • {}\n", p)); } } msg.push_str(&format!("\nTotal skills loaded: {}", total_skills)); super::to_ext_response(Ok(SkillsConfigResponse { paths, ignore, total_skills, message: msg, skills, })) } "x.ai/skills/toggle" => { let req: SkillsToggleRequest = serde_json::from_str(args.params.get())?; let cwd = req.cwd.as_deref().unwrap_or("."); // Validate the skill name exists before modifying config. let current_skills = reload_skills(cwd, plugin_registry, compat).await; if !current_skills.iter().any(|s| s.name == req.name) { return super::to_ext_response(Err::(anyhow::anyhow!( "Skill '{}' not found", req.name ))); } let name = req.name.clone(); let enabled = req.enabled; if let Err(e) = cli_config::update_config(|cfg| { if enabled { cfg.skills.disabled.retain(|d| d != &name); } else if !cfg.skills.disabled.contains(&name) { cfg.skills.disabled.push(name.clone()); } }) .await { return super::to_ext_response(Err::(anyhow::anyhow!( "Failed to save config: {e}" ))); } // Re-apply disabled marking against the already-loaded skills // to reflect the config change without a second full discovery. let config = cli_config::load_config().await.skills; let disabled_set: std::collections::HashSet<&str> = config.disabled.iter().map(|s| s.as_str()).collect(); let skills: Vec = current_skills .into_iter() .map(|mut s| { s.enabled = !disabled_set.contains(s.name.as_str()); s }) .collect(); super::to_ext_response(Ok(SkillsListResponse { skills })) } _ => Err(acp::Error::method_not_found()), } } #[cfg(test)] mod tests { use super::*; #[test] fn test_add_request_with_cwd() { let json = r#"{"path": "/home/user/skills", "cwd": "/project"}"#; let req: SkillsAddRequest = serde_json::from_str(json).unwrap(); assert_eq!(req.path, "/home/user/skills"); assert_eq!(req.cwd, Some("/project".to_string())); } #[test] fn test_add_request_without_cwd() { let json = r#"{"path": "~/my-skills"}"#; let req: SkillsAddRequest = serde_json::from_str(json).unwrap(); assert_eq!(req.path, "~/my-skills"); assert_eq!(req.cwd, None); } #[test] fn test_remove_request() { let json = r#"{"path": "/home/user/skills", "cwd": "/project"}"#; let req: SkillsRemoveRequest = serde_json::from_str(json).unwrap(); assert_eq!(req.path, "/home/user/skills"); assert_eq!(req.cwd, Some("/project".to_string())); } #[test] fn test_list_request() { let json = r#"{"cwd": "/project"}"#; let req: SkillsListRequest = serde_json::from_str(json).unwrap(); assert_eq!(req.cwd, "/project"); } #[test] fn test_add_response_camel_case() { let resp = SkillsAddResponse { added_count: 3, total: 10, path: "/test".to_string(), skills: vec![], message: "ok".to_string(), }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["addedCount"], 3); assert_eq!(json["total"], 10); assert_eq!(json["path"], "/test"); } #[test] fn test_resolve_absolute_path_unchanged() { let resolved = resolve_skill_path("/absolute/path/to/skills", "/some/cwd"); // Canonicalize will fail (path doesn't exist), so we get the joined absolute path assert_eq!(resolved, "/absolute/path/to/skills"); } #[test] fn test_resolve_relative_path_against_cwd() { let tmp = tempfile::tempdir().unwrap(); let sub = tmp.path().join("sub"); std::fs::create_dir(&sub).unwrap(); let resolved = resolve_skill_path("sub", &tmp.path().to_string_lossy()); assert_eq!( resolved, dunce::canonicalize(&sub).unwrap().to_string_lossy() ); } #[test] fn test_resolve_dotdot_path() { let tmp = tempfile::tempdir().unwrap(); let cwd = tmp.path().join("a").join("b"); std::fs::create_dir_all(&cwd).unwrap(); let resolved = resolve_skill_path("../..", &cwd.to_string_lossy()); assert_eq!( resolved, dunce::canonicalize(tmp.path()).unwrap().to_string_lossy() ); } /// Hermetic tilde expansion: pin HOME to a temp dir so remote sandboxes /// (missing HOME, symlink-resolved homes, pre-existing ~/my-skills) cannot /// make `starts_with($HOME)` fail spuriously. Serial because env mutation /// is process-global. #[test] #[serial_test::serial] fn test_resolve_tilde_path() { let tmp = tempfile::tempdir().unwrap(); let home = tmp.path().to_path_buf(); let prev_home = std::env::var_os("HOME"); let prev_userprofile = std::env::var_os("USERPROFILE"); // SAFETY: serial test; restored in the same scope below. unsafe { std::env::set_var("HOME", &home); std::env::remove_var("USERPROFILE"); } let resolved = resolve_skill_path("~/my-skills", "/ignored"); match prev_home { Some(v) => unsafe { std::env::set_var("HOME", v) }, None => unsafe { std::env::remove_var("HOME") }, } match prev_userprofile { Some(v) => unsafe { std::env::set_var("USERPROFILE", v) }, None => unsafe { std::env::remove_var("USERPROFILE") }, } let expected = home.join("my-skills"); assert_eq!( std::path::PathBuf::from(&resolved), expected, "resolved={resolved}" ); } #[test] fn test_config_response_camel_case() { let resp = SkillsConfigResponse { paths: vec!["/a".into()], ignore: vec![], total_skills: 5, message: "ok".to_string(), skills: vec![], }; let json = serde_json::to_value(&resp).unwrap(); assert_eq!(json["totalSkills"], 5); assert!(json["paths"].is_array()); } }