use std::path::{Path, PathBuf};
use crate::env::Environment;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Location {
home_relative: PathBuf,
relocated_by: Option<(String, PathBuf)>,
}
impl Location {
#[must_use]
pub fn home(relative: impl Into<PathBuf>) -> Self {
Self {
home_relative: relative.into(),
relocated_by: None,
}
}
#[must_use]
pub fn or_env(mut self, var: impl Into<String>, suffix: impl Into<PathBuf>) -> Self {
self.relocated_by = Some((var.into(), suffix.into()));
self
}
#[must_use]
pub fn resolve(&self, env: &Environment) -> PathBuf {
if let Some((var, suffix)) = &self.relocated_by
&& let Some(value) = env.var(var)
{
return if suffix.as_os_str().is_empty() {
PathBuf::from(value)
} else {
Path::new(value).join(suffix)
};
}
env.home().join(&self.home_relative)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct SkillDir {
pub id: String,
pub path: Location,
pub requires: Option<Location>,
pub preferred: bool,
}
impl SkillDir {
#[must_use]
pub fn new(id: impl Into<String>, path: Location) -> Self {
Self {
id: id.into(),
path,
requires: None,
preferred: false,
}
}
#[must_use]
pub fn requires(mut self, home: Location) -> Self {
self.requires = Some(home);
self
}
#[must_use]
pub const fn preferred(mut self) -> Self {
self.preferred = true;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Tool {
pub id: String,
pub name: String,
pub home: Location,
pub reads: Vec<String>,
pub may_read: Vec<String>,
}
impl Tool {
#[must_use]
pub fn new(id: impl Into<String>, name: impl Into<String>, home: Location) -> Self {
Self {
id: id.into(),
name: name.into(),
home,
reads: Vec::new(),
may_read: Vec::new(),
}
}
#[must_use]
pub fn reads<I, S>(mut self, dirs: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.reads.extend(dirs.into_iter().map(Into::into));
self
}
#[must_use]
pub fn may_read<I, S>(mut self, dirs: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.may_read.extend(dirs.into_iter().map(Into::into));
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Catalog {
pub(crate) dirs: Vec<SkillDir>,
pub(crate) tools: Vec<Tool>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CatalogError {
DuplicateId(String),
UnknownDir {
tool: String,
dir: String,
},
ReadsNothing(String),
}
impl std::fmt::Display for CatalogError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DuplicateId(id) => write!(f, "the catalog already has an entry '{id}'"),
Self::UnknownDir { tool, dir } => {
write!(
f,
"tool '{tool}' reads '{dir}', which the catalog does not have"
)
}
Self::ReadsNothing(tool) => write!(f, "tool '{tool}' reads no directory"),
}
}
}
impl std::error::Error for CatalogError {}
fn dir(id: &str, path: Location, requires: Option<Location>, preferred: bool) -> SkillDir {
SkillDir {
id: id.to_string(),
path,
requires,
preferred,
}
}
fn tool(id: &str, name: &str, home: Location, reads: &[&str], may_read: &[&str]) -> Tool {
Tool {
id: id.to_string(),
name: name.to_string(),
home,
reads: reads.iter().map(ToString::to_string).collect(),
may_read: may_read.iter().map(ToString::to_string).collect(),
}
}
impl Catalog {
#[must_use]
pub const fn empty() -> Self {
Self {
dirs: Vec::new(),
tools: Vec::new(),
}
}
#[must_use]
pub fn builtin() -> Self {
let claude_home = Location::home(".claude").or_env("CLAUDE_CONFIG_DIR", "");
let copilot_home = Location::home(".copilot").or_env("COPILOT_HOME", "");
let opencode_home =
Location::home(".config/opencode").or_env("XDG_CONFIG_HOME", "opencode");
let dirs = vec![
dir(
"claude",
Location::home(".claude/skills").or_env("CLAUDE_CONFIG_DIR", "skills"),
Some(claude_home.clone()),
false,
),
dir("agents", Location::home(".agents/skills"), None, true),
dir(
"gemini",
Location::home(".gemini/skills"),
Some(Location::home(".gemini")),
false,
),
dir(
"copilot",
Location::home(".copilot/skills").or_env("COPILOT_HOME", "skills"),
Some(copilot_home.clone()),
false,
),
dir(
"cursor",
Location::home(".cursor/skills"),
Some(Location::home(".cursor")),
false,
),
dir(
"opencode",
Location::home(".config/opencode/skills")
.or_env("XDG_CONFIG_HOME", "opencode/skills"),
Some(opencode_home.clone()),
false,
),
];
let tools = vec![
tool("claude-code", "Claude Code", claude_home, &["claude"], &[]),
tool(
"codex",
"Codex",
Location::home(".codex").or_env("CODEX_HOME", ""),
&["agents"],
&[],
),
tool(
"gemini-cli",
"Gemini CLI",
Location::home(".gemini"),
&["gemini", "agents"],
&[],
),
tool(
"copilot",
"GitHub Copilot",
copilot_home,
&["copilot", "agents"],
&["claude"],
),
tool(
"cursor",
"Cursor",
Location::home(".cursor"),
&["cursor", "agents", "claude"],
&[],
),
tool(
"opencode",
"OpenCode",
opencode_home,
&["opencode", "claude", "agents"],
&[],
),
];
Self { dirs, tools }
}
pub fn add_dir(&mut self, dir: SkillDir) -> Result<&mut Self, CatalogError> {
if self.dirs.iter().any(|known| known.id == dir.id) {
return Err(CatalogError::DuplicateId(dir.id));
}
self.dirs.push(dir);
Ok(self)
}
pub fn add_tool(&mut self, tool: Tool) -> Result<&mut Self, CatalogError> {
if self.tools.iter().any(|known| known.id == tool.id) {
return Err(CatalogError::DuplicateId(tool.id));
}
if tool.reads.is_empty() {
return Err(CatalogError::ReadsNothing(tool.id));
}
if let Some(unknown) = tool
.reads
.iter()
.chain(&tool.may_read)
.find(|id| !self.dirs.iter().any(|dir| &dir.id == *id))
{
return Err(CatalogError::UnknownDir {
tool: tool.id.clone(),
dir: unknown.clone(),
});
}
self.tools.push(tool);
Ok(self)
}
#[must_use]
pub fn tools(&self) -> &[Tool] {
&self.tools
}
#[must_use]
pub fn dirs(&self) -> &[SkillDir] {
&self.dirs
}
pub(crate) fn dir_index(&self, id: &str) -> Option<usize> {
self.dirs.iter().position(|dir| dir.id == id)
}
}