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