Skip to main content

hermes_agent_cli_core/
skills.rs

1use anyhow::{Context, Result};
2use directories::ProjectDirs;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::path::PathBuf;
7
8/// Skill metadata from frontmatter
9#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10pub struct SkillMetadata {
11    pub name: String,
12    pub description: String,
13    #[serde(default)]
14    pub version: Option<String>,
15    #[serde(default)]
16    pub license: Option<String>,
17    #[serde(default)]
18    pub platforms: Vec<String>,
19    #[serde(default)]
20    pub tags: Vec<String>,
21    #[serde(default)]
22    pub related_skills: Vec<String>,
23}
24
25/// A skill with its metadata
26#[derive(Debug, Clone)]
27pub struct Skill {
28    pub metadata: SkillMetadata,
29    pub path: PathBuf,
30}
31
32/// Skills index (cached)
33#[derive(Debug, Clone, Serialize, Deserialize, Default)]
34pub struct SkillsIndex {
35    #[serde(default)]
36    pub skills: HashMap<String, SkillMetadata>,
37}
38
39impl SkillsIndex {
40    /// Load skills index from disk
41    pub fn load() -> Result<Self> {
42        let path = Self::skills_index_path();
43        if !path.exists() {
44            return Ok(SkillsIndex::default());
45        }
46        let content = fs::read_to_string(&path)
47            .with_context(|| format!("failed to read skills index from {:?}", path))?;
48        let index: SkillsIndex = serde_yaml::from_str(&content)
49            .with_context(|| format!("failed to parse skills index from {:?}", path))?;
50        Ok(index)
51    }
52
53    /// Save skills index to disk
54    pub fn save(&self) -> Result<()> {
55        let path = Self::skills_index_path();
56        if let Some(parent) = path.parent() {
57            fs::create_dir_all(parent)
58                .with_context(|| format!("failed to create skills directory {:?}", parent))?;
59        }
60        let content = serde_yaml::to_string(self).context("failed to serialize skills index")?;
61        fs::write(&path, content)
62            .with_context(|| format!("failed to write skills index to {:?}", path))?;
63        Ok(())
64    }
65
66    /// Get skills index path
67    fn skills_index_path() -> PathBuf {
68        Self::skills_home().join(".hub").join("index.yaml")
69    }
70
71    /// Get skills home directory
72    pub fn skills_home() -> PathBuf {
73        if let Ok(home) = std::env::var("HERMES_HOME") {
74            return PathBuf::from(home).join("skills");
75        }
76        if let Ok(profile) = std::env::var("HERMES_PROFILE") {
77            if let Some(proj_dirs) =
78                ProjectDirs::from("ai", "hermes", &format!("hermes-{}", profile))
79            {
80                return proj_dirs.data_dir().join("skills");
81            }
82        }
83        if let Some(proj_dirs) = ProjectDirs::from("ai", "hermes", "hermes-cli") {
84            return proj_dirs.data_dir().join("skills");
85        }
86        if let Ok(home) = std::env::var("USERPROFILE") {
87            return PathBuf::from(home).join(".hermes").join("skills");
88        }
89        PathBuf::from(".hermes").join("skills")
90    }
91
92    /// Scan local skills directory and update index
93    pub fn scan_local_skills(&mut self) -> Result<usize> {
94        let skills_dir = Self::skills_home();
95        let mut count = 0;
96
97        if !skills_dir.exists() {
98            return Ok(0);
99        }
100
101        // Clear existing skills
102        self.skills.clear();
103
104        // Scan for skill directories
105        if let Ok(entries) = fs::read_dir(&skills_dir) {
106            for entry in entries.flatten() {
107                let path = entry.path();
108                if path.is_dir() {
109                    let skill_md = path.join("SKILL.md");
110                    if skill_md.exists() {
111                        if let Ok(content) = fs::read_to_string(&skill_md) {
112                            if let Some(metadata) = parse_skill_frontmatter(&content) {
113                                self.skills.insert(metadata.name.clone(), metadata);
114                                count += 1;
115                            }
116                        }
117                    }
118                }
119            }
120        }
121
122        self.save()?;
123        Ok(count)
124    }
125
126    /// Get all skills
127    pub fn get_all(&self) -> Vec<&SkillMetadata> {
128        self.skills.values().collect()
129    }
130
131    /// Search skills by query
132    pub fn search(&self, query: &str) -> Vec<&SkillMetadata> {
133        let query_lower = query.to_lowercase();
134        self.skills
135            .values()
136            .filter(|skill| {
137                skill.name.to_lowercase().contains(&query_lower)
138                    || skill.description.to_lowercase().contains(&query_lower)
139                    || skill.tags.iter().any(|t| t.to_lowercase().contains(&query_lower))
140            })
141            .collect()
142    }
143
144    /// Get a skill by name
145    pub fn get(&self, name: &str) -> Option<&SkillMetadata> {
146        self.skills.get(name)
147    }
148
149    /// Add or update a skill
150    pub fn add(&mut self, metadata: SkillMetadata) {
151        self.skills.insert(metadata.name.clone(), metadata);
152    }
153
154    /// Remove a skill
155    pub fn remove(&mut self, name: &str) -> bool {
156        self.skills.remove(name).is_some()
157    }
158}
159
160/// Parse frontmatter from SKILL.md content
161fn parse_skill_frontmatter(content: &str) -> Option<SkillMetadata> {
162    let content = content.trim();
163
164    // Check for YAML frontmatter
165    if !content.starts_with("---") {
166        return None;
167    }
168
169    let end = content[3..].find("---")?;
170    let frontmatter = &content[3..end];
171
172    // Parse YAML frontmatter
173    let metadata: SkillMetadata = serde_yaml::from_str(frontmatter).ok()?;
174
175    Some(metadata)
176}
177
178/// Scan skills from bundled skills directory
179pub fn scan_bundled_skills(bundled_path: &PathBuf) -> Result<Vec<Skill>> {
180    let mut skills = Vec::new();
181
182    if !bundled_path.exists() {
183        return Ok(skills);
184    }
185
186    if let Ok(entries) = fs::read_dir(bundled_path) {
187        for entry in entries.flatten() {
188            let path = entry.path();
189            if path.is_dir() {
190                let skill_md = path.join("SKILL.md");
191                if skill_md.exists() {
192                    if let Ok(content) = fs::read_to_string(&skill_md) {
193                        if let Some(metadata) = parse_skill_frontmatter(&content) {
194                            skills.push(Skill { metadata, path: path.clone() });
195                        }
196                    }
197                }
198            }
199        }
200    }
201
202    Ok(skills)
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[test]
210    fn test_skills_index_search() {
211        let mut index = SkillsIndex::default();
212        index.add(SkillMetadata {
213            name: "test-skill".to_string(),
214            description: "A test skill for testing".to_string(),
215            tags: vec!["test".to_string()],
216            ..Default::default()
217        });
218        index.add(SkillMetadata {
219            name: "rust-programming".to_string(),
220            description: "Rust programming help".to_string(),
221            tags: vec!["rust".to_string(), "programming".to_string()],
222            ..Default::default()
223        });
224
225        let results = index.search("rust");
226        assert_eq!(results.len(), 1);
227        assert_eq!(results[0].name, "rust-programming");
228
229        let results = index.search("test");
230        assert_eq!(results.len(), 1);
231        assert_eq!(results[0].name, "test-skill");
232    }
233}