use std::borrow::Cow;
use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::{Error, Result, Scope, paths};
const SHARED_PROJECT_DIR: &str = ".agents/skills";
const LIST_SEPARATOR: char = if cfg!(windows) { ';' } else { ':' };
#[derive(Clone)]
enum UserDir {
Home(&'static [&'static str]),
EnvOrHome {
env: &'static str,
env_parts: &'static [&'static str],
home_parts: &'static [&'static str],
},
Custom(Arc<dyn Fn() -> io::Result<PathBuf> + Send + Sync>),
None,
}
impl UserDir {
fn resolve(&self) -> io::Result<PathBuf> {
match self {
Self::Home(parts) => Ok(join(paths::home()?, parts)),
Self::EnvOrHome { env, env_parts, home_parts } => {
let raw = std::env::var_os(env);
let root = raw.as_deref().and_then(|v| match v.to_str() {
Some(s) => {
let first = s.trim().split(LIST_SEPARATOR).next().unwrap_or("");
(!first.is_empty()).then(|| PathBuf::from(first))
}
None => Some(PathBuf::from(v)),
});
match root {
Some(root) => Ok(join(root, env_parts)),
None => Ok(join(paths::home()?, home_parts)),
}
}
Self::Custom(f) => f(),
Self::None => Err(io::Error::new(
io::ErrorKind::Unsupported,
"the agent has no user scope directory",
)),
}
}
}
fn join(mut base: PathBuf, parts: &[&str]) -> PathBuf {
base.extend(parts);
base
}
#[derive(Clone)]
pub struct Agent {
name: Cow<'static, str>,
title: Cow<'static, str>,
project_dir: Cow<'static, str>,
user_dir: UserDir,
}
impl Agent {
pub const GITHUB_COPILOT: Self = Self::builtin(
"github-copilot",
"GitHub Copilot",
SHARED_PROJECT_DIR,
UserDir::Home(&[".copilot", "skills"]),
);
pub const CLAUDE_CODE: Self = Self::builtin(
"claude-code",
"Claude Code",
".claude/skills",
UserDir::EnvOrHome {
env: "CLAUDE_CONFIG_DIR",
env_parts: &["skills"],
home_parts: &[".claude", "skills"],
},
);
pub const CURSOR: Self = Self::builtin(
"cursor",
"Cursor",
SHARED_PROJECT_DIR,
UserDir::Home(&[".cursor", "skills"]),
);
pub const CODEX: Self =
Self::builtin("codex", "Codex", SHARED_PROJECT_DIR, UserDir::Home(&[".codex", "skills"]));
pub const GEMINI: Self = Self::builtin(
"gemini",
"Gemini CLI",
SHARED_PROJECT_DIR,
UserDir::Home(&[".gemini", "skills"]),
);
pub const ANTIGRAVITY: Self = Self::builtin(
"antigravity",
"Antigravity",
SHARED_PROJECT_DIR,
UserDir::Home(&[".gemini", "antigravity", "skills"]),
);
#[must_use]
pub fn builtins() -> Vec<Self> {
vec![
Self::GITHUB_COPILOT,
Self::CLAUDE_CODE,
Self::CURSOR,
Self::CODEX,
Self::GEMINI,
Self::ANTIGRAVITY,
]
}
const fn builtin(
name: &'static str,
title: &'static str,
project_dir: &'static str,
user_dir: UserDir,
) -> Self {
Self {
name: Cow::Borrowed(name),
title: Cow::Borrowed(title),
project_dir: Cow::Borrowed(project_dir),
user_dir,
}
}
#[must_use]
pub fn new(
name: impl Into<String>,
title: impl Into<String>,
project_dir: impl Into<String>,
) -> Self {
Self {
name: Cow::Owned(name.into()),
title: Cow::Owned(title.into()),
project_dir: Cow::Owned(project_dir.into()),
user_dir: UserDir::None,
}
}
#[must_use]
pub fn with_user_dir(
mut self,
f: impl Fn() -> io::Result<PathBuf> + Send + Sync + 'static,
) -> Self {
self.user_dir = UserDir::Custom(Arc::new(f));
self
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn title(&self) -> &str {
&self.title
}
#[must_use]
pub fn project_dir(&self) -> &str {
&self.project_dir
}
pub fn dir(&self, scope: Scope, project_root: Option<&Path>) -> Result<PathBuf> {
match scope {
Scope::Project => {
let root = match project_root {
Some(root) => root.to_path_buf(),
None => std::env::current_dir()
.map_err(|e| Error::io("read the working directory", e))?,
};
let mut dir = root;
dir.extend(self.project_dir.split('/'));
Ok(dir)
}
Scope::User => self.user_dir.resolve().map_err(|e| {
Error::io(format!("resolve the user scope directory for {}", self.name), e)
}),
}
}
}
impl fmt::Debug for Agent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Agent")
.field("name", &self.name)
.field("title", &self.title)
.field("project_dir", &self.project_dir)
.finish_non_exhaustive()
}
}
#[derive(Clone, Debug, PartialEq, Eq, strum::Display)]
#[strum(serialize_all = "lowercase")]
pub enum AgentSelector {
All,
Detected,
#[strum(to_string = "{0}")]
Named(String),
}
impl AgentSelector {
pub fn parse_list(value: &str) -> impl Iterator<Item = Self> + '_ {
value.split(',').map(str::trim).filter(|s| !s.is_empty()).map(|s| match s {
"all" => Self::All,
"detected" => Self::Detected,
name => Self::Named(name.to_owned()),
})
}
}
impl From<&Agent> for AgentSelector {
fn from(a: &Agent) -> Self {
Self::Named(a.name().to_owned())
}
}
pub(crate) fn choices(agents: &[Agent]) -> String {
let names: Vec<&str> = agents.iter().map(Agent::name).collect();
format!("{{{}}}", names.join("|"))
}
pub(crate) fn resolve(
known: &[Agent],
values: &[AgentSelector],
detected: &dyn Fn(&Agent) -> bool,
) -> Result<Vec<Agent>> {
fn push(out: &mut Vec<Agent>, a: &Agent) {
if !out.iter().any(|k| k.name() == a.name()) {
out.push(a.clone());
}
}
let mut out: Vec<Agent> = Vec::new();
for v in values {
match v {
AgentSelector::All => {
for a in known {
push(&mut out, a);
}
}
AgentSelector::Detected => {
for a in known.iter().filter(|a| detected(a)) {
push(&mut out, a);
}
}
AgentSelector::Named(name) => {
let Some(a) = known.iter().find(|a| a.name() == name) else {
let mut valid: Vec<String> =
known.iter().map(|a| a.name().to_owned()).collect();
valid.sort();
return Err(Error::UnknownAgent { name: name.clone(), valid });
};
push(&mut out, a);
}
}
}
Ok(out)
}
pub(crate) fn fall_back_to_all(
known: &[Agent],
resolved: Vec<Agent>,
values: &[AgentSelector],
) -> Vec<Agent> {
if resolved.is_empty() && values.contains(&AgentSelector::Detected) {
return known.to_vec();
}
resolved
}