use std::path::{Path, PathBuf};
use toml_edit::{DocumentMut, Item, Table, value};
use crate::config::CodingAgent;
use crate::error::CliError;
use crate::installer::{hermes_hooks, hook_forward_command, merge_hermes_config};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ConfigScope {
Project,
Global,
Both,
}
impl ConfigScope {
pub(super) fn label(self) -> &'static str {
match self {
Self::Project => "project ./.nemo-relay/config.toml (recommended)",
Self::Global => "global ~/.config/nemo-relay/config.toml",
Self::Both => "both project overrides global",
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct SetupAnswers {
pub scope: ConfigScope,
pub agents: Vec<CodingAgent>,
pub hermes_hooks_path: Option<PathBuf>,
}
pub(crate) fn detect_installed_agents() -> Vec<CodingAgent> {
detect_installed_agents_in(std::env::var_os("PATH").as_deref())
}
pub(crate) fn detect_installed_agents_in(path_var: Option<&std::ffi::OsStr>) -> Vec<CodingAgent> {
let Some(path_var) = path_var else {
return Vec::new();
};
let candidates = [
(CodingAgent::ClaudeCode, "claude"),
(CodingAgent::Codex, "codex"),
(CodingAgent::Cursor, "cursor-agent"),
(CodingAgent::Hermes, "hermes"),
];
candidates
.into_iter()
.filter_map(|(agent, exec)| {
let found = std::env::split_paths(path_var).any(|dir| {
let candidate = dir.join(exec);
candidate.is_file()
});
found.then_some(agent)
})
.collect()
}
pub(crate) fn build_config(answers: &SetupAnswers) -> DocumentMut {
let mut doc = DocumentMut::new();
if let Some(agents_table) = build_agents_table(answers) {
doc["agents"] = Item::Table(agents_table);
}
doc
}
pub(super) fn build_agents_table(answers: &SetupAnswers) -> Option<Table> {
if answers.agents.is_empty() {
return None;
}
let mut agents_table = Table::new();
for agent in &answers.agents {
let (key, command) = agent_key_and_command(*agent);
let mut agent_table = Table::new();
agent_table["command"] = value(command);
if matches!(agent, CodingAgent::Hermes)
&& let Some(path) = answers.hermes_hooks_path.as_deref()
{
agent_table["hooks_path"] = value(path.display().to_string());
}
agents_table.insert(key, Item::Table(agent_table));
}
Some(agents_table)
}
pub(crate) fn save_config(
doc: &DocumentMut,
scope: ConfigScope,
cwd: &Path,
home: &Path,
merge_scope: Option<CodingAgent>,
) -> Result<Vec<PathBuf>, CliError> {
let mut written = Vec::new();
if matches!(scope, ConfigScope::Project | ConfigScope::Both) {
let project_dir = cwd.join(".nemo-relay");
std::fs::create_dir_all(&project_dir)?;
let path = project_dir.join("config.toml");
write_or_merge(&path, doc, merge_scope)?;
written.push(path);
}
if matches!(scope, ConfigScope::Global | ConfigScope::Both) {
let global_dir = global_config_dir(home);
std::fs::create_dir_all(&global_dir)?;
let path = global_dir.join("config.toml");
write_or_merge(&path, doc, merge_scope)?;
written.push(path);
}
Ok(written)
}
pub(super) fn global_config_dir(home: &Path) -> PathBuf {
if let Some(base) = std::env::var_os("XDG_CONFIG_HOME") {
return PathBuf::from(base).join("nemo-relay");
}
home.join(".config").join("nemo-relay")
}
pub(super) fn write_or_merge(
path: &Path,
doc: &DocumentMut,
merge_scope: Option<CodingAgent>,
) -> Result<(), CliError> {
let Some(agent) = merge_scope else {
std::fs::write(path, doc.to_string())?;
return Ok(());
};
if !path.exists() {
std::fs::write(path, doc.to_string())?;
return Ok(());
}
let existing_raw = std::fs::read_to_string(path)?;
let mut existing: DocumentMut = existing_raw
.parse()
.map_err(|err| CliError::Config(format!("could not parse existing config: {err}")))?;
let agent_key = agent_key_and_command(agent).0;
merge_section(&mut existing, doc, "plugins");
merge_agents_entry(&mut existing, doc, agent_key);
std::fs::write(path, existing.to_string())?;
Ok(())
}
pub(super) fn merge_section(dst: &mut DocumentMut, src: &DocumentMut, key: &str) {
if let Some(item) = src.get(key) {
dst[key] = item.clone();
}
}
pub(super) fn merge_agents_entry(dst: &mut DocumentMut, src: &DocumentMut, agent_key: &str) {
let Some(src_agent) = src
.get("agents")
.and_then(|item| item.as_table())
.and_then(|table| table.get(agent_key))
else {
return;
};
let needs_init = dst
.get("agents")
.is_none_or(|item| item.as_table().is_none());
if needs_init {
dst["agents"] = Item::Table(Table::new());
}
let agents_table = dst["agents"]
.as_table_mut()
.expect("agents key is a table after the init guard above");
agents_table.insert(agent_key, src_agent.clone());
}
pub(crate) fn reset(agent_hint: Option<CodingAgent>) -> Result<(), CliError> {
let cwd = std::env::current_dir()?;
let path = cwd.join(".nemo-relay").join("config.toml");
if !path.exists() {
println!(" No project config to reset at {}", path.display());
return Ok(());
}
match agent_hint {
None => {
std::fs::remove_file(&path)?;
println!(" ✓ Removed {}", path.display());
println!(" Run `nemo-relay config` to set up again.");
}
Some(agent) => {
let agent_key = agent_key_and_command(agent).0;
let raw = std::fs::read_to_string(&path)?;
let mut doc: DocumentMut = raw.parse().map_err(|err| {
CliError::Config(format!("could not parse existing config: {err}"))
})?;
let Some(agents) = doc.get_mut("agents").and_then(Item::as_table_mut) else {
println!(
" No `[agents.{agent_key}]` block to reset in {}",
path.display()
);
return Ok(());
};
if agents.remove(agent_key).is_none() {
println!(
" No `[agents.{agent_key}]` block to reset in {}",
path.display()
);
return Ok(());
}
if agents.is_empty() {
doc.remove("agents");
}
std::fs::write(&path, doc.to_string())?;
println!(" ✓ Removed `[agents.{agent_key}]` from {}", path.display());
}
}
Ok(())
}
pub(crate) fn hermes_hooks_path_for_scope(
agents: &[CodingAgent],
scope: ConfigScope,
cwd: &Path,
home: &Path,
) -> Option<PathBuf> {
if !agents.contains(&CodingAgent::Hermes) {
return None;
}
match scope {
ConfigScope::Project | ConfigScope::Both => Some(cwd.join(".hermes").join("config.yaml")),
ConfigScope::Global => Some(home.join(".hermes").join("config.yaml")),
}
}
pub(crate) fn install_hermes_hooks(
scope: ConfigScope,
cwd: &Path,
home: &Path,
) -> Result<Vec<PathBuf>, CliError> {
let generated = hermes_hooks(&hook_forward_command("nemo-relay", CodingAgent::Hermes));
let mut written = Vec::new();
for path in hermes_hook_targets(scope, cwd, home) {
let existing = match std::fs::read_to_string(&path) {
Ok(raw) => raw,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => return Err(CliError::Io(error)),
};
let merged = merge_hermes_config(&existing, generated.clone())?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, merged)?;
written.push(path);
}
Ok(written)
}
pub(super) fn hermes_hook_targets(scope: ConfigScope, cwd: &Path, home: &Path) -> Vec<PathBuf> {
let mut targets = Vec::new();
if matches!(scope, ConfigScope::Project | ConfigScope::Both) {
targets.push(cwd.join(".hermes").join("config.yaml"));
}
if matches!(scope, ConfigScope::Global | ConfigScope::Both) {
targets.push(home.join(".hermes").join("config.yaml"));
}
targets
}
#[derive(Debug, Clone, Default)]
pub(super) struct Defaults {
pub(super) scope: Option<ConfigScope>,
pub(super) agents: Vec<CodingAgent>,
}
impl Defaults {
pub(super) fn has_any(&self) -> bool {
self.scope.is_some() || !self.agents.is_empty()
}
}
pub(super) fn read_existing_defaults() -> Option<Defaults> {
let cwd = std::env::current_dir().ok()?;
let home = home_dir();
let workspace_path = cwd.join(".nemo-relay").join("config.toml");
let global_path = home
.as_ref()
.map(|h| global_config_dir(h).join("config.toml"));
let workspace_exists = workspace_path.exists();
let global_exists = global_path.as_ref().is_some_and(|p| p.exists());
let read_doc =
|path: &Path| -> Option<DocumentMut> { std::fs::read_to_string(path).ok()?.parse().ok() };
let doc = match (workspace_exists, global_exists) {
(true, _) => read_doc(&workspace_path)?,
(false, true) => read_doc(global_path.as_ref()?)?,
(false, false) => return None,
};
let scope = match (workspace_exists, global_exists) {
(true, true) => Some(ConfigScope::Both),
(true, false) => Some(ConfigScope::Project),
(false, true) => Some(ConfigScope::Global),
(false, false) => None,
};
Some(Defaults {
scope,
agents: read_agents_from_doc(&doc),
})
}
pub(super) fn read_agents_from_doc(doc: &DocumentMut) -> Vec<CodingAgent> {
let Some(table) = doc.get("agents").and_then(|i| i.as_table()) else {
return Vec::new();
};
let mut found = Vec::new();
for (key, _) in table.iter() {
let agent = match key {
"claude" => Some(CodingAgent::ClaudeCode),
"codex" => Some(CodingAgent::Codex),
"cursor" => Some(CodingAgent::Cursor),
"hermes" => Some(CodingAgent::Hermes),
_ => None,
};
if let Some(agent) = agent {
found.push(agent);
}
}
found
}
pub(super) fn agent_key_and_command(agent: CodingAgent) -> (&'static str, &'static str) {
match agent {
CodingAgent::ClaudeCode => ("claude", "claude"),
CodingAgent::Codex => ("codex", "codex"),
CodingAgent::Cursor => ("cursor", "cursor-agent"),
CodingAgent::Hermes => ("hermes", "hermes"),
}
}
pub(super) fn preview_paths(scope: ConfigScope, cwd: &Path, home: &Path) -> Vec<PathBuf> {
let mut paths = Vec::new();
if matches!(scope, ConfigScope::Project | ConfigScope::Both) {
paths.push(cwd.join(".nemo-relay").join("config.toml"));
}
if matches!(scope, ConfigScope::Global | ConfigScope::Both) {
paths.push(global_config_dir(home).join("config.toml"));
}
paths
}
pub(super) fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
}