Replace DIY terminal color with console crate

Styles fields change from &'static str (raw ANSI escape codes) to
console::Style, removing unsafe Send/Sync impls and manual reset
handling. The console crate handles TTY detection and NO_COLOR natively.

Also adds live connectivity probes to arc doctor (--live flag).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-03 16:02:10 -05:00
parent d101892bdf
commit c2ea8b3084
17 changed files with 759 additions and 396 deletions

27
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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<arc_llm::types::Response, arc_llm::error::SdkError> {
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<arc_llm::types::Response, arc_llm::error::SdkError> {
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!("<serialize error: {e}>"))
);
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!("<serialize error: {e}>"))
);
@ -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<Styles> = std::sync::LazyLock::new(|| Styles::new(false));
// tool_category tests

View file

@ -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?;

View file

@ -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"

View file

@ -32,6 +32,7 @@ pub struct CheckResult {
pub struct DoctorReport {
pub checks: Vec<CheckResult>,
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<PathBuf>) -> 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<CheckDetail> = statuses
let mut details: Vec<CheckDetail> = 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<String> = 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<Result<(), String>>,
pub docker: Option<Result<(), String>>,
pub daytona_configured: bool,
pub daytona_probe: Option<Result<(), String>>,
pub docker_probe: Option<Result<(), String>>,
}
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<Result<(), String>> {
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<Vec<(Provider, Result<(), String>)>>;
let brave_live_result: Option<Result<(), String>>;
let api_live_result: Option<Result<(), String>>;
let web_live_result: Option<Result<(), String>>;
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<Provider> = 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);

View file

@ -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);
}
}

View file

@ -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]

View file

@ -103,22 +103,23 @@ fn format_speed(tps: Option<f64>) -> 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),
);
}

View file

@ -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

View file

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

View file

@ -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,
)),
);
}

View file

@ -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<Styles> = std::sync::LazyLock::new(|| Styles::new(false));
&STYLES
}
#[test]

View file

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

View file

@ -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(())
}

View file

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

View file

@ -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<Styles> = std::sync::LazyLock::new(|| Styles::new(false));
#[tokio::test]
#[ignore = "requires ANTHROPIC_API_KEY"]