supercode-harness 0.4.15

The optional native Supercode agent and tool harness
Documentation
//! BP-6 (catalog D1 "Skill-invocation surface", D2 "Skills
//! (progressive-disclosure packages)"): the `skill` tool — Claude Code's
//! `Skill` shape, one tool through which every skill is invoked rather than
//! one tool per skill (cc§1 "Skill", `docs:tools-reference`).
//!
//! This is the load half of progressive disclosure. Discovery
//! ([`crate::skills::load_for_config`]) reads only each package's
//! frontmatter, and only its `name`/`description` reach the system prompt;
//! a body is read from disk here, on the model's own call, and nowhere else.

use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{json, Value};

use crate::error::{Error, Result};
use crate::skills::LoopSkill;
use crate::tools::{Tool, ToolContext};

/// The registered name of the skill-invocation tool.
pub const SKILL_TOOL: &str = "skill";

/// How many skill names an error lists back to the model.
const MAX_SUGGESTIONS: usize = 40;

/// Load a discovered skill package's body on demand.
///
/// Holds the discovered INDEX (names, descriptions, manifest paths) that
/// [`crate::tools::ToolRegistry::from_config`] resolved for this config —
/// never a body. The set is fixed for the tool's lifetime, the same way the
/// prompt's index section is: a skill installed mid-session appears after
/// the next agent construction, exactly as it would in the harnesses this
/// transcribes.
pub struct SkillTool {
    skills: Vec<LoopSkill>,
    /// BP-5: the permission-engine authorization a loaded body's
    /// `` !`cmd` `` runs under. Executes nothing unless the config that
    /// built this tool turned `[core.skills] shell_injection` on.
    shell: crate::skills::ShellInjection,
}

#[derive(Deserialize)]
struct SkillArgs {
    name: String,
    #[serde(default)]
    arguments: Option<String>,
}

impl SkillTool {
    /// Build the tool over an already-discovered skill set. Loads bodies
    /// with no shell injection — see [`Self::with_shell`].
    pub fn new(skills: Vec<LoopSkill>) -> Self {
        Self {
            skills,
            shell: crate::skills::ShellInjection::disabled(),
        }
    }

    /// BP-5: the same tool, loading bodies under `shell`'s authorization —
    /// what [`crate::tools::ToolRegistry::from_config`] builds, so the tool
    /// door and the `/name` door expand a body identically.
    pub fn with_shell(mut self, shell: crate::skills::ShellInjection) -> Self {
        self.shell = shell;
        self
    }

    /// The skills this tool can load.
    pub fn skills(&self) -> &[LoopSkill] {
        &self.skills
    }

    /// Resolve an invocation name — [`crate::skills::find_skill`], the same
    /// resolver `/name` and `$slug` go through.
    pub fn find(&self, name: &str) -> Option<&LoopSkill> {
        crate::skills::find_skill(&self.skills, name)
    }

    /// The message a miss returns: never silence, always the set that WAS
    /// discovered, so a model that guessed a name can correct itself.
    fn unknown(&self, name: &str) -> Error {
        let mut names: Vec<&str> = self
            .skills
            .iter()
            .map(|skill| skill.name.as_str())
            .take(MAX_SUGGESTIONS)
            .collect();
        names.sort_unstable();
        let known = if names.is_empty() {
            "no skills are installed in the roots this config reads".to_string()
        } else {
            format!("known skills: {}", names.join(", "))
        };
        Error::tool(SKILL_TOOL, format!("no skill named `{name}` — {known}"))
    }
}

#[async_trait]
impl Tool for SkillTool {
    fn name(&self) -> &str {
        SKILL_TOOL
    }

    fn description(&self) -> &str {
        "Load a skill's full instructions on demand. The system prompt lists only each \
         skill's name and description; call this with a name from that list to read the \
         skill's body into the conversation, then follow it. `arguments` is substituted \
         for `$ARGUMENTS` in the body."
    }

    fn parameters(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "name": {
                    "type": "string",
                    "description": "Skill name exactly as the skills list gives it (qualified `dir:skill` / `plugin:skill` names included)."
                },
                "arguments": {
                    "type": "string",
                    "description": "Text substituted for `$ARGUMENTS` (and `$1`..`$9`) inside the skill body."
                }
            },
            "required": ["name"],
            "additionalProperties": false
        })
    }

    async fn execute(&self, args: Value, _ctx: &ToolContext) -> Result<String> {
        let a: SkillArgs = serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
            tool: SKILL_TOOL.to_string(),
            message: e.to_string(),
        })?;
        let skill = self.find(&a.name).ok_or_else(|| self.unknown(&a.name))?;
        let arguments = a.arguments.unwrap_or_default();
        let body = skill
            .body_with_shell(&arguments, &self.shell)
            .map_err(|e| Error::tool(SKILL_TOOL, format!("{}: {e}", skill.manifest.display())))?;
        Ok(crate::skills::render_skill(skill, &body))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::skills::SkillScope;
    use std::path::PathBuf;

    fn skill(name: &str, dir: PathBuf) -> LoopSkill {
        LoopSkill {
            name: name.to_string(),
            description: Some("does a thing".into()),
            version: None,
            scope: SkillScope::Project,
            manifest: dir.join("SKILL.md"),
            dir,
            model_invocable: true,
            allowed_tools: Vec::new(),
            argument_names: Vec::new(),
            argument_hint: None,
        }
    }

    #[test]
    fn qualified_names_resolve_by_leaf_only_when_unambiguous() {
        let tool = SkillTool::new(vec![
            skill("infra:deploy", PathBuf::from("/tmp/a")),
            skill("release", PathBuf::from("/tmp/b")),
        ]);
        assert_eq!(tool.find("infra:deploy").unwrap().name, "infra:deploy");
        assert_eq!(tool.find("deploy").unwrap().name, "infra:deploy");
        assert_eq!(tool.find("RELEASE").unwrap().name, "release");
        assert!(tool.find("nope").is_none());

        let ambiguous = SkillTool::new(vec![
            skill("infra:deploy", PathBuf::from("/tmp/a")),
            skill("web:deploy", PathBuf::from("/tmp/b")),
        ]);
        assert!(
            ambiguous.find("deploy").is_none(),
            "an ambiguous leaf must be refused, not silently guessed"
        );
    }

    #[test]
    fn an_unknown_name_names_the_discovered_set() {
        let tool = SkillTool::new(vec![skill("release", PathBuf::from("/tmp/b"))]);
        let message = tool.unknown("deploy").to_string();
        assert!(message.contains("deploy"), "{message}");
        assert!(message.contains("release"), "{message}");
    }
}