a3s_code_core/skills/
registry.rs1use 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};
12
13pub struct SkillRegistry {
18 skills: Arc<RwLock<HashMap<String, Arc<Skill>>>>,
19 builtin_names: Arc<RwLock<HashSet<String>>>,
20 validator: Arc<RwLock<Option<Arc<dyn SkillValidator>>>>,
21}
22
23impl SkillRegistry {
24 pub fn new() -> Self {
26 Self {
27 skills: Arc::new(RwLock::new(HashMap::new())),
28 builtin_names: Arc::new(RwLock::new(HashSet::new())),
29 validator: Arc::new(RwLock::new(None)),
30 }
31 }
32
33 pub fn with_builtins() -> Self {
39 let registry = Self::new();
40 for skill in super::builtin::builtin_skills() {
41 registry.register_builtin(skill);
42 }
43 registry
44 }
45
46 pub fn fork(&self) -> Self {
52 let skills = self.skills.read().unwrap().clone();
53 let builtin_names = self.builtin_names.read().unwrap().clone();
54 Self {
55 skills: Arc::new(RwLock::new(skills)),
56 builtin_names: Arc::new(RwLock::new(builtin_names)),
57 validator: Arc::new(RwLock::new(self.validator.read().unwrap().clone())),
58 }
59 }
60
61 pub fn set_validator(&self, validator: Arc<dyn SkillValidator>) {
63 *self.validator.write().unwrap() = Some(validator);
64 }
65
66 pub fn register(
71 &self,
72 skill: Arc<Skill>,
73 ) -> Result<(), super::validator::SkillValidationError> {
74 if let Some(ref validator) = *self.validator.read().unwrap() {
76 validator.validate(&skill)?;
77 }
78 self.register_unchecked(skill);
79 Ok(())
80 }
81
82 pub fn register_unchecked(&self, skill: Arc<Skill>) {
87 let mut skills = self.skills.write().unwrap();
88 self.builtin_names.write().unwrap().remove(&skill.name);
89 skills.insert(skill.name.clone(), skill);
90 }
91
92 pub(crate) fn register_with_shadow(
99 &self,
100 skill: Arc<Skill>,
101 ) -> Result<(bool, Option<Arc<Skill>>), super::validator::SkillValidationError> {
102 if let Some(ref validator) = *self.validator.read().unwrap() {
103 validator.validate(&skill)?;
104 }
105
106 let name = skill.name.clone();
107 let mut skills = self.skills.write().unwrap();
108 let builtin_names = self.builtin_names.read().unwrap();
109 if builtin_names.contains(&name) {
110 tracing::warn!(
111 skill = %name,
112 "Rejected live skill registration because a built-in owns the name"
113 );
114 return Ok((false, None));
115 }
116
117 Ok((true, skills.insert(name, skill)))
118 }
119
120 pub(crate) fn restore_if_same(
125 &self,
126 name: &str,
127 expected: &Arc<Skill>,
128 replacement: Option<Arc<Skill>>,
129 ) -> bool {
130 let mut skills = self.skills.write().unwrap();
131 let Some(current) = skills.get(name) else {
132 return false;
133 };
134 if !Arc::ptr_eq(current, expected) {
135 return false;
136 }
137
138 match replacement {
139 Some(skill) => {
140 skills.insert(name.to_string(), skill);
141 }
142 None => {
143 skills.remove(name);
144 }
145 }
146 true
147 }
148
149 fn register_builtin(&self, skill: Arc<Skill>) {
150 let name = skill.name.clone();
151 self.skills.write().unwrap().insert(name.clone(), skill);
152 self.builtin_names.write().unwrap().insert(name);
153 }
154
155 pub fn get(&self, name: &str) -> Option<Arc<Skill>> {
157 let skills = self.skills.read().unwrap();
158 skills.get(name).cloned()
159 }
160
161 pub fn list(&self) -> Vec<String> {
163 let skills = self.skills.read().unwrap();
164 let mut names = skills.keys().cloned().collect::<Vec<_>>();
165 names.sort();
166 names
167 }
168
169 pub fn all(&self) -> Vec<Arc<Skill>> {
171 let skills = self.skills.read().unwrap();
172 let mut values = skills.values().cloned().collect::<Vec<_>>();
173 values.sort_by(|a, b| a.name.cmp(&b.name));
174 values
175 }
176
177 pub fn load_from_dir(&self, dir: impl AsRef<Path>) -> anyhow::Result<usize> {
189 let dir = dir.as_ref();
190
191 if !dir.exists() {
192 return Ok(0);
193 }
194
195 if !dir.is_dir() {
196 anyhow::bail!("Path is not a directory: {}", dir.display());
197 }
198
199 let mut loaded = 0;
200 for candidate in Self::collect_skill_candidates(dir)? {
201 match Skill::from_file(&candidate) {
202 Ok(skill) => {
203 let name = skill.name.clone();
204 if skill.allowed_tools.is_none() {
205 tracing::warn!(
206 skill = %name,
207 path = %candidate.display(),
208 "Skill omits allowed-tools; Skill invocation is fail-secure and will deny tool use until allowed-tools is declared"
209 );
210 } else if skill.uses_legacy_allowed_tools_syntax() {
211 tracing::warn!(
212 skill = %name,
213 path = %candidate.display(),
214 "Skill uses legacy whitespace-separated allowed-tools; use comma-separated permissions such as Read(*), Write(*), Bash(*) or a YAML list"
215 );
216 }
217 let skill = Arc::new(skill);
218 if self.get(&name).is_some() {
219 tracing::warn!(
220 skill = %name,
221 path = %candidate.display(),
222 "Duplicate skill name encountered during directory load — overriding previous definition"
223 );
224 }
225 match self.register(skill) {
226 Ok(()) => loaded += 1,
227 Err(e) => {
228 tracing::warn!(
229 "Skill validation failed for {}: {}",
230 candidate.display(),
231 e
232 );
233 }
234 }
235 }
236 Err(e) => {
237 tracing::debug!("Skipped {}: {}", candidate.display(), e);
238 }
239 }
240 }
241
242 Ok(loaded)
243 }
244
245 fn collect_skill_candidates(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
246 fn visit(dir: &Path, out: &mut Vec<PathBuf>) -> anyhow::Result<()> {
247 let mut entries = std::fs::read_dir(dir)
248 .with_context(|| format!("Failed to read directory: {}", dir.display()))?
249 .collect::<Result<Vec<_>, std::io::Error>>()?;
250 entries.sort_by_key(|entry| entry.path());
251
252 for entry in entries {
253 let path = entry.path();
254 if path.is_dir() {
255 let skill_md = path.join("SKILL.md");
256 if skill_md.is_file() {
257 out.push(skill_md);
258 }
259 visit(&path, out)?;
260 } else if path.extension().and_then(|s| s.to_str()) == Some("md") {
261 out.push(path);
262 }
263 }
264 Ok(())
265 }
266
267 let mut out = Vec::new();
268 visit(dir, &mut out)?;
269 out.sort();
270 out.dedup();
271 Ok(out)
272 }
273
274 pub fn load_from_file(&self, path: impl AsRef<Path>) -> anyhow::Result<Arc<Skill>> {
276 let skill = Skill::from_file(path)?;
277 let skill = Arc::new(skill);
278 self.register(skill.clone())
279 .map_err(|e| anyhow::anyhow!("Skill validation failed: {}", e))?;
280 Ok(skill)
281 }
282
283 pub fn remove(&self, name: &str) -> Option<Arc<Skill>> {
285 let mut skills = self.skills.write().unwrap();
286 skills.remove(name)
287 }
288
289 pub fn clear(&self) {
291 let mut skills = self.skills.write().unwrap();
292 skills.clear();
293 }
294
295 pub fn len(&self) -> usize {
297 let skills = self.skills.read().unwrap();
298 skills.len()
299 }
300
301 pub fn is_empty(&self) -> bool {
303 self.len() == 0
304 }
305
306 pub fn by_kind(&self, kind: super::SkillKind) -> Vec<Arc<Skill>> {
308 let skills = self.skills.read().unwrap();
309 let mut values = skills
310 .values()
311 .filter(|s| s.kind == kind)
312 .cloned()
313 .collect::<Vec<_>>();
314 values.sort_by(|a, b| a.name.cmp(&b.name));
315 values
316 }
317
318 pub fn global_tool_restricting_skills(&self) -> Vec<Arc<Skill>> {
324 let skills = self.skills.read().unwrap();
325 let builtin_names = self.builtin_names.read().unwrap();
326 let mut values = skills
327 .values()
328 .filter(|skill| {
329 skill.kind == SkillKind::Instruction
330 && skill.allowed_tools.is_some()
331 && !builtin_names.contains(&skill.name)
332 })
333 .cloned()
334 .collect::<Vec<_>>();
335 values.sort_by(|a, b| a.name.cmp(&b.name));
336 values
337 }
338
339 pub fn by_tag(&self, tag: &str) -> Vec<Arc<Skill>> {
341 let skills = self.skills.read().unwrap();
342 let mut values = skills
343 .values()
344 .filter(|s| s.tags.iter().any(|t| t == tag))
345 .cloned()
346 .collect::<Vec<_>>();
347 values.sort_by(|a, b| a.name.cmp(&b.name));
348 values
349 }
350
351 pub fn personas(&self) -> Vec<Arc<Skill>> {
356 self.by_kind(super::SkillKind::Persona)
357 }
358
359 pub fn search(&self, query: &str, limit: usize) -> Vec<Arc<Skill>> {
361 let skills = self.skills.read().unwrap();
362 let query_lower = query.to_lowercase();
363 let query_tokens: Vec<&str> = query_lower
364 .split_whitespace()
365 .map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
366 .filter(|w| w.len() >= 2)
367 .collect();
368
369 let mut scored: Vec<(u32, String, Arc<Skill>)> = skills
370 .values()
371 .filter(|s| Self::is_discoverable_skill(s))
372 .filter_map(|skill| {
373 let score = Self::skill_search_score(skill, &query_lower, &query_tokens);
374 if score == 0 {
375 None
376 } else {
377 Some((score, skill.name.clone(), Arc::clone(skill)))
378 }
379 })
380 .collect();
381
382 scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
383 scored
384 .into_iter()
385 .take(limit.max(1))
386 .map(|(_, _, skill)| skill)
387 .collect()
388 }
389
390 fn is_discoverable_skill(skill: &Skill) -> bool {
391 skill.kind == super::SkillKind::Instruction || skill.kind == super::SkillKind::Tool
392 }
393
394 fn skill_search_score(skill: &Skill, query_lower: &str, query_tokens: &[&str]) -> u32 {
395 if query_lower.trim().is_empty() {
396 return 1;
397 }
398
399 let name = skill.name.to_lowercase();
400 let description = skill.description.to_lowercase();
401 let tags: Vec<String> = skill.tags.iter().map(|t| t.to_lowercase()).collect();
402 let content = skill.content.to_lowercase();
403 let mut score = 0;
404
405 if query_lower.contains(&name) {
406 score += 100;
407 }
408 if tags.iter().any(|tag| query_lower.contains(tag)) {
409 score += 80;
410 }
411
412 for token in query_tokens {
413 if name.contains(token) {
414 score += 20;
415 }
416 if tags.iter().any(|tag| tag.contains(token)) {
417 score += 15;
418 }
419 if description.contains(token) {
420 score += 8;
421 }
422 if content.contains(token) {
423 score += 2;
424 }
425 }
426
427 score
428 }
429
430 pub fn to_system_prompt(&self) -> String {
440 let skills = self.skills.read().unwrap();
441
442 let has_discoverable_skill = skills.values().any(|s| Self::is_discoverable_skill(s));
443
444 if !has_discoverable_skill {
445 return String::new();
446 }
447
448 String::from(crate::prompts::SKILLS_CATALOG_HEADER)
449 }
450
451 pub fn match_skills(&self, user_input: &str) -> String {
456 let matched = self.search(user_input, 3);
457
458 if matched.is_empty() {
459 return String::new();
460 }
461
462 let mut out = String::from("# Skill Instructions\n\n");
463 for skill in matched {
464 out.push_str(&skill.to_system_prompt());
465 out.push_str("\n\n---\n\n");
466 }
467 out
468 }
469}
470
471impl Default for SkillRegistry {
472 fn default() -> Self {
473 Self::new()
474 }
475}
476
477#[cfg(test)]
478#[path = "registry/tests.rs"]
479mod tests;