Skip to main content

houston_skills/
lib.rs

1//! Hermes-style self-improving skills for AI agents.
2//!
3//! Skills are directories containing a `SKILL.md` file with frontmatter metadata
4//! and a markdown body. Stored under a skills directory (typically `.houston/skills/`).
5
6pub mod format;
7pub mod index;
8pub mod patch;
9#[cfg(feature = "remote")]
10pub mod remote;
11mod validate;
12
13use serde::{Deserialize, Serialize};
14use std::path::Path;
15use thiserror::Error;
16
17// ── Types ──────────────────────────────────────────────────────────
18
19#[derive(Debug, Error)]
20pub enum SkillError {
21    #[error("IO error: {0}")]
22    Io(String),
23    #[error("Parse error: {0}")]
24    Parse(String),
25    #[error("Validation error: {0}")]
26    Validation(String),
27    #[error("Skill not found: {0}")]
28    NotFound(String),
29    #[error("Skill already exists: {0}")]
30    AlreadyExists(String),
31    #[error("Patch failed: old text not found")]
32    PatchNotFound,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SkillSummary {
37    pub name: String,
38    pub description: String,
39    pub version: u32,
40    pub tags: Vec<String>,
41    pub created: Option<String>,
42    pub last_used: Option<String>,
43}
44
45#[derive(Debug, Clone)]
46pub struct Skill {
47    pub summary: SkillSummary,
48    pub content: String,
49}
50
51pub struct CreateSkillInput {
52    pub name: String,
53    pub description: String,
54    pub content: String,
55    pub tags: Vec<String>,
56}
57
58// ── Public API ─────────────────────────────────────────────────────
59
60/// List all skills. Returns name + description only (progressive disclosure).
61pub fn list_skills(skills_dir: &Path) -> Result<Vec<SkillSummary>, SkillError> {
62    if !skills_dir.exists() {
63        return Ok(Vec::new());
64    }
65    let entries = std::fs::read_dir(skills_dir).map_err(|e| SkillError::Io(e.to_string()))?;
66    let mut summaries = Vec::new();
67    for entry in entries.flatten() {
68        let path = entry.path();
69        if !path.is_dir() {
70            continue;
71        }
72        let skill_md = path.join("SKILL.md");
73        if !skill_md.exists() {
74            continue;
75        }
76        match format::parse_file(&skill_md) {
77            Ok((summary, _body)) => summaries.push(summary),
78            Err(e) => eprintln!("[houston-skills] skipping {}: {e}", path.display()),
79        }
80    }
81    summaries.sort_by(|a, b| a.name.cmp(&b.name));
82    Ok(summaries)
83}
84
85/// Load a skill's full content. Updates `last_used` in frontmatter.
86pub fn load_skill(skills_dir: &Path, name: &str) -> Result<Skill, SkillError> {
87    let skill_dir = skills_dir.join(name);
88    let skill_md = skill_dir.join("SKILL.md");
89    if !skill_md.exists() {
90        return Err(SkillError::NotFound(name.to_string()));
91    }
92    let (mut summary, body) = format::parse_file(&skill_md)?;
93    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
94    summary.last_used = Some(today);
95    let updated = format::serialize(&summary, &body);
96    std::fs::write(&skill_md, &updated).map_err(|e| SkillError::Io(e.to_string()))?;
97    Ok(Skill {
98        summary,
99        content: body,
100    })
101}
102
103/// Create a new skill directory with SKILL.md.
104pub fn create_skill(skills_dir: &Path, input: CreateSkillInput) -> Result<(), SkillError> {
105    validate::name(&input.name)?;
106    validate::description(&input.description)?;
107    validate::content(&input.content)?;
108
109    let skill_dir = skills_dir.join(&input.name);
110    if skill_dir.exists() {
111        return Err(SkillError::AlreadyExists(input.name));
112    }
113    std::fs::create_dir_all(&skill_dir).map_err(|e| SkillError::Io(e.to_string()))?;
114
115    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
116    let summary = SkillSummary {
117        name: input.name,
118        description: input.description,
119        version: 1,
120        tags: input.tags,
121        created: Some(today.clone()),
122        last_used: Some(today),
123    };
124    let content = format::serialize(&summary, &input.content);
125    let skill_md = skill_dir.join("SKILL.md");
126    std::fs::write(&skill_md, &content).map_err(|e| SkillError::Io(e.to_string()))?;
127    Ok(())
128}
129
130/// Fuzzy find-and-replace within a skill's content. Increments version.
131pub fn patch_skill(
132    skills_dir: &Path,
133    name: &str,
134    old_text: &str,
135    new_text: &str,
136) -> Result<(), SkillError> {
137    let skill_md = skills_dir.join(name).join("SKILL.md");
138    if !skill_md.exists() {
139        return Err(SkillError::NotFound(name.to_string()));
140    }
141    let (mut summary, body) = format::parse_file(&skill_md)?;
142    let patched_body = patch::fuzzy_replace(&body, old_text, new_text)
143        .ok_or(SkillError::PatchNotFound)?;
144    validate::content(&patched_body)?;
145    summary.version += 1;
146    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
147    summary.last_used = Some(today);
148    let content = format::serialize(&summary, &patched_body);
149    std::fs::write(&skill_md, &content).map_err(|e| SkillError::Io(e.to_string()))?;
150    Ok(())
151}
152
153/// Full rewrite of skill content (preserves frontmatter metadata, increments version).
154pub fn edit_skill(skills_dir: &Path, name: &str, new_content: &str) -> Result<(), SkillError> {
155    validate::content(new_content)?;
156    let skill_md = skills_dir.join(name).join("SKILL.md");
157    if !skill_md.exists() {
158        return Err(SkillError::NotFound(name.to_string()));
159    }
160    let (mut summary, _old_body) = format::parse_file(&skill_md)?;
161    summary.version += 1;
162    let today = chrono::Local::now().format("%Y-%m-%d").to_string();
163    summary.last_used = Some(today);
164    let content = format::serialize(&summary, new_content);
165    std::fs::write(&skill_md, &content).map_err(|e| SkillError::Io(e.to_string()))?;
166    Ok(())
167}
168
169/// Delete a skill (removes entire directory).
170pub fn delete_skill(skills_dir: &Path, name: &str) -> Result<(), SkillError> {
171    let skill_dir = skills_dir.join(name);
172    if !skill_dir.exists() {
173        return Err(SkillError::NotFound(name.to_string()));
174    }
175    std::fs::remove_dir_all(&skill_dir).map_err(|e| SkillError::Io(e.to_string()))?;
176    Ok(())
177}
178
179/// Build compact skills index for system prompt injection.
180pub fn build_skills_index(skills_dir: &Path) -> Result<String, SkillError> {
181    index::build(skills_dir)
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187    use tempfile::TempDir;
188
189    #[test]
190    fn list_empty_dir() {
191        let tmp = TempDir::new().unwrap();
192        let result = list_skills(tmp.path()).unwrap();
193        assert!(result.is_empty());
194    }
195
196    #[test]
197    fn list_nonexistent_dir() {
198        let result = list_skills(Path::new("/nonexistent/path/skills"));
199        assert!(result.is_ok());
200        assert!(result.unwrap().is_empty());
201    }
202
203    #[test]
204    fn create_and_list() {
205        let tmp = TempDir::new().unwrap();
206        let dir = tmp.path();
207        create_skill(dir, CreateSkillInput {
208            name: "my-skill".into(),
209            description: "Test skill".into(),
210            content: "## Procedure\n\n1. Do stuff\n".into(),
211            tags: vec!["test".into()],
212        }).unwrap();
213
214        let skills = list_skills(dir).unwrap();
215        assert_eq!(skills.len(), 1);
216        assert_eq!(skills[0].name, "my-skill");
217        assert_eq!(skills[0].version, 1);
218    }
219
220    #[test]
221    fn create_and_load() {
222        let tmp = TempDir::new().unwrap();
223        let dir = tmp.path();
224        create_skill(dir, CreateSkillInput {
225            name: "loader-test".into(),
226            description: "Load test".into(),
227            content: "## Procedure\nTest body".into(),
228            tags: vec![],
229        }).unwrap();
230
231        let skill = load_skill(dir, "loader-test").unwrap();
232        assert_eq!(skill.summary.name, "loader-test");
233        assert!(skill.content.contains("Test body"));
234    }
235
236    #[test]
237    fn create_duplicate_fails() {
238        let tmp = TempDir::new().unwrap();
239        let dir = tmp.path();
240        create_skill(dir, CreateSkillInput {
241            name: "dup".into(),
242            description: "".into(),
243            content: "body".into(),
244            tags: vec![],
245        }).unwrap();
246        let result = create_skill(dir, CreateSkillInput {
247            name: "dup".into(),
248            description: "".into(),
249            content: "body".into(),
250            tags: vec![],
251        });
252        assert!(result.is_err());
253    }
254
255    #[test]
256    fn edit_increments_version() {
257        let tmp = TempDir::new().unwrap();
258        let dir = tmp.path();
259        create_skill(dir, CreateSkillInput {
260            name: "editable".into(),
261            description: "Edit me".into(),
262            content: "v1 content".into(),
263            tags: vec![],
264        }).unwrap();
265
266        edit_skill(dir, "editable", "v2 content").unwrap();
267        let skill = load_skill(dir, "editable").unwrap();
268        assert_eq!(skill.summary.version, 2);
269        assert!(skill.content.contains("v2 content"));
270    }
271
272    #[test]
273    fn patch_fuzzy() {
274        let tmp = TempDir::new().unwrap();
275        let dir = tmp.path();
276        create_skill(dir, CreateSkillInput {
277            name: "patchable".into(),
278            description: "Patch me".into(),
279            content: "1. First step\n2. Second step\n".into(),
280            tags: vec![],
281        }).unwrap();
282
283        patch_skill(dir, "patchable", "Second step", "Updated step").unwrap();
284        let skill = load_skill(dir, "patchable").unwrap();
285        assert!(skill.content.contains("Updated step"));
286        assert!(!skill.content.contains("Second step"));
287        assert_eq!(skill.summary.version, 2);
288    }
289
290    #[test]
291    fn delete_removes_dir() {
292        let tmp = TempDir::new().unwrap();
293        let dir = tmp.path();
294        create_skill(dir, CreateSkillInput {
295            name: "deleteme".into(),
296            description: "".into(),
297            content: "body".into(),
298            tags: vec![],
299        }).unwrap();
300        assert!(dir.join("deleteme").exists());
301        delete_skill(dir, "deleteme").unwrap();
302        assert!(!dir.join("deleteme").exists());
303    }
304
305    #[test]
306    fn delete_nonexistent_fails() {
307        let tmp = TempDir::new().unwrap();
308        let result = delete_skill(tmp.path(), "nope");
309        assert!(result.is_err());
310    }
311}