tuffcli 0.7.0

Capability lifecycle manager for coding agents.
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;

use crate::adapter::{AdapterKind, AgentAdapter};
use crate::config;
use crate::error::{Result, TuffError};
use crate::lockfile::{self, TargetLockEntry};
use crate::manifest::CapabilityType;

use super::resolve_agent_selection;

pub fn cmd_create(
    repo_root: &Path,
    kind: CapabilityType,
    raw_id: &str,
    target_ids: &[String],
) -> Result<()> {
    let id = raw_id.trim();
    if id.is_empty() {
        return Err(TuffError::usage("capability name must not be empty"));
    }

    let target_ids = resolve_agent_selection(repo_root, target_ids, false)?;
    let mut adapters = Vec::new();
    for target in &target_ids {
        let adapter = AdapterKind::from_id(target).ok_or_else(|| {
            TuffError::usage(format!("unknown agent '{}'", target,))
                .with_hint("run 'tuff agent list' to see available agents")
        })?;
        if !adapter.supports(kind) {
            return Err(TuffError::unsupported(format!(
                "{} does not yet support {} capabilities",
                adapter.display_name(),
                kind
            )));
        }
        if !adapters.contains(&adapter) {
            adapters.push(adapter);
        }
    }

    let lock_path = lockfile::project_lockfile(repo_root);
    let mut tuff_lock = if lock_path.exists() {
        lockfile::read_lockfile_at(&lock_path)?
    } else {
        lockfile::Lockfile {
            version: lockfile::LOCKFILE_VERSION,
            capabilities: BTreeMap::new(),
        }
    };
    if tuff_lock.capabilities.contains_key(id) {
        return Err(
            TuffError::refused(format!("capability '{}' is already tracked", id))
                .with_hint("choose another id"),
        );
    }

    let mut plans = Vec::new();
    for adapter in &adapters {
        let (relative_dir, files) = create_scaffold_files(kind, id, *adapter)?;
        let root = repo_root
            .join(adapter.dir_prefix())
            .join(relative_dir)
            .join(id);
        if root.exists() {
            return Err(TuffError::refused(format!(
                "refusing to overwrite existing capability scaffold: {}",
                lockfile::relative_or_absolute_fs(&root, repo_root)
            )));
        }
        plans.push((*adapter, root, files));
    }

    let mut config = config::read_config(repo_root)?;
    let mut target_entries = BTreeMap::new();
    let source_path = plans
        .first()
        .map(|(_, root, _)| lockfile::relative_or_absolute_fs(root, repo_root))
        .unwrap_or_default();

    for (adapter, root, files) in &plans {
        fs::create_dir_all(root)?;
        for (name, content) in files {
            fs::write(root.join(name), content)?;
        }

        #[cfg(unix)]
        if kind == CapabilityType::Tool || kind == CapabilityType::Hook {
            use std::os::unix::fs::PermissionsExt;
            let run_sh = root.join("run.sh");
            let mut permissions = fs::metadata(&run_sh)?.permissions();
            permissions.set_mode(0o755);
            fs::set_permissions(run_sh, permissions)?;
        }

        let baseline_hash = crate::cache::hash_tree(root)?;
        crate::cache::populate(&super::home_dir()?, &baseline_hash, root)?;

        let mut managed_hooks = Vec::new();

        if kind == CapabilityType::Hook {
            let settings_relpath = adapter.hook_settings_relpath();
            let settings_path = repo_root.join(settings_relpath);
            let event = adapter.scaffold_hook_event();
            let fragment = adapter.command_hook_fragment(
                event,
                &format!("sh {}/hooks/{id}/run.sh", adapter.dir_prefix()),
            );
            let existing = if settings_path.is_file() {
                Some(fs::read(&settings_path)?)
            } else {
                None
            };
            let merged = adapter.merge_hook_fragment(existing.as_deref(), &fragment)?;
            if let Some(parent) = settings_path.parent() {
                fs::create_dir_all(parent)?;
            }
            fs::write(&settings_path, &merged)?;
            let canonical_event = adapter.canonical_hook_event(event)?;
            managed_hooks = lockfile::managed_hooks_from_fragment_with_canonical(
                repo_root,
                settings_relpath,
                &fragment,
                Some(canonical_event),
            )?;
            println!(
                "created and tracked hook '{}' ({}) -> {}",
                id,
                adapter.id(),
                lockfile::relative_or_absolute_fs(&settings_path, repo_root)
            );
        }

        target_entries.insert(
            adapter.id().to_string(),
            TargetLockEntry {
                managed_hooks,
                managed_mcp_entry: None,
                ownership: lockfile::TargetOwnership::Generated,
                sha256: baseline_hash,
                installed_path: lockfile::relative_or_absolute_fs(root, repo_root),
            },
        );
        if !config.agents.iter().any(|agent| agent == adapter.id()) {
            config.agents.push(adapter.id().to_string());
        }

        for (name, _) in files {
            println!(
                "created and tracked {} '{}' ({}) -> {}",
                kind,
                id,
                adapter.id(),
                lockfile::relative_or_absolute_fs(&root.join(name), repo_root)
            );
        }
    }

    tuff_lock.capabilities.insert(
        id.to_string(),
        lockfile::CapabilityLockEntry {
            capability_type: kind,
            version: "0.1.0".to_string(),
            version_scheme: lockfile::VersionScheme::Declared,
            description: default_capability_description(kind).to_string(),
            source: lockfile::CapabilitySource::local(source_path),
            targets: target_entries,
            implementation: None,
            parameters: None,
            workflow: None,
            server: None,
        },
    );
    config::write_config(repo_root, &config)?;
    lockfile::write_lockfile(repo_root, &tuff_lock)?;
    println!("next: edit the generated files, then run `tuff list` or `tuff diff {id}`");
    Ok(())
}

fn default_capability_description(kind: CapabilityType) -> &'static str {
    match kind {
        CapabilityType::Skill => "What this skill helps the agent do.",
        CapabilityType::Tool => "What this tool does for the agent.",
        CapabilityType::Hook => "What this hook enforces.",
        CapabilityType::Workflow => "When the agent should run this workflow.",
        CapabilityType::Policy => "Policy capabilities are not scaffolded yet.",
        CapabilityType::McpServer => "MCP servers are added, not scaffolded.",
    }
}

fn create_scaffold_files(
    kind: CapabilityType,
    id: &str,
    adapter: AdapterKind,
) -> Result<(&'static str, Vec<(&'static str, String)>)> {
    let files = match kind {
        CapabilityType::Skill => vec![(
            "SKILL.md",
            format!(
                "# {id}\n\n## Purpose\n\nDescribe when the agent should use this skill.\n\n## Guidance\n\n- Add the key rules the agent should follow.\n- Add examples, constraints, and team conventions.\n"
            ),
        )],
        CapabilityType::Tool => vec![(
            "run.sh",
            "#!/usr/bin/env bash\nset -euo pipefail\n\necho \"replace with tool logic\"\n"
                .to_string(),
        )],
        CapabilityType::Hook => {
            if adapter == AdapterKind::Claude || adapter == AdapterKind::OpenAgents {
                return Ok((
                    kind.plural_dir(),
                    vec![
                        (
                            "run.sh",
                            "#!/usr/bin/env bash\nset -euo pipefail\n\necho \"replace with hook logic\"\n"
                                .to_string(),
                        ),
                    ],
                ));
            }
            let file = adapter.hook_filename();
            let content = adapter
                .hook_file_content(&crate::manifest::HookConfig {
                    event: "before_finish".into(),
                    command: "echo review hook".into(),
                    working_directory: ".".into(),
                })
                .and_then(|bytes| {
                    String::from_utf8(bytes).map_err(|e| {
                        TuffError::corrupt(format!("hook content is not valid UTF-8: {e}"))
                    })
                })?;
            vec![(file, content)]
        }
        CapabilityType::Workflow => vec![(
            "workflow.toml",
            format!(
                "id = \"{id}\"\nversion = \"0.1.0\"\ntype = \"workflow\"\ndescription = \"When the agent should run this workflow.\"\n\n[[workflow.requires]]\nid = \"replace-me\"\ntype = \"skill\"\n"
            ),
        )],
        CapabilityType::Policy => {
            return Err(TuffError::unsupported(
                "policy capabilities are not scaffolded yet",
            ));
        }
        CapabilityType::McpServer => {
            return Err(TuffError::unsupported(
                "mcp-server capabilities are not scaffolded; use `tuff add mcp <catalog-id|path|git-url>` instead",
            ));
        }
    };
    let relative_dir = kind.plural_dir();
    Ok((relative_dir, files))
}