capo-agent 0.6.0

Coding-agent library built on motosan-agent-loop. Composable, embeddable.
Documentation
#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]

use crate::skills::loader::strip_frontmatter_body;
use crate::skills::types::Skill;

/// Expand a leading `/skill:<name> [args]` token in user input into the
/// skill's body wrapped in a `<skill>` block. Returns the input unchanged
/// when there's no `/skill:` prefix, the named skill is missing, or the
/// file can't be read.
pub fn expand_skill_command(text: &str, skills: &[Skill]) -> String {
    let Some(rest) = text.strip_prefix("/skill:") else {
        return text.to_string();
    };
    let (name, args) = match rest.find(char::is_whitespace) {
        Some(i) => (&rest[..i], rest[i + 1..].trim()),
        None => (rest, ""),
    };
    let Some(skill) = skills.iter().find(|s| s.name == name) else {
        return text.to_string();
    };
    let raw = match std::fs::read_to_string(&skill.file_path) {
        Ok(s) => s,
        Err(_) => return text.to_string(),
    };
    let body = strip_frontmatter_body(&raw).trim().to_string();
    let block = format!(
        "<skill name=\"{}\" location=\"{}\">\nReferences are relative to {}.\n\n{}\n</skill>",
        skill.name,
        skill.file_path.display(),
        skill.base_dir.display(),
        body,
    );
    if args.is_empty() {
        block
    } else {
        format!("{block}\n\n{args}")
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::skills::types::SkillSource;
    use pretty_assertions::assert_eq;
    use std::fs;
    use std::path::PathBuf;
    use tempfile::tempdir;

    fn mk_skill(dir: &std::path::Path, name: &str, body: &str) -> Skill {
        let file = dir.join(format!("{name}.md"));
        fs::write(&file, format!("---\ndescription: d\n---\n{body}")).unwrap();
        Skill {
            name: name.into(),
            description: "d".into(),
            file_path: file,
            base_dir: dir.to_path_buf(),
            disable_model_invocation: false,
            source: SkillSource::Global,
        }
    }

    #[test]
    fn non_skill_text_unchanged() {
        let skills: Vec<Skill> = vec![];
        assert_eq!(expand_skill_command("hello world", &skills), "hello world");
    }

    #[test]
    fn unknown_skill_unchanged() {
        let skills: Vec<Skill> = vec![];
        assert_eq!(
            expand_skill_command("/skill:missing", &skills),
            "/skill:missing"
        );
    }

    #[test]
    fn expands_to_skill_block_no_args() {
        let tmp = tempdir().unwrap();
        let s = mk_skill(tmp.path(), "foo", "the body\n");
        let out = expand_skill_command("/skill:foo", std::slice::from_ref(&s));
        assert!(out.contains("<skill name=\"foo\""), "{out}");
        assert!(out.contains(&format!("location=\"{}\"", s.file_path.display())));
        assert!(out.contains("the body"));
        assert!(!out.ends_with("\n\n")); // no trailing args block
    }

    #[test]
    fn appends_args_after_block() {
        let tmp = tempdir().unwrap();
        let s = mk_skill(tmp.path(), "foo", "the body\n");
        let out = expand_skill_command("/skill:foo extra context", &[s]);
        assert!(out.contains("<skill name=\"foo\""));
        assert!(out.ends_with("extra context"));
    }

    #[test]
    fn unreadable_file_unchanged() {
        let s = Skill {
            name: "ghost".into(),
            description: "d".into(),
            file_path: PathBuf::from("/no/such/file-for-test"),
            base_dir: PathBuf::from("/tmp"),
            disable_model_invocation: false,
            source: SkillSource::Global,
        };
        assert_eq!(expand_skill_command("/skill:ghost", &[s]), "/skill:ghost");
    }
}