1use std::path::{Path, PathBuf};
11
12use crate::env::Environment;
13
14#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct Location {
18 home_relative: PathBuf,
19 relocated_by: Option<(String, PathBuf)>,
20}
21
22impl Location {
23 #[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 #[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 #[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 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#[derive(Debug, Clone, PartialEq, Eq)]
59#[non_exhaustive]
60pub struct SkillDir {
61 pub id: String,
63 pub path: Location,
65 pub requires: Option<Location>,
70 pub preferred: bool,
73}
74
75impl SkillDir {
76 #[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 #[must_use]
89 pub fn requires(mut self, home: Location) -> Self {
90 self.requires = Some(home);
91 self
92 }
93
94 #[must_use]
96 pub const fn preferred(mut self) -> Self {
97 self.preferred = true;
98 self
99 }
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
104#[non_exhaustive]
105pub struct Tool {
106 pub id: String,
108 pub name: String,
110 pub home: Location,
112 pub reads: Vec<String>,
115 pub may_read: Vec<String>,
119}
120
121impl Tool {
122 #[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 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct Catalog {
160 pub(crate) dirs: Vec<SkillDir>,
161 pub(crate) tools: Vec<Tool>,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
166#[non_exhaustive]
167pub enum CatalogError {
168 DuplicateId(String),
170 UnknownDir {
172 tool: String,
174 dir: String,
176 },
177 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 #[must_use]
221 pub const fn empty() -> Self {
222 Self {
223 dirs: Vec::new(),
224 tools: Vec::new(),
225 }
226 }
227
228 #[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 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 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 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 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 #[must_use]
357 pub fn tools(&self) -> &[Tool] {
358 &self.tools
359 }
360
361 #[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}