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