1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::paths::{expand_tilde, user_config_dir};
6use crate::ResolvedConfig;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum SkillSource {
11 CursorUser,
12 User,
13 CursorProject,
14 Project,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct Skill {
20 pub name: String,
21 pub description: String,
22 pub path: PathBuf,
23 pub source: SkillSource,
24}
25
26pub fn discover_skills(config: &ResolvedConfig) -> Vec<Skill> {
31 let mut by_name: HashMap<String, Skill> = HashMap::new();
32
33 scan_and_merge(
34 &mut by_name,
35 &expand_tilde("~/.cursor/skills"),
36 SkillSource::CursorUser,
37 );
38 scan_and_merge(
39 &mut by_name,
40 &user_config_dir().join("skills"),
41 SkillSource::User,
42 );
43
44 if let Some(root) = &config.project_root {
45 scan_and_merge(
46 &mut by_name,
47 &root.join(".cursor/skills"),
48 SkillSource::CursorProject,
49 );
50 scan_and_merge(
51 &mut by_name,
52 &root.join(".codei/skills"),
53 SkillSource::Project,
54 );
55 }
56
57 let mut skills: Vec<_> = by_name.into_values().collect();
58 skills.sort_by(|a, b| a.name.cmp(&b.name));
59 skills
60}
61
62pub fn format_skills_for_prompt(skills: &[Skill]) -> String {
64 if skills.is_empty() {
65 return String::new();
66 }
67
68 let mut lines = vec![
69 "## Available skills".to_string(),
70 "Specialized instructions live in skill files. When a user task matches a skill description, call the `read_skill` tool with the skill name before proceeding.".to_string(),
71 String::new(),
72 ];
73 for skill in skills {
74 lines.push(format!("- **{}**: {}", skill.name, skill.description));
75 }
76 lines.join("\n")
77}
78
79pub fn find_skill<'a>(skills: &'a [Skill], query: &str) -> Option<&'a Skill> {
81 let query = query.trim();
82 if query.is_empty() {
83 return None;
84 }
85 let query_lower = query.to_ascii_lowercase();
86 skills
87 .iter()
88 .find(|skill| skill.name.eq_ignore_ascii_case(query))
89 .or_else(|| {
90 skills.iter().find(|skill| {
91 skill
92 .path
93 .parent()
94 .and_then(|p| p.file_name())
95 .is_some_and(|name| name.eq_ignore_ascii_case(query))
96 })
97 })
98 .or_else(|| {
99 skills
100 .iter()
101 .find(|skill| skill.name.to_ascii_lowercase() == query_lower)
102 })
103}
104
105pub fn read_skill_body(skill: &Skill) -> std::io::Result<String> {
107 let raw = fs::read_to_string(&skill.path)?;
108 Ok(strip_frontmatter(&raw).trim().to_string())
109}
110
111fn scan_and_merge(by_name: &mut HashMap<String, Skill>, root: &Path, source: SkillSource) {
112 let entries = match fs::read_dir(root) {
113 Ok(entries) => entries,
114 Err(_) => return,
115 };
116
117 for entry in entries.flatten() {
118 let path = entry.path();
119 if !path.is_dir() {
120 continue;
121 }
122 let skill_md = path.join("SKILL.md");
123 if !skill_md.is_file() {
124 continue;
125 }
126 if let Some(skill) = parse_skill_file(&skill_md, source) {
127 by_name.insert(skill.name.clone(), skill);
128 }
129 }
130}
131
132fn parse_skill_file(path: &Path, source: SkillSource) -> Option<Skill> {
133 let raw = fs::read_to_string(path).ok()?;
134 let (frontmatter, body) = split_frontmatter(&raw);
135 let dir_name = path
136 .parent()
137 .and_then(|p| p.file_name())
138 .map(|n| n.to_string_lossy().into_owned())
139 .unwrap_or_else(|| "skill".into());
140
141 let mut name = dir_name.clone();
142 let mut description = String::new();
143
144 if let Some(meta) = frontmatter {
145 for line in meta.lines() {
146 let line = line.trim();
147 if let Some(value) = line.strip_prefix("name:") {
148 let value = value.trim().trim_matches('"').trim_matches('\'');
149 if !value.is_empty() {
150 name = value.to_string();
151 }
152 } else if let Some(value) = line.strip_prefix("description:") {
153 description = value
154 .trim()
155 .trim_matches('"')
156 .trim_matches('\'')
157 .to_string();
158 }
159 }
160 }
161
162 if description.is_empty() {
163 description = body
164 .lines()
165 .map(str::trim)
166 .find(|line| !line.is_empty() && !line.starts_with('#'))
167 .unwrap_or("Specialized agent instructions.")
168 .chars()
169 .take(200)
170 .collect();
171 }
172
173 Some(Skill {
174 name,
175 description,
176 path: path.to_path_buf(),
177 source,
178 })
179}
180
181fn split_frontmatter(content: &str) -> (Option<&str>, &str) {
182 let trimmed = content.trim_start_matches('\u{feff}');
183 if !trimmed.starts_with("---") {
184 return (None, trimmed);
185 }
186 let rest = &trimmed[3..];
187 let Some(end) = rest.find("\n---") else {
188 return (None, trimmed);
189 };
190 let meta = &rest[..end];
191 let body = rest[end + 4..].trim_start_matches('\n');
192 (Some(meta), body)
193}
194
195fn strip_frontmatter(content: &str) -> String {
196 match split_frontmatter(content) {
197 (Some(_), body) => body.to_string(),
198 (None, body) => body.to_string(),
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use std::path::PathBuf;
206
207 #[test]
208 fn parses_skill_frontmatter() {
209 let dir = tempfile::tempdir().unwrap();
210 let skill_dir = dir.path().join("pdf-helper");
211 fs::create_dir_all(&skill_dir).unwrap();
212 fs::write(
213 skill_dir.join("SKILL.md"),
214 "---\nname: pdf-helper\ndescription: Process PDF files.\n---\n\n# PDF\nDo work.",
215 )
216 .unwrap();
217
218 let config = ResolvedConfig {
219 config: Default::default(),
220 cwd: dir.path().to_path_buf(),
221 project_root: Some(dir.path().to_path_buf()),
222 user_config_path: PathBuf::from("/tmp/config.toml"),
223 project_config_path: None,
224 };
225
226 fs::create_dir_all(dir.path().join(".codei/skills")).unwrap();
227 fs::rename(skill_dir, dir.path().join(".codei/skills/pdf-helper")).unwrap();
228
229 let skills = discover_skills(&config);
230 assert_eq!(skills.len(), 1);
231 assert_eq!(skills[0].name, "pdf-helper");
232 assert_eq!(skills[0].description, "Process PDF files.");
233 assert_eq!(read_skill_body(&skills[0]).unwrap(), "# PDF\nDo work.");
234 }
235
236 #[test]
237 fn find_skill_matches_name_case_insensitively() {
238 let skill = Skill {
239 name: "PDF-Helper".into(),
240 description: "x".into(),
241 path: PathBuf::from("/tmp/pdf-helper/SKILL.md"),
242 source: SkillSource::Project,
243 };
244 assert!(find_skill(&[skill], "pdf-helper").is_some());
245 }
246}