use std::collections::HashSet;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use axum::http::HeaderMap;
use clap::{ArgGroup, Args, Parser, Subcommand, ValueEnum};
use serde::Deserialize;
use serde_json::Value;
use crate::error::CliError;
#[derive(Debug, Clone, Parser)]
#[command(name = "nemo-relay")]
#[command(about = "Coding-agent gateway for NeMo Relay observability")]
#[command(version)]
pub(crate) struct Cli {
#[command(flatten)]
pub(crate) server: ServerArgs,
#[command(subcommand)]
pub(crate) command: Option<Command>,
}
#[derive(Debug, Clone, Subcommand)]
pub(crate) enum Command {
#[command(
long_about = "Run Anthropic's `claude` CLI under an ephemeral NeMo Relay gateway. \
Observability (ATIF + OpenInference) is wired in transparently via \
ANTHROPIC_BASE_URL. First-time use launches the setup wizard so the \
`[agents.claude]` block lands in `.nemo-relay/config.toml` and observation \
starts on the next invocation without prompts.",
after_help = "Examples:\n \
nemo-relay claude\n \
nemo-relay claude -- chat \"refactor the launcher\"\n \
nemo-relay claude -- --resume <session-id>"
)]
Claude(EasyPathCommand),
#[command(
long_about = "Run OpenAI's `codex` CLI under an ephemeral NeMo Relay gateway. NeMo Relay \
injects a `nemo-relay-openai` provider override so codex points at the \
gateway; the gateway then forwards to `--openai-base-url` (defaults to \
api.openai.com) with `OPENAI_API_KEY` injected on the codex route (see \
NMF-86 — codex's own auth.json JWT is stripped). Requires codex-cli >= \
0.129.0.",
after_help = "Examples:\n \
nemo-relay codex\n \
nemo-relay codex -- exec \"fix the bug in foo.rs\"\n \
nemo-relay --openai-base-url https://inference-api.nvidia.com codex"
)]
Codex(EasyPathCommand),
#[command(
long_about = "Run Cursor's `cursor-agent` CLI under an ephemeral NeMo Relay gateway. The \
launcher temporarily patches `.cursor/hooks.json` in the project root \
during the run and restores it on exit. Disable that via \
`[agents.cursor] patch_restore_hooks = false` in config.toml if you \
maintain `.cursor/hooks.json` yourself.",
after_help = "Examples:\n \
nemo-relay cursor\n \
nemo-relay cursor -- agent --resume <session-id>"
)]
Cursor(EasyPathCommand),
#[command(
long_about = "Run NVIDIA's Hermes agent under a NeMo Relay gateway. Hermes reads hooks \
from `.hermes/config.yaml`; first-run setup writes that file alongside \
`.nemo-relay/config.toml` so every subsequent invocation traces \
automatically. Re-run `nemo-relay config hermes` to refresh the hooks.",
after_help = "Examples:\n \
nemo-relay hermes\n \
nemo-relay hermes -- chat --provider custom"
)]
Hermes(EasyPathCommand),
Config(ConfigCommand),
Plugins(PluginsCommand),
Doctor(DoctorCommand),
Agents(AgentsCommand),
Completions(CompletionsCommand),
Run(RunCommand),
#[command(hide = true)]
HookForward(HookForwardCommand),
}
#[derive(Debug, Clone, Args)]
pub(crate) struct DoctorCommand {
#[arg(value_enum)]
pub(crate) agent: Option<CodingAgent>,
#[arg(long)]
pub(crate) json: bool,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct AgentsCommand {
#[arg(long)]
pub(crate) json: bool,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct CompletionsCommand {
#[arg(value_enum)]
pub(crate) shell: Option<clap_complete::Shell>,
#[arg(long)]
pub(crate) install: bool,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct ConfigCommand {
#[arg(value_enum)]
pub(crate) agent: Option<CodingAgent>,
#[arg(long)]
pub(crate) reset: bool,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct PluginsCommand {
#[command(subcommand)]
pub(crate) command: PluginsSubcommand,
}
#[derive(Debug, Clone, Subcommand)]
pub(crate) enum PluginsSubcommand {
Edit(PluginsEditCommand),
}
#[derive(Debug, Clone, Default, Args)]
#[command(group(
ArgGroup::new("scope")
.args(["user", "project", "global"])
.multiple(false)
))]
pub(crate) struct PluginsEditCommand {
#[arg(long)]
pub(crate) user: bool,
#[arg(long)]
pub(crate) project: bool,
#[arg(long)]
pub(crate) global: bool,
}
#[derive(Debug, Clone, Default, Args)]
pub(crate) struct ServerArgs {
#[arg(long)]
pub(crate) config: Option<PathBuf>,
#[arg(long, env = "NEMO_RELAY_GATEWAY_BIND")]
pub(crate) bind: Option<SocketAddr>,
#[arg(long, env = "NEMO_RELAY_OPENAI_BASE_URL")]
pub(crate) openai_base_url: Option<String>,
#[arg(long, env = "NEMO_RELAY_ANTHROPIC_BASE_URL")]
pub(crate) anthropic_base_url: Option<String>,
#[arg(long, env = "NEMO_RELAY_PLUGIN_CONFIG")]
pub(crate) plugin_config: Option<String>,
}
impl ServerArgs {
pub(crate) fn requested_daemon_mode(&self) -> bool {
self.bind.is_some()
|| self.openai_base_url.is_some()
|| self.anthropic_base_url.is_some()
|| self.plugin_config.is_some()
|| self.config.is_some()
}
}
#[derive(Debug, Clone)]
pub(crate) struct GatewayConfig {
pub(crate) bind: SocketAddr,
pub(crate) openai_base_url: String,
pub(crate) anthropic_base_url: String,
pub(crate) metadata: Option<Value>,
pub(crate) plugin_config: Option<Value>,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct HookForwardCommand {
#[arg(value_enum)]
pub(crate) agent: CodingAgent,
#[arg(long)]
pub(crate) gateway_url: Option<String>,
#[arg(long)]
pub(crate) profile: Option<String>,
#[arg(long)]
pub(crate) session_metadata: Option<String>,
#[arg(long)]
pub(crate) plugin_config: Option<String>,
#[arg(long, value_enum)]
pub(crate) gateway_mode: Option<GatewayMode>,
#[arg(long)]
pub(crate) fail_closed: bool,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct EasyPathCommand {
#[arg(last = true)]
pub(crate) command: Vec<String>,
}
#[derive(Debug, Clone, Args)]
pub(crate) struct RunCommand {
#[arg(long, value_enum)]
pub(crate) agent: Option<CodingAgent>,
#[arg(long)]
pub(crate) config: Option<PathBuf>,
#[arg(long)]
pub(crate) openai_base_url: Option<String>,
#[arg(long)]
pub(crate) anthropic_base_url: Option<String>,
#[arg(long)]
pub(crate) session_metadata: Option<String>,
#[arg(long)]
pub(crate) plugin_config: Option<String>,
#[arg(long)]
pub(crate) dry_run: bool,
#[arg(long)]
pub(crate) print: bool,
#[arg(last = true)]
pub(crate) command: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "kebab-case")]
pub(crate) enum CodingAgent {
#[value(name = "claude", alias = "claude-code")]
ClaudeCode,
Codex,
Cursor,
Hermes,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "kebab-case")]
pub(crate) enum GatewayMode {
HookOnly,
Passthrough,
Required,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct SessionConfig {
pub(crate) metadata: Option<Value>,
pub(crate) plugin_config: Option<Value>,
pub(crate) profile: Option<String>,
pub(crate) gateway_mode: Option<String>,
}
impl GatewayConfig {
pub(crate) fn session_config_from_headers(&self, headers: &HeaderMap) -> SessionConfig {
let metadata =
header_json(headers, "x-nemo-relay-session-metadata").or_else(|| self.metadata.clone());
let plugin_config = header_json(headers, "x-nemo-relay-plugin-config")
.or_else(|| self.plugin_config.clone());
let profile = header_string(headers, "x-nemo-relay-config-profile");
let gateway_mode = header_string(headers, "x-nemo-relay-gateway-mode");
SessionConfig {
metadata,
plugin_config,
profile,
gateway_mode,
}
}
}
#[derive(Debug, Clone, Default)]
pub(crate) struct ResolvedConfig {
pub(crate) gateway: GatewayConfig,
pub(crate) agents: AgentConfigs,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct AgentConfigs {
pub(crate) claude: AgentCommandConfig,
pub(crate) codex: AgentCommandConfig,
pub(crate) cursor: CursorAgentConfig,
pub(crate) hermes: AgentCommandConfig,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct AgentCommandConfig {
pub(crate) command: Option<String>,
pub(crate) hooks_path: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub(crate) struct CursorAgentConfig {
pub(crate) command: Option<String>,
pub(crate) patch_restore_hooks: bool,
}
impl Default for CursorAgentConfig {
fn default() -> Self {
Self {
command: None,
patch_restore_hooks: true,
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
struct FileConfig {
upstream: Option<FileUpstreamConfig>,
plugins: Option<FilePluginsConfig>,
agents: Option<FileAgentsConfig>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct FileUpstreamConfig {
openai_base_url: Option<String>,
anthropic_base_url: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct FilePluginsConfig {
config: Option<Value>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct FileAgentsConfig {
claude: Option<FileAgentCommandConfig>,
codex: Option<FileAgentCommandConfig>,
cursor: Option<FileCursorAgentConfig>,
hermes: Option<FileAgentCommandConfig>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct FileAgentCommandConfig {
command: Option<String>,
hooks_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, Deserialize)]
struct FileCursorAgentConfig {
command: Option<String>,
patch_restore_hooks: Option<bool>,
}
impl Default for GatewayConfig {
fn default() -> Self {
Self {
bind: "127.0.0.1:4040"
.parse()
.expect("valid default bind address"),
openai_base_url: "https://api.openai.com/v1".into(),
anthropic_base_url: "https://api.anthropic.com".into(),
metadata: None,
plugin_config: None,
}
}
}
pub(crate) fn resolve_server_config(args: &ServerArgs) -> Result<ResolvedConfig, CliError> {
let mut resolved = load_shared_config(args.config.as_ref())?;
apply_server_overrides(&mut resolved.gateway, args)?;
Ok(resolved)
}
pub(crate) fn resolve_run_config(
command: &RunCommand,
inherited: Option<&ServerArgs>,
) -> Result<ResolvedConfig, CliError> {
let config = command
.config
.as_ref()
.or_else(|| inherited.and_then(|args| args.config.as_ref()));
let mut resolved = load_shared_config(config)?;
if let Some(args) = inherited {
if command.plugin_config.is_some() && args.plugin_config.is_some() {
let mut inherited = args.clone();
inherited.plugin_config = None;
apply_server_overrides(&mut resolved.gateway, &inherited)?;
} else {
apply_server_overrides(&mut resolved.gateway, args)?;
}
}
apply_run_overrides(&mut resolved.gateway, command)?;
resolved.gateway.bind = "127.0.0.1:0"
.parse()
.expect("valid transparent bind address");
Ok(resolved)
}
fn apply_run_overrides(config: &mut GatewayConfig, command: &RunCommand) -> Result<(), CliError> {
apply_run_url_overrides(config, command);
apply_run_json_overrides(config, command)?;
Ok(())
}
fn apply_run_url_overrides(config: &mut GatewayConfig, command: &RunCommand) {
if let Some(value) = &command.openai_base_url {
config.openai_base_url = value.clone();
}
if let Some(value) = &command.anthropic_base_url {
config.anthropic_base_url = value.clone();
}
}
fn apply_run_json_overrides(
config: &mut GatewayConfig,
command: &RunCommand,
) -> Result<(), CliError> {
if let Some(value) = &command.session_metadata {
config.metadata = Some(parse_json_option("session metadata", value)?);
}
if let Some(value) = &command.plugin_config {
apply_cli_plugin_config(config, value)?;
}
Ok(())
}
fn apply_server_overrides(config: &mut GatewayConfig, args: &ServerArgs) -> Result<(), CliError> {
if let Some(value) = args.bind {
config.bind = value;
}
if let Some(value) = &args.openai_base_url {
config.openai_base_url = value.clone();
}
if let Some(value) = &args.anthropic_base_url {
config.anthropic_base_url = value.clone();
}
if let Some(value) = &args.plugin_config {
apply_cli_plugin_config(config, value)?;
}
Ok(())
}
const PLUGINS_TOML: &str = "plugins.toml";
fn load_shared_config(explicit: Option<&PathBuf>) -> Result<ResolvedConfig, CliError> {
let mut merged = toml::Value::Table(toml::map::Map::new());
let mut config_toml_plugin_sources = Vec::new();
for path in config_paths(explicit) {
if path.exists() {
let raw = std::fs::read_to_string(&path)?;
let parsed = raw
.parse::<toml::Table>()
.map(toml::Value::Table)
.map_err(|error| {
CliError::Config(format!("invalid TOML in {}: {error}", path.display()))
})?;
let legacy_observability = legacy_observability_sections(&parsed);
if !legacy_observability.is_empty() {
return Err(CliError::Config(format!(
"legacy observability config in {} is no longer supported: {}; configure \
observability in plugins.toml with `nemo-relay plugins edit`",
path.display(),
legacy_observability.join(", ")
)));
}
if has_config_toml_plugin_config(&parsed) {
config_toml_plugin_sources.push(path.clone());
}
merge_toml(&mut merged, parsed);
}
}
if config_toml_plugin_sources.len() > 1 {
return Err(CliError::Config(format!(
"plugin config is defined in multiple config.toml files: {}; move it to one \
[plugins].config block or use plugins.toml",
format_paths(&config_toml_plugin_sources)
)));
}
let plugin_toml = load_plugin_toml_config(explicit)?;
let mut resolved = ResolvedConfig {
gateway: GatewayConfig::default(),
..ResolvedConfig::default()
};
apply_file_config(&mut resolved, merged)?;
apply_plugin_toml_config(
&mut resolved.gateway,
config_toml_plugin_sources.first(),
plugin_toml,
)?;
apply_env_config(&mut resolved.gateway);
Ok(resolved)
}
pub(crate) fn any_config_file_exists() -> bool {
config_paths(None).iter().any(|path| path.exists())
}
fn config_paths(explicit: Option<&PathBuf>) -> Vec<PathBuf> {
if let Some(path) = explicit {
return vec![path.clone()];
}
let mut paths = vec![PathBuf::from("/etc/nemo-relay/config.toml")];
if let Ok(cwd) = std::env::current_dir()
&& let Some(project) = find_project_config(&cwd)
{
paths.push(project);
}
if let Some(user) = user_config_path() {
paths.push(user);
}
paths
}
fn plugin_config_paths(explicit: Option<&PathBuf>) -> Vec<PathBuf> {
if let Some(path) = explicit {
return path
.parent()
.map(|parent| vec![parent.join(PLUGINS_TOML)])
.unwrap_or_default();
}
implicit_plugin_config_paths(std::env::current_dir().ok().as_deref(), user_config_dir())
}
fn implicit_plugin_config_paths(
cwd: Option<&std::path::Path>,
user_config_dir: Option<PathBuf>,
) -> Vec<PathBuf> {
let mut paths = vec![PathBuf::from("/etc/nemo-relay").join(PLUGINS_TOML)];
if let Some(cwd) = cwd
&& let Some(project) = find_project_plugin_config(cwd)
{
paths.push(project);
}
if let Some(user) = user_config_dir {
paths.push(user.join(PLUGINS_TOML));
}
paths
}
fn find_project_config(start: &std::path::Path) -> Option<PathBuf> {
for ancestor in start.ancestors() {
let path = ancestor.join(".nemo-relay/config.toml");
if path.exists() {
return Some(path);
}
}
None
}
fn find_project_plugin_config(start: &std::path::Path) -> Option<PathBuf> {
for ancestor in start.ancestors() {
let path = ancestor.join(".nemo-relay").join(PLUGINS_TOML);
if path.exists() {
return Some(path);
}
}
None
}
pub(crate) fn user_plugin_config_path() -> Option<PathBuf> {
user_config_dir().map(|dir| dir.join(PLUGINS_TOML))
}
pub(crate) fn project_plugin_config_path(start: &std::path::Path) -> PathBuf {
find_project_plugin_config(start)
.or_else(|| {
find_project_config(start)
.and_then(|path| path.parent().map(|parent| parent.join(PLUGINS_TOML)))
})
.unwrap_or_else(|| start.join(".nemo-relay").join(PLUGINS_TOML))
}
pub(crate) fn global_plugin_config_path() -> PathBuf {
PathBuf::from("/etc/nemo-relay").join(PLUGINS_TOML)
}
fn user_config_path() -> Option<PathBuf> {
user_config_dir().map(|dir| dir.join("config.toml"))
}
pub(crate) fn user_config_dir() -> Option<PathBuf> {
if let Some(base) = std::env::var_os("XDG_CONFIG_HOME") {
return Some(PathBuf::from(base).join("nemo-relay"));
}
home_dir().map(|home| home.join(".config/nemo-relay"))
}
fn apply_file_config(resolved: &mut ResolvedConfig, value: toml::Value) -> Result<(), CliError> {
let config: FileConfig = value.try_into().map_err(|error| {
CliError::Config(format!("invalid gateway configuration shape: {error}"))
})?;
apply_file_upstream_config(&mut resolved.gateway, config.upstream);
apply_file_plugins_config(&mut resolved.gateway, config.plugins);
apply_file_agents_config(&mut resolved.agents, config.agents);
Ok(())
}
fn apply_file_upstream_config(gateway: &mut GatewayConfig, upstream: Option<FileUpstreamConfig>) {
let Some(upstream) = upstream else {
return;
};
if let Some(value) = upstream.openai_base_url {
gateway.openai_base_url = value;
}
if let Some(value) = upstream.anthropic_base_url {
gateway.anthropic_base_url = value;
}
}
fn apply_file_plugins_config(gateway: &mut GatewayConfig, plugins: Option<FilePluginsConfig>) {
let Some(plugins) = plugins else {
return;
};
if let Some(value) = plugins.config {
gateway.plugin_config = Some(value);
}
}
#[derive(Debug, Clone)]
struct PluginTomlConfig {
value: Value,
sources: Vec<PathBuf>,
}
fn load_plugin_toml_config(
explicit: Option<&PathBuf>,
) -> Result<Option<PluginTomlConfig>, CliError> {
load_plugin_toml_config_from_paths(plugin_config_paths(explicit))
}
fn load_plugin_toml_config_from_paths<I>(paths: I) -> Result<Option<PluginTomlConfig>, CliError>
where
I: IntoIterator<Item = PathBuf>,
{
let mut merged = toml::Value::Table(toml::map::Map::new());
let mut sources = Vec::new();
for path in paths {
if path.exists() {
let raw = std::fs::read_to_string(&path)?;
let parsed = raw
.parse::<toml::Table>()
.map(toml::Value::Table)
.map_err(|error| {
CliError::Config(format!(
"invalid plugin TOML in {}: {error}",
path.display()
))
})?;
validate_plugin_toml_component_kinds(&path, &parsed)?;
merge_plugin_toml(&mut merged, parsed);
sources.push(path);
}
}
if sources.is_empty() {
return Ok(None);
}
let value = serde_json::to_value(merged)
.map_err(|error| CliError::Config(format!("invalid plugin TOML shape: {error}")))?;
Ok(Some(PluginTomlConfig { value, sources }))
}
fn apply_plugin_toml_config(
gateway: &mut GatewayConfig,
config_toml_plugin_source: Option<&PathBuf>,
plugin_toml: Option<PluginTomlConfig>,
) -> Result<(), CliError> {
let Some(plugin_toml) = plugin_toml else {
return Ok(());
};
if let Some(config_source) = config_toml_plugin_source {
return Err(CliError::Config(format!(
"plugin config is defined in both {} and {}; choose one source",
config_source.display(),
format_paths(&plugin_toml.sources)
)));
}
gateway.plugin_config = Some(plugin_toml.value);
Ok(())
}
fn apply_cli_plugin_config(config: &mut GatewayConfig, value: &str) -> Result<(), CliError> {
if config.plugin_config.is_some() {
return Err(CliError::Config(
"plugin config is defined by both --plugin-config and file configuration; choose one source".into(),
));
}
config.plugin_config = Some(parse_json_option("plugin config", value)?);
Ok(())
}
fn apply_file_agents_config(agents: &mut AgentConfigs, file_agents: Option<FileAgentsConfig>) {
let Some(file_agents) = file_agents else {
return;
};
if let Some(value) = file_agents.claude {
agents.claude.command = value.command;
}
if let Some(value) = file_agents.codex {
agents.codex.command = value.command;
}
if let Some(value) = file_agents.cursor {
agents.cursor.command = value.command;
if let Some(patch_restore_hooks) = value.patch_restore_hooks {
agents.cursor.patch_restore_hooks = patch_restore_hooks;
}
}
if let Some(value) = file_agents.hermes {
agents.hermes.command = value.command;
agents.hermes.hooks_path = value.hooks_path;
}
}
fn apply_env_config(config: &mut GatewayConfig) {
if let Ok(value) = std::env::var("NEMO_RELAY_GATEWAY_BIND")
&& let Ok(value) = value.parse()
{
config.bind = value;
}
if let Ok(value) = std::env::var("NEMO_RELAY_OPENAI_BASE_URL") {
config.openai_base_url = value;
}
if let Ok(value) = std::env::var("NEMO_RELAY_ANTHROPIC_BASE_URL") {
config.anthropic_base_url = value;
}
}
fn merge_toml(left: &mut toml::Value, right: toml::Value) {
match (left, right) {
(toml::Value::Table(left), toml::Value::Table(right)) => {
for (key, value) in right {
match left.get_mut(&key) {
Some(existing) => merge_toml(existing, value),
None => {
left.insert(key, value);
}
}
}
}
(left, right) => *left = right,
}
}
fn merge_plugin_toml(left: &mut toml::Value, right: toml::Value) {
match (left, right) {
(toml::Value::Table(left), toml::Value::Table(right)) => {
for (key, value) in right {
match (key.as_str(), left.get_mut(&key)) {
("components", Some(existing)) => merge_plugin_components(existing, value),
(_, Some(existing)) => merge_toml(existing, value),
_ => {
left.insert(key, value);
}
}
}
}
(left, right) => *left = right,
}
}
fn merge_plugin_components(left: &mut toml::Value, right: toml::Value) {
let toml::Value::Array(left_components) = left else {
*left = right;
return;
};
let toml::Value::Array(right_components) = right else {
*left = right;
return;
};
for component in right_components {
let Some(kind) = component_kind(&component).map(str::to_owned) else {
left_components.push(component);
continue;
};
if let Some(existing) = left_components
.iter_mut()
.find(|candidate| component_kind(candidate) == Some(kind.as_str()))
{
merge_toml(existing, component);
} else {
left_components.push(component);
}
}
}
fn component_kind(component: &toml::Value) -> Option<&str> {
component
.as_table()
.and_then(|table| table.get("kind"))
.and_then(toml::Value::as_str)
}
fn validate_plugin_toml_component_kinds(path: &Path, value: &toml::Value) -> Result<(), CliError> {
let Some(components) = value.get("components").and_then(toml::Value::as_array) else {
return Ok(());
};
let mut seen = HashSet::new();
let mut duplicates = Vec::new();
for component in components {
let Some(kind) = component_kind(component) else {
continue;
};
if !seen.insert(kind.to_string()) {
duplicates.push(kind.to_string());
}
}
duplicates.sort();
duplicates.dedup();
if duplicates.is_empty() {
Ok(())
} else {
Err(CliError::Config(format!(
"duplicate plugin component kind in {}: {}; declare each kind once per plugins.toml",
path.display(),
duplicates.join(", ")
)))
}
}
fn has_config_toml_plugin_config(value: &toml::Value) -> bool {
value
.get("plugins")
.and_then(|plugins| plugins.get("config"))
.is_some()
}
fn legacy_observability_sections(value: &toml::Value) -> Vec<&'static str> {
let mut sections = Vec::new();
if value.get("exporters").is_some() {
sections.push("[exporters]");
}
if value.get("observability").is_some() {
sections.push("[observability]");
}
if value
.get("export")
.and_then(|export| export.get("openinference"))
.is_some()
{
sections.push("[export.openinference]");
}
sections
}
fn format_paths(paths: &[PathBuf]) -> String {
paths
.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
}
fn parse_json_option(name: &str, value: &str) -> Result<Value, CliError> {
serde_json::from_str::<Value>(value)
.map_err(|error| CliError::Config(format!("invalid {name}: {error}")))
}
fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}
pub(crate) fn header_string(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn header_json(headers: &HeaderMap, name: &str) -> Option<Value> {
header_string(headers, name).and_then(|raw| serde_json::from_str(&raw).ok())
}
impl CodingAgent {
pub(crate) const fn hook_path(self) -> &'static str {
match self {
Self::ClaudeCode => "/hooks/claude-code",
Self::Codex => "/hooks/codex",
Self::Cursor => "/hooks/cursor",
Self::Hermes => "/hooks/hermes",
}
}
pub(crate) const fn as_arg(self) -> &'static str {
match self {
Self::ClaudeCode => "claude",
Self::Codex => "codex",
Self::Cursor => "cursor",
Self::Hermes => "hermes",
}
}
pub(crate) fn infer(command: &str) -> Option<Self> {
let name = std::path::Path::new(command)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(command);
match name {
"claude" | "claude-code" => Some(Self::ClaudeCode),
"codex" => Some(Self::Codex),
"cursor" | "cursor-agent" => Some(Self::Cursor),
"hermes" | "hermes-agent" => Some(Self::Hermes),
_ => None,
}
}
}
impl GatewayMode {
pub(crate) const fn as_arg(self) -> &'static str {
match self {
Self::HookOnly => "hook-only",
Self::Passthrough => "passthrough",
Self::Required => "required",
}
}
}
#[cfg(test)]
#[path = "../tests/coverage/config_tests.rs"]
mod tests;