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(
26        name: impl Into<String>,
27        content: impl Into<String>,
28        version: impl Into<String>,
29    ) -> Self {
30        Self {
31            name: name.into(),
32            content: content.into(),
33            version: version.into(),
34            environment: Environment::detect(),
35        }
36    }
37
38    /// Create a new skill config with an explicit environment.
39    pub fn with_environment(
40        name: impl Into<String>,
41        content: impl Into<String>,
42        version: impl Into<String>,
43        environment: Environment,
44    ) -> Self {
45        Self {
46            name: name.into(),
47            content: content.into(),
48            version: version.into(),
49            environment,
50        }
51    }
52
53    /// Resolve the skill file path under the given root (or CWD if None).
54    /// Uses the detected environment to determine the path layout.
55    pub fn skill_path(&self, root: Option<&Path>) -> PathBuf {
56        self.environment.skill_path(&self.name, root)
57    }
58
59    /// Install the bundled SKILL.md to the project.
60    /// When `root` is None, paths are relative to CWD.
61    pub fn install(&self, root: Option<&Path>) -> Result<()> {
62        let path = self.skill_path(root);
63
64        // Check if already up to date
65        if path.exists() {
66            let existing = std::fs::read_to_string(&path)
67                .with_context(|| format!("failed to read {}", path.display()))?;
68            if existing == self.content {
69                eprintln!("Skill already up to date (v{}).", self.version);
70                return Ok(());
71            }
72        }
73
74        // Create directories
75        if let Some(parent) = path.parent() {
76            std::fs::create_dir_all(parent)
77                .with_context(|| format!("failed to create {}", parent.display()))?;
78        }
79
80        // Write
81        std::fs::write(&path, &self.content)
82            .with_context(|| format!("failed to write {}", path.display()))?;
83        eprintln!("Installed skill v{} → {}", self.version, path.display());
84
85        Ok(())
86    }
87
88    /// Check if the installed skill matches the bundled version.
89    /// When `root` is None, paths are relative to CWD.
90    ///
91    /// Returns `Ok(true)` if up to date, `Ok(false)` if outdated or not installed.
92    pub fn check(&self, root: Option<&Path>) -> Result<bool> {
93        let path = self.skill_path(root);
94
95        if !path.exists() {
96            eprintln!(
97                "Not installed. Run `{} skill install` to install.",
98                self.name
99            );
100            return Ok(false);
101        }
102
103        let existing = std::fs::read_to_string(&path)
104            .with_context(|| format!("failed to read {}", path.display()))?;
105
106        if existing == self.content {
107            eprintln!("Up to date (v{}).", self.version);
108            Ok(true)
109        } else {
110            eprintln!(
111                "Outdated. Run `{} skill install` to update to v{}.",
112                self.name, self.version
113            );
114            Ok(false)
115        }
116    }
117
118    /// Install the skill to a specific environment (overriding auto-detection).
119    pub fn install_for(&self, env: Environment, root: Option<&Path>) -> Result<()> {
120        let rel = env.skill_rel_path(&self.name);
121        let path = match root {
122            Some(r) => r.join(rel),
123            None => rel,
124        };
125
126        if path.exists() {
127            let existing = std::fs::read_to_string(&path)
128                .with_context(|| format!("failed to read {}", path.display()))?;
129            if existing == self.content {
130                eprintln!("[{}] already up to date (v{}).", env, self.version);
131                return Ok(());
132            }
133        }
134
135        if let Some(parent) = path.parent() {
136            std::fs::create_dir_all(parent)
137                .with_context(|| format!("failed to create {}", parent.display()))?;
138        }
139
140        std::fs::write(&path, &self.content)
141            .with_context(|| format!("failed to write {}", path.display()))?;
142        eprintln!(
143            "[{}] installed skill v{} → {}",
144            env,
145            self.version,
146            path.display()
147        );
148        Ok(())
149    }
150
151    /// Install the skill to all supported environments.
152    pub fn install_all(&self, root: Option<&Path>) -> Result<()> {
153        for (env, _) in Environment::all_skill_rel_paths(&self.name) {
154            self.install_for(env, root)?;
155        }
156        Ok(())
157    }
158
159    /// Uninstall the skill file and its parent directory (if empty).
160    pub fn uninstall(&self, root: Option<&Path>) -> Result<()> {
161        let path = self.skill_path(root);
162
163        if !path.exists() {
164            eprintln!("Skill not installed.");
165            return Ok(());
166        }
167
168        std::fs::remove_file(&path)
169            .with_context(|| format!("failed to remove {}", path.display()))?;
170
171        // Remove parent dir if empty
172        if let Some(parent) = path.parent()
173            && parent.read_dir().is_ok_and(|mut d| d.next().is_none())
174        {
175            let _ = std::fs::remove_dir(parent);
176        }
177
178        eprintln!("Uninstalled skill from {}", path.display());
179        Ok(())
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    fn test_config() -> SkillConfig {
188        SkillConfig::with_environment(
189            "test-tool",
190            "# Test Skill\n\nSome content.\n",
191            "1.0.0",
192            crate::detect::Environment::ClaudeCode,
193        )
194    }
195
196    #[test]
197    fn skill_path_with_root() {
198        let config = test_config();
199        let path = config.skill_path(Some(Path::new("/project")));
200        assert_eq!(
201            path,
202            PathBuf::from("/project/.claude/skills/test-tool/SKILL.md")
203        );
204    }
205
206    #[test]
207    fn skill_path_without_root() {
208        let config = test_config();
209        let path = config.skill_path(None);
210        assert_eq!(path, PathBuf::from(".claude/skills/test-tool/SKILL.md"));
211    }
212
213    #[test]
214    fn install_creates_file() {
215        let dir = tempfile::tempdir().unwrap();
216        let config = test_config();
217
218        config.install(Some(dir.path())).unwrap();
219
220        let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
221        assert!(path.exists());
222        let content = std::fs::read_to_string(&path).unwrap();
223        assert_eq!(content, config.content);
224    }
225
226    #[test]
227    fn install_idempotent() {
228        let dir = tempfile::tempdir().unwrap();
229        let config = test_config();
230
231        config.install(Some(dir.path())).unwrap();
232        config.install(Some(dir.path())).unwrap();
233
234        let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
235        let content = std::fs::read_to_string(&path).unwrap();
236        assert_eq!(content, config.content);
237    }
238
239    #[test]
240    fn install_overwrites_outdated() {
241        let dir = tempfile::tempdir().unwrap();
242        let config = test_config();
243
244        let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
245        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
246        std::fs::write(&path, "old content").unwrap();
247
248        config.install(Some(dir.path())).unwrap();
249
250        let content = std::fs::read_to_string(&path).unwrap();
251        assert_eq!(content, config.content);
252    }
253
254    #[test]
255    fn check_not_installed() {
256        let dir = tempfile::tempdir().unwrap();
257        let config = test_config();
258
259        let result = config.check(Some(dir.path())).unwrap();
260        assert!(!result);
261    }
262
263    #[test]
264    fn check_up_to_date() {
265        let dir = tempfile::tempdir().unwrap();
266        let config = test_config();
267
268        config.install(Some(dir.path())).unwrap();
269        let result = config.check(Some(dir.path())).unwrap();
270        assert!(result);
271    }
272
273    #[test]
274    fn check_outdated() {
275        let dir = tempfile::tempdir().unwrap();
276        let config = test_config();
277
278        let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
279        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
280        std::fs::write(&path, "old content").unwrap();
281
282        let result = config.check(Some(dir.path())).unwrap();
283        assert!(!result);
284    }
285
286    #[test]
287    fn uninstall_removes_file() {
288        let dir = tempfile::tempdir().unwrap();
289        let config = test_config();
290
291        config.install(Some(dir.path())).unwrap();
292        config.uninstall(Some(dir.path())).unwrap();
293
294        let path = dir.path().join(".claude/skills/test-tool/SKILL.md");
295        assert!(!path.exists());
296    }
297
298    #[test]
299    fn uninstall_not_installed() {
300        let dir = tempfile::tempdir().unwrap();
301        let config = test_config();
302
303        // Should not error
304        config.uninstall(Some(dir.path())).unwrap();
305    }
306}