use super::validator::SkillValidator;
use super::{Skill, SkillKind};
use anyhow::Context;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use thiserror::Error;
#[derive(Debug, Error)]
pub(crate) enum SkillRegistrySnapshotError {
#[error("projected skill name '{name}' conflicts with the compatibility registry")]
NameConflict { name: String },
#[error("projected skill '{name}' failed Session validation: {message}")]
Validation { name: String, message: String },
}
pub struct SkillRegistry {
skills: Arc<RwLock<HashMap<String, Arc<Skill>>>>,
builtin_names: Arc<RwLock<HashSet<String>>>,
validator: Arc<RwLock<Option<Arc<dyn SkillValidator>>>>,
}
impl SkillRegistry {
pub fn new() -> Self {
Self {
skills: Arc::new(RwLock::new(HashMap::new())),
builtin_names: Arc::new(RwLock::new(HashSet::new())),
validator: Arc::new(RwLock::new(None)),
}
}
pub fn with_builtins() -> Self {
let registry = Self::new();
for skill in super::builtin::builtin_skills() {
registry.register_builtin(skill);
}
registry
}
pub fn fork(&self) -> Self {
let skills = self.skills.read().unwrap().clone();
let builtin_names = self.builtin_names.read().unwrap().clone();
Self {
skills: Arc::new(RwLock::new(skills)),
builtin_names: Arc::new(RwLock::new(builtin_names)),
validator: Arc::new(RwLock::new(self.validator.read().unwrap().clone())),
}
}
pub(crate) fn snapshot_with_external_skills(
&self,
external: impl IntoIterator<Item = Arc<Skill>>,
) -> Result<Self, SkillRegistrySnapshotError> {
let mut skills = self.skills.read().unwrap().clone();
let builtin_names = self.builtin_names.read().unwrap().clone();
let validator = self.validator.read().unwrap().clone();
for skill in external {
let name = skill.name.clone();
if skills.contains_key(&name) {
return Err(SkillRegistrySnapshotError::NameConflict { name });
}
if let Some(validator) = &validator {
validator.validate(&skill).map_err(|error| {
SkillRegistrySnapshotError::Validation {
name: name.clone(),
message: error.to_string(),
}
})?;
}
skills.insert(name, skill);
}
Ok(Self {
skills: Arc::new(RwLock::new(skills)),
builtin_names: Arc::new(RwLock::new(builtin_names)),
validator: Arc::new(RwLock::new(validator)),
})
}
pub fn set_validator(&self, validator: Arc<dyn SkillValidator>) {
*self.validator.write().unwrap() = Some(validator);
}
pub fn register(
&self,
skill: Arc<Skill>,
) -> Result<(), super::validator::SkillValidationError> {
if let Some(ref validator) = *self.validator.read().unwrap() {
validator.validate(&skill)?;
}
self.register_unchecked(skill);
Ok(())
}
pub fn register_unchecked(&self, skill: Arc<Skill>) {
let mut skills = self.skills.write().unwrap();
self.builtin_names.write().unwrap().remove(&skill.name);
skills.insert(skill.name.clone(), skill);
}
pub(crate) fn register_with_shadow(
&self,
skill: Arc<Skill>,
) -> Result<(bool, Option<Arc<Skill>>), super::validator::SkillValidationError> {
if let Some(ref validator) = *self.validator.read().unwrap() {
validator.validate(&skill)?;
}
let name = skill.name.clone();
let mut skills = self.skills.write().unwrap();
let builtin_names = self.builtin_names.read().unwrap();
if builtin_names.contains(&name) {
tracing::warn!(
skill = %name,
"Rejected live skill registration because a built-in owns the name"
);
return Ok((false, None));
}
Ok((true, skills.insert(name, skill)))
}
pub(crate) fn restore_if_same(
&self,
name: &str,
expected: &Arc<Skill>,
replacement: Option<Arc<Skill>>,
) -> bool {
let mut skills = self.skills.write().unwrap();
let Some(current) = skills.get(name) else {
return false;
};
if !Arc::ptr_eq(current, expected) {
return false;
}
match replacement {
Some(skill) => {
skills.insert(name.to_string(), skill);
}
None => {
skills.remove(name);
}
}
true
}
fn register_builtin(&self, skill: Arc<Skill>) {
let name = skill.name.clone();
self.skills.write().unwrap().insert(name.clone(), skill);
self.builtin_names.write().unwrap().insert(name);
}
pub fn get(&self, name: &str) -> Option<Arc<Skill>> {
let skills = self.skills.read().unwrap();
skills.get(name).cloned()
}
pub fn list(&self) -> Vec<String> {
let skills = self.skills.read().unwrap();
let mut names = skills.keys().cloned().collect::<Vec<_>>();
names.sort();
names
}
pub fn all(&self) -> Vec<Arc<Skill>> {
let skills = self.skills.read().unwrap();
let mut values = skills.values().cloned().collect::<Vec<_>>();
values.sort_by(|a, b| a.name.cmp(&b.name));
values
}
pub fn load_from_dir(&self, dir: impl AsRef<Path>) -> anyhow::Result<usize> {
let dir = dir.as_ref();
if !dir.exists() {
return Ok(0);
}
if !dir.is_dir() {
anyhow::bail!("Path is not a directory: {}", dir.display());
}
let mut loaded = 0;
for candidate in Self::collect_skill_candidates(dir)? {
match Skill::from_file(&candidate) {
Ok(skill) => {
let name = skill.name.clone();
if skill.allowed_tools.is_none() {
tracing::warn!(
skill = %name,
path = %candidate.display(),
"Skill omits allowed-tools; Skill invocation is fail-secure and will deny tool use until allowed-tools is declared"
);
} else if skill.uses_legacy_allowed_tools_syntax() {
tracing::warn!(
skill = %name,
path = %candidate.display(),
"Skill uses legacy whitespace-separated allowed-tools; use comma-separated permissions such as Read(*), Write(*), Bash(*) or a YAML list"
);
}
let skill = Arc::new(skill);
if self.get(&name).is_some() {
tracing::warn!(
skill = %name,
path = %candidate.display(),
"Duplicate skill name encountered during directory load — overriding previous definition"
);
}
match self.register(skill) {
Ok(()) => loaded += 1,
Err(e) => {
tracing::warn!(
"Skill validation failed for {}: {}",
candidate.display(),
e
);
}
}
}
Err(e) => {
tracing::debug!("Skipped {}: {}", candidate.display(), e);
}
}
}
Ok(loaded)
}
fn collect_skill_candidates(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
fn visit(dir: &Path, out: &mut Vec<PathBuf>) -> anyhow::Result<()> {
let mut entries = std::fs::read_dir(dir)
.with_context(|| format!("Failed to read directory: {}", dir.display()))?
.collect::<Result<Vec<_>, std::io::Error>>()?;
entries.sort_by_key(|entry| entry.path());
for entry in entries {
let path = entry.path();
if path.is_dir() {
let skill_md = path.join("SKILL.md");
if skill_md.is_file() {
out.push(skill_md);
}
visit(&path, out)?;
} else if path.extension().and_then(|s| s.to_str()) == Some("md") {
out.push(path);
}
}
Ok(())
}
let mut out = Vec::new();
visit(dir, &mut out)?;
out.sort();
out.dedup();
Ok(out)
}
pub fn load_from_file(&self, path: impl AsRef<Path>) -> anyhow::Result<Arc<Skill>> {
let skill = Skill::from_file(path)?;
let skill = Arc::new(skill);
self.register(skill.clone())
.map_err(|e| anyhow::anyhow!("Skill validation failed: {}", e))?;
Ok(skill)
}
pub fn remove(&self, name: &str) -> Option<Arc<Skill>> {
let mut skills = self.skills.write().unwrap();
skills.remove(name)
}
pub fn clear(&self) {
let mut skills = self.skills.write().unwrap();
skills.clear();
}
pub fn len(&self) -> usize {
let skills = self.skills.read().unwrap();
skills.len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn by_kind(&self, kind: super::SkillKind) -> Vec<Arc<Skill>> {
let skills = self.skills.read().unwrap();
let mut values = skills
.values()
.filter(|s| s.kind == kind)
.cloned()
.collect::<Vec<_>>();
values.sort_by(|a, b| a.name.cmp(&b.name));
values
}
pub fn global_tool_restricting_skills(&self) -> Vec<Arc<Skill>> {
let skills = self.skills.read().unwrap();
let builtin_names = self.builtin_names.read().unwrap();
let mut values = skills
.values()
.filter(|skill| {
skill.kind == SkillKind::Instruction
&& skill.allowed_tools.is_some()
&& !builtin_names.contains(&skill.name)
})
.cloned()
.collect::<Vec<_>>();
values.sort_by(|a, b| a.name.cmp(&b.name));
values
}
pub fn by_tag(&self, tag: &str) -> Vec<Arc<Skill>> {
let skills = self.skills.read().unwrap();
let mut values = skills
.values()
.filter(|s| s.tags.iter().any(|t| t == tag))
.cloned()
.collect::<Vec<_>>();
values.sort_by(|a, b| a.name.cmp(&b.name));
values
}
pub fn personas(&self) -> Vec<Arc<Skill>> {
self.by_kind(super::SkillKind::Persona)
}
pub fn search(&self, query: &str, limit: usize) -> Vec<Arc<Skill>> {
let skills = self.skills.read().unwrap();
let query_lower = query.to_lowercase();
let query_tokens: Vec<&str> = query_lower
.split_whitespace()
.map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
.filter(|w| w.len() >= 2)
.collect();
let mut scored: Vec<(u32, String, Arc<Skill>)> = skills
.values()
.filter(|s| Self::is_discoverable_skill(s))
.filter_map(|skill| {
let score = Self::skill_search_score(skill, &query_lower, &query_tokens);
if score == 0 {
None
} else {
Some((score, skill.name.clone(), Arc::clone(skill)))
}
})
.collect();
scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
scored
.into_iter()
.take(limit.max(1))
.map(|(_, _, skill)| skill)
.collect()
}
fn is_discoverable_skill(skill: &Skill) -> bool {
skill.kind == super::SkillKind::Instruction || skill.kind == super::SkillKind::Tool
}
fn skill_search_score(skill: &Skill, query_lower: &str, query_tokens: &[&str]) -> u32 {
if query_lower.trim().is_empty() {
return 1;
}
let name = skill.name.to_lowercase();
let description = skill.description.to_lowercase();
let tags: Vec<String> = skill.tags.iter().map(|t| t.to_lowercase()).collect();
let content = skill.content.to_lowercase();
let mut score = 0;
if query_lower.contains(&name) {
score += 100;
}
if tags.iter().any(|tag| query_lower.contains(tag)) {
score += 80;
}
for token in query_tokens {
if name.contains(token) {
score += 20;
}
if tags.iter().any(|tag| tag.contains(token)) {
score += 15;
}
if description.contains(token) {
score += 8;
}
if content.contains(token) {
score += 2;
}
}
score
}
pub fn to_system_prompt(&self) -> String {
let skills = self.skills.read().unwrap();
let has_discoverable_skill = skills.values().any(|s| Self::is_discoverable_skill(s));
if !has_discoverable_skill {
return String::new();
}
String::from(crate::prompts::SKILLS_CATALOG_HEADER)
}
pub fn match_skills(&self, user_input: &str) -> String {
let matched = self.search(user_input, 3);
if matched.is_empty() {
return String::new();
}
let mut out = String::from("# Skill Instructions\n\n");
for skill in matched {
out.push_str(&skill.to_system_prompt());
out.push_str("\n\n---\n\n");
}
out
}
}
impl Default for SkillRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
#[path = "registry/tests.rs"]
mod tests;