oy-cli 0.11.2

OpenCode launcher and deterministic MCP helpers for repository audit and review workflows
Documentation
//! `oy doctor` checks the integration and local deterministic helpers.

use anyhow::Result;
use clap::Args;
use std::path::Path;

use crate::config;

const TOKEI_HINT: &str = "mise use cargo:tokei || brew install tokei";
const CTAGS_HINT: &str = "mise use aqua:universal-ctags/ctags || brew install universal-ctags";

#[derive(Debug, Args, Clone)]
pub(super) struct DoctorArgs {
    #[arg(
        long,
        alias = "agent",
        default_value = "default",
        help = "Safety mode to inspect (default: balanced): plan, ask, edit, or auto"
    )]
    mode: config::SafetyMode,
}

pub(super) async fn doctor_command(args: DoctorArgs) -> Result<i32> {
    let root = config::oy_root()?;
    let mode = args.mode;
    let policy = config::tool_policy(mode);
    let opencode_ok = command_ok("opencode", &["--version"]);
    let oy_mcp_ok = command_ok("oy", &["mcp"]);
    let tokei_ok = crate::tools::has_external_sloc_counter();
    let ctags_ok = crate::tools::has_external_outline_tool();
    let global_config = crate::opencode::global_config_path()?;
    let workspace_config = crate::opencode::workspace_config_path()?;
    let configured = global_config.exists() || workspace_config.exists();

    if crate::ui::is_json() {
        let payload = serde_json::json!({
            "workspace": root,
            "mode": mode.name(),
            "policy": policy,
            "opencode": opencode_ok,
            "oy_mcp_command": oy_mcp_ok,
            "optional_tools": {
                "tokei": {
                    "available": tokei_ok,
                    "enables": "sloc MCP tool",
                    "install": TOKEI_HINT,
                },
                "universal_ctags": {
                    "available": ctags_ok,
                    "enables": "outline MCP tool",
                    "install": CTAGS_HINT,
                }
            },
            "global_opencode_config": global_config,
            "workspace_opencode_config": workspace_config,
            "configured": configured,
            "next_step": recommended_next_step(opencode_ok, configured),
        });
        crate::ui::line(serde_json::to_string_pretty(&payload)?);
        return Ok(0);
    }

    crate::ui::section("Doctor");
    crate::ui::kv("workspace", root.display());
    crate::ui::kv("mode", mode.name());
    crate::ui::kv("files-write", format_args!("{:?}", policy.files_write()));
    crate::ui::kv("shell", format_args!("{:?}", policy.shell));
    crate::ui::kv(
        "opencode",
        crate::ui::status_text(opencode_ok, if opencode_ok { "ok" } else { "missing" }),
    );
    crate::ui::kv(
        "optional tokei",
        crate::ui::status_text(
            tokei_ok,
            if tokei_ok {
                "ok; enables sloc MCP tool".to_string()
            } else {
                format!("missing; install with `{TOKEI_HINT}`")
            },
        ),
    );
    crate::ui::kv(
        "optional ctags",
        crate::ui::status_text(
            ctags_ok,
            if ctags_ok {
                "ok; enables outline MCP tool".to_string()
            } else {
                format!("missing; install Universal Ctags with `{CTAGS_HINT}`")
            },
        ),
    );
    crate::ui::kv(
        "global config",
        crate::ui::status_text(
            global_config.exists(),
            format_args!("{}", global_config.display()),
        ),
    );
    crate::ui::kv(
        "workspace config",
        crate::ui::status_text(
            workspace_config.exists(),
            format_args!("{}", workspace_config.display()),
        ),
    );
    crate::ui::line("");
    crate::ui::section("Recommended next step");
    crate::ui::line(format_args!(
        "  {}",
        recommended_next_step(opencode_ok, configured)
    ));
    crate::ui::line("");
    crate::ui::section("Container hint");
    crate::ui::line(format_args!("  {}", safe_container_command(&root, true)));
    Ok(0)
}

fn command_ok(command: &str, args: &[&str]) -> bool {
    std::process::Command::new(command)
        .args(args)
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .status()
        .map(|status| status.success())
        .unwrap_or(false)
}

fn recommended_next_step(opencode_ok: bool, configured: bool) -> &'static str {
    match (opencode_ok, configured) {
        (false, _) => "Install opencode, then run `oy setup`.",
        (true, false) => "Run `oy setup`, then restart opencode.",
        (true, true) => "Run `oy` to launch with the oy integration.",
    }
}

fn safe_container_command(root: &Path, read_only: bool) -> String {
    let mode = if read_only { "ro" } else { "rw" };
    let mount = format!("{}:/workspace:{mode}", root.display());
    format!(
        "docker run --rm -it -v {} -w /workspace oy-image oy",
        shell_quote(&mount)
    )
}

fn shell_quote(value: &str) -> String {
    if !value.is_empty()
        && value
            .chars()
            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | '/' | ':' | '='))
    {
        return value.to_string();
    }
    format!("'{}'", value.replace('\'', "'\\''"))
}

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

    #[test]
    fn command_probe_closes_stdin() {
        assert!(!command_ok(
            "sh",
            &["-c", "if read _; then exit 0; else exit 17; fi"]
        ));
    }
}