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    /// Install the skill to a specific environment (overriding auto-detection).
112    pub fn install_for(&self, env: Environment, root: Option<&Path>) -> Result<()> {
113        let rel = env.skill_rel_path(&self.name);
114        let path = match root {
115            Some(r) => r.join(rel),
116            None => rel,
117        };
118
119        if path.exists() {
120            let existing = std::fs::read_to_string(&path)
121                .with_context(|| format!("failed to read {}", path.display()))?;
122            if existing == self.content {
123                eprintln!("[{}] already up to date (v{}).", env, self.version);
124                return Ok(());
125            }
126        }
127
128        if let Some(parent) = path.parent() {
129            std::fs::create_dir_all(parent)
130                .with_context(|| format!("failed to create {}", parent.display()))?;
131        }
132
133        std::fs::write(&path, &self.content)
134            .with_context(|| format!("failed to write {}", path.display()))?;
135        eprintln!("[{}] installed skill v{} → {}", env, self.version, path.display());
136        Ok(())
137    }
138
139    /// Install the skill to all supported environments.
140    pub fn install_all(&self, root: Option<&Path>) -> Result<()> {
141        for (env, _) in Environment::all_skill_rel_paths(&self.name) {
142            self.install_for(env, root)?;
143        }
144        Ok(())
145    }
146
147    /// Uninstall the skill file and its parent directory (if empty).
148    pub fn uninstall(&self, root: Option<&Path>) -> Result<()> {
149        let path = self.skill_path(root);
150
151        if !path.exists() {
152            eprintln!("Skill not installed.");
153            return Ok(());
154        }
155
156        std::fs::remove_file(&path)
157            .with_context(|| format!("failed to remove {}", path.display()))?;
158
159        // Remove parent dir if empty
160        if let Some(parent) = path.parent()
161            && parent.read_dir().is_ok_and(|mut d| d.next().is_none())
162        {
163            let _ = std::fs::remove_dir(parent);
164        }
165
166        eprintln!("Uninstalled skill from {}", path.display());
167        Ok(())
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    fn test_config() -> SkillConfig {
176        SkillConfig::with_environment(
177            "test-tool",
178            "# Test Skill\n\nSome content.\n",
179            "1.0.0",
180            crate::detect::Environment::ClaudeCode,
181        )
182    }
183
184    #[test]
185    fn skill_path_with_root() {
186        let config = test_config();
187        let path = config.skill_path(Some(Path::new("/project")));
188        assert_eq!(path, PathBuf::from("/project/.claude/skills/test-tool/SKILL.md"));
189    }
190
191    #[test]
192    fn skill_path_without_root() {
193        let config = test_config();
194        let path = config.skill_path(None);
195        assert_eq!(path, PathBuf::from(".claude/skills/test-tool/SKILL.md"));
196    }
197
198    #[test]
199    fn install_creates_file() {
200        let dir = tempfile::tempdir().unwrap();
201        let config = test_config();
202
203        config.install(Some(dir.path())).unwrap();
204
205        let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
206        assert!(path.exists());
207        let content = std::fs::read_to_string(&path).unwrap();
208        assert_eq!(content, config.content);
209    }
210
211    #[test]
212    fn install_idempotent() {
213        let dir = tempfile::tempdir().unwrap();
214        let config = test_config();
215
216        config.install(Some(dir.path())).unwrap();
217        config.install(Some(dir.path())).unwrap();
218
219        let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
220        let content = std::fs::read_to_string(&path).unwrap();
221        assert_eq!(content, config.content);
222    }
223
224    #[test]
225    fn install_overwrites_outdated() {
226        let dir = tempfile::tempdir().unwrap();
227        let config = test_config();
228
229        let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
230        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
231        std::fs::write(&path, "old content").unwrap();
232
233        config.install(Some(dir.path())).unwrap();
234
235        let content = std::fs::read_to_string(&path).unwrap();
236        assert_eq!(content, config.content);
237    }
238
239    #[test]
240    fn check_not_installed() {
241        let dir = tempfile::tempdir().unwrap();
242        let config = test_config();
243
244        let result = config.check(Some(dir.path())).unwrap();
245        assert!(!result);
246    }
247
248    #[test]
249    fn check_up_to_date() {
250        let dir = tempfile::tempdir().unwrap();
251        let config = test_config();
252
253        config.install(Some(dir.path())).unwrap();
254        let result = config.check(Some(dir.path())).unwrap();
255        assert!(result);
256    }
257
258    #[test]
259    fn check_outdated() {
260        let dir = tempfile::tempdir().unwrap();
261        let config = test_config();
262
263        let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
264        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
265        std::fs::write(&path, "old content").unwrap();
266
267        let result = config.check(Some(dir.path())).unwrap();
268        assert!(!result);
269    }
270
271    #[test]
272    fn uninstall_removes_file() {
273        let dir = tempfile::tempdir().unwrap();
274        let config = test_config();
275
276        config.install(Some(dir.path())).unwrap();
277        config.uninstall(Some(dir.path())).unwrap();
278
279        let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
280        assert!(!path.exists());
281    }
282
283    #[test]
284    fn uninstall_not_installed() {
285        let dir = tempfile::tempdir().unwrap();
286        let config = test_config();
287
288        // Should not error
289        config.uninstall(Some(dir.path())).unwrap();
290    }
291}