Skip to main content

supercode_harness/tools/
skill.rs

1//! BP-6 (catalog D1 "Skill-invocation surface", D2 "Skills
2//! (progressive-disclosure packages)"): the `skill` tool — Claude Code's
3//! `Skill` shape, one tool through which every skill is invoked rather than
4//! one tool per skill (cc§1 "Skill", `docs:tools-reference`).
5//!
6//! This is the load half of progressive disclosure. Discovery
7//! ([`crate::skills::load_for_config`]) reads only each package's
8//! frontmatter, and only its `name`/`description` reach the system prompt;
9//! a body is read from disk here, on the model's own call, and nowhere else.
10
11use async_trait::async_trait;
12use serde::Deserialize;
13use serde_json::{json, Value};
14
15use crate::error::{Error, Result};
16use crate::skills::LoopSkill;
17use crate::tools::{Tool, ToolContext};
18
19/// The registered name of the skill-invocation tool.
20pub const SKILL_TOOL: &str = "skill";
21
22/// How many skill names an error lists back to the model.
23const MAX_SUGGESTIONS: usize = 40;
24
25/// Load a discovered skill package's body on demand.
26///
27/// Holds the discovered INDEX (names, descriptions, manifest paths) that
28/// [`crate::tools::ToolRegistry::from_config`] resolved for this config —
29/// never a body. The set is fixed for the tool's lifetime, the same way the
30/// prompt's index section is: a skill installed mid-session appears after
31/// the next agent construction, exactly as it would in the harnesses this
32/// transcribes.
33pub struct SkillTool {
34    skills: Vec<LoopSkill>,
35    /// BP-5: the permission-engine authorization a loaded body's
36    /// `` !`cmd` `` runs under. Executes nothing unless the config that
37    /// built this tool turned `[core.skills] shell_injection` on.
38    shell: crate::skills::ShellInjection,
39}
40
41#[derive(Deserialize)]
42struct SkillArgs {
43    name: String,
44    #[serde(default)]
45    arguments: Option<String>,
46}
47
48impl SkillTool {
49    /// Build the tool over an already-discovered skill set. Loads bodies
50    /// with no shell injection — see [`Self::with_shell`].
51    pub fn new(skills: Vec<LoopSkill>) -> Self {
52        Self {
53            skills,
54            shell: crate::skills::ShellInjection::disabled(),
55        }
56    }
57
58    /// BP-5: the same tool, loading bodies under `shell`'s authorization —
59    /// what [`crate::tools::ToolRegistry::from_config`] builds, so the tool
60    /// door and the `/name` door expand a body identically.
61    pub fn with_shell(mut self, shell: crate::skills::ShellInjection) -> Self {
62        self.shell = shell;
63        self
64    }
65
66    /// The skills this tool can load.
67    pub fn skills(&self) -> &[LoopSkill] {
68        &self.skills
69    }
70
71    /// Resolve an invocation name — [`crate::skills::find_skill`], the same
72    /// resolver `/name` and `$slug` go through.
73    pub fn find(&self, name: &str) -> Option<&LoopSkill> {
74        crate::skills::find_skill(&self.skills, name)
75    }
76
77    /// The message a miss returns: never silence, always the set that WAS
78    /// discovered, so a model that guessed a name can correct itself.
79    fn unknown(&self, name: &str) -> Error {
80        let mut names: Vec<&str> = self
81            .skills
82            .iter()
83            .map(|skill| skill.name.as_str())
84            .take(MAX_SUGGESTIONS)
85            .collect();
86        names.sort_unstable();
87        let known = if names.is_empty() {
88            "no skills are installed in the roots this config reads".to_string()
89        } else {
90            format!("known skills: {}", names.join(", "))
91        };
92        Error::tool(SKILL_TOOL, format!("no skill named `{name}` — {known}"))
93    }
94}
95
96#[async_trait]
97impl Tool for SkillTool {
98    fn name(&self) -> &str {
99        SKILL_TOOL
100    }
101
102    fn description(&self) -> &str {
103        "Load a skill's full instructions on demand. The system prompt lists only each \
104         skill's name and description; call this with a name from that list to read the \
105         skill's body into the conversation, then follow it. `arguments` is substituted \
106         for `$ARGUMENTS` in the body."
107    }
108
109    fn parameters(&self) -> Value {
110        json!({
111            "type": "object",
112            "properties": {
113                "name": {
114                    "type": "string",
115                    "description": "Skill name exactly as the skills list gives it (qualified `dir:skill` / `plugin:skill` names included)."
116                },
117                "arguments": {
118                    "type": "string",
119                    "description": "Text substituted for `$ARGUMENTS` (and `$1`..`$9`) inside the skill body."
120                }
121            },
122            "required": ["name"],
123            "additionalProperties": false
124        })
125    }
126
127    async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
128        let a: SkillArgs = serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
129            tool: SKILL_TOOL.to_string(),
130            message: e.to_string(),
131        })?;
132        let skill = self.find(&a.name).ok_or_else(|| self.unknown(&a.name))?;
133        let arguments = a.arguments.unwrap_or_default();
134        let body = skill
135            .body_with_shell(&arguments, &self.shell)
136            .map_err(|e| Error::tool(SKILL_TOOL, format!("{}: {e}", skill.manifest.display())))?;
137        Ok(crate::skills::render_skill(skill, &body))
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::skills::SkillScope;
145    use std::path::PathBuf;
146
147    fn skill(name: &str, dir: PathBuf) -> LoopSkill {
148        LoopSkill {
149            name: name.to_string(),
150            description: Some("does a thing".into()),
151            version: None,
152            scope: SkillScope::Project,
153            manifest: dir.join("SKILL.md"),
154            dir,
155            model_invocable: true,
156            allowed_tools: Vec::new(),
157            argument_names: Vec::new(),
158            argument_hint: None,
159        }
160    }
161
162    #[test]
163    fn qualified_names_resolve_by_leaf_only_when_unambiguous() {
164        let tool = SkillTool::new(vec![
165            skill("infra:deploy", PathBuf::from("/tmp/a")),
166            skill("release", PathBuf::from("/tmp/b")),
167        ]);
168        assert_eq!(tool.find("infra:deploy").unwrap().name, "infra:deploy");
169        assert_eq!(tool.find("deploy").unwrap().name, "infra:deploy");
170        assert_eq!(tool.find("RELEASE").unwrap().name, "release");
171        assert!(tool.find("nope").is_none());
172
173        let ambiguous = SkillTool::new(vec![
174            skill("infra:deploy", PathBuf::from("/tmp/a")),
175            skill("web:deploy", PathBuf::from("/tmp/b")),
176        ]);
177        assert!(
178            ambiguous.find("deploy").is_none(),
179            "an ambiguous leaf must be refused, not silently guessed"
180        );
181    }
182
183    #[test]
184    fn an_unknown_name_names_the_discovered_set() {
185        let tool = SkillTool::new(vec![skill("release", PathBuf::from("/tmp/b"))]);
186        let message = tool.unknown("deploy").to_string();
187        assert!(message.contains("deploy"), "{message}");
188        assert!(message.contains("release"), "{message}");
189    }
190}