use anyhow::{Context, Result};
use ignore::WalkBuilder;
use serde::Deserialize;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use std::{
collections::HashSet,
fs,
path::{Path, PathBuf},
};
use reqwest::blocking::Client;
const SKILL_FILE_NAME: &str = "SKILL.md";
const SKILL_ROOTS: &[&str] = &[".opencode/skills", ".claude/skills", ".agents/skills"];
const MAX_COMPANION_FILES: usize = 10;
static CATALOG: OnceLock<Arc<SkillCatalogInner>> = OnceLock::new();
#[derive(Clone, Debug)]
pub struct SkillInfo {
pub name: String,
pub description: String,
pub directory: PathBuf,
pub location: PathBuf,
pub content: String,
pub companion_files: Vec<PathBuf>,
}
#[derive(Debug, Default)]
struct SkillCatalogInner {
skills: Vec<SkillInfo>,
}
#[derive(Clone, Debug, Default)]
pub struct SkillCatalog {
inner: Arc<SkillCatalogInner>,
}
#[derive(Clone, Debug, Deserialize)]
struct SkillFrontmatter {
name: String,
description: String,
}
fn discover_inner(
workspace_root: &Path,
config_dir: &Path,
extra_sources: &[String],
worktree: Option<&Path>,
) -> SkillCatalogInner {
crate::log_debug!("discover_inner: start, worktree={:?}", worktree.map(|p| p.display().to_string()));
let mut skills = Vec::new();
let mut seen_names = HashSet::new();
let mut seen_locations = HashSet::new();
let roots = candidate_roots(workspace_root, config_dir, worktree);
crate::log_debug!("discover_inner: candidate_roots returned {} roots", roots.len());
for (i, root) in roots.iter().enumerate() {
crate::log_debug!("discover_inner: root[{}] = {}", i, root.display());
for skill_file in discover_skill_files(root) {
crate::log_debug!("discover_inner: found skill_file = {}", skill_file.display());
let canonical_location = skill_file
.canonicalize()
.unwrap_or_else(|_| skill_file.clone());
if !seen_locations.insert(canonical_location) {
crate::log_debug!("discover_inner: duplicate location, skip");
continue;
}
let Ok(skill) = parse_skill_file(&skill_file) else {
crate::log_debug!("discover_inner: parse_skill_file failed for {}", skill_file.display());
continue;
};
if !seen_names.insert(skill.name.clone()) {
crate::log_debug!("discover_inner: duplicate skill name '{}', skip", skill.name);
continue;
}
crate::log_info!("discover_inner: loaded skill '{}' from {}", skill.name, skill_file.display());
skills.push(skill);
}
}
for raw_source in extra_sources {
crate::log_debug!("discover_inner: loading extra source = {}", raw_source);
let Some(skill) = load_additional_skill_source(raw_source, workspace_root) else {
crate::log_debug!("discover_inner: load_additional_skill_source returned None for {}", raw_source);
continue;
};
let canonical_location = skill
.location
.canonicalize()
.unwrap_or_else(|_| skill.location.clone());
if !seen_locations.insert(canonical_location) {
crate::log_debug!("discover_inner: duplicate extra location, skip");
continue;
}
if !seen_names.insert(skill.name.clone()) {
crate::log_debug!("discover_inner: duplicate extra skill name '{}', skip", skill.name);
continue;
}
crate::log_info!("discover_inner: loaded extra skill '{}' from {}", skill.name, skill.location.display());
skills.push(skill);
}
crate::log_info!("discover_inner: done, total skills = {}", skills.len());
SkillCatalogInner { skills }
}
impl SkillCatalog {
pub fn discover(
workspace_root: &Path,
config_dir: &Path,
skill_sources: &[String],
worktree: Option<&Path>,
) -> Self {
crate::log_debug!(
"SkillCatalog::discover: workspace_root={}, config_dir={}, skill_sources={:?}, SKILL_ROOTS={:?}, worktree={:?}",
workspace_root.display(), config_dir.display(), skill_sources, SKILL_ROOTS, worktree.map(|p| p.display().to_string())
);
let inner = CATALOG
.get_or_init(|| {
let start = std::time::Instant::now();
crate::log_info!("SkillCatalog::discover: initializing catalog (first call)");
let inner = discover_inner(workspace_root, config_dir, skill_sources, worktree);
crate::log_info!(
"SkillCatalog::discover: catalog initialized with {} skills in {:?}",
inner.skills.len(), start.elapsed()
);
Arc::new(inner)
})
.clone();
Self { inner }
}
pub fn all(&self) -> &[SkillInfo] {
&self.inner.skills
}
pub fn is_empty(&self) -> bool {
self.inner.skills.is_empty()
}
pub fn get(&self, name: &str) -> Option<&SkillInfo> {
self.inner.skills.iter().find(|skill| skill.name == name)
}
pub fn tool_description(&self) -> String {
if self.inner.skills.is_empty() {
return String::from("Load a reusable skill by name. No skills were discovered.");
}
let mut description = String::from("Load a reusable skill by name. Available skills:\n");
for skill in &self.inner.skills {
description.push_str("- ");
description.push_str(&skill.name);
description.push_str(": ");
description.push_str(skill.description.trim());
description.push('\n');
}
description.trim_end().to_string()
}
pub fn permission_key_for_name(name: &str) -> String {
format!("skill:{name}")
}
pub fn render_skill(&self, name: &str) -> Result<String> {
let skill = self
.get(name)
.with_context(|| format!("unknown skill '{name}'"))?;
let mut output = String::new();
output.push_str(&format!("# Skill: {}\n\n", skill.name));
output.push_str(&format!("Location: {}\n\n", skill.location.display()));
output.push_str(skill.content.trim());
if !skill.companion_files.is_empty() {
output.push_str("\n\n## Companion files\n");
for file in &skill.companion_files {
output.push_str("- ");
output.push_str(&file.display().to_string());
output.push('\n');
}
}
Ok(output)
}
}
fn candidate_roots(workspace_root: &Path, config_dir: &Path, worktree: Option<&Path>) -> Vec<PathBuf> {
crate::log_debug!("candidate_roots: workspace_root={}, config_dir={}, worktree={:?}", workspace_root.display(), config_dir.display(), worktree.map(|p| p.display().to_string()));
let mut roots = Vec::new();
let mut seen = HashSet::new();
for ancestor in workspace_root.ancestors() {
if let Some(wt) = worktree {
if ancestor == wt {
crate::log_debug!("candidate_roots: reached worktree boundary at {}", ancestor.display());
} else if !ancestor.starts_with(wt) {
crate::log_debug!("candidate_roots: passed worktree boundary, stopping traversal");
break;
}
}
for root in SKILL_ROOTS {
let candidate = ancestor.join(root);
crate::log_debug!("candidate_roots: checking candidate={}", candidate.display());
if !candidate.is_dir() {
crate::log_debug!("candidate_roots: not a directory, skip");
continue;
}
let canonical = candidate
.canonicalize()
.unwrap_or_else(|_| candidate.clone());
if seen.insert(canonical.clone()) {
crate::log_info!("candidate_roots: found skill directory: {}", canonical.display());
roots.push(canonical);
} else {
crate::log_debug!("candidate_roots: already seen, skip");
}
}
if let Some(wt) = worktree
&& ancestor == wt {
crate::log_debug!("candidate_roots: stopping at worktree root");
break;
}
}
let global_root = config_dir.join("skills");
crate::log_debug!("candidate_roots: checking global root={}", global_root.display());
if global_root.is_dir() {
let canonical = global_root
.canonicalize()
.unwrap_or_else(|_| global_root.clone());
if seen.insert(canonical.clone()) {
crate::log_info!("candidate_roots: found global skill directory: {}", canonical.display());
roots.push(canonical);
}
}
crate::log_debug!("candidate_roots: returning {} roots", roots.len());
roots
}
fn discover_skill_files(root: &Path) -> Vec<PathBuf> {
crate::log_debug!("discover_skill_files: root={}", root.display());
let walker = WalkBuilder::new(root)
.hidden(false)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.follow_links(false)
.build();
let mut files = Vec::new();
let mut entry_count = 0u64;
for entry in walker {
entry_count += 1;
let Ok(entry) = entry else {
crate::log_debug!("discover_skill_files: walk error: {:?}", entry);
continue;
};
if !entry
.file_type()
.map(|file_type| file_type.is_file())
.unwrap_or(false)
{
continue;
}
if entry.file_name().to_string_lossy() == SKILL_FILE_NAME {
crate::log_debug!("discover_skill_files: found SKILL.md at {}", entry.path().display());
files.push(entry.path().to_path_buf());
}
}
crate::log_debug!(
"discover_skill_files: walked {} entries, found {} SKILL.md files",
entry_count, files.len()
);
files.sort();
files
}
fn parse_skill_file(path: &Path) -> Result<SkillInfo, ()> {
crate::log_debug!("parse_skill_file: path={}", path.display());
let raw_content = fs::read_to_string(path).map_err(|e| {
crate::log_debug!("parse_skill_file: read error for {}: {}", path.display(), e);
})?;
let parent = path.parent().ok_or_else(|| {
crate::log_debug!("parse_skill_file: no parent for {}", path.display());
})?;
parse_skill_content(
path.to_path_buf(),
Some(parent.to_path_buf()),
raw_content,
)
}
fn parse_skill_content(
location: PathBuf,
directory: Option<PathBuf>,
raw_content: String,
) -> Result<SkillInfo, ()> {
crate::log_debug!("parse_skill_content: location={}", location.display());
let normalized_content = raw_content.replace("\r\n", "\n");
let (frontmatter, body) = split_frontmatter(&normalized_content).ok_or_else(|| {
crate::log_debug!("parse_skill_content: no frontmatter found in {}", location.display());
})?;
let parsed: SkillFrontmatter = serde_yaml::from_str(frontmatter).map_err(|e| {
crate::log_debug!("parse_skill_content: yaml parse error for {}: {}", location.display(), e);
})?;
if !is_valid_skill_name(&parsed.name) {
crate::log_debug!("parse_skill_content: invalid skill name '{}' in {}", parsed.name, location.display());
return Err(());
}
let companion_files = directory
.as_ref()
.map(|dir| collect_companion_files(dir, &location))
.unwrap_or_default();
if let Some(directory) = directory.as_ref() {
let directory_name = directory
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
crate::log_debug!("parse_skill_content: invalid directory name in {}", location.display());
})?;
if directory_name != parsed.name {
crate::log_debug!(
"parse_skill_content: directory name '{}' does not match skill name '{}' in {}",
directory_name, parsed.name, location.display()
);
return Err(());
}
}
crate::log_debug!(
"parse_skill_content: success name='{}', description='{}', companion_files={}",
parsed.name, parsed.description, companion_files.len()
);
Ok(SkillInfo {
name: parsed.name,
description: parsed.description,
directory: directory.unwrap_or_else(|| location.clone()),
location,
content: body.trim().to_string(),
companion_files,
})
}
fn load_additional_skill_source(raw_source: &str, workspace_root: &Path) -> Option<SkillInfo> {
let raw_source = raw_source.trim();
if raw_source.is_empty() {
return None;
}
if raw_source.starts_with("http://") || raw_source.starts_with("https://") {
return fetch_remote_skill(raw_source).ok();
}
let resolved = resolve_local_skill_source(workspace_root, raw_source)?;
let content = fs::read_to_string(&resolved).ok()?;
parse_skill_content(
resolved.clone(),
resolved.parent().map(Path::to_path_buf),
content,
)
.ok()
}
fn resolve_local_skill_source(workspace_root: &Path, raw_source: &str) -> Option<PathBuf> {
let candidate = if let Some(stripped) = raw_source.strip_prefix("~/") {
dirs::home_dir()
.map(|dir| dir.join(stripped))
.unwrap_or_else(|| PathBuf::from(raw_source))
} else {
PathBuf::from(raw_source)
};
let candidate = if candidate.is_absolute() {
candidate
} else {
workspace_root.join(candidate)
};
if candidate.is_file() {
return Some(candidate);
}
let skill_file = candidate.join(SKILL_FILE_NAME);
if skill_file.is_file() {
return Some(skill_file);
}
None
}
fn fetch_remote_skill(url: &str) -> Result<SkillInfo, ()> {
let client = Client::builder()
.timeout(Duration::from_secs(5))
.build()
.map_err(|_| ())?;
let response = client.get(url).send().map_err(|_| ())?;
if !response.status().is_success() {
return Err(());
}
let content = response.text().map_err(|_| ())?;
parse_skill_content(PathBuf::from(url), None, content)
}
fn split_frontmatter(content: &str) -> Option<(&str, &str)> {
let content = content.strip_prefix("---\n")?;
let (frontmatter, body) = content.split_once("\n---\n")?;
Some((frontmatter, body))
}
fn collect_companion_files(skill_dir: &Path, skill_file: &Path) -> Vec<PathBuf> {
crate::log_debug!("collect_companion_files: skill_dir={}, skill_file={}", skill_dir.display(), skill_file.display());
let walker = WalkBuilder::new(skill_dir)
.hidden(false)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.follow_links(false)
.build();
let mut files = Vec::new();
let mut entry_count = 0u64;
for entry in walker {
entry_count += 1;
let Ok(entry) = entry else {
crate::log_debug!("collect_companion_files: walk error: {:?}", entry);
continue;
};
if !entry
.file_type()
.map(|file_type| file_type.is_file())
.unwrap_or(false)
{
continue;
}
let path = entry.path();
if path == skill_file {
continue;
}
if let Ok(relative) = path.strip_prefix(skill_dir) {
files.push(relative.to_path_buf());
} else {
files.push(path.to_path_buf());
}
if files.len() >= MAX_COMPANION_FILES {
crate::log_debug!("collect_companion_files: reached max companion files ({})", MAX_COMPANION_FILES);
break;
}
}
files.sort();
crate::log_debug!("collect_companion_files: walked {} entries, found {} companion files", entry_count, files.len());
files
}
fn is_valid_skill_name(name: &str) -> bool {
let name = name.trim();
if name.is_empty() || name.len() > 64 {
return false;
}
if name.starts_with('-') || name.ends_with('-') || name.contains("--") {
return false;
}
name.chars().all(|character| {
character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-'
})
}