diff --git a/crates/cli/README.md b/crates/cli/README.md index 443d3003d..ea45bbb6b 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -104,6 +104,13 @@ After setup, inspect local readiness: nemo-relay doctor ``` +To troubleshoot a specific configuration file, pass it explicitly. Doctor reports a missing or +invalid file in its configuration checks instead of stopping before diagnostics: + +```bash +nemo-relay --config /path/to/config.toml doctor +``` + Run a supported agent through the gateway: ```bash diff --git a/crates/cli/src/commands/diagnostics.rs b/crates/cli/src/commands/diagnostics.rs index b32fa7121..18e557c2e 100644 --- a/crates/cli/src/commands/diagnostics.rs +++ b/crates/cli/src/commands/diagnostics.rs @@ -29,11 +29,20 @@ pub(crate) struct AgentsCommand { pub(crate) json: bool, } -pub(super) async fn execute(command: DoctorCommand) -> Result { +pub(super) async fn execute( + command: DoctorCommand, + server: &super::serve::ServerArgs, +) -> Result { if let Some(plugin) = command.plugin { return execute_plugin_doctor(plugin, command.install_dir, command.json); } - crate::diagnostics::run_doctor(command.agent.map(Into::into), command.json).await + let gateway_overrides = server.to_runtime(); + crate::diagnostics::run_doctor( + command.agent.map(Into::into), + command.json, + &gateway_overrides, + ) + .await } fn execute_plugin_doctor( diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 4a2879b69..3a8ea4678 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -80,8 +80,24 @@ async fn dispatch(bootstrap_shutdown_token: Option) -> Result cli.server.config.as_deref(), } }; - let config = cli.logging.resolve(explicit_config, user_only)?; + let mut logging_fallback_error = None; + let config = match cli.logging.resolve(explicit_config, user_only) { + Ok(config) => config, + Err(error) if matches!(cli.command.as_ref(), Some(Command::Doctor(_))) => { + logging_fallback_error = Some(error); + nemo_relay::logging::LoggingConfig::default() + } + Err(error) => return Err(error), + }; let runtime = nemo_relay::logging::LoggingRuntime::configure(config)?; + if let Some(error) = logging_fallback_error { + log::warn!( + target: "nemo_relay.cli", + event = "doctor_logging_fallback", + error_kind = error.log_kind(); + "Doctor fell back to default logging after resolution failure" + ); + } Some(runtime) } else { None @@ -147,7 +163,7 @@ async fn run_command(command: Command, server: &ServerArgs) -> Result configure::execute(command).await, Command::Plugins(command) => plugins::execute(command, server), Command::ModelPricing(command) => model_pricing::execute(command), - Command::Doctor(command) => diagnostics::execute(command).await, + Command::Doctor(command) => diagnostics::execute(command, server).await, Command::Agents(command) => runtime_diagnostics::run_agents(command.json).await, Command::Completions(command) => completions::execute(command), } @@ -195,7 +211,7 @@ async fn run_default( .await?; Ok(ExitCode::SUCCESS) } else if runtime_configuration::any_config_file_exists() { - runtime_diagnostics::run_doctor(None, false).await + runtime_diagnostics::run_doctor(None, false, &runtime_args).await } else { configure::run(None).await?; Ok(ExitCode::SUCCESS) diff --git a/crates/cli/src/configuration/mod.rs b/crates/cli/src/configuration/mod.rs index e8c2bf5ff..51f7b8d0d 100644 --- a/crates/cli/src/configuration/mod.rs +++ b/crates/cli/src/configuration/mod.rs @@ -1108,11 +1108,6 @@ fn user_config_scope() -> bool { std::env::var("NEMO_RELAY_CONFIG_SCOPE").ok().as_deref() == Some("user") } -/// Returns the implicit `plugins.toml` discovery paths used by the gateway and doctor. -pub(crate) fn default_plugin_config_paths() -> Vec { - plugin_config_paths(None, None) -} - fn implicit_plugin_config_paths( cwd: Option<&std::path::Path>, user_config_dir: Option, @@ -1288,10 +1283,24 @@ fn load_plugin_toml_config_scoped( )) } +/// Returns the plugin configuration paths selected by the same rules as runtime resolution. +/// +/// Diagnostics use this so an explicit gateway configuration reports only its sibling +/// `plugins.toml`, rather than unrelated discovered plugin configuration. +pub(crate) fn diagnostic_plugin_config_paths( + explicit: Option<&PathBuf>, + plugin_config_path: Option<&PathBuf>, +) -> Vec { + plugin_config_paths(explicit, plugin_config_path) +} + /// Returns the physical `plugins.toml` files that contribute effective runtime or dynamic -/// plugin configuration under the default discovery rules. -pub(crate) fn effective_plugin_toml_sources() -> Result, CliError> { - let Some(config) = load_plugin_toml_config(None, None)? else { +/// plugin configuration for the selected gateway configuration scope. +pub(crate) fn effective_plugin_toml_sources( + explicit: Option<&PathBuf>, + plugin_config_path: Option<&PathBuf>, +) -> Result, CliError> { + let Some(config) = load_plugin_toml_config(explicit, plugin_config_path)? else { return Ok(Vec::new()); }; let mut sources = config.contributing_sources; diff --git a/crates/cli/src/diagnostics/mod.rs b/crates/cli/src/diagnostics/mod.rs index 0a3e1560d..cbe400fa8 100644 --- a/crates/cli/src/diagnostics/mod.rs +++ b/crates/cli/src/diagnostics/mod.rs @@ -35,7 +35,7 @@ use uuid::Uuid; use crate::agents::CodingAgent; use crate::configuration::{ AgentConfigs, DynamicPluginHostConfigStatus, GatewayConfig, ResolvedConfig, - default_plugin_config_paths, effective_plugin_toml_sources, resolve_server_config, + diagnostic_plugin_config_paths, effective_plugin_toml_sources, resolve_server_config, }; use crate::error::CliError; use crate::server::{GatewayOverrides, register_and_validate_plugin_components}; @@ -54,8 +54,9 @@ struct PluginConfigurationDiagnostics { /// the first missing directory. pub(crate) async fn collect_report( target_agent: Option, + gateway_overrides: &GatewayOverrides, ) -> Result { - let (resolved, resolution) = match resolve_server_config(&GatewayOverrides::default()) { + let (resolved, resolution) = match resolve_server_config(gateway_overrides) { Ok(resolved) => ( resolved, Check { @@ -77,7 +78,9 @@ pub(crate) async fn collect_report( Check { name: "Resolution", status: Status::Fail, - details: format!("could not resolve merged config: {err}"), + details: format!( + "could not resolve merged config: {err}; repair or recreate the named configuration file, or rerun the setup or installer that manages it" + ), }, ) } @@ -85,7 +88,10 @@ pub(crate) async fn collect_report( let cwd = std::env::current_dir().ok(); let home = home_dir(); let configured_agents = configured_agent_names(&resolved.agents); - let (plugin_sources, plugin_error) = match effective_plugin_toml_sources() { + let (plugin_sources, plugin_error) = match effective_plugin_toml_sources( + gateway_overrides.config.as_ref(), + gateway_overrides.plugin_config_path.as_ref(), + ) { Ok(sources) => (sources, None), Err(error) => { log::warn!( @@ -114,6 +120,7 @@ pub(crate) async fn collect_report( configuration: collect_configuration( cwd.as_deref(), home.as_deref(), + gateway_overrides, resolution, configured_agents, &resolved.dynamic_plugins, @@ -129,11 +136,13 @@ pub(crate) async fn collect_report( fn collect_configuration( cwd: Option<&Path>, home: Option<&Path>, + gateway_overrides: &GatewayOverrides, resolution: Check, configured_agents: Vec, dynamic_plugins: &[crate::configuration::ResolvedDynamicPluginConfig], plugin_diagnostics: &PluginConfigurationDiagnostics, ) -> ConfigurationInfo { + let explicit_config = gateway_overrides.config.is_some(); let workspace_path = cwd .map(|p| p.join(".nemo-relay").join("config.toml")) .unwrap_or_else(|| PathBuf::from(".nemo-relay/config.toml")); @@ -144,21 +153,39 @@ fn collect_configuration( .or_else(|| home.map(|h| h.join(".config").join("nemo-relay").join("config.toml"))) .unwrap_or_else(|| PathBuf::from("~/.config/nemo-relay/config.toml")); let system_path = PathBuf::from("/etc/nemo-relay/config.toml"); + let workspace = gateway_overrides + .config + .as_deref() + .map_or_else(|| layer_status(&workspace_path), layer_status); + let global = if explicit_config { + ignored_layer_status(&global_path) + } else { + layer_status(&global_path) + }; + let system = if explicit_config { + ignored_layer_status(&system_path) + } else { + layer_status(&system_path) + }; ConfigurationInfo { - workspace: layer_status(&workspace_path), - global: layer_status(&global_path), - system: layer_status(&system_path), - plugin_configs: default_plugin_config_paths() - .iter() - .map(|path| { - plugin_layer_status( - path, - &plugin_diagnostics.sources, - plugin_diagnostics.error.as_deref(), - ) - }) - .collect(), + explicit_config, + workspace, + global, + system, + plugin_configs: diagnostic_plugin_config_paths( + gateway_overrides.config.as_ref(), + gateway_overrides.plugin_config_path.as_ref(), + ) + .iter() + .map(|path| { + plugin_layer_status( + path, + &plugin_diagnostics.sources, + plugin_diagnostics.error.as_deref(), + ) + }) + .collect(), plugin_resolution: plugin_diagnostics.resolution.clone(), resolution, // `default_agent` is reserved in the design for Phase 2 dispatch; not currently parsed @@ -285,6 +312,15 @@ fn layer_status(path: &Path) -> ConfigLayer { } } +fn ignored_layer_status(path: &Path) -> ConfigLayer { + ConfigLayer { + path: path.to_path_buf(), + status: Status::Info, + active: false, + details: "not selected because --config scopes configuration".into(), + } +} + fn plugin_layer_status( path: &Path, contributing_paths: &[PathBuf], @@ -1183,8 +1219,9 @@ pub(crate) fn format_agents_json(agents: &[AgentInfo]) -> Result, json: bool, + gateway_overrides: &GatewayOverrides, ) -> Result { - let report = collect_report(target_agent).await?; + let report = collect_report(target_agent, gateway_overrides).await?; log::info!( target: "nemo_relay.diagnostics", event = "diagnostics_completed", diff --git a/crates/cli/src/diagnostics/model.rs b/crates/cli/src/diagnostics/model.rs index 82250d3d4..4d27e14f1 100644 --- a/crates/cli/src/diagnostics/model.rs +++ b/crates/cli/src/diagnostics/model.rs @@ -50,6 +50,8 @@ pub(crate) struct EnvironmentInfo { #[derive(Debug, Clone, Serialize)] pub(crate) struct ConfigurationInfo { + #[serde(skip)] + pub explicit_config: bool, pub workspace: ConfigLayer, pub global: ConfigLayer, pub system: ConfigLayer, diff --git a/crates/cli/src/diagnostics/render.rs b/crates/cli/src/diagnostics/render.rs index 49190b710..25c3c5366 100644 --- a/crates/cli/src/diagnostics/render.rs +++ b/crates/cli/src/diagnostics/render.rs @@ -86,8 +86,13 @@ pub(super) fn format_human_environment(out: &mut String, report: &DoctorReport) pub(super) fn format_human_configuration(out: &mut String, report: &DoctorReport) { out.push_str(" Configuration\n"); + let workspace_label = if report.configuration.explicit_config { + "Explicit" + } else { + "Workspace" + }; out.push_str(&format!( - " Workspace {}\n", + " {workspace_label:<11}{}\n", format_layer(&report.configuration.workspace) )); out.push_str(&format!( diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index 0fa1e1dd3..97571dafb 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -3137,6 +3137,205 @@ fn cli_bare_invocation_reports_invalid_config_resolution() { assert!(stdout.contains("invalid plugin TOML")); } +#[test] +fn cli_doctor_reports_a_missing_explicit_config() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + let cwd = temp.path().join("workdir"); + let config = temp.path().join("missing/config.toml"); + std::fs::create_dir_all(&xdg).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + + let output = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", &xdg) + .env("HOME", temp.path()) + .args(["--config", config.to_str().unwrap(), "doctor"]) + .output() + .unwrap(); + + assert!(!output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stdout.contains("Configuration")); + assert!(stdout.contains("Resolution")); + assert!(stdout.contains(config.to_str().unwrap())); + assert!(stdout.contains("repair or recreate")); + assert!(stdout.contains("Some checks FAILED")); + assert!(!stderr.contains("explicit configuration file")); +} + +#[test] +fn cli_doctor_json_reports_a_missing_explicit_config() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + let cwd = temp.path().join("workdir"); + let config = temp.path().join("missing/config.toml"); + std::fs::create_dir_all(&xdg).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + + let output = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", &xdg) + .env("HOME", temp.path()) + .args(["--config", config.to_str().unwrap(), "doctor", "--json"]) + .output() + .unwrap(); + + assert!(!output.status.success()); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let resolution = &report["configuration"]["resolution"]; + assert_eq!(resolution["status"], "fail"); + assert!( + resolution["details"] + .as_str() + .unwrap() + .contains(config.to_str().unwrap()) + ); +} + +#[test] +fn cli_doctor_explicit_config_ignores_invalid_workspace_runtime_config() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + let cwd = temp.path().join("workdir"); + let config = temp.path().join("explicit/config.toml"); + std::fs::create_dir_all(&xdg).unwrap(); + std::fs::create_dir_all(cwd.join(".nemo-relay")).unwrap(); + std::fs::create_dir_all(config.parent().unwrap()).unwrap(); + std::fs::write(cwd.join(".nemo-relay/config.toml"), "[upstream\n").unwrap(); + std::fs::write(&config, "[upstream]\n").unwrap(); + + let output = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", &xdg) + .env("HOME", temp.path()) + .args(["--config", config.to_str().unwrap(), "doctor", "--json"]) + .output() + .unwrap(); + + assert!( + output.status.success(), + "explicit config should ignore invalid workspace config: stderr={}", + String::from_utf8_lossy(&output.stderr) + ); + let report: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!( + report["configuration"]["workspace"]["path"], + config.display().to_string() + ); + assert_eq!(report["configuration"]["workspace"]["status"], "pass"); + assert_eq!(report["configuration"]["workspace"]["active"], true); + assert_eq!(report["configuration"]["global"]["status"], "info"); + assert_eq!(report["configuration"]["global"]["active"], false); + assert!( + report["configuration"]["global"]["details"] + .as_str() + .unwrap() + .contains("--config scopes configuration") + ); + + let human_output = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", &xdg) + .env("HOME", temp.path()) + .args(["--config", config.to_str().unwrap(), "doctor"]) + .output() + .unwrap(); + assert!(human_output.status.success()); + let stdout = String::from_utf8_lossy(&human_output.stdout); + assert!(stdout.contains("Explicit")); + assert!(stdout.contains(config.to_str().unwrap())); + assert!(stdout.contains("not selected because --config scopes configuration")); +} + +#[test] +fn cli_doctor_reports_invalid_explicit_config_and_sibling_plugins() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + let cwd = temp.path().join("workdir"); + let config_dir = temp.path().join("explicit"); + let config = config_dir.join("config.toml"); + std::fs::create_dir_all(&xdg).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::create_dir_all(cwd.join(".nemo-relay")).unwrap(); + std::fs::write(cwd.join(".nemo-relay/plugins.toml"), "components = [\n").unwrap(); + + std::fs::write(&config, "[upstream\n").unwrap(); + let invalid_config = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", &xdg) + .env("HOME", temp.path()) + .args(["--config", config.to_str().unwrap(), "doctor"]) + .output() + .unwrap(); + assert!(!invalid_config.status.success()); + assert!(String::from_utf8_lossy(&invalid_config.stdout).contains("invalid TOML")); + + std::fs::write(&config, "[upstream]\n").unwrap(); + std::fs::write(config_dir.join("plugins.toml"), "components = [\n").unwrap(); + let invalid_plugins = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", &xdg) + .env("HOME", temp.path()) + .args(["--config", config.to_str().unwrap(), "doctor"]) + .output() + .unwrap(); + assert!(!invalid_plugins.status.success()); + let stdout = String::from_utf8_lossy(&invalid_plugins.stdout); + assert!(stdout.contains("invalid plugin TOML")); + assert!(stdout.contains(&config_dir.join("plugins.toml").display().to_string())); + + std::fs::write( + config_dir.join("plugins.toml"), + "version = 1\ncomponents = []\n", + ) + .unwrap(); + let valid_config = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", &xdg) + .env("HOME", temp.path()) + .args(["--config", config.to_str().unwrap(), "doctor", "--json"]) + .output() + .unwrap(); + let report: serde_json::Value = serde_json::from_slice(&valid_config.stdout).unwrap(); + assert_eq!(report["configuration"]["resolution"]["status"], "pass"); + assert_eq!( + report["configuration"]["plugin_configs"][0]["path"], + config_dir.join("plugins.toml").display().to_string() + ); +} + +#[test] +fn cli_plugin_doctor_is_not_preempted_by_a_missing_runtime_config() { + let temp = tempfile::tempdir().unwrap(); + let xdg = temp.path().join("xdg"); + let cwd = temp.path().join("workdir"); + let config = temp.path().join("missing/config.toml"); + std::fs::create_dir_all(&xdg).unwrap(); + std::fs::create_dir_all(&cwd).unwrap(); + + let output = Command::new(gateway_bin()) + .current_dir(&cwd) + .env("XDG_CONFIG_HOME", &xdg) + .env("HOME", temp.path()) + .args([ + "--config", + config.to_str().unwrap(), + "doctor", + "--plugin", + "all", + ]) + .output() + .unwrap(); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("no installed Claude Code, Codex, or Hermes integration state")); + assert!(!stderr.contains("explicit configuration file")); +} + #[test] fn cli_run_dry_run_resolves_config_and_command() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/shared/config_tests.rs b/crates/cli/tests/coverage/shared/config_tests.rs index 103e3ff54..c5acb3dd3 100644 --- a/crates/cli/tests/coverage/shared/config_tests.rs +++ b/crates/cli/tests/coverage/shared/config_tests.rs @@ -196,7 +196,7 @@ fn effective_plugin_toml_sources_reports_empty_and_sorted_contributors() { let _scope = PluginConfigDiscoveryScope::enter(&project, &xdg); assert_eq!( - effective_plugin_toml_sources().unwrap(), + effective_plugin_toml_sources(None, None).unwrap(), Vec::::new() ); @@ -207,7 +207,7 @@ fn effective_plugin_toml_sources_reports_empty_and_sorted_contributors() { std::fs::write(&project_plugins, "version = 1\ncomponents = []\n").unwrap(); std::fs::write(&user_plugins, "version = 1\ncomponents = []\n").unwrap(); - let sources = effective_plugin_toml_sources().unwrap(); + let sources = effective_plugin_toml_sources(None, None).unwrap(); assert!(sources.is_sorted()); assert!(sources.windows(2).all(|paths| paths[0] != paths[1])); @@ -224,6 +224,30 @@ fn effective_plugin_toml_sources_reports_empty_and_sorted_contributors() { assert_eq!(actual, expected); } +#[test] +fn effective_plugin_toml_sources_scope_to_an_explicit_config_sibling() { + let temp = tempfile::tempdir().unwrap(); + let project = temp.path().join("project"); + let xdg = temp.path().join("xdg"); + let explicit_dir = temp.path().join("explicit"); + std::fs::create_dir_all(&project).unwrap(); + std::fs::create_dir_all(&xdg).unwrap(); + std::fs::create_dir_all(&explicit_dir).unwrap(); + let _scope = PluginConfigDiscoveryScope::enter(&project, &xdg); + + let explicit_config = explicit_dir.join("config.toml"); + let explicit_plugins = explicit_dir.join("plugins.toml"); + std::fs::write(&explicit_config, "").unwrap(); + std::fs::write(&explicit_plugins, "version = 1\ncomponents = []\n").unwrap(); + std::fs::create_dir_all(project.join(".nemo-relay")).unwrap(); + std::fs::write(project.join(".nemo-relay/plugins.toml"), "components = [\n").unwrap(); + + assert_eq!( + effective_plugin_toml_sources(Some(&explicit_config), None).unwrap(), + vec![explicit_plugins] + ); +} + fn isolated_config_path(temp: &tempfile::TempDir) -> std::path::PathBuf { temp.path().join("config.toml") } diff --git a/crates/cli/tests/coverage/shared/doctor_tests.rs b/crates/cli/tests/coverage/shared/doctor_tests.rs index f5a2b12f1..9cd5718d5 100644 --- a/crates/cli/tests/coverage/shared/doctor_tests.rs +++ b/crates/cli/tests/coverage/shared/doctor_tests.rs @@ -8,6 +8,7 @@ use std::path::PathBuf; use std::sync::{Arc, Mutex}; use crate::configuration::ResolvedDynamicPluginConfig; +use crate::server::GatewayOverrides; use crate::test_support::{EnvScope, accept_bounded}; fn start_doctor_http_capture_server() -> (String, Arc>, std::thread::JoinHandle<()>) { @@ -55,6 +56,7 @@ fn empty_report() -> DoctorReport { shell: Some("zsh".into()), }, configuration: ConfigurationInfo { + explicit_config: false, workspace: ConfigLayer { path: PathBuf::from("/x/.nemo-relay/config.toml"), status: Status::Info, @@ -565,6 +567,7 @@ fn collect_configuration_uses_xdg_global_path_and_renders_resolution_branches() let configuration = collect_configuration( Some(&workspace), Some(&home), + &GatewayOverrides::default(), Check { name: "Resolution", status: Status::Warn, @@ -839,6 +842,7 @@ fn configuration_and_path_helpers_cover_direct_paths_and_fallbacks() { let info = collect_configuration( Some(&workspace), Some(&home), + &GatewayOverrides::default(), Check { name: "Resolution", status: Status::Pass,