Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions crates/cli/src/plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -324,7 +326,7 @@ fn clear_component_menu_item(
}

fn cancelled_error() -> CliError {
CliError::Config("plugin edit cancelled; no config saved".into())
CliError::Config(PLUGIN_EDIT_CANCELLED_MESSAGE.into())
}
Comment thread
willkill07 marked this conversation as resolved.

fn edit_component_field(
Expand Down Expand Up @@ -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 config saved".into())
CliError::Config(PLUGIN_EDIT_CANCELLED_MESSAGE.into())
} else {
CliError::Config(format!("plugin editor terminal error: {error}"))
}
Expand Down Expand Up @@ -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 config saved".into())
CliError::Config(PLUGIN_EDIT_CANCELLED_MESSAGE.into())
}
other => CliError::Config(format!("plugin edit error: {other}")),
}
Expand Down
71 changes: 68 additions & 3 deletions crates/cli/src/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -133,9 +134,73 @@ pub(crate) async fn run(agent_hint: Option<CodingAgent>) -> Result<(), CliError>
for path in &written {
println!(" {}", path.display());
}
println!(" Configure plugins with `nemo-relay plugins edit`.");
println!();
Ok(())
continue_to_plugins(answers.scope)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// 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> {
let proceed = match Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Configure Relay plugins now?")
.default(true)
.interact()
{
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)
)));
}
Comment thread
willkill07 marked this conversation as resolved.
};
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)
))
})
}

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
)
)
}

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() {
Expand Down
30 changes: 30 additions & 0 deletions crates/cli/src/setup/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -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 {
Expand Down
20 changes: 20 additions & 0 deletions crates/cli/tests/coverage/plugins_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
51 changes: 51 additions & 0 deletions crates/cli/tests/coverage/setup_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -575,3 +575,54 @@ 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_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));
}

let error = dialoguer::Error::IO(std::io::Error::other("boom"));
assert!(!plugin_prompt_was_interrupted(&error));
}
Loading