use std::io::IsTerminal;
use std::path::PathBuf;
use dialoguer::theme::ColorfulTheme;
use dialoguer::{Confirm, MultiSelect, Select};
use toml_edit::DocumentMut;
use crate::config::CodingAgent;
use crate::error::CliError;
mod model;
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,
plugins_edit_command_for_scope, plugins_resume_command, preview_paths, read_existing_defaults,
save_config,
};
#[cfg(test)]
use self::model::{Defaults, global_config_dir, read_agents_from_doc, write_or_merge};
#[cfg(test)]
use self::model::detect_installed_agents_in;
pub(crate) fn prompt_user(
detected_agents: &[CodingAgent],
agent_hint: Option<CodingAgent>,
) -> Result<SetupAnswers, CliError> {
ensure_tty()?;
let defaults = read_existing_defaults().unwrap_or_default();
crate::banner::print_intro();
match agent_hint {
Some(agent) => {
let (name, _) = agent_key_and_command(agent);
println!(" Setting up {name}.");
println!(" Re-run `nemo-relay config` later to configure additional agents.");
}
None => {
println!(" Let's set up your coding agent.");
println!(" This runs once. Re-run later with `nemo-relay config`.");
}
}
if agent_hint.is_none() {
println!();
print_detected_agents(detected_agents);
}
if defaults.has_any() {
println!();
println!(" Existing config detected — current values are pre-selected.");
}
println!();
println!(
" Tip: ↑/↓ to move, SPACE to toggle a checkbox, ENTER to confirm. Defaults are pre-selected."
);
println!();
let theme = ColorfulTheme::default();
let scope = ask_scope(&theme, defaults.scope)?;
let agents = match agent_hint {
Some(agent) => vec![agent],
None => ask_agents(&theme, detected_agents, &defaults.agents)?,
};
if agents.contains(&CodingAgent::Codex) {
print_codex_api_key_guide();
}
Ok(SetupAnswers {
scope,
agents,
hermes_hooks_path: None,
})
}
pub(crate) async fn run(agent_hint: Option<CodingAgent>) -> Result<(), CliError> {
let detected = detect_installed_agents();
let mut answers = prompt_user(&detected, agent_hint)?;
let cwd = std::env::current_dir()?;
let home = home_dir().ok_or_else(|| {
CliError::Config("cannot determine home directory (set $HOME or $USERPROFILE)".into())
})?;
answers.hermes_hooks_path =
hermes_hooks_path_for_scope(&answers.agents, answers.scope, &cwd, &home);
let doc = build_config(&answers);
let mut preview_paths = preview_paths(answers.scope, &cwd, &home);
preview_paths.extend(
hermes_hook_targets(answers.scope, &cwd, &home)
.into_iter()
.filter(|_| answers.agents.contains(&CodingAgent::Hermes)),
);
if !confirm_summary(&preview_paths, &doc)? {
return Err(CliError::Config("setup cancelled — no config saved".into()));
}
let mut written = save_config(&doc, answers.scope, &cwd, &home, agent_hint)?;
if answers.agents.contains(&CodingAgent::Hermes) {
written.extend(install_hermes_hooks(answers.scope, &cwd, &home)?);
}
println!();
println!(" ✓ Saved:");
for path in &written {
println!(" {}", path.display());
}
println!();
continue_to_plugins(answers.scope)
}
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)
)));
}
};
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() {
println!();
println!(" ℹ Codex sends Responses-API requests through the gateway.");
println!(" Authentication (pick one):");
println!(" • ChatGPT-Plus login: codex --login (uses ~/.codex/auth.json)");
println!(" • OpenAI API key: export OPENAI_API_KEY=sk-...");
println!(" When OPENAI_API_KEY is set the gateway uses it; otherwise the");
println!(" ChatGPT-Plus OAuth token is forwarded to the ChatGPT backend.");
println!();
}
fn ensure_tty() -> Result<(), CliError> {
if !std::io::stdin().is_terminal() {
return Err(CliError::Config(
"interactive setup requires a TTY; pass `--config <path>` or set up \
`.nemo-relay/config.toml` manually"
.into(),
));
}
Ok(())
}
fn print_detected_agents(detected: &[CodingAgent]) {
println!(" Detected agents on $PATH:");
for agent in detected {
let (name, _) = agent_key_and_command(*agent);
println!(" ✓ {name}");
}
if detected.is_empty() {
println!(" (none — you can still add agents later)");
}
}
fn ask_scope(
theme: &ColorfulTheme,
existing: Option<ConfigScope>,
) -> Result<ConfigScope, CliError> {
let options = [ConfigScope::Project, ConfigScope::Global, ConfigScope::Both];
let labels: Vec<&str> = options.iter().map(|s| s.label()).collect();
let default_idx = existing
.and_then(|s| options.iter().position(|opt| *opt == s))
.unwrap_or(0);
let idx = Select::with_theme(theme)
.with_prompt("Save config where?")
.items(&labels)
.default(default_idx)
.interact()
.map_err(setup_error)?;
Ok(options[idx])
}
fn ask_agents(
theme: &ColorfulTheme,
detected: &[CodingAgent],
configured: &[CodingAgent],
) -> Result<Vec<CodingAgent>, CliError> {
let all_supported = [
CodingAgent::ClaudeCode,
CodingAgent::Codex,
CodingAgent::Hermes,
];
let labels: Vec<String> = all_supported
.iter()
.map(|a| {
let (name, _) = agent_key_and_command(*a);
name.to_string()
})
.collect();
let defaults: Vec<bool> = if configured.is_empty() {
all_supported.iter().map(|a| detected.contains(a)).collect()
} else {
all_supported
.iter()
.map(|a| configured.contains(a))
.collect()
};
let selected_idx = MultiSelect::with_theme(theme)
.with_prompt("Which agents to observe?")
.items(&labels)
.defaults(&defaults)
.interact()
.map_err(setup_error)?;
Ok(selected_idx.into_iter().map(|i| all_supported[i]).collect())
}
pub(crate) fn confirm_summary(
written_paths: &[PathBuf],
doc: &DocumentMut,
) -> Result<bool, CliError> {
println!();
println!(" ─── Summary ─────────────────────────────────────────────");
println!(" Will write to:");
for path in written_paths {
println!(" {}", path.display());
}
println!();
println!(" Contents:");
for line in doc.to_string().lines() {
println!(" {line}");
}
println!();
Confirm::with_theme(&ColorfulTheme::default())
.with_prompt("Looks good?")
.default(true)
.interact()
.map_err(setup_error)
}
fn setup_error(err: dialoguer::Error) -> CliError {
match err {
dialoguer::Error::IO(io_err)
if matches!(
io_err.kind(),
std::io::ErrorKind::Interrupted | std::io::ErrorKind::UnexpectedEof
) =>
{
CliError::Config("setup cancelled — no config saved".into())
}
other => CliError::Config(format!("setup error: {other}")),
}
}
#[cfg(test)]
#[path = "../tests/coverage/setup_tests.rs"]
mod tests;