use anyhow::Result;
use vtcode_core::config::LifecycleHooksConfig;
use vtcode_core::config::ToolPolicy;
use vtcode_core::config::api_keys::{ApiKeySources, get_api_key};
use vtcode_core::config::loader::{ConfigManager, VTCodeConfig};
use vtcode_core::config::types::AgentConfig as CoreAgentConfig;
use vtcode_core::tools::ripgrep_binary::RIPGREP_INSTALL_COMMAND;
use vtcode_core::utils::ansi::{AnsiRenderer, MessageStyle};
use super::async_mcp_manager::{AsyncMcpManager, McpInitStatus};
use super::workspace_links::LinkedDirectory;
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct CheckupOptions {
pub quick: bool,
}
#[derive(Default)]
struct DoctorSummary {
passed: usize,
warnings: usize,
failures: usize,
}
impl DoctorSummary {
fn record(&mut self, outcome: &DoctorCheckOutcome) {
match outcome {
DoctorCheckOutcome::Pass(_) => self.passed += 1,
DoctorCheckOutcome::Warn(_) => self.warnings += 1,
DoctorCheckOutcome::Fail(_) => self.failures += 1,
}
}
}
enum DoctorCheckOutcome {
Pass(String),
Warn(String),
Fail(String),
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn run_checkup_diagnostics(
renderer: &mut AnsiRenderer,
config: &CoreAgentConfig,
vt_cfg: Option<&VTCodeConfig>,
provider_name: &str,
async_mcp_manager: Option<&AsyncMcpManager>,
linked_directories: &[LinkedDirectory],
loaded_skills: Option<
&std::sync::Arc<tokio::sync::RwLock<hashbrown::HashMap<String, vtcode_core::skills::types::Skill>>>,
>,
options: CheckupOptions,
) -> Result<()> {
let mut summary = DoctorSummary::default();
renderer.line(MessageStyle::Info, "")?;
renderer.line(MessageStyle::Status, "═══════════════════════════════════════════════════════════════")?;
renderer.line(MessageStyle::Status, &format!("VT Code Doctor v{}", env!("CARGO_PKG_VERSION")))?;
renderer.line(MessageStyle::Status, "═══════════════════════════════════════════════════════════════")?;
if options.quick {
renderer
.line(MessageStyle::Info, "Mode: quick (skips dependency, external service, and session context checks)")?;
}
renderer.line(MessageStyle::Info, "")?;
renderer.line(MessageStyle::Status, "[Core Environment]")?;
let workspace_result = if config.workspace.is_dir() {
DoctorCheckOutcome::Pass(format!("{}", config.workspace.display()))
} else {
DoctorCheckOutcome::Fail("Workspace directory is missing or inaccessible".to_string())
};
render_doctor_check(renderer, &mut summary, "Workspace", workspace_result)?;
let cli_version = format!("VT Code {}", env!("CARGO_PKG_VERSION"));
render_doctor_check(renderer, &mut summary, "CLI Version", DoctorCheckOutcome::Pass(cli_version))?;
renderer.line(MessageStyle::Info, "")?;
renderer.line(MessageStyle::Status, "[Configuration]")?;
let config_result = match ConfigManager::load_from_workspace(&config.workspace) {
Ok(manager) => {
if let Some(path) = manager.config_path() {
DoctorCheckOutcome::Pass(format!("Loaded from {}", path.display()))
} else {
DoctorCheckOutcome::Warn("Using runtime defaults (no vtcode.toml found)".to_string())
}
}
Err(err) => DoctorCheckOutcome::Fail(err.to_string()),
};
render_doctor_check(renderer, &mut summary, "Config File", config_result)?;
if let Some(cfg) = vt_cfg {
render_doctor_check(renderer, &mut summary, "Theme", DoctorCheckOutcome::Pass(cfg.agent.theme.clone()))?;
let model_info = if cfg.agent.small_model.enabled {
let lightweight_model = if cfg.agent.small_model.model.is_empty() {
"auto-select".to_string()
} else {
cfg.agent.small_model.model.clone()
};
format!("{} (+ lightweight model: {})", cfg.agent.default_model, lightweight_model)
} else {
cfg.agent.default_model.clone()
};
render_doctor_check(renderer, &mut summary, "Model", DoctorCheckOutcome::Pass(model_info))?;
render_doctor_check(
renderer,
&mut summary,
"Max Turns",
DoctorCheckOutcome::Pass(format!("{}", cfg.agent.max_conversation_turns)),
)?;
render_doctor_check(
renderer,
&mut summary,
"Context Tokens",
DoctorCheckOutcome::Pass(format!("{}", cfg.context.max_context_tokens)),
)?;
render_doctor_check(renderer, &mut summary, "Token Budget", DoctorCheckOutcome::Pass("Disabled".to_string()))?;
let ledger_status = if cfg.context.ledger.enabled {
format!("Enabled (max {} entries)", cfg.context.ledger.max_entries)
} else {
"Disabled".to_string()
};
render_doctor_check(renderer, &mut summary, "Decision Ledger", DoctorCheckOutcome::Pass(ledger_status))?;
render_doctor_check(
renderer,
&mut summary,
"Max Tool Loops",
DoctorCheckOutcome::Pass(format!("{}", cfg.tools.max_tool_loops)),
)?;
render_doctor_check(
renderer,
&mut summary,
"HITL Enabled",
DoctorCheckOutcome::Pass((if cfg.security.human_in_the_loop { "Yes" } else { "No" }).to_string()),
)?;
let policy = match cfg.tools.default_policy {
ToolPolicy::Allow => "Allow all (no confirmation)",
ToolPolicy::Deny => "Deny all (security)",
ToolPolicy::Prompt => "Prompt on tool use",
};
render_doctor_check(renderer, &mut summary, "Tool Policy", DoctorCheckOutcome::Pass(policy.to_string()))?;
render_doctor_check(
renderer,
&mut summary,
"PTY Enabled",
DoctorCheckOutcome::Pass((if cfg.pty.enabled { "Yes" } else { "No" }).to_string()),
)?;
}
renderer.line(MessageStyle::Info, "")?;
renderer.line(MessageStyle::Status, "[API & Providers]")?;
let configured_provider = canonical_provider_name(&config.provider);
let runtime_provider_source = if provider_name.trim().is_empty() {
config.provider.as_str()
} else {
provider_name
};
let runtime_provider = canonical_provider_name(runtime_provider_source);
if runtime_provider == "openresponses" {
render_doctor_check(
renderer,
&mut summary,
"Provider",
DoctorCheckOutcome::Pass(format!(
"Configured '{configured_provider}', runtime adapter '{runtime_provider}'"
)),
)?;
} else if configured_provider == runtime_provider {
render_doctor_check(
renderer,
&mut summary,
"Provider",
DoctorCheckOutcome::Pass(format!("Configured and active: {runtime_provider}")),
)?;
} else {
render_doctor_check(
renderer,
&mut summary,
"Provider",
DoctorCheckOutcome::Warn(format!("Configured '{configured_provider}', active '{runtime_provider}'")),
)?;
}
let provider_for_api_check = if runtime_provider == "openresponses" {
configured_provider.as_str()
} else {
runtime_provider.as_str()
};
let api_key_result =
if provider_for_api_check.eq_ignore_ascii_case("openai") && config.openai_chatgpt_auth.is_some() {
DoctorCheckOutcome::Pass("ChatGPT subscription active for OpenAI".to_string())
} else if provider_requires_api_key(provider_for_api_check) {
match get_api_key(provider_for_api_check, &ApiKeySources::default()) {
Ok(_) => DoctorCheckOutcome::Pass(format!("API key configured for '{provider_for_api_check}'")),
Err(err) => {
let detail = err.to_string();
if detail.contains("Unsupported provider") {
DoctorCheckOutcome::Warn(format!(
"Skipped API-key validation for unsupported provider '{provider_for_api_check}'"
))
} else {
DoctorCheckOutcome::Fail(format!(
"Missing API key for '{}': {} (expected: {})",
provider_for_api_check,
detail,
api_key_env_hint(provider_for_api_check)
))
}
}
}
} else {
DoctorCheckOutcome::Pass(format!("Not required for local provider '{provider_for_api_check}'"))
};
render_doctor_check(renderer, &mut summary, "API Key", api_key_result)?;
if options.quick {
renderer.line(MessageStyle::Info, "")?;
renderer.line(MessageStyle::Status, "[Skipped Checks]")?;
renderer.line(
MessageStyle::Info,
"Dependencies, MCP status, workspace links, and loaded skills are skipped in quick mode.",
)?;
} else {
renderer.line(MessageStyle::Info, "")?;
renderer.line(MessageStyle::Status, "[Dependencies]")?;
let node_result = match detect_command_version("node", &["--version"]) {
Ok(version) => DoctorCheckOutcome::Pass(format!("Node.js {version}")),
Err(err) => DoctorCheckOutcome::Warn(err),
};
render_doctor_check(renderer, &mut summary, "Node.js", node_result)?;
let npm_result = match detect_command_version("npm", &["--version"]) {
Ok(version) => DoctorCheckOutcome::Pass(format!("npm {version}")),
Err(err) => DoctorCheckOutcome::Warn(err),
};
render_doctor_check(renderer, &mut summary, "npm", npm_result)?;
let ripgrep_result = match detect_command_version("rg", &["--version"]) {
Ok(version) => DoctorCheckOutcome::Pass(format!("Ripgrep {}", version.lines().next().unwrap_or(&version))),
Err(err) => {
if err.contains("not found") {
DoctorCheckOutcome::Warn("Not installed (searches will fall back to built-in grep)".to_string())
} else {
DoctorCheckOutcome::Warn(err)
}
}
};
render_doctor_check(renderer, &mut summary, "Ripgrep", ripgrep_result)?;
renderer.line(MessageStyle::Info, "")?;
renderer.line(MessageStyle::Status, "[External Services]")?;
let mcp_result = if let Some(cfg) = vt_cfg {
if cfg.mcp.enabled {
if let Some(manager) = async_mcp_manager {
let status = manager.get_status().await;
match status {
McpInitStatus::Ready { client } => {
let runtime_status = client.get_status();
DoctorCheckOutcome::Pass(format!(
"{} configured, {} active connection(s)",
runtime_status.configured_providers.len(),
runtime_status.active_connections
))
}
McpInitStatus::Initializing { progress } => {
DoctorCheckOutcome::Warn(format!("Initializing: {progress}"))
}
McpInitStatus::Error { message } => DoctorCheckOutcome::Fail(format!("Init error: {message}")),
McpInitStatus::Disabled => DoctorCheckOutcome::Pass("Disabled in config".to_string()),
}
} else {
DoctorCheckOutcome::Fail("Enabled in config but manager not initialized".to_string())
}
} else {
DoctorCheckOutcome::Pass("Disabled".to_string())
}
} else {
DoctorCheckOutcome::Warn("No MCP configuration".to_string())
};
render_doctor_check(renderer, &mut summary, "MCP", mcp_result)?;
renderer.line(MessageStyle::Info, "")?;
renderer.line(MessageStyle::Status, "[Workspace Links]")?;
if linked_directories.is_empty() {
renderer.line(MessageStyle::Output, " No linked directories")?;
} else {
for (idx, entry) in linked_directories.iter().enumerate() {
renderer.line(
MessageStyle::Output,
&format!(" [{}] {} -> {}", idx + 1, entry.display_path, entry.original.display()),
)?;
}
}
renderer.line(MessageStyle::Info, "")?;
renderer.line(MessageStyle::Status, "[Skills]")?;
if let Some(skills_map) = loaded_skills {
let skills = skills_map.read().await;
if skills.is_empty() {
renderer.line(MessageStyle::Output, " No skills loaded in session")?;
} else {
renderer.line(MessageStyle::Output, &format!(" {} loaded skill(s):", skills.len()))?;
let mut sorted_skills: Vec<_> = skills.iter().collect();
sorted_skills.sort_by(|(name_a, _), (name_b, _)| name_a.cmp(name_b));
for (idx, (name, skill)) in sorted_skills.iter().enumerate() {
let scope = format!("{:?}", skill.scope).to_lowercase();
renderer.line(MessageStyle::Output, &format!(" [{}] {} ({})", idx + 1, name, scope))?;
}
}
} else {
renderer.line(MessageStyle::Output, " Skills context unavailable")?;
}
}
renderer.line(MessageStyle::Info, "")?;
renderer.line(MessageStyle::Status, "═══════════════════════════════════════════════════════════════")?;
render_checkup_optimizations(renderer, vt_cfg, config)?;
renderer.line(MessageStyle::Info, "")?;
renderer.line(MessageStyle::Status, "[Summary]")?;
renderer.line(
MessageStyle::Status,
&format!(" {} passed, {} warning(s), {} failure(s)", summary.passed, summary.warnings, summary.failures),
)?;
if summary.failures > 0 {
renderer
.line(MessageStyle::Error, "[FAIL] Resolve failures first, then rerun `/checkup` (alias `/doctor`).")?;
} else if summary.warnings > 0 {
renderer.line(MessageStyle::Warning, "[WARN] Core checks passed, but there are warnings to review.")?;
} else {
renderer.line(MessageStyle::Status, "[OK] All checks passed. You are ready to use VT Code.")?;
}
renderer.line(MessageStyle::Info, "")?;
renderer.line(MessageStyle::Status, "[Recommended Next Actions]")?;
if summary.failures > 0 {
renderer.line(
MessageStyle::Info,
"[FAIL] Follow the remediation hints above and run `/checkup` again (alias `/doctor`).",
)?;
}
if summary.warnings > 0 {
renderer.line(
MessageStyle::Info,
"[WARN] Address warnings for better reliability (they are not always blocking).",
)?;
}
if options.quick {
renderer.line(
MessageStyle::Info,
"[TIP] Run `/checkup --full` (alias `/doctor`) to include dependencies, MCP, links, and skills checks.",
)?;
}
renderer.line(
MessageStyle::Info,
"[TIP] For more details: `/skills list` (available skills), `/status` (session), `/compact` (conversation compaction)",
)?;
renderer.line(MessageStyle::Info, "[DOCS] See docs/ide/troubleshooting.md for troubleshooting guidance.")?;
renderer.line(MessageStyle::Info, "")?;
renderer.line(MessageStyle::Status, "═══════════════════════════════════════════════════════════════")?;
renderer.line(MessageStyle::Info, "")?;
Ok(())
}
fn render_checkup_optimizations(
renderer: &mut AnsiRenderer,
vt_cfg: Option<&VTCodeConfig>,
_config: &CoreAgentConfig,
) -> Result<()> {
renderer.line(MessageStyle::Status, "[Optimization Suggestions]")?;
if let Some(cfg) = vt_cfg {
if !cfg.automation.full_auto.enabled {
renderer.line(
MessageStyle::Info,
"[TIP] Auto mode is disabled. Enable [automation.full_auto] in vtcode.toml (or run `vtcode --auto`) to let VT Code run unattended.",
)?;
}
let hook_count = count_configured_hooks(&cfg.hooks.lifecycle);
if hook_count > 0 {
renderer.line(
MessageStyle::Info,
&format!(
"[TIP] {hook_count} lifecycle hook group(s) configured. Disable slow hooks (e.g. pre/post-tool-use) when not needed to speed up runs."
),
)?;
}
}
match vt_cfg.map(|c| &c.tools.default_policy) {
Some(ToolPolicy::Prompt) => renderer.line(
MessageStyle::Info,
"[TIP] Tool policy is 'prompt': every tool use asks for confirmation. Pre-approve frequently-used read-only commands to reduce interruptions.",
)?,
Some(ToolPolicy::Deny) => renderer.line(
MessageStyle::Info,
"[TIP] Tool policy is 'deny': most tools are blocked. Allow the commands you trust to avoid manual overrides.",
)?,
Some(ToolPolicy::Allow) | None => {}
}
renderer.line(MessageStyle::Info, "[TIP] Run `/update` or `vtcode update` to check for the latest version.")?;
renderer.line(
MessageStyle::Info,
"[TIP] Remove unused skills/MCPs and deduplicate or split your CLAUDE.md into nested files to save context.",
)?;
Ok(())
}
pub(crate) fn count_configured_hooks(hooks: &LifecycleHooksConfig) -> usize {
[
hooks.session_start.len(),
hooks.session_end.len(),
hooks.subagent_start.len(),
hooks.subagent_stop.len(),
hooks.user_prompt_submit.len(),
hooks.pre_tool_use.len(),
hooks.post_tool_use.len(),
hooks.permission_request.len(),
hooks.pre_compact.len(),
hooks.stop.len(),
hooks.task_completion.len(),
hooks.task_completed.len(),
hooks.notification.len(),
]
.into_iter()
.sum()
}
fn render_doctor_check(
renderer: &mut AnsiRenderer,
summary: &mut DoctorSummary,
label: &str,
outcome: DoctorCheckOutcome,
) -> Result<()> {
summary.record(&outcome);
match outcome {
DoctorCheckOutcome::Pass(detail) => renderer.line(MessageStyle::Status, &format!("✓ {label}: {detail}"))?,
DoctorCheckOutcome::Warn(detail) => {
renderer.line(MessageStyle::Warning, &format!("! {label}: {detail}"))?;
if let Some(suggestion) = get_suggestion_for_issue(label, &detail) {
renderer.line(MessageStyle::Info, &format!(" -> {suggestion}"))?;
}
}
DoctorCheckOutcome::Fail(detail) => {
renderer.line(MessageStyle::Error, &format!("✗ {label}: {detail}"))?;
if let Some(suggestion) = get_suggestion_for_issue(label, &detail) {
renderer.line(MessageStyle::Info, &format!(" -> {suggestion}"))?;
}
}
}
Ok(())
}
fn get_suggestion_for_issue(label: &str, detail: &str) -> Option<String> {
let label_lower = label.to_ascii_lowercase();
let detail_lower = detail.to_ascii_lowercase();
if label_lower.contains("workspace") {
Some("Ensure workspace directory is accessible and not deleted.".to_string())
} else if label_lower.contains("api key") {
Some(
"Set the provider API key in your environment or vtcode.toml, then rerun `/checkup` (alias `/doctor`)."
.to_string(),
)
} else if label_lower.contains("config file") && detail_lower.contains("no vtcode.toml") {
Some("Run `/init` to generate vtcode.toml with guided setup.".to_string())
} else if label_lower.contains("node.js") {
Some("Install Node.js: brew install node (macOS) or see nodejs.org".to_string())
} else if label_lower == "npm" {
Some("Install npm with Node.js or update it: npm install -g npm@latest".to_string())
} else if label_lower.contains("ripgrep") {
Some(format!(
"Run `{RIPGREP_INSTALL_COMMAND}` for guided installation, or install `ripgrep` manually with your package manager."
))
} else if label_lower.contains("mcp") && detail_lower.contains("error") {
Some("Check MCP configuration in vtcode.toml, server availability, and timeouts.".to_string())
} else {
None
}
}
fn canonical_provider_name(raw: &str) -> String {
let normalized = raw.trim().to_ascii_lowercase();
if normalized.is_empty() {
return "gemini".to_string();
}
match normalized.as_str() {
"open-responses" => "openresponses".to_string(),
"lm studio" | "lm_studio" => "lmstudio".to_string(),
"llama.cpp" | "llama-cpp" | "llama cpp" => "llamacpp".to_string(),
"z.ai" | "z-ai" => "zai".to_string(),
_ => normalized,
}
}
fn provider_requires_api_key(provider: &str) -> bool {
!matches!(provider, "ollama" | "lmstudio" | "llamacpp" | "llama.cpp" | "llama-cpp")
}
fn api_key_env_hint(provider: &str) -> String {
match provider {
"gemini" => "GEMINI_API_KEY or GOOGLE_API_KEY".to_string(),
"openai" => "OPENAI_API_KEY".to_string(),
"anthropic" => "ANTHROPIC_API_KEY".to_string(),
"deepseek" => "DEEPSEEK_API_KEY".to_string(),
"openrouter" => "OPENROUTER_API_KEY".to_string(),
"zai" => "ZAI_API_KEY".to_string(),
"moonshot" => "MOONSHOT_API_KEY".to_string(),
"minimax" => "MINIMAX_API_KEY".to_string(),
"huggingface" => "HF_TOKEN".to_string(),
"ollama" => "OLLAMA_API_KEY".to_string(),
"lmstudio" => "LMSTUDIO_API_KEY".to_string(),
"llamacpp" => "LLAMACPP_API_KEY".to_string(),
other => format!("{}_API_KEY", other.to_ascii_uppercase()),
}
}
fn detect_command_version(command: &str, args: &[&str]) -> std::result::Result<String, String> {
use std::process::Command;
match Command::new(command).args(args).output() {
Ok(output) => {
if output.status.success() {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let version_text = if !stdout.is_empty() { stdout } else { stderr };
if version_text.is_empty() {
Err(format!("{} {} returned no version output", command, args.join(" ")))
} else {
Ok(version_text)
}
} else {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let reason = if !stderr.is_empty() {
stderr
} else {
output.status.to_string()
};
Err(format!("{} {} exited with error: {}", command, args.join(" "), reason))
}
}
Err(err) => {
if err.kind() == std::io::ErrorKind::NotFound {
Err(format!("{command} not found in PATH"))
} else {
Err(format!("Failed to execute {command}: {err}"))
}
}
}
}
#[cfg(test)]
mod tests {
use super::{api_key_env_hint, canonical_provider_name, provider_requires_api_key};
#[test]
fn canonical_provider_keeps_openresponses_runtime_adapter() {
assert_eq!(canonical_provider_name("openresponses"), "openresponses");
assert_eq!(canonical_provider_name("open-responses"), "openresponses");
}
#[test]
fn canonical_provider_normalizes_variants() {
assert_eq!(canonical_provider_name(" LM Studio "), "lmstudio");
assert_eq!(canonical_provider_name("Z.AI"), "zai");
assert_eq!(canonical_provider_name(""), "gemini");
}
#[test]
fn local_providers_do_not_require_api_keys() {
assert!(!provider_requires_api_key("ollama"));
assert!(!provider_requires_api_key("lmstudio"));
assert!(provider_requires_api_key("openai"));
}
#[test]
fn api_key_hint_is_provider_specific() {
assert_eq!(api_key_env_hint("openrouter"), "OPENROUTER_API_KEY");
assert_eq!(api_key_env_hint("huggingface"), "HF_TOKEN");
}
}