agents-skills 0.4.0

A minimal, stable, easy-to-understand skill installer and manager for AI agents
Documentation
//! Integration tests for the library API: proving the lib works independently of the CLI,
//! driven entirely through `ManagerBuilder` + request structs (no `assert_cmd`).

use std::path::{Path, PathBuf};

use agents_skills::{
    AddRequest, LinkRequest, ListRequest, Manager, RemoveRequest, SkillsError, UpdateRequest,
};

fn write_skill_source(root: &Path, rel_dir: &str, name: &str) -> PathBuf {
    let dir = root.join(rel_dir);
    std::fs::create_dir_all(&dir).unwrap();
    let md = format!("---\nname: {name}\ndescription: does {name}\n---\n\n# {name}\n");
    std::fs::write(dir.join("SKILL.md"), md).unwrap();
    dir
}

#[test]
fn lib_add_list_remove_roundtrip() {
    let tmp = tempfile::TempDir::new().unwrap();
    let cwd = tmp.path().join("project");
    std::fs::create_dir_all(&cwd).unwrap();

    let manager = Manager::builder()
        .home(tmp.path().join("home"))
        .config(tmp.path().join("config"))
        .cwd(cwd.clone())
        .build();

    // Add a local skill.
    let src = write_skill_source(tmp.path(), "src", "pdf");
    let outcome = manager
        .add(&AddRequest {
            source: src.display().to_string(),
            ..Default::default()
        })
        .unwrap();

    assert_eq!(outcome.skills.len(), 1);
    assert_eq!(outcome.installed.len(), 1);
    assert!(outcome.failed.is_empty());
    assert!(cwd.join(".agents/skills/pdf/SKILL.md").exists());

    // List finds it, with scope and lock metadata.
    let listed = manager.list(&ListRequest::default()).unwrap();
    assert_eq!(listed.len(), 1);
    assert_eq!(listed[0].name, "pdf");
    assert_eq!(listed[0].scope, "project");
    assert!(listed[0].source.is_some());

    // Remove it.
    let removed = manager
        .remove(&RemoveRequest {
            skills: vec!["pdf".to_string()],
            ..Default::default()
        })
        .unwrap();
    assert_eq!(removed.removed, vec!["pdf".to_string()]);
    assert!(!cwd.join(".agents/skills/pdf").exists());
}

#[test]
fn lib_add_global_uses_home() {
    let tmp = tempfile::TempDir::new().unwrap();
    let home = tmp.path().join("home");
    let cwd = tmp.path().join("project");
    std::fs::create_dir_all(&home).unwrap();
    std::fs::create_dir_all(&cwd).unwrap();

    let manager = Manager::builder()
        .home(home.clone())
        .config(tmp.path().join("config"))
        .cwd(cwd.clone())
        .build();

    let src = write_skill_source(tmp.path(), "src", "pdf");
    let outcome = manager
        .add(&AddRequest {
            source: src.display().to_string(),
            global: true,
            ..Default::default()
        })
        .unwrap();

    assert_eq!(outcome.installed.len(), 1);
    assert!(home.join(".agents/skills/pdf/SKILL.md").exists());
    assert!(!cwd.join(".agents/skills/pdf").exists());
}

#[test]
fn lib_invalid_agent_returns_error() {
    let tmp = tempfile::TempDir::new().unwrap();
    let manager = Manager::builder().cwd(tmp.path().join("project")).build();

    let err = manager
        .link(&LinkRequest {
            agents: vec!["not-an-agent".to_string()],
            ..Default::default()
        })
        .unwrap_err();
    assert!(matches!(err, SkillsError::InvalidAgents(_)));
}

#[test]
fn lib_missing_local_path_returns_error() {
    let tmp = tempfile::TempDir::new().unwrap();
    let manager = Manager::builder().cwd(tmp.path().join("project")).build();

    let err = manager
        .add(&AddRequest {
            source: tmp.path().join("nope").display().to_string(),
            ..Default::default()
        })
        .unwrap_err();
    assert!(err.to_string().contains("Local path does not exist"));
}

#[test]
fn lib_list_json_shape() {
    let tmp = tempfile::TempDir::new().unwrap();
    let cwd = tmp.path().join("project");
    std::fs::create_dir_all(&cwd).unwrap();

    let manager = Manager::builder().cwd(cwd).build();
    let src = write_skill_source(tmp.path(), "src", "pdf");
    manager
        .add(&AddRequest {
            source: src.display().to_string(),
            ..Default::default()
        })
        .unwrap();

    let listed = manager.list(&ListRequest::default()).unwrap();
    let json = serde_json::to_string_pretty(&listed).unwrap();
    assert!(json.contains("\"name\": \"pdf\""));
    assert!(json.contains("\"scope\": \"project\""));
    assert!(json.contains("\"source\":"));
}

#[test]
fn lib_link_status_reports_canonical_and_linked() {
    let tmp = tempfile::TempDir::new().unwrap();
    let home = tmp.path().join("home");
    let cwd = tmp.path().join("project");
    std::fs::create_dir_all(&home).unwrap();
    std::fs::create_dir_all(&cwd).unwrap();

    // Installed universal agent (codex detects ~/.codex) → canonical: true.
    std::fs::create_dir_all(home.join(".codex")).unwrap();
    // Installed non-universal agent (trae detects ~/.trae) → linked after linking.
    std::fs::create_dir_all(home.join(".trae")).unwrap();

    let manager = Manager::builder()
        .home(home.clone())
        .config(tmp.path().join("config"))
        .cwd(cwd.clone())
        .build();

    manager
        .link(&LinkRequest {
            agents: vec!["trae".to_string()],
            global: true,
            ..Default::default()
        })
        .unwrap();

    let statuses = manager.link_status(true);
    let trae = statuses.iter().find(|s| s.name == "trae").unwrap();
    assert!(!trae.canonical);
    assert!(trae.linked);
    let codex = statuses.iter().find(|s| s.name == "codex").unwrap();
    assert!(codex.canonical);
    assert!(codex.linked);
    // Uninstalled agents (neither installed nor linked) are not reported.
    assert!(statuses.iter().all(|s| s.name != "claude-code"));
    // Uninstalled universal agents are not reported either.
    assert!(statuses.iter().all(|s| s.name != "amp"));
}

#[test]
fn lib_list_agent_filter_and_visibility() {
    let tmp = tempfile::TempDir::new().unwrap();
    let cwd = tmp.path().join("project");
    std::fs::create_dir_all(&cwd).unwrap();

    let manager = Manager::builder()
        .home(tmp.path().join("home"))
        .config(tmp.path().join("config"))
        .cwd(cwd.clone())
        .build();

    let src = write_skill_source(tmp.path(), "src", "pdf");
    manager
        .add(&AddRequest {
            source: src.display().to_string(),
            ..Default::default()
        })
        .unwrap();

    // All universal agents see the skill by default (agents holds display names).
    let all = manager.list(&ListRequest::default()).unwrap();
    assert_eq!(all.len(), 1);
    assert!(all[0].agents.contains(&"Codex".to_string()));

    // Filtering to a universal agent keeps the skill visible.
    let filtered = manager
        .list(&ListRequest {
            agents: vec!["codex".to_string()],
            ..Default::default()
        })
        .unwrap();
    assert_eq!(filtered.len(), 1);
    assert!(filtered[0].agents.contains(&"Codex".to_string()));

    // Filtering to an uninstalled, unlinked agent keeps the skill listed but
    // reports no visible agents (matches the CLI's -a behavior).
    let none = manager
        .list(&ListRequest {
            agents: vec!["claude-code".to_string()],
            ..Default::default()
        })
        .unwrap();
    assert_eq!(none.len(), 1);
    assert!(none[0].agents.is_empty());
}

#[test]
fn lib_update_empty_is_noop() {
    let tmp = tempfile::TempDir::new().unwrap();
    let manager = Manager::builder()
        .home(tmp.path().join("home"))
        .cwd(tmp.path().join("project"))
        .build();

    let outcome = manager.update(&UpdateRequest::default()).unwrap();
    assert_eq!(outcome.updated, 0);
    assert_eq!(outcome.failed, 0);
}