Skip to main content

skill_embed/
agent.rs

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
9/// The directory agreed on by every agent except Claude Code.
10const SHARED_PROJECT_DIR: &str = ".agents/skills";
11
12/// What separates the roots in a variable that may name several.
13///
14/// It is one character per platform, and not both of them. Splitting on `:`
15/// everywhere cut `C:\\Users\\...` at the drive letter, so `CLAUDE_CONFIG_DIR`
16/// resolved to `C\\skills` on Windows.
17const LIST_SEPARATOR: char = if cfg!(windows) { ';' } else { ':' };
18
19/// How an agent's user scope directory is found.
20#[derive(Clone)]
21enum UserDir {
22    /// Under the home directory.
23    Home(&'static [&'static str]),
24    /// Under whatever an environment variable names, falling back to the home
25    /// directory.
26    EnvOrHome {
27        env: &'static str,
28        env_parts: &'static [&'static str],
29        home_parts: &'static [&'static str],
30    },
31    /// Whatever the caller says.
32    Custom(Arc<dyn Fn() -> io::Result<PathBuf> + Send + Sync>),
33    /// The agent has none.
34    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                // A config dir may hold several separated roots. The first wins.
43                let raw = std::env::var_os(env);
44                let root = raw.as_deref().and_then(|v| match v.to_str() {
45                    // The whole value is trimmed, and then cut at the first
46                    // separator. A root whose own name ends in a space keeps it,
47                    // which is the order the Go original reaches too.
48                    Some(s) => {
49                        let first = s.trim().split(LIST_SEPARATOR).next().unwrap_or("");
50                        (!first.is_empty()).then(|| PathBuf::from(first))
51                    }
52                    // Not Unicode, so it cannot be trimmed or split as text. It
53                    // is one path, and it is taken as it stands.
54                    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/// A coding agent that reads skills from a known directory.
76///
77/// The built-in agents mirror the directories used by `gh skill install`.
78#[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    /// GitHub Copilot.
88    pub const GITHUB_COPILOT: Self = Self::builtin(
89        "github-copilot",
90        "GitHub Copilot",
91        SHARED_PROJECT_DIR,
92        UserDir::Home(&[".copilot", "skills"]),
93    );
94    /// Claude Code, which relocates its whole configuration with
95    /// `CLAUDE_CONFIG_DIR`.
96    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    /// Cursor.
107    pub const CURSOR: Self = Self::builtin(
108        "cursor",
109        "Cursor",
110        SHARED_PROJECT_DIR,
111        UserDir::Home(&[".cursor", "skills"]),
112    );
113    /// Codex.
114    pub const CODEX: Self =
115        Self::builtin("codex", "Codex", SHARED_PROJECT_DIR, UserDir::Home(&[".codex", "skills"]));
116    /// Gemini CLI.
117    pub const GEMINI: Self = Self::builtin(
118        "gemini",
119        "Gemini CLI",
120        SHARED_PROJECT_DIR,
121        UserDir::Home(&[".gemini", "skills"]),
122    );
123    /// Antigravity.
124    pub const ANTIGRAVITY: Self = Self::builtin(
125        "antigravity",
126        "Antigravity",
127        SHARED_PROJECT_DIR,
128        UserDir::Home(&[".gemini", "antigravity", "skills"]),
129    );
130
131    /// Every built-in agent, in the order `gh skill install` documents them.
132    ///
133    /// The constants above and this list are two places, and the table in
134    /// `tests/agent.rs` is what holds them together: it names every agent, its
135    /// title and its project directory, and asserts the length, so an agent
136    /// added to one and not the other is a failing test rather than an agent
137    /// `--agent all` never reaches.
138    #[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    /// An agent this library does not know about.
165    ///
166    /// `project_dir` is relative to the project root and separated with `/`. A
167    /// project install that would land outside the project root is refused
168    /// whoever wrote the path, so this is not a way around that bound;
169    /// [`Installer::with_project_root`] and `--dir` are.
170    ///
171    /// [`Installer::with_project_root`]: crate::Installer::with_project_root
172    #[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    /// Sets where user scope writes. Without one the agent has project scope
187    /// alone.
188    #[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    /// The `--agent` value, such as `claude-code`.
198    #[must_use]
199    pub fn name(&self) -> &str {
200        &self.name
201    }
202
203    /// The human readable name, such as `Claude Code`.
204    #[must_use]
205    pub fn title(&self) -> &str {
206        &self.title
207    }
208
209    /// The skills directory relative to the project root, separated with `/`.
210    #[must_use]
211    pub fn project_dir(&self) -> &str {
212        &self.project_dir
213    }
214
215    /// Resolves the skills directory for a scope.
216    ///
217    /// `project_root` applies to [`Scope::Project`] alone, and [`None`] means
218    /// the working directory.
219    ///
220    /// # Errors
221    ///
222    /// Fails when the home directory is not known, or when the agent has no
223    /// user scope directory.
224    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/// Names one agent, or a group of them, as `--agent` accepts it.
254///
255/// The two group words cannot be agent names, which is why this is its own
256/// type rather than a list of [`Agent`].
257#[derive(Clone, Debug, PartialEq, Eq, strum::Display)]
258#[strum(serialize_all = "lowercase")]
259pub enum AgentSelector {
260    /// Every agent the installer offers, present or not.
261    All,
262    /// The agents whose directory is already there. Falls back to
263    /// [`AgentSelector::All`] when it finds none.
264    Detected,
265    /// One agent, by name.
266    #[strum(to_string = "{0}")]
267    Named(String),
268}
269
270impl AgentSelector {
271    /// Reads a `--agent` value, which may be a comma separated list.
272    ///
273    /// Empty entries are dropped, so `--agent ""` selects nothing rather than
274    /// naming an agent called "".
275    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
290/// Renders the `--agent` help string, such as `{a|b|c}`.
291pub(crate) fn choices(agents: &[Agent]) -> String {
292    let names: Vec<&str> = agents.iter().map(Agent::name).collect();
293    format!("{{{}}}", names.join("|"))
294}
295
296/// Maps `--agent` values to agents.
297///
298/// [`AgentSelector::All`] expands to every agent the installer offers.
299/// [`AgentSelector::Detected`] keeps the ones whose directory already exists.
300///
301/// An empty `values` is "nothing was asked for", which the caller tells apart
302/// from "what was asked for resolved to nothing".
303pub(crate) fn resolve(
304    known: &[Agent],
305    values: &[AgentSelector],
306    detected: &dyn Fn(&Agent) -> bool,
307) -> Result<Vec<Agent>> {
308    /// Adds an agent unless the list already names it. The two group words can
309    /// both reach one agent, and a value may be repeated.
310    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
343/// The answer when [`AgentSelector::Detected`] found nothing.
344///
345/// On a machine with no agent directory any guess is as good as another, and
346/// writing nothing would read as a failure.
347pub(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}