Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2a6c938
feat: add dynamic plugin lifecycle CLI management
afourniernv Jun 23, 2026
2940171
feat: complete dynamic plugin lifecycle CLI diagnostics
afourniernv Jun 23, 2026
6b9be08
test: harden dynamic plugin lifecycle CLI coverage
afourniernv Jun 23, 2026
90d89c7
refactor: clarify dynamic plugin lifecycle responses
afourniernv Jun 23, 2026
086832f
refactor: harden dynamic plugin target parsing
afourniernv Jun 23, 2026
5e94508
fix: address dynamic plugin lifecycle CLI review nits
afourniernv Jun 24, 2026
ec081cd
Merge remote-tracking branch 'github/main' into afournier/relay-340-a…
afourniernv Jun 24, 2026
a55a87a
refactor: tighten dynamic plugin lifecycle text rendering
afourniernv Jun 24, 2026
cd8c682
refactor: reuse shared plugins toml constant
afourniernv Jun 24, 2026
cf6ddf7
refactor: hide dynamic plugin lifecycle state file
afourniernv Jun 24, 2026
eaf621d
refactor: trim lifecycle mutation command output
afourniernv Jun 24, 2026
b73b13e
fix: resolve lifecycle target manifest refs symmetrically
afourniernv Jun 24, 2026
3757b24
fix: simplify lifecycle mutation output handling
afourniernv Jun 24, 2026
eebfff8
test: raise lifecycle CLI patch coverage
afourniernv Jun 24, 2026
f35f8d1
refactor: derive lifecycle labels with strum
afourniernv Jun 24, 2026
ba501e6
refactor: derive lifecycle scope labels with strum
afourniernv Jun 24, 2026
c993948
refactor: derive lifecycle labels from enum display
afourniernv Jun 24, 2026
eb98a40
Merge remote-tracking branch 'github/main' into afournier/relay-340-a…
afourniernv Jun 24, 2026
518648e
refactor: reduce lifecycle renderer string churn
afourniernv Jun 24, 2026
5d736c3
Merge branch 'main' into afournier/relay-340-add-dynamic-plugin-lifec…
afourniernv Jun 24, 2026
63f460d
refactor: simplify lifecycle output rendering
willkill07 Jun 24, 2026
44411df
test: fix stale launcher config initializers
afourniernv Jun 24, 2026
c595e36
Merge branch 'afournier/relay-340-add-dynamic-plugin-lifecycle-cli-co…
afourniernv Jun 24, 2026
0cd15e9
fix: restore lifecycle inspect and host config semantics
afourniernv Jun 24, 2026
106bdcb
Merge remote-tracking branch 'github/main' into afournier/relay-340-a…
afourniernv Jun 24, 2026
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
117 changes: 116 additions & 1 deletion crates/cli/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,51 @@ pub(crate) struct PluginsCommand {
pub(crate) command: PluginsSubcommand,
}

#[derive(Debug, Clone, Copy)]
pub(crate) struct PluginJsonContext<'a> {
pub(crate) command: &'static str,
pub(crate) target: Option<&'a str>,
}

/// Plugin configuration subcommands.
#[derive(Debug, Clone, Subcommand)]
pub(crate) enum PluginsSubcommand {
/// Interactively create or edit built-in plugin configuration in `plugins.toml`.
Edit(PluginsEditCommand),
/// Register a manifest-backed dynamic plugin in `plugins.toml`.
Add(PluginsAddCommand),
/// Validate a manifest-backed dynamic plugin by path or installed ID.
Validate(PluginsValidateCommand),
/// List discovered dynamic plugins from the resolved host config.
List(PluginsListCommand),
/// Inspect one discovered dynamic plugin by canonical ID.
Inspect(PluginsInspectCommand),
/// Mark a registered dynamic plugin enabled in desired state.
Enable(PluginsEnableCommand),
/// Mark a registered dynamic plugin disabled in desired state.
Disable(PluginsDisableCommand),
/// Tombstone a registered dynamic plugin and remove its host discovery reference.
Remove(PluginsRemoveCommand),
}

impl PluginsSubcommand {
pub(crate) fn json_context(&self) -> Option<PluginJsonContext<'_>> {
match self {
Self::Validate(command) if command.json => Some(PluginJsonContext {
command: "plugins validate",
target: Some(command.target.as_str()),
}),
Self::List(command) if command.json => Some(PluginJsonContext {
command: "plugins list",
target: None,
}),
Self::Inspect(command) if command.json => Some(PluginJsonContext {
command: "plugins inspect",
target: Some(command.id.as_str()),
}),
_ => None,
}
}
}

/// Args for `nemo-relay pricing`.
Expand Down Expand Up @@ -298,7 +338,7 @@ pub(crate) struct PricingResolveCommand {
.args(["user", "project", "global"])
.multiple(false)
))]
pub(crate) struct PluginsEditCommand {
pub(crate) struct PluginsScopeArgs {
/// Edit the user config at `$XDG_CONFIG_HOME/nemo-relay/plugins.toml`.
#[arg(long)]
pub(crate) user: bool,
Expand All @@ -310,6 +350,74 @@ pub(crate) struct PluginsEditCommand {
pub(crate) global: bool,
}

/// Args for `nemo-relay plugins edit`.
#[derive(Debug, Clone, Default, Args)]
pub(crate) struct PluginsEditCommand {
#[command(flatten)]
pub(crate) scope: PluginsScopeArgs,
}

/// Args for `nemo-relay plugins add`.
#[derive(Debug, Clone, Default, Args)]
pub(crate) struct PluginsAddCommand {
#[command(flatten)]
pub(crate) scope: PluginsScopeArgs,
/// Path to a plugin directory or explicit `relay-plugin.toml`.
pub(crate) path: PathBuf,
}

/// Args for `nemo-relay plugins validate`.
#[derive(Debug, Clone, Args)]
pub(crate) struct PluginsValidateCommand {
/// Canonical plugin ID or a local plugin directory / `relay-plugin.toml` path.
pub(crate) target: String,
/// Emit machine-readable JSON output.
#[arg(long)]
pub(crate) json: bool,
}

/// Args for `nemo-relay plugins list`.
#[derive(Debug, Clone, Default, Args)]
pub(crate) struct PluginsListCommand {
/// Include tombstoned dynamic plugin records in the output.
#[arg(long)]
pub(crate) all: bool,
/// Emit machine-readable JSON output.
#[arg(long)]
pub(crate) json: bool,
}

/// Args for `nemo-relay plugins inspect`.
#[derive(Debug, Clone, Args)]
pub(crate) struct PluginsInspectCommand {
/// Canonical plugin ID.
pub(crate) id: String,
/// Emit machine-readable JSON output.
#[arg(long)]
pub(crate) json: bool,
}

/// Args for `nemo-relay plugins enable`.
#[derive(Debug, Clone, Args)]
pub(crate) struct PluginsEnableCommand {
/// Canonical plugin ID.
pub(crate) id: String,
}

/// Args for `nemo-relay plugins disable`.
#[derive(Debug, Clone, Args)]
pub(crate) struct PluginsDisableCommand {
/// Canonical plugin ID.
pub(crate) id: String,
}

/// Args for `nemo-relay plugins remove`.
#[derive(Debug, Clone, Args)]
pub(crate) struct PluginsRemoveCommand {
/// Canonical plugin ID.
pub(crate) id: String,
}

#[derive(Debug, Clone, Default, Args)]
pub(crate) struct ServerArgs {
/// Path to an explicit config file (disables auto-discovery of workspace/global/system)
Expand Down Expand Up @@ -623,6 +731,13 @@ pub(crate) fn resolve_server_config(args: &ServerArgs) -> Result<ResolvedConfig,
Ok(resolved)
}

/// Resolves shared config for plugin-facing CLI commands without mutating gateway runtime fields.
pub(crate) fn resolve_plugins_config(
explicit: Option<&PathBuf>,
) -> Result<ResolvedConfig, CliError> {
load_shared_config(explicit)
}

/// Resolves transparent `run` configuration and switches the gateway to an ephemeral bind address.
///
/// Explicit run arguments override inherited top-level server flags, which override shared config.
Expand Down
30 changes: 30 additions & 0 deletions crates/cli/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,17 @@ use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use nemo_relay::error::FlowError;
use serde::Serialize;
use serde_json::{Map, Value, json};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum PluginLifecycleFailureKind {
Failed,
NotFound,
Refused,
}

#[derive(Debug, thiserror::Error)]
pub(crate) enum CliError {
#[error("guardrail rejected: {0}")]
Expand All @@ -27,6 +36,13 @@ pub(crate) enum CliError {
Config(String),
#[error("launcher error: {0}")]
Launch(String),
#[error("{message}")]
PluginLifecycle {
command: &'static str,
target: Option<String>,
kind: PluginLifecycleFailureKind,
message: String,
},
#[error("NeMo Relay runtime error: {0}")]
Flow(#[from] nemo_relay::error::FlowError),
#[error("openinference error: {0}")]
Expand All @@ -41,6 +57,20 @@ impl CliError {
_ => None,
}
}

pub(crate) fn plugin_lifecycle(
&self,
) -> Option<(&'static str, Option<&str>, PluginLifecycleFailureKind, &str)> {
match self {
Self::PluginLifecycle {
command,
target,
kind,
message,
} => Some((command, target.as_deref(), *kind, message.as_str())),
_ => None,
}
}
}

impl IntoResponse for CliError {
Expand Down
40 changes: 35 additions & 5 deletions crates/cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ async fn run_command(command: Command, server: &ServerArgs) -> Result<ExitCode,
launcher::easy_path(CodingAgent::Hermes, command, Some(server)).await
}
Command::Config(command) => run_config(command).await,
Command::Plugins(command) => run_plugins(command),
Command::Plugins(command) => run_plugins(command, server),
Command::Pricing(command) => run_pricing(command),
Command::Doctor(command) => run_doctor(command).await,
Command::Agents(command) => doctor::run_agents(command.json).await,
Expand All @@ -101,11 +101,41 @@ async fn run_config(command: ConfigCommand) -> Result<ExitCode, error::CliError>
Ok(ExitCode::SUCCESS)
}

fn run_plugins(command: PluginsCommand) -> Result<ExitCode, error::CliError> {
match command.command {
PluginsSubcommand::Edit(command) => plugins::edit(command)?,
fn run_plugins(command: PluginsCommand, server: &ServerArgs) -> Result<ExitCode, error::CliError> {
let json_context = command
.command
.json_context()
.map(|context| (context.command, context.target.map(str::to_owned)));
let json = json_context.is_some();
let result = match command.command {
PluginsSubcommand::Edit(command) => plugins::edit(command),
PluginsSubcommand::Add(command) => plugins::lifecycle::add(command, server),
PluginsSubcommand::Validate(command) => plugins::lifecycle::validate(command, server),
PluginsSubcommand::List(command) => plugins::lifecycle::list(command, server),
PluginsSubcommand::Inspect(command) => plugins::lifecycle::inspect(command, server),
PluginsSubcommand::Enable(command) => plugins::lifecycle::enable(command, server),
PluginsSubcommand::Disable(command) => plugins::lifecycle::disable(command, server),
PluginsSubcommand::Remove(command) => plugins::lifecycle::remove(command, server),
};
match result {
Ok(()) => Ok(ExitCode::SUCCESS),
Err(error) => {
if let Some(exit_code) = plugins::lifecycle::render_plugin_error(&error, json)? {
Ok(exit_code)
} else if json {
let (json_command, json_target) = json_context
.as_ref()
.expect("json plugin command context should exist when json output is enabled");
plugins::lifecycle::render_generic_plugin_json_error(
json_command,
json_target.as_deref(),
&error.to_string(),
)
} else {
Err(error)
}
}
}
Ok(ExitCode::SUCCESS)
}

fn run_pricing(command: PricingCommand) -> Result<ExitCode, error::CliError> {
Expand Down
3 changes: 2 additions & 1 deletion crates/cli/src/plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ use crate::error::CliError;

pub(crate) mod config_io;
mod editor_model;
pub(crate) mod lifecycle;

use self::config_io::*;
use self::editor_model::*;
Expand Down Expand Up @@ -91,7 +92,7 @@ fn print_save_success(path: &Path) {

pub(crate) fn edit(command: PluginsEditCommand) -> Result<(), CliError> {
ensure_tty()?;
let scope = target_scope(&command)?;
let scope = target_scope(&command.scope)?;
let path = target_path(scope)?;
let mut config = read_plugin_config(&path)?;
ensure_observability_component(&mut config)?;
Expand Down
Loading