Skip to main content

kiss_coding/
skills.rs

1//! Agent Skills (agentskills.io style): Markdown files with YAML frontmatter,
2//! discovered from user/project locations, surfaced in the system prompt and
3//! through `$name` or `/name` input tokens. The older `/skill:name` form is
4//! also accepted.
5
6use std::path::{Path, PathBuf};
7
8use anyhow::{Context, Result};
9
10#[derive(Debug, Clone, PartialEq)]
11pub struct Skill {
12    pub name: String,
13    pub description: String,
14    pub file_path: PathBuf,
15    pub disable_model_invocation: bool,
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct SkillInvocation {
20    pub skill_names: Vec<String>,
21    pub request: String,
22}
23
24/// Parse leading skill commands and `$name` skill mentions anywhere in user input.
25pub fn parse_invocation(input: &str, skills: &[Skill]) -> Option<SkillInvocation> {
26    let mut rest = input.trim_start();
27    let mut skill_names = Vec::new();
28
29    loop {
30        let token_end = rest.find(char::is_whitespace).unwrap_or(rest.len());
31        let token = &rest[..token_end];
32        let candidate = token
33            .strip_prefix('$')
34            .or_else(|| token.strip_prefix("/skill:"))
35            .or_else(|| token.strip_prefix('/'));
36        let Some(skill) = candidate
37            .filter(|name| !name.is_empty())
38            .and_then(|name| skills.iter().find(|skill| skill.name == name))
39        else {
40            break;
41        };
42        if !skill_names.contains(&skill.name) {
43            skill_names.push(skill.name.clone());
44        }
45        rest = rest[token_end..].trim_start();
46    }
47
48    for name in rest
49        .split_whitespace()
50        .filter_map(|token| token.strip_prefix('$'))
51    {
52        if let Some(skill) = skills.iter().find(|skill| skill.name == name)
53            && !skill_names.contains(&skill.name)
54        {
55            skill_names.push(skill.name.clone());
56        }
57    }
58
59    (!skill_names.is_empty()).then(|| SkillInvocation {
60        skill_names,
61        request: rest.trim().to_string(),
62    })
63}
64
65/// Read invoked skill files and build text that is sent only to the model.
66pub fn expand_invocation(invocation: &SkillInvocation, skills: &[Skill]) -> Result<String> {
67    let mut output = String::from(
68        "The user explicitly invoked the following skills. Follow each skill for this turn.\n\n",
69    );
70    for name in &invocation.skill_names {
71        let skill = skills
72            .iter()
73            .find(|skill| &skill.name == name)
74            .with_context(|| format!("invoked skill `{name}` is not available"))?;
75        let content = std::fs::read_to_string(&skill.file_path).with_context(|| {
76            format!(
77                "could not read invoked skill `{name}` at {}",
78                skill.file_path.display()
79            )
80        })?;
81        output.push_str(&format!(
82            "<invoked_skill name=\"{}\" path=\"{}\">\n{}\n</invoked_skill>\n\n",
83            escape_xml(&skill.name),
84            escape_xml(&skill.file_path.display().to_string()),
85            content
86        ));
87    }
88    output.push_str("<user_request>\n");
89    output.push_str(&invocation.request);
90    output.push_str("\n</user_request>");
91    Ok(output)
92}
93
94/// Split a Markdown document into (frontmatter, body).
95pub fn split_frontmatter(text: &str) -> (Option<&str>, &str) {
96    let Some(rest) = text.strip_prefix("---") else {
97        return (None, text);
98    };
99    let rest = rest
100        .strip_prefix('\n')
101        .or_else(|| rest.strip_prefix("\r\n"))
102        .unwrap_or(rest);
103    for end in ["\n---\n", "\n---\r\n", "\r\n---\r\n", "\r\n---\n"] {
104        if let Some(pos) = rest.find(end) {
105            return (Some(&rest[..pos]), &rest[pos + end.len()..]);
106        }
107    }
108    if let Some(stripped) = rest
109        .strip_suffix("\n---")
110        .or_else(|| rest.strip_suffix("\r\n---"))
111    {
112        return (Some(stripped), "");
113    }
114    (None, text)
115}
116
117fn parse_skill(path: &Path) -> Option<Skill> {
118    let text = std::fs::read_to_string(path).ok()?;
119    let (frontmatter, _body) = split_frontmatter(&text);
120    let mut name = None;
121    let mut description = None;
122    let mut disable_model_invocation = false;
123    if let Some(fm) = frontmatter
124        && let Ok(value) = serde_yaml::from_str::<serde_yaml::Value>(fm)
125    {
126        name = value["name"].as_str().map(String::from);
127        description = value["description"].as_str().map(String::from);
128        disable_model_invocation = value["disable-model-invocation"].as_bool().unwrap_or(false);
129    }
130    let fallback_name = || {
131        if path.file_name().and_then(|f| f.to_str()) == Some("SKILL.md") {
132            path.parent()?.file_name()?.to_str().map(String::from)
133        } else {
134            path.file_stem()?.to_str().map(String::from)
135        }
136    };
137    let name = name.or_else(fallback_name)?;
138    Some(Skill {
139        description: description.unwrap_or_else(|| format!("Skill {name}")),
140        name,
141        file_path: path.to_path_buf(),
142        disable_model_invocation,
143    })
144}
145
146/// Recursively find `SKILL.md` files under `dir`. When `include_root_md` is
147/// set, direct `.md` children of `dir` also count as single-file skills.
148fn scan_dir(dir: &Path, include_root_md: bool, out: &mut Vec<Skill>) {
149    let Ok(entries) = std::fs::read_dir(dir) else {
150        return;
151    };
152    for entry in entries.flatten() {
153        let path = entry.path();
154        if path.is_dir() {
155            let skill_md = path.join("SKILL.md");
156            if skill_md.is_file()
157                && let Some(skill) = parse_skill(&skill_md)
158            {
159                out.push(skill);
160            }
161            scan_dir(&path, false, out);
162        } else if include_root_md
163            && path.extension().and_then(|e| e.to_str()) == Some("md")
164            && path.file_name().and_then(|f| f.to_str()) != Some("SKILL.md")
165            && let Some(skill) = parse_skill(&path)
166        {
167            out.push(skill);
168        }
169    }
170}
171
172/// Discover skills from every location pi checks (kiss equivalents).
173pub fn discover(cwd: &Path, project_trusted: bool, extra_paths: &[PathBuf]) -> Vec<Skill> {
174    let mut out: Vec<Skill> = Vec::new();
175    if let Some(home) = dirs::home_dir() {
176        scan_dir(&home.join(".kiss/agent/skills"), true, &mut out);
177        scan_dir(&home.join(".agents/skills"), false, &mut out);
178    }
179    if project_trusted {
180        scan_dir(&cwd.join(".kiss/skills"), true, &mut out);
181        // .agents/skills in cwd and ancestors up to a git root.
182        for dir in cwd.ancestors() {
183            scan_dir(&dir.join(".agents/skills"), false, &mut out);
184            if dir.join(".git").exists() {
185                break;
186            }
187        }
188    }
189    for path in extra_paths {
190        if path.is_dir() {
191            scan_dir(path, true, &mut out);
192        } else if let Some(skill) = parse_skill(path) {
193            out.push(skill);
194        }
195    }
196    // Later discoveries with the same name lose (first location wins).
197    let mut seen = std::collections::HashSet::new();
198    out.retain(|s| seen.insert(s.name.clone()));
199    out
200}
201
202fn escape_xml(value: &str) -> String {
203    value
204        .replace('&', "&amp;")
205        .replace('<', "&lt;")
206        .replace('>', "&gt;")
207        .replace('"', "&quot;")
208        .replace('\'', "&apos;")
209}
210
211pub fn format_skills_for_prompt(skills: &[Skill]) -> String {
212    format_skills_for_prompt_with_tool(skills, "read")
213}
214
215pub fn format_skills_for_prompt_with_tool(skills: &[Skill], tool: &str) -> String {
216    let visible: Vec<&Skill> = skills
217        .iter()
218        .filter(|s| !s.disable_model_invocation)
219        .collect();
220    if visible.is_empty() {
221        return String::new();
222    }
223    let mut out = format!(
224        "The following skills provide specialized instructions for specific tasks.\nUse {tool} to load a skill's file when the task matches its description.\nWhen a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.\n\n<available_skills>\n",
225    );
226    for skill in visible {
227        out.push_str("  <skill>\n");
228        out.push_str(&format!("    <name>{}</name>\n", escape_xml(&skill.name)));
229        out.push_str(&format!(
230            "    <description>{}</description>\n",
231            escape_xml(&skill.description)
232        ));
233        out.push_str(&format!(
234            "    <location>{}</location>\n",
235            escape_xml(&skill.file_path.display().to_string())
236        ));
237        out.push_str("  </skill>\n");
238    }
239    out.push_str("</available_skills>");
240    out
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn frontmatter_split() {
249        let (fm, body) = split_frontmatter("---\nname: x\n---\nbody here");
250        assert_eq!(fm, Some("name: x"));
251        assert_eq!(body, "body here");
252        let (fm, body) = split_frontmatter("no frontmatter");
253        assert!(fm.is_none());
254        assert_eq!(body, "no frontmatter");
255    }
256
257    #[test]
258    fn discovers_skill_dirs_and_root_md() {
259        let dir = tempfile::tempdir().unwrap();
260        let root = dir.path();
261        std::fs::create_dir_all(root.join("pdf-tools")).unwrap();
262        std::fs::write(
263            root.join("pdf-tools/SKILL.md"),
264            "---\nname: pdf-tools\ndescription: Work with PDFs\n---\nInstructions",
265        )
266        .unwrap();
267        std::fs::write(
268            root.join("quick.md"),
269            "---\ndescription: Quick one\n---\nGo",
270        )
271        .unwrap();
272        let mut out = Vec::new();
273        scan_dir(root, true, &mut out);
274        let names: Vec<&str> = out.iter().map(|s| s.name.as_str()).collect();
275        assert!(names.contains(&"pdf-tools"));
276        assert!(names.contains(&"quick"));
277    }
278
279    #[test]
280    fn prompt_xml_escapes() {
281        let skills = vec![Skill {
282            name: "a<b".into(),
283            description: "uses & things".into(),
284            file_path: "/s/SKILL.md".into(),
285            disable_model_invocation: false,
286        }];
287        let xml = format_skills_for_prompt(&skills);
288        assert!(xml.contains("<name>a&lt;b</name>"));
289        assert!(xml.contains("uses &amp; things"));
290    }
291
292    fn skill(name: &str, path: PathBuf) -> Skill {
293        Skill {
294            name: name.into(),
295            description: format!("Use {name}"),
296            file_path: path,
297            disable_model_invocation: false,
298        }
299    }
300
301    #[test]
302    fn parses_chained_dollar_slash_and_compatible_skill_tokens() {
303        let skills = vec![
304            skill("review", "review/SKILL.md".into()),
305            skill("tests", "tests/SKILL.md".into()),
306            skill("docs", "docs/SKILL.md".into()),
307        ];
308        assert_eq!(
309            parse_invocation("  $review /tests /skill:docs check this", &skills),
310            Some(SkillInvocation {
311                skill_names: vec!["review".into(), "tests".into(), "docs".into()],
312                request: "check this".into(),
313            })
314        );
315        assert_eq!(
316            parse_invocation("please use $review and $tests", &skills),
317            Some(SkillInvocation {
318                skill_names: vec!["review".into(), "tests".into()],
319                request: "please use $review and $tests".into(),
320            })
321        );
322        assert_eq!(parse_invocation("$unknown request", &skills), None);
323    }
324
325    #[test]
326    fn expansion_reads_each_skill_once_and_keeps_the_request_last() {
327        let dir = tempfile::tempdir().unwrap();
328        let review = dir.path().join("review.md");
329        let tests = dir.path().join("tests.md");
330        std::fs::write(&review, "Review instructions").unwrap();
331        std::fs::write(&tests, "Test instructions").unwrap();
332        let skills = vec![skill("review", review), skill("tests", tests)];
333        let invocation = parse_invocation("$review /tests inspect it", &skills).unwrap();
334
335        let expanded = expand_invocation(&invocation, &skills).unwrap();
336
337        assert_eq!(expanded.matches("Review instructions").count(), 1);
338        assert_eq!(expanded.matches("Test instructions").count(), 1);
339        assert!(expanded.ends_with("<user_request>\ninspect it\n</user_request>"));
340    }
341
342    #[test]
343    fn expansion_error_names_an_unreadable_skill() {
344        let skills = vec![skill("missing", "/no/such/skill/SKILL.md".into())];
345        let invocation = parse_invocation("$missing run", &skills).unwrap();
346        let error = expand_invocation(&invocation, &skills).unwrap_err();
347        assert!(format!("{error:#}").contains("invoked skill `missing`"));
348    }
349}