Skip to main content

agent_kit/
skill.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}
18
19impl SkillConfig {
20    /// Create a new skill config.
21    pub fn new(name: impl Into<String>, content: impl Into<String>, version: impl Into<String>) -> Self {
22        Self {
23            name: name.into(),
24            content: content.into(),
25            version: version.into(),
26        }
27    }
28
29    /// Resolve the skill file path under the given root (or CWD if None).
30    /// Currently targets Claude Code layout: `.claude/skills/<name>/SKILL.md`
31    pub fn skill_path(&self, root: Option<&Path>) -> PathBuf {
32        let rel = format!(".claude/skills/{}/SKILL.md", self.name);
33        match root {
34            Some(r) => r.join(rel),
35            None => PathBuf::from(rel),
36        }
37    }
38
39    /// Install the bundled SKILL.md to the project.
40    /// When `root` is None, paths are relative to CWD.
41    pub fn install(&self, root: Option<&Path>) -> Result<()> {
42        let path = self.skill_path(root);
43
44        // Check if already up to date
45        if path.exists() {
46            let existing = std::fs::read_to_string(&path)
47                .with_context(|| format!("failed to read {}", path.display()))?;
48            if existing == self.content {
49                eprintln!("Skill already up to date (v{}).", self.version);
50                return Ok(());
51            }
52        }
53
54        // Create directories
55        if let Some(parent) = path.parent() {
56            std::fs::create_dir_all(parent)
57                .with_context(|| format!("failed to create {}", parent.display()))?;
58        }
59
60        // Write
61        std::fs::write(&path, &self.content)
62            .with_context(|| format!("failed to write {}", path.display()))?;
63        eprintln!("Installed skill v{} → {}", self.version, path.display());
64
65        Ok(())
66    }
67
68    /// Check if the installed skill matches the bundled version.
69    /// When `root` is None, paths are relative to CWD.
70    ///
71    /// Returns `Ok(true)` if up to date, `Ok(false)` if outdated or not installed.
72    pub fn check(&self, root: Option<&Path>) -> Result<bool> {
73        let path = self.skill_path(root);
74
75        if !path.exists() {
76            eprintln!("Not installed. Run `{} skill install` to install.", self.name);
77            return Ok(false);
78        }
79
80        let existing = std::fs::read_to_string(&path)
81            .with_context(|| format!("failed to read {}", path.display()))?;
82
83        if existing == self.content {
84            eprintln!("Up to date (v{}).", self.version);
85            Ok(true)
86        } else {
87            eprintln!(
88                "Outdated. Run `{} skill install` to update to v{}.",
89                self.name, self.version
90            );
91            Ok(false)
92        }
93    }
94
95    /// Uninstall the skill file and its parent directory (if empty).
96    pub fn uninstall(&self, root: Option<&Path>) -> Result<()> {
97        let path = self.skill_path(root);
98
99        if !path.exists() {
100            eprintln!("Skill not installed.");
101            return Ok(());
102        }
103
104        std::fs::remove_file(&path)
105            .with_context(|| format!("failed to remove {}", path.display()))?;
106
107        // Remove parent dir if empty
108        if let Some(parent) = path.parent()
109            && parent.read_dir().is_ok_and(|mut d| d.next().is_none())
110        {
111            let _ = std::fs::remove_dir(parent);
112        }
113
114        eprintln!("Uninstalled skill from {}", path.display());
115        Ok(())
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn test_config() -> SkillConfig {
124        SkillConfig::new("test-tool", "# Test Skill\n\nSome content.\n", "1.0.0")
125    }
126
127    #[test]
128    fn skill_path_with_root() {
129        let config = test_config();
130        let path = config.skill_path(Some(Path::new("/project")));
131        assert_eq!(path, PathBuf::from("/project/.claude/skills/test-tool/SKILL.md"));
132    }
133
134    #[test]
135    fn skill_path_without_root() {
136        let config = test_config();
137        let path = config.skill_path(None);
138        assert_eq!(path, PathBuf::from(".claude/skills/test-tool/SKILL.md"));
139    }
140
141    #[test]
142    fn install_creates_file() {
143        let dir = tempfile::tempdir().unwrap();
144        let config = test_config();
145
146        config.install(Some(dir.path())).unwrap();
147
148        let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
149        assert!(path.exists());
150        let content = std::fs::read_to_string(&path).unwrap();
151        assert_eq!(content, config.content);
152    }
153
154    #[test]
155    fn install_idempotent() {
156        let dir = tempfile::tempdir().unwrap();
157        let config = test_config();
158
159        config.install(Some(dir.path())).unwrap();
160        config.install(Some(dir.path())).unwrap();
161
162        let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
163        let content = std::fs::read_to_string(&path).unwrap();
164        assert_eq!(content, config.content);
165    }
166
167    #[test]
168    fn install_overwrites_outdated() {
169        let dir = tempfile::tempdir().unwrap();
170        let config = test_config();
171
172        let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
173        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
174        std::fs::write(&path, "old content").unwrap();
175
176        config.install(Some(dir.path())).unwrap();
177
178        let content = std::fs::read_to_string(&path).unwrap();
179        assert_eq!(content, config.content);
180    }
181
182    #[test]
183    fn check_not_installed() {
184        let dir = tempfile::tempdir().unwrap();
185        let config = test_config();
186
187        let result = config.check(Some(dir.path())).unwrap();
188        assert!(!result);
189    }
190
191    #[test]
192    fn check_up_to_date() {
193        let dir = tempfile::tempdir().unwrap();
194        let config = test_config();
195
196        config.install(Some(dir.path())).unwrap();
197        let result = config.check(Some(dir.path())).unwrap();
198        assert!(result);
199    }
200
201    #[test]
202    fn check_outdated() {
203        let dir = tempfile::tempdir().unwrap();
204        let config = test_config();
205
206        let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
207        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
208        std::fs::write(&path, "old content").unwrap();
209
210        let result = config.check(Some(dir.path())).unwrap();
211        assert!(!result);
212    }
213
214    #[test]
215    fn uninstall_removes_file() {
216        let dir = tempfile::tempdir().unwrap();
217        let config = test_config();
218
219        config.install(Some(dir.path())).unwrap();
220        config.uninstall(Some(dir.path())).unwrap();
221
222        let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
223        assert!(!path.exists());
224    }
225
226    #[test]
227    fn uninstall_not_installed() {
228        let dir = tempfile::tempdir().unwrap();
229        let config = test_config();
230
231        // Should not error
232        config.uninstall(Some(dir.path())).unwrap();
233    }
234}