mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-08 22:21:45 +00:00
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:
parent
d101892bdf
commit
c2ea8b3084
17 changed files with 759 additions and 396 deletions
27
Cargo.lock
generated
27
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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}")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}]")));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue