1use std::borrow::Cow;
2use std::fmt;
3use std::io;
4use std::path::{Path, PathBuf};
5use std::sync::Arc;
6
7use crate::{Error, Result, Scope, paths};
8
9const SHARED_PROJECT_DIR: &str = ".agents/skills";
11
12const LIST_SEPARATOR: char = if cfg!(windows) { ';' } else { ':' };
18
19#[derive(Clone)]
21enum UserDir {
22 Home(&'static [&'static str]),
24 EnvOrHome {
27 env: &'static str,
28 env_parts: &'static [&'static str],
29 home_parts: &'static [&'static str],
30 },
31 Custom(Arc<dyn Fn() -> io::Result<PathBuf> + Send + Sync>),
33 None,
35}
36
37impl UserDir {
38 fn resolve(&self) -> io::Result<PathBuf> {
39 match self {
40 Self::Home(parts) => Ok(join(paths::home()?, parts)),
41 Self::EnvOrHome { env, env_parts, home_parts } => {
42 let raw = std::env::var_os(env);
44 let root = raw.as_deref().and_then(|v| match v.to_str() {
45 Some(s) => {
49 let first = s.trim().split(LIST_SEPARATOR).next().unwrap_or("");
50 (!first.is_empty()).then(|| PathBuf::from(first))
51 }
52 None => Some(PathBuf::from(v)),
55 });
56 match root {
57 Some(root) => Ok(join(root, env_parts)),
58 None => Ok(join(paths::home()?, home_parts)),
59 }
60 }
61 Self::Custom(f) => f(),
62 Self::None => Err(io::Error::new(
63 io::ErrorKind::Unsupported,
64 "the agent has no user scope directory",
65 )),
66 }
67 }
68}
69
70fn join(mut base: PathBuf, parts: &[&str]) -> PathBuf {
71 base.extend(parts);
72 base
73}
74
75#[derive(Clone)]
79pub struct Agent {
80 name: Cow<'static, str>,
81 title: Cow<'static, str>,
82 project_dir: Cow<'static, str>,
83 user_dir: UserDir,
84}
85
86impl Agent {
87 pub const GITHUB_COPILOT: Self = Self::builtin(
89 "github-copilot",
90 "GitHub Copilot",
91 SHARED_PROJECT_DIR,
92 UserDir::Home(&[".copilot", "skills"]),
93 );
94 pub const CLAUDE_CODE: Self = Self::builtin(
97 "claude-code",
98 "Claude Code",
99 ".claude/skills",
100 UserDir::EnvOrHome {
101 env: "CLAUDE_CONFIG_DIR",
102 env_parts: &["skills"],
103 home_parts: &[".claude", "skills"],
104 },
105 );
106 pub const CURSOR: Self = Self::builtin(
108 "cursor",
109 "Cursor",
110 SHARED_PROJECT_DIR,
111 UserDir::Home(&[".cursor", "skills"]),
112 );
113 pub const CODEX: Self =
115 Self::builtin("codex", "Codex", SHARED_PROJECT_DIR, UserDir::Home(&[".codex", "skills"]));
116 pub const GEMINI: Self = Self::builtin(
118 "gemini",
119 "Gemini CLI",
120 SHARED_PROJECT_DIR,
121 UserDir::Home(&[".gemini", "skills"]),
122 );
123 pub const ANTIGRAVITY: Self = Self::builtin(
125 "antigravity",
126 "Antigravity",
127 SHARED_PROJECT_DIR,
128 UserDir::Home(&[".gemini", "antigravity", "skills"]),
129 );
130
131 #[must_use]
139 pub fn builtins() -> Vec<Self> {
140 vec![
141 Self::GITHUB_COPILOT,
142 Self::CLAUDE_CODE,
143 Self::CURSOR,
144 Self::CODEX,
145 Self::GEMINI,
146 Self::ANTIGRAVITY,
147 ]
148 }
149
150 const fn builtin(
151 name: &'static str,
152 title: &'static str,
153 project_dir: &'static str,
154 user_dir: UserDir,
155 ) -> Self {
156 Self {
157 name: Cow::Borrowed(name),
158 title: Cow::Borrowed(title),
159 project_dir: Cow::Borrowed(project_dir),
160 user_dir,
161 }
162 }
163
164 #[must_use]
173 pub fn new(
174 name: impl Into<String>,
175 title: impl Into<String>,
176 project_dir: impl Into<String>,
177 ) -> Self {
178 Self {
179 name: Cow::Owned(name.into()),
180 title: Cow::Owned(title.into()),
181 project_dir: Cow::Owned(project_dir.into()),
182 user_dir: UserDir::None,
183 }
184 }
185
186 #[must_use]
189 pub fn with_user_dir(
190 mut self,
191 f: impl Fn() -> io::Result<PathBuf> + Send + Sync + 'static,
192 ) -> Self {
193 self.user_dir = UserDir::Custom(Arc::new(f));
194 self
195 }
196
197 #[must_use]
199 pub fn name(&self) -> &str {
200 &self.name
201 }
202
203 #[must_use]
205 pub fn title(&self) -> &str {
206 &self.title
207 }
208
209 #[must_use]
211 pub fn project_dir(&self) -> &str {
212 &self.project_dir
213 }
214
215 pub fn dir(&self, scope: Scope, project_root: Option<&Path>) -> Result<PathBuf> {
225 match scope {
226 Scope::Project => {
227 let root = match project_root {
228 Some(root) => root.to_path_buf(),
229 None => std::env::current_dir()
230 .map_err(|e| Error::io("read the working directory", e))?,
231 };
232 let mut dir = root;
233 dir.extend(self.project_dir.split('/'));
234 Ok(dir)
235 }
236 Scope::User => self.user_dir.resolve().map_err(|e| {
237 Error::io(format!("resolve the user scope directory for {}", self.name), e)
238 }),
239 }
240 }
241}
242
243impl fmt::Debug for Agent {
244 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245 f.debug_struct("Agent")
246 .field("name", &self.name)
247 .field("title", &self.title)
248 .field("project_dir", &self.project_dir)
249 .finish_non_exhaustive()
250 }
251}
252
253#[derive(Clone, Debug, PartialEq, Eq, strum::Display)]
258#[strum(serialize_all = "lowercase")]
259pub enum AgentSelector {
260 All,
262 Detected,
265 #[strum(to_string = "{0}")]
267 Named(String),
268}
269
270impl AgentSelector {
271 pub fn parse_list(value: &str) -> impl Iterator<Item = Self> + '_ {
276 value.split(',').map(str::trim).filter(|s| !s.is_empty()).map(|s| match s {
277 "all" => Self::All,
278 "detected" => Self::Detected,
279 name => Self::Named(name.to_owned()),
280 })
281 }
282}
283
284impl From<&Agent> for AgentSelector {
285 fn from(a: &Agent) -> Self {
286 Self::Named(a.name().to_owned())
287 }
288}
289
290pub(crate) fn choices(agents: &[Agent]) -> String {
292 let names: Vec<&str> = agents.iter().map(Agent::name).collect();
293 format!("{{{}}}", names.join("|"))
294}
295
296pub(crate) fn resolve(
304 known: &[Agent],
305 values: &[AgentSelector],
306 detected: &dyn Fn(&Agent) -> bool,
307) -> Result<Vec<Agent>> {
308 fn push(out: &mut Vec<Agent>, a: &Agent) {
311 if !out.iter().any(|k| k.name() == a.name()) {
312 out.push(a.clone());
313 }
314 }
315
316 let mut out: Vec<Agent> = Vec::new();
317 for v in values {
318 match v {
319 AgentSelector::All => {
320 for a in known {
321 push(&mut out, a);
322 }
323 }
324 AgentSelector::Detected => {
325 for a in known.iter().filter(|a| detected(a)) {
326 push(&mut out, a);
327 }
328 }
329 AgentSelector::Named(name) => {
330 let Some(a) = known.iter().find(|a| a.name() == name) else {
331 let mut valid: Vec<String> =
332 known.iter().map(|a| a.name().to_owned()).collect();
333 valid.sort();
334 return Err(Error::UnknownAgent { name: name.clone(), valid });
335 };
336 push(&mut out, a);
337 }
338 }
339 }
340 Ok(out)
341}
342
343pub(crate) fn fall_back_to_all(
348 known: &[Agent],
349 resolved: Vec<Agent>,
350 values: &[AgentSelector],
351) -> Vec<Agent> {
352 if resolved.is_empty() && values.contains(&AgentSelector::Detected) {
353 return known.to_vec();
354 }
355 resolved
356}