use std::path::PathBuf;
use serde::Serialize;
use super::{Env, AGENT_IDS};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum How {
Project,
Home,
Env,
}
impl std::fmt::Display for How {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
How::Project => "project",
How::Home => "home",
How::Env => "env",
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct DetectedAgent {
pub id: String,
pub how: How,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Detection {
pub agents: Vec<DetectedAgent>,
pub universal: bool,
}
impl Detection {
pub fn ids(&self) -> Vec<String> {
self.agents.iter().map(|agent| agent.id.clone()).collect()
}
pub fn contains(&self, id: &str) -> bool {
self.agents.iter().any(|agent| agent.id == id)
}
}
struct Signals {
project: &'static [&'static str],
env: &'static [&'static str],
}
fn signals(id: &str) -> Signals {
let (project, env): (&'static [&'static str], &'static [&'static str]) = match id {
"claude-code" => (&[".claude", "CLAUDE.md", ".mcp.json"], &["CLAUDECODE"]),
"cursor" => (&[".cursor"], &["CURSOR_AGENT"]),
"codex" => (
&[".codex"],
&[
"CODEX_THREAD_ID",
"CODEX_SANDBOX",
"CODEX_SANDBOX_NETWORK_DISABLED",
],
),
"opencode" => (
&["opencode.json", "opencode.jsonc", ".opencode"],
&["OPENCODE_CLIENT"],
),
"gemini-cli" => (&[".gemini"], &["GEMINI_CLI"]),
"vscode" => (&[".vscode"], &[]),
"copilot-cli" => (&[".github/copilot-instructions.md"], &[]),
"windsurf" => (&[".windsurf"], &[]),
"cline" => (&[".clinerules"], &[]),
"zed" => (&[".zed"], &[]),
"amp" => (&[".amp"], &[]),
"kiro" => (&[".kiro"], &[]),
"roo" => (&[".roo"], &[]),
"goose" => (&[".goose"], &[]),
_ => (&[], &[]),
};
Signals { project, env }
}
pub fn home_signal(env: &Env, id: &str) -> Option<PathBuf> {
let home = env.home.as_ref();
match id {
"claude-code" => home.map(|h| h.join(".claude")),
"cursor" => home.map(|h| h.join(".cursor")),
"codex" => env.codex_home(),
"opencode" => env.xdg_config_home().map(|c| c.join("opencode")),
"gemini-cli" => home.map(|h| h.join(".gemini")),
"vscode" => None,
"copilot-cli" => home.map(|h| h.join(".copilot")),
"windsurf" => home.map(|h| h.join(".codeium").join("windsurf")),
"cline" => home.map(|h| h.join(".cline")),
"zed" => home.map(|h| h.join(".config").join("zed")),
"amp" => home.map(|h| h.join(".config").join("amp")),
"kiro" => home.map(|h| h.join(".kiro")),
"roo" => home.map(|h| h.join(".roo")),
"goose" => home.map(|h| h.join(".config").join("goose")),
_ => None,
}
}
fn detect_one(env: &Env, id: &str) -> Option<How> {
let signals = signals(id);
if signals.env.iter().any(|key| env.var(key).is_some()) {
return Some(How::Env);
}
if signals
.project
.iter()
.any(|relative| env.root.join(relative).exists())
{
return Some(How::Project);
}
if home_signal(env, id).is_some_and(|path: PathBuf| path.exists()) {
return Some(How::Home);
}
None
}
pub fn detect(env: &Env) -> Detection {
let agents = AGENT_IDS
.iter()
.filter_map(|id| {
detect_one(env, id).map(|how| DetectedAgent {
id: (*id).to_string(),
how,
})
})
.collect();
Detection {
agents,
universal: env.root.join(".agents").is_dir(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
fn env(root: &Path, home: &Path, vars: &[(&str, &str)]) -> Env {
Env::new(root, Some(home.to_path_buf()), vars)
}
#[test]
fn nothing_detected_in_empty_dirs() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("proj");
let home = dir.path().join("home");
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(&home).unwrap();
let detection = detect(&env(&root, &home, &[]));
assert_eq!(detection, Detection::default());
}
#[test]
fn env_wins_over_project_over_home() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("proj");
let home = dir.path().join("home");
fs::create_dir_all(root.join(".claude")).unwrap();
fs::create_dir_all(home.join(".claude")).unwrap();
fs::create_dir_all(home.join(".cursor")).unwrap();
fs::create_dir_all(root.join(".agents")).unwrap();
fs::write(root.join("opencode.jsonc"), "{}").unwrap();
let detection = detect(&env(
&root,
&home,
&[("CLAUDECODE", "1"), ("GEMINI_CLI", "1")],
));
assert!(detection.universal);
assert_eq!(
detection.agents,
vec![
DetectedAgent {
id: "claude-code".into(),
how: How::Env
},
DetectedAgent {
id: "cursor".into(),
how: How::Home
},
DetectedAgent {
id: "opencode".into(),
how: How::Project
},
DetectedAgent {
id: "gemini-cli".into(),
how: How::Env
},
]
);
let detection = detect(&env(&root, &home, &[]));
assert_eq!(detection.agents[0].how, How::Project);
}
#[test]
fn codex_and_opencode_honour_their_home_overrides() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("proj");
let home = dir.path().join("home");
let codex_home = dir.path().join("codex-home");
let xdg = dir.path().join("xdg");
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(&codex_home).unwrap();
fs::create_dir_all(xdg.join("opencode")).unwrap();
let detection = detect(&env(
&root,
&home,
&[
("CODEX_HOME", codex_home.to_str().unwrap()),
("XDG_CONFIG_HOME", xdg.to_str().unwrap()),
],
));
assert_eq!(detection.ids(), vec!["codex", "opencode"]);
assert!(detection.agents.iter().all(|a| a.how == How::Home));
}
#[test]
fn env_signals_are_the_cited_set_only() {
assert_eq!(signals("claude-code").env, ["CLAUDECODE"]);
assert_eq!(signals("cursor").env, ["CURSOR_AGENT"]);
assert_eq!(
signals("codex").env,
[
"CODEX_THREAD_ID",
"CODEX_SANDBOX",
"CODEX_SANDBOX_NETWORK_DISABLED"
]
);
assert_eq!(signals("opencode").env, ["OPENCODE_CLIENT"]);
assert_eq!(signals("gemini-cli").env, ["GEMINI_CLI"]);
for id in AGENT_IDS.iter().skip(5) {
assert!(signals(id).env.is_empty(), "{id}");
}
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("proj");
let home = dir.path().join("home");
fs::create_dir_all(&root).unwrap();
fs::create_dir_all(&home).unwrap();
assert!(detect(&env(&root, &home, &[("OPENCODE", "1")]))
.agents
.is_empty());
let detection = detect(&env(
&root,
&home,
&[
("OPENCODE_CLIENT", "acp"),
("CODEX_THREAD_ID", "t-1"),
("CURSOR_AGENT", "1"),
],
));
assert_eq!(detection.ids(), vec!["cursor", "codex", "opencode"]);
assert!(detection.agents.iter().all(|a| a.how == How::Env));
}
#[test]
fn every_agent_has_a_project_signal_and_json_shape_is_flat() {
for id in AGENT_IDS {
assert!(!signals(id).project.is_empty(), "{id}");
}
let agent = DetectedAgent {
id: "cursor".into(),
how: How::Home,
};
assert_eq!(
serde_json::to_string(&agent).unwrap(),
r#"{"id":"cursor","how":"home"}"#
);
}
}