From 291362cc472ab4c720c714f3f18d9bad48eed17d Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Wed, 29 Jul 2026 11:42:02 -0600 Subject: [PATCH 1/3] fix: preserve explicit plugin config targets Signed-off-by: Bryan Bednarski --- crates/cli/README.md | 5 +++ crates/cli/src/commands/configure/mod.rs | 13 +++++- crates/cli/src/commands/configure/model.rs | 29 +++++++++---- crates/cli/src/commands/configure/wizard.rs | 43 +++++++++++-------- crates/cli/src/commands/mod.rs | 4 +- crates/cli/src/commands/plugins/mod.rs | 13 +++++- .../cli/src/commands/plugins/subcommands.rs | 14 +++++- crates/cli/src/commands/run.rs | 2 +- crates/cli/src/configuration/mod.rs | 25 +++++++---- crates/cli/src/plugins/mod.rs | 16 +++++-- crates/cli/src/plugins/types.rs | 2 + .../cli/tests/coverage/commands/main_tests.rs | 41 +++++++++++++++++- .../cli/tests/coverage/shared/config_tests.rs | 17 ++++++++ .../tests/coverage/shared/plugins_tests.rs | 13 ++++++ .../cli/tests/coverage/shared/setup_tests.rs | 23 +++++++++- .../plugin-configuration-files.mdx | 7 +++ docs/nemo-relay-cli/basic-usage.mdx | 4 +- 17 files changed, 220 insertions(+), 51 deletions(-) diff --git a/crates/cli/README.md b/crates/cli/README.md index 2e59a94a1..a4a4001a2 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -180,6 +180,11 @@ plugin config with: nemo-relay plugins edit ``` +When the top-level CLI receives `--plugin-config-path`, the editor uses that +exact file. Otherwise, `--config path/to/config.toml` makes the editor use the +sibling `path/to/plugins.toml`, matching runtime selection. An explicit +`--user`, `--project`, or `--global` editor flag overrides the inherited target. + The top-level editor menu contains one entry per supported built-in, followed by the dynamic plugin references in the selected physical `plugins.toml`. Dynamic plugins with a manifest-declared JSON Schema provide structured field controls. diff --git a/crates/cli/src/commands/configure/mod.rs b/crates/cli/src/commands/configure/mod.rs index dc915c618..23cb5b5ea 100644 --- a/crates/cli/src/commands/configure/mod.rs +++ b/crates/cli/src/commands/configure/mod.rs @@ -6,6 +6,7 @@ use std::process::ExitCode; use clap::{ArgGroup, Args, Subcommand}; use super::root::AgentArg; +use super::serve::ServerArgs; use crate::error::CliError; mod editor; @@ -54,7 +55,10 @@ pub(crate) struct ConfigEditCommand { pub(crate) global: bool, } -pub(super) async fn execute(command: ConfigCommand) -> Result { +pub(super) async fn execute( + command: ConfigCommand, + server: &ServerArgs, +) -> Result { if let Some(ConfigSubcommand::Edit(edit)) = command.command.as_ref() { editor::edit(edit.clone())?; return Ok(ExitCode::SUCCESS); @@ -63,7 +67,12 @@ pub(super) async fn execute(command: ConfigCommand) -> Result PluginsEditRequest { +/// An explicit plugin path follows the runtime contract and wins over the wizard scope. Without +/// one, `Project` and `Both` configure the project `plugins.toml`, while `Global` configures the +/// user `plugins.toml`. +pub(crate) fn plugins_edit_command_for_scope( + scope: ConfigScope, + explicit_path: Option, +) -> PluginsEditRequest { let scope = match scope { ConfigScope::Project | ConfigScope::Both => ConfigurationScope::Project, ConfigScope::Global => ConfigurationScope::User, }; - PluginsEditRequest { scope } + PluginsEditRequest { + scope, + explicit_path, + } } /// Returns the exact command a user runs to resume plugin setup after skipping the continuation. -pub(crate) fn plugins_resume_command(scope: ConfigScope) -> &'static str { +pub(crate) fn plugins_resume_command(scope: ConfigScope, explicit_path: Option<&Path>) -> String { + if let Some(path) = explicit_path { + let path = crate::process::shell_quote_arg_for_platform( + &path.display().to_string(), + cfg!(windows), + ); + return format!("nemo-relay --plugin-config-path {path} plugins edit"); + } match scope { - ConfigScope::Project | ConfigScope::Both => "nemo-relay plugins edit --project", - ConfigScope::Global => "nemo-relay plugins edit", + ConfigScope::Project | ConfigScope::Both => "nemo-relay plugins edit --project".into(), + ConfigScope::Global => "nemo-relay plugins edit".into(), } } diff --git a/crates/cli/src/commands/configure/wizard.rs b/crates/cli/src/commands/configure/wizard.rs index b0b06c21b..7a26335d6 100644 --- a/crates/cli/src/commands/configure/wizard.rs +++ b/crates/cli/src/commands/configure/wizard.rs @@ -94,7 +94,10 @@ pub(crate) fn prompt_user( /// `agent_hint` carries the agent the user typed on the easy path (`nemo-relay claude`); when /// `Some`, the agent multi-select is skipped because intent is already declared. `None` from /// `nemo-relay config` asks the full set so users can configure multiple agents at once. -pub(crate) async fn run(agent_hint: Option) -> Result<(), CliError> { +pub(crate) async fn run( + agent_hint: Option, + explicit_plugin_path: Option, +) -> Result<(), CliError> { let detected = detect_installed_agents(); let answers = prompt_user(&detected, agent_hint)?; @@ -116,18 +119,22 @@ pub(crate) async fn run(agent_hint: Option) -> Result<(), CliError> println!(" {}", path.display()); } println!(); - continue_to_plugins(answers.scope) + continue_to_plugins(answers.scope, explicit_plugin_path) } /// After the base config is saved, offers to continue into plugin configuration in-process. /// -/// Prompts once. On acceptance it runs the existing plugin editor targeting the scope derived -/// from the base setup (project for `Project`/`Both`, user for `Global`). On decline it reports -/// that the base config was saved, that plugin setup was skipped, and prints the command to -/// resume later. Prompt interruption is treated as a skip; other prompt or editor failures -/// surface an error that makes clear the base config remains saved. The saved `config.toml` -/// is never rolled back here. -fn continue_to_plugins(scope: ConfigScope) -> Result<(), CliError> { +/// Prompts once. On acceptance it runs the existing plugin editor targeting an explicit runtime +/// plugin path when present, otherwise the scope derived from base setup (project for +/// `Project`/`Both`, user for `Global`). On decline it reports that the base config was saved, +/// that plugin setup was skipped, and prints the command to resume later. Prompt interruption is +/// treated as a skip; other prompt or editor failures surface an error that makes clear the base +/// config remains saved. The saved `config.toml` is never rolled back here. +fn continue_to_plugins( + scope: ConfigScope, + explicit_plugin_path: Option, +) -> Result<(), CliError> { + let resume_command = plugins_resume_command(scope, explicit_plugin_path.as_deref()); let proceed = match Confirm::with_theme(&ColorfulTheme::default()) .with_prompt("Configure Relay plugins now?") .default(true) @@ -135,22 +142,23 @@ fn continue_to_plugins(scope: ConfigScope) -> Result<(), CliError> { { Ok(proceed) => proceed, Err(error) if plugin_prompt_was_interrupted(&error) => { - print_plugins_skipped(scope); + print_plugins_skipped(&resume_command); return Ok(()); } Err(error) => { return Err(CliError::Config(format!( "plugin setup did not complete; base configuration remains saved. \ Resume with `{}`. Cause: {error}", - plugins_resume_command(scope) + resume_command ))); } }; if !proceed { - print_plugins_skipped(scope); + print_plugins_skipped(&resume_command); return Ok(()); } - crate::plugins::edit(plugins_edit_command_for_scope(scope)).map_err(|error| { + let result = crate::plugins::edit(plugins_edit_command_for_scope(scope, explicit_plugin_path)); + result.map_err(|error| { let cause = match error { CliError::Config(message) => message, other => other.to_string(), @@ -158,7 +166,7 @@ fn continue_to_plugins(scope: ConfigScope) -> Result<(), CliError> { CliError::Config(format!( "plugin setup did not complete; base configuration remains saved. \ Resume with `{}`. Cause: {cause}", - plugins_resume_command(scope) + resume_command )) }) } @@ -174,13 +182,10 @@ fn plugin_prompt_was_interrupted(error: &dialoguer::Error) -> bool { ) } -fn print_plugins_skipped(scope: ConfigScope) { +fn print_plugins_skipped(resume_command: &str) { println!(); println!(" Base configuration saved. Plugin configuration skipped."); - println!( - " Configure plugins later with `{}`.", - plugins_resume_command(scope) - ); + println!(" Configure plugins later with `{resume_command}`."); println!(); } diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 3a8ea4678..635d5bdd7 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -160,7 +160,7 @@ async fn run_command(command: Command, server: &ServerArgs) -> Result run::easy_path(CodingAgent::Codex, command, server).await, Command::Hermes(command) => run::easy_path(CodingAgent::Hermes, command, server).await, Command::Mcp => mcp::execute(server).await, - Command::Config(command) => configure::execute(command).await, + Command::Config(command) => configure::execute(command, server).await, Command::Plugins(command) => plugins::execute(command, server), Command::ModelPricing(command) => model_pricing::execute(command), Command::Doctor(command) => diagnostics::execute(command, server).await, @@ -213,7 +213,7 @@ async fn run_default( } else if runtime_configuration::any_config_file_exists() { runtime_diagnostics::run_doctor(None, false, &runtime_args).await } else { - configure::run(None).await?; + configure::run(None, None).await?; Ok(ExitCode::SUCCESS) } } diff --git a/crates/cli/src/commands/plugins/mod.rs b/crates/cli/src/commands/plugins/mod.rs index c52d298d9..12a97b7c0 100644 --- a/crates/cli/src/commands/plugins/mod.rs +++ b/crates/cli/src/commands/plugins/mod.rs @@ -14,6 +14,17 @@ use std::process::ExitCode; use super::serve::ServerArgs; use crate::error::CliError; +pub(super) fn edit_request( + command: subcommands::PluginsEditCommand, + server: &crate::server::GatewayOverrides, +) -> crate::plugins::PluginsEditRequest { + let explicit_path = crate::configuration::explicit_plugin_config_path( + server.config.as_ref(), + server.plugin_config_path.as_ref(), + ); + command.into_runtime(explicit_path) +} + pub(super) fn execute(command: PluginsCommand, server: &ServerArgs) -> Result { let server = server.to_runtime(); let json_context = command @@ -23,7 +34,7 @@ pub(super) fn execute(command: PluginsCommand, server: &ServerArgs) -> Result { - crate::plugins::edit(command.into_runtime()) + crate::plugins::edit(edit_request(command, &server)) } subcommands::PluginsSubcommand::Add(command) => { crate::plugins::lifecycle::add(command.into_runtime(), &server) diff --git a/crates/cli/src/commands/plugins/subcommands.rs b/crates/cli/src/commands/plugins/subcommands.rs index 479fc3776..77d9ec291 100644 --- a/crates/cli/src/commands/plugins/subcommands.rs +++ b/crates/cli/src/commands/plugins/subcommands.rs @@ -164,9 +164,19 @@ impl From for crate::plugins::ConfigurationScope { } impl PluginsEditCommand { - pub(crate) fn into_runtime(self) -> crate::plugins::PluginsEditRequest { + pub(crate) fn into_runtime( + self, + explicit_path: Option, + ) -> crate::plugins::PluginsEditRequest { + let scope = self.scope.into(); + let explicit_path = if matches!(scope, crate::plugins::ConfigurationScope::Default) { + explicit_path + } else { + None + }; crate::plugins::PluginsEditRequest { - scope: self.scope.into(), + explicit_path, + scope, } } } diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs index 8edbca27b..a4f3ee852 100644 --- a/crates/cli/src/commands/run.rs +++ b/crates/cli/src/commands/run.rs @@ -76,7 +76,7 @@ pub(super) async fn easy_path( let explicit_config = inherited.config.as_deref(); let needs_setup = explicit_config.is_none() && !crate::configuration::any_config_file_exists(); if needs_setup { - super::configure::run(Some(agent)).await?; + super::configure::run(Some(agent), None).await?; } let runtime = crate::process::RunOverrides { agent: Some(agent), diff --git a/crates/cli/src/configuration/mod.rs b/crates/cli/src/configuration/mod.rs index 51f7b8d0d..2a6be133d 100644 --- a/crates/cli/src/configuration/mod.rs +++ b/crates/cli/src/configuration/mod.rs @@ -1089,14 +1089,10 @@ fn plugin_config_paths_scoped( plugin_config_path: Option<&PathBuf>, user_only: bool, ) -> Vec { - if let Some(path) = plugin_config_path { - return vec![path.clone()]; - } - if let Some(path) = explicit { - return path - .parent() - .map(|parent| vec![parent.join(PLUGINS_TOML)]) - .unwrap_or_default(); + if plugin_config_path.is_some() || explicit.is_some() { + return explicit_plugin_config_path(explicit, plugin_config_path) + .into_iter() + .collect(); } if user_only { return implicit_plugin_config_paths(None, user_config_dir()); @@ -1104,6 +1100,19 @@ fn plugin_config_paths_scoped( implicit_plugin_config_paths(std::env::current_dir().ok().as_deref(), user_config_dir()) } +/// Resolves the single plugin document selected by explicit gateway configuration. +/// +/// An explicit plugin path wins. Otherwise an explicit `config.toml` selects its sibling +/// `plugins.toml`, matching runtime loading. `None` means normal layered discovery applies. +pub(crate) fn explicit_plugin_config_path( + config_path: Option<&PathBuf>, + plugin_config_path: Option<&PathBuf>, +) -> Option { + plugin_config_path.cloned().or_else(|| { + config_path.and_then(|path| path.parent().map(|parent| parent.join(PLUGINS_TOML))) + }) +} + fn user_config_scope() -> bool { std::env::var("NEMO_RELAY_CONFIG_SCOPE").ok().as_deref() == Some("user") } diff --git a/crates/cli/src/plugins/mod.rs b/crates/cli/src/plugins/mod.rs index 052b46938..61b542e2d 100644 --- a/crates/cli/src/plugins/mod.rs +++ b/crates/cli/src/plugins/mod.rs @@ -8,7 +8,7 @@ //! tests, so Codecov does not depend on exercising interactive prompt loops. use std::io::IsTerminal; -use std::path::Path; +use std::path::{Path, PathBuf}; use console::{Key, Term, style, truncate_str}; use dialoguer::theme::ColorfulTheme; @@ -104,8 +104,7 @@ fn print_save_success(path: &Path) { pub(crate) fn edit(command: PluginsEditRequest) -> Result<(), CliError> { ensure_tty()?; - let scope = target_scope(&command.scope)?; - let path = target_path(scope)?; + let (scope, path) = resolve_edit_target(command)?; let mut document = PluginConfigDocument::read(&path)?; ensure_observability_component(document.config_mut())?; ensure_adaptive_component(document.config_mut())?; @@ -146,6 +145,17 @@ pub(crate) fn edit(command: PluginsEditRequest) -> Result<(), CliError> { } } +pub(crate) fn resolve_edit_target( + command: PluginsEditRequest, +) -> Result<(TargetScope, PathBuf), CliError> { + let scope = target_scope(&command.scope)?; + let path = match command.explicit_path { + Some(path) => path, + None => target_path(scope)?, + }; + Ok((scope, path)) +} + fn handle_menu_response( theme: &ColorfulTheme, document: &mut PluginConfigDocument, diff --git a/crates/cli/src/plugins/types.rs b/crates/cli/src/plugins/types.rs index 05f842436..950ead3af 100644 --- a/crates/cli/src/plugins/types.rs +++ b/crates/cli/src/plugins/types.rs @@ -18,6 +18,8 @@ pub(crate) enum ConfigurationScope { #[derive(Debug, Clone, Default)] pub(crate) struct PluginsEditRequest { pub(crate) scope: ConfigurationScope, + /// Physical file inherited from top-level runtime configuration. + pub(crate) explicit_path: Option, } #[derive(Debug, Clone, Default)] pub(crate) struct PluginsAddRequest { diff --git a/crates/cli/tests/coverage/commands/main_tests.rs b/crates/cli/tests/coverage/commands/main_tests.rs index 11396a745..0ffe1e2a0 100644 --- a/crates/cli/tests/coverage/commands/main_tests.rs +++ b/crates/cli/tests/coverage/commands/main_tests.rs @@ -3,6 +3,7 @@ use clap::Parser; use std::ffi::OsString; +use std::path::PathBuf; use super::completions::CompletionsCommand; use super::serve::ServerArgs; @@ -10,11 +11,47 @@ use super::*; use crate::commands::configure::ConfigSubcommand; use crate::commands::model_pricing::{PricingSubcommand, PricingValidateCommand}; use crate::commands::plugins::{ - PluginsCommand, PluginsInspectCommand, PluginsListCommand, PluginsSubcommand, - PluginsValidateCommand, + PluginsCommand, PluginsEditCommand, PluginsInspectCommand, PluginsListCommand, + PluginsScopeArgs, PluginsSubcommand, PluginsValidateCommand, }; use crate::commands::root::AgentArg; +#[test] +fn plugins_edit_inherits_explicit_plugin_target_unless_scope_is_selected() { + let config = PathBuf::from("/managed/config.toml"); + let server = crate::server::GatewayOverrides { + config: Some(config), + ..crate::server::GatewayOverrides::default() + }; + + let default = PluginsEditCommand::default(); + let request = plugins::edit_request(default, &server); + assert_eq!( + request.explicit_path, + Some(PathBuf::from("/managed/plugins.toml")) + ); + + let server = crate::server::GatewayOverrides { + config: Some(PathBuf::from("/managed/config.toml")), + plugin_config_path: Some(PathBuf::from("/override/plugins.toml")), + ..crate::server::GatewayOverrides::default() + }; + let request = plugins::edit_request(PluginsEditCommand::default(), &server); + assert_eq!( + request.explicit_path, + Some(PathBuf::from("/override/plugins.toml")) + ); + + let project = PluginsEditCommand { + scope: PluginsScopeArgs { + project: true, + ..PluginsScopeArgs::default() + }, + }; + let request = plugins::edit_request(project, &server); + assert_eq!(request.explicit_path, None); +} + #[test] fn operational_command_names_cover_logging_exempt_commands() { for (args, expected) in [ diff --git a/crates/cli/tests/coverage/shared/config_tests.rs b/crates/cli/tests/coverage/shared/config_tests.rs index c5acb3dd3..c9d748fd6 100644 --- a/crates/cli/tests/coverage/shared/config_tests.rs +++ b/crates/cli/tests/coverage/shared/config_tests.rs @@ -30,6 +30,23 @@ use crate::plugins::policy::{ evaluate_dynamic_plugin_host_policy, }; +#[test] +fn explicit_plugin_config_path_resolves_runtime_target() { + let config = PathBuf::from("/managed/config.toml"); + let sibling = PathBuf::from("/managed/plugins.toml"); + let override_path = PathBuf::from("/override/plugins.toml"); + + assert_eq!( + explicit_plugin_config_path(Some(&config), None), + Some(sibling) + ); + assert_eq!( + explicit_plugin_config_path(Some(&config), Some(&override_path)), + Some(override_path) + ); + assert_eq!(explicit_plugin_config_path(None, None), None); +} + struct PluginConfigDiscoveryScope { _cwd_guard: crate::test_support::CwdTestScope, _guard: MutexGuard<'static, ()>, diff --git a/crates/cli/tests/coverage/shared/plugins_tests.rs b/crates/cli/tests/coverage/shared/plugins_tests.rs index 35c8c23ea..cd6cc9abf 100644 --- a/crates/cli/tests/coverage/shared/plugins_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_tests.rs @@ -172,6 +172,19 @@ fn target_scope_defaults_to_user_and_rejects_conflicts() { assert!(error.contains("choose only one"), "error was: {error}"); } +#[test] +fn editor_uses_explicit_plugin_target_without_default_discovery() { + let path = PathBuf::from("/managed/plugins.toml"); + let (scope, target) = resolve_edit_target(PluginsEditRequest { + scope: ConfigurationScope::User, + explicit_path: Some(path.clone()), + }) + .unwrap(); + + assert_eq!(scope, TargetScope::User); + assert_eq!(target, path); +} + #[test] fn typed_editor_model_contains_observability_sections() { let schema = ObservabilityConfig::editor_schema(); diff --git a/crates/cli/tests/coverage/shared/setup_tests.rs b/crates/cli/tests/coverage/shared/setup_tests.rs index 0710bea1d..758e142f9 100644 --- a/crates/cli/tests/coverage/shared/setup_tests.rs +++ b/crates/cli/tests/coverage/shared/setup_tests.rs @@ -546,7 +546,7 @@ fn plugins_edit_command_for_scope_targets_expected_plugin_scope() { ]; for (scope, expected) in cases { - let command = plugins_edit_command_for_scope(scope); + let command = plugins_edit_command_for_scope(scope, None); assert_eq!( target_scope(&command.scope).unwrap(), expected, @@ -555,6 +555,15 @@ fn plugins_edit_command_for_scope_targets_expected_plugin_scope() { } } +#[test] +fn plugins_edit_command_for_scope_preserves_explicit_plugin_path() { + let path = PathBuf::from("/managed/plugins.toml"); + + let command = plugins_edit_command_for_scope(ConfigScope::Global, Some(path.clone())); + + assert_eq!(command.explicit_path, Some(path)); +} + #[test] fn plugins_resume_command_matches_scope() { let cases = [ @@ -565,13 +574,23 @@ fn plugins_resume_command_matches_scope() { for (scope, expected) in cases { assert_eq!( - plugins_resume_command(scope), + plugins_resume_command(scope, None), expected, "unexpected resume command for {scope:?}" ); } } +#[test] +fn plugins_resume_command_preserves_explicit_plugin_path() { + let path = PathBuf::from("/managed/plugins.toml"); + + assert_eq!( + plugins_resume_command(ConfigScope::Global, Some(&path)), + "nemo-relay --plugin-config-path /managed/plugins.toml plugins edit" + ); +} + #[test] fn plugin_prompt_interruption_recognizes_cancel_inputs() { for kind in [ diff --git a/docs/configure-plugins/plugin-configuration-files.mdx b/docs/configure-plugins/plugin-configuration-files.mdx index 37c05df70..aa3216614 100644 --- a/docs/configure-plugins/plugin-configuration-files.mdx +++ b/docs/configure-plugins/plugin-configuration-files.mdx @@ -221,6 +221,13 @@ or: ~/.config/nemo-relay/plugins.toml ``` +When the top-level CLI receives `--plugin-config-path`, the editor uses that +exact file. Otherwise, `--config path/to/config.toml` makes the editor use the +sibling `path/to/plugins.toml`, matching the runtime selection for that +configuration. An explicit editor scope flag overrides this inherited target. +These rules only select the file opened by the editor; they do not change +runtime discovery, layering, or merge precedence. + Use a scope flag to edit another location: ```bash diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index 23e285862..0903d6810 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -161,7 +161,9 @@ The base `config.toml` stores the agent settings. Select **Yes** at the plugin prompt to configure optional Relay components and save them separately in `plugins.toml`. Project setup uses the project plugin configuration, global setup uses the user plugin configuration, and `both` continues with the project -plugin configuration. +plugin configuration. When the top-level command receives +`--config path/to/config.toml`, the plugin editor instead uses the sibling +`path/to/plugins.toml`, matching runtime selection. Select **No** to finish after saving `config.toml`. Canceling the prompt or leaving the plugin editor does not remove the saved base configuration. You can From a6476bbf36fca6662ccf403dbe43686018d0decf Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Wed, 29 Jul 2026 12:18:09 -0600 Subject: [PATCH 2/3] fix: honor explicit config in config editor Signed-off-by: Bryan Bednarski --- crates/cli/README.md | 4 ++++ crates/cli/src/commands/configure/editor.rs | 24 ++++++++++++++++--- crates/cli/src/commands/configure/mod.rs | 2 +- .../commands/configure_editor_tests.rs | 18 ++++++++++++++ .../cli/tests/coverage/commands/main_tests.rs | 13 ++++++++++ docs/nemo-relay-cli/basic-usage.mdx | 5 ++++ 6 files changed, 62 insertions(+), 4 deletions(-) diff --git a/crates/cli/README.md b/crates/cli/README.md index a4a4001a2..2178713a4 100644 --- a/crates/cli/README.md +++ b/crates/cli/README.md @@ -173,6 +173,10 @@ Use `--project` for the nearest project `config.toml`, or `--global` for Unix) and reject authorization headers; use the corresponding environment variables or a user config for credentials. +When the top-level CLI receives `--config path/to/config.toml`, the config +editor uses that exact file. An explicit `config edit --user`, `--project`, or +`--global` flag overrides the inherited target. + Observability exporters are configured through the plugin config. Edit the user plugin config with: diff --git a/crates/cli/src/commands/configure/editor.rs b/crates/cli/src/commands/configure/editor.rs index 410966df5..30c9fc37b 100644 --- a/crates/cli/src/commands/configure/editor.rs +++ b/crates/cli/src/commands/configure/editor.rs @@ -37,10 +37,12 @@ impl From<&ConfigEditCommand> for TargetScope { } } -pub(super) fn edit(command: ConfigEditCommand) -> Result<(), CliError> { +pub(super) fn edit( + command: ConfigEditCommand, + explicit_path: Option, +) -> Result<(), CliError> { ensure_tty()?; - let scope = TargetScope::from(&command); - let path = target_path(scope)?; + let (scope, path) = resolve_edit_target(&command, explicit_path)?; let mut document = ConfigDocument::read(path)?; let theme = ColorfulTheme::default(); @@ -74,6 +76,22 @@ pub(super) fn edit(command: ConfigEditCommand) -> Result<(), CliError> { } } +fn resolve_edit_target( + command: &ConfigEditCommand, + explicit_path: Option, +) -> Result<(TargetScope, PathBuf), CliError> { + let scope = TargetScope::from(command); + let path = if command.user || command.project || command.global { + target_path(scope)? + } else { + match explicit_path { + Some(path) => path, + None => target_path(scope)?, + } + }; + Ok((scope, path)) +} + fn ensure_tty() -> Result<(), CliError> { ensure_tty_with(std::io::stdin().is_terminal()) } diff --git a/crates/cli/src/commands/configure/mod.rs b/crates/cli/src/commands/configure/mod.rs index 23cb5b5ea..b181d4927 100644 --- a/crates/cli/src/commands/configure/mod.rs +++ b/crates/cli/src/commands/configure/mod.rs @@ -60,7 +60,7 @@ pub(super) async fn execute( server: &ServerArgs, ) -> Result { if let Some(ConfigSubcommand::Edit(edit)) = command.command.as_ref() { - editor::edit(edit.clone())?; + editor::edit(edit.clone(), server.to_runtime().config)?; return Ok(ExitCode::SUCCESS); } let agent = command.agent.map(Into::into); diff --git a/crates/cli/tests/coverage/commands/configure_editor_tests.rs b/crates/cli/tests/coverage/commands/configure_editor_tests.rs index 69a463aeb..7155d2805 100644 --- a/crates/cli/tests/coverage/commands/configure_editor_tests.rs +++ b/crates/cli/tests/coverage/commands/configure_editor_tests.rs @@ -180,6 +180,24 @@ fn target_selection_and_file_loading_behave_as_expected() { assert!(error.contains(&invalid.display().to_string())); } +#[test] +fn config_editor_inherits_explicit_target_unless_scope_is_selected() { + let inherited = PathBuf::from("/managed/config.toml"); + let (scope, path) = + resolve_edit_target(&ConfigEditCommand::default(), Some(inherited.clone())).unwrap(); + assert_eq!(scope, TargetScope::User); + assert_eq!(path, inherited); + + let global = ConfigEditCommand { + global: true, + ..ConfigEditCommand::default() + }; + let (scope, path) = + resolve_edit_target(&global, Some(PathBuf::from("/ignored/config.toml"))).unwrap(); + assert_eq!(scope, TargetScope::Global); + assert_eq!(path, PathBuf::from("/etc/nemo-relay/config.toml")); +} + #[test] fn documents_are_written_atomically_with_scope_appropriate_permissions() { let directory = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/commands/main_tests.rs b/crates/cli/tests/coverage/commands/main_tests.rs index 0ffe1e2a0..a6fddf7cd 100644 --- a/crates/cli/tests/coverage/commands/main_tests.rs +++ b/crates/cli/tests/coverage/commands/main_tests.rs @@ -276,6 +276,19 @@ fn cli_parses_config_edit_scopes_and_rejects_conflicts() { assert!(!command.project); assert!(!command.global); + let explicit = Cli::try_parse_from([ + "nemo-relay", + "--config", + "/managed/config.toml", + "config", + "edit", + ]) + .unwrap(); + assert_eq!( + explicit.server.config, + Some(PathBuf::from("/managed/config.toml")) + ); + let project = Cli::try_parse_from(["nemo-relay", "config", "edit", "--project"]).unwrap(); let Command::Config(command) = project.command.unwrap() else { panic!("expected config command"); diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index 0903d6810..cb3e825c5 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -186,6 +186,11 @@ clear a setting to restore normal configuration precedence and defaults. Global saves are system-readable (`0644` on Unix), so they reject authorization headers; store credentials in a user config or environment variables instead. +When the top-level CLI receives `--config path/to/config.toml`, the editor uses +that exact file. An explicit `config edit --user`, `--project`, or `--global` +flag overrides the inherited target. This selects only the file opened by the +editor; it does not change runtime discovery, layering, or merge precedence. + Agent command setup remains under `nemo-relay config`; plugin components remain under `nemo-relay plugins edit`. From b9df4821de928dcdd3968a8ba5d33aed3e568cd4 Mon Sep 17 00:00:00 2001 From: Bryan Bednarski Date: Wed, 29 Jul 2026 13:38:36 -0600 Subject: [PATCH 3/3] fix: preserve plugin target during easy-path setup Signed-off-by: Bryan Bednarski --- crates/cli/src/commands/run.rs | 13 +++++++- .../commands/configure_editor_tests.rs | 30 +++++++++++++++++++ .../cli/tests/coverage/commands/main_tests.rs | 14 +++++++++ .../cli/tests/coverage/shared/setup_tests.rs | 14 +++++++-- docs/nemo-relay-cli/basic-usage.mdx | 5 +++- 5 files changed, 72 insertions(+), 4 deletions(-) diff --git a/crates/cli/src/commands/run.rs b/crates/cli/src/commands/run.rs index a4f3ee852..3c176c147 100644 --- a/crates/cli/src/commands/run.rs +++ b/crates/cli/src/commands/run.rs @@ -64,6 +64,16 @@ pub(super) async fn execute( crate::process::launcher::run(command.into_runtime(), Some(&inherited)).await } +/// Resolves the plugin document that easy-path setup must preserve. +pub(super) fn easy_path_plugin_config_path( + inherited: &crate::server::GatewayOverrides, +) -> Option { + crate::configuration::explicit_plugin_config_path( + inherited.config.as_ref(), + inherited.plugin_config_path.as_ref(), + ) +} + pub(super) async fn easy_path( agent: CodingAgent, command: EasyPathCommand, @@ -76,7 +86,8 @@ pub(super) async fn easy_path( let explicit_config = inherited.config.as_deref(); let needs_setup = explicit_config.is_none() && !crate::configuration::any_config_file_exists(); if needs_setup { - super::configure::run(Some(agent), None).await?; + let explicit_plugin_path = easy_path_plugin_config_path(&inherited); + super::configure::run(Some(agent), explicit_plugin_path).await?; } let runtime = crate::process::RunOverrides { agent: Some(agent), diff --git a/crates/cli/tests/coverage/commands/configure_editor_tests.rs b/crates/cli/tests/coverage/commands/configure_editor_tests.rs index 7155d2805..e66c5122e 100644 --- a/crates/cli/tests/coverage/commands/configure_editor_tests.rs +++ b/crates/cli/tests/coverage/commands/configure_editor_tests.rs @@ -188,6 +188,36 @@ fn config_editor_inherits_explicit_target_unless_scope_is_selected() { assert_eq!(scope, TargetScope::User); assert_eq!(path, inherited); + let user = ConfigEditCommand { + user: true, + ..ConfigEditCommand::default() + }; + let (scope, path) = + resolve_edit_target(&user, Some(PathBuf::from("/ignored/config.toml"))).unwrap(); + assert_eq!(scope, TargetScope::User); + assert_eq!( + path, + crate::configuration::user_config_dir() + .unwrap() + .join("config.toml") + ); + + let project_root = tempfile::tempdir().unwrap(); + let _cwd = crate::test_support::CwdTestScope::enter(project_root.path()); + let project = ConfigEditCommand { + project: true, + ..ConfigEditCommand::default() + }; + let (scope, path) = + resolve_edit_target(&project, Some(PathBuf::from("/ignored/config.toml"))).unwrap(); + assert_eq!(scope, TargetScope::Project); + assert_eq!( + path, + std::env::current_dir() + .unwrap() + .join(".nemo-relay/config.toml") + ); + let global = ConfigEditCommand { global: true, ..ConfigEditCommand::default() diff --git a/crates/cli/tests/coverage/commands/main_tests.rs b/crates/cli/tests/coverage/commands/main_tests.rs index a6fddf7cd..66ee3d558 100644 --- a/crates/cli/tests/coverage/commands/main_tests.rs +++ b/crates/cli/tests/coverage/commands/main_tests.rs @@ -52,6 +52,20 @@ fn plugins_edit_inherits_explicit_plugin_target_unless_scope_is_selected() { assert_eq!(request.explicit_path, None); } +#[test] +fn easy_path_setup_inherits_explicit_plugin_target() { + let plugin_config_path = PathBuf::from("/managed/plugins.toml"); + let inherited = crate::server::GatewayOverrides { + plugin_config_path: Some(plugin_config_path.clone()), + ..crate::server::GatewayOverrides::default() + }; + + assert_eq!( + run::easy_path_plugin_config_path(&inherited), + Some(plugin_config_path) + ); +} + #[test] fn operational_command_names_cover_logging_exempt_commands() { for (args, expected) in [ diff --git a/crates/cli/tests/coverage/shared/setup_tests.rs b/crates/cli/tests/coverage/shared/setup_tests.rs index 758e142f9..ee825081f 100644 --- a/crates/cli/tests/coverage/shared/setup_tests.rs +++ b/crates/cli/tests/coverage/shared/setup_tests.rs @@ -583,11 +583,21 @@ fn plugins_resume_command_matches_scope() { #[test] fn plugins_resume_command_preserves_explicit_plugin_path() { - let path = PathBuf::from("/managed/plugins.toml"); + let path = PathBuf::from("/managed/plugin configs/plugins.toml"); + #[cfg(windows)] + let expected = concat!( + "nemo-relay --plugin-config-path ", + "\"/managed/plugin configs/plugins.toml\" plugins edit" + ); + #[cfg(not(windows))] + let expected = concat!( + "nemo-relay --plugin-config-path ", + "'/managed/plugin configs/plugins.toml' plugins edit" + ); assert_eq!( plugins_resume_command(ConfigScope::Global, Some(&path)), - "nemo-relay --plugin-config-path /managed/plugins.toml plugins edit" + expected ); } diff --git a/docs/nemo-relay-cli/basic-usage.mdx b/docs/nemo-relay-cli/basic-usage.mdx index cb3e825c5..597a185e2 100644 --- a/docs/nemo-relay-cli/basic-usage.mdx +++ b/docs/nemo-relay-cli/basic-usage.mdx @@ -167,7 +167,10 @@ plugin configuration. When the top-level command receives Select **No** to finish after saving `config.toml`. Canceling the prompt or leaving the plugin editor does not remove the saved base configuration. You can -open the plugin editor again later with `nemo-relay plugins edit`, or use +open the plugin editor again later with the resume command printed by Relay. +For an explicit `--config path/to/config.toml` flow, the equivalent command is +`nemo-relay --plugin-config-path path/to/plugins.toml plugins edit`. Otherwise, +use `nemo-relay plugins edit` for user configuration or `nemo-relay plugins edit --project` for project configuration. ### Edit Gateway Configuration