hermes_agent_cli_core/
skills.rs1use anyhow::{Context, Result};
2use directories::ProjectDirs;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::path::PathBuf;
7
8#[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#[derive(Debug, Clone)]
27pub struct Skill {
28 pub metadata: SkillMetadata,
29 pub path: PathBuf,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize, Default)]
34pub struct SkillsIndex {
35 #[serde(default)]
36 pub skills: HashMap<String, SkillMetadata>,
37}
38
39impl SkillsIndex {
40 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 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 fn skills_index_path() -> PathBuf {
68 Self::skills_home().join(".hub").join("index.yaml")
69 }
70
71 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 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 self.skills.clear();
103
104 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 pub fn get_all(&self) -> Vec<&SkillMetadata> {
128 self.skills.values().collect()
129 }
130
131 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 pub fn get(&self, name: &str) -> Option<&SkillMetadata> {
146 self.skills.get(name)
147 }
148
149 pub fn add(&mut self, metadata: SkillMetadata) {
151 self.skills.insert(metadata.name.clone(), metadata);
152 }
153
154 pub fn remove(&mut self, name: &str) -> bool {
156 self.skills.remove(name).is_some()
157 }
158}
159
160fn parse_skill_frontmatter(content: &str) -> Option<SkillMetadata> {
162 let content = content.trim();
163
164 if !content.starts_with("---") {
166 return None;
167 }
168
169 let end = content[3..].find("---")?;
170 let frontmatter = &content[3..end];
171
172 let metadata: SkillMetadata = serde_yaml::from_str(frontmatter).ok()?;
174
175 Some(metadata)
176}
177
178pub 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}