Skip to main content

cersei_tools/skills/
mod.rs

1//! Skills system: discover, load, and execute skill prompt templates.
2//!
3//! Supported formats:
4//! - `.claude/commands/*.md` with `$ARGUMENTS` expansion
5//! - `.claude/skills/**/SKILL.md` with YAML frontmatter
6//! - Custom directories: any path passed to the scanner
7
8pub mod bundled;
9pub mod discovery;
10
11use serde::{Deserialize, Serialize};
12
13/// Metadata about a discovered skill.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct SkillMeta {
16    /// Skill name (filename without .md, or frontmatter `name:` field).
17    pub name: String,
18    /// One-line description (from frontmatter or first content line).
19    pub description: String,
20    /// Absolute path to the .md file (None for bundled skills).
21    pub path: Option<String>,
22    /// Whether this is a built-in bundled skill.
23    pub bundled: bool,
24    /// Alternative names for this skill.
25    pub aliases: Vec<String>,
26    /// Tool restrictions: None = all tools, Some = only these tools.
27    pub allowed_tools: Option<Vec<String>>,
28    /// Usage hint for arguments.
29    pub argument_hint: Option<String>,
30    /// Skill format detected.
31    pub format: SkillFormat,
32}
33
34/// Which format the skill file uses.
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
36pub enum SkillFormat {
37    /// Commands format: .claude/commands/<name>.md
38    Commands,
39    /// Skills format: .claude/skills/<name>/SKILL.md with required frontmatter
40    Skills,
41    /// Bundled (compiled into binary)
42    Bundled,
43}
44
45/// A loaded skill ready for expansion.
46#[derive(Debug, Clone)]
47pub struct LoadedSkill {
48    pub meta: SkillMeta,
49    /// Raw content (frontmatter stripped).
50    pub content: String,
51}
52
53impl LoadedSkill {
54    /// Expand the skill template with given arguments.
55    pub fn expand(&self, args: Option<&str>) -> String {
56        let mut result = self.content.clone();
57
58        // Replace $ARGUMENTS_SUFFIX first (it contains $ARGUMENTS as substring)
59        if let Some(args) = args {
60            result = result.replace("$ARGUMENTS_SUFFIX", &format!(": {}", args));
61            result = result.replace("$ARGUMENTS", args);
62        } else {
63            result = result.replace("$ARGUMENTS_SUFFIX", "");
64            result = result.replace("$ARGUMENTS", "");
65        }
66
67        result
68    }
69}
70
71/// Strip YAML frontmatter from content.
72/// Handles `---\n...\n---\n` format.
73pub fn strip_frontmatter(content: &str) -> String {
74    if content.starts_with("---") {
75        let after_open = &content[3..];
76        if let Some(close_pos) = after_open.find("\n---") {
77            let rest = &after_open[close_pos + 4..];
78            return rest.trim_start_matches('\n').to_string();
79        }
80    }
81    content.to_string()
82}
83
84/// Parse YAML frontmatter into key-value pairs.
85/// Returns (frontmatter_map, content_after_frontmatter).
86pub fn parse_frontmatter(content: &str) -> (std::collections::HashMap<String, String>, String) {
87    let mut map = std::collections::HashMap::new();
88
89    if !content.starts_with("---") {
90        return (map, content.to_string());
91    }
92
93    let after_open = &content[3..];
94    if let Some(close_pos) = after_open.find("\n---") {
95        let yaml_block = &after_open[..close_pos].trim();
96        let body = after_open[close_pos + 4..]
97            .trim_start_matches('\n')
98            .to_string();
99
100        // Simple YAML key: value parser (handles single-line values)
101        for line in yaml_block.lines() {
102            let line = line.trim();
103            if line.is_empty() || line.starts_with('#') {
104                continue;
105            }
106            if let Some(colon_pos) = line.find(':') {
107                let key = line[..colon_pos].trim().to_string();
108                let value = line[colon_pos + 1..].trim().to_string();
109                map.insert(key, value);
110            }
111        }
112
113        return (map, body);
114    }
115
116    (map, content.to_string())
117}
118
119/// Extract description from content: first non-empty line (max 80 chars).
120/// Headings are stripped of their `#` prefix.
121pub fn extract_description(content: &str) -> String {
122    for line in content.lines() {
123        let trimmed = line.trim();
124        if trimmed.is_empty() || trimmed == "---" {
125            continue;
126        }
127        // Strip heading markers
128        let trimmed = trimmed.trim_start_matches('#').trim();
129        let desc = if trimmed.len() > 80 {
130            format!("{}...", &trimmed[..77])
131        } else {
132            trimmed.to_string()
133        };
134        return desc;
135    }
136    String::new()
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn test_strip_frontmatter_with_yaml() {
145        let content = "---\nname: test\ndescription: A test skill\n---\n\n# Body\n\nContent here.";
146        let stripped = strip_frontmatter(content);
147        assert!(stripped.starts_with("# Body"));
148        assert!(!stripped.contains("name: test"));
149    }
150
151    #[test]
152    fn test_strip_frontmatter_without_yaml() {
153        let content = "# Just a heading\n\nSome content.";
154        let stripped = strip_frontmatter(content);
155        assert_eq!(stripped, content);
156    }
157
158    #[test]
159    fn test_parse_frontmatter() {
160        let content = "---\nname: my-skill\ndescription: Does things\nallowed-tools: Read, Write\n---\n\nBody";
161        let (fm, body) = parse_frontmatter(content);
162        assert_eq!(fm.get("name").unwrap(), "my-skill");
163        assert_eq!(fm.get("description").unwrap(), "Does things");
164        assert!(body.starts_with("Body"));
165    }
166
167    #[test]
168    fn test_expand_with_arguments() {
169        let skill = LoadedSkill {
170            meta: SkillMeta {
171                name: "test".into(),
172                description: "test".into(),
173                path: None,
174                bundled: true,
175                aliases: vec![],
176                allowed_tools: None,
177                argument_hint: None,
178                format: SkillFormat::Bundled,
179            },
180            content: "Do $ARGUMENTS in the codebase$ARGUMENTS_SUFFIX".into(),
181        };
182
183        let expanded = skill.expand(Some("fix tests"));
184        assert_eq!(expanded, "Do fix tests in the codebase: fix tests");
185
186        let expanded_empty = skill.expand(None);
187        assert_eq!(expanded_empty, "Do  in the codebase");
188    }
189
190    #[test]
191    fn test_extract_description() {
192        assert_eq!(
193            extract_description("# Heading\n\nFirst real line here."),
194            "Heading"
195        );
196        // extract_description works on raw content — frontmatter lines are skipped by the --- check
197        assert_eq!(
198            extract_description(&strip_frontmatter("---\nfoo\n---\nContent after FM")),
199            "Content after FM"
200        );
201        assert_eq!(extract_description(""), "");
202    }
203}