Skip to main content

hermes_agent_cli_core/
skills_store.rs

1//! Skills store — reusable prompt templates loaded into the agent's system prompt.
2//!
3//! Skills are stored as individual `.md` (with YAML frontmatter) or `.yaml` files
4//! inside `~/.hermes/skills/`. When the directory is empty, built-in skills are
5//! returned by `list_skills()` without being written to disk.
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::PathBuf;
11
12// ── Types ──────────────────────────────────────────────────────────────────────
13
14/// A reusable prompt template that can be loaded into the agent's system prompt.
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct Skill {
17    pub name: String,
18    pub description: String,
19    /// The system prompt template injected when the skill is activated.
20    pub prompt: String,
21    /// Optional grouping category (e.g. "coding", "language").
22    #[serde(default, skip_serializing_if = "Option::is_none")]
23    pub category: Option<String>,
24}
25
26/// Internal representation used when deserialising YAML skill files.
27#[derive(Debug, Deserialize)]
28struct YamlSkillFile {
29    name: String,
30    description: String,
31    prompt: String,
32    #[serde(default)]
33    category: Option<String>,
34}
35
36/// Internal representation of YAML frontmatter found in Markdown skill files.
37#[derive(Debug, Deserialize)]
38struct MdFrontmatter {
39    #[serde(default)]
40    description: String,
41    #[serde(default)]
42    category: Option<String>,
43}
44
45// ── SkillStore ─────────────────────────────────────────────────────────────────
46
47/// Manages skill files on disk under `~/.hermes/skills/`.
48pub struct SkillStore {
49    skills_dir: PathBuf,
50}
51
52impl SkillStore {
53    /// Create a new `SkillStore`, ensuring the skills directory exists.
54    pub fn new() -> Result<Self> {
55        let skills_dir = Self::default_skills_dir();
56        fs::create_dir_all(&skills_dir)
57            .with_context(|| format!("failed to create skills directory {:?}", skills_dir))?;
58        Ok(Self { skills_dir })
59    }
60
61    /// Create a `SkillStore` pointed at an arbitrary directory (useful for testing).
62    pub fn with_dir(dir: PathBuf) -> Result<Self> {
63        fs::create_dir_all(&dir)
64            .with_context(|| format!("failed to create skills directory {:?}", dir))?;
65        Ok(Self { skills_dir: dir })
66    }
67
68    /// Return the path to the skills directory.
69    pub fn skill_path(&self) -> PathBuf {
70        self.skills_dir.clone()
71    }
72
73    // ── Load ────────────────────────────────────────────────────────────────
74
75    /// Load a single skill by name.
76    ///
77    /// Looks for `{name}.md` first, then `{name}.yaml`.
78    pub fn load_skill(&self, name: &str) -> Result<Skill> {
79        // Sanitise: reject path separators to prevent directory traversal.
80        if name.contains(std::path::MAIN_SEPARATOR) || name.contains('/') || name.contains('\\') {
81            anyhow::bail!("invalid skill name '{}': path separators are not allowed", name);
82        }
83
84        let md_path = self.skills_dir.join(format!("{}.md", name));
85        if md_path.exists() {
86            return self.parse_md_skill(name, &md_path);
87        }
88
89        let yaml_path = self.skills_dir.join(format!("{}.yaml", name));
90        if yaml_path.exists() {
91            return self.parse_yaml_skill(&yaml_path);
92        }
93
94        // Fall back to built-in skills.
95        if let Some(builtin) = Self::builtin_skills().iter().find(|s| s.name == name) {
96            return Ok(builtin.clone());
97        }
98
99        anyhow::bail!("skill '{}' not found", name)
100    }
101
102    // ── List ────────────────────────────────────────────────────────────────
103
104    /// List all available skills.
105    ///
106    /// Scans on-disk files and merges them with the built-in defaults (built-in
107    /// skills are only included when the directory has no files at all).
108    pub fn list_skills(&self) -> Result<Vec<Skill>> {
109        let mut skills = Vec::new();
110
111        // Scan .md and .yaml files in the skills directory.
112        if let Ok(entries) = fs::read_dir(&self.skills_dir) {
113            for entry in entries.flatten() {
114                let path = entry.path();
115                let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
116
117                match ext {
118                    "md" => {
119                        if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
120                            if let Ok(skill) = self.parse_md_skill(name, &path) {
121                                skills.push(skill);
122                            }
123                        }
124                    }
125                    "yaml" | "yml" => {
126                        if let Ok(skill) = self.parse_yaml_skill(&path) {
127                            skills.push(skill);
128                        }
129                    }
130                    _ => {}
131                }
132            }
133        }
134
135        // If no files were found on disk, return built-in skills.
136        if skills.is_empty() {
137            skills = Self::builtin_skills();
138        }
139
140        Ok(skills)
141    }
142
143    // ── Install / Uninstall ─────────────────────────────────────────────────
144
145    /// Save a new skill to disk as a Markdown file with YAML frontmatter.
146    pub fn install_skill(&self, name: &str, content: &str) -> Result<()> {
147        if name.contains(std::path::MAIN_SEPARATOR) || name.contains('/') || name.contains('\\') {
148            anyhow::bail!("invalid skill name '{}': path separators are not allowed", name);
149        }
150
151        let path = self.skills_dir.join(format!("{}.md", name));
152        fs::write(&path, content)
153            .with_context(|| format!("failed to write skill to {:?}", path))?;
154        Ok(())
155    }
156
157    /// Delete a skill file from disk. Returns `false` if nothing was deleted.
158    pub fn uninstall_skill(&self, name: &str) -> Result<bool> {
159        if name.contains(std::path::MAIN_SEPARATOR) || name.contains('/') || name.contains('\\') {
160            anyhow::bail!("invalid skill name '{}': path separators are not allowed", name);
161        }
162
163        let md_path = self.skills_dir.join(format!("{}.md", name));
164        if md_path.exists() {
165            fs::remove_file(&md_path).with_context(|| format!("failed to delete {:?}", md_path))?;
166            return Ok(true);
167        }
168
169        let yaml_path = self.skills_dir.join(format!("{}.yaml", name));
170        if yaml_path.exists() {
171            fs::remove_file(&yaml_path)
172                .with_context(|| format!("failed to delete {:?}", yaml_path))?;
173            return Ok(true);
174        }
175
176        Ok(false)
177    }
178
179    // ── Parsing helpers ─────────────────────────────────────────────────────
180
181    /// Parse a Markdown skill file with YAML frontmatter.
182    fn parse_md_skill(&self, name: &str, path: &PathBuf) -> Result<Skill> {
183        let content =
184            fs::read_to_string(path).with_context(|| format!("failed to read {:?}", path))?;
185        let (fm, body) = split_frontmatter(&content);
186
187        let description;
188        let category;
189
190        if let Some(ref fm_text) = fm {
191            let parsed: MdFrontmatter = serde_yaml::from_str(fm_text)
192                .with_context(|| format!("failed to parse frontmatter in {:?}", path))?;
193            description = parsed.description;
194            category = parsed.category;
195        } else {
196            description = String::new();
197            category = None;
198        }
199
200        Ok(Skill { name: name.to_string(), description, prompt: body.trim().to_string(), category })
201    }
202
203    /// Parse a YAML skill file.
204    fn parse_yaml_skill(&self, path: &PathBuf) -> Result<Skill> {
205        let content =
206            fs::read_to_string(path).with_context(|| format!("failed to read {:?}", path))?;
207        let parsed: YamlSkillFile = serde_yaml::from_str(&content)
208            .with_context(|| format!("failed to parse YAML skill {:?}", path))?;
209        Ok(Skill {
210            name: parsed.name,
211            description: parsed.description,
212            prompt: parsed.prompt,
213            category: parsed.category,
214        })
215    }
216
217    // ── Built-in skills ─────────────────────────────────────────────────────
218
219    /// Return the hard-coded built-in skill set.
220    fn builtin_skills() -> Vec<Skill> {
221        vec![
222            Skill {
223                name: "code-review".into(),
224                description: "Code review assistant".into(),
225                prompt: "You are a code review expert. Analyze code for bugs, security issues, \
226                    and performance problems. Provide constructive feedback with specific \
227                    suggestions for improvement."
228                    .into(),
229                category: Some("coding".into()),
230            },
231            Skill {
232                name: "explain".into(),
233                description: "Code explanation".into(),
234                prompt: "You are a patient code explainer. When given code, break it down \
235                    step-by-step in plain language. Explain the purpose of each section, \
236                    key algorithms, and potential gotchas."
237                    .into(),
238                category: Some("coding".into()),
239            },
240            Skill {
241                name: "translate".into(),
242                description: "Translation".into(),
243                prompt: "You are a professional translator. Translate text accurately while \
244                    preserving tone, idioms, and cultural nuances. When translating code \
245                    comments, keep them natural in the target language."
246                    .into(),
247                category: Some("language".into()),
248            },
249            Skill {
250                name: "summarize".into(),
251                description: "Text summarization".into(),
252                prompt: "You are a summarization specialist. Given any text, produce a clear, \
253                    concise summary that captures the key points. Offer different lengths \
254                    (one sentence, one paragraph, bullet points) when helpful."
255                    .into(),
256                category: Some("productivity".into()),
257            },
258        ]
259    }
260
261    // ── Directory helpers ───────────────────────────────────────────────────
262
263    /// Resolve the default skills directory: `~/.hermes/skills/`.
264    fn default_skills_dir() -> PathBuf {
265        if let Ok(home) = std::env::var("USERPROFILE") {
266            return PathBuf::from(home).join(".hermes").join("skills");
267        }
268        if let Ok(home) = std::env::var("HOME") {
269            return PathBuf::from(home).join(".hermes").join("skills");
270        }
271        PathBuf::from(".hermes").join("skills")
272    }
273}
274
275// ── Frontmatter splitter ───────────────────────────────────────────────────────
276
277/// Split Markdown content into optional YAML frontmatter and body.
278///
279/// Returns `(None, content)` when no frontmatter is found.
280fn split_frontmatter(content: &str) -> (Option<String>, String) {
281    let trimmed = content.trim_start();
282    if !trimmed.starts_with("---") {
283        return (None, content.to_string());
284    }
285
286    // Find the closing `---`.
287    let after_first = &trimmed[3..];
288    if let Some(end_offset) = after_first.find("---") {
289        let frontmatter = after_first[..end_offset].to_string();
290        let body = after_first[end_offset + 3..].to_string();
291        return (Some(frontmatter), body);
292    }
293
294    (None, content.to_string())
295}
296
297// ── Tests ──────────────────────────────────────────────────────────────────────
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    fn temp_store() -> (SkillStore, tempfile::TempDir) {
304        let dir = tempfile::tempdir().expect("temp dir");
305        let store = SkillStore::with_dir(dir.path().to_path_buf()).expect("store");
306        (store, dir)
307    }
308
309    #[test]
310    fn test_create_skillstore_with_temp_dir() {
311        let (store, _dir) = temp_store();
312        assert!(store.skill_path().exists());
313        assert!(store.skill_path().is_dir());
314    }
315
316    #[test]
317    fn test_install_and_load_skill() {
318        let (store, _dir) = temp_store();
319
320        let content =
321            "---\ndescription: Test skill\ncategory: test\n---\nYou are a test assistant.";
322        store.install_skill("my-test", content).expect("install");
323
324        let skill = store.load_skill("my-test").expect("load");
325        assert_eq!(skill.name, "my-test");
326        assert_eq!(skill.description, "Test skill");
327        assert_eq!(skill.category, Some("test".to_string()));
328        assert_eq!(skill.prompt, "You are a test assistant.");
329    }
330
331    #[test]
332    fn test_list_skills_returns_builtins_when_empty() {
333        let (store, _dir) = temp_store();
334        let skills = store.list_skills().expect("list");
335        assert!(!skills.is_empty());
336        // We define 4 built-ins.
337        assert_eq!(skills.len(), 4);
338        assert!(skills.iter().any(|s| s.name == "code-review"));
339        assert!(skills.iter().any(|s| s.name == "explain"));
340        assert!(skills.iter().any(|s| s.name == "translate"));
341        assert!(skills.iter().any(|s| s.name == "summarize"));
342    }
343
344    #[test]
345    fn test_list_skills_returns_disk_skills_when_present() {
346        let (store, _dir) = temp_store();
347
348        store.install_skill("custom", "---\ndescription: Custom\n---\nDo stuff.").expect("install");
349
350        let skills = store.list_skills().expect("list");
351        // On-disk skills should replace built-ins entirely.
352        assert_eq!(skills.len(), 1);
353        assert_eq!(skills[0].name, "custom");
354    }
355
356    #[test]
357    fn test_uninstall_skill() {
358        let (store, _dir) = temp_store();
359
360        store
361            .install_skill("temp-skill", "---\ndescription: Temp\n---\nTemp prompt.")
362            .expect("install");
363
364        assert!(store.uninstall_skill("temp-skill").expect("uninstall"));
365        assert!(!store.uninstall_skill("temp-skill").expect("uninstall again"));
366    }
367
368    #[test]
369    fn test_parse_markdown_frontmatter() {
370        let content = "---\ndescription: Code review assistant\ncategory: coding\n---\nYou are a code review expert.";
371        let (fm, body) = split_frontmatter(content);
372        assert!(fm.is_some());
373        let fm = fm.unwrap();
374        assert!(fm.contains("description: Code review assistant"));
375        assert!(fm.contains("category: coding"));
376        assert_eq!(body.trim(), "You are a code review expert.");
377    }
378
379    #[test]
380    fn test_parse_markdown_no_frontmatter() {
381        let content = "Just a plain prompt with no frontmatter.";
382        let (fm, body) = split_frontmatter(content);
383        assert!(fm.is_none());
384        assert_eq!(body, content);
385    }
386
387    #[test]
388    fn test_load_yaml_skill() {
389        let (store, _dir) = temp_store();
390
391        let yaml = "name: my-yaml-skill\ndescription: A YAML skill\ncategory: misc\nprompt: |\n  You are a YAML-powered assistant.\n";
392        let yaml_path = store.skill_path().join("my-yaml-skill.yaml");
393        fs::write(&yaml_path, yaml).expect("write yaml");
394
395        let skill = store.load_skill("my-yaml-skill").expect("load yaml");
396        assert_eq!(skill.name, "my-yaml-skill");
397        assert_eq!(skill.description, "A YAML skill");
398        assert_eq!(skill.category, Some("misc".to_string()));
399        assert!(skill.prompt.contains("YAML-powered assistant"));
400    }
401
402    #[test]
403    fn test_reject_path_traversal() {
404        let (store, _dir) = temp_store();
405        assert!(store.load_skill("../etc/passwd").is_err());
406        assert!(store.install_skill("a/b", "x").is_err());
407        assert!(store.uninstall_skill("a\\b").is_err());
408    }
409}