mod adapters;
mod alignment;
mod banner;
mod completions_install;
mod config;
mod doctor;
mod error;
mod gateway;
mod installer;
mod json_path;
mod launcher;
mod model;
mod model_pricing;
mod plugin_install;
mod plugin_shim;
mod plugins;
mod server;
mod session;
mod setup;
use std::process::ExitCode;
use clap::Parser;
use crate::config::{
Cli, CodingAgent, Command, CompletionsCommand, ConfigCommand, DoctorCommand, PluginsCommand,
PluginsSubcommand, PricingCommand, PricingSubcommand, ServerArgs,
};
#[tokio::main]
async fn main() -> ExitCode {
match run().await {
Ok(code) => code,
Err(error) => {
let exit_code = if error.guardrail_rejection_reason().is_some() {
ExitCode::from(2)
} else {
ExitCode::FAILURE
};
eprintln!("{error}");
exit_code
}
}
}
async fn run() -> Result<ExitCode, error::CliError> {
let cli = Cli::parse();
match cli.command {
Some(command) => run_command(command, &cli.server).await,
None => run_default(&cli.server).await,
}
}
async fn run_command(command: Command, server: &ServerArgs) -> Result<ExitCode, error::CliError> {
match command {
Command::HookForward(command) => {
installer::hook_forward(command).await?;
Ok(ExitCode::SUCCESS)
}
Command::PluginShim(command) => plugin_shim::run(command),
Command::Install(command) => plugin_install::install(command),
Command::Uninstall(command) => plugin_install::uninstall(command),
Command::Run(command) => launcher::run(command, Some(server)).await,
Command::Claude(command) => {
launcher::easy_path(CodingAgent::ClaudeCode, command, Some(server)).await
}
Command::Codex(command) => {
launcher::easy_path(CodingAgent::Codex, command, Some(server)).await
}
Command::Hermes(command) => {
launcher::easy_path(CodingAgent::Hermes, command, Some(server)).await
}
Command::Config(command) => run_config(command).await,
Command::Plugins(command) => run_plugins(command, server),
Command::ModelPricing(command) => run_pricing(command),
Command::Doctor(command) => run_doctor(command).await,
Command::Agents(command) => doctor::run_agents(command.json).await,
Command::Completions(command) => run_completions(command),
}
}
async fn run_config(command: ConfigCommand) -> Result<ExitCode, error::CliError> {
if command.reset {
setup::reset(command.agent)?;
} else {
setup::run(command.agent).await?;
}
Ok(ExitCode::SUCCESS)
}
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)
}
}
}
}
fn run_pricing(command: PricingCommand) -> Result<ExitCode, error::CliError> {
match command.command {
PricingSubcommand::Validate(command) => model_pricing::validate(command)?,
PricingSubcommand::Init(command) => model_pricing::init(command)?,
PricingSubcommand::AddSource(command) => model_pricing::add_source(command)?,
PricingSubcommand::Resolve(command) => model_pricing::resolve(command)?,
}
Ok(ExitCode::SUCCESS)
}
async fn run_doctor(command: DoctorCommand) -> Result<ExitCode, error::CliError> {
if let Some(plugin) = command.plugin {
plugin_install::doctor(plugin, command.install_dir, command.json)
} else {
doctor::run_doctor(command.agent, command.json).await
}
}
fn run_completions(command: CompletionsCommand) -> Result<ExitCode, error::CliError> {
if command.install {
let path = completions_install::install(command.shell)?;
println!("✓ Installed completions: {}", path.display());
} else {
generate_completions(command.shell)?;
}
Ok(ExitCode::SUCCESS)
}
fn generate_completions(shell: Option<clap_complete::Shell>) -> Result<(), error::CliError> {
generate_completions_to(shell, &mut std::io::stdout())
}
fn generate_completions_to(
shell: Option<clap_complete::Shell>,
writer: &mut dyn std::io::Write,
) -> Result<(), error::CliError> {
let shell = shell.ok_or_else(|| {
error::CliError::Config(
"missing shell argument; pass a shell name (bash, zsh, fish, ...) or \
use `--install` to auto-detect from $SHELL"
.into(),
)
})?;
let mut clap_command = <Cli as clap::CommandFactory>::command();
clap_complete::generate(shell, &mut clap_command, "nemo-relay", writer);
Ok(())
}
async fn run_default(server_args: &ServerArgs) -> Result<ExitCode, error::CliError> {
if server_args.requested_daemon_mode() {
let resolved = config::resolve_server_config(server_args)?;
let dynamic_plugins = plugins::lifecycle::active_dynamic_plugin_components(
server_args.config.as_ref(),
&resolved,
)?;
server::serve_with_dynamic(resolved.gateway, dynamic_plugins).await?;
Ok(ExitCode::SUCCESS)
} else if config::any_config_file_exists() {
doctor::run_doctor(None, false).await
} else {
setup::run(None).await?;
Ok(ExitCode::SUCCESS)
}
}
#[cfg(test)]
mod test_support {
#[must_use]
pub(crate) struct CwdTestScope {
_guard: std::sync::MutexGuard<'static, ()>,
prev: Option<std::path::PathBuf>,
}
impl CwdTestScope {
pub(crate) fn locked() -> Self {
Self {
_guard: lock_cwd(),
prev: None,
}
}
pub(crate) fn enter(path: &std::path::Path) -> Self {
let guard = lock_cwd();
let prev = std::env::current_dir().unwrap();
std::env::set_current_dir(path).unwrap();
Self {
_guard: guard,
prev: Some(prev),
}
}
}
impl Drop for CwdTestScope {
fn drop(&mut self) {
if let Some(prev) = &self.prev
&& let Err(error) = std::env::set_current_dir(prev)
{
CWD_RESTORE_FAILED.store(true, std::sync::atomic::Ordering::SeqCst);
if std::thread::panicking() {
eprintln!("failed to restore current_dir to {prev:?}: {error}");
} else {
panic!("failed to restore current_dir to {prev:?}: {error}");
}
}
}
}
pub(crate) static CWD_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
static CWD_RESTORE_FAILED: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(false);
pub(crate) static ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
pub(crate) static PLUGIN_CONFIG_TEST_LOCK: tokio::sync::Mutex<()> =
tokio::sync::Mutex::const_new(());
fn lock_cwd() -> std::sync::MutexGuard<'static, ()> {
let guard = CWD_TEST_LOCK.lock().expect("CWD_TEST_LOCK poisoned");
assert!(
!CWD_RESTORE_FAILED.load(std::sync::atomic::Ordering::SeqCst),
"current_dir restore failed in a previous test; aborting to prevent cross-test contamination",
);
guard
}
}
#[cfg(test)]
#[path = "../tests/coverage/main_tests.rs"]
mod tests;