1use super::validator::SkillValidator;
7use super::{Skill, SkillKind};
8use anyhow::Context;
9use std::collections::{HashMap, HashSet};
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, RwLock};
12use thiserror::Error;
13
14#[derive(Debug, Error)]
15pub(crate) enum SkillRegistrySnapshotError {
16 #[error("projected skill name '{name}' conflicts with the compatibility registry")]
17 NameConflict { name: String },
18 #[error("projected skill '{name}' failed Session validation: {message}")]
19 Validation { name: String, message: String },
20}
21
22pub struct SkillRegistry {
27 skills: Arc<RwLock<HashMap<String, Arc<Skill>>>>,
28 builtin_names: Arc<RwLock<HashSet<String>>>,
29 validator: Arc<RwLock<Option<Arc<dyn SkillValidator>>>>,
30}
31
32impl SkillRegistry {
33 pub fn new() -> Self {
35 Self {
36 skills: Arc::new(RwLock::new(HashMap::new())),
37 builtin_names: Arc::new(RwLock::new(HashSet::new())),
38 validator: Arc::new(RwLock::new(None)),
39 }
40 }
41
42 pub fn with_builtins() -> Self {
48 let registry = Self::new();
49 for skill in super::builtin::builtin_skills() {
50 registry.register_builtin(skill);
51 }
52 registry
53 }
54
55 pub fn fork(&self) -> Self {
61 let skills = self.skills.read().unwrap().clone();
62 let builtin_names = self.builtin_names.read().unwrap().clone();
63 Self {
64 skills: Arc::new(RwLock::new(skills)),
65 builtin_names: Arc::new(RwLock::new(builtin_names)),
66 validator: Arc::new(RwLock::new(self.validator.read().unwrap().clone())),
67 }
68 }
69
70 pub(crate) fn snapshot_with_external_skills(
74 &self,
75 external: impl IntoIterator<Item = Arc<Skill>>,
76 ) -> Result<Self, SkillRegistrySnapshotError> {
77 let mut skills = self.skills.read().unwrap().clone();
78 let builtin_names = self.builtin_names.read().unwrap().clone();
79 let validator = self.validator.read().unwrap().clone();
80 for skill in external {
81 let name = skill.name.clone();
82 if skills.contains_key(&name) {
83 return Err(SkillRegistrySnapshotError::NameConflict { name });
84 }
85 if let Some(validator) = &validator {
86 validator.validate(&skill).map_err(|error| {
87 SkillRegistrySnapshotError::Validation {
88 name: name.clone(),
89 message: error.to_string(),
90 }
91 })?;
92 }
93 skills.insert(name, skill);
94 }
95 Ok(Self {
96 skills: Arc::new(RwLock::new(skills)),
97 builtin_names: Arc::new(RwLock::new(builtin_names)),
98 validator: Arc::new(RwLock::new(validator)),
99 })
100 }
101
102 pub fn set_validator(&self, validator: Arc<dyn SkillValidator>) {
104 *self.validator.write().unwrap() = Some(validator);
105 }
106
107 pub fn register(
112 &self,
113 skill: Arc<Skill>,
114 ) -> Result<(), super::validator::SkillValidationError> {
115 if let Some(ref validator) = *self.validator.read().unwrap() {
117 validator.validate(&skill)?;
118 }
119 self.register_unchecked(skill);
120 Ok(())
121 }
122
123 pub fn register_unchecked(&self, skill: Arc<Skill>) {
128 let mut skills = self.skills.write().unwrap();
129 self.builtin_names.write().unwrap().remove(&skill.name);
130 skills.insert(skill.name.clone(), skill);
131 }
132
133 pub(crate) fn register_with_shadow(
140 &self,
141 skill: Arc<Skill>,
142 ) -> Result<(bool, Option<Arc<Skill>>), super::validator::SkillValidationError> {
143 if let Some(ref validator) = *self.validator.read().unwrap() {
144 validator.validate(&skill)?;
145 }
146
147 let name = skill.name.clone();
148 let mut skills = self.skills.write().unwrap();
149 let builtin_names = self.builtin_names.read().unwrap();
150 if builtin_names.contains(&name) {
151 tracing::warn!(
152 skill = %name,
153 "Rejected live skill registration because a built-in owns the name"
154 );
155 return Ok((false, None));
156 }
157
158 Ok((true, skills.insert(name, skill)))
159 }
160
161 pub(crate) fn restore_if_same(
166 &self,
167 name: &str,
168 expected: &Arc<Skill>,
169 replacement: Option<Arc<Skill>>,
170 ) -> bool {
171 let mut skills = self.skills.write().unwrap();
172 let Some(current) = skills.get(name) else {
173 return false;
174 };
175 if !Arc::ptr_eq(current, expected) {
176 return false;
177 }
178
179 match replacement {
180 Some(skill) => {
181 skills.insert(name.to_string(), skill);
182 }
183 None => {
184 skills.remove(name);
185 }
186 }
187 true
188 }
189
190 fn register_builtin(&self, skill: Arc<Skill>) {
191 let name = skill.name.clone();
192 self.skills.write().unwrap().insert(name.clone(), skill);
193 self.builtin_names.write().unwrap().insert(name);
194 }
195
196 pub fn get(&self, name: &str) -> Option<Arc<Skill>> {
198 let skills = self.skills.read().unwrap();
199 skills.get(name).cloned()
200 }
201
202 pub fn list(&self) -> Vec<String> {
204 let skills = self.skills.read().unwrap();
205 let mut names = skills.keys().cloned().collect::<Vec<_>>();
206 names.sort();
207 names
208 }
209
210 pub fn all(&self) -> Vec<Arc<Skill>> {
212 let skills = self.skills.read().unwrap();
213 let mut values = skills.values().cloned().collect::<Vec<_>>();
214 values.sort_by(|a, b| a.name.cmp(&b.name));
215 values
216 }
217
218 pub fn load_from_dir(&self, dir: impl AsRef<Path>) -> anyhow::Result<usize> {
230 let dir = dir.as_ref();
231
232 if !dir.exists() {
233 return Ok(0);
234 }
235
236 if !dir.is_dir() {
237 anyhow::bail!("Path is not a directory: {}", dir.display());
238 }
239
240 let mut loaded = 0;
241 for candidate in Self::collect_skill_candidates(dir)? {
242 match Skill::from_file(&candidate) {
243 Ok(skill) => {
244 let name = skill.name.clone();
245 if skill.allowed_tools.is_none() {
246 tracing::warn!(
247 skill = %name,
248 path = %candidate.display(),
249 "Skill omits allowed-tools; Skill invocation is fail-secure and will deny tool use until allowed-tools is declared"
250 );
251 } else if skill.uses_legacy_allowed_tools_syntax() {
252 tracing::warn!(
253 skill = %name,
254 path = %candidate.display(),
255 "Skill uses legacy whitespace-separated allowed-tools; use comma-separated permissions such as Read(*), Write(*), Bash(*) or a YAML list"
256 );
257 }
258 let skill = Arc::new(skill);
259 if self.get(&name).is_some() {
260 tracing::warn!(
261 skill = %name,
262 path = %candidate.display(),
263 "Duplicate skill name encountered during directory load — overriding previous definition"
264 );
265 }
266 match self.register(skill) {
267 Ok(()) => loaded += 1,
268 Err(e) => {
269 tracing::warn!(
270 "Skill validation failed for {}: {}",
271 candidate.display(),
272 e
273 );
274 }
275 }
276 }
277 Err(e) => {
278 tracing::debug!("Skipped {}: {}", candidate.display(), e);
279 }
280 }
281 }
282
283 Ok(loaded)
284 }
285
286 fn collect_skill_candidates(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
287 fn visit(dir: &Path, out: &mut Vec<PathBuf>) -> anyhow::Result<()> {
288 let mut entries = std::fs::read_dir(dir)
289 .with_context(|| format!("Failed to read directory: {}", dir.display()))?
290 .collect::<Result<Vec<_>, std::io::Error>>()?;
291 entries.sort_by_key(|entry| entry.path());
292
293 for entry in entries {
294 let path = entry.path();
295 if path.is_dir() {
296 let skill_md = path.join("SKILL.md");
297 if skill_md.is_file() {
298 out.push(skill_md);
299 }
300 visit(&path, out)?;
301 } else if path.extension().and_then(|s| s.to_str()) == Some("md") {
302 out.push(path);
303 }
304 }
305 Ok(())
306 }
307
308 let mut out = Vec::new();
309 visit(dir, &mut out)?;
310 out.sort();
311 out.dedup();
312 Ok(out)
313 }
314
315 pub fn load_from_file(&self, path: impl AsRef<Path>) -> anyhow::Result<Arc<Skill>> {
317 let skill = Skill::from_file(path)?;
318 let skill = Arc::new(skill);
319 self.register(skill.clone())
320 .map_err(|e| anyhow::anyhow!("Skill validation failed: {}", e))?;
321 Ok(skill)
322 }
323
324 pub fn remove(&self, name: &str) -> Option<Arc<Skill>> {
326 let mut skills = self.skills.write().unwrap();
327 skills.remove(name)
328 }
329
330 pub fn clear(&self) {
332 let mut skills = self.skills.write().unwrap();
333 skills.clear();
334 }
335
336 pub fn len(&self) -> usize {
338 let skills = self.skills.read().unwrap();
339 skills.len()
340 }
341
342 pub fn is_empty(&self) -> bool {
344 self.len() == 0
345 }
346
347 pub fn by_kind(&self, kind: super::SkillKind) -> Vec<Arc<Skill>> {
349 let skills = self.skills.read().unwrap();
350 let mut values = skills
351 .values()
352 .filter(|s| s.kind == kind)
353 .cloned()
354 .collect::<Vec<_>>();
355 values.sort_by(|a, b| a.name.cmp(&b.name));
356 values
357 }
358
359 pub fn global_tool_restricting_skills(&self) -> Vec<Arc<Skill>> {
365 let skills = self.skills.read().unwrap();
366 let builtin_names = self.builtin_names.read().unwrap();
367 let mut values = skills
368 .values()
369 .filter(|skill| {
370 skill.kind == SkillKind::Instruction
371 && skill.allowed_tools.is_some()
372 && !builtin_names.contains(&skill.name)
373 })
374 .cloned()
375 .collect::<Vec<_>>();
376 values.sort_by(|a, b| a.name.cmp(&b.name));
377 values
378 }
379
380 pub fn by_tag(&self, tag: &str) -> Vec<Arc<Skill>> {
382 let skills = self.skills.read().unwrap();
383 let mut values = skills
384 .values()
385 .filter(|s| s.tags.iter().any(|t| t == tag))
386 .cloned()
387 .collect::<Vec<_>>();
388 values.sort_by(|a, b| a.name.cmp(&b.name));
389 values
390 }
391
392 pub fn personas(&self) -> Vec<Arc<Skill>> {
397 self.by_kind(super::SkillKind::Persona)
398 }
399
400 pub fn search(&self, query: &str, limit: usize) -> Vec<Arc<Skill>> {
402 let skills = self.skills.read().unwrap();
403 let query_lower = query.to_lowercase();
404 let query_tokens: Vec<&str> = query_lower
405 .split_whitespace()
406 .map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
407 .filter(|w| w.len() >= 2)
408 .collect();
409
410 let mut scored: Vec<(u32, String, Arc<Skill>)> = skills
411 .values()
412 .filter(|s| Self::is_discoverable_skill(s))
413 .filter_map(|skill| {
414 let score = Self::skill_search_score(skill, &query_lower, &query_tokens);
415 if score == 0 {
416 None
417 } else {
418 Some((score, skill.name.clone(), Arc::clone(skill)))
419 }
420 })
421 .collect();
422
423 scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
424 scored
425 .into_iter()
426 .take(limit.max(1))
427 .map(|(_, _, skill)| skill)
428 .collect()
429 }
430
431 fn is_discoverable_skill(skill: &Skill) -> bool {
432 skill.kind == super::SkillKind::Instruction || skill.kind == super::SkillKind::Tool
433 }
434
435 fn skill_search_score(skill: &Skill, query_lower: &str, query_tokens: &[&str]) -> u32 {
436 if query_lower.trim().is_empty() {
437 return 1;
438 }
439
440 let name = skill.name.to_lowercase();
441 let description = skill.description.to_lowercase();
442 let tags: Vec<String> = skill.tags.iter().map(|t| t.to_lowercase()).collect();
443 let content = skill.content.to_lowercase();
444 let mut score = 0;
445
446 if query_lower.contains(&name) {
447 score += 100;
448 }
449 if tags.iter().any(|tag| query_lower.contains(tag)) {
450 score += 80;
451 }
452
453 for token in query_tokens {
454 if name.contains(token) {
455 score += 20;
456 }
457 if tags.iter().any(|tag| tag.contains(token)) {
458 score += 15;
459 }
460 if description.contains(token) {
461 score += 8;
462 }
463 if content.contains(token) {
464 score += 2;
465 }
466 }
467
468 score
469 }
470
471 pub fn to_system_prompt(&self) -> String {
481 let skills = self.skills.read().unwrap();
482
483 let has_discoverable_skill = skills.values().any(|s| Self::is_discoverable_skill(s));
484
485 if !has_discoverable_skill {
486 return String::new();
487 }
488
489 String::from(crate::prompts::SKILLS_CATALOG_HEADER)
490 }
491
492 pub fn match_skills(&self, user_input: &str) -> String {
497 let matched = self.search(user_input, 3);
498
499 if matched.is_empty() {
500 return String::new();
501 }
502
503 let mut out = String::from("# Skill Instructions\n\n");
504 for skill in matched {
505 out.push_str(&skill.to_system_prompt());
506 out.push_str("\n\n---\n\n");
507 }
508 out
509 }
510}
511
512impl Default for SkillRegistry {
513 fn default() -> Self {
514 Self::new()
515 }
516}
517
518#[cfg(test)]
519#[path = "registry/tests.rs"]
520mod tests;