oy-cli 0.11.8

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::io::{IsTerminal as _, Write as _};
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,
    #[arg(
        long,
        default_value_t = false,
        help = "Install missing opencode/tokei/ctags with mise when mise is available"
    )]
    install_missing: bool,
}

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 mise_ok = command_ok("mise", &["--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,
            "mise": mise_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, mise_ok, missing_mise_tools(opencode_ok, tokei_ok, ctags_ok).is_empty()),
        });
        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(
        "mise",
        crate::ui::status_text(mise_ok, if mise_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,
            mise_ok,
            missing_mise_tools(opencode_ok, tokei_ok, ctags_ok).is_empty(),
        )
    ));
    crate::ui::line("");
    crate::ui::section("Container hint");
    crate::ui::line(format_args!("  {}", safe_container_command(&root, true)));

    maybe_install_missing_with_mise(
        args.install_missing,
        mise_ok,
        opencode_ok,
        tokei_ok,
        ctags_ok,
    )?;
    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,
    mise_ok: bool,
    no_missing_mise_tools: bool,
) -> &'static str {
    match (opencode_ok, configured, mise_ok, no_missing_mise_tools) {
        (false, _, true, _) => {
            "Run `oy doctor --install-missing` to install opencode with mise, then `oy setup`."
        }
        (false, _, false, _) => "Install opencode, then run `oy setup`.",
        (true, false, _, _) => "Run `oy setup`, then restart opencode.",
        (true, true, true, false) => {
            "Run `oy doctor --install-missing` to install optional mise helpers, or `oy` to launch now."
        }
        (true, true, _, _) => "Run `oy` to launch with the oy integration.",
    }
}

fn missing_mise_tools(opencode_ok: bool, tokei_ok: bool, ctags_ok: bool) -> Vec<&'static str> {
    let mut tools = Vec::new();
    if !opencode_ok {
        tools.push("opencode");
    }
    if !tokei_ok {
        tools.push("cargo:tokei");
    }
    if !ctags_ok {
        tools.push("aqua:universal-ctags/ctags");
    }
    tools
}

fn maybe_install_missing_with_mise(
    requested: bool,
    mise_ok: bool,
    opencode_ok: bool,
    tokei_ok: bool,
    ctags_ok: bool,
) -> Result<()> {
    let tools = missing_mise_tools(opencode_ok, tokei_ok, ctags_ok);
    if tools.is_empty() {
        return Ok(());
    }
    if !mise_ok {
        return Ok(());
    }
    if !requested && !should_prompt_install(&tools)? {
        return Ok(());
    }
    crate::ui::line(format_args!(
        "Installing missing tools with mise: {}",
        tools.join(" ")
    ));
    let status = std::process::Command::new("mise")
        .arg("install")
        .args(&tools)
        .status()?;
    if status.success() {
        crate::ui::success("mise install completed");
    } else {
        crate::ui::err_line(format_args!(
            "mise install failed with exit code {}",
            status.code().unwrap_or(1)
        ));
    }
    Ok(())
}

fn should_prompt_install(tools: &[&str]) -> Result<bool> {
    if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() || crate::ui::is_json() {
        return Ok(false);
    }
    crate::ui::line("");
    crate::ui::out(&format!(
        "Install missing tools with mise now? [{}] [y/N] ",
        tools.join(" ")
    ));
    std::io::stdout().flush()?;
    let mut answer = String::new();
    std::io::stdin().read_line(&mut answer)?;
    Ok(matches!(
        answer.trim().to_ascii_lowercase().as_str(),
        "y" | "yes"
    ))
}

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"]
        ));
    }

    #[test]
    fn mise_tool_list_tracks_missing_tools() {
        assert_eq!(
            missing_mise_tools(false, false, true),
            vec!["opencode", "cargo:tokei"]
        );
        assert!(missing_mise_tools(true, true, true).is_empty());
    }
}