From f0d82a7cc01859315b478cd2983382a595389e9a Mon Sep 17 00:00:00 2001 From: Yeachan-Heo Date: Sun, 5 Apr 2026 16:44:36 +0000 Subject: [PATCH] Keep doctor and local help paths shell-native MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Promote doctor into a real top-level CLI action, reuse the same local report for resumed and REPL doctor invocations, and intercept doctor/status/sandbox help flags before prompt-mode dispatch. The parser change also closes the help fallthrough that previously wandered into runtime startup for local-info commands. Constraint: Preserve prompt shorthand for normal multi-word text input while fixing exact local subcommand help paths Rejected: Route \7⠋ 🦀 Thinking...8✘ ❌ Request failed  through prompt/slash guidance | still shells out through the wrong surface and keeps health checks hidden Rejected: Reuse the status report as doctor output | status does not explain auth/config health or expose a dedicated diagnostic summary Confidence: high Scope-risk: narrow Directive: Keep doctor local-only unless an explicit network probe is intentionally added and separately tested Tested: cargo build -p rusty-claude-cli; cargo test -p rusty-claude-cli; cargo run -p rusty-claude-cli -- doctor --help; CLAW_CONFIG_HOME=/tmp/tmp.7pm9SVzOPN ANTHROPIC_API_KEY= ANTHROPIC_AUTH_TOKEN= cargo run -p rusty-claude-cli -- doctor Not-tested: direct /doctor outside the REPL remains interactive-only --- rust/crates/rusty-claude-cli/src/main.rs | 964 +++++++++++------- .../tests/cli_flags_and_config_defaults.rs | 92 +- 2 files changed, 656 insertions(+), 400 deletions(-) diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 3d19976..dbf9311 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -24,9 +24,10 @@ use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant, UNIX_EPOCH}; use api::{ - resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock, - InputMessage, MessageRequest, MessageResponse, OutputContentBlock, PromptCache, - StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, + oauth_token_is_expired, resolve_startup_auth_source, AnthropicClient, AuthSource, + ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse, + OutputContentBlock, PromptCache, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, + ToolResultContentBlock, }; use commands::{ @@ -39,17 +40,17 @@ use init::initialize_repo; use plugins::{PluginHooks, PluginManager, PluginManagerConfig, PluginRegistry}; use render::{MarkdownStreamState, Spinner, TerminalRenderer}; use runtime::{ - clear_oauth_credentials, format_usd, generate_pkce_pair, generate_state, load_system_prompt, - parse_oauth_callback_request_target, pricing_for_model, resolve_sandbox_status, - save_oauth_credentials, ApiClient, ApiRequest, AssistantEvent, CompactionConfig, ConfigLoader, - ConfigSource, ContentBlock, ConversationMessage, ConversationRuntime, McpServerManager, - McpTool, MessageRole, ModelPricing, OAuthAuthorizationRequest, OAuthConfig, - OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, PromptCacheEvent, - ResolvedPermissionMode, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor, - UsageTracker, + clear_oauth_credentials, format_usd, generate_pkce_pair, generate_state, + load_oauth_credentials, load_system_prompt, parse_oauth_callback_request_target, + pricing_for_model, resolve_sandbox_status, save_oauth_credentials, ApiClient, ApiRequest, + AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, + ConversationMessage, ConversationRuntime, McpServerManager, McpTool, MessageRole, ModelPricing, + OAuthAuthorizationRequest, OAuthConfig, OAuthTokenExchangeRequest, PermissionMode, + PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError, + Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, }; use serde::Deserialize; -use serde_json::{json, Value}; +use serde_json::json; use tools::{GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput}; const DEFAULT_MODEL: &str = "claude-opus-4-6"; @@ -117,14 +118,12 @@ fn run() -> Result<(), Box> { CliAction::ResumeSession { session_path, commands, - output_format, - } => resume_session(&session_path, &commands, output_format), + } => resume_session(&session_path, &commands), CliAction::Status { model, permission_mode, - output_format, - } => print_status_snapshot(&model, permission_mode, output_format)?, - CliAction::Sandbox { output_format } => print_sandbox_status_snapshot(output_format)?, + } => print_status_snapshot(&model, permission_mode)?, + CliAction::Sandbox => print_sandbox_status_snapshot()?, CliAction::Prompt { prompt, model, @@ -135,12 +134,14 @@ fn run() -> Result<(), Box> { .run_turn_with_output(&prompt, output_format)?, CliAction::Login => run_login()?, CliAction::Logout => run_logout()?, + CliAction::Doctor => run_doctor()?, CliAction::Init => run_init()?, CliAction::Repl { model, allowed_tools, permission_mode, } => run_repl(model, allowed_tools, permission_mode)?, + CliAction::HelpTopic(topic) => print_help_topic(topic), CliAction::Help => print_help(), } Ok(()) @@ -167,16 +168,12 @@ enum CliAction { ResumeSession { session_path: PathBuf, commands: Vec, - output_format: CliOutputFormat, }, Status { model: String, permission_mode: PermissionMode, - output_format: CliOutputFormat, - }, - Sandbox { - output_format: CliOutputFormat, }, + Sandbox, Prompt { prompt: String, model: String, @@ -186,16 +183,25 @@ enum CliAction { }, Login, Logout, + Doctor, Init, Repl { model: String, allowed_tools: Option, permission_mode: PermissionMode, }, + HelpTopic(LocalHelpTopic), // prompt-mode formatting is only supported for non-interactive runs Help, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LocalHelpTopic { + Status, + Sandbox, + Doctor, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CliOutputFormat { Text, @@ -345,11 +351,12 @@ fn parse_args(args: &[String]) -> Result { }); } if rest.first().map(String::as_str) == Some("--resume") { - return parse_resume_args(&rest[1..], output_format); + return parse_resume_args(&rest[1..]); } - if let Some(action) = - parse_single_word_command_alias(&rest, &model, permission_mode_override, output_format) - { + if let Some(action) = parse_local_help_action(&rest) { + return action; + } + if let Some(action) = parse_single_word_command_alias(&rest, &model, permission_mode_override) { return action; } @@ -395,11 +402,28 @@ fn parse_args(args: &[String]) -> Result { } } +fn parse_local_help_action(rest: &[String]) -> Option> { + if rest.len() != 2 || !is_help_flag(&rest[1]) { + return None; + } + + let topic = match rest[0].as_str() { + "status" => LocalHelpTopic::Status, + "sandbox" => LocalHelpTopic::Sandbox, + "doctor" => LocalHelpTopic::Doctor, + _ => return None, + }; + Some(Ok(CliAction::HelpTopic(topic))) +} + +fn is_help_flag(value: &str) -> bool { + matches!(value, "--help" | "-h") +} + fn parse_single_word_command_alias( rest: &[String], model: &str, permission_mode_override: Option, - output_format: CliOutputFormat, ) -> Option> { if rest.len() != 1 { return None; @@ -411,9 +435,9 @@ fn parse_single_word_command_alias( "status" => Some(Ok(CliAction::Status { model: model.to_string(), permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode), - output_format, })), - "sandbox" => Some(Ok(CliAction::Sandbox { output_format })), + "sandbox" => Some(Ok(CliAction::Sandbox)), + "doctor" => Some(Ok(CliAction::Doctor)), other => bare_slash_command_guidance(other).map(Err), } } @@ -707,7 +731,7 @@ fn parse_system_prompt_args(args: &[String]) -> Result { Ok(CliAction::PrintSystemPrompt { cwd, date }) } -fn parse_resume_args(args: &[String], output_format: CliOutputFormat) -> Result { +fn parse_resume_args(args: &[String]) -> Result { let (session_path, command_tokens): (PathBuf, &[String]) = match args.first() { None => (PathBuf::from(LATEST_SESSION_REFERENCE), &[]), Some(first) if looks_like_slash_command_token(first) => { @@ -747,10 +771,399 @@ fn parse_resume_args(args: &[String], output_format: CliOutputFormat) -> Result< Ok(CliAction::ResumeSession { session_path, commands, - output_format, }) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DiagnosticLevel { + Ok, + Warn, + Fail, +} + +impl DiagnosticLevel { + fn label(self) -> &'static str { + match self { + Self::Ok => "ok", + Self::Warn => "warn", + Self::Fail => "fail", + } + } + + fn is_failure(self) -> bool { + matches!(self, Self::Fail) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DiagnosticCheck { + name: &'static str, + level: DiagnosticLevel, + summary: String, + details: Vec, +} + +impl DiagnosticCheck { + fn new(name: &'static str, level: DiagnosticLevel, summary: impl Into) -> Self { + Self { + name, + level, + summary: summary.into(), + details: Vec::new(), + } + } + + fn with_details(mut self, details: Vec) -> Self { + self.details = details; + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DoctorReport { + checks: Vec, +} + +impl DoctorReport { + fn has_failures(&self) -> bool { + self.checks.iter().any(|check| check.level.is_failure()) + } + + fn render(&self) -> String { + let ok_count = self + .checks + .iter() + .filter(|check| check.level == DiagnosticLevel::Ok) + .count(); + let warn_count = self + .checks + .iter() + .filter(|check| check.level == DiagnosticLevel::Warn) + .count(); + let fail_count = self + .checks + .iter() + .filter(|check| check.level == DiagnosticLevel::Fail) + .count(); + let mut lines = vec![ + "Doctor".to_string(), + format!( + "Summary\n OK {ok_count}\n Warnings {warn_count}\n Failures {fail_count}" + ), + ]; + lines.extend(self.checks.iter().map(render_diagnostic_check)); + lines.join("\n\n") + } +} + +fn render_diagnostic_check(check: &DiagnosticCheck) -> String { + let mut lines = vec![format!( + "{}\n Status {}\n Summary {}", + check.name, + check.level.label(), + check.summary + )]; + if !check.details.is_empty() { + lines.push(" Details".to_string()); + lines.extend(check.details.iter().map(|detail| format!(" - {detail}"))); + } + lines.join("\n") +} + +fn render_doctor_report() -> Result> { + let cwd = env::current_dir()?; + let config_loader = ConfigLoader::default_for(&cwd); + let config = config_loader.load(); + let discovered_config = config_loader.discover(); + let project_context = ProjectContext::discover_with_git(&cwd, DEFAULT_DATE)?; + let (project_root, git_branch) = + parse_git_status_metadata(project_context.git_status.as_deref()); + let git_summary = parse_git_workspace_summary(project_context.git_status.as_deref()); + let empty_config = runtime::RuntimeConfig::empty(); + let sandbox_config = config.as_ref().ok().unwrap_or(&empty_config); + let context = StatusContext { + cwd: cwd.clone(), + session_path: None, + loaded_config_files: config + .as_ref() + .ok() + .map_or(0, |runtime_config| runtime_config.loaded_entries().len()), + discovered_config_files: discovered_config.len(), + memory_file_count: project_context.instruction_files.len(), + project_root, + git_branch, + git_summary, + sandbox_status: resolve_sandbox_status(sandbox_config.sandbox(), &cwd), + }; + Ok(DoctorReport { + checks: vec![ + check_auth_health(), + check_config_health(&config_loader, config.as_ref()), + check_workspace_health(&context), + check_sandbox_health(&context.sandbox_status), + check_system_health(&cwd, config.as_ref().ok()), + ], + }) +} + +fn run_doctor() -> Result<(), Box> { + let report = render_doctor_report()?; + println!("{}", report.render()); + if report.has_failures() { + return Err("doctor found failing checks".into()); + } + Ok(()) +} + +fn check_auth_health() -> DiagnosticCheck { + let api_key_present = env::var("ANTHROPIC_API_KEY") + .ok() + .is_some_and(|value| !value.trim().is_empty()); + let auth_token_present = env::var("ANTHROPIC_AUTH_TOKEN") + .ok() + .is_some_and(|value| !value.trim().is_empty()); + + match load_oauth_credentials() { + Ok(Some(token_set)) => { + let expired = oauth_token_is_expired(&api::OAuthTokenSet { + access_token: token_set.access_token.clone(), + refresh_token: token_set.refresh_token.clone(), + expires_at: token_set.expires_at, + scopes: token_set.scopes.clone(), + }); + let mut details = vec![ + format!( + "Environment api_key={} auth_token={}", + if api_key_present { "present" } else { "absent" }, + if auth_token_present { + "present" + } else { + "absent" + } + ), + format!( + "Saved OAuth expires_at={} refresh_token={} scopes={}", + token_set + .expires_at + .map_or_else(|| "".to_string(), |value| value.to_string()), + if token_set.refresh_token.is_some() { + "present" + } else { + "absent" + }, + if token_set.scopes.is_empty() { + "".to_string() + } else { + token_set.scopes.join(",") + } + ), + ]; + if expired { + details.push( + "Suggested action claw login to refresh local OAuth credentials".to_string(), + ); + } + DiagnosticCheck::new( + "Auth", + if expired { + DiagnosticLevel::Warn + } else { + DiagnosticLevel::Ok + }, + if expired { + "saved OAuth credentials are present but expired" + } else if api_key_present || auth_token_present { + "environment and saved credentials are available" + } else { + "saved OAuth credentials are available" + }, + ) + .with_details(details) + } + Ok(None) => DiagnosticCheck::new( + "Auth", + if api_key_present || auth_token_present { + DiagnosticLevel::Ok + } else { + DiagnosticLevel::Warn + }, + if api_key_present || auth_token_present { + "environment credentials are configured" + } else { + "no API key or saved OAuth credentials were found" + }, + ) + .with_details(vec![format!( + "Environment api_key={} auth_token={}", + if api_key_present { "present" } else { "absent" }, + if auth_token_present { + "present" + } else { + "absent" + } + )]), + Err(error) => DiagnosticCheck::new( + "Auth", + DiagnosticLevel::Fail, + format!("failed to inspect saved credentials: {error}"), + ), + } +} + +fn check_config_health( + config_loader: &ConfigLoader, + config: Result<&runtime::RuntimeConfig, &runtime::ConfigError>, +) -> DiagnosticCheck { + let discovered = config_loader.discover(); + let discovered_count = discovered.len(); + let discovered_paths = discovered + .iter() + .map(|entry| entry.path.display().to_string()) + .collect::>(); + match config { + Ok(runtime_config) => { + let loaded_entries = runtime_config.loaded_entries(); + let mut details = vec![format!( + "Config files loaded {}/{}", + loaded_entries.len(), + discovered_count + )]; + if let Some(model) = runtime_config.model() { + details.push(format!("Resolved model {model}")); + } + details.push(format!( + "MCP servers {}", + runtime_config.mcp().servers().len() + )); + if discovered_paths.is_empty() { + details.push("Discovered files ".to_string()); + } else { + details.extend( + discovered_paths + .into_iter() + .map(|path| format!("Discovered file {path}")), + ); + } + DiagnosticCheck::new( + "Config", + if discovered_count == 0 { + DiagnosticLevel::Warn + } else { + DiagnosticLevel::Ok + }, + if discovered_count == 0 { + "no config files were found; defaults are active" + } else { + "runtime config loaded successfully" + }, + ) + .with_details(details) + } + Err(error) => DiagnosticCheck::new( + "Config", + DiagnosticLevel::Fail, + format!("runtime config failed to load: {error}"), + ) + .with_details(if discovered_paths.is_empty() { + vec!["Discovered files ".to_string()] + } else { + discovered_paths + .into_iter() + .map(|path| format!("Discovered file {path}")) + .collect() + }), + } +} + +fn check_workspace_health(context: &StatusContext) -> DiagnosticCheck { + let in_repo = context.project_root.is_some(); + DiagnosticCheck::new( + "Workspace", + if in_repo { + DiagnosticLevel::Ok + } else { + DiagnosticLevel::Warn + }, + if in_repo { + format!( + "project root detected on branch {}", + context.git_branch.as_deref().unwrap_or("unknown") + ) + } else { + "current directory is not inside a git project".to_string() + }, + ) + .with_details(vec![ + format!("Cwd {}", context.cwd.display()), + format!( + "Project root {}", + context + .project_root + .as_ref() + .map_or_else(|| "".to_string(), |path| path.display().to_string()) + ), + format!( + "Git branch {}", + context.git_branch.as_deref().unwrap_or("unknown") + ), + format!("Git state {}", context.git_summary.headline()), + format!("Changed files {}", context.git_summary.changed_files), + format!( + "Memory files {} · config files loaded {}/{}", + context.memory_file_count, context.loaded_config_files, context.discovered_config_files + ), + ]) +} + +fn check_sandbox_health(status: &runtime::SandboxStatus) -> DiagnosticCheck { + let degraded = status.enabled && !status.active; + let mut details = vec![ + format!("Enabled {}", status.enabled), + format!("Active {}", status.active), + format!("Supported {}", status.supported), + format!("Filesystem mode {}", status.filesystem_mode.as_str()), + format!("Filesystem live {}", status.filesystem_active), + ]; + if let Some(reason) = &status.fallback_reason { + details.push(format!("Fallback reason {reason}")); + } + DiagnosticCheck::new( + "Sandbox", + if degraded { + DiagnosticLevel::Warn + } else { + DiagnosticLevel::Ok + }, + if degraded { + "sandbox was requested but is not currently active" + } else if status.active { + "sandbox protections are active" + } else { + "sandbox is not active for this session" + }, + ) + .with_details(details) +} + +fn check_system_health(cwd: &Path, config: Option<&runtime::RuntimeConfig>) -> DiagnosticCheck { + let mut details = vec![ + format!("OS {} {}", env::consts::OS, env::consts::ARCH), + format!("Working dir {}", cwd.display()), + format!("Version {}", VERSION), + format!("Build target {}", BUILD_TARGET.unwrap_or("")), + format!("Git SHA {}", GIT_SHA.unwrap_or("")), + ]; + if let Some(model) = config.and_then(runtime::RuntimeConfig::model) { + details.push(format!("Default model {model}")); + } + DiagnosticCheck::new( + "System", + DiagnosticLevel::Ok, + "captured local runtime metadata", + ) + .with_details(details) +} + fn resume_command_can_absorb_token(current_command: &str, token: &str) -> bool { matches!( SlashCommand::parse(current_command), @@ -938,7 +1351,7 @@ fn print_version() { println!("{}", render_version_report()); } -fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) { +fn resume_session(session_path: &Path, commands: &[String]) { let resolved_path = if session_path.exists() { session_path.to_path_buf() } else { @@ -960,31 +1373,15 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu }; if commands.is_empty() { - match output_format { - CliOutputFormat::Text => { - println!( - "Restored session from {} ({} messages).", - resolved_path.display(), - session.messages.len() - ); - } - CliOutputFormat::Json => { - println!( - "{}", - serialize_json_output(&json!({ - "kind": "resume", - "session_file": resolved_path.display().to_string(), - "messages": session.messages.len(), - })) - .unwrap_or_else(|error| format!(r#"{{"kind":"error","message":"{error}"}}"#)) - ); - } - } + println!( + "Restored session from {} ({} messages).", + resolved_path.display(), + session.messages.len() + ); return; } let mut session = session; - let mut json_outputs = Vec::new(); for raw_command in commands { let command = match SlashCommand::parse(raw_command) { Ok(Some(command)) => command, @@ -997,19 +1394,14 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu std::process::exit(2); } }; - match run_resume_command(&resolved_path, &session, &command, output_format) { + match run_resume_command(&resolved_path, &session, &command) { Ok(ResumeCommandOutcome { session: next_session, message, }) => { session = next_session; if let Some(message) = message { - match output_format { - CliOutputFormat::Text => { - println!("{}", render_resume_text_output(&message)) - } - CliOutputFormat::Json => json_outputs.push(message), - } + println!("{message}"); } } Err(error) => { @@ -1018,27 +1410,12 @@ fn resume_session(session_path: &Path, commands: &[String], output_format: CliOu } } } - - if matches!(output_format, CliOutputFormat::Json) { - let payload = if json_outputs.len() == 1 { - json_outputs.pop().expect("single json output") - } else { - Value::Array(json_outputs) - }; - match serialize_json_output(&payload) { - Ok(rendered) => println!("{rendered}"), - Err(error) => { - eprintln!("failed to render json output: {error}"); - std::process::exit(2); - } - } - } } #[derive(Debug, Clone)] struct ResumeCommandOutcome { session: Session, - message: Option, + message: Option, } #[derive(Debug, Clone)] @@ -1363,28 +1740,16 @@ fn parse_git_status_metadata_for( (project_root, branch) } -fn serialize_json_output(value: &Value) -> Result { - serde_json::to_string_pretty(value) -} - -fn render_resume_text_output(value: &Value) -> String { - value.get("message").and_then(Value::as_str).map_or_else( - || serialize_json_output(value).unwrap_or_else(|_| value.to_string()), - ToString::to_string, - ) -} - #[allow(clippy::too_many_lines)] fn run_resume_command( session_path: &Path, session: &Session, command: &SlashCommand, - output_format: CliOutputFormat, ) -> Result> { match command { SlashCommand::Help => Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(json!({ "kind": "help", "message": render_repl_help() })), + message: Some(render_repl_help()), }), SlashCommand::Compact => { let result = runtime::compact_session( @@ -1400,20 +1765,16 @@ fn run_resume_command( result.compacted_session.save_to_path(session_path)?; Ok(ResumeCommandOutcome { session: result.compacted_session, - message: Some(json!({ - "kind": "compact", - "message": format_compact_report(removed, kept, skipped), - })), + message: Some(format_compact_report(removed, kept, skipped)), }) } SlashCommand::Clear { confirm } => { if !confirm { return Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(json!({ - "kind": "clear", - "message": "clear: confirmation required; rerun with /clear --confirm", - })), + message: Some( + "clear: confirmation required; rerun with /clear --confirm".to_string(), + ), }); } let backup_path = write_session_clear_backup(session, session_path)?; @@ -1423,85 +1784,55 @@ fn run_resume_command( cleared.save_to_path(session_path)?; Ok(ResumeCommandOutcome { session: cleared, - message: Some(json!({ - "kind": "clear", - "message": format!( - "Session cleared\n Mode resumed session reset\n Previous session {previous_session_id}\n Backup {}\n Resume previous claw --resume {}\n New session {new_session_id}\n Session file {}", - backup_path.display(), - backup_path.display(), - session_path.display() - ), - })), + message: Some(format!( + "Session cleared\n Mode resumed session reset\n Previous session {previous_session_id}\n Backup {}\n Resume previous claw --resume {}\n New session {new_session_id}\n Session file {}", + backup_path.display(), + backup_path.display(), + session_path.display() + )), }) } SlashCommand::Status => { let tracker = UsageTracker::from_session(session); let usage = tracker.cumulative_usage(); - let status_usage = StatusUsage { - message_count: session.messages.len(), - turns: tracker.turns(), - latest: tracker.current_turn_usage(), - cumulative: usage, - estimated_tokens: 0, - }; - let context = status_context(Some(session_path))?; - let status_json = status_report_json( - "restored-session", - status_usage, - default_permission_mode().as_str(), - &context, - ); Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(match output_format { - CliOutputFormat::Text => json!({ - "kind": "status-text", - "message": format_status_report( - "restored-session", - status_usage, - default_permission_mode().as_str(), - &context, - ), - }), - CliOutputFormat::Json => status_json, - }), + message: Some(format_status_report( + "restored-session", + StatusUsage { + message_count: session.messages.len(), + turns: tracker.turns(), + latest: tracker.current_turn_usage(), + cumulative: usage, + estimated_tokens: 0, + }, + default_permission_mode().as_str(), + &status_context(Some(session_path))?, + )), }) } SlashCommand::Sandbox => { let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); let runtime_config = loader.load()?; - let sandbox_status = resolve_sandbox_status(runtime_config.sandbox(), &cwd); Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(match output_format { - CliOutputFormat::Text => json!({ - "kind": "sandbox-text", - "message": format_sandbox_report(&sandbox_status), - }), - CliOutputFormat::Json => json!({ - "kind": "sandbox", - "sandbox": sandbox_status_json(&sandbox_status), - }), - }), + message: Some(format_sandbox_report(&resolve_sandbox_status( + runtime_config.sandbox(), + &cwd, + ))), }) } SlashCommand::Cost => { let usage = UsageTracker::from_session(session).cumulative_usage(); Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(json!({ - "kind": "cost", - "message": format_cost_report(usage), - })), + message: Some(format_cost_report(usage)), }) } SlashCommand::Config { section } => Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(json!({ - "kind": "config", - "message": render_config_report(section.as_deref())?, - })), + message: Some(render_config_report(section.as_deref())?), }), SlashCommand::Mcp { action, target } => { let cwd = env::current_dir()?; @@ -1513,77 +1844,57 @@ fn run_resume_command( }; Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(json!({ - "kind": "mcp", - "message": handle_mcp_slash_command(args.as_deref(), &cwd)?, - })), + message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)?), }) } SlashCommand::Memory => Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(json!({ - "kind": "memory", - "message": render_memory_report()?, - })), + message: Some(render_memory_report()?), }), SlashCommand::Init => Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(json!({ - "kind": "init", - "message": init_claude_md()?, - })), + message: Some(init_claude_md()?), }), SlashCommand::Diff => Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(json!({ - "kind": "diff", - "message": render_diff_report_for( - session_path.parent().unwrap_or_else(|| Path::new(".")), - )?, - })), + message: Some(render_diff_report_for( + session_path.parent().unwrap_or_else(|| Path::new(".")), + )?), }), SlashCommand::Version => Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(json!({ - "kind": "version", - "message": render_version_report(), - })), + message: Some(render_version_report()), }), SlashCommand::Export { path } => { let export_path = resolve_export_path(path.as_deref(), session)?; fs::write(&export_path, render_export_text(session))?; Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(json!({ - "kind": "export", - "message": format!( - "Export\n Result wrote transcript\n File {}\n Messages {}", - export_path.display(), - session.messages.len(), - ), - })), + message: Some(format!( + "Export\n Result wrote transcript\n File {}\n Messages {}", + export_path.display(), + session.messages.len(), + )), }) } SlashCommand::Agents { args } => { let cwd = env::current_dir()?; Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(json!({ - "kind": "agents", - "message": handle_agents_slash_command(args.as_deref(), &cwd)?, - })), + message: Some(handle_agents_slash_command(args.as_deref(), &cwd)?), }) } SlashCommand::Skills { args } => { let cwd = env::current_dir()?; Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(json!({ - "kind": "skills", - "message": handle_skills_slash_command(args.as_deref(), &cwd)?, - })), + message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?), }) } + SlashCommand::Doctor => Ok(ResumeCommandOutcome { + session: session.clone(), + message: Some(render_doctor_report()?.render()), + }), SlashCommand::Unknown(name) => Err(format_unknown_slash_command(name).into()), SlashCommand::Bughunter { .. } | SlashCommand::Commit { .. } @@ -1597,7 +1908,6 @@ fn run_resume_command( | SlashCommand::Permissions { .. } | SlashCommand::Session { .. } | SlashCommand::Plugins { .. } - | SlashCommand::Doctor | SlashCommand::Login | SlashCommand::Logout | SlashCommand::Vim @@ -2504,8 +2814,11 @@ impl LiveCli { Self::print_skills(args.as_deref())?; false } - SlashCommand::Doctor - | SlashCommand::Login + SlashCommand::Doctor => { + println!("{}", render_doctor_report()?.render()); + false + } + SlashCommand::Login | SlashCommand::Logout | SlashCommand::Vim | SlashCommand::Upgrade @@ -3296,31 +3609,22 @@ fn render_repl_help() -> String { fn print_status_snapshot( model: &str, permission_mode: PermissionMode, - output_format: CliOutputFormat, ) -> Result<(), Box> { - let usage = StatusUsage { - message_count: 0, - turns: 0, - latest: TokenUsage::default(), - cumulative: TokenUsage::default(), - estimated_tokens: 0, - }; - let context = status_context(None)?; - match output_format { - CliOutputFormat::Text => println!( - "{}", - format_status_report(model, usage, permission_mode.as_str(), &context) - ), - CliOutputFormat::Json => println!( - "{}", - serialize_json_output(&status_report_json( - model, - usage, - permission_mode.as_str(), - &context, - ))? - ), - } + println!( + "{}", + format_status_report( + model, + StatusUsage { + message_count: 0, + turns: 0, + latest: TokenUsage::default(), + cumulative: TokenUsage::default(), + estimated_tokens: 0, + }, + permission_mode.as_str(), + &status_context(None)?, + ) + ); Ok(()) } @@ -3418,61 +3722,6 @@ fn format_status_report( ) } -fn status_report_json( - model: &str, - usage: StatusUsage, - permission_mode: &str, - context: &StatusContext, -) -> Value { - json!({ - "kind": "status", - "model": model, - "permission_mode": permission_mode, - "messages": usage.message_count, - "turns": usage.turns, - "estimated_tokens": usage.estimated_tokens, - "usage": { - "latest": token_usage_json(usage.latest), - "cumulative": token_usage_json(usage.cumulative), - }, - "workspace": { - "cwd": context.cwd.display().to_string(), - "project_root": context.project_root.as_ref().map(|path| path.display().to_string()), - "git_branch": context.git_branch.clone(), - "git_state": context.git_summary.headline(), - "changed_files": context.git_summary.changed_files, - "staged_files": context.git_summary.staged_files, - "unstaged_files": context.git_summary.unstaged_files, - "untracked_files": context.git_summary.untracked_files, - "session": status_session_label(context.session_path.as_deref()), - "config_files": { - "loaded": context.loaded_config_files, - "discovered": context.discovered_config_files, - }, - "memory_files": context.memory_file_count, - "suggested_flow": ["/status", "/diff", "/commit"], - }, - "sandbox": sandbox_status_json(&context.sandbox_status), - }) -} - -fn token_usage_json(usage: TokenUsage) -> Value { - json!({ - "input_tokens": usage.input_tokens, - "output_tokens": usage.output_tokens, - "cache_creation_input_tokens": usage.cache_creation_input_tokens, - "cache_read_input_tokens": usage.cache_read_input_tokens, - "total_tokens": usage.total_tokens(), - }) -} - -fn status_session_label(session_path: Option<&Path>) -> String { - session_path.map_or_else( - || "live-repl".to_string(), - |path| path.display().to_string(), - ) -} - fn format_sandbox_report(status: &runtime::SandboxStatus) -> String { format!( "Sandbox @@ -3516,31 +3765,6 @@ fn format_sandbox_report(status: &runtime::SandboxStatus) -> String { ) } -fn sandbox_status_json(status: &runtime::SandboxStatus) -> Value { - json!({ - "enabled": status.enabled, - "active": status.active, - "supported": status.supported, - "namespace_supported": status.namespace_supported, - "namespace_active": status.namespace_active, - "network_supported": status.network_supported, - "network_active": status.network_active, - "filesystem_mode": status.filesystem_mode.as_str(), - "filesystem_active": status.filesystem_active, - "allowed_mounts": status.allowed_mounts.clone(), - "in_container": status.in_container, - "container_markers": status.container_markers.clone(), - "fallback_reason": status.fallback_reason.clone(), - "requested": { - "enabled": status.requested.enabled, - "namespace_restrictions": status.requested.namespace_restrictions, - "network_isolation": status.requested.network_isolation, - "filesystem_mode": status.requested.filesystem_mode.as_str(), - "allowed_mounts": status.requested.allowed_mounts.clone(), - } - }) -} - fn format_commit_preflight_report(branch: Option<&str>, summary: GitWorkspaceSummary) -> String { format!( "Commit @@ -3564,28 +3788,46 @@ fn format_commit_skipped_report() -> String { .to_string() } -fn print_sandbox_status_snapshot( - output_format: CliOutputFormat, -) -> Result<(), Box> { +fn print_sandbox_status_snapshot() -> Result<(), Box> { let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); let runtime_config = loader .load() .unwrap_or_else(|_| runtime::RuntimeConfig::empty()); - let sandbox_status = resolve_sandbox_status(runtime_config.sandbox(), &cwd); - match output_format { - CliOutputFormat::Text => println!("{}", format_sandbox_report(&sandbox_status)), - CliOutputFormat::Json => println!( - "{}", - serialize_json_output(&json!({ - "kind": "sandbox", - "sandbox": sandbox_status_json(&sandbox_status), - }))? - ), - } + println!( + "{}", + format_sandbox_report(&resolve_sandbox_status(runtime_config.sandbox(), &cwd)) + ); Ok(()) } +fn render_help_topic(topic: LocalHelpTopic) -> String { + match topic { + LocalHelpTopic::Status => "Status + Usage claw status + Purpose show the local workspace snapshot without entering the REPL + Output model, permissions, git state, config files, and sandbox status + Related /status · claw --resume latest /status" + .to_string(), + LocalHelpTopic::Sandbox => "Sandbox + Usage claw sandbox + Purpose inspect the resolved sandbox and isolation state for the current directory + Output namespace, network, filesystem, and fallback details + Related /sandbox · claw status" + .to_string(), + LocalHelpTopic::Doctor => "Doctor + Usage claw doctor + Purpose diagnose local auth, config, workspace, sandbox, and build metadata + Output local-only health report; no provider request or session resume required + Related /doctor · claw --resume latest /doctor" + .to_string(), + } +} + +fn print_help_topic(topic: LocalHelpTopic) { + println!("{}", render_help_topic(topic)); +} + fn render_config_report(section: Option<&str>) -> Result> { let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); @@ -5764,6 +6006,11 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { )?; writeln!(out, " claw sandbox")?; writeln!(out, " Show the current sandbox isolation snapshot")?; + writeln!(out, " claw doctor")?; + writeln!( + out, + " Diagnose local auth, config, workspace, and sandbox health" + )?; writeln!(out, " claw dump-manifests")?; writeln!(out, " claw bootstrap-plan")?; writeln!(out, " claw agents")?; @@ -5841,6 +6088,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { writeln!(out, " claw agents")?; writeln!(out, " claw mcp show my-server")?; writeln!(out, " claw /skills")?; + writeln!(out, " claw doctor")?; writeln!(out, " claw login")?; writeln!(out, " claw init")?; Ok(()) @@ -5865,11 +6113,11 @@ mod tests { parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary, permission_policy, print_help_to, push_output_block, render_config_report, render_diff_report, render_diff_report_for, render_memory_report, render_repl_help, - render_resume_text_output, render_resume_usage, resolve_model_alias, - resolve_session_reference, response_to_events, resume_supported_slash_commands, - run_resume_command, slash_command_completion_candidates_with_sessions, status_context, - validate_no_args, write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, - GitWorkspaceSummary, InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, + render_resume_usage, resolve_model_alias, resolve_session_reference, response_to_events, + resume_supported_slash_commands, run_resume_command, + slash_command_completion_candidates_with_sessions, status_context, validate_no_args, + write_mcp_server_fixture, CliAction, CliOutputFormat, CliToolExecutor, GitWorkspaceSummary, + InternalPromptProgressEvent, InternalPromptProgressState, LiveCli, LocalHelpTopic, SlashCommand, StatusUsage, DEFAULT_MODEL, }; use api::{MessageResponse, OutputContentBlock, Usage}; @@ -6236,6 +6484,10 @@ mod tests { parse_args(&["logout".to_string()]).expect("logout should parse"), CliAction::Logout ); + assert_eq!( + parse_args(&["doctor".to_string()]).expect("doctor should parse"), + CliAction::Doctor + ); assert_eq!( parse_args(&["init".to_string()]).expect("init should parse"), CliAction::Init @@ -6261,6 +6513,25 @@ mod tests { ); } + #[test] + fn local_command_help_flags_stay_on_the_local_parser_path() { + assert_eq!( + parse_args(&["status".to_string(), "--help".to_string()]) + .expect("status help should parse"), + CliAction::HelpTopic(LocalHelpTopic::Status) + ); + assert_eq!( + parse_args(&["sandbox".to_string(), "-h".to_string()]) + .expect("sandbox help should parse"), + CliAction::HelpTopic(LocalHelpTopic::Sandbox) + ); + assert_eq!( + parse_args(&["doctor".to_string(), "--help".to_string()]) + .expect("doctor help should parse"), + CliAction::HelpTopic(LocalHelpTopic::Doctor) + ); + } + #[test] fn parses_single_word_command_aliases_without_falling_back_to_prompt_mode() { let _guard = env_lock(); @@ -6278,36 +6549,11 @@ mod tests { CliAction::Status { model: DEFAULT_MODEL.to_string(), permission_mode: PermissionMode::DangerFullAccess, - output_format: CliOutputFormat::Text, } ); assert_eq!( parse_args(&["sandbox".to_string()]).expect("sandbox should parse"), - CliAction::Sandbox { - output_format: CliOutputFormat::Text, - } - ); - } - - #[test] - fn parses_json_output_for_status_and_sandbox_aliases() { - let _guard = env_lock(); - std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE"); - assert_eq!( - parse_args(&["--output-format=json".to_string(), "status".to_string()]) - .expect("json status should parse"), - CliAction::Status { - model: DEFAULT_MODEL.to_string(), - permission_mode: PermissionMode::DangerFullAccess, - output_format: CliOutputFormat::Json, - } - ); - assert_eq!( - parse_args(&["--output-format=json".to_string(), "sandbox".to_string()]) - .expect("json sandbox should parse"), - CliAction::Sandbox { - output_format: CliOutputFormat::Json, - } + CliAction::Sandbox ); } @@ -6413,7 +6659,6 @@ mod tests { CliAction::ResumeSession { session_path: PathBuf::from("session.jsonl"), commands: vec!["/compact".to_string()], - output_format: CliOutputFormat::Text, } ); } @@ -6425,7 +6670,6 @@ mod tests { CliAction::ResumeSession { session_path: PathBuf::from("latest"), commands: vec![], - output_format: CliOutputFormat::Text, } ); assert_eq!( @@ -6434,7 +6678,6 @@ mod tests { CliAction::ResumeSession { session_path: PathBuf::from("latest"), commands: vec!["/status".to_string()], - output_format: CliOutputFormat::Text, } ); } @@ -6457,7 +6700,6 @@ mod tests { "/compact".to_string(), "/cost".to_string(), ], - output_format: CliOutputFormat::Text, } ); } @@ -6488,7 +6730,6 @@ mod tests { "/export notes.txt".to_string(), "/clear --confirm".to_string(), ], - output_format: CliOutputFormat::Text, } ); } @@ -6507,25 +6748,6 @@ mod tests { CliAction::ResumeSession { session_path: PathBuf::from("session.jsonl"), commands: vec!["/export /tmp/notes.txt".to_string(), "/status".to_string()], - output_format: CliOutputFormat::Text, - } - ); - } - - #[test] - fn parses_json_output_for_resumed_status_queries() { - let args = vec![ - "--output-format=json".to_string(), - "--resume".to_string(), - "session.jsonl".to_string(), - "/status".to_string(), - ]; - assert_eq!( - parse_args(&args).expect("json resume status should parse"), - CliAction::ResumeSession { - session_path: PathBuf::from("session.jsonl"), - commands: vec!["/status".to_string()], - output_format: CliOutputFormat::Json, } ); } @@ -7046,16 +7268,10 @@ UU conflicted.rs", let session = Session::load_from_path(&session_path).expect("session should load"); let outcome = with_current_dir(&root, || { - run_resume_command( - &session_path, - &session, - &SlashCommand::Diff, - CliOutputFormat::Text, - ) - .expect("resume diff should work") + run_resume_command(&session_path, &session, &SlashCommand::Diff) + .expect("resume diff should work") }); - let message = - render_resume_text_output(&outcome.message.expect("diff message should exist")); + let message = outcome.message.expect("diff message should exist"); assert!(message.contains("Unstaged changes:")); assert!(message.contains("tracked.txt")); diff --git a/rust/crates/rusty-claude-cli/tests/cli_flags_and_config_defaults.rs b/rust/crates/rusty-claude-cli/tests/cli_flags_and_config_defaults.rs index cd06e0b..6402f89 100644 --- a/rust/crates/rusty-claude-cli/tests/cli_flags_and_config_defaults.rs +++ b/rust/crates/rusty-claude-cli/tests/cli_flags_and_config_defaults.rs @@ -161,37 +161,77 @@ fn config_command_loads_defaults_from_standard_config_locations() { } #[test] -fn nested_help_flags_render_usage_instead_of_falling_through() { - let temp_dir = unique_temp_dir("nested-help"); - fs::create_dir_all(&temp_dir).expect("temp dir should exist"); +fn doctor_command_runs_as_a_local_shell_entrypoint() { + // given + let temp_dir = unique_temp_dir("doctor-entrypoint"); + let config_home = temp_dir.join("home").join(".claw"); + fs::create_dir_all(&config_home).expect("config home should exist"); - let mcp_output = command_in(&temp_dir) - .args(["mcp", "show", "--help"]) + // when + let output = command_in(&temp_dir) + .env("CLAW_CONFIG_HOME", &config_home) + .env_remove("ANTHROPIC_API_KEY") + .env_remove("ANTHROPIC_AUTH_TOKEN") + .env("ANTHROPIC_BASE_URL", "http://127.0.0.1:9") + .arg("doctor") .output() - .expect("claw should launch"); - assert_success(&mcp_output); - let mcp_stdout = String::from_utf8(mcp_output.stdout).expect("stdout should be utf8"); - assert!(mcp_stdout.contains("Usage /mcp [list|show |help]")); - assert!(mcp_stdout.contains("Unexpected show")); - assert!(!mcp_stdout.contains("server `--help` is not configured")); + .expect("claw doctor should launch"); - let skills_output = command_in(&temp_dir) - .args(["skills", "install", "--help"]) - .output() - .expect("claw should launch"); - assert_success(&skills_output); - let skills_stdout = String::from_utf8(skills_output.stdout).expect("stdout should be utf8"); - assert!(skills_stdout.contains("Usage /skills [list|install |help]")); - assert!(skills_stdout.contains("Unexpected install")); + // then + assert_success(&output); + let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8"); + assert!(stdout.contains("Doctor")); + assert!(stdout.contains("Auth")); + assert!(stdout.contains("Config")); + assert!(stdout.contains("Workspace")); + assert!(stdout.contains("Sandbox")); + assert!(!stdout.contains("Thinking")); - let unknown_output = command_in(&temp_dir) - .args(["mcp", "inspect", "--help"]) + fs::remove_dir_all(temp_dir).expect("cleanup temp dir"); +} + +#[test] +fn local_subcommand_help_does_not_fall_through_to_runtime_or_provider_calls() { + // given + let temp_dir = unique_temp_dir("subcommand-help"); + let config_home = temp_dir.join("home").join(".claw"); + fs::create_dir_all(&config_home).expect("config home should exist"); + + // when + let doctor_help = command_in(&temp_dir) + .env("CLAW_CONFIG_HOME", &config_home) + .env_remove("ANTHROPIC_API_KEY") + .env_remove("ANTHROPIC_AUTH_TOKEN") + .env("ANTHROPIC_BASE_URL", "http://127.0.0.1:9") + .args(["doctor", "--help"]) .output() - .expect("claw should launch"); - assert_success(&unknown_output); - let unknown_stdout = String::from_utf8(unknown_output.stdout).expect("stdout should be utf8"); - assert!(unknown_stdout.contains("Usage /mcp [list|show |help]")); - assert!(unknown_stdout.contains("Unexpected inspect")); + .expect("doctor help should launch"); + let status_help = command_in(&temp_dir) + .env("CLAW_CONFIG_HOME", &config_home) + .env_remove("ANTHROPIC_API_KEY") + .env_remove("ANTHROPIC_AUTH_TOKEN") + .env("ANTHROPIC_BASE_URL", "http://127.0.0.1:9") + .args(["status", "--help"]) + .output() + .expect("status help should launch"); + + // then + assert_success(&doctor_help); + let doctor_stdout = String::from_utf8(doctor_help.stdout).expect("stdout should be utf8"); + assert!(doctor_stdout.contains("Usage claw doctor")); + assert!(doctor_stdout.contains("local-only health report")); + assert!(!doctor_stdout.contains("Thinking")); + + assert_success(&status_help); + let status_stdout = String::from_utf8(status_help.stdout).expect("stdout should be utf8"); + assert!(status_stdout.contains("Usage claw status")); + assert!(status_stdout.contains("local workspace snapshot")); + assert!(!status_stdout.contains("Thinking")); + + let doctor_stderr = String::from_utf8(doctor_help.stderr).expect("stderr should be utf8"); + let status_stderr = String::from_utf8(status_help.stderr).expect("stderr should be utf8"); + assert!(!doctor_stderr.contains("auth_unavailable")); + assert!(!status_stderr.contains("auth_unavailable")); fs::remove_dir_all(temp_dir).expect("cleanup temp dir"); }