tuffcli 0.10.2

Capability lifecycle manager for coding agents.
mod add;
mod agent;
mod cache;
mod capability_index;
mod check_cmd;
mod create;
mod delete;
mod diff;
mod hooks;
mod init;
mod list;
mod lock;
mod mcp_catalog;
mod mcp_doctor;
mod mcp_search;
mod outdated;
mod pack;
mod policy;
mod project_pack;
mod report;
mod scan;
mod status;
mod update;

pub use add::*;
pub use agent::*;
pub use cache::*;
pub use check_cmd::*;
pub use create::*;
pub use delete::*;
pub use diff::*;
pub use hooks::*;
pub use init::*;
pub use list::*;
pub use lock::*;
pub use mcp_catalog::*;
pub use mcp_doctor::*;
pub use mcp_search::*;
pub use outdated::*;
pub use pack::*;
pub use policy::*;
pub use report::*;
pub use scan::*;
pub use status::*;
pub use update::*;

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

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

// ── Display utilities (used by multiple submodules) ─────────────────────────

pub(crate) fn use_color() -> bool {
    crate::display::use_color()
}

pub(crate) fn paint(text: &str, code: &str) -> String {
    if use_color() {
        format!("\x1b[{code}m{text}\x1b[0m")
    } else {
        text.to_string()
    }
}

pub(crate) fn style_drift_status(status: &str) -> String {
    match status {
        "clean" => format!("{} {}", paint("✓", "32"), paint("clean", "32")),
        "modified" => format!("{} {}", paint("●", "33"), paint("modified", "33")),
        "missing" => format!("{} {}", paint("✗", "31"), paint("missing", "31")),
        "error" => format!("{} {}", paint("✗", "31"), paint("error", "31")),
        // Deliberately not green: this is an absence of information, not a
        // clean bill of health.
        "not checked" => format!("{} {}", paint("?", "2"), paint("not checked", "2")),
        other => other.to_string(),
    }
}

pub(crate) fn style_capability_type(capability_type: CapabilityType) -> String {
    let s = capability_type.as_str();
    let code = match capability_type {
        CapabilityType::Skill => "36",
        CapabilityType::Tool => "35",
        CapabilityType::Hook => "33",
        CapabilityType::Workflow => "34",
        CapabilityType::Policy => "31",
        CapabilityType::McpServer => "96",
    };
    paint(s, code)
}

pub(crate) fn style_outdated_status(status: &str) -> String {
    match status {
        "up to date" => format!("{} {}", paint("✓", "32"), paint("up to date", "32")),
        "outdated" => format!("{} {}", paint("●", "33"), paint("outdated", "33")),
        "error" => format!("{} {}", paint("✗", "31"), paint("error", "31")),
        "repointed" => format!("{} {}", paint("✗", "31"), paint("repointed", "31")),
        "tag missing" => format!("{} {}", paint("✗", "31"), paint("tag missing", "31")),
        other => other.to_string(),
    }
}

pub(crate) fn visible_width(text: &str) -> usize {
    let mut width = 0usize;
    let mut chars = text.chars().peekable();

    while let Some(ch) = chars.next() {
        if ch == '\u{1b}' {
            if chars.peek() == Some(&'[') {
                let _ = chars.next();
                for c in chars.by_ref() {
                    if c.is_ascii_alphabetic() {
                        break;
                    }
                }
            }
            continue;
        }
        width += 1;
    }

    width
}

fn pad_cell(text: &str, width: usize) -> String {
    let padding = width.saturating_sub(visible_width(text));
    format!("{text}{}", " ".repeat(padding))
}

fn border(left: char, join: char, right: char, widths: &[usize]) -> String {
    let mut line = String::new();
    line.push(left);
    for (index, width) in widths.iter().enumerate() {
        line.push_str(&"─".repeat(width + 2));
        if index + 1 == widths.len() {
            line.push(right);
        } else {
            line.push(join);
        }
    }
    line
}

pub(crate) fn render_table(headers: &[&str], rows: &[Vec<String>]) -> String {
    let mut widths: Vec<usize> = headers.iter().map(|header| header.len()).collect();
    for row in rows {
        for (index, cell) in row.iter().enumerate() {
            widths[index] = widths[index].max(visible_width(cell));
        }
    }

    let mut out = String::new();
    out.push_str(&border('┌', '┬', '┐', &widths));
    out.push('\n');

    out.push('│');
    for (index, header) in headers.iter().enumerate() {
        out.push(' ');
        out.push_str(&pad_cell(header, widths[index]));
        out.push(' ');
        out.push('│');
    }
    out.push('\n');

    out.push_str(&border('├', '┼', '┤', &widths));
    out.push('\n');

    for row in rows {
        out.push('│');
        for (index, cell) in row.iter().enumerate() {
            out.push(' ');
            out.push_str(&pad_cell(cell, widths[index]));
            out.push(' ');
            out.push('│');
        }
        out.push('\n');
    }

    out.push_str(&border('└', '┴', '┘', &widths));
    out
}

// ── Shared utility functions ────────────────────────────────────────────────

/// The newest release of `id` in a repository that satisfies `request`,
/// from one `ls-remote`, for the commands that move or compare a pinned
/// entry within its requirement.
pub(crate) fn resolve_git_release(
    url: &str,
    id: &str,
    request: &crate::release::VersionRequest,
) -> Result<crate::release::ReleaseTag> {
    let tags = crate::git::list_remote_tags(url)?;
    crate::release::resolve_release(tags.iter().map(|tag| tag.name.as_str()), id, request)
}

pub(crate) fn short_sha(v: &str) -> &str {
    if v.len() >= 7 && v.chars().all(|c| c.is_ascii_hexdigit()) {
        &v[..7]
    } else {
        v
    }
}

pub(crate) fn markdown_escape(value: &str) -> String {
    value.replace('|', "\\|")
}

/// Run a future to completion on a fresh single-threaded runtime.
///
/// `push`, `pull`, and `outdated`'s pack-registry check all need to call the
/// async OCI client from otherwise-synchronous command code, and each needs
/// exactly this. It was duplicated once already; one copy is the point.
pub(crate) fn block_on_oci<T>(future: impl std::future::Future<Output = Result<T>>) -> Result<T> {
    tokio::runtime::Builder::new_current_thread()
        .enable_all()
        .build()?
        .block_on(future)
}

pub(crate) fn home_dir() -> Result<PathBuf> {
    home_dir_opt().ok_or_else(|| TuffError::usage("HOME environment variable not set"))
}

pub(crate) fn home_dir_opt() -> Option<PathBuf> {
    std::env::var("HOME").ok().map(PathBuf::from)
}

pub(crate) fn resolve_agent_selection(
    root: &Path,
    requested: &[String],
    global: bool,
) -> Result<Vec<String>> {
    let values = if requested.is_empty() {
        let config = if global {
            config::read_global_config(root)?
        } else {
            config::read_config(root)?
        };
        vec![config.default_agent]
    } else {
        requested.to_vec()
    };

    let mut selected = Vec::new();
    for value in values {
        let adapter = AdapterKind::from_id(&value).ok_or_else(|| {
            TuffError::usage(format!("unknown agent '{}'", value,))
                .with_hint("run 'tuff agent list' to see available agents")
        })?;
        if !selected.iter().any(|existing| existing == adapter.id()) {
            selected.push(adapter.id().to_string());
        }
    }
    Ok(selected)
}

/// The capability kind and harness a local path implies.
///
/// Both are read from a harness prefix found anywhere in the path, with the
/// kind taken from the directory immediately inside it. Reading fixed
/// positions from the end instead would misattribute a capability that sits
/// deeper than one level, and they do: a grouping directory such as
/// `.claude/skills/security/security-review/` is a layout harnesses accept.
///
/// `.agents` is the layout of both Codex and Open Agents and the two cannot
/// be told apart from a path, so it resolves to the adapter that owns the
/// shared layout. A path in no harness layout keeps the old reading — the
/// parent directory names the kind — which is what `tuff add ./skills/foo`
/// relies on.
pub(crate) fn infer_from_path(path: &Path) -> (CapabilityType, String) {
    let components: Vec<String> = path
        .components()
        .map(|component| component.as_os_str().to_string_lossy().to_string())
        .collect();

    let mut target = "open-agents";
    let mut kind_dir: Option<&str> = None;
    for (index, component) in components.iter().enumerate() {
        let harness = match component.as_str() {
            ".claude" => "claude",
            ".cursor" => "cursor",
            ".agents" => "open-agents",
            _ => continue,
        };
        target = harness;
        kind_dir = components.get(index + 1).map(String::as_str);
    }

    let kind_dir = kind_dir
        .or_else(|| components.iter().rev().nth(1).map(String::as_str))
        .unwrap_or_default();

    let ctype = match kind_dir {
        "tools" => CapabilityType::Tool,
        "hooks" => CapabilityType::Hook,
        "workflows" => CapabilityType::Workflow,
        "mcp-servers" => CapabilityType::McpServer,
        _ => CapabilityType::Skill,
    };

    (ctype, target.to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn short_sha_truncates_hex() {
        let sha = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0";
        assert_eq!(short_sha(sha), "a1b2c3d");
    }

    #[test]
    fn short_sha_keeps_short_strings() {
        assert_eq!(short_sha("abc"), "abc");
        assert_eq!(short_sha("1.0.0"), "1.0.0");
    }

    #[test]
    fn short_sha_handles_non_hex_long_string() {
        assert_eq!(short_sha("abcdefgh1234567890"), "abcdefgh1234567890");
    }
}