Skip to main content

skill_harness/
manage.rs

1//! Skill management — install/check/uninstall SKILL.md files for agent environments.
2//!
3//! CLI tools bundle a SKILL.md via `include_str!` and use this module to install
4//! it to the appropriate location for the active agent environment.
5
6use anyhow::{Context, Result};
7use std::path::{Path, PathBuf};
8
9/// Configuration for a skill to be managed.
10pub struct SkillConfig {
11    /// The tool name (e.g., "agent-doc", "webmaster").
12    pub name: String,
13    /// The bundled SKILL.md content (typically from `include_str!`).
14    pub content: String,
15    /// The tool version (typically from `env!("CARGO_PKG_VERSION")`).
16    pub version: String,
17    /// The relative path resolver for the target environment.
18    pub path_resolver: Box<dyn Fn(&str) -> PathBuf + Send + Sync>,
19}
20
21impl SkillConfig {
22    /// Create a new skill config with a custom path resolver.
23    pub fn new(
24        name: impl Into<String>,
25        content: impl Into<String>,
26        version: impl Into<String>,
27        path_resolver: impl Fn(&str) -> PathBuf + Send + Sync + 'static,
28    ) -> Self {
29        Self {
30            name: name.into(),
31            content: content.into(),
32            version: version.into(),
33            path_resolver: Box::new(path_resolver),
34        }
35    }
36
37    /// Create a skill config that installs to `.agent/skills/<name>/SKILL.md`.
38    pub fn generic(
39        name: impl Into<String>,
40        content: impl Into<String>,
41        version: impl Into<String>,
42    ) -> Self {
43        Self::new(name, content, version, |name| {
44            PathBuf::from(format!(".agent/skills/{name}/SKILL.md"))
45        })
46    }
47
48    /// Resolve the skill file path under the given root (or CWD if None).
49    pub fn skill_path(&self, root: Option<&Path>) -> PathBuf {
50        let rel = (self.path_resolver)(&self.name);
51        match root {
52            Some(r) => r.join(rel),
53            None => rel,
54        }
55    }
56
57    /// Install the bundled SKILL.md to the project.
58    pub fn install(&self, root: Option<&Path>) -> Result<()> {
59        let path = self.skill_path(root);
60
61        if path.exists() {
62            let existing = std::fs::read_to_string(&path)
63                .with_context(|| format!("failed to read {}", path.display()))?;
64            if existing == self.content {
65                eprintln!("Skill already up to date (v{}).", self.version);
66                return Ok(());
67            }
68        }
69
70        if let Some(parent) = path.parent() {
71            std::fs::create_dir_all(parent)
72                .with_context(|| format!("failed to create {}", parent.display()))?;
73        }
74
75        std::fs::write(&path, &self.content)
76            .with_context(|| format!("failed to write {}", path.display()))?;
77        eprintln!("Installed skill v{} → {}", self.version, path.display());
78
79        Ok(())
80    }
81
82    /// Check if the installed skill matches the bundled version.
83    pub fn check(&self, root: Option<&Path>) -> Result<bool> {
84        let path = self.skill_path(root);
85
86        if !path.exists() {
87            eprintln!("Not installed. Run `{} skill install` to install.", self.name);
88            return Ok(false);
89        }
90
91        let existing = std::fs::read_to_string(&path)
92            .with_context(|| format!("failed to read {}", path.display()))?;
93
94        if existing == self.content {
95            eprintln!("Up to date (v{}).", self.version);
96            Ok(true)
97        } else {
98            eprintln!(
99                "Outdated. Run `{} skill install` to update to v{}.",
100                self.name, self.version
101            );
102            Ok(false)
103        }
104    }
105
106    /// Uninstall the skill file and its parent directory (if empty).
107    pub fn uninstall(&self, root: Option<&Path>) -> Result<()> {
108        let path = self.skill_path(root);
109
110        if !path.exists() {
111            eprintln!("Skill not installed.");
112            return Ok(());
113        }
114
115        std::fs::remove_file(&path)
116            .with_context(|| format!("failed to remove {}", path.display()))?;
117
118        if let Some(parent) = path.parent()
119            && parent.read_dir().is_ok_and(|mut d| d.next().is_none())
120        {
121            let _ = std::fs::remove_dir(parent);
122        }
123
124        eprintln!("Uninstalled skill from {}", path.display());
125        Ok(())
126    }
127}
128
129/// Create a SkillConfig that uses agent-kit Environment for path resolution.
130#[cfg(feature = "detect")]
131pub fn skill_for_environment(
132    name: impl Into<String>,
133    content: impl Into<String>,
134    version: impl Into<String>,
135) -> SkillConfig {
136    let env = agent_kit::detect::Environment::detect();
137    let name_str = name.into();
138    let name_clone = name_str.clone();
139    SkillConfig {
140        name: name_str,
141        content: content.into(),
142        version: version.into(),
143        path_resolver: Box::new(move |_| env.skill_rel_path(&name_clone)),
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    fn test_config() -> SkillConfig {
152        SkillConfig::new(
153            "test-tool",
154            "# Test Skill\n\nSome content.\n",
155            "1.0.0",
156            |name| PathBuf::from(format!(".claude/skills/{name}/SKILL.md")),
157        )
158    }
159
160    #[test]
161    fn skill_path_with_root() {
162        let config = test_config();
163        let path = config.skill_path(Some(Path::new("/project")));
164        assert_eq!(
165            path,
166            PathBuf::from("/project/.claude/skills/test-tool/SKILL.md")
167        );
168    }
169
170    #[test]
171    fn skill_path_without_root() {
172        let config = test_config();
173        let path = config.skill_path(None);
174        assert_eq!(
175            path,
176            PathBuf::from(".claude/skills/test-tool/SKILL.md")
177        );
178    }
179
180    #[test]
181    fn generic_skill_path() {
182        let config = SkillConfig::generic("my-tool", "content", "1.0.0");
183        let path = config.skill_path(None);
184        assert_eq!(
185            path,
186            PathBuf::from(".agent/skills/my-tool/SKILL.md")
187        );
188    }
189
190    #[test]
191    fn install_creates_file() {
192        let dir = tempfile::tempdir().unwrap();
193        let config = test_config();
194        config.install(Some(dir.path())).unwrap();
195
196        let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
197        assert!(path.exists());
198        let content = std::fs::read_to_string(&path).unwrap();
199        assert_eq!(content, config.content);
200    }
201
202    #[test]
203    fn install_idempotent() {
204        let dir = tempfile::tempdir().unwrap();
205        let config = test_config();
206        config.install(Some(dir.path())).unwrap();
207        config.install(Some(dir.path())).unwrap();
208
209        let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
210        let content = std::fs::read_to_string(&path).unwrap();
211        assert_eq!(content, config.content);
212    }
213
214    #[test]
215    fn check_not_installed() {
216        let dir = tempfile::tempdir().unwrap();
217        let config = test_config();
218        assert!(!config.check(Some(dir.path())).unwrap());
219    }
220
221    #[test]
222    fn check_up_to_date() {
223        let dir = tempfile::tempdir().unwrap();
224        let config = test_config();
225        config.install(Some(dir.path())).unwrap();
226        assert!(config.check(Some(dir.path())).unwrap());
227    }
228
229    #[test]
230    fn uninstall_removes_file() {
231        let dir = tempfile::tempdir().unwrap();
232        let config = test_config();
233        config.install(Some(dir.path())).unwrap();
234        config.uninstall(Some(dir.path())).unwrap();
235
236        let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
237        assert!(!path.exists());
238    }
239
240    #[test]
241    fn uninstall_not_installed() {
242        let dir = tempfile::tempdir().unwrap();
243        let config = test_config();
244        config.uninstall(Some(dir.path())).unwrap();
245    }
246}