diff --git a/Cargo.lock b/Cargo.lock index 4a83111fe..1962c33ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -180,6 +180,7 @@ dependencies = [ "dotenvy", "httpmock", "predicates", + "reqwest 0.12.28", "serde_json", "tempfile", "tokio", @@ -234,6 +235,7 @@ dependencies = [ "base64", "bytes", "clap", + "dialoguer", "dotenvy", "futures", "http", @@ -281,6 +283,7 @@ name = "arc-util" version = "0.1.0" dependencies = [ "aho-corasick", + "console 0.15.11", "regex", "serde", "serde_json", @@ -728,6 +731,19 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + [[package]] name = "console" version = "0.16.2" @@ -979,7 +995,7 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" dependencies = [ - "console", + "console 0.16.2", "shell-words", "tempfile", "zeroize", @@ -5060,6 +5076,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index aa8c6a5f0..f8581c99e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ jsonschema = "0.42" chrono = { version = "0.4", features = ["clock"] } bollard = "0.18" tar = "0.4" +console = "0.15" dialoguer = "0.12" git2 = "0.19" tracing = "0.1" diff --git a/crates/arc-agent/src/cli.rs b/crates/arc-agent/src/cli.rs index 05ab41fc6..e0ad6237d 100644 --- a/crates/arc-agent/src/cli.rs +++ b/crates/arc-agent/src/cli.rs @@ -126,8 +126,8 @@ fn build_tool_approval( // Interactive prompt on stderr let category = tool_category(tool_name); eprint!( - "Allow {}{tool_name}{} ({category})? [y]es / [n]o / [a]lways: ", - styles.bold, styles.reset, + "Allow {} ({category})? [y]es / [n]o / [a]lways: ", + styles.bold.apply_to(tool_name), ); std::io::stderr().flush().ok(); @@ -245,8 +245,10 @@ fn print_summary(session: &Session, styles: &Styles) { format!("{total_tokens} tokens") }; eprintln!( - "{}Done ({turn_count} turns, {tool_call_count} tool calls, {token_str}){}", - styles.dim, styles.reset, + "{}", + styles.dim.apply_to(format!( + "Done ({turn_count} turns, {tool_call_count} tool calls, {token_str})" + )), ); } @@ -264,23 +266,25 @@ impl arc_llm::middleware::Middleware for DebugMiddleware { ) -> Result { let s = self.styles; eprintln!( - "{}[debug] request: model={} messages={} tools={}{}", - s.dim, - request.model, - request.messages.len(), - request.tools.as_ref().map_or(0, Vec::len), - s.reset, + "{}", + s.dim.apply_to(format!( + "[debug] request: model={} messages={} tools={}", + request.model, + request.messages.len(), + request.tools.as_ref().map_or(0, Vec::len), + )), ); let response = next(request).await?; eprintln!( - "{}[debug] response: model={} finish={:?} usage=({}/{}/{}){}", - s.dim, - response.model, - response.finish_reason, - response.usage.input_tokens, - response.usage.output_tokens, - response.usage.total_tokens, - s.reset, + "{}", + s.dim.apply_to(format!( + "[debug] response: model={} finish={:?} usage=({}/{}/{})", + response.model, + response.finish_reason, + response.usage.input_tokens, + response.usage.output_tokens, + response.usage.total_tokens, + )), ); Ok(response) } @@ -308,17 +312,15 @@ impl arc_llm::middleware::Middleware for VerboseMiddleware { ) -> Result { let s = self.styles; eprintln!( - "{}[verbose] request:{}\n{}", - s.dim, - s.reset, + "{}\n{}", + s.dim.apply_to("[verbose] request:"), serde_json::to_string_pretty(&request) .unwrap_or_else(|e| format!("")) ); let response = next(request).await?; eprintln!( - "{}[verbose] response:{}\n{}", - s.dim, - s.reset, + "{}\n{}", + s.dim.apply_to("[verbose] response:"), serde_json::to_string_pretty(&response) .unwrap_or_else(|e| format!("")) ); @@ -365,7 +367,7 @@ pub async fn run_with_args(args: AgentArgs) -> anyhow::Result<()> { .model .as_deref() .unwrap_or_else(|| default_model(provider)); - eprintln!("{}Using model: {model}{}", styles.dim, styles.reset,); + eprintln!("{}", styles.dim.apply_to(format!("Using model: {model}"))); let mut profile = build_profile(provider, model, Some(client.clone())); // Build sandbox @@ -465,12 +467,13 @@ pub async fn run_with_args(args: AgentArgs) -> anyhow::Result<()> { .. } => { eprintln!( - " {dim}\u{25cf}{reset} {bold}{cyan}{tool_name}{reset}{dim}({args}){reset}", - dim = s.dim, - reset = s.reset, - bold = s.bold, - cyan = s.cyan, - args = format_tool_args(arguments, &cwd_str), + " {} {}{}", + s.dim.apply_to("\u{25cf}"), + s.bold_cyan.apply_to(tool_name), + s.dim.apply_to(format!( + "({})", + format_tool_args(arguments, &cwd_str) + )), ); } AgentEvent::ToolCallCompleted { @@ -485,18 +488,16 @@ pub async fn run_with_args(args: AgentArgs) -> anyhow::Result<()> { "tool result" }; eprintln!( - " {}[{label}] {tool_name}:{}\n{}", - s.dim, - s.reset, + " {}\n{}", + s.dim.apply_to(format!("[{label}] {tool_name}:")), serde_json::to_string_pretty(output) .unwrap_or_else(|_| output.to_string()), ); } AgentEvent::Error { error } => { eprintln!( - " {red}\u{2717} {error}{reset}", - red = s.red, - reset = s.reset, + " {}", + s.red.apply_to(format!("\u{2717} {error}")), ); } AgentEvent::SubAgentSpawned { @@ -508,8 +509,10 @@ pub async fn run_with_args(args: AgentArgs) -> anyhow::Result<()> { let short_id = &agent_id[..8.min(agent_id.len())]; let task_preview = if task.len() > 60 { &task[..60] } else { task }; eprintln!( - " {dim}\u{25b6} subagent {short_id} spawned (depth={depth}) task={task_preview:?}{reset}", - dim = s.dim, reset = s.reset, + " {}", + s.dim.apply_to(format!( + "\u{25b6} subagent {short_id} spawned (depth={depth}) task={task_preview:?}" + )), ); } AgentEvent::SubAgentCompleted { @@ -520,8 +523,10 @@ pub async fn run_with_args(args: AgentArgs) -> anyhow::Result<()> { } => { let short_id = &agent_id[..8.min(agent_id.len())]; eprintln!( - " {dim}\u{25a0} subagent {short_id} completed (depth={depth}, success={success}, turns={turns_used}){reset}", - dim = s.dim, reset = s.reset, + " {}", + s.dim.apply_to(format!( + "\u{25a0} subagent {short_id} completed (depth={depth}, success={success}, turns={turns_used})" + )), ); } AgentEvent::SubAgentFailed { @@ -531,16 +536,19 @@ pub async fn run_with_args(args: AgentArgs) -> anyhow::Result<()> { } => { let short_id = &agent_id[..8.min(agent_id.len())]; eprintln!( - " {red}\u{2717} subagent {short_id} failed (depth={depth}): {error}{reset}", - red = s.red, reset = s.reset, + " {}", + s.red.apply_to(format!( + "\u{2717} subagent {short_id} failed (depth={depth}): {error}" + )), ); } AgentEvent::SubAgentClosed { agent_id, depth } => { let short_id = &agent_id[..8.min(agent_id.len())]; eprintln!( - " {dim}\u{25a0} subagent {short_id} closed (depth={depth}){reset}", - dim = s.dim, - reset = s.reset, + " {}", + s.dim.apply_to(format!( + "\u{25a0} subagent {short_id} closed (depth={depth})" + )), ); } AgentEvent::SubAgentEvent { @@ -550,9 +558,10 @@ pub async fn run_with_args(args: AgentArgs) -> anyhow::Result<()> { } if verbose => { let short_id = &agent_id[..8.min(agent_id.len())]; eprintln!( - " {dim}[subagent {short_id}] {child_event:?}{reset}", - dim = s.dim, - reset = s.reset, + " {}", + s.dim.apply_to(format!( + "[subagent {short_id}] {child_event:?}" + )), ); } _ => {} @@ -591,7 +600,7 @@ mod tests { use arc_llm::provider::Provider; use serde_json::json; - static NO_COLOR: Styles = Styles::new(false); + static NO_COLOR: std::sync::LazyLock = std::sync::LazyLock::new(|| Styles::new(false)); // tool_category tests diff --git a/crates/arc-api/src/serve.rs b/crates/arc-api/src/serve.rs index 8c0b3835d..ae1582dfb 100644 --- a/crates/arc-api/src/serve.rs +++ b/crates/arc-api/src/serve.rs @@ -57,17 +57,16 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: match arc_llm::client::Client::from_env().await { Ok(c) if c.provider_names().is_empty() => { eprintln!( - "{yellow}Warning:{reset} No LLM providers configured. Running in dry-run mode.", - yellow = styles.yellow, - reset = styles.reset, + "{} No LLM providers configured. Running in dry-run mode.", + styles.yellow.apply_to("Warning:"), ); true } Ok(_) => false, Err(e) => { eprintln!( - "{yellow}Warning:{reset} Failed to initialize LLM client: {e}. Running in dry-run mode.", - yellow = styles.yellow, reset = styles.reset, + "{} Failed to initialize LLM client: {e}. Running in dry-run mode.", + styles.yellow.apply_to("Warning:"), ); true } @@ -135,17 +134,14 @@ pub async fn serve_command(args: ServeArgs, styles: &'static Styles) -> anyhow:: info!(host = %args.host, port = args.port, dry_run = dry_run_mode, "API server started"); eprintln!( - "{bold}Arc server listening on {green}{addr}{reset}", - bold = styles.bold, - green = styles.green, - reset = styles.reset, + "{}", + styles.bold.apply_to(format!( + "Arc server listening on {}", + styles.green.apply_to(&addr) + )), ); if dry_run_mode { - eprintln!( - "{dim}(dry-run mode){reset}", - dim = styles.dim, - reset = styles.reset, - ); + eprintln!("{}", styles.dim.apply_to("(dry-run mode)")); } axum::serve(listener, router).await?; diff --git a/crates/arc-cli/Cargo.toml b/crates/arc-cli/Cargo.toml index e07d3b29c..2ec2ecd69 100644 --- a/crates/arc-cli/Cargo.toml +++ b/crates/arc-cli/Cargo.toml @@ -26,6 +26,7 @@ tracing-subscriber.workspace = true tracing-appender.workspace = true chrono.workspace = true dirs.workspace = true +reqwest.workspace = true [dev-dependencies] assert_cmd = "2" diff --git a/crates/arc-cli/src/doctor.rs b/crates/arc-cli/src/doctor.rs index aeaaaff7a..2c301b5da 100644 --- a/crates/arc-cli/src/doctor.rs +++ b/crates/arc-cli/src/doctor.rs @@ -32,6 +32,7 @@ pub struct CheckResult { pub struct DoctorReport { pub checks: Vec, + pub live: bool, } impl DoctorReport { @@ -51,24 +52,22 @@ impl DoctorReport { pub fn render(&self, s: &Styles, verbose: bool) -> String { let mut out = String::new(); - writeln!(out, "{b}Arc Doctor{r}", b = s.bold, r = s.reset).unwrap(); + writeln!(out, "{}", s.bold.apply_to("Arc Doctor")).unwrap(); writeln!(out).unwrap(); for check in &self.checks { let (icon, color) = match check.status { - CheckStatus::Pass => ("[✓]", s.green), - CheckStatus::Warning => ("[!]", s.yellow), - CheckStatus::Error => ("[✗]", s.red), + CheckStatus::Pass => ("[✓]", &s.green), + CheckStatus::Warning => ("[!]", &s.yellow), + CheckStatus::Error => ("[✗]", &s.red), }; writeln!( out, - " {color}{icon}{r} {b}{name}{r} ({summary})", - color = color, - r = s.reset, - b = s.bold, - name = check.name, - summary = check.summary, + " {} {} ({})", + color.apply_to(icon), + s.bold.apply_to(&check.name), + check.summary, ) .unwrap(); @@ -99,7 +98,7 @@ impl DoctorReport { .collect(); if !errors.is_empty() { writeln!(out).unwrap(); - writeln!(out, "{b}Errors:{r}", b = s.bold, r = s.reset).unwrap(); + writeln!(out, "{}", s.bold.apply_to("Errors:")).unwrap(); for check in &errors { write!(out, " • {}", check.name).unwrap(); if let Some(ref rem) = check.remediation { @@ -116,7 +115,7 @@ impl DoctorReport { .collect(); if !warnings.is_empty() { writeln!(out).unwrap(); - writeln!(out, "{b}Warnings:{r}", b = s.bold, r = s.reset).unwrap(); + writeln!(out, "{}", s.bold.apply_to("Warnings:")).unwrap(); for check in &warnings { write!(out, " • {}", check.name).unwrap(); if let Some(ref rem) = check.remediation { @@ -127,6 +126,11 @@ impl DoctorReport { } } + if !self.live { + writeln!(out).unwrap(); + writeln!(out, "Run with --live to probe service connectivity.").unwrap(); + } + out } } @@ -158,12 +162,15 @@ pub fn check_config(path: Option) -> CheckResult { } } -pub fn check_llm_providers(statuses: &[(Provider, bool)]) -> CheckResult { +pub fn check_llm_providers( + statuses: &[(Provider, bool)], + live_results: Option<&[(Provider, Result<(), String>)]>, +) -> CheckResult { let configured: Vec<_> = statuses.iter().filter(|(_, set)| *set).collect(); let total = statuses.len(); let count = configured.len(); - let details: Vec = statuses + let mut details: Vec = statuses .iter() .map(|(provider, set)| { let env_vars = provider.api_key_env_vars().join(" or "); @@ -174,6 +181,23 @@ pub fn check_llm_providers(statuses: &[(Provider, bool)]) -> CheckResult { }) .collect(); + let mut has_live_error = false; + if let Some(results) = live_results { + for (provider, result) in results { + match result { + Ok(()) => details.push(CheckDetail { + text: format!("{provider} connectivity: OK"), + }), + Err(e) => { + has_live_error = true; + details.push(CheckDetail { + text: format!("{provider} connectivity: {e}"), + }); + } + } + } + } + if count == 0 { CheckResult { name: "LLM providers".to_string(), @@ -182,6 +206,14 @@ pub fn check_llm_providers(statuses: &[(Provider, bool)]) -> CheckResult { details, remediation: Some("Set at least one provider API key".to_string()), } + } else if has_live_error { + CheckResult { + name: "LLM providers".to_string(), + status: CheckStatus::Warning, + summary: format!("{count} of {total} configured (connectivity issues)"), + details, + remediation: Some("Check provider API keys and network connectivity".to_string()), + } } else { CheckResult { name: "LLM providers".to_string(), @@ -193,41 +225,76 @@ pub fn check_llm_providers(statuses: &[(Provider, bool)]) -> CheckResult { } } -pub fn check_brave_search(api_key_set: bool) -> CheckResult { - if api_key_set { - CheckResult { - name: "Brave Search".to_string(), - status: CheckStatus::Pass, - summary: "API key set".to_string(), - details: vec![CheckDetail { - text: "BRAVE_SEARCH_API_KEY is set".to_string(), - }], - remediation: None, - } +pub fn check_brave_search( + api_key_set: bool, + live_result: Option<&Result<(), String>>, +) -> CheckResult { + let mut details = vec![CheckDetail { + text: format!( + "BRAVE_SEARCH_API_KEY is {}", + if api_key_set { "set" } else { "not set" } + ), + }]; + + let mut status = if api_key_set { + CheckStatus::Pass } else { - CheckResult { - name: "Brave Search".to_string(), - status: CheckStatus::Warning, - summary: "not configured".to_string(), - details: vec![CheckDetail { - text: "BRAVE_SEARCH_API_KEY is not set".to_string(), - }], - remediation: Some("Set BRAVE_SEARCH_API_KEY to enable web search".to_string()), + CheckStatus::Warning + }; + let mut remediation: Option = if api_key_set { + None + } else { + Some("Set BRAVE_SEARCH_API_KEY to enable web search".to_string()) + }; + + if let Some(result) = live_result { + match result { + Ok(()) => details.push(CheckDetail { + text: "Connectivity: OK".to_string(), + }), + Err(e) => { + status = CheckStatus::Warning; + details.push(CheckDetail { + text: format!("Connectivity: {e}"), + }); + remediation = Some("Check BRAVE_SEARCH_API_KEY and network connectivity".to_string()); + } } } + + let summary = match (api_key_set, live_result) { + (true, Some(Ok(()))) => "API key set, connected".to_string(), + (true, Some(Err(_))) => "API key set, connectivity error".to_string(), + (true, None) => "API key set".to_string(), + (false, _) => "not configured".to_string(), + }; + + CheckResult { + name: "Brave Search".to_string(), + status, + summary, + details, + remediation, + } } pub struct SandboxStatus { - pub daytona: Option>, - pub docker: Option>, + pub daytona_configured: bool, + pub daytona_probe: Option>, + pub docker_probe: Option>, } pub fn check_sandbox(status: &SandboxStatus) -> CheckResult { + let mut configured = Vec::new(); let mut available = Vec::new(); let mut details = Vec::new(); let mut errors = Vec::new(); - match &status.daytona { + if status.daytona_configured { + configured.push("Daytona"); + } + + match &status.daytona_probe { Some(Ok(())) => { available.push("Daytona"); details.push(CheckDetail { @@ -240,6 +307,11 @@ pub fn check_sandbox(status: &SandboxStatus) -> CheckResult { text: format!("Daytona (DAYTONA_API_KEY): error — {e}"), }); } + None if status.daytona_configured => { + details.push(CheckDetail { + text: "Daytona (DAYTONA_API_KEY): configured".to_string(), + }); + } None => { details.push(CheckDetail { text: "Daytona (DAYTONA_API_KEY): not configured".to_string(), @@ -247,7 +319,7 @@ pub fn check_sandbox(status: &SandboxStatus) -> CheckResult { } } - match &status.docker { + match &status.docker_probe { Some(Ok(())) => { available.push("Docker"); details.push(CheckDetail { @@ -262,7 +334,7 @@ pub fn check_sandbox(status: &SandboxStatus) -> CheckResult { } None => { details.push(CheckDetail { - text: "Docker: not available".to_string(), + text: "Docker: not probed".to_string(), }); } } @@ -275,21 +347,26 @@ pub fn check_sandbox(status: &SandboxStatus) -> CheckResult { details, remediation: Some("Fix sandbox configuration errors".to_string()), } - } else if available.is_empty() { + } else if configured.is_empty() && available.is_empty() { CheckResult { name: "Sandbox".to_string(), status: CheckStatus::Warning, - summary: "no sandbox available".to_string(), + summary: "no sandbox configured".to_string(), details, remediation: Some( "Install Docker or set DAYTONA_API_KEY to enable sandboxed execution".to_string(), ), } } else { + let summary = if available.is_empty() { + format!("{} configured", configured.join(" + ")) + } else { + format!("{} available", available.join(" + ")) + }; CheckResult { name: "Sandbox".to_string(), status: CheckStatus::Pass, - summary: format!("{} available", available.join(" + ")), + summary, details, remediation: None, } @@ -378,20 +455,44 @@ pub struct ApiStatus { pub authentication_strategy: String, } -pub fn check_api(status: &ApiStatus) -> CheckResult { +pub fn check_api( + status: &ApiStatus, + live_result: Option<&Result<(), String>>, +) -> CheckResult { + let mut details = vec![ + CheckDetail { + text: format!("Base URL: {}", status.base_url), + }, + CheckDetail { + text: format!("Authentication: {}", status.authentication_strategy), + }, + ]; + + let mut check_status = CheckStatus::Pass; + let mut remediation = None; + + if let Some(result) = live_result { + match result { + Ok(()) => details.push(CheckDetail { + text: "Connectivity: OK".to_string(), + }), + Err(e) => { + check_status = CheckStatus::Warning; + details.push(CheckDetail { + text: format!("Connectivity: {e}"), + }); + remediation = + Some("Check that the API server is running and reachable".to_string()); + } + } + } + CheckResult { name: "Arc API".to_string(), - status: CheckStatus::Pass, + status: check_status, summary: status.base_url.clone(), - details: vec![ - CheckDetail { - text: format!("Base URL: {}", status.base_url), - }, - CheckDetail { - text: format!("Authentication: {}", status.authentication_strategy), - }, - ], - remediation: None, + details, + remediation, } } @@ -401,23 +502,47 @@ pub struct WebStatus { pub allowed_usernames_count: usize, } -pub fn check_web(status: &WebStatus) -> CheckResult { +pub fn check_web( + status: &WebStatus, + live_result: Option<&Result<(), String>>, +) -> CheckResult { + let mut details = vec![ + CheckDetail { + text: format!("URL: {}", status.url), + }, + CheckDetail { + text: format!("Auth provider: {}", status.auth_provider), + }, + CheckDetail { + text: format!("Allowed usernames: {}", status.allowed_usernames_count), + }, + ]; + + let mut check_status = CheckStatus::Pass; + let mut remediation = None; + + if let Some(result) = live_result { + match result { + Ok(()) => details.push(CheckDetail { + text: "Connectivity: OK".to_string(), + }), + Err(e) => { + check_status = CheckStatus::Warning; + details.push(CheckDetail { + text: format!("Connectivity: {e}"), + }); + remediation = + Some("Check that the web app is running and reachable".to_string()); + } + } + } + CheckResult { name: "Arc Web".to_string(), - status: CheckStatus::Pass, + status: check_status, summary: status.url.clone(), - details: vec![ - CheckDetail { - text: format!("URL: {}", status.url), - }, - CheckDetail { - text: format!("Auth provider: {}", status.auth_provider), - }, - CheckDetail { - text: format!("Allowed usernames: {}", status.allowed_usernames_count), - }, - ], - remediation: None, + details, + remediation, } } @@ -444,7 +569,80 @@ async fn probe_docker() -> Option> { Some(docker.ping().await.map(|_| ()).map_err(|e| e.to_string())) } -pub async fn run_doctor(verbose: bool) -> i32 { +fn cheapest_model(provider: Provider) -> String { + let models = arc_llm::catalog::list_models(Some(provider.as_str())); + models + .iter() + .min_by(|a, b| { + let cost_a = a.input_cost_per_million.unwrap_or(f64::MAX); + let cost_b = b.input_cost_per_million.unwrap_or(f64::MAX); + cost_a.total_cmp(&cost_b) + }) + .map(|m| m.id.clone()) + .unwrap_or_else(|| format!("unknown-{}", provider.as_str())) +} + +async fn probe_llm_provider( + client: &arc_llm::client::Client, + provider: Provider, +) -> (Provider, Result<(), String>) { + let request = arc_llm::types::Request { + model: cheapest_model(provider), + messages: vec![arc_llm::types::Message::user("hi")], + provider: Some(provider.as_str().to_string()), + tools: None, + tool_choice: None, + response_format: None, + temperature: None, + top_p: None, + max_tokens: Some(16), + stop_sequences: None, + reasoning_effort: None, + metadata: None, + provider_options: None, + }; + let result = client.complete(&request).await.map(|_| ()).map_err(|e| e.to_string()); + (provider, result) +} + +async fn probe_brave_search() -> Result<(), String> { + let api_key = std::env::var("BRAVE_SEARCH_API_KEY") + .map_err(|_| "BRAVE_SEARCH_API_KEY not set".to_string())?; + let client = reqwest::Client::new(); + let resp = client + .get("https://api.search.brave.com/res/v1/web/search?q=test&count=1") + .header("X-Subscription-Token", api_key) + .send() + .await + .map_err(|e| e.to_string())?; + if resp.status().is_success() { + Ok(()) + } else { + Err(format!("HTTP {}", resp.status())) + } +} + +async fn probe_api(base_url: &str) -> Result<(), String> { + let client = reqwest::Client::new(); + client + .get(format!("{base_url}/runs")) + .send() + .await + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +async fn probe_web(url: &str) -> Result<(), String> { + let client = reqwest::Client::new(); + client + .get(url) + .send() + .await + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +pub async fn run_doctor(verbose: bool, live: bool) -> i32 { let styles = Styles::detect_stdout(); // Gather state @@ -488,21 +686,77 @@ pub async fn run_doctor(verbose: bool) -> i32 { private_key: std::env::var("GITHUB_APP_PRIVATE_KEY").is_ok(), }; - // Probe sandboxes concurrently - let (daytona_result, docker_result) = tokio::join!(probe_daytona(), probe_docker()); - let sandbox_status = SandboxStatus { - daytona: daytona_result, - docker: docker_result, - }; + // Live probes (only when --live is set) + let sandbox_status; + let llm_live_results: Option)>>; + let brave_live_result: Option>; + let api_live_result: Option>; + let web_live_result: Option>; + + if live { + // Build LLM client — may fail if no keys are set + let llm_client = arc_llm::client::Client::from_env().await.ok(); + + let configured_providers: Vec = llm_statuses + .iter() + .filter(|(_, set)| *set) + .map(|(p, _)| *p) + .collect(); + + let llm_fut = async { + if let Some(client) = &llm_client { + let mut results = Vec::new(); + for provider in &configured_providers { + results.push(probe_llm_provider(client, *provider).await); + } + Some(results) + } else { + None + } + }; + + let daytona_configured = std::env::var("DAYTONA_API_KEY").is_ok(); + let sandbox_fut = async { + let (daytona_probe, docker_probe) = tokio::join!(probe_daytona(), probe_docker()); + SandboxStatus { + daytona_configured, + daytona_probe, + docker_probe, + } + }; + let brave_fut = probe_brave_search(); + let api_fut = probe_api(&server_config.api.base_url); + let web_fut = probe_web(&server_config.web.url); + + let (sandbox, llm, brave, api, web) = + tokio::join!(sandbox_fut, llm_fut, brave_fut, api_fut, web_fut); + + sandbox_status = sandbox; + llm_live_results = llm; + brave_live_result = Some(brave); + api_live_result = Some(api); + web_live_result = Some(web); + } else { + sandbox_status = SandboxStatus { + daytona_configured: std::env::var("DAYTONA_API_KEY").is_ok(), + daytona_probe: None, + docker_probe: None, + }; + llm_live_results = None; + brave_live_result = None; + api_live_result = None; + web_live_result = None; + } // Run pure checks let report = DoctorReport { + live, checks: vec![ check_config(if config_exists { config_path } else { None }), - check_api(&api_status), - check_web(&web_status), - check_llm_providers(&llm_statuses), - check_brave_search(brave_key_set), + check_api(&api_status, api_live_result.as_ref()), + check_web(&web_status, web_live_result.as_ref()), + check_llm_providers(&llm_statuses, llm_live_results.as_deref()), + check_brave_search(brave_key_set, brave_live_result.as_ref()), check_sandbox(&sandbox_status), check_github_app(&github_status), ], @@ -562,6 +816,7 @@ mod tests { #[test] fn render_all_pass_no_color() { let report = DoctorReport { + live: false, checks: vec![pass_check("Test")], }; let out = report.render(&Styles::new(false), false); @@ -575,6 +830,7 @@ mod tests { #[test] fn render_warning_footer() { let report = DoctorReport { + live: false, checks: vec![warning_check("Optional")], }; let out = report.render(&Styles::new(false), false); @@ -589,6 +845,7 @@ mod tests { #[test] fn render_error_footer() { let report = DoctorReport { + live: false, checks: vec![error_check("Broken")], }; let out = report.render(&Styles::new(false), false); @@ -602,6 +859,7 @@ mod tests { #[test] fn render_verbose_shows_details() { let report = DoctorReport { + live: false, checks: vec![pass_check("Verbose")], }; let out = report.render(&Styles::new(false), true); @@ -612,6 +870,7 @@ mod tests { #[test] fn render_default_hides_details() { let report = DoctorReport { + live: false, checks: vec![pass_check("Verbose")], }; let out = report.render(&Styles::new(false), false); @@ -623,6 +882,7 @@ mod tests { #[test] fn render_color_pass_green() { let report = DoctorReport { + live: false, checks: vec![pass_check("Color")], }; let out = report.render(&Styles::new(true), false); @@ -632,6 +892,7 @@ mod tests { #[test] fn render_color_warning_yellow() { let report = DoctorReport { + live: false, checks: vec![warning_check("Color")], }; let out = report.render(&Styles::new(true), false); @@ -641,6 +902,7 @@ mod tests { #[test] fn render_color_error_red() { let report = DoctorReport { + live: false, checks: vec![error_check("Color")], }; let out = report.render(&Styles::new(true), false); @@ -652,6 +914,7 @@ mod tests { #[test] fn has_errors_false_for_warnings_only() { let report = DoctorReport { + live: false, checks: vec![pass_check("OK"), warning_check("Warn")], }; assert!(!report.has_errors()); @@ -660,6 +923,7 @@ mod tests { #[test] fn has_errors_true_when_error_present() { let report = DoctorReport { + live: false, checks: vec![pass_check("OK"), error_check("Broken")], }; assert!(report.has_errors()); @@ -668,6 +932,7 @@ mod tests { #[test] fn issue_count_counts_warnings_and_errors() { let report = DoctorReport { + live: false, checks: vec![ pass_check("OK"), warning_check("Warn"), @@ -699,7 +964,7 @@ mod tests { fn check_llm_all_configured() { let statuses: Vec<(Provider, bool)> = Provider::ALL.iter().map(|p| (*p, true)).collect(); - let result = check_llm_providers(&statuses); + let result = check_llm_providers(&statuses, None); assert_eq!(result.status, CheckStatus::Pass); assert!(result.summary.contains("7 of 7")); } @@ -713,7 +978,7 @@ mod tests { statuses[2].1 = true; // Gemini statuses[3].1 = true; // Kimi statuses[4].1 = true; // Zai - let result = check_llm_providers(&statuses); + let result = check_llm_providers(&statuses, None); assert_eq!(result.status, CheckStatus::Pass); assert!(result.summary.contains("5 of 7")); } @@ -722,33 +987,68 @@ mod tests { fn check_llm_none_configured() { let statuses: Vec<(Provider, bool)> = Provider::ALL.iter().map(|p| (*p, false)).collect(); - let result = check_llm_providers(&statuses); + let result = check_llm_providers(&statuses, None); assert_eq!(result.status, CheckStatus::Error); assert!(result.summary.contains("0 of 7")); } + #[test] + fn check_llm_live_ok() { + let statuses = vec![(Provider::Anthropic, true)]; + let live = vec![(Provider::Anthropic, Ok(()))]; + let result = check_llm_providers(&statuses, Some(&live)); + assert_eq!(result.status, CheckStatus::Pass); + assert!(result.details.iter().any(|d| d.text.contains("connectivity: OK"))); + } + + #[test] + fn check_llm_live_error() { + let statuses = vec![(Provider::Anthropic, true)]; + let live = vec![(Provider::Anthropic, Err("timeout".to_string()))]; + let result = check_llm_providers(&statuses, Some(&live)); + assert_eq!(result.status, CheckStatus::Warning); + assert!(result.details.iter().any(|d| d.text.contains("timeout"))); + } + // -- check_brave_search -- #[test] fn check_brave_configured() { - let result = check_brave_search(true); + let result = check_brave_search(true, None); assert_eq!(result.status, CheckStatus::Pass); } #[test] fn check_brave_not_configured() { - let result = check_brave_search(false); + let result = check_brave_search(false, None); assert_eq!(result.status, CheckStatus::Warning); assert!(result.remediation.is_some()); } + #[test] + fn check_brave_live_ok() { + let live = Ok(()); + let result = check_brave_search(true, Some(&live)); + assert_eq!(result.status, CheckStatus::Pass); + assert!(result.summary.contains("connected")); + } + + #[test] + fn check_brave_live_error() { + let live = Err("HTTP 401".to_string()); + let result = check_brave_search(true, Some(&live)); + assert_eq!(result.status, CheckStatus::Warning); + assert!(result.details.iter().any(|d| d.text.contains("HTTP 401"))); + } + // -- check_sandbox -- #[test] - fn check_sandbox_daytona_ok() { + fn check_sandbox_daytona_probed_ok() { let status = SandboxStatus { - daytona: Some(Ok(())), - docker: None, + daytona_configured: true, + daytona_probe: Some(Ok(())), + docker_probe: None, }; let result = check_sandbox(&status); assert_eq!(result.status, CheckStatus::Pass); @@ -756,10 +1056,11 @@ mod tests { } #[test] - fn check_sandbox_docker_ok() { + fn check_sandbox_docker_probed_ok() { let status = SandboxStatus { - daytona: None, - docker: Some(Ok(())), + daytona_configured: false, + daytona_probe: None, + docker_probe: Some(Ok(())), }; let result = check_sandbox(&status); assert_eq!(result.status, CheckStatus::Pass); @@ -767,10 +1068,11 @@ mod tests { } #[test] - fn check_sandbox_both_ok() { + fn check_sandbox_both_probed_ok() { let status = SandboxStatus { - daytona: Some(Ok(())), - docker: Some(Ok(())), + daytona_configured: true, + daytona_probe: Some(Ok(())), + docker_probe: Some(Ok(())), }; let result = check_sandbox(&status); assert_eq!(result.status, CheckStatus::Pass); @@ -778,20 +1080,36 @@ mod tests { } #[test] - fn check_sandbox_both_unavailable() { + fn check_sandbox_nothing_configured() { let status = SandboxStatus { - daytona: None, - docker: None, + daytona_configured: false, + daytona_probe: None, + docker_probe: None, }; let result = check_sandbox(&status); assert_eq!(result.status, CheckStatus::Warning); + assert!(result.summary.contains("no sandbox configured")); + } + + #[test] + fn check_sandbox_daytona_configured_not_probed() { + let status = SandboxStatus { + daytona_configured: true, + daytona_probe: None, + docker_probe: None, + }; + let result = check_sandbox(&status); + assert_eq!(result.status, CheckStatus::Pass); + assert!(result.summary.contains("Daytona configured")); + assert!(result.details.iter().any(|d| d.text.contains("configured"))); } #[test] fn check_sandbox_configured_but_broken() { let status = SandboxStatus { - daytona: Some(Err("connection refused".to_string())), - docker: None, + daytona_configured: true, + daytona_probe: Some(Err("connection refused".to_string())), + docker_probe: None, }; let result = check_sandbox(&status); assert_eq!(result.status, CheckStatus::Error); @@ -850,7 +1168,7 @@ mod tests { base_url: "http://localhost:3000".to_string(), authentication_strategy: "jwt".to_string(), }; - let result = check_api(&status); + let result = check_api(&status, None); assert_eq!(result.status, CheckStatus::Pass); assert_eq!(result.summary, "http://localhost:3000"); } @@ -861,7 +1179,7 @@ mod tests { base_url: "https://api.example.com".to_string(), authentication_strategy: "jwt".to_string(), }; - let result = check_api(&status); + let result = check_api(&status, None); assert!(result.details.iter().any(|d| d.text.contains("jwt"))); assert!(result .details @@ -869,6 +1187,30 @@ mod tests { .any(|d| d.text.contains("https://api.example.com"))); } + #[test] + fn check_api_live_ok() { + let status = ApiStatus { + base_url: "http://localhost:3000".to_string(), + authentication_strategy: "jwt".to_string(), + }; + let live = Ok(()); + let result = check_api(&status, Some(&live)); + assert_eq!(result.status, CheckStatus::Pass); + assert!(result.details.iter().any(|d| d.text.contains("Connectivity: OK"))); + } + + #[test] + fn check_api_live_error() { + let status = ApiStatus { + base_url: "http://localhost:3000".to_string(), + authentication_strategy: "jwt".to_string(), + }; + let live = Err("connection refused".to_string()); + let result = check_api(&status, Some(&live)); + assert_eq!(result.status, CheckStatus::Warning); + assert!(result.details.iter().any(|d| d.text.contains("connection refused"))); + } + // -- check_web -- #[test] @@ -878,7 +1220,7 @@ mod tests { auth_provider: "github".to_string(), allowed_usernames_count: 0, }; - let result = check_web(&status); + let result = check_web(&status, None); assert_eq!(result.status, CheckStatus::Pass); assert_eq!(result.summary, "http://localhost:5173"); } @@ -890,7 +1232,7 @@ mod tests { auth_provider: "github".to_string(), allowed_usernames_count: 3, }; - let result = check_web(&status); + let result = check_web(&status, None); assert!(result.details.iter().any(|d| d.text.contains("github"))); assert!(result .details @@ -902,11 +1244,38 @@ mod tests { .any(|d| d.text.contains("Allowed usernames: 3"))); } + #[test] + fn check_web_live_ok() { + let status = WebStatus { + url: "http://localhost:5173".to_string(), + auth_provider: "github".to_string(), + allowed_usernames_count: 0, + }; + let live = Ok(()); + let result = check_web(&status, Some(&live)); + assert_eq!(result.status, CheckStatus::Pass); + assert!(result.details.iter().any(|d| d.text.contains("Connectivity: OK"))); + } + + #[test] + fn check_web_live_error() { + let status = WebStatus { + url: "http://localhost:5173".to_string(), + auth_provider: "github".to_string(), + allowed_usernames_count: 0, + }; + let live = Err("connection refused".to_string()); + let result = check_web(&status, Some(&live)); + assert_eq!(result.status, CheckStatus::Warning); + assert!(result.details.iter().any(|d| d.text.contains("connection refused"))); + } + // -- render: multiple issues -- #[test] fn render_multiple_issues_pluralizes() { let report = DoctorReport { + live: false, checks: vec![warning_check("A"), error_check("B")], }; let out = report.render(&Styles::new(false), false); diff --git a/crates/arc-cli/src/main.rs b/crates/arc-cli/src/main.rs index d02c86d40..7ee7f4e78 100644 --- a/crates/arc-cli/src/main.rs +++ b/crates/arc-cli/src/main.rs @@ -48,6 +48,10 @@ enum Command { /// Show detailed information for each check #[arg(short, long)] verbose: bool, + + /// Probe live services (LLM, sandbox, API, web, Brave Search) + #[arg(short, long)] + live: bool, }, } @@ -122,8 +126,8 @@ async fn main() -> Result<()> { Box::leak(Box::new(arc_util::terminal::Styles::detect_stderr())); arc_api::serve::serve_command(args, styles).await?; } - Command::Doctor { verbose } => { - let exit_code = doctor::run_doctor(verbose).await; + Command::Doctor { verbose, live } => { + let exit_code = doctor::run_doctor(verbose, live).await; std::process::exit(exit_code); } } diff --git a/crates/arc-cli/tests/cli.rs b/crates/arc-cli/tests/cli.rs index 968e707fc..eab6cbc46 100644 --- a/crates/arc-cli/tests/cli.rs +++ b/crates/arc-cli/tests/cli.rs @@ -555,6 +555,15 @@ fn doctor_checks_arc_web() { .stdout(predicate::str::contains("Arc Web")); } +#[test] +fn doctor_live_flag_accepted() { + arc() + .args(["--no-dotenv", "doctor", "--live"]) + .env_clear() + .assert() + .stdout(predicate::str::contains("Arc Doctor")); +} + // == JSONL logging ============================================================ #[test] diff --git a/crates/arc-llm/src/cli.rs b/crates/arc-llm/src/cli.rs index 8b61d1de2..8c54f4bca 100644 --- a/crates/arc-llm/src/cli.rs +++ b/crates/arc-llm/src/cli.rs @@ -103,22 +103,23 @@ fn format_speed(tps: Option) -> String { fn print_models_table(models: &[crate::types::ModelInfo], s: &Styles) { println!( - "{b}{d}{:<24} {:<12} {:<24} {:>10} {:>7} {:>7} {:>10}{r}", - "MODEL", "PROVIDER", "ALIASES", "CONTEXT", "COST", "", "SPEED", - b = s.bold, d = s.dim, r = s.reset, + "{}", + s.bold_dim.apply_to(format!( + "{:<24} {:<12} {:<24} {:>10} {:>7} {:>7} {:>10}", + "MODEL", "PROVIDER", "ALIASES", "CONTEXT", "COST", "", "SPEED", + )), ); for model in models { let aliases = model.aliases.join(", "); println!( - "{b}{:<24}{r} {d}{:<12}{r} {d}{:<24}{r} {:>10} {:>7} / {:<7} {c}{:>10}{r}", - model.id, - model.provider, - aliases, + "{} {} {} {:>10} {:>7} / {:<7} {}", + s.bold.apply_to(format!("{:<24}", model.id)), + s.dim.apply_to(format!("{:<12}", model.provider)), + s.dim.apply_to(format!("{:<24}", aliases)), format_context_window(model.context_window), format_cost(model.input_cost_per_million), format_cost(model.output_cost_per_million), - format_speed(model.estimated_output_tps), - b = s.bold, d = s.dim, c = s.cyan, r = s.reset, + s.cyan.apply_to(format!("{:>10}", format_speed(model.estimated_output_tps))), ); } } @@ -394,9 +395,11 @@ async fn test_models(provider: Option<&str>, model: Option<&str>, s: &Styles) -> } println!( - "{b}{d}{:<24} {:<12} {:>10} {:>7} {:>7} {:>10} RESULT{r}", - "MODEL", "PROVIDER", "CONTEXT", "COST", "", "SPEED", - b = s.bold, d = s.dim, r = s.reset, + "{}", + s.bold_dim.apply_to(format!( + "{:<24} {:<12} {:>10} {:>7} {:>7} {:>10} RESULT", + "MODEL", "PROVIDER", "CONTEXT", "COST", "", "SPEED", + )), ); let mut failures = 0u32; @@ -410,26 +413,26 @@ async fn test_models(provider: Option<&str>, model: Option<&str>, s: &Styles) -> tokio::time::timeout(Duration::from_secs(30), generate::generate(params)).await; let (status_color, status) = match result { - Ok(Ok(_)) => (s.green, "ok".to_string()), + Ok(Ok(_)) => (&s.green, "ok".to_string()), Ok(Err(e)) => { failures += 1; - (s.red, format!("error: {e}")) + (&s.red, format!("error: {e}")) } Err(_) => { failures += 1; - (s.red, "error: timeout (30s)".to_string()) + (&s.red, "error: timeout (30s)".to_string()) } }; println!( - "{b}{:<24}{r} {d}{:<12}{r} {:>10} {:>7} / {:<7} {c}{:>10}{r} {sc}{status}{r}", - info.id, - info.provider, + "{} {} {:>10} {:>7} / {:<7} {} {}", + s.bold.apply_to(format!("{:<24}", info.id)), + s.dim.apply_to(format!("{:<12}", info.provider)), format_context_window(info.context_window), format_cost(info.input_cost_per_million), format_cost(info.output_cost_per_million), - format_speed(info.estimated_output_tps), - b = s.bold, d = s.dim, c = s.cyan, r = s.reset, sc = status_color, + s.cyan.apply_to(format!("{:>10}", format_speed(info.estimated_output_tps))), + status_color.apply_to(&status), ); } diff --git a/crates/arc-util/Cargo.toml b/crates/arc-util/Cargo.toml index 2390b9269..37ff75145 100644 --- a/crates/arc-util/Cargo.toml +++ b/crates/arc-util/Cargo.toml @@ -9,6 +9,7 @@ description = "Shared utilities: secret redaction and terminal styling" doctest = false [dependencies] +console.workspace = true regex.workspace = true aho-corasick.workspace = true serde_json.workspace = true diff --git a/crates/arc-util/src/terminal.rs b/crates/arc-util/src/terminal.rs index f0224167f..bf5085641 100644 --- a/crates/arc-util/src/terminal.rs +++ b/crates/arc-util/src/terminal.rs @@ -1,44 +1,30 @@ -use std::io::IsTerminal; +use console::Style; -/// Pre-resolved ANSI escape codes for styled terminal output. -/// All fields are empty strings when color is disabled (non-TTY or `NO_COLOR`). +/// Pre-built [`console::Style`] instances for styled terminal output. +/// Each style is forced on/off based on the `use_color` flag passed to [`Styles::new`]. pub struct Styles { - pub bold: &'static str, - pub dim: &'static str, - pub cyan: &'static str, - pub green: &'static str, - pub yellow: &'static str, - pub red: &'static str, - pub reset: &'static str, + pub bold: Style, + pub dim: Style, + pub cyan: Style, + pub green: Style, + pub yellow: Style, + pub red: Style, + pub bold_dim: Style, + pub bold_cyan: Style, } -// SAFETY: Styles contains only `&'static str` fields, which are inherently Send + Sync. -unsafe impl Send for Styles {} -unsafe impl Sync for Styles {} - impl Styles { #[must_use] - pub const fn new(use_color: bool) -> Self { - if use_color { - Self { - bold: "\x1b[1m", - dim: "\x1b[2m", - cyan: "\x1b[36m", - green: "\x1b[32m", - yellow: "\x1b[33m", - red: "\x1b[31m", - reset: "\x1b[0m", - } - } else { - Self { - bold: "", - dim: "", - cyan: "", - green: "", - yellow: "", - red: "", - reset: "", - } + pub fn new(use_color: bool) -> Self { + Self { + bold: Style::new().bold().force_styling(use_color), + dim: Style::new().dim().force_styling(use_color), + cyan: Style::new().cyan().force_styling(use_color), + green: Style::new().green().force_styling(use_color), + yellow: Style::new().yellow().force_styling(use_color), + red: Style::new().red().force_styling(use_color), + bold_dim: Style::new().bold().dim().force_styling(use_color), + bold_cyan: Style::new().bold().cyan().force_styling(use_color), } } @@ -46,16 +32,14 @@ impl Styles { /// Respects `NO_COLOR` environment variable. #[must_use] pub fn detect_stderr() -> Self { - let use_color = std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none(); - Self::new(use_color) + Self::new(console::colors_enabled_stderr()) } /// Create styles based on whether stdout is a TTY. /// Respects `NO_COLOR` environment variable. #[must_use] pub fn detect_stdout() -> Self { - let use_color = std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none(); - Self::new(use_color) + Self::new(console::colors_enabled()) } } @@ -64,34 +48,43 @@ mod tests { use super::*; #[test] - fn styles_with_color() { + fn styles_with_color_produces_ansi() { let s = Styles::new(true); - assert_eq!(s.bold, "\x1b[1m"); - assert_eq!(s.dim, "\x1b[2m"); - assert_eq!(s.cyan, "\x1b[36m"); - assert_eq!(s.green, "\x1b[32m"); - assert_eq!(s.yellow, "\x1b[33m"); - assert_eq!(s.red, "\x1b[31m"); - assert_eq!(s.reset, "\x1b[0m"); + let output = format!("{}", s.bold.apply_to("text")); + assert!(output.contains("\x1b["), "bold should contain ANSI codes"); + assert!(output.contains("text")); + + let output = format!("{}", s.green.apply_to("ok")); + assert!(output.contains("\x1b["), "green should contain ANSI codes"); + assert!(output.contains("ok")); } #[test] - fn styles_without_color() { + fn styles_without_color_produces_plain_text() { let s = Styles::new(false); - assert!(s.bold.is_empty()); - assert!(s.dim.is_empty()); - assert!(s.cyan.is_empty()); - assert!(s.green.is_empty()); - assert!(s.yellow.is_empty()); - assert!(s.red.is_empty()); - assert!(s.reset.is_empty()); + assert_eq!(format!("{}", s.bold.apply_to("text")), "text"); + assert_eq!(format!("{}", s.dim.apply_to("text")), "text"); + assert_eq!(format!("{}", s.cyan.apply_to("text")), "text"); + assert_eq!(format!("{}", s.green.apply_to("text")), "text"); + assert_eq!(format!("{}", s.yellow.apply_to("text")), "text"); + assert_eq!(format!("{}", s.red.apply_to("text")), "text"); } - static NO_COLOR: Styles = Styles::new(false); + #[test] + fn combined_styles_work() { + let s = Styles::new(true); + let output = format!("{}", s.bold_dim.apply_to("header")); + assert!(output.contains("\x1b["), "bold_dim should contain ANSI codes"); + assert!(output.contains("header")); + + let output = format!("{}", s.bold_cyan.apply_to("tool")); + assert!(output.contains("\x1b["), "bold_cyan should contain ANSI codes"); + assert!(output.contains("tool")); + } #[test] - fn no_color_static_is_empty() { - assert!(NO_COLOR.bold.is_empty()); - assert!(NO_COLOR.reset.is_empty()); + fn no_color_lazy_is_plain() { + let styles = Styles::new(false); + assert_eq!(format!("{}", styles.bold.apply_to("x")), "x"); } } diff --git a/crates/arc-workflows/src/cli/backend.rs b/crates/arc-workflows/src/cli/backend.rs index a4902a5f4..f08813308 100644 --- a/crates/arc-workflows/src/cli/backend.rs +++ b/crates/arc-workflows/src/cli/backend.rs @@ -291,20 +291,18 @@ impl CodergenBackend for AgentApiBackend { .. } => { eprintln!( - "{dim}[{node_id}]{reset} {dim}\u{25cf}{reset} {bold}{cyan}{tool_name}{reset}{dim}({args}){reset}", - dim = styles.dim, - reset = styles.reset, - bold = styles.bold, - cyan = styles.cyan, - args = format_tool_args(arguments), + "{} {} {}{}", + styles.dim.apply_to(format!("[{node_id}]")), + styles.dim.apply_to("\u{25cf}"), + styles.bold_cyan.apply_to(tool_name), + styles.dim.apply_to(format!("({})", format_tool_args(arguments))), ); } AgentEvent::Error { error } => { eprintln!( - "{dim}[{node_id}]{reset} {red}\u{2717} {error}{reset}", - dim = styles.dim, - red = styles.red, - reset = styles.reset, + "{} {}", + styles.dim.apply_to(format!("[{node_id}]")), + styles.red.apply_to(format!("\u{2717} {error}")), ); } _ => {} @@ -373,10 +371,11 @@ impl CodergenBackend for AgentApiBackend { }; let reuse_label = if is_reused { " (reused session)" } else { "" }; eprintln!( - "{dim}[{node_id}] Done ({turn_count} turns, {tool_call_count} tool calls, {token_str}{reuse_label}){reset}", - node_id = node.id, - dim = self.styles.dim, - reset = self.styles.reset, + "{}", + self.styles.dim.apply_to(format!( + "[{}] Done ({turn_count} turns, {tool_call_count} tool calls, {token_str}{reuse_label})", + node.id, + )), ); } diff --git a/crates/arc-workflows/src/cli/mod.rs b/crates/arc-workflows/src/cli/mod.rs index 50991e336..e9a450d65 100644 --- a/crates/arc-workflows/src/cli/mod.rs +++ b/crates/arc-workflows/src/cli/mod.rs @@ -149,27 +149,20 @@ pub fn print_diagnostics(diagnostics: &[Diagnostic], styles: &Styles) { }; match d.severity { Severity::Error => eprintln!( - "{red}error{reset}{location}: {} ({dim}{}{reset})", + "{}{location}: {} ({})", + styles.red.apply_to("error"), d.message, - d.rule, - red = styles.red, - dim = styles.dim, - reset = styles.reset, + styles.dim.apply_to(&d.rule), ), Severity::Warning => eprintln!( - "{yellow}warning{reset}{location}: {} ({dim}{}{reset})", + "{}{location}: {} ({})", + styles.yellow.apply_to("warning"), d.message, - d.rule, - yellow = styles.yellow, - dim = styles.dim, - reset = styles.reset, + styles.dim.apply_to(&d.rule), ), Severity::Info => eprintln!( - "{dim}info{location}: {} ({}){reset}", - d.message, - d.rule, - dim = styles.dim, - reset = styles.reset, + "{}", + styles.dim.apply_to(format!("info{location}: {} ({})", d.message, d.rule)), ), } } @@ -568,7 +561,7 @@ pub fn format_event_summary(event: &WorkflowRunEvent, styles: &Styles) -> String format!("[ASSETS_CAPTURED] node={node_id} files_copied={files_copied} total_bytes={total_bytes} files_skipped={files_skipped}") } }; - format!("{dim}{body}{reset}", dim = styles.dim, reset = styles.reset) + format!("{}", styles.dim.apply_to(body)) } /// Compute the dollar cost for a stage's token usage, if pricing is available. @@ -637,7 +630,8 @@ mod tests { } fn test_styles() -> &'static Styles { - Box::leak(Box::new(Styles::new(false))) + static STYLES: std::sync::LazyLock = std::sync::LazyLock::new(|| Styles::new(false)); + &STYLES } #[test] diff --git a/crates/arc-workflows/src/cli/run.rs b/crates/arc-workflows/src/cli/run.rs index 9358eff5b..d52b3dd77 100644 --- a/crates/arc-workflows/src/cli/run.rs +++ b/crates/arc-workflows/src/cli/run.rs @@ -218,22 +218,15 @@ pub async fn run_command( let (graph, diagnostics) = WorkflowBuilder::new().prepare(&source)?; eprintln!( - "{bold}Parsed workflow:{reset} {} ({dim}{} nodes, {} edges{reset})", + "{} {} ({})", + styles.bold.apply_to("Parsed workflow:"), graph.name, - graph.nodes.len(), - graph.edges.len(), - bold = styles.bold, - dim = styles.dim, - reset = styles.reset, + styles.dim.apply_to(format!("{} nodes, {} edges", graph.nodes.len(), graph.edges.len())), ); let goal = graph.goal(); if !goal.is_empty() { - eprintln!( - "{bold}Goal:{reset} {goal}", - bold = styles.bold, - reset = styles.reset - ); + eprintln!("{} {goal}", styles.bold.apply_to("Goal:")); } print_diagnostics(&diagnostics, styles); @@ -276,10 +269,8 @@ pub async fn run_command( if args.verbose { eprintln!( - "{dim}Logs: {}{reset}", - logs_dir.display(), - dim = styles.dim, - reset = styles.reset, + "{}", + styles.dim.apply_to(format!("Logs: {}", logs_dir.display())), ); } @@ -382,9 +373,8 @@ pub async fn run_command( .. // node_id and other fields } => { let mut line = format!( - "{dim}Stage \"{name}\" completed ({status}) in {duration}", + "Stage \"{name}\" completed ({status}) in {duration}", duration = format_duration_human(*duration_ms), - dim = styles.dim, ); if let Some(u) = usage { let total = u.input_tokens + u.output_tokens; @@ -398,13 +388,12 @@ pub async fn run_command( line.push_str(&format!(" \u{2014} {tokens_str} tokens")); } } - eprintln!("{line}{reset}", reset = styles.reset); + eprintln!("{}", styles.dim.apply_to(line)); } crate::event::WorkflowRunEvent::StageFailed { name, .. } => { eprintln!( - "{dim}Stage \"{name}\" failed{reset}", - dim = styles.dim, - reset = styles.reset, + "{}", + styles.dim.apply_to(format!("Stage \"{name}\" failed")), ); } _ => {} @@ -427,8 +416,8 @@ pub async fn run_command( } Err(e) => { eprintln!( - "{yellow}Warning:{reset} Git worktree setup failed ({e}), running without worktree.", - yellow = styles.yellow, reset = styles.reset, + "{} Git worktree setup failed ({e}), running without worktree.", + styles.yellow.apply_to("Warning:"), ); (None, None, None, None, None) } @@ -509,8 +498,8 @@ pub async fn run_command( Ok((rid, base, branch)) => (Some(rid), Some(base), Some(branch)), Err(e) => { eprintln!( - "{yellow}Warning:{reset} Daytona git setup failed ({e}), running without git checkpoints.", - yellow = styles.yellow, reset = styles.reset, + "{} Daytona git setup failed ({e}), running without git checkpoints.", + styles.yellow.apply_to("Warning:"), ); (None, None, None) } @@ -569,17 +558,16 @@ pub async fn run_command( match arc_llm::client::Client::from_env().await { Ok(c) if c.provider_names().is_empty() => { eprintln!( - "{yellow}Warning:{reset} No LLM providers configured. Running in dry-run mode.", - yellow = styles.yellow, - reset = styles.reset, + "{} No LLM providers configured. Running in dry-run mode.", + styles.yellow.apply_to("Warning:"), ); (true, None) } Ok(c) => (false, Some(c)), Err(e) => { eprintln!( - "{yellow}Warning:{reset} Failed to initialize LLM client: {e}. Running in dry-run mode.", - yellow = styles.yellow, reset = styles.reset, + "{} Failed to initialize LLM client: {e}. Running in dry-run mode.", + styles.yellow.apply_to("Warning:"), ); (true, None) } @@ -717,19 +705,18 @@ pub async fn run_command( // 8. Print result eprintln!( - "\n{bold}=== Run Result ==={reset}", - bold = styles.bold, - reset = styles.reset, + "\n{}", + styles.bold.apply_to("=== Run Result ==="), ); let status_str = outcome.status.to_string().to_uppercase(); let status_color = match outcome.status { - StageStatus::Success | StageStatus::PartialSuccess => styles.green, - _ => styles.red, + StageStatus::Success | StageStatus::PartialSuccess => &styles.green, + _ => &styles.red, }; eprintln!( - "Status: {status_color}{status_str}{reset}", - reset = styles.reset + "Status: {}", + status_color.apply_to(&status_str), ); eprintln!("Duration: {}", format_duration_human(run_duration_ms)); @@ -747,19 +734,21 @@ pub async fn run_command( } if acc.total_cache_read_tokens > 0 { eprintln!( - "{dim}Cache: {} read, {} write{reset}", - format_tokens_human(acc.total_cache_read_tokens), - format_tokens_human(acc.total_cache_write_tokens), - dim = styles.dim, - reset = styles.reset, + "{}", + styles.dim.apply_to(format!( + "Cache: {} read, {} write", + format_tokens_human(acc.total_cache_read_tokens), + format_tokens_human(acc.total_cache_write_tokens), + )), ); } if acc.total_reasoning_tokens > 0 { eprintln!( - "{dim}Reasoning: {} tokens{reset}", - format_tokens_human(acc.total_reasoning_tokens), - dim = styles.dim, - reset = styles.reset, + "{}", + styles.dim.apply_to(format!( + "Reasoning: {} tokens", + format_tokens_human(acc.total_reasoning_tokens), + )), ); } } @@ -770,16 +759,13 @@ pub async fn run_command( } if let Some(failure) = outcome.failure_reason() { eprintln!( - "{red}Failure: {failure}{reset}", - red = styles.red, - reset = styles.reset, + "{}", + styles.red.apply_to(format!("Failure: {failure}")), ); } eprintln!( - "{dim}Logs: {}{reset}", - logs_dir.display(), - dim = styles.dim, - reset = styles.reset, + "{}", + styles.dim.apply_to(format!("Logs: {}", logs_dir.display())), ); // 9. Exit code @@ -899,11 +885,10 @@ async fn run_from_branch( let (graph, diagnostics) = crate::workflow::WorkflowBuilder::new().prepare(&source)?; eprintln!( - "{bold}Resuming workflow:{reset} {} from branch {dim}{run_branch}{reset}", + "{} {} from branch {}", + styles.bold.apply_to("Resuming workflow:"), graph.name, - bold = styles.bold, - dim = styles.dim, - reset = styles.reset, + styles.dim.apply_to(run_branch), ); super::print_diagnostics(&diagnostics, styles); @@ -1044,28 +1029,25 @@ async fn run_from_branch( let outcome = engine_result?; eprintln!( - "\n{bold}=== Run Result ==={reset}", - bold = styles.bold, - reset = styles.reset, + "\n{}", + styles.bold.apply_to("=== Run Result ==="), ); let status_str = outcome.status.to_string().to_uppercase(); let status_color = match outcome.status { - StageStatus::Success | StageStatus::PartialSuccess => styles.green, - _ => styles.red, + StageStatus::Success | StageStatus::PartialSuccess => &styles.green, + _ => &styles.red, }; eprintln!( - "Status: {status_color}{status_str}{reset}", - reset = styles.reset + "Status: {}", + status_color.apply_to(&status_str), ); eprintln!( "Duration: {}", super::format_duration_human(run_duration_ms) ); eprintln!( - "{dim}Logs: {}{reset}", - logs_dir.display(), - dim = styles.dim, - reset = styles.reset, + "{}", + styles.dim.apply_to(format!("Logs: {}", logs_dir.display())), ); match outcome.status { @@ -1200,9 +1182,8 @@ async fn run_preflight( // 7. Print warnings/errors to stderr for err in &errors { eprintln!( - "{red}error{reset}: {err}", - red = styles.red, - reset = styles.reset, + "{}: {err}", + styles.red.apply_to("error"), ); } @@ -1210,16 +1191,14 @@ async fn run_preflight( let ok = sandbox_ready && llm_available && provider_valid; if ok { eprintln!( - "\n{green}Preflight: OK{reset}", - green = styles.green, - reset = styles.reset, + "\n{}", + styles.green.apply_to("Preflight: OK"), ); Ok(()) } else { eprintln!( - "\n{red}Preflight: FAIL{reset}", - red = styles.red, - reset = styles.reset, + "\n{}", + styles.red.apply_to("Preflight: FAIL"), ); std::process::exit(1); } @@ -1249,9 +1228,8 @@ async fn generate_retro( Ok(cp) => cp, Err(e) => { eprintln!( - "{yellow}Warning:{reset} Could not load checkpoint, skipping retro: {e}", - yellow = styles.yellow, - reset = styles.reset, + "{} Could not load checkpoint, skipping retro: {e}", + styles.yellow.apply_to("Warning:"), ); return; } @@ -1273,9 +1251,8 @@ async fn generate_retro( Ok(()) => {} Err(e) => { eprintln!( - "{yellow}Warning:{reset} Failed to save initial retro: {e}", - yellow = styles.yellow, - reset = styles.reset, + "{} Failed to save initial retro: {e}", + styles.yellow.apply_to("Warning:"), ); } } @@ -1296,26 +1273,22 @@ async fn generate_retro( match retro.save(logs_dir) { Ok(()) => { eprintln!( - "{dim}Retro saved to {}/retro.json{reset}", - logs_dir.display(), - dim = styles.dim, - reset = styles.reset, + "{}", + styles.dim.apply_to(format!("Retro saved to {}/retro.json", logs_dir.display())), ); } Err(e) => { eprintln!( - "{yellow}Warning:{reset} Failed to save retro with narrative: {e}", - yellow = styles.yellow, - reset = styles.reset, + "{} Failed to save retro with narrative: {e}", + styles.yellow.apply_to("Warning:"), ); } } } Err(e) => { eprintln!( - "{dim}Retro agent skipped: {e}{reset}", - dim = styles.dim, - reset = styles.reset, + "{}", + styles.dim.apply_to(format!("Retro agent skipped: {e}")), ); } } diff --git a/crates/arc-workflows/src/cli/validate.rs b/crates/arc-workflows/src/cli/validate.rs index 046c850ff..ea8567641 100644 --- a/crates/arc-workflows/src/cli/validate.rs +++ b/crates/arc-workflows/src/cli/validate.rs @@ -16,13 +16,10 @@ pub fn validate_command(args: &ValidateArgs, styles: &Styles) -> anyhow::Result< let (graph, diagnostics) = WorkflowBuilder::new().prepare(&source)?; eprintln!( - "{bold}Parsed workflow:{reset} {} ({dim}{} nodes, {} edges{reset})", - graph.name, + "{} ({} nodes, {} edges)", + styles.bold.apply_to(format!("Parsed workflow: {}", graph.name)), graph.nodes.len(), graph.edges.len(), - bold = styles.bold, - dim = styles.dim, - reset = styles.reset, ); print_diagnostics(&diagnostics, styles); @@ -31,10 +28,6 @@ pub fn validate_command(args: &ValidateArgs, styles: &Styles) -> anyhow::Result< bail!("Validation failed"); } - eprintln!( - "Validation: {green}OK{reset}", - green = styles.green, - reset = styles.reset, - ); + eprintln!("Validation: {}", styles.green.apply_to("OK")); Ok(()) } diff --git a/crates/arc-workflows/src/interviewer/console.rs b/crates/arc-workflows/src/interviewer/console.rs index c642dfd3a..aeda03ed0 100644 --- a/crates/arc-workflows/src/interviewer/console.rs +++ b/crates/arc-workflows/src/interviewer/console.rs @@ -15,7 +15,7 @@ pub struct ConsoleInterviewer { impl ConsoleInterviewer { #[must_use] - pub const fn new(styles: &'static Styles) -> Self { + pub fn new(styles: &'static Styles) -> Self { Self { styles } } } @@ -164,24 +164,21 @@ impl Interviewer for ConsoleInterviewer { // Non-TTY fallback: line-based stdin reading let s = self.styles; eprintln!( - "{bold}{cyan}?{reset} {}", + "{} {}", + s.bold_cyan.apply_to("?"), question.text, - bold = s.bold, - cyan = s.cyan, - reset = s.reset, ); match question.question_type { QuestionType::MultipleChoice | QuestionType::MultiSelect => { for (i, opt) in question.options.iter().enumerate() { eprintln!( - " {dim}[{reset}{bold}{}{reset}{dim}]{reset} {} - {}", - i + 1, + " {}{}{} {} - {}", + s.dim.apply_to("["), + s.bold.apply_to(i + 1), + s.dim.apply_to("]"), opt.key, opt.label, - dim = s.dim, - bold = s.bold, - reset = s.reset, ); } if question.allow_freeform { @@ -215,11 +212,7 @@ impl Interviewer for ConsoleInterviewer { async fn inform(&self, message: &str, stage: &str) { let s = self.styles; - eprintln!( - "{dim}[{stage}]{reset} {message}", - dim = s.dim, - reset = s.reset, - ); + eprintln!("{} {message}", s.dim.apply_to(format!("[{stage}]"))); } } diff --git a/crates/arc-workflows/tests/integration.rs b/crates/arc-workflows/tests/integration.rs index 8196c5639..b6c5efcaf 100644 --- a/crates/arc-workflows/tests/integration.rs +++ b/crates/arc-workflows/tests/integration.rs @@ -7168,7 +7168,7 @@ fn parse_tool_hooks_from_dot_syntax() { // E2E test with real LLM // --------------------------------------------------------------------------- -static TEST_STYLES: Styles = Styles::new(false); +static TEST_STYLES: std::sync::LazyLock = std::sync::LazyLock::new(|| Styles::new(false)); #[tokio::test] #[ignore = "requires ANTHROPIC_API_KEY"]