tuffcli 0.13.0

Manage and govern agent capabilities.
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;

use crate::adapter::{AdapterKind, AgentAdapter};
use crate::config;
use crate::display;
use crate::error::Result;
use crate::lockfile;
use crate::manifest::CapabilityType;
use crate::resolver::Scope;

use super::add::add_targets_from_installed_dir;
use super::home_dir;

const TUFF_GUIDE_CONTENT: &str = include_str!("../../assets/tuff-cli-guide.md");

/// Harnesses whose own layout is present in the project.
///
/// Codex is deliberately absent. Its output root is `.agents/`, the same one
/// `open-agents` writes, so recording it would add a second lockfile target
/// pointing at a file that is already emitted, and its detector matches the
/// directory this command creates.
///
/// A harness that takes no skills, such as OpenCode's policy-only target, is
/// absent too: this command installs the CLI guide skill into every harness
/// it records.
fn detected_harnesses(repo_root: &Path) -> Vec<AdapterKind> {
    AdapterKind::all()
        .into_iter()
        .filter(|adapter| {
            !matches!(adapter, AdapterKind::OpenAgents | AdapterKind::Codex)
                && adapter.supports(CapabilityType::Skill)
                && adapter.detect(repo_root)
        })
        .collect()
}

pub fn cmd_init(repo_root: &Path, global: bool) -> Result<()> {
    if global {
        display::print_init_banner();
        let home = home_dir()?;
        let lock_path = crate::paths::global_lockfile(&home);
        lockfile::init_lockfile_at(&lock_path)?;
        let _ = config::read_global_config(&home)?;
        println!("initialized global Tuff state");
    } else {
        display::print_init_banner();

        // Detected before anything is scaffolded: the Codex detector matches on
        // `.agents/`, which this command creates, so detection after scaffolding
        // would report every project as a Codex project.
        let detected = detected_harnesses(repo_root);

        let lock_path = lockfile::init_lockfile(repo_root)?;
        let had_config = crate::paths::project_config(repo_root).exists();
        let mut config = config::read_config(repo_root)?;
        if !config.agents.iter().any(|agent| agent == "open-agents") {
            config.agents.push("open-agents".to_string());
        }
        for adapter in &detected {
            if !config.agents.iter().any(|agent| agent == adapter.id()) {
                config.agents.push(adapter.id().to_string());
            }
        }

        // Project adapter registration is reconstructed from tuff.lock so a
        // clone remains usable even when project preferences are absent.
        let lock = lockfile::read_lockfile_at(&lock_path)?;
        let targets: BTreeSet<String> = lock
            .capabilities
            .values()
            .flat_map(|entry| entry.targets.keys().cloned())
            .collect();
        for target in &targets {
            if !config.agents.iter().any(|agent| agent == target) {
                config.agents.push(target.clone());
            }
        }
        if !had_config && !targets.is_empty() && !targets.contains(&config.default_agent) {
            config.default_agent = targets
                .iter()
                .next()
                .cloned()
                .unwrap_or_else(|| config.default_agent.clone());
        }
        config::write_config(repo_root, &config)?;

        for dir in &["skills", "tools", "hooks"] {
            let path = repo_root.join(".agents").join(dir);
            if !path.exists() {
                std::fs::create_dir_all(&path)?;
            }
        }

        println!(
            "initialized {}",
            lockfile::relative_or_absolute_fs(&lock_path, repo_root)
        );
        println!("scaffolded .agents/ — place your capabilities here:");

        let guide_path = repo_root
            .join(".agents")
            .join("skills")
            .join("tuff-cli-guide");
        if !guide_path.exists() {
            std::fs::create_dir_all(&guide_path)?;
            std::fs::write(guide_path.join("SKILL.md"), TUFF_GUIDE_CONTENT)?;

            let baseline_tree_hash = crate::cache::hash_tree(&guide_path)?;
            crate::cache::populate(&home_dir()?, &baseline_tree_hash, &guide_path)?;
            let mut lf = lockfile::require_lockfile(repo_root).unwrap_or(lockfile::Lockfile {
                version: lockfile::LOCKFILE_VERSION,
                capabilities: BTreeMap::new(),
            });

            let mut targets = BTreeMap::new();
            targets.insert(
                "open-agents".to_string(),
                lockfile::TargetLockEntry {
                    managed_hooks: Vec::new(),
                    managed_mcp_entry: None,
                    managed_permissions: Vec::new(),
                    unenforced_rules: Vec::new(),
                    ownership: lockfile::TargetOwnership::Generated,
                    sha256: baseline_tree_hash,
                    installed_path: ".agents/skills/tuff-cli-guide".into(),
                },
            );

            lf.capabilities.insert(
                "tuff-cli-guide".into(),
                lockfile::CapabilityLockEntry {
                    capability_type: CapabilityType::Skill,
                    version: "0.1.0".into(),
                    version_scheme: lockfile::VersionScheme::Declared,
                    description: "Guide for using Tuff CLI commands inside this repository.".into(),
                    source: lockfile::CapabilitySource::local(""),
                    targets,
                    implementation: None,
                    parameters: None,
                    server: None,
                },
            );

            lockfile::write_lockfile(repo_root, &lf)?;

            // The guide teaches an agent to drive Tuff, so it has to reach the
            // harness the developer actually runs. `.agents/` is not read by
            // Claude Code or Cursor, and a project that has never been told
            // which harness it uses is exactly the one whose agent needs the
            // guide most.
            let extra: Vec<String> = detected
                .iter()
                .map(|adapter| adapter.id().to_string())
                .collect();
            if !extra.is_empty() {
                add_targets_from_installed_dir(
                    repo_root,
                    Scope::Project,
                    &guide_path,
                    &extra,
                    true,
                )?;
            }
        }
    }
    Ok(())
}