use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use serde::Deserialize;
pub const UNIVERSAL_SKILLS_DIR: &str = ".agents/skills";
pub const DISABLED_SKILLS_DIR: &str = ".agents/disabled-skills";
static AGENT_TABLE_JSONL: &str = include_str!("agents.jsonl");
pub struct Env {
pub home: PathBuf,
pub config: PathBuf,
pub cwd: PathBuf,
vars: Option<HashMap<String, String>>,
probe_system_dirs: bool,
}
impl Env {
pub fn new(home: impl AsRef<Path>, config: impl AsRef<Path>, cwd: impl AsRef<Path>) -> Self {
Env {
home: home.as_ref().to_path_buf(),
config: config.as_ref().to_path_buf(),
cwd: cwd.as_ref().to_path_buf(),
vars: None,
probe_system_dirs: true,
}
}
pub fn var(&self, key: &str) -> Option<String> {
match &self.vars {
Some(map) => map.get(key).cloned(),
None => std::env::var(key).ok(),
}
}
pub fn set_vars(&mut self, vars: HashMap<String, String>) {
self.vars = Some(vars);
}
pub fn set_probe_system_dirs(&mut self, probe: bool) {
self.probe_system_dirs = probe;
}
}
pub fn config_home() -> PathBuf {
match std::env::var("XDG_CONFIG_HOME") {
Ok(v) if !v.trim().is_empty() => PathBuf::from(v.trim()),
_ => home().join(".config"),
}
}
pub fn home() -> PathBuf {
dirs::home_dir().unwrap_or_default()
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EnvHomeSpec {
pub var: String,
pub default: String,
#[serde(default)]
pub path: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EnvVarSpec {
pub var: String,
#[serde(default)]
pub path: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum PathSpec {
Home {
home: String,
},
Config {
config: String,
},
Cwd {
cwd: String,
},
EnvHome {
env_home: EnvHomeSpec,
},
EnvVar {
env_var: EnvVarSpec,
},
System {
system: String,
},
}
impl PathSpec {
fn resolve(&self, env: &Env) -> Option<PathBuf> {
match self {
PathSpec::Home { home } => Some(env.home.join(home)),
PathSpec::Config { config } => Some(env.config.join(config)),
PathSpec::Cwd { cwd } => Some(env.cwd.join(cwd)),
PathSpec::EnvHome { env_home } => Some(env_home.resolve(env)),
PathSpec::EnvVar { env_var } => env_var.resolve(env),
PathSpec::System { system } => env.probe_system_dirs.then(|| PathBuf::from(system)),
}
}
}
impl EnvHomeSpec {
fn resolve(&self, env: &Env) -> PathBuf {
let base = match env.var(&self.var) {
Some(v) if !v.trim().is_empty() => PathBuf::from(v.trim()),
_ => env.home.join(&self.default),
};
match &self.path {
Some(p) => base.join(p),
None => base,
}
}
}
impl EnvVarSpec {
fn resolve(&self, env: &Env) -> Option<PathBuf> {
let base = PathBuf::from(env.var(&self.var)?);
Some(match &self.path {
Some(p) => base.join(p),
None => base,
})
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Agent {
pub name: String,
pub display: String,
pub skills_dir: String,
pub global: PathSpec,
#[serde(default)]
pub detect: Vec<PathSpec>,
#[serde(default)]
pub hidden: bool,
}
impl Agent {
pub fn is_universal(&self) -> bool {
self.skills_dir == UNIVERSAL_SKILLS_DIR
}
}
fn parse_agent_table(jsonl: &str) -> Vec<Agent> {
let mut agents: Vec<Agent> = Vec::new();
for (idx, line) in jsonl.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let agent: Agent = serde_json::from_str(line)
.unwrap_or_else(|e| panic!("agents.jsonl line {}: {e}", idx + 1));
if let Some(prev) = agents.iter().position(|a| a.name == agent.name) {
panic!(
"agents.jsonl line {}: duplicate agent '{}' (first defined on line {})",
idx + 1,
agent.name,
prev + 1
);
}
agents.push(agent);
}
assert!(
!agents.is_empty(),
"agents.jsonl must define at least one agent"
);
agents
}
pub static AGENTS: LazyLock<&'static [Agent]> = LazyLock::new(|| {
Box::leak(parse_agent_table(AGENT_TABLE_JSONL).into_boxed_slice()) as &'static [Agent]
});
pub fn get_agent(name: &str) -> Option<&'static Agent> {
let agents: &'static [Agent] = *AGENTS;
agents.iter().find(|a| a.name == name)
}
pub fn agent_display(name: &str) -> String {
get_agent(name)
.map(|a| a.display.to_string())
.unwrap_or_else(|| name.to_string())
}
pub fn universal_agents() -> Vec<&'static Agent> {
let agents: &'static [Agent] = *AGENTS;
agents
.iter()
.filter(|a| a.is_universal() && !a.hidden)
.collect()
}
pub fn global_skills_dir(agent: &Agent, env: &Env) -> Option<PathBuf> {
agent.global.resolve(env)
}
pub fn canonical_skills_dir(global: bool, env: &Env) -> PathBuf {
let base = if global { &env.home } else { &env.cwd };
base.join(UNIVERSAL_SKILLS_DIR)
}
pub fn disabled_skills_dir(global: bool, env: &Env) -> PathBuf {
let base = if global { &env.home } else { &env.cwd };
base.join(DISABLED_SKILLS_DIR)
}
pub fn agent_skills_dir(agent: &Agent, global: bool, env: &Env) -> Option<PathBuf> {
if agent.is_universal() {
return None;
}
if global {
global_skills_dir(agent, env)
} else {
Some(env.cwd.join(&agent.skills_dir))
}
}
pub fn is_installed(agent: &Agent, env: &Env) -> bool {
agent
.detect
.iter()
.any(|spec| spec.resolve(env).is_some_and(|p| p.exists()))
}
pub fn detect_installed_agents(env: &Env) -> Vec<&'static Agent> {
let agents: &'static [Agent] = *AGENTS;
agents.iter().filter(|a| is_installed(a, env)).collect()
}
pub fn ensure_universal_agents(mut target: Vec<&'static Agent>) -> Vec<&'static Agent> {
let agents: &'static [Agent] = *AGENTS;
for a in agents.iter().filter(|a| a.is_universal()) {
if !target.iter().any(|x| x.name == a.name) {
target.push(a);
}
}
target
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::test_utils::env_at;
#[test]
fn agent_table_has_key_agents() {
assert!(get_agent("claude-code").is_some());
assert!(get_agent("codex").is_some());
assert!(get_agent("universal").is_some());
assert!(get_agent("amp").is_some());
assert!(get_agent("nope").is_none());
}
#[test]
fn universal_agents_use_agents_skills_dir() {
for a in ["amp", "codex", "cursor", "opencode"] {
let agent = get_agent(a).unwrap();
assert!(agent.is_universal(), "{a} should be universal");
}
let claude = get_agent("claude-code").unwrap();
assert!(!claude.is_universal());
assert_eq!(claude.skills_dir, ".claude/skills");
}
#[test]
fn hidden_agents_excluded_from_universal_list() {
let names: Vec<&str> = universal_agents().iter().map(|a| a.name.as_str()).collect();
assert!(!names.contains(&"dexto"));
assert!(!names.contains(&"universal"));
assert!(names.contains(&"amp"));
}
#[test]
fn global_dir_resolution() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let cline = get_agent("cline").unwrap();
assert_eq!(
global_skills_dir(cline, &env).unwrap(),
tmp.path().join(".agents/skills")
);
let amp = get_agent("amp").unwrap();
assert_eq!(
global_skills_dir(amp, &env).unwrap(),
tmp.path().join("config/agents/skills")
);
}
#[test]
fn detect_home_config_cwd() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
std::fs::create_dir_all(tmp.path().join(".cline")).unwrap();
assert!(is_installed(get_agent("cline").unwrap(), &env));
std::fs::create_dir_all(tmp.path().join("config/amp")).unwrap();
assert!(is_installed(get_agent("amp").unwrap(), &env));
std::fs::create_dir_all(tmp.path().join(".replit")).unwrap();
assert!(is_installed(get_agent("replit").unwrap(), &env));
}
#[test]
fn detect_home_or_cwd() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
std::fs::create_dir_all(tmp.path().join(".codebuddy")).unwrap();
assert!(is_installed(get_agent("codebuddy").unwrap(), &env));
}
#[test]
fn universal_never_detected_installed() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
assert!(!is_installed(get_agent("universal").unwrap(), &env));
}
#[test]
fn env_home_prefers_var_over_default() {
let tmp = tempfile::TempDir::new().unwrap();
let mut env = env_at(&tmp);
let claude = get_agent("claude-code").unwrap();
std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
assert!(is_installed(claude, &env));
let mut vars = HashMap::new();
vars.insert(
"CLAUDE_CONFIG_DIR".to_string(),
tmp.path().join("cc").display().to_string(),
);
env.set_vars(vars);
assert!(!is_installed(claude, &env));
std::fs::create_dir_all(tmp.path().join("cc")).unwrap();
assert!(is_installed(claude, &env));
}
#[test]
fn env_var_unset_never_matches() {
let tmp = tempfile::TempDir::new().unwrap();
let mut env = env_at(&tmp);
env.set_vars(HashMap::new()); let zed = get_agent("zed").unwrap();
assert!(!is_installed(zed, &env));
let mut vars = HashMap::new();
vars.insert(
"APPDATA".to_string(),
tmp.path().join("appdata").display().to_string(),
);
env.set_vars(vars);
assert!(!is_installed(zed, &env)); std::fs::create_dir_all(tmp.path().join("appdata/Zed")).unwrap();
assert!(is_installed(zed, &env));
}
#[test]
fn system_probe_respects_probe_flag() {
let tmp = tempfile::TempDir::new().unwrap();
let mut env = env_at(&tmp);
env.set_vars(HashMap::new());
env.set_probe_system_dirs(true);
let marker = tmp.path().join("sys-marker"); std::fs::create_dir_all(&marker).unwrap();
let spec = PathSpec::System {
system: marker.display().to_string(),
};
assert!(spec.resolve(&env).is_some());
env.set_probe_system_dirs(false);
assert!(spec.resolve(&env).is_none());
}
#[test]
fn agent_table_parses_comments_and_blank_lines() {
let table = parse_agent_table(
"# header comment\n\n{\"name\":\"x\",\"display\":\"X\",\"skills_dir\":\".agents/skills\",\"global\":{\"home\":\".x/skills\"},\"detect\":[{\"home\":\".x\"}]}\n\n",
);
assert_eq!(table.len(), 1);
assert!(table[0].is_universal());
assert!(!table[0].hidden);
}
#[test]
#[should_panic(expected = "duplicate agent 'x'")]
fn agent_table_rejects_duplicate_names() {
let one =
r#"{"name":"x","display":"X","skills_dir":"s","global":{"home":".x"},"detect":[]}"#;
let two =
r#"{"name":"x","display":"Y","skills_dir":"s","global":{"home":".y"},"detect":[]}"#;
parse_agent_table(&format!("{one}\n{two}\n"));
}
#[test]
#[should_panic(expected = "agents.jsonl line 2:")]
fn agent_table_reports_offending_line() {
parse_agent_table(
"{\"name\":\"x\",\"display\":\"X\",\"skills_dir\":\"s\",\"global\":{\"home\":\".x\"},\"detect\":[]}\nnot json\n",
);
}
#[test]
fn agent_table_row_count() {
let agents: &'static [Agent] = *AGENTS;
assert_eq!(agents.len(), 83);
}
}