collet 0.1.0

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Skill tool — meta-tool that loads and applies skills on demand.
//!
//! The LLM calls this tool when it determines a skill is relevant.
//! This loads Level-2 instructions (SKILL.md body) into the conversation.

use crate::common::Result;
use serde::Deserialize;

use crate::skills::SkillRegistry;
use crate::tools::tool_index::ToolIndex;

#[derive(Debug, Deserialize)]
pub struct SkillInput {
    /// The skill name to invoke (or a keyword for fuzzy match).
    pub skill: String,
    /// Optional arguments to pass to the skill.
    #[serde(default)]
    pub args: Option<String>,
}

pub fn definition() -> serde_json::Value {
    serde_json::json!({
        "type": "function",
        "function": {
            "name": "skill",
            "description": "Invoke an Agent Skill to load specialized instructions and capabilities. Use this when a task matches an available skill's description. The skill's instructions will be loaded into your context.",
            "parameters": {
                "type": "object",
                "properties": {
                    "skill": {
                        "type": "string",
                        "description": "The skill name to invoke (e.g., 'lint-code', 'deploy-app'). Supports fuzzy matching."
                    },
                    "args": {
                        "type": "string",
                        "description": "Optional arguments for the skill (e.g., file paths, options)."
                    }
                },
                "required": ["skill"]
            }
        }
    })
}

/// Execute the skill tool — loads skill instructions.
/// Uses BM25 semantic fallback via `tool_index` when exact/fuzzy name matching fails.
pub fn execute(
    input: SkillInput,
    registry: &SkillRegistry,
    tool_index: &ToolIndex,
) -> Result<String> {
    let invocation = registry
        .invoke_with_index(&input.skill, tool_index)
        .map_err(|e| crate::common::AgentError::Config(format!("Skill invocation failed: {e}")))?;

    let mut result = invocation.to_context_string();

    if let Some(ref args) = input.args {
        result.push_str(&format!("\n\n<skill-args>{args}</skill-args>"));
    }

    tracing::info!(
        skill = %invocation.name,
        resources = invocation.available_resources.len(),
        "Skill invoked",
    );

    Ok(result)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tools::tool_index::ToolIndex;
    use std::fs;

    fn create_registry_with_skill(dir: &std::path::Path) -> SkillRegistry {
        let skill_dir = dir.join(".claude").join("skills").join("test-skill");
        fs::create_dir_all(&skill_dir).unwrap();
        fs::write(
            skill_dir.join("SKILL.md"),
            "---\nname: test-skill\ndescription: A test skill\n---\n\n# Instructions\nDo the test thing.",
        ).unwrap();
        SkillRegistry::discover(dir)
    }

    fn empty_index() -> ToolIndex {
        ToolIndex::new()
    }

    fn indexed_registry(reg: &SkillRegistry) -> ToolIndex {
        let mut idx = ToolIndex::new();
        idx.index_skills(reg);
        idx
    }

    #[test]
    fn test_skill_definition() {
        let def = definition();
        assert_eq!(def["function"]["name"], "skill");
        assert!(
            def["function"]["description"]
                .as_str()
                .unwrap()
                .contains("Skill")
        );
    }

    #[test]
    fn test_skill_execute() {
        let dir = tempfile::tempdir().unwrap();
        let reg = create_registry_with_skill(dir.path());

        let input = SkillInput {
            skill: "test-skill".to_string(),
            args: None,
        };

        let idx = indexed_registry(&reg);
        let result = execute(input, &reg, &idx).unwrap();
        assert!(result.contains("<skill name=\"test-skill\">"));
        assert!(result.contains("Do the test thing"));
        assert!(result.contains("</skill>"));
    }

    #[test]
    fn test_skill_execute_with_args() {
        let dir = tempfile::tempdir().unwrap();
        let reg = create_registry_with_skill(dir.path());
        let idx = indexed_registry(&reg);

        let input = SkillInput {
            skill: "test-skill".to_string(),
            args: Some("--verbose".to_string()),
        };

        let result = execute(input, &reg, &idx).unwrap();
        assert!(result.contains("<skill-args>--verbose</skill-args>"));
    }

    #[test]
    fn test_skill_execute_not_found() {
        let reg = SkillRegistry::new();
        let idx = empty_index();

        let input = SkillInput {
            skill: "nonexistent".to_string(),
            args: None,
        };

        assert!(execute(input, &reg, &idx).is_err());
    }

    #[test]
    fn test_skill_execute_fuzzy() {
        let dir = tempfile::tempdir().unwrap();
        let reg = create_registry_with_skill(dir.path());
        let idx = indexed_registry(&reg);

        // Use "test-sk" which uniquely matches "test-skill" even with user skills present
        let input = SkillInput {
            skill: "test-sk".to_string(),
            args: None,
        };

        let result = execute(input, &reg, &idx).unwrap();
        assert!(result.contains("test-skill"));
    }
}