1use 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
24pub 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
65pub 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
94pub 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
146fn 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
172pub 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 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 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('&', "&")
205 .replace('<', "<")
206 .replace('>', ">")
207 .replace('"', """)
208 .replace('\'', "'")
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<b</name>"));
289 assert!(xml.contains("uses & 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}