From 8db220827a100d7f321b1702339e8084d2c1122c Mon Sep 17 00:00:00 2001 From: Eric Evans <194135482+ericevans-nv@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:27:38 -0500 Subject: [PATCH 1/3] fix(cli): guide setup into plugin configuration Signed-off-by: Eric Evans <194135482+ericevans-nv@users.noreply.github.com> --- crates/cli/src/plugins.rs | 6 +- crates/cli/src/setup.rs | 68 ++++++++++++++++++++- crates/cli/src/setup/model.rs | 30 +++++++++ crates/cli/tests/coverage/setup_tests.rs | 78 ++++++++++++++++++++++++ 4 files changed, 176 insertions(+), 6 deletions(-) diff --git a/crates/cli/src/plugins.rs b/crates/cli/src/plugins.rs index c7bd097e..d532425d 100644 --- a/crates/cli/src/plugins.rs +++ b/crates/cli/src/plugins.rs @@ -324,7 +324,7 @@ fn clear_component_menu_item( } fn cancelled_error() -> CliError { - CliError::Config("plugin edit cancelled; no config saved".into()) + CliError::Config("plugin edit cancelled; no plugin changes saved".into()) } fn edit_component_field( @@ -610,7 +610,7 @@ fn menu_error(error: std::io::Error) -> CliError { error.kind(), std::io::ErrorKind::Interrupted | std::io::ErrorKind::UnexpectedEof ) { - CliError::Config("plugin edit cancelled; no config saved".into()) + CliError::Config("plugin edit cancelled; no plugin changes saved".into()) } else { CliError::Config(format!("plugin editor terminal error: {error}")) } @@ -1369,7 +1369,7 @@ fn editor_error(err: dialoguer::Error) -> CliError { std::io::ErrorKind::Interrupted | std::io::ErrorKind::UnexpectedEof ) => { - CliError::Config("plugin edit cancelled; no config saved".into()) + CliError::Config("plugin edit cancelled; no plugin changes saved".into()) } other => CliError::Config(format!("plugin edit error: {other}")), } diff --git a/crates/cli/src/setup.rs b/crates/cli/src/setup.rs index 627bb812..cccfc8a0 100644 --- a/crates/cli/src/setup.rs +++ b/crates/cli/src/setup.rs @@ -27,7 +27,8 @@ pub(crate) use self::model::reset; use self::model::{ ConfigScope, SetupAnswers, agent_key_and_command, build_config, detect_installed_agents, hermes_hook_targets, hermes_hooks_path_for_scope, home_dir, install_hermes_hooks, - preview_paths, read_existing_defaults, save_config, + plugins_edit_command_for_scope, plugins_resume_command, preview_paths, read_existing_defaults, + save_config, }; #[cfg(test)] @@ -133,9 +134,70 @@ pub(crate) async fn run(agent_hint: Option) -> Result<(), CliError> for path in &written { println!(" {}", path.display()); } - println!(" Configure plugins with `nemo-relay plugins edit`."); println!(); - Ok(()) + continue_to_plugins(answers.scope) +} + +/// 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. Cancellation or editor failure is surfaced as 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> { + let proceed = Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt("Configure Relay plugins now?") + .default(true) + .interact() + .map_err(|error| plugin_prompt_error(scope, error))?; + if !proceed { + print_plugins_skipped(scope); + return Ok(()); + } + crate::plugins::edit(plugins_edit_command_for_scope(scope)).map_err(|error| { + let cause = match error { + CliError::Config(message) => message, + other => other.to_string(), + }; + CliError::Config(format!( + "plugin setup did not complete; base configuration remains saved. \ + Resume with `{}`. Cause: {cause}", + plugins_resume_command(scope) + )) + }) +} + +// Classifies a post-save prompt interruption: Interrupted/UnexpectedEof read as cancellation, +// other dialoguer errors keep their cause. Both note the base config is saved and how to resume. +fn plugin_prompt_error(scope: ConfigScope, error: dialoguer::Error) -> CliError { + let resume = plugins_resume_command(scope); + match error { + dialoguer::Error::IO(io_error) + if matches!( + io_error.kind(), + std::io::ErrorKind::Interrupted | std::io::ErrorKind::UnexpectedEof + ) => + { + CliError::Config(format!( + "plugin setup cancelled; base configuration remains saved. Resume with `{resume}`." + )) + } + other => CliError::Config(format!( + "plugin setup did not complete; base configuration remains saved. \ + Resume with `{resume}`. Cause: {other}" + )), + } +} + +fn print_plugins_skipped(scope: ConfigScope) { + println!(); + println!(" Base configuration saved. Plugin configuration skipped."); + println!( + " Configure plugins later with `{}`.", + plugins_resume_command(scope) + ); + println!(); } fn print_codex_api_key_guide() { diff --git a/crates/cli/src/setup/model.rs b/crates/cli/src/setup/model.rs index 45b13751..2481f3a1 100644 --- a/crates/cli/src/setup/model.rs +++ b/crates/cli/src/setup/model.rs @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf}; use toml_edit::{DocumentMut, Item, Table, value}; use crate::config::CodingAgent; +use crate::config::{PluginsEditCommand, PluginsScopeArgs}; use crate::error::CliError; use crate::installer::{hermes_hooks, hook_forward_command, merge_hermes_config}; @@ -32,6 +33,35 @@ impl ConfigScope { } } +/// Maps the base setup scope to the plugin editor target for the guided continuation. +/// +/// `Project` and `Both` configure the project `plugins.toml`; `Global` configures the user +/// `plugins.toml`. Returns the existing `PluginsEditCommand` so the in-process editor behaves +/// exactly like the equivalent `nemo-relay plugins edit` invocation. +pub(super) fn plugins_edit_command_for_scope(scope: ConfigScope) -> PluginsEditCommand { + let scope = match scope { + ConfigScope::Project | ConfigScope::Both => PluginsScopeArgs { + user: false, + project: true, + global: false, + }, + ConfigScope::Global => PluginsScopeArgs { + user: true, + project: false, + global: false, + }, + }; + PluginsEditCommand { scope } +} + +/// Returns the exact command a user runs to resume plugin setup after skipping the continuation. +pub(super) fn plugins_resume_command(scope: ConfigScope) -> &'static str { + match scope { + ConfigScope::Project | ConfigScope::Both => "nemo-relay plugins edit --project", + ConfigScope::Global => "nemo-relay plugins edit", + } +} + /// Resolved answers from setup. Built either by `prompt_user` (interactive) or by tests. #[derive(Debug, Clone)] pub(crate) struct SetupAnswers { diff --git a/crates/cli/tests/coverage/setup_tests.rs b/crates/cli/tests/coverage/setup_tests.rs index f1242104..4f2e3e80 100644 --- a/crates/cli/tests/coverage/setup_tests.rs +++ b/crates/cli/tests/coverage/setup_tests.rs @@ -574,3 +574,81 @@ fn reset_reports_missing_or_malformed_agent_blocks_without_rewriting() { "error was: {error}" ); } + +#[test] +fn plugins_edit_command_for_scope_targets_expected_plugin_scope() { + use crate::plugins::config_io::{TargetScope, target_scope}; + + let cases = [ + (ConfigScope::Project, TargetScope::Project), + (ConfigScope::Global, TargetScope::User), + (ConfigScope::Both, TargetScope::Project), + ]; + + for (scope, expected) in cases { + let command = plugins_edit_command_for_scope(scope); + assert_eq!( + target_scope(&command.scope).unwrap(), + expected, + "unexpected plugin target scope for {scope:?}" + ); + } +} + +#[test] +fn plugins_resume_command_matches_scope() { + let cases = [ + (ConfigScope::Project, "nemo-relay plugins edit --project"), + (ConfigScope::Both, "nemo-relay plugins edit --project"), + (ConfigScope::Global, "nemo-relay plugins edit"), + ]; + + for (scope, expected) in cases { + assert_eq!( + plugins_resume_command(scope), + expected, + "unexpected resume command for {scope:?}" + ); + } +} + +#[test] +fn plugin_prompt_error_reports_cancellation_with_base_saved_and_resume() { + let error = plugin_prompt_error( + ConfigScope::Project, + dialoguer::Error::IO(std::io::Error::from(std::io::ErrorKind::Interrupted)), + ); + let message = error.to_string(); + assert!(message.contains("cancelled"), "message: {message}"); + assert!( + message.contains("base configuration remains saved"), + "message: {message}" + ); + assert!( + message.contains("nemo-relay plugins edit --project"), + "message: {message}" + ); +} + +#[test] +fn plugin_prompt_error_reports_failure_with_cause_and_resume() { + let error = plugin_prompt_error( + ConfigScope::Global, + dialoguer::Error::IO(std::io::Error::other("boom")), + ); + let message = error.to_string(); + assert!(message.contains("did not complete"), "message: {message}"); + assert!( + message.contains("base configuration remains saved"), + "message: {message}" + ); + assert!( + message.contains("Cause: IO error: boom"), + "message: {message}" + ); + assert!( + message.contains("Resume with `nemo-relay plugins edit`"), + "message: {message}" + ); + assert!(!message.contains("--project"), "message: {message}"); +} From 6b860cd2794958cc845a821127906702a9529c96 Mon Sep 17 00:00:00 2001 From: Eric Evans <194135482+ericevans-nv@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:04:00 -0500 Subject: [PATCH 2/3] fix(cli): treat plugin prompt interruption as skip Signed-off-by: Eric Evans <194135482+ericevans-nv@users.noreply.github.com> --- crates/cli/src/setup.rs | 43 ++++++++++++---------- crates/cli/tests/coverage/setup_tests.rs | 47 +++++------------------- 2 files changed, 33 insertions(+), 57 deletions(-) diff --git a/crates/cli/src/setup.rs b/crates/cli/src/setup.rs index cccfc8a0..9a717cc3 100644 --- a/crates/cli/src/setup.rs +++ b/crates/cli/src/setup.rs @@ -143,14 +143,28 @@ pub(crate) async fn run(agent_hint: Option) -> Result<(), CliError> /// 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. Cancellation or editor failure is surfaced as an error that makes clear the -/// base config remains saved; the saved `config.toml` is never rolled back here. +/// 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> { - let proceed = Confirm::with_theme(&ColorfulTheme::default()) + let proceed = match Confirm::with_theme(&ColorfulTheme::default()) .with_prompt("Configure Relay plugins now?") .default(true) .interact() - .map_err(|error| plugin_prompt_error(scope, error))?; + { + Ok(proceed) => proceed, + Err(error) if plugin_prompt_was_interrupted(&error) => { + print_plugins_skipped(scope); + 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) + ))); + } + }; if !proceed { print_plugins_skipped(scope); return Ok(()); @@ -168,26 +182,15 @@ fn continue_to_plugins(scope: ConfigScope) -> Result<(), CliError> { }) } -// Classifies a post-save prompt interruption: Interrupted/UnexpectedEof read as cancellation, -// other dialoguer errors keep their cause. Both note the base config is saved and how to resume. -fn plugin_prompt_error(scope: ConfigScope, error: dialoguer::Error) -> CliError { - let resume = plugins_resume_command(scope); - match error { +fn plugin_prompt_was_interrupted(error: &dialoguer::Error) -> bool { + matches!( + error, dialoguer::Error::IO(io_error) if matches!( io_error.kind(), std::io::ErrorKind::Interrupted | std::io::ErrorKind::UnexpectedEof - ) => - { - CliError::Config(format!( - "plugin setup cancelled; base configuration remains saved. Resume with `{resume}`." - )) - } - other => CliError::Config(format!( - "plugin setup did not complete; base configuration remains saved. \ - Resume with `{resume}`. Cause: {other}" - )), - } + ) + ) } fn print_plugins_skipped(scope: ConfigScope) { diff --git a/crates/cli/tests/coverage/setup_tests.rs b/crates/cli/tests/coverage/setup_tests.rs index 4f2e3e80..6284bbcb 100644 --- a/crates/cli/tests/coverage/setup_tests.rs +++ b/crates/cli/tests/coverage/setup_tests.rs @@ -613,42 +613,15 @@ fn plugins_resume_command_matches_scope() { } #[test] -fn plugin_prompt_error_reports_cancellation_with_base_saved_and_resume() { - let error = plugin_prompt_error( - ConfigScope::Project, - dialoguer::Error::IO(std::io::Error::from(std::io::ErrorKind::Interrupted)), - ); - let message = error.to_string(); - assert!(message.contains("cancelled"), "message: {message}"); - assert!( - message.contains("base configuration remains saved"), - "message: {message}" - ); - assert!( - message.contains("nemo-relay plugins edit --project"), - "message: {message}" - ); -} +fn plugin_prompt_interruption_recognizes_cancel_inputs() { + for kind in [ + std::io::ErrorKind::Interrupted, + std::io::ErrorKind::UnexpectedEof, + ] { + let error = dialoguer::Error::IO(std::io::Error::from(kind)); + assert!(plugin_prompt_was_interrupted(&error)); + } -#[test] -fn plugin_prompt_error_reports_failure_with_cause_and_resume() { - let error = plugin_prompt_error( - ConfigScope::Global, - dialoguer::Error::IO(std::io::Error::other("boom")), - ); - let message = error.to_string(); - assert!(message.contains("did not complete"), "message: {message}"); - assert!( - message.contains("base configuration remains saved"), - "message: {message}" - ); - assert!( - message.contains("Cause: IO error: boom"), - "message: {message}" - ); - assert!( - message.contains("Resume with `nemo-relay plugins edit`"), - "message: {message}" - ); - assert!(!message.contains("--project"), "message: {message}"); + let error = dialoguer::Error::IO(std::io::Error::other("boom")); + assert!(!plugin_prompt_was_interrupted(&error)); } From 9e26fa4d15a3380295bb09503e6103b0ee7d00c3 Mon Sep 17 00:00:00 2001 From: Eric Evans <194135482+ericevans-nv@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:15:48 -0500 Subject: [PATCH 3/3] refactor(cli): share plugin cancellation message Signed-off-by: Eric Evans <194135482+ericevans-nv@users.noreply.github.com> --- crates/cli/src/plugins.rs | 8 +++++--- crates/cli/tests/coverage/plugins_tests.rs | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/cli/src/plugins.rs b/crates/cli/src/plugins.rs index d532425d..be3676c5 100644 --- a/crates/cli/src/plugins.rs +++ b/crates/cli/src/plugins.rs @@ -30,6 +30,8 @@ use self::config_io::*; use self::dynamic_editor::*; use self::editor_model::*; +const PLUGIN_EDIT_CANCELLED_MESSAGE: &str = "plugin edit cancelled; no plugin changes saved"; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum MenuShortcut { Preview, @@ -324,7 +326,7 @@ fn clear_component_menu_item( } fn cancelled_error() -> CliError { - CliError::Config("plugin edit cancelled; no plugin changes saved".into()) + CliError::Config(PLUGIN_EDIT_CANCELLED_MESSAGE.into()) } fn edit_component_field( @@ -610,7 +612,7 @@ fn menu_error(error: std::io::Error) -> CliError { error.kind(), std::io::ErrorKind::Interrupted | std::io::ErrorKind::UnexpectedEof ) { - CliError::Config("plugin edit cancelled; no plugin changes saved".into()) + CliError::Config(PLUGIN_EDIT_CANCELLED_MESSAGE.into()) } else { CliError::Config(format!("plugin editor terminal error: {error}")) } @@ -1369,7 +1371,7 @@ fn editor_error(err: dialoguer::Error) -> CliError { std::io::ErrorKind::Interrupted | std::io::ErrorKind::UnexpectedEof ) => { - CliError::Config("plugin edit cancelled; no plugin changes saved".into()) + CliError::Config(PLUGIN_EDIT_CANCELLED_MESSAGE.into()) } other => CliError::Config(format!("plugin edit error: {other}")), } diff --git a/crates/cli/tests/coverage/plugins_tests.rs b/crates/cli/tests/coverage/plugins_tests.rs index e5304556..542ca18d 100644 --- a/crates/cli/tests/coverage/plugins_tests.rs +++ b/crates/cli/tests/coverage/plugins_tests.rs @@ -615,6 +615,26 @@ fn menu_response_index_tracks_selected_and_shortcut_positions() { assert_eq!(menu_response_index(&MenuResponse::Cancel), None); } +#[test] +fn plugin_cancellation_paths_share_message() { + let errors = [ + cancelled_error(), + menu_error(std::io::Error::from(std::io::ErrorKind::Interrupted)), + editor_error(dialoguer::Error::IO(std::io::Error::from( + std::io::ErrorKind::UnexpectedEof, + ))), + ]; + + for error in errors { + match error { + CliError::Config(message) => { + assert_eq!(message, PLUGIN_EDIT_CANCELLED_MESSAGE); + } + other => panic!("expected config error, got {other:?}"), + } + } +} + #[test] fn plugin_menu_marks_configured_sections_and_fields() { let mut observability = ObservabilityConfig::default();