Skip to main content

agent_skill_installer/
catalog.rs

1// SPDX-License-Identifier: LGPL-3.0-or-later
2// Copyright (C) 2026 Fabian Schmieder
3
4//! Which tools read skills from where.
5//!
6//! The built-in entries follow `docs/tools.md`: the mainstream tools only, each
7//! with the directories its vendor documents. A program can add its own tools
8//! and directories; the planner treats them exactly like the built-in ones.
9
10use std::path::{Path, PathBuf};
11
12use crate::env::Environment;
13
14/// Where something lives: below the home directory, unless a variable moves
15/// it.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Location {
18    home_relative: PathBuf,
19    relocated_by: Option<(String, PathBuf)>,
20}
21
22impl Location {
23    /// A path relative to the home directory.
24    #[must_use]
25    pub fn home(relative: impl Into<PathBuf>) -> Self {
26        Self {
27            home_relative: relative.into(),
28            relocated_by: None,
29        }
30    }
31
32    /// The same location, moved to `$var/suffix` when `var` is set.
33    #[must_use]
34    pub fn or_env(mut self, var: impl Into<String>, suffix: impl Into<PathBuf>) -> Self {
35        self.relocated_by = Some((var.into(), suffix.into()));
36        self
37    }
38
39    /// The path this location has in an environment.
40    #[must_use]
41    pub fn resolve(&self, env: &Environment) -> PathBuf {
42        if let Some((var, suffix)) = &self.relocated_by
43            && let Some(value) = env.var(var)
44        {
45            // An empty suffix means the variable names the place itself;
46            // joining "" would only append a separator.
47            return if suffix.as_os_str().is_empty() {
48                PathBuf::from(value)
49            } else {
50                Path::new(value).join(suffix)
51            };
52        }
53        env.home().join(&self.home_relative)
54    }
55}
56
57/// A directory some tool reads skills from.
58#[derive(Debug, Clone, PartialEq, Eq)]
59#[non_exhaustive]
60pub struct SkillDir {
61    /// A short, stable identifier, used by [`Tool`] to refer to it.
62    pub id: String,
63    /// Where the directory is.
64    pub path: Location,
65    /// The tool home that has to exist before this directory may be created.
66    ///
67    /// `None` for a vendor-neutral directory, which belongs to no single tool
68    /// and may be created whenever a tool that reads it is installed.
69    pub requires: Option<Location>,
70    /// Preferred when two choices are otherwise equal. Set for the
71    /// vendor-neutral directory, because more tools read it.
72    pub preferred: bool,
73}
74
75impl SkillDir {
76    /// A directory at `path`, owned by no single tool and not preferred.
77    #[must_use]
78    pub fn new(id: impl Into<String>, path: Location) -> Self {
79        Self {
80            id: id.into(),
81            path,
82            requires: None,
83            preferred: false,
84        }
85    }
86
87    /// The same directory, created only once `home` exists.
88    #[must_use]
89    pub fn requires(mut self, home: Location) -> Self {
90        self.requires = Some(home);
91        self
92    }
93
94    /// The same directory, preferred when two choices are otherwise equal.
95    #[must_use]
96    pub const fn preferred(mut self) -> Self {
97        self.preferred = true;
98        self
99    }
100}
101
102/// A tool that reads skills.
103#[derive(Debug, Clone, PartialEq, Eq)]
104#[non_exhaustive]
105pub struct Tool {
106    /// A short, stable identifier.
107    pub id: String,
108    /// The name a person knows the tool by.
109    pub name: String,
110    /// The directory whose existence shows that the tool is installed.
111    pub home: Location,
112    /// Directories the tool reads in every installation. A skill in one of
113    /// them reaches the tool.
114    pub reads: Vec<String>,
115    /// Directories only some installations read, such as an editor
116    /// integration reading more than the command line does. They never count
117    /// as reaching the tool, but a copy there may still be seen twice.
118    pub may_read: Vec<String>,
119}
120
121impl Tool {
122    /// A tool, installed when `home` exists, that reads no directory yet.
123    #[must_use]
124    pub fn new(id: impl Into<String>, name: impl Into<String>, home: Location) -> Self {
125        Self {
126            id: id.into(),
127            name: name.into(),
128            home,
129            reads: Vec::new(),
130            may_read: Vec::new(),
131        }
132    }
133
134    /// The same tool, reading these directories in every installation.
135    #[must_use]
136    pub fn reads<I, S>(mut self, dirs: I) -> Self
137    where
138        I: IntoIterator<Item = S>,
139        S: Into<String>,
140    {
141        self.reads.extend(dirs.into_iter().map(Into::into));
142        self
143    }
144
145    /// The same tool, reading these directories only in some installations.
146    #[must_use]
147    pub fn may_read<I, S>(mut self, dirs: I) -> Self
148    where
149        I: IntoIterator<Item = S>,
150        S: Into<String>,
151    {
152        self.may_read.extend(dirs.into_iter().map(Into::into));
153        self
154    }
155}
156
157/// The tools and directories the planner knows.
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct Catalog {
160    pub(crate) dirs: Vec<SkillDir>,
161    pub(crate) tools: Vec<Tool>,
162}
163
164/// Why an entry could not be added to a catalog.
165#[derive(Debug, Clone, PartialEq, Eq)]
166#[non_exhaustive]
167pub enum CatalogError {
168    /// An entry with this identifier is already there.
169    DuplicateId(String),
170    /// A tool refers to a directory the catalog does not have.
171    UnknownDir {
172        /// The tool that refers to it.
173        tool: String,
174        /// The identifier that is not in the catalog.
175        dir: String,
176    },
177    /// A tool reads no directory, so no skill could ever reach it.
178    ReadsNothing(String),
179}
180
181impl std::fmt::Display for CatalogError {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        match self {
184            Self::DuplicateId(id) => write!(f, "the catalog already has an entry '{id}'"),
185            Self::UnknownDir { tool, dir } => {
186                write!(
187                    f,
188                    "tool '{tool}' reads '{dir}', which the catalog does not have"
189                )
190            }
191            Self::ReadsNothing(tool) => write!(f, "tool '{tool}' reads no directory"),
192        }
193    }
194}
195
196impl std::error::Error for CatalogError {}
197
198fn dir(id: &str, path: Location, requires: Option<Location>, preferred: bool) -> SkillDir {
199    SkillDir {
200        id: id.to_string(),
201        path,
202        requires,
203        preferred,
204    }
205}
206
207fn tool(id: &str, name: &str, home: Location, reads: &[&str], may_read: &[&str]) -> Tool {
208    Tool {
209        id: id.to_string(),
210        name: name.to_string(),
211        home,
212        reads: reads.iter().map(ToString::to_string).collect(),
213        may_read: may_read.iter().map(ToString::to_string).collect(),
214    }
215}
216
217impl Catalog {
218    /// An empty catalog, for a program that wants none of the built-in
219    /// entries.
220    #[must_use]
221    pub const fn empty() -> Self {
222        Self {
223            dirs: Vec::new(),
224            tools: Vec::new(),
225        }
226    }
227
228    /// The mainstream tools, as documented in `docs/tools.md`.
229    #[must_use]
230    pub fn builtin() -> Self {
231        let claude_home = Location::home(".claude").or_env("CLAUDE_CONFIG_DIR", "");
232        let copilot_home = Location::home(".copilot").or_env("COPILOT_HOME", "");
233        // From secondary sources only, see docs/tools.md.
234        let opencode_home =
235            Location::home(".config/opencode").or_env("XDG_CONFIG_HOME", "opencode");
236
237        let dirs = vec![
238            dir(
239                "claude",
240                Location::home(".claude/skills").or_env("CLAUDE_CONFIG_DIR", "skills"),
241                Some(claude_home.clone()),
242                false,
243            ),
244            dir("agents", Location::home(".agents/skills"), None, true),
245            dir(
246                "gemini",
247                Location::home(".gemini/skills"),
248                Some(Location::home(".gemini")),
249                false,
250            ),
251            dir(
252                "copilot",
253                Location::home(".copilot/skills").or_env("COPILOT_HOME", "skills"),
254                Some(copilot_home.clone()),
255                false,
256            ),
257            dir(
258                "cursor",
259                Location::home(".cursor/skills"),
260                Some(Location::home(".cursor")),
261                false,
262            ),
263            dir(
264                "opencode",
265                Location::home(".config/opencode/skills")
266                    .or_env("XDG_CONFIG_HOME", "opencode/skills"),
267                Some(opencode_home.clone()),
268                false,
269            ),
270        ];
271
272        let tools = vec![
273            tool("claude-code", "Claude Code", claude_home, &["claude"], &[]),
274            tool(
275                "codex",
276                "Codex",
277                Location::home(".codex").or_env("CODEX_HOME", ""),
278                &["agents"],
279                &[],
280            ),
281            tool(
282                "gemini-cli",
283                "Gemini CLI",
284                Location::home(".gemini"),
285                &["gemini", "agents"],
286                &[],
287            ),
288            // The command line reads two directories; Copilot in VS Code reads
289            // `~/.claude/skills` as well, and shares the home directory.
290            tool(
291                "copilot",
292                "GitHub Copilot",
293                copilot_home,
294                &["copilot", "agents"],
295                &["claude"],
296            ),
297            tool(
298                "cursor",
299                "Cursor",
300                Location::home(".cursor"),
301                &["cursor", "agents", "claude"],
302                &[],
303            ),
304            tool(
305                "opencode",
306                "OpenCode",
307                opencode_home,
308                &["opencode", "claude", "agents"],
309                &[],
310            ),
311        ];
312
313        Self { dirs, tools }
314    }
315
316    /// Add a directory.
317    ///
318    /// # Errors
319    /// Returns an error when the identifier is taken.
320    pub fn add_dir(&mut self, dir: SkillDir) -> Result<&mut Self, CatalogError> {
321        if self.dirs.iter().any(|known| known.id == dir.id) {
322            return Err(CatalogError::DuplicateId(dir.id));
323        }
324        self.dirs.push(dir);
325        Ok(self)
326    }
327
328    /// Add a tool.
329    ///
330    /// # Errors
331    /// Returns an error when the identifier is taken, the tool reads nothing,
332    /// or it refers to a directory the catalog does not have.
333    pub fn add_tool(&mut self, tool: Tool) -> Result<&mut Self, CatalogError> {
334        if self.tools.iter().any(|known| known.id == tool.id) {
335            return Err(CatalogError::DuplicateId(tool.id));
336        }
337        if tool.reads.is_empty() {
338            return Err(CatalogError::ReadsNothing(tool.id));
339        }
340        if let Some(unknown) = tool
341            .reads
342            .iter()
343            .chain(&tool.may_read)
344            .find(|id| !self.dirs.iter().any(|dir| &dir.id == *id))
345        {
346            return Err(CatalogError::UnknownDir {
347                tool: tool.id.clone(),
348                dir: unknown.clone(),
349            });
350        }
351        self.tools.push(tool);
352        Ok(self)
353    }
354
355    /// The tools in this catalog.
356    #[must_use]
357    pub fn tools(&self) -> &[Tool] {
358        &self.tools
359    }
360
361    /// The directories in this catalog.
362    #[must_use]
363    pub fn dirs(&self) -> &[SkillDir] {
364        &self.dirs
365    }
366
367    pub(crate) fn dir_index(&self, id: &str) -> Option<usize> {
368        self.dirs.iter().position(|dir| dir.id == id)
369    }
370}