sac-cli 0.1.0

Terminal-based AI coding agent — fork of NAC with extended backend support and context management
Documentation
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use anyhow::{anyhow, Context, Result};
use serde::Deserialize;
use serde_json::{json, Value};

use crate::paths::sac_home_dir;
use crate::sandbox::{MountSpec, SandboxSession};
use crate::tools::{require_str, ToolResult, ToolRuntime};
use crate::types::{FunctionDef, ToolDefinition};

const SKILL_FILENAME: &str = "SKILL.md";
const MAX_SCAN_DEPTH: usize = 6;
const MAX_SCAN_DIRS: usize = 2_000;
const MAX_RESOURCE_ENTRIES: usize = 64;
const PROJECT_SAC_SKILLS_GUEST_ROOT: &str = "/sac/skills/project/sac";
const PROJECT_AGENTS_SKILLS_GUEST_ROOT: &str = "/sac/skills/project/agents";
const USER_SAC_HOME_SKILLS_GUEST_ROOT: &str = "/sac/skills/user/sac-home";
const USER_AGENTS_HOME_SKILLS_GUEST_ROOT: &str = "/sac/skills/user/agents-home";

mod discovery;
mod frontmatter;
mod registry;
mod resources;
mod tool;

pub use registry::SkillRegistry;
pub use tool::{auto_mounts, execute_activate_skill};

use discovery::*;
use frontmatter::*;
use resources::*;

#[derive(Clone, Debug)]
pub struct SkillRecord {
    pub name: String,
    pub description: String,
    pub compatibility: Option<String>,
    pub skill_md_path: PathBuf,
    pub skill_root_host: PathBuf,
    pub skill_root_visible: PathBuf,
    pub body: String,
    pub resources: Vec<String>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SkillCatalogEntry {
    pub name: String,
    pub description: String,
    pub compatibility: Option<String>,
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::sandbox::{SandboxSpec, DEFAULT_SANDBOX_IMAGE, DEFAULT_SANDBOX_WORKDIR};
    use crate::test_env_lock;
    use std::time::{SystemTime, UNIX_EPOCH};

    fn temp_dir(label: &str) -> PathBuf {
        let unique = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        let path = std::env::temp_dir().join(format!("sac_skills_test_{}_{}", label, unique));
        fs::create_dir_all(&path).unwrap();
        path
    }

    fn write_skill(root: &Path, name: &str, description: &str, body: &str) -> PathBuf {
        let dir = root.join(name);
        fs::create_dir_all(&dir).unwrap();
        fs::write(
            dir.join(SKILL_FILENAME),
            format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}\n"),
        )
        .unwrap();
        dir
    }

    #[test]
    fn project_sources_override_user_sources() {
        let _guard = test_env_lock();
        let root = temp_dir("precedence");
        let repo = root.join("repo");
        fs::create_dir_all(repo.join(".git")).unwrap();
        let project_skills = repo.join(".sac/skills");
        let agents_skills = repo.join(".agents/skills");
        let user_skills = root.join("home/.config/sac/skills");
        fs::create_dir_all(&project_skills).unwrap();
        fs::create_dir_all(&agents_skills).unwrap();
        fs::create_dir_all(&user_skills).unwrap();

        write_skill(&user_skills, "build", "user", "user body");
        write_skill(
            &agents_skills,
            "build",
            "project agents",
            "project agents body",
        );
        write_skill(&project_skills, "build", "project sac", "project sac body");

        let previous_sac_home = std::env::var_os("SAC_HOME");
        unsafe {
            std::env::set_var("SAC_HOME", root.join("home/.config/sac"));
        }

        let registry = SkillRegistry::load(Some(&repo), None).unwrap().unwrap();
        match previous_sac_home {
            Some(value) => unsafe { std::env::set_var("SAC_HOME", value) },
            None => unsafe { std::env::remove_var("SAC_HOME") },
        }
        let entry = registry
            .catalog_entries()
            .into_iter()
            .find(|entry| entry.name == "build")
            .unwrap();
        assert_eq!(entry.description, "project sac");
        let activated = registry.activate("build", false);
        assert!(activated.content.contains("project sac body"));
    }

    #[test]
    fn missing_description_skips_skill() {
        let _guard = test_env_lock();
        let root = temp_dir("missing_desc");
        let skill_root = root.join("repo/.agents/skills/foo");
        let sac_home = root.join("home/.config/sac");
        let home = root.join("home");
        fs::create_dir_all(&skill_root).unwrap();
        fs::create_dir_all(&sac_home).unwrap();
        fs::create_dir_all(&home).unwrap();
        fs::create_dir_all(root.join("repo/.git")).unwrap();
        fs::write(
            skill_root.join(SKILL_FILENAME),
            "---\nname: foo\n---\n\nbody\n",
        )
        .unwrap();

        let previous_sac_home = std::env::var_os("SAC_HOME");
        let previous_home = std::env::var_os("HOME");
        unsafe {
            std::env::set_var("SAC_HOME", &sac_home);
            std::env::set_var("HOME", &home);
        }

        let registry = SkillRegistry::load(Some(&root.join("repo")), None).unwrap();

        match previous_sac_home {
            Some(value) => unsafe { std::env::set_var("SAC_HOME", value) },
            None => unsafe { std::env::remove_var("SAC_HOME") },
        }
        match previous_home {
            Some(value) => unsafe { std::env::set_var("HOME", value) },
            None => unsafe { std::env::remove_var("HOME") },
        }

        assert!(registry.is_none());
    }

    #[test]
    fn activation_uses_guest_path_when_sandboxed() {
        let root = temp_dir("sandboxed_path");
        let repo = root.join("repo");
        fs::create_dir_all(repo.join(".git")).unwrap();
        let project_skills = repo.join(".agents/skills");
        fs::create_dir_all(&project_skills).unwrap();
        let skill_dir = write_skill(&project_skills, "lint", "lint code", "body");

        let sandbox = SandboxSession::new_for_test(SandboxSpec {
            image: DEFAULT_SANDBOX_IMAGE.to_string(),
            mounts: vec![
                MountSpec {
                    host: repo.clone(),
                    guest: PathBuf::from(DEFAULT_SANDBOX_WORKDIR),
                    read_only: false,
                },
                MountSpec {
                    host: project_skills.clone(),
                    guest: PathBuf::from(PROJECT_AGENTS_SKILLS_GUEST_ROOT),
                    read_only: true,
                },
            ],
            workdir: PathBuf::from(DEFAULT_SANDBOX_WORKDIR),
            gpu_devices: Vec::new(),
            shm_size: Some("0".to_string()),
        });

        let registry = SkillRegistry::load(Some(&repo), Some(&sandbox))
            .unwrap()
            .unwrap();
        let activated = registry.activate("lint", false);
        assert!(
            activated.content.contains("/workspace/.agents/skills/lint")
                || activated
                    .content
                    .contains(&format!("{}/lint", PROJECT_AGENTS_SKILLS_GUEST_ROOT))
        );
        assert!(activated.content.contains("body"));
        assert_eq!(skill_dir, project_skills.join("lint"));
    }

    #[test]
    fn auto_mounts_skip_paths_already_covered_by_workspace_mount() {
        let _guard = test_env_lock();
        let root = temp_dir("auto_mounts_covered");
        let repo = root.join("repo");
        let sac_home = root.join("home/.config/sac");
        let home = root.join("home");
        fs::create_dir_all(repo.join(".git")).unwrap();
        fs::create_dir_all(repo.join(".agents/skills")).unwrap();
        fs::create_dir_all(&sac_home).unwrap();
        fs::create_dir_all(&home).unwrap();

        let previous_sac_home = std::env::var_os("SAC_HOME");
        let previous_home = std::env::var_os("HOME");
        unsafe {
            std::env::set_var("SAC_HOME", &sac_home);
            std::env::set_var("HOME", &home);
        }

        let mounts = auto_mounts(
            &repo,
            &[MountSpec {
                host: repo.clone(),
                guest: PathBuf::from(DEFAULT_SANDBOX_WORKDIR),
                read_only: false,
            }],
        )
        .unwrap();

        match previous_sac_home {
            Some(value) => unsafe { std::env::set_var("SAC_HOME", value) },
            None => unsafe { std::env::remove_var("SAC_HOME") },
        }
        match previous_home {
            Some(value) => unsafe { std::env::set_var("HOME", value) },
            None => unsafe { std::env::remove_var("HOME") },
        }

        assert!(mounts.is_empty());
    }

    #[test]
    fn repair_frontmatter_handles_unquoted_colons() {
        let frontmatter = "name: lint\ndescription: Use when handling foo:bar tasks\n";
        let parsed = parse_frontmatter(frontmatter).unwrap();
        assert_eq!(
            parsed.description.as_deref(),
            Some("Use when handling foo:bar tasks")
        );
    }

    #[test]
    fn repeated_activation_returns_short_notice() {
        let root = temp_dir("activation_dedupe");
        let repo = root.join("repo");
        fs::create_dir_all(repo.join(".git")).unwrap();
        let project_skills = repo.join(".agents/skills");
        fs::create_dir_all(&project_skills).unwrap();
        write_skill(&project_skills, "lint", "lint code", "full body");

        let registry = SkillRegistry::load(Some(&repo), None).unwrap().unwrap();
        let first = registry.activate("lint", false);
        let second = registry.activate("lint", true);

        assert!(first.content.contains("full body"));
        assert!(second.content.contains("already active"));
        assert!(!second.content.contains("full body"));
    }
}