Skip to main content

codei_config/
skills.rs

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/// Where a skill was discovered on disk.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum SkillSource {
11    CursorUser,
12    User,
13    CursorProject,
14    Project,
15}
16
17/// Metadata for a discovered `SKILL.md`.
18#[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
26/// Discover skills from user and project directories.
27///
28/// Search order (later overrides earlier on name conflict):
29/// `~/.cursor/skills`, `~/.config/codei/skills`, `{project}/.cursor/skills`, `{project}/.codei/skills`.
30pub 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
62/// Format a compact skill index for the system prompt.
63pub 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
79/// Find a skill by `name` (case-insensitive) or parent directory name.
80pub 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
105/// Read skill instructions (body without YAML frontmatter).
106pub 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}