diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index dbf9311..3d19976 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -24,10 +24,9 @@ use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant, UNIX_EPOCH}; use api::{ - oauth_token_is_expired, resolve_startup_auth_source, AnthropicClient, AuthSource, - ContentBlockDelta, InputContentBlock, InputMessage, MessageRequest, MessageResponse, - OutputContentBlock, PromptCache, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, - ToolResultContentBlock, + resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock, + InputMessage, MessageRequest, MessageResponse, OutputContentBlock, PromptCache, + StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock, }; use commands::{ @@ -40,17 +39,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_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, + 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, }; use serde::Deserialize; -use serde_json::json; +use serde_json::{json, Value}; use tools::{GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput}; const DEFAULT_MODEL: &str = "claude-opus-4-6"; @@ -118,12 +117,14 @@ fn run() -> Result<(), Box> { CliAction::ResumeSession { session_path, commands, - } => resume_session(&session_path, &commands), + output_format, + } => resume_session(&session_path, &commands, output_format), CliAction::Status { model, permission_mode, - } => print_status_snapshot(&model, permission_mode)?, - CliAction::Sandbox => print_sandbox_status_snapshot()?, + output_format, + } => print_status_snapshot(&model, permission_mode, output_format)?, + CliAction::Sandbox { output_format } => print_sandbox_status_snapshot(output_format)?, CliAction::Prompt { prompt, model, @@ -134,14 +135,12 @@ 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(()) @@ -168,12 +167,16 @@ 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, @@ -183,25 +186,16 @@ 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, @@ -351,12 +345,11 @@ fn parse_args(args: &[String]) -> Result { }); } if rest.first().map(String::as_str) == Some("--resume") { - return parse_resume_args(&rest[1..]); + return parse_resume_args(&rest[1..], 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) { + if let Some(action) = + parse_single_word_command_alias(&rest, &model, permission_mode_override, output_format) + { return action; } @@ -402,28 +395,11 @@ 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; @@ -435,9 +411,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)), - "doctor" => Some(Ok(CliAction::Doctor)), + "sandbox" => Some(Ok(CliAction::Sandbox { output_format })), other => bare_slash_command_guidance(other).map(Err), } } @@ -731,7 +707,7 @@ fn parse_system_prompt_args(args: &[String]) -> Result { Ok(CliAction::PrintSystemPrompt { cwd, date }) } -fn parse_resume_args(args: &[String]) -> Result { +fn parse_resume_args(args: &[String], output_format: CliOutputFormat) -> 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) => { @@ -771,399 +747,10 @@ fn parse_resume_args(args: &[String]) -> 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), @@ -1351,7 +938,7 @@ fn print_version() { println!("{}", render_version_report()); } -fn resume_session(session_path: &Path, commands: &[String]) { +fn resume_session(session_path: &Path, commands: &[String], output_format: CliOutputFormat) { let resolved_path = if session_path.exists() { session_path.to_path_buf() } else { @@ -1373,15 +960,31 @@ fn resume_session(session_path: &Path, commands: &[String]) { }; if commands.is_empty() { - println!( - "Restored session from {} ({} messages).", - resolved_path.display(), - session.messages.len() - ); + 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}"}}"#)) + ); + } + } 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, @@ -1394,14 +997,19 @@ fn resume_session(session_path: &Path, commands: &[String]) { std::process::exit(2); } }; - match run_resume_command(&resolved_path, &session, &command) { + match run_resume_command(&resolved_path, &session, &command, output_format) { Ok(ResumeCommandOutcome { session: next_session, message, }) => { session = next_session; if let Some(message) = message { - println!("{message}"); + match output_format { + CliOutputFormat::Text => { + println!("{}", render_resume_text_output(&message)) + } + CliOutputFormat::Json => json_outputs.push(message), + } } } Err(error) => { @@ -1410,12 +1018,27 @@ fn resume_session(session_path: &Path, commands: &[String]) { } } } + + 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)] @@ -1740,16 +1363,28 @@ 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(render_repl_help()), + message: Some(json!({ "kind": "help", "message": render_repl_help() })), }), SlashCommand::Compact => { let result = runtime::compact_session( @@ -1765,16 +1400,20 @@ fn run_resume_command( result.compacted_session.save_to_path(session_path)?; Ok(ResumeCommandOutcome { session: result.compacted_session, - message: Some(format_compact_report(removed, kept, skipped)), + message: Some(json!({ + "kind": "compact", + "message": format_compact_report(removed, kept, skipped), + })), }) } SlashCommand::Clear { confirm } => { if !confirm { return Ok(ResumeCommandOutcome { session: session.clone(), - message: Some( - "clear: confirmation required; rerun with /clear --confirm".to_string(), - ), + message: Some(json!({ + "kind": "clear", + "message": "clear: confirmation required; rerun with /clear --confirm", + })), }); } let backup_path = write_session_clear_backup(session, session_path)?; @@ -1784,55 +1423,85 @@ fn run_resume_command( cleared.save_to_path(session_path)?; Ok(ResumeCommandOutcome { session: cleared, - 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() - )), + 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() + ), + })), }) } 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(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))?, - )), + 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, + }), }) } 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(format_sandbox_report(&resolve_sandbox_status( - runtime_config.sandbox(), - &cwd, - ))), + 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), + }), + }), }) } SlashCommand::Cost => { let usage = UsageTracker::from_session(session).cumulative_usage(); Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(format_cost_report(usage)), + message: Some(json!({ + "kind": "cost", + "message": format_cost_report(usage), + })), }) } SlashCommand::Config { section } => Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(render_config_report(section.as_deref())?), + message: Some(json!({ + "kind": "config", + "message": render_config_report(section.as_deref())?, + })), }), SlashCommand::Mcp { action, target } => { let cwd = env::current_dir()?; @@ -1844,57 +1513,77 @@ fn run_resume_command( }; Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(handle_mcp_slash_command(args.as_deref(), &cwd)?), + message: Some(json!({ + "kind": "mcp", + "message": handle_mcp_slash_command(args.as_deref(), &cwd)?, + })), }) } SlashCommand::Memory => Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(render_memory_report()?), + message: Some(json!({ + "kind": "memory", + "message": render_memory_report()?, + })), }), SlashCommand::Init => Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(init_claude_md()?), + message: Some(json!({ + "kind": "init", + "message": init_claude_md()?, + })), }), SlashCommand::Diff => Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(render_diff_report_for( - session_path.parent().unwrap_or_else(|| Path::new(".")), - )?), + message: Some(json!({ + "kind": "diff", + "message": render_diff_report_for( + session_path.parent().unwrap_or_else(|| Path::new(".")), + )?, + })), }), SlashCommand::Version => Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(render_version_report()), + message: Some(json!({ + "kind": "version", + "message": 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(format!( - "Export\n Result wrote transcript\n File {}\n Messages {}", - export_path.display(), - session.messages.len(), - )), + message: Some(json!({ + "kind": "export", + "message": 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(handle_agents_slash_command(args.as_deref(), &cwd)?), + message: Some(json!({ + "kind": "agents", + "message": handle_agents_slash_command(args.as_deref(), &cwd)?, + })), }) } SlashCommand::Skills { args } => { let cwd = env::current_dir()?; Ok(ResumeCommandOutcome { session: session.clone(), - message: Some(handle_skills_slash_command(args.as_deref(), &cwd)?), + message: Some(json!({ + "kind": "skills", + "message": 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 { .. } @@ -1908,6 +1597,7 @@ fn run_resume_command( | SlashCommand::Permissions { .. } | SlashCommand::Session { .. } | SlashCommand::Plugins { .. } + | SlashCommand::Doctor | SlashCommand::Login | SlashCommand::Logout | SlashCommand::Vim @@ -2814,11 +2504,8 @@ impl LiveCli { Self::print_skills(args.as_deref())?; false } - SlashCommand::Doctor => { - println!("{}", render_doctor_report()?.render()); - false - } - SlashCommand::Login + SlashCommand::Doctor + | SlashCommand::Login | SlashCommand::Logout | SlashCommand::Vim | SlashCommand::Upgrade @@ -3609,22 +3296,31 @@ fn render_repl_help() -> String { fn print_status_snapshot( model: &str, permission_mode: PermissionMode, + output_format: CliOutputFormat, ) -> Result<(), Box> { - 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)?, - ) - ); + 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, + ))? + ), + } Ok(()) } @@ -3722,6 +3418,61 @@ 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 @@ -3765,6 +3516,31 @@ 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 @@ -3788,44 +3564,26 @@ fn format_commit_skipped_report() -> String { .to_string() } -fn print_sandbox_status_snapshot() -> Result<(), Box> { +fn print_sandbox_status_snapshot( + output_format: CliOutputFormat, +) -> Result<(), Box> { let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); let runtime_config = loader .load() .unwrap_or_else(|_| runtime::RuntimeConfig::empty()); - 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(), + 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), + }))? + ), } -} - -fn print_help_topic(topic: LocalHelpTopic) { - println!("{}", render_help_topic(topic)); + Ok(()) } fn render_config_report(section: Option<&str>) -> Result> { @@ -6006,11 +5764,6 @@ 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")?; @@ -6088,7 +5841,6 @@ 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(()) @@ -6113,11 +5865,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_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, + 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, SlashCommand, StatusUsage, DEFAULT_MODEL, }; use api::{MessageResponse, OutputContentBlock, Usage}; @@ -6484,10 +6236,6 @@ 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 @@ -6513,25 +6261,6 @@ 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(); @@ -6549,11 +6278,36 @@ 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 + 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, + } ); } @@ -6659,6 +6413,7 @@ mod tests { CliAction::ResumeSession { session_path: PathBuf::from("session.jsonl"), commands: vec!["/compact".to_string()], + output_format: CliOutputFormat::Text, } ); } @@ -6670,6 +6425,7 @@ mod tests { CliAction::ResumeSession { session_path: PathBuf::from("latest"), commands: vec![], + output_format: CliOutputFormat::Text, } ); assert_eq!( @@ -6678,6 +6434,7 @@ mod tests { CliAction::ResumeSession { session_path: PathBuf::from("latest"), commands: vec!["/status".to_string()], + output_format: CliOutputFormat::Text, } ); } @@ -6700,6 +6457,7 @@ mod tests { "/compact".to_string(), "/cost".to_string(), ], + output_format: CliOutputFormat::Text, } ); } @@ -6730,6 +6488,7 @@ mod tests { "/export notes.txt".to_string(), "/clear --confirm".to_string(), ], + output_format: CliOutputFormat::Text, } ); } @@ -6748,6 +6507,25 @@ 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, } ); } @@ -7268,10 +7046,16 @@ 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) - .expect("resume diff should work") + run_resume_command( + &session_path, + &session, + &SlashCommand::Diff, + CliOutputFormat::Text, + ) + .expect("resume diff should work") }); - let message = outcome.message.expect("diff message should exist"); + let message = + render_resume_text_output(&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 6402f89..8de4b85 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 @@ -5,6 +5,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use runtime::Session; +use serde_json::Value; static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -37,6 +38,64 @@ fn status_command_applies_model_and_permission_mode_flags() { fs::remove_dir_all(temp_dir).expect("cleanup temp dir"); } +#[test] +fn status_command_emits_structured_json_when_requested() { + // given + let temp_dir = unique_temp_dir("status-json"); + fs::create_dir_all(&temp_dir).expect("temp dir should exist"); + + // when + let output = Command::new(env!("CARGO_BIN_EXE_claw")) + .current_dir(&temp_dir) + .args([ + "--model", + "sonnet", + "--permission-mode", + "read-only", + "--output-format", + "json", + "status", + ]) + .output() + .expect("claw should launch"); + + // then + assert_success(&output); + let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8"); + let parsed: Value = serde_json::from_str(stdout.trim()).expect("status output should be json"); + assert_eq!(parsed["kind"], "status"); + assert_eq!(parsed["model"], "claude-sonnet-4-6"); + assert_eq!(parsed["permission_mode"], "read-only"); + assert_eq!(parsed["workspace"]["session"], "live-repl"); + assert!(parsed["sandbox"].is_object()); + + fs::remove_dir_all(temp_dir).expect("cleanup temp dir"); +} + +#[test] +fn sandbox_command_emits_structured_json_when_requested() { + // given + let temp_dir = unique_temp_dir("sandbox-json"); + fs::create_dir_all(&temp_dir).expect("temp dir should exist"); + + // when + let output = Command::new(env!("CARGO_BIN_EXE_claw")) + .current_dir(&temp_dir) + .args(["--output-format", "json", "sandbox"]) + .output() + .expect("claw should launch"); + + // then + assert_success(&output); + let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8"); + let parsed: Value = serde_json::from_str(stdout.trim()).expect("sandbox output should be json"); + assert_eq!(parsed["kind"], "sandbox"); + assert!(parsed["sandbox"].is_object()); + assert!(parsed["sandbox"]["requested"].is_object()); + + fs::remove_dir_all(temp_dir).expect("cleanup temp dir"); +} + #[test] fn resume_flag_loads_a_saved_session_and_dispatches_status() { // given diff --git a/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs b/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs index ccef95f..1d060c2 100644 --- a/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs +++ b/rust/crates/rusty-claude-cli/tests/resume_slash_commands.rs @@ -7,6 +7,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use runtime::ContentBlock; use runtime::Session; +use serde_json::Value; static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0); @@ -221,6 +222,52 @@ fn resume_latest_restores_the_most_recent_managed_session() { assert!(stdout.contains(newer_path.to_str().expect("utf8 path"))); } +#[test] +fn resumed_status_command_emits_structured_json_when_requested() { + // given + let temp_dir = unique_temp_dir("resume-status-json"); + fs::create_dir_all(&temp_dir).expect("temp dir should exist"); + let session_path = temp_dir.join("session.jsonl"); + + let mut session = Session::new(); + session + .push_user_text("resume status json fixture") + .expect("session write should succeed"); + session + .save_to_path(&session_path) + .expect("session should persist"); + + // when + let output = run_claw( + &temp_dir, + &[ + "--output-format", + "json", + "--resume", + session_path.to_str().expect("utf8 path"), + "/status", + ], + ); + + // then + assert!( + output.status.success(), + "stdout:\n{}\n\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8(output.stdout).expect("stdout should be utf8"); + let parsed: Value = + serde_json::from_str(stdout.trim()).expect("resume status output should be json"); + assert_eq!(parsed["kind"], "status"); + assert_eq!(parsed["messages"], 1); + assert_eq!( + parsed["workspace"]["session"], + session_path.to_str().expect("utf8 path") + ); +} + fn run_claw(current_dir: &Path, args: &[&str]) -> Output { run_claw_with_env(current_dir, args, &[]) }