Skip to main content

stynx_code_skills/application/
skill_loader.rs

1use std::path::{Path, PathBuf};
2
3use stynx_code_errors::{AppError, AppResult};
4
5use crate::domain::bundled_skill::bundled_skills;
6use crate::domain::skill::{Skill, SkillMetadata, SkillSource};
7
8pub struct SkillLoader;
9
10impl SkillLoader {
11    pub fn new() -> Self {
12        Self
13    }
14
15    pub fn load_from_directory(&self, dir: &Path) -> AppResult<Vec<Skill>> {
16        let mut skills = Vec::new();
17        if !dir.is_dir() {
18            return Ok(skills);
19        }
20
21        let entries = std::fs::read_dir(dir)
22            .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to read dir {:?}: {}", dir, e)))?;
23
24        for entry in entries.flatten() {
25            let p = entry.path();
26
27            if p.is_dir() {
28                let skill_file = p.join("SKILL.md");
29                let skill_file_lower = p.join("skill.md");
30                let actual = if skill_file.is_file() {
31                    Some(skill_file)
32                } else if skill_file_lower.is_file() {
33                    Some(skill_file_lower)
34                } else {
35                    None
36                };
37                if let Some(file_path) = actual {
38                    if let Ok(skill) = parse_skill_file(&file_path) {
39                        if !skills.iter().any(|s: &Skill| s.metadata.name == skill.metadata.name) {
40                            skills.push(skill);
41                        }
42                    }
43                }
44                continue;
45            }
46
47            if p.extension().and_then(|e| e.to_str()) == Some("md") {
48                if let Ok(skill) = parse_skill_file(&p) {
49                    if !skills.iter().any(|s: &Skill| s.metadata.name == skill.metadata.name) {
50                        skills.push(skill);
51                    }
52                }
53            }
54        }
55
56        Ok(skills)
57    }
58
59    pub fn load_all(&self) -> AppResult<Vec<Skill>> {
60        let home = stynx_code_config::home_dir()
61            .map(|p| p.to_string_lossy().to_string())
62            .unwrap_or_else(|| ".".to_string());
63        let mut all: Vec<Skill> = Vec::new();
64
65        let dirs: Vec<(PathBuf, bool)> = vec![
66            (PathBuf::from(format!("{home}/.claude/skills")), true),
67            (PathBuf::from(".claude/skills"), false),
68            (PathBuf::from(format!("{home}/.claude/commands")), true),
69            (PathBuf::from(".claude/commands"), false),
70        ];
71
72        for (dir, is_user) in dirs {
73            if !dir.is_dir() {
74                continue;
75            }
76            let skills = self.load_from_directory(&dir)?;
77            for mut skill in skills {
78                if !all.iter().any(|s| s.metadata.name == skill.metadata.name) {
79                    skill.source = if is_user {
80                        SkillSource::UserSkill(dir.clone())
81                    } else {
82                        SkillSource::ProjectSkill(dir.clone())
83                    };
84                    all.push(skill);
85                }
86            }
87        }
88
89        for skill in bundled_skills() {
90            if !all.iter().any(|s| s.metadata.name == skill.metadata.name) {
91                all.push(skill);
92            }
93        }
94
95        Ok(all)
96    }
97}
98
99impl Default for SkillLoader {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105pub fn parse_skill_file(path: &Path) -> AppResult<Skill> {
106    let raw = std::fs::read_to_string(path)
107        .map_err(|e| AppError::Internal(anyhow::anyhow!("Failed to read {:?}: {}", path, e)))?;
108
109    let (frontmatter, body) = split_frontmatter(&raw);
110
111    let name = extract_field(&frontmatter, "name").or_else(|| {
112        path.file_stem()
113            .and_then(|s| s.to_str())
114            .map(|s| s.to_string())
115    }).ok_or_else(|| AppError::BadRequest(format!("Cannot determine name for {:?}", path)))?;
116
117    let description = extract_field(&frontmatter, "description").unwrap_or_default();
118
119    let triggers = extract_field(&frontmatter, "triggers")
120        .map(|v| {
121            v.split(',')
122                .map(|t| t.trim().to_string())
123                .filter(|t| !t.is_empty())
124                .collect()
125        })
126        .unwrap_or_default();
127
128    let model = extract_field(&frontmatter, "model");
129
130    let is_hidden = extract_field(&frontmatter, "is_hidden")
131        .or_else(|| extract_field(&frontmatter, "hidden"))
132        .map(|v| matches!(v.to_lowercase().as_str(), "true" | "yes" | "1"))
133        .unwrap_or(false);
134
135    Ok(Skill {
136        metadata: SkillMetadata {
137            name,
138            description,
139            triggers,
140            model,
141            is_hidden,
142        },
143        content: body.trim().to_string(),
144
145        source: SkillSource::UserSkill(path.to_path_buf()),
146    })
147}
148
149fn split_frontmatter(content: &str) -> (String, String) {
150    let content = content.trim_start();
151    if !content.starts_with("---") {
152        return (String::new(), content.to_string());
153    }
154    let rest = &content[3..];
155    if let Some(end) = rest.find("\n---") {
156        let frontmatter = rest[..end].to_string();
157        let body = rest[end + 4..].to_string();
158        (frontmatter, body)
159    } else {
160        (String::new(), content.to_string())
161    }
162}
163
164fn extract_field(frontmatter: &str, key: &str) -> Option<String> {
165    for line in frontmatter.lines() {
166        if let Some(rest) = line.strip_prefix(&format!("{key}:")) {
167            let val = rest.trim().trim_matches('"').trim_matches('\'').to_string();
168            if !val.is_empty() {
169                return Some(val);
170            }
171        }
172    }
173    None
174}