Skip to main content

speccoder_runtime/
skills.rs

1//! Installed Agent Skills: `SKILL.md` files under `.claude/skills/`
2//! and `.agents/skills/`, as spec formats install their commands.
3//!
4//! speccoder hosts the planning skills (`speckit-specify`, `-clarify`,
5//! `-plan`, `-tasks`, `-analyze`, `-checklist`, `-constitution`, and others)
6//! with its own model and policy. It refuses the ones it does itself, with
7//! evidence (`speckit-implement` → `speccoder run`, `speckit-converge` →
8//! `speccoder converge`), and the ones that reach outside the repository.
9
10use std::path::{Path, PathBuf};
11
12use serde::Serialize;
13
14use crate::format::Planning;
15
16/// Where skills are installed, relative to the project root.
17pub const DIRS: [&str; 2] = [".claude/skills", ".agents/skills"];
18
19/// One installed skill.
20#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
21pub struct Skill {
22    /// `name` from the frontmatter (else the directory name).
23    pub name: String,
24    /// `description` from the frontmatter.
25    pub description: String,
26    /// The `SKILL.md`, relative to the root.
27    pub path: String,
28    /// `user-invocable` is not `false`.
29    pub invocable: bool,
30    /// Why speccoder will not host it, if it will not.
31    pub refused: Option<String>,
32}
33
34/// Skills speccoder does not host in a project of the reference format, and
35/// what to use instead.
36#[must_use]
37pub fn refusal(name: &str) -> Option<String> {
38    (crate::format::reference().planning().refused)(name)
39}
40
41/// The frontmatter's simple `key: value` lines and the body after it.
42#[must_use]
43pub fn split(text: &str) -> (Vec<(String, String)>, String) {
44    let Some(rest) = text
45        .strip_prefix("---\n")
46        .or_else(|| text.strip_prefix("---\r\n"))
47    else {
48        return (Vec::new(), text.to_owned());
49    };
50    let Some(end) = rest.find("\n---") else {
51        return (Vec::new(), text.to_owned());
52    };
53    let fields = rest[..end]
54        .lines()
55        .filter(|l| !l.starts_with(' ') && !l.starts_with('\t'))
56        .filter_map(|l| l.split_once(':'))
57        .map(|(k, v)| {
58            let v = v.trim().trim_matches('"').trim_matches('\'').to_owned();
59            (k.trim().to_owned(), v)
60        })
61        .collect();
62    let body = rest[end + 4..].trim_start_matches(['\r', '\n']).to_owned();
63    (fields, body)
64}
65
66/// Every installed skill under `root`, by name, for a project of the
67/// reference format.
68#[must_use]
69pub fn discover(root: &Path) -> Vec<Skill> {
70    discover_with(root, crate::format::reference().planning())
71}
72
73/// [`discover`] for a project whose format hosts `planning`.
74#[must_use]
75pub fn discover_with(root: &Path, planning: &Planning) -> Vec<Skill> {
76    let mut out: Vec<Skill> = Vec::new();
77    for dir in DIRS {
78        let Ok(entries) = std::fs::read_dir(root.join(dir)) else {
79            continue;
80        };
81        for e in entries.flatten() {
82            let file = e.path().join("SKILL.md");
83            let Ok(text) = std::fs::read_to_string(&file) else {
84                continue;
85            };
86            let (fields, _) = split(&text);
87            let get = |k: &str| fields.iter().find(|(f, _)| f == k).map(|(_, v)| v.clone());
88            let name = get("name").unwrap_or_else(|| e.file_name().to_string_lossy().into_owned());
89            if out.iter().any(|s| s.name == name) {
90                continue; // the first directory wins
91            }
92            out.push(Skill {
93                refused: (planning.refused)(&name),
94                description: get("description").unwrap_or_default(),
95                path: format!("{dir}/{}/SKILL.md", e.file_name().to_string_lossy()),
96                invocable: get("user-invocable").is_none_or(|v| v != "false"),
97                name,
98            });
99        }
100    }
101    out.sort_by(|a, b| a.name.cmp(&b.name));
102    out
103}
104
105/// The skill's instruction with `$ARGUMENTS` replaced (Spec Kit's convention).
106pub fn instruction(root: &Path, skill: &Skill, arguments: &str) -> Result<String, String> {
107    let path: PathBuf = root.join(&skill.path);
108    let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
109    let (_, body) = split(&text);
110    Ok(body.replace("$ARGUMENTS", arguments))
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn frontmatter_and_body_split_and_nested_keys_are_ignored() {
119        let text = "---\nname: \"speckit-plan\"\ndescription: \"Plan it.\"\nmetadata:\n  author: \"x\"\nuser-invocable: true\n---\n\n## User Input\n\n$ARGUMENTS\n";
120        let (fields, body) = split(text);
121        assert_eq!(
122            fields,
123            [
124                ("name".to_owned(), "speckit-plan".to_owned()),
125                ("description".to_owned(), "Plan it.".to_owned()),
126                ("metadata".to_owned(), String::new()),
127                ("user-invocable".to_owned(), "true".to_owned()),
128            ]
129        );
130        assert!(body.starts_with("## User Input"));
131        assert!(refusal("speckit-implement").is_some() && refusal("speckit-plan").is_none());
132    }
133}