1use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::PathBuf;
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16pub struct Skill {
17 pub name: String,
18 pub description: String,
19 pub prompt: String,
21 #[serde(default, skip_serializing_if = "Option::is_none")]
23 pub category: Option<String>,
24}
25
26#[derive(Debug, Deserialize)]
28struct YamlSkillFile {
29 name: String,
30 description: String,
31 prompt: String,
32 #[serde(default)]
33 category: Option<String>,
34}
35
36#[derive(Debug, Deserialize)]
38struct MdFrontmatter {
39 #[serde(default)]
40 description: String,
41 #[serde(default)]
42 category: Option<String>,
43}
44
45pub struct SkillStore {
49 skills_dir: PathBuf,
50}
51
52impl SkillStore {
53 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 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 pub fn skill_path(&self) -> PathBuf {
70 self.skills_dir.clone()
71 }
72
73 pub fn load_skill(&self, name: &str) -> Result<Skill> {
79 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 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 pub fn list_skills(&self) -> Result<Vec<Skill>> {
109 let mut skills = Vec::new();
110
111 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 skills.is_empty() {
137 skills = Self::builtin_skills();
138 }
139
140 Ok(skills)
141 }
142
143 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 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 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 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 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 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
275fn 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 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#[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 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 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}