Add [web] config section and arc doctor command

Move auth config under [web.auth] in arc.toml to group web-specific
settings together. Add WebConfig with url field (default localhost:5173).
Add `arc doctor` command with checks for config, API, web, LLM providers,
Brave Search, sandbox, and GitHub App. Extract Provider::api_key_env_vars
and has_api_key to deduplicate validation logic.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-03 15:17:41 -05:00
parent f9e30c7ae0
commit 84b2003a5a
10 changed files with 1118 additions and 30 deletions

2
Cargo.lock generated
View file

@ -172,8 +172,10 @@ dependencies = [
"arc-util",
"arc-workflows",
"assert_cmd",
"bollard",
"chrono",
"clap",
"daytona-sdk",
"dirs",
"dotenvy",
"httpmock",

View file

@ -19,8 +19,13 @@ interface GitConfig {
client_id: string | null;
}
export interface AppConfig {
interface WebConfig {
url: string;
auth: AuthConfig;
}
export interface AppConfig {
web: WebConfig;
api: ApiConfig;
git: GitConfig;
}
@ -30,6 +35,11 @@ const AUTH_DEFAULTS: AuthConfig = {
allowed_usernames: [],
};
const WEB_DEFAULTS: WebConfig = {
url: "http://localhost:5173",
auth: AUTH_DEFAULTS,
};
const API_DEFAULTS: ApiConfig = {
base_url: "http://localhost:3000",
authentication_strategy: "jwt",
@ -53,12 +63,17 @@ function loadAppConfig(): AppConfig {
// File doesn't exist or is unreadable — use defaults
}
const rawAuth = (raw.auth ?? {}) as Partial<AuthConfig>;
const rawWeb = (raw.web ?? {}) as Record<string, unknown>;
const rawWebAuth = (rawWeb.auth ?? {}) as Partial<AuthConfig>;
const rawApi = (raw.api ?? {}) as Partial<ApiConfig>;
const rawGit = (raw.git ?? {}) as Partial<GitConfig>;
return {
auth: { ...AUTH_DEFAULTS, ...rawAuth },
web: {
...WEB_DEFAULTS,
url: (rawWeb.url as string) ?? WEB_DEFAULTS.url,
auth: { ...AUTH_DEFAULTS, ...rawWebAuth },
},
api: { ...API_DEFAULTS, ...rawApi },
git: { ...GIT_DEFAULTS, ...rawGit },
};

View file

@ -43,7 +43,7 @@ export async function loader({ request }: Route.LoaderArgs) {
}>;
const primaryEmail = emails.find((e) => e.primary && e.verified)?.email ?? "";
const { allowed_usernames } = getAppConfig().auth;
const { allowed_usernames } = getAppConfig().web.auth;
if (allowed_usernames.length > 0 && !allowed_usernames.includes(profile.login)) {
throw redirect("/auth/login?error=unauthorized");
}

View file

@ -191,20 +191,6 @@ fn build_profile(
}
}
fn validate_api_key(provider: Provider) -> bool {
match provider {
Provider::Anthropic => std::env::var("ANTHROPIC_API_KEY").is_ok(),
Provider::OpenAi => std::env::var("OPENAI_API_KEY").is_ok(),
Provider::Gemini => {
std::env::var("GEMINI_API_KEY").is_ok() || std::env::var("GOOGLE_API_KEY").is_ok()
}
Provider::Kimi => std::env::var("KIMI_API_KEY").is_ok(),
Provider::Zai => std::env::var("ZAI_API_KEY").is_ok(),
Provider::Minimax => std::env::var("MINIMAX_API_KEY").is_ok(),
Provider::Inception => std::env::var("INCEPTION_API_KEY").is_ok(),
}
}
fn format_tool_args(args: &serde_json::Value, cwd: &str) -> String {
let cwd_prefix = if cwd.ends_with('/') {
cwd.to_string()
@ -359,7 +345,7 @@ pub async fn run_with_args(args: AgentArgs) -> anyhow::Result<()> {
.map_err(|e: String| anyhow::anyhow!("{e}"))?;
// Validate provider API key
if !validate_api_key(provider) {
if !provider.has_api_key() {
anyhow::bail!("API key not set for provider '{provider}'");
}

View file

@ -63,11 +63,32 @@ pub struct GitConfig {
pub client_id: Option<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
pub struct WebConfig {
#[serde(default = "default_web_url")]
pub url: String,
#[serde(default)]
pub auth: AuthConfig,
}
fn default_web_url() -> String {
"http://localhost:5173".to_string()
}
impl Default for WebConfig {
fn default() -> Self {
Self {
url: default_web_url(),
auth: AuthConfig::default(),
}
}
}
#[derive(Debug, Default, Deserialize)]
pub struct ServerConfig {
pub data_dir: Option<PathBuf>,
#[serde(default)]
pub auth: AuthConfig,
pub web: WebConfig,
#[serde(default)]
pub api: ApiConfig,
#[serde(default)]
@ -141,7 +162,10 @@ mod tests {
#[test]
fn parse_full_config() {
let toml = r#"
[auth]
[web]
url = "https://arc.example.com"
[web.auth]
provider = "github"
allowed_usernames = ["brynary", "alice"]
@ -155,8 +179,9 @@ app_id = "12345"
client_id = "Iv1.abc123"
"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.auth.provider, AuthProvider::Github);
assert_eq!(config.auth.allowed_usernames, vec!["brynary", "alice"]);
assert_eq!(config.web.url, "https://arc.example.com");
assert_eq!(config.web.auth.provider, AuthProvider::Github);
assert_eq!(config.web.auth.allowed_usernames, vec!["brynary", "alice"]);
assert_eq!(config.api.base_url, "http://example.com:8080");
assert_eq!(
config.api.authentication_strategy,
@ -168,11 +193,12 @@ client_id = "Iv1.abc123"
}
#[test]
fn parse_auth_defaults() {
fn parse_web_defaults() {
let toml = "";
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.auth.provider, AuthProvider::Github);
assert!(config.auth.allowed_usernames.is_empty());
assert_eq!(config.web.url, "http://localhost:5173");
assert_eq!(config.web.auth.provider, AuthProvider::Github);
assert!(config.web.auth.allowed_usernames.is_empty());
}
#[test]
@ -235,7 +261,7 @@ repo_url = "https://github.com/org/repo"
#[test]
fn parse_config_server_and_run_defaults_together() {
let toml = r#"
[auth]
[web.auth]
provider = "github"
[git]
@ -246,7 +272,7 @@ app_id = "123"
model = "gpt-4"
"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.auth.provider, AuthProvider::Github);
assert_eq!(config.web.auth.provider, AuthProvider::Github);
assert_eq!(config.git.app_id.as_deref(), Some("123"));
let llm = config.run_defaults.llm.unwrap();
assert_eq!(llm.model.as_deref(), Some("gpt-4"));
@ -255,14 +281,14 @@ model = "gpt-4"
#[test]
fn parse_insecure_disabled_values() {
let toml = r#"
[auth]
[web.auth]
provider = "insecure_disabled"
[api]
authentication_strategy = "insecure_disabled"
"#;
let config: ServerConfig = toml::from_str(toml).unwrap();
assert_eq!(config.auth.provider, AuthProvider::InsecureDisabled);
assert_eq!(config.web.auth.provider, AuthProvider::InsecureDisabled);
assert_eq!(
config.api.authentication_strategy,
ApiAuthenticationStrategy::InsecureDisabled

View file

@ -15,7 +15,9 @@ arc-agent = { path = "../arc-agent" }
arc-workflows = { path = "../arc-workflows" }
arc-api = { path = "../arc-api" }
arc-util = { path = "../arc-util" }
bollard.workspace = true
clap.workspace = true
daytona-sdk.workspace = true
anyhow.workspace = true
dotenvy.workspace = true
tokio.workspace = true

View file

@ -0,0 +1,915 @@
use std::fmt::Write;
use std::path::PathBuf;
use arc_api::server_config::{ApiAuthenticationStrategy, AuthProvider};
use arc_llm::provider::Provider;
use arc_util::terminal::Styles;
// ---------------------------------------------------------------------------
// Core types
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CheckStatus {
Pass,
Warning,
Error,
}
#[derive(Debug, Clone)]
pub struct CheckDetail {
pub text: String,
}
#[derive(Debug, Clone)]
pub struct CheckResult {
pub name: String,
pub status: CheckStatus,
pub summary: String,
pub details: Vec<CheckDetail>,
pub remediation: Option<String>,
}
pub struct DoctorReport {
pub checks: Vec<CheckResult>,
}
impl DoctorReport {
pub fn has_errors(&self) -> bool {
self.checks
.iter()
.any(|c| c.status == CheckStatus::Error)
}
pub fn issue_count(&self) -> usize {
self.checks
.iter()
.filter(|c| matches!(c.status, CheckStatus::Warning | CheckStatus::Error))
.count()
}
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).unwrap();
for check in &self.checks {
let (icon, color) = match check.status {
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,
)
.unwrap();
if verbose {
for detail in &check.details {
writeln!(out, " • {}", detail.text).unwrap();
}
}
}
let issues = self.issue_count();
writeln!(out).unwrap();
if issues == 0 {
writeln!(out, "All checks passed.").unwrap();
} else {
writeln!(
out,
"Doctor found issues in {issues} {}.",
if issues == 1 { "category" } else { "categories" }
)
.unwrap();
let errors: Vec<_> = self
.checks
.iter()
.filter(|c| c.status == CheckStatus::Error)
.collect();
if !errors.is_empty() {
writeln!(out).unwrap();
writeln!(out, "{b}Errors:{r}", b = s.bold, r = s.reset).unwrap();
for check in &errors {
write!(out, " • {}", check.name).unwrap();
if let Some(ref rem) = check.remediation {
write!(out, " — {rem}").unwrap();
}
writeln!(out).unwrap();
}
}
let warnings: Vec<_> = self
.checks
.iter()
.filter(|c| c.status == CheckStatus::Warning)
.collect();
if !warnings.is_empty() {
writeln!(out).unwrap();
writeln!(out, "{b}Warnings:{r}", b = s.bold, r = s.reset).unwrap();
for check in &warnings {
write!(out, " • {}", check.name).unwrap();
if let Some(ref rem) = check.remediation {
write!(out, " — {rem}").unwrap();
}
writeln!(out).unwrap();
}
}
}
out
}
}
// ---------------------------------------------------------------------------
// Check functions (pure, testable)
// ---------------------------------------------------------------------------
pub fn check_config(path: Option<PathBuf>) -> CheckResult {
match path {
Some(p) => CheckResult {
name: "Configuration".to_string(),
status: CheckStatus::Pass,
summary: p.display().to_string(),
details: vec![CheckDetail {
text: format!("Loaded from {}", p.display()),
}],
remediation: None,
},
None => CheckResult {
name: "Configuration".to_string(),
status: CheckStatus::Warning,
summary: "no config file found".to_string(),
details: vec![CheckDetail {
text: "Create ~/.arc/arc.toml to configure Arc".to_string(),
}],
remediation: Some("Create ~/.arc/arc.toml".to_string()),
},
}
}
pub fn check_llm_providers(statuses: &[(Provider, bool)]) -> CheckResult {
let configured: Vec<_> = statuses.iter().filter(|(_, set)| *set).collect();
let total = statuses.len();
let count = configured.len();
let details: Vec<CheckDetail> = statuses
.iter()
.map(|(provider, set)| {
let env_vars = provider.api_key_env_vars().join(" or ");
let status_text = if *set { "set" } else { "not set" };
CheckDetail {
text: format!("{provider} ({env_vars}): {status_text}"),
}
})
.collect();
if count == 0 {
CheckResult {
name: "LLM providers".to_string(),
status: CheckStatus::Error,
summary: format!("{count} of {total} configured"),
details,
remediation: Some("Set at least one provider API key".to_string()),
}
} else {
CheckResult {
name: "LLM providers".to_string(),
status: CheckStatus::Pass,
summary: format!("{count} of {total} configured"),
details,
remediation: None,
}
}
}
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,
}
} 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()),
}
}
}
pub struct SandboxStatus {
pub daytona: Option<Result<(), String>>,
pub docker: Option<Result<(), String>>,
}
pub fn check_sandbox(status: &SandboxStatus) -> CheckResult {
let mut available = Vec::new();
let mut details = Vec::new();
let mut errors = Vec::new();
match &status.daytona {
Some(Ok(())) => {
available.push("Daytona");
details.push(CheckDetail {
text: "Daytona (DAYTONA_API_KEY): available".to_string(),
});
}
Some(Err(e)) => {
errors.push(format!("Daytona: {e}"));
details.push(CheckDetail {
text: format!("Daytona (DAYTONA_API_KEY): error — {e}"),
});
}
None => {
details.push(CheckDetail {
text: "Daytona (DAYTONA_API_KEY): not configured".to_string(),
});
}
}
match &status.docker {
Some(Ok(())) => {
available.push("Docker");
details.push(CheckDetail {
text: "Docker: available".to_string(),
});
}
Some(Err(e)) => {
errors.push(format!("Docker: {e}"));
details.push(CheckDetail {
text: format!("Docker: error — {e}"),
});
}
None => {
details.push(CheckDetail {
text: "Docker: not available".to_string(),
});
}
}
if !errors.is_empty() {
CheckResult {
name: "Sandbox".to_string(),
status: CheckStatus::Error,
summary: errors.join("; "),
details,
remediation: Some("Fix sandbox configuration errors".to_string()),
}
} else if available.is_empty() {
CheckResult {
name: "Sandbox".to_string(),
status: CheckStatus::Warning,
summary: "no sandbox available".to_string(),
details,
remediation: Some(
"Install Docker or set DAYTONA_API_KEY to enable sandboxed execution".to_string(),
),
}
} else {
CheckResult {
name: "Sandbox".to_string(),
status: CheckStatus::Pass,
summary: format!("{} available", available.join(" + ")),
details,
remediation: None,
}
}
}
pub struct GithubAppStatus {
pub app_id: bool,
pub client_id: bool,
pub client_secret: bool,
pub webhook_secret: bool,
pub private_key: bool,
}
impl GithubAppStatus {
fn all_set(&self) -> bool {
self.app_id
&& self.client_id
&& self.client_secret
&& self.webhook_secret
&& self.private_key
}
fn none_set(&self) -> bool {
!self.app_id
&& !self.client_id
&& !self.client_secret
&& !self.webhook_secret
&& !self.private_key
}
}
pub fn check_github_app(status: &GithubAppStatus) -> CheckResult {
let fields = [
("git.app_id", status.app_id),
("git.client_id", status.client_id),
("GITHUB_APP_CLIENT_SECRET", status.client_secret),
("GITHUB_APP_WEBHOOK_SECRET", status.webhook_secret),
("GITHUB_APP_PRIVATE_KEY", status.private_key),
];
let details: Vec<CheckDetail> = fields
.iter()
.map(|(name, set)| CheckDetail {
text: format!("{name}: {}", if *set { "set" } else { "not set" }),
})
.collect();
if status.all_set() {
CheckResult {
name: "GitHub App".to_string(),
status: CheckStatus::Pass,
summary: "fully configured".to_string(),
details,
remediation: None,
}
} else if status.none_set() {
CheckResult {
name: "GitHub App".to_string(),
status: CheckStatus::Warning,
summary: "not configured".to_string(),
details,
remediation: Some(
"Configure GitHub App in arc.toml and set env vars to enable GitHub integration"
.to_string(),
),
}
} else {
let missing: Vec<_> = fields
.iter()
.filter(|(_, set)| !set)
.map(|(name, _)| *name)
.collect();
CheckResult {
name: "GitHub App".to_string(),
status: CheckStatus::Error,
summary: "partially configured".to_string(),
details,
remediation: Some(format!("Missing: {}", missing.join(", "))),
}
}
}
pub struct ApiStatus {
pub base_url: String,
pub authentication_strategy: String,
}
pub fn check_api(status: &ApiStatus) -> CheckResult {
CheckResult {
name: "Arc API".to_string(),
status: CheckStatus::Pass,
summary: status.base_url.clone(),
details: vec![
CheckDetail {
text: format!("Base URL: {}", status.base_url),
},
CheckDetail {
text: format!("Authentication: {}", status.authentication_strategy),
},
],
remediation: None,
}
}
pub struct WebStatus {
pub url: String,
pub auth_provider: String,
pub allowed_usernames_count: usize,
}
pub fn check_web(status: &WebStatus) -> CheckResult {
CheckResult {
name: "Arc Web".to_string(),
status: CheckStatus::Pass,
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,
}
}
// ---------------------------------------------------------------------------
// Orchestrator (does real I/O)
// ---------------------------------------------------------------------------
async fn probe_daytona() -> Option<Result<(), String>> {
if std::env::var("DAYTONA_API_KEY").is_err() {
return None;
}
Some(
daytona_sdk::Client::new()
.await
.map(|_| ())
.map_err(|e| e.to_string()),
)
}
async fn probe_docker() -> Option<Result<(), String>> {
let docker = bollard::Docker::connect_with_local_defaults()
.map_err(|e| e.to_string())
.ok()?;
Some(docker.ping().await.map(|_| ()).map_err(|e| e.to_string()))
}
pub async fn run_doctor(verbose: bool) -> i32 {
let styles = Styles::detect_stdout();
// Gather state
let config_path = dirs::home_dir().map(|h| h.join(".arc").join("arc.toml"));
let config_exists = config_path
.as_ref()
.is_some_and(|p| p.exists());
let llm_statuses: Vec<(Provider, bool)> = Provider::ALL
.iter()
.map(|p| (*p, p.has_api_key()))
.collect();
let brave_key_set = std::env::var("BRAVE_SEARCH_API_KEY").is_ok();
let server_config = arc_api::server_config::load_server_config()
.unwrap_or_default();
let api_status = ApiStatus {
base_url: server_config.api.base_url.clone(),
authentication_strategy: match server_config.api.authentication_strategy {
ApiAuthenticationStrategy::Jwt => "jwt".to_string(),
ApiAuthenticationStrategy::InsecureDisabled => "insecure_disabled".to_string(),
},
};
let web_status = WebStatus {
url: server_config.web.url.clone(),
auth_provider: match server_config.web.auth.provider {
AuthProvider::Github => "github".to_string(),
AuthProvider::InsecureDisabled => "insecure_disabled".to_string(),
},
allowed_usernames_count: server_config.web.auth.allowed_usernames.len(),
};
let github_status = GithubAppStatus {
app_id: server_config.git.app_id.is_some(),
client_id: server_config.git.client_id.is_some(),
client_secret: std::env::var("GITHUB_APP_CLIENT_SECRET").is_ok(),
webhook_secret: std::env::var("GITHUB_APP_WEBHOOK_SECRET").is_ok(),
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,
};
// Run pure checks
let report = DoctorReport {
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_sandbox(&sandbox_status),
check_github_app(&github_status),
],
};
print!("{}", report.render(&styles, verbose));
if report.has_errors() { 1 } else { 0 }
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn pass_check(name: &str) -> CheckResult {
CheckResult {
name: name.to_string(),
status: CheckStatus::Pass,
summary: "all good".to_string(),
details: vec![CheckDetail {
text: "everything is fine".to_string(),
}],
remediation: None,
}
}
fn warning_check(name: &str) -> CheckResult {
CheckResult {
name: name.to_string(),
status: CheckStatus::Warning,
summary: "not configured".to_string(),
details: vec![CheckDetail {
text: "missing something".to_string(),
}],
remediation: Some("fix it".to_string()),
}
}
fn error_check(name: &str) -> CheckResult {
CheckResult {
name: name.to_string(),
status: CheckStatus::Error,
summary: "broken".to_string(),
details: vec![CheckDetail {
text: "something is wrong".to_string(),
}],
remediation: Some("repair it".to_string()),
}
}
// -- render: all-pass, no color --
#[test]
fn render_all_pass_no_color() {
let report = DoctorReport {
checks: vec![pass_check("Test")],
};
let out = report.render(&Styles::new(false), false);
assert!(out.contains("[✓]"));
assert!(out.contains("All checks passed."));
assert!(out.contains("Arc Doctor"));
}
// -- render: warning footer --
#[test]
fn render_warning_footer() {
let report = DoctorReport {
checks: vec![warning_check("Optional")],
};
let out = report.render(&Styles::new(false), false);
assert!(out.contains("[!]"));
assert!(out.contains("Doctor found issues in 1 category."));
assert!(out.contains("Warnings:"));
assert!(out.contains("fix it"));
}
// -- render: error footer --
#[test]
fn render_error_footer() {
let report = DoctorReport {
checks: vec![error_check("Broken")],
};
let out = report.render(&Styles::new(false), false);
assert!(out.contains("[✗]"));
assert!(out.contains("Errors:"));
assert!(out.contains("repair it"));
}
// -- render: verbose mode --
#[test]
fn render_verbose_shows_details() {
let report = DoctorReport {
checks: vec![pass_check("Verbose")],
};
let out = report.render(&Styles::new(false), true);
assert!(out.contains(""));
assert!(out.contains("everything is fine"));
}
#[test]
fn render_default_hides_details() {
let report = DoctorReport {
checks: vec![pass_check("Verbose")],
};
let out = report.render(&Styles::new(false), false);
assert!(!out.contains("everything is fine"));
}
// -- render: color --
#[test]
fn render_color_pass_green() {
let report = DoctorReport {
checks: vec![pass_check("Color")],
};
let out = report.render(&Styles::new(true), false);
assert!(out.contains("\x1b[32m")); // green
}
#[test]
fn render_color_warning_yellow() {
let report = DoctorReport {
checks: vec![warning_check("Color")],
};
let out = report.render(&Styles::new(true), false);
assert!(out.contains("\x1b[33m")); // yellow
}
#[test]
fn render_color_error_red() {
let report = DoctorReport {
checks: vec![error_check("Color")],
};
let out = report.render(&Styles::new(true), false);
assert!(out.contains("\x1b[31m")); // red
}
// -- has_errors / issue_count --
#[test]
fn has_errors_false_for_warnings_only() {
let report = DoctorReport {
checks: vec![pass_check("OK"), warning_check("Warn")],
};
assert!(!report.has_errors());
}
#[test]
fn has_errors_true_when_error_present() {
let report = DoctorReport {
checks: vec![pass_check("OK"), error_check("Broken")],
};
assert!(report.has_errors());
}
#[test]
fn issue_count_counts_warnings_and_errors() {
let report = DoctorReport {
checks: vec![
pass_check("OK"),
warning_check("Warn"),
error_check("Broken"),
],
};
assert_eq!(report.issue_count(), 2);
}
// -- check_config --
#[test]
fn check_config_pass_with_path() {
let result = check_config(Some(PathBuf::from("/home/user/.arc/arc.toml")));
assert_eq!(result.status, CheckStatus::Pass);
assert!(result.summary.contains(".arc/arc.toml"));
}
#[test]
fn check_config_warning_without_path() {
let result = check_config(None);
assert_eq!(result.status, CheckStatus::Warning);
assert!(result.remediation.is_some());
}
// -- check_llm_providers --
#[test]
fn check_llm_all_configured() {
let statuses: Vec<(Provider, bool)> =
Provider::ALL.iter().map(|p| (*p, true)).collect();
let result = check_llm_providers(&statuses);
assert_eq!(result.status, CheckStatus::Pass);
assert!(result.summary.contains("7 of 7"));
}
#[test]
fn check_llm_some_configured() {
let mut statuses: Vec<(Provider, bool)> =
Provider::ALL.iter().map(|p| (*p, false)).collect();
statuses[0].1 = true; // Anthropic
statuses[1].1 = true; // OpenAi
statuses[2].1 = true; // Gemini
statuses[3].1 = true; // Kimi
statuses[4].1 = true; // Zai
let result = check_llm_providers(&statuses);
assert_eq!(result.status, CheckStatus::Pass);
assert!(result.summary.contains("5 of 7"));
}
#[test]
fn check_llm_none_configured() {
let statuses: Vec<(Provider, bool)> =
Provider::ALL.iter().map(|p| (*p, false)).collect();
let result = check_llm_providers(&statuses);
assert_eq!(result.status, CheckStatus::Error);
assert!(result.summary.contains("0 of 7"));
}
// -- check_brave_search --
#[test]
fn check_brave_configured() {
let result = check_brave_search(true);
assert_eq!(result.status, CheckStatus::Pass);
}
#[test]
fn check_brave_not_configured() {
let result = check_brave_search(false);
assert_eq!(result.status, CheckStatus::Warning);
assert!(result.remediation.is_some());
}
// -- check_sandbox --
#[test]
fn check_sandbox_daytona_ok() {
let status = SandboxStatus {
daytona: Some(Ok(())),
docker: None,
};
let result = check_sandbox(&status);
assert_eq!(result.status, CheckStatus::Pass);
assert!(result.summary.contains("Daytona available"));
}
#[test]
fn check_sandbox_docker_ok() {
let status = SandboxStatus {
daytona: None,
docker: Some(Ok(())),
};
let result = check_sandbox(&status);
assert_eq!(result.status, CheckStatus::Pass);
assert!(result.summary.contains("Docker available"));
}
#[test]
fn check_sandbox_both_ok() {
let status = SandboxStatus {
daytona: Some(Ok(())),
docker: Some(Ok(())),
};
let result = check_sandbox(&status);
assert_eq!(result.status, CheckStatus::Pass);
assert!(result.summary.contains("Daytona + Docker available"));
}
#[test]
fn check_sandbox_both_unavailable() {
let status = SandboxStatus {
daytona: None,
docker: None,
};
let result = check_sandbox(&status);
assert_eq!(result.status, CheckStatus::Warning);
}
#[test]
fn check_sandbox_configured_but_broken() {
let status = SandboxStatus {
daytona: Some(Err("connection refused".to_string())),
docker: None,
};
let result = check_sandbox(&status);
assert_eq!(result.status, CheckStatus::Error);
}
// -- check_github_app --
#[test]
fn check_github_all_set() {
let status = GithubAppStatus {
app_id: true,
client_id: true,
client_secret: true,
webhook_secret: true,
private_key: true,
};
let result = check_github_app(&status);
assert_eq!(result.status, CheckStatus::Pass);
}
#[test]
fn check_github_none_set() {
let status = GithubAppStatus {
app_id: false,
client_id: false,
client_secret: false,
webhook_secret: false,
private_key: false,
};
let result = check_github_app(&status);
assert_eq!(result.status, CheckStatus::Warning);
}
#[test]
fn check_github_partial() {
let status = GithubAppStatus {
app_id: true,
client_id: true,
client_secret: false,
webhook_secret: false,
private_key: false,
};
let result = check_github_app(&status);
assert_eq!(result.status, CheckStatus::Error);
let rem = result.remediation.unwrap();
assert!(rem.contains("GITHUB_APP_CLIENT_SECRET"));
assert!(rem.contains("GITHUB_APP_WEBHOOK_SECRET"));
assert!(rem.contains("GITHUB_APP_PRIVATE_KEY"));
}
// -- check_api --
#[test]
fn check_api_shows_base_url() {
let status = ApiStatus {
base_url: "http://localhost:3000".to_string(),
authentication_strategy: "jwt".to_string(),
};
let result = check_api(&status);
assert_eq!(result.status, CheckStatus::Pass);
assert_eq!(result.summary, "http://localhost:3000");
}
#[test]
fn check_api_details_show_auth_strategy() {
let status = ApiStatus {
base_url: "https://api.example.com".to_string(),
authentication_strategy: "jwt".to_string(),
};
let result = check_api(&status);
assert!(result.details.iter().any(|d| d.text.contains("jwt")));
assert!(result
.details
.iter()
.any(|d| d.text.contains("https://api.example.com")));
}
// -- check_web --
#[test]
fn check_web_shows_url() {
let status = WebStatus {
url: "http://localhost:5173".to_string(),
auth_provider: "github".to_string(),
allowed_usernames_count: 0,
};
let result = check_web(&status);
assert_eq!(result.status, CheckStatus::Pass);
assert_eq!(result.summary, "http://localhost:5173");
}
#[test]
fn check_web_details_show_auth() {
let status = WebStatus {
url: "https://arc.example.com".to_string(),
auth_provider: "github".to_string(),
allowed_usernames_count: 3,
};
let result = check_web(&status);
assert!(result.details.iter().any(|d| d.text.contains("github")));
assert!(result
.details
.iter()
.any(|d| d.text.contains("https://arc.example.com")));
assert!(result
.details
.iter()
.any(|d| d.text.contains("Allowed usernames: 3")));
}
// -- render: multiple issues --
#[test]
fn render_multiple_issues_pluralizes() {
let report = DoctorReport {
checks: vec![warning_check("A"), error_check("B")],
};
let out = report.render(&Styles::new(false), false);
assert!(out.contains("2 categories"));
}
}

View file

@ -1,3 +1,4 @@
mod doctor;
mod logging;
use anyhow::Result;
@ -42,6 +43,12 @@ enum Command {
},
/// Start the HTTP API server
Serve(arc_api::serve::ServeArgs),
/// Check environment and integration health
Doctor {
/// Show detailed information for each check
#[arg(short, long)]
verbose: bool,
},
}
#[derive(Subcommand)]
@ -80,6 +87,7 @@ async fn main() -> Result<()> {
Command::Validate(_) => "validate",
Command::Models { .. } => "models",
Command::Serve(_) => "serve",
Command::Doctor { .. } => "doctor",
};
debug!(command = %command_name, "CLI command started");
@ -114,6 +122,10 @@ 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;
std::process::exit(exit_code);
}
}
Ok(())

View file

@ -498,6 +498,63 @@ fn dry_run_legacy_tool() {
.success();
}
// == Doctor ===================================================================
#[test]
fn doctor_runs_and_prints_header() {
arc()
.args(["--no-dotenv", "doctor"])
.env_clear()
.assert()
.stdout(predicate::str::contains("Arc Doctor"));
}
#[test]
fn doctor_verbose_runs_and_prints_header() {
arc()
.args(["--no-dotenv", "doctor", "-v"])
.env_clear()
.assert()
.stdout(predicate::str::contains("Arc Doctor"));
}
#[test]
fn doctor_no_color_when_no_color_set() {
arc()
.args(["--no-dotenv", "doctor"])
.env_clear()
.env("NO_COLOR", "1")
.assert()
.stdout(predicate::str::contains("\x1b[").not());
}
#[test]
fn doctor_checks_llm_providers() {
arc()
.args(["--no-dotenv", "doctor"])
.env_clear()
.assert()
.stdout(predicate::str::contains("LLM providers"));
}
#[test]
fn doctor_checks_arc_api() {
arc()
.args(["--no-dotenv", "doctor"])
.env_clear()
.assert()
.stdout(predicate::str::contains("Arc API"));
}
#[test]
fn doctor_checks_arc_web() {
arc()
.args(["--no-dotenv", "doctor"])
.env_clear()
.assert()
.stdout(predicate::str::contains("Arc Web"));
}
// == JSONL logging ============================================================
#[test]

View file

@ -35,6 +35,29 @@ impl Provider {
Provider::Inception,
];
/// Environment variable names that can provide the API key for this provider.
/// Gemini accepts either `GEMINI_API_KEY` or `GOOGLE_API_KEY`.
#[must_use]
pub fn api_key_env_vars(self) -> &'static [&'static str] {
match self {
Self::Anthropic => &["ANTHROPIC_API_KEY"],
Self::OpenAi => &["OPENAI_API_KEY"],
Self::Gemini => &["GEMINI_API_KEY", "GOOGLE_API_KEY"],
Self::Kimi => &["KIMI_API_KEY"],
Self::Zai => &["ZAI_API_KEY"],
Self::Minimax => &["MINIMAX_API_KEY"],
Self::Inception => &["INCEPTION_API_KEY"],
}
}
/// Returns `true` if at least one of the provider's API key env vars is set.
#[must_use]
pub fn has_api_key(self) -> bool {
self.api_key_env_vars()
.iter()
.any(|var| std::env::var(var).is_ok())
}
/// Stable lowercase string representation used in `Request.provider`,
/// adapter names, and other serialization boundaries.
#[must_use]
@ -212,4 +235,54 @@ mod tests {
fn inception_as_str() {
assert_eq!(Provider::Inception.as_str(), "inception");
}
#[test]
fn api_key_env_vars_anthropic() {
assert_eq!(
Provider::Anthropic.api_key_env_vars(),
&["ANTHROPIC_API_KEY"]
);
}
#[test]
fn api_key_env_vars_openai() {
assert_eq!(Provider::OpenAi.api_key_env_vars(), &["OPENAI_API_KEY"]);
}
#[test]
fn api_key_env_vars_gemini_has_two() {
let vars = Provider::Gemini.api_key_env_vars();
assert_eq!(vars.len(), 2);
assert_eq!(vars, &["GEMINI_API_KEY", "GOOGLE_API_KEY"]);
}
#[test]
fn api_key_env_vars_kimi() {
assert_eq!(Provider::Kimi.api_key_env_vars(), &["KIMI_API_KEY"]);
}
#[test]
fn api_key_env_vars_zai() {
assert_eq!(Provider::Zai.api_key_env_vars(), &["ZAI_API_KEY"]);
}
#[test]
fn api_key_env_vars_minimax() {
assert_eq!(Provider::Minimax.api_key_env_vars(), &["MINIMAX_API_KEY"]);
}
#[test]
fn api_key_env_vars_inception() {
assert_eq!(
Provider::Inception.api_key_env_vars(),
&["INCEPTION_API_KEY"]
);
}
#[test]
fn every_provider_has_at_least_one_env_var() {
assert!(Provider::ALL
.iter()
.all(|p| !p.api_key_env_vars().is_empty()));
}
}