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};
pub const SKILL_TOOL: &str = "skill";
const MAX_SUGGESTIONS: usize = 40;
pub struct SkillTool {
skills: Vec<LoopSkill>,
shell: crate::skills::ShellInjection,
}
#[derive(Deserialize)]
struct SkillArgs {
name: String,
#[serde(default)]
arguments: Option<String>,
}
impl SkillTool {
pub fn new(skills: Vec<LoopSkill>) -> Self {
Self {
skills,
shell: crate::skills::ShellInjection::disabled(),
}
}
pub fn with_shell(mut self, shell: crate::skills::ShellInjection) -> Self {
self.shell = shell;
self
}
pub fn skills(&self) -> &[LoopSkill] {
&self.skills
}
pub fn find(&self, name: &str) -> Option<&LoopSkill> {
crate::skills::find_skill(&self.skills, name)
}
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}");
}
}