shuvarie-core 0.3.3

Blazingly fast AI coding TUI for chivalrous people (core module)
use serde_json::{Value, json};
use shuvarie_llm::{Tool, ToolContext, ToolExecutionError, ToolOutput};

use crate::Skills;

use super::arg_value;

pub(crate) struct SkillTool {
    skills: Skills,
    max_output_chars: usize,
}

impl SkillTool {
    pub(crate) fn new(skills: Skills, max_output_chars: usize) -> Self {
        Self {
            skills,
            max_output_chars,
        }
    }
}

impl Tool for SkillTool {
    const NAME: &'static str = "skill";

    type Args = Value;
    type Output = ToolOutput;
    type Error = ToolExecutionError;

    fn description(&self) -> String {
        "Load one of the available skills by name and return its SKILL.md instructions. \
         Use it when a task matches a listed skill's description, before starting work: \
         the returned instructions tell you how to approach the task. A skill's SKILL.md \
         may reference further resource files (scripts, docs, data) relative to its \
         directory; load one with the `path` argument to follow the reference."
            .to_string()
    }

    fn parameters(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "skill": { "type": "string", "description": "Name of the skill to load (see the available_skills preamble block)" },
                "path": { "type": "string", "description": "Resource file to read instead of SKILL.md, relative to the skill's directory" }
            },
            "required": ["skill"]
        })
    }

    async fn call(
        &self,
        _ctx: &mut ToolContext,
        args: Value,
    ) -> Result<ToolOutput, ToolExecutionError> {
        let skills = self.skills.clone();
        let max_output_chars = self.max_output_chars;
        let result: Result<ToolOutput, String> = async move {
            let name = arg_value(&args, "skill")?;
            let skill = skills
                .skills
                .iter()
                .find(|s| s.name.eq_ignore_ascii_case(&name))
                .ok_or_else(|| unknown_skill(&skills, &name))?;
            if skill.disable_model_invocation {
                return Err(format!(
                    "skill '{name}' is user-invocation only; ask the user to run /skill:{name}"
                ));
            }
            let text = match args.get("path").and_then(Value::as_str) {
                Some(rel) => read_resource(skill, rel).await?,
                None => skill
                    .invocation_content(None)
                    .map_err(|e| format!("read SKILL.md of '{name}': {e}"))?,
            };
            let hint = format!("loaded by the `skill` tool ({name})");
            if let Some(capped) = crate::truncate::truncate_output(&text, max_output_chars, &hint) {
                return Ok(ToolOutput::text(capped));
            }
            Ok(ToolOutput::text(text))
        }
        .await;
        result.map_err(ToolExecutionError::other)
    }
}

async fn read_resource(skill: &crate::Skill, rel: &str) -> Result<String, String> {
    let rel_path = std::path::Path::new(rel);
    if rel.trim().is_empty() {
        return Err("empty 'path' argument".into());
    }
    if rel_path.is_absolute() {
        return Err(format!(
            "'path' must be relative to the skill directory: {rel}"
        ));
    }
    let dir =
        std::fs::canonicalize(&skill.path).map_err(|e| format!("{}: {e}", skill.path.display()))?;
    let canonical = dir
        .join(rel_path)
        .canonicalize()
        .map_err(|e| format!("{rel}: {e}"))?;
    if !canonical.starts_with(&dir) {
        return Err(format!("{rel} resolves outside the skill directory"));
    }
    if canonical.is_dir() {
        return Err(format!("'{rel}' is a directory, not a file"));
    }
    let data = tokio::fs::read(&canonical)
        .await
        .map_err(|e| format!("read {rel}: {e}"))?;
    if data.contains(&0) {
        return Err(format!(
            "'{rel}' is binary ({} bytes); run it with run_shell instead of reading it",
            data.len()
        ));
    }
    Ok(format!(
        "{rel} (at {}):\n\n{}",
        canonical.display(),
        String::from_utf8_lossy(&data)
    ))
}

fn unknown_skill(skills: &Skills, name: &str) -> String {
    let mut names: Vec<&str> = skills.skills.iter().map(|s| s.name.as_str()).collect();
    names.sort_unstable();
    if names.is_empty() {
        format!("unknown skill '{name}': no skills are available")
    } else {
        format!(
            "unknown skill '{name}': available skills are {}",
            names.join(", ")
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::test_util::{new_ctx, tempdir};
    use crate::{Skill, SkillWarning};
    use std::path::PathBuf;

    fn skill_fixture() -> Skills {
        Skills {
            skills: vec![Skill {
                name: "demo".to_string(),
                description: "demo skill".to_string(),
                tags: vec![],
                category: None,
                path: PathBuf::from(".agents/skills/demo"),
                global: false,
                disable_model_invocation: false,
            }],
            warnings: Vec::<SkillWarning>::new(),
        }
    }

    #[tokio::test]
    async fn skill_loads_skill_md() {
        let (dir, _guard) = tempdir();
        std::fs::create_dir_all(".agents/skills/demo").unwrap();
        std::fs::write(
            ".agents/skills/demo/SKILL.md",
            "# Demo\nUse the demo steps.\n",
        )
        .unwrap();
        let tool = SkillTool::new(skill_fixture(), 0);
        let out = tool
            .call(&mut new_ctx(), json!({ "skill": "demo" }))
            .await
            .unwrap();
        let text = out.as_text().unwrap();
        assert!(text.contains("<skill name=\"demo\""), "{text}");
        assert!(text.contains("Use the demo steps."), "{text}");
        assert!(text.contains("References are relative to"), "{text}");
        drop(dir);
    }

    #[tokio::test]
    async fn skill_name_match_is_case_insensitive() {
        let (dir, _guard) = tempdir();
        std::fs::create_dir_all(".agents/skills/demo").unwrap();
        std::fs::write(".agents/skills/demo/SKILL.md", "# Demo\nbody\n").unwrap();
        let tool = SkillTool::new(skill_fixture(), 0);
        let out = tool
            .call(&mut new_ctx(), json!({ "skill": "DEMO" }))
            .await
            .unwrap();
        assert!(out.as_text().unwrap().contains("body"));
        drop(dir);
    }

    #[tokio::test]
    async fn skill_unknown_name_lists_available() {
        let (dir, _guard) = tempdir();
        let tool = SkillTool::new(skill_fixture(), 0);
        let err = tool
            .call(&mut new_ctx(), json!({ "skill": "missing" }))
            .await
            .unwrap_err();
        assert!(
            err.to_string().contains("available skills are demo"),
            "{}",
            err.to_string()
        );
        drop(dir);
    }

    #[tokio::test]
    async fn skill_rejects_model_disabled_skills() {
        let (dir, _guard) = tempdir();
        let mut fixture = skill_fixture();
        fixture.skills[0].disable_model_invocation = true;
        let tool = SkillTool::new(fixture, 0);
        let err = tool
            .call(&mut new_ctx(), json!({ "skill": "demo" }))
            .await
            .unwrap_err();
        assert!(
            err.to_string().contains("user-invocation only"),
            "{}",
            err.to_string()
        );
        drop(dir);
    }

    #[tokio::test]
    async fn skill_reads_resource_within_dir() {
        let (dir, _guard) = tempdir();
        std::fs::create_dir_all(".agents/skills/demo/scripts").unwrap();
        std::fs::write(".agents/skills/demo/SKILL.md", "# Demo\n").unwrap();
        std::fs::write(".agents/skills/demo/scripts/run.py", "print('hi')\n").unwrap();
        let tool = SkillTool::new(skill_fixture(), 0);
        let out = tool
            .call(
                &mut new_ctx(),
                json!({ "skill": "demo", "path": "scripts/run.py" }),
            )
            .await
            .unwrap();
        let text = out.as_text().unwrap();
        assert!(text.contains("scripts/run.py (at "), "{text}");
        assert!(text.contains("print('hi')"), "{text}");
        drop(dir);
    }

    #[tokio::test]
    async fn skill_resource_cannot_escape_skill_dir() {
        let (dir, _guard) = tempdir();
        std::fs::create_dir_all(".agents/skills/demo").unwrap();
        std::fs::write(".agents/skills/demo/SKILL.md", "# Demo\n").unwrap();
        std::fs::write("secret.txt", "nope\n").unwrap();
        let tool = SkillTool::new(skill_fixture(), 0);
        let err = tool
            .call(
                &mut new_ctx(),
                json!({ "skill": "demo", "path": "../../../secret.txt" }),
            )
            .await
            .unwrap_err();
        assert!(
            err.to_string().contains("outside the skill directory"),
            "{}",
            err.to_string()
        );
        let err = tool
            .call(
                &mut new_ctx(),
                json!({ "skill": "demo", "path": "/etc/hostname" }),
            )
            .await
            .unwrap_err();
        assert!(
            err.to_string().contains("must be relative"),
            "{}",
            err.to_string()
        );
        drop(dir);
    }

    #[tokio::test]
    async fn skill_refuses_binary_resources() {
        let (dir, _guard) = tempdir();
        std::fs::create_dir_all(".agents/skills/demo").unwrap();
        std::fs::write(".agents/skills/demo/SKILL.md", "# Demo\n").unwrap();
        std::fs::write(".agents/skills/demo/blob.bin", [0, 1, 2, 3]).unwrap();
        let tool = SkillTool::new(skill_fixture(), 0);
        let err = tool
            .call(
                &mut new_ctx(),
                json!({ "skill": "demo", "path": "blob.bin" }),
            )
            .await
            .unwrap_err();
        assert!(err.to_string().contains("binary"), "{}", err.to_string());
        drop(dir);
    }

    #[tokio::test]
    async fn skill_truncates_oversized_output() {
        let (dir, _guard) = tempdir();
        std::fs::create_dir_all(".agents/skills/demo").unwrap();
        std::fs::write(".agents/skills/demo/SKILL.md", "x".repeat(10_000) + "\n").unwrap();
        let tool = SkillTool::new(skill_fixture(), 100);
        let out = tool
            .call(&mut new_ctx(), json!({ "skill": "demo" }))
            .await
            .unwrap();
        let text = out.as_text().unwrap();
        assert!(text.contains("truncated"), "{text}");
        assert!(text.chars().count() < 10_400, "{}", text.chars().count());
        drop(dir);
    }
}