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
9 changes: 9 additions & 0 deletions crates/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,13 +173,22 @@ 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:

```bash
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.
Expand Down
24 changes: 21 additions & 3 deletions crates/cli/src/commands/configure/editor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,
) -> 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();

Expand Down Expand Up @@ -74,6 +76,22 @@ pub(super) fn edit(command: ConfigEditCommand) -> Result<(), CliError> {
}
}

fn resolve_edit_target(
command: &ConfigEditCommand,
explicit_path: Option<PathBuf>,
) -> 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())
}
Expand Down
15 changes: 12 additions & 3 deletions crates/cli/src/commands/configure/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -54,16 +55,24 @@ pub(crate) struct ConfigEditCommand {
pub(crate) global: bool,
}

pub(super) async fn execute(command: ConfigCommand) -> Result<ExitCode, CliError> {
pub(super) async fn execute(
command: ConfigCommand,
server: &ServerArgs,
) -> Result<ExitCode, CliError> {
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);
if command.reset {
model::reset(command.scope.unwrap_or(model::ConfigScope::Project), agent)?;
} else {
wizard::run(agent).await?;
let overrides = server.to_runtime();
let explicit_plugin_path = crate::configuration::explicit_plugin_config_path(
overrides.config.as_ref(),
overrides.plugin_config_path.as_ref(),
);
wizard::run(agent, explicit_plugin_path).await?;
}
Ok(ExitCode::SUCCESS)
}
29 changes: 21 additions & 8 deletions crates/cli/src/commands/configure/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,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 `PluginsEditRequest` so the in-process editor behaves
/// exactly like the equivalent `nemo-relay plugins edit` invocation.
pub(crate) fn plugins_edit_command_for_scope(scope: ConfigScope) -> 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<PathBuf>,
) -> 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(),
}
}

Expand Down
43 changes: 24 additions & 19 deletions crates/cli/src/commands/configure/wizard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CodingAgent>) -> Result<(), CliError> {
pub(crate) async fn run(
agent_hint: Option<CodingAgent>,
explicit_plugin_path: Option<PathBuf>,
) -> Result<(), CliError> {
let detected = detect_installed_agents();
let answers = prompt_user(&detected, agent_hint)?;

Expand All @@ -116,49 +119,54 @@ pub(crate) async fn run(agent_hint: Option<CodingAgent>) -> 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<PathBuf>,
) -> 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)
.interact()
{
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(),
};
CliError::Config(format!(
"plugin setup did not complete; base configuration remains saved. \
Resume with `{}`. Cause: {cause}",
plugins_resume_command(scope)
resume_command
))
})
}
Expand All @@ -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!();
}

Expand Down
4 changes: 2 additions & 2 deletions crates/cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ async fn run_command(command: Command, server: &ServerArgs) -> Result<ExitCode,
Command::Codex(command) => 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,
Expand Down Expand Up @@ -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)
}
}
Expand Down
13 changes: 12 additions & 1 deletion crates/cli/src/commands/plugins/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ExitCode, CliError> {
let server = server.to_runtime();
let json_context = command
Expand All @@ -23,7 +34,7 @@ pub(super) fn execute(command: PluginsCommand, server: &ServerArgs) -> Result<Ex
let json = json_context.is_some();
let result = match command.command {
subcommands::PluginsSubcommand::Edit(command) => {
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)
Expand Down
14 changes: 12 additions & 2 deletions crates/cli/src/commands/plugins/subcommands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,9 +164,19 @@ impl From<PluginsScopeArgs> for crate::plugins::ConfigurationScope {
}

impl PluginsEditCommand {
pub(crate) fn into_runtime(self) -> crate::plugins::PluginsEditRequest {
pub(crate) fn into_runtime(
self,
explicit_path: Option<PathBuf>,
) -> 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,
}
}
}
Expand Down
13 changes: 12 additions & 1 deletion crates/cli/src/commands/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> {
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,
Expand All @@ -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)).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),
Expand Down
25 changes: 17 additions & 8 deletions crates/cli/src/configuration/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1089,21 +1089,30 @@ fn plugin_config_paths_scoped(
plugin_config_path: Option<&PathBuf>,
user_only: bool,
) -> Vec<PathBuf> {
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());
}
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<PathBuf> {
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")
}
Expand Down
Loading
Loading