stynx_code_skills/application/
skill_loader.rs1use 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 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 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 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 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 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
111pub 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 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 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}