use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use serde::Deserialize;
use crate::core::path_util::normalize_lexical;
use crate::error::{Result, SkillsError};
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,
install_internal_skills: 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,
install_internal_skills: false,
}
}
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 install_internal_skills(&self) -> bool {
self.install_internal_skills
}
pub fn set_install_internal_skills(&mut self, install: bool) {
self.install_internal_skills = install;
}
}
pub fn config_home() -> Result<PathBuf> {
match std::env::var("XDG_CONFIG_HOME") {
Ok(v) if !v.trim().is_empty() => Ok(PathBuf::from(v.trim())),
_ => Ok(home()?.join(".config")),
}
}
pub fn home() -> Result<PathBuf> {
std::env::var_os("HOME")
.filter(|h| !h.is_empty())
.map(PathBuf::from)
.or_else(dirs::home_dir)
.ok_or_else(|| SkillsError::msg("cannot determine the home directory (set $HOME)"))
}
#[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: PathSpec,
#[serde(default)]
pub detect: Vec<PathSpec>,
#[serde(default)]
pub link_without_root: bool,
}
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 is_native(agent: &Agent, env: &Env) -> bool {
agent
.skills_dir
.resolve(env)
.is_some_and(|dir| normalize_lexical(&dir) == normalize_lexical(&canonical_skills_dir(env)))
}
pub fn canonical_skills_dir(env: &Env) -> PathBuf {
env.home.join(UNIVERSAL_SKILLS_DIR)
}
pub fn disabled_skills_dir(env: &Env) -> PathBuf {
env.home.join(DISABLED_SKILLS_DIR)
}
pub fn agent_skills_dir(agent: &Agent, env: &Env) -> Option<PathBuf> {
agent.skills_dir.resolve(env)
}
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()
}
#[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 skills_dir_resolution() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let cline = get_agent("cline").unwrap();
assert_eq!(
agent_skills_dir(cline, &env).unwrap(),
tmp.path().join(".agents/skills")
);
let amp = get_agent("amp").unwrap();
assert_eq!(
agent_skills_dir(amp, &env).unwrap(),
tmp.path().join("config/agents/skills")
);
let claude = get_agent("claude-code").unwrap();
assert_eq!(
agent_skills_dir(claude, &env).unwrap(),
tmp.path().join(".claude/skills")
);
}
#[test]
fn is_native_only_for_the_canonical_dir() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
assert!(is_native(get_agent("cline").unwrap(), &env));
assert!(!is_native(get_agent("claude-code").unwrap(), &env));
assert!(!is_native(get_agent("antigravity").unwrap(), &env));
assert!(!is_native(get_agent("codex").unwrap(), &env));
}
#[test]
fn is_native_false_when_env_var_unset() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
let eve = get_agent("eve").unwrap();
assert!(!is_native(eve, &env));
assert_eq!(agent_skills_dir(eve, &env), None);
}
#[test]
fn canonical_and_disabled_dirs_live_under_home() {
let tmp = tempfile::TempDir::new().unwrap();
let env = env_at(&tmp);
assert_eq!(
canonical_skills_dir(&env),
tmp.path().join(".agents/skills")
);
assert_eq!(
disabled_skills_dir(&env),
tmp.path().join(".agents/disabled-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 agent_without_detect_rules_is_never_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\":{\"home\":\".x/skills\"},\"detect\":[{\"home\":\".x\"}]}\n\n",
);
assert_eq!(table.len(), 1);
assert_eq!(table[0].name, "x");
}
#[test]
#[should_panic(expected = "duplicate agent 'x'")]
fn agent_table_rejects_duplicate_names() {
let one = r#"{"name":"x","display":"X","skills_dir":{"home":".x"},"detect":[]}"#;
let two = r#"{"name":"x","display":"Y","skills_dir":{"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\":{\"home\":\".x\"},\"detect\":[]}\nnot json\n",
);
}
#[test]
fn agent_table_row_count() {
let agents: &'static [Agent] = *AGENTS;
assert_eq!(agents.len(), 81);
}
#[test]
fn claude_code_links_without_root() {
assert!(get_agent("claude-code").unwrap().link_without_root);
assert!(!get_agent("codex").unwrap().link_without_root);
}
}