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 self.builtin_names.write().unwrap().remove(&skill.name);
88 let mut skills = self.skills.write().unwrap();
89 skills.insert(skill.name.clone(), skill);
90 }
91
92 fn register_builtin(&self, skill: Arc<Skill>) {
93 let name = skill.name.clone();
94 self.skills.write().unwrap().insert(name.clone(), skill);
95 self.builtin_names.write().unwrap().insert(name);
96 }
97
98 pub fn get(&self, name: &str) -> Option<Arc<Skill>> {
100 let skills = self.skills.read().unwrap();
101 skills.get(name).cloned()
102 }
103
104 pub fn list(&self) -> Vec<String> {
106 let skills = self.skills.read().unwrap();
107 let mut names = skills.keys().cloned().collect::<Vec<_>>();
108 names.sort();
109 names
110 }
111
112 pub fn all(&self) -> Vec<Arc<Skill>> {
114 let skills = self.skills.read().unwrap();
115 let mut values = skills.values().cloned().collect::<Vec<_>>();
116 values.sort_by(|a, b| a.name.cmp(&b.name));
117 values
118 }
119
120 pub fn load_from_dir(&self, dir: impl AsRef<Path>) -> anyhow::Result<usize> {
132 let dir = dir.as_ref();
133
134 if !dir.exists() {
135 return Ok(0);
136 }
137
138 if !dir.is_dir() {
139 anyhow::bail!("Path is not a directory: {}", dir.display());
140 }
141
142 let mut loaded = 0;
143 for candidate in Self::collect_skill_candidates(dir)? {
144 match Skill::from_file(&candidate) {
145 Ok(skill) => {
146 let name = skill.name.clone();
147 if skill.allowed_tools.is_none() {
148 tracing::warn!(
149 skill = %name,
150 path = %candidate.display(),
151 "Skill omits allowed-tools; Skill invocation is fail-secure and will deny tool use until allowed-tools is declared"
152 );
153 } else if skill.uses_legacy_allowed_tools_syntax() {
154 tracing::warn!(
155 skill = %name,
156 path = %candidate.display(),
157 "Skill uses legacy whitespace-separated allowed-tools; use comma-separated permissions such as Read(*), Write(*), Bash(*) or a YAML list"
158 );
159 }
160 let skill = Arc::new(skill);
161 if self.get(&name).is_some() {
162 tracing::warn!(
163 skill = %name,
164 path = %candidate.display(),
165 "Duplicate skill name encountered during directory load — overriding previous definition"
166 );
167 }
168 match self.register(skill) {
169 Ok(()) => loaded += 1,
170 Err(e) => {
171 tracing::warn!(
172 "Skill validation failed for {}: {}",
173 candidate.display(),
174 e
175 );
176 }
177 }
178 }
179 Err(e) => {
180 tracing::debug!("Skipped {}: {}", candidate.display(), e);
181 }
182 }
183 }
184
185 Ok(loaded)
186 }
187
188 fn collect_skill_candidates(dir: &Path) -> anyhow::Result<Vec<PathBuf>> {
189 fn visit(dir: &Path, out: &mut Vec<PathBuf>) -> anyhow::Result<()> {
190 let mut entries = std::fs::read_dir(dir)
191 .with_context(|| format!("Failed to read directory: {}", dir.display()))?
192 .collect::<Result<Vec<_>, std::io::Error>>()?;
193 entries.sort_by_key(|entry| entry.path());
194
195 for entry in entries {
196 let path = entry.path();
197 if path.is_dir() {
198 let skill_md = path.join("SKILL.md");
199 if skill_md.is_file() {
200 out.push(skill_md);
201 }
202 visit(&path, out)?;
203 } else if path.extension().and_then(|s| s.to_str()) == Some("md") {
204 out.push(path);
205 }
206 }
207 Ok(())
208 }
209
210 let mut out = Vec::new();
211 visit(dir, &mut out)?;
212 out.sort();
213 out.dedup();
214 Ok(out)
215 }
216
217 pub fn load_from_file(&self, path: impl AsRef<Path>) -> anyhow::Result<Arc<Skill>> {
219 let skill = Skill::from_file(path)?;
220 let skill = Arc::new(skill);
221 self.register(skill.clone())
222 .map_err(|e| anyhow::anyhow!("Skill validation failed: {}", e))?;
223 Ok(skill)
224 }
225
226 pub fn remove(&self, name: &str) -> Option<Arc<Skill>> {
228 let mut skills = self.skills.write().unwrap();
229 skills.remove(name)
230 }
231
232 pub fn clear(&self) {
234 let mut skills = self.skills.write().unwrap();
235 skills.clear();
236 }
237
238 pub fn len(&self) -> usize {
240 let skills = self.skills.read().unwrap();
241 skills.len()
242 }
243
244 pub fn is_empty(&self) -> bool {
246 self.len() == 0
247 }
248
249 pub fn by_kind(&self, kind: super::SkillKind) -> Vec<Arc<Skill>> {
251 let skills = self.skills.read().unwrap();
252 let mut values = skills
253 .values()
254 .filter(|s| s.kind == kind)
255 .cloned()
256 .collect::<Vec<_>>();
257 values.sort_by(|a, b| a.name.cmp(&b.name));
258 values
259 }
260
261 pub fn global_tool_restricting_skills(&self) -> Vec<Arc<Skill>> {
267 let skills = self.skills.read().unwrap();
268 let builtin_names = self.builtin_names.read().unwrap();
269 let mut values = skills
270 .values()
271 .filter(|skill| {
272 skill.kind == SkillKind::Instruction
273 && skill.allowed_tools.is_some()
274 && !builtin_names.contains(&skill.name)
275 })
276 .cloned()
277 .collect::<Vec<_>>();
278 values.sort_by(|a, b| a.name.cmp(&b.name));
279 values
280 }
281
282 pub fn by_tag(&self, tag: &str) -> Vec<Arc<Skill>> {
284 let skills = self.skills.read().unwrap();
285 let mut values = skills
286 .values()
287 .filter(|s| s.tags.iter().any(|t| t == tag))
288 .cloned()
289 .collect::<Vec<_>>();
290 values.sort_by(|a, b| a.name.cmp(&b.name));
291 values
292 }
293
294 pub fn personas(&self) -> Vec<Arc<Skill>> {
299 self.by_kind(super::SkillKind::Persona)
300 }
301
302 pub fn search(&self, query: &str, limit: usize) -> Vec<Arc<Skill>> {
304 let skills = self.skills.read().unwrap();
305 let query_lower = query.to_lowercase();
306 let query_tokens: Vec<&str> = query_lower
307 .split_whitespace()
308 .map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
309 .filter(|w| w.len() >= 2)
310 .collect();
311
312 let mut scored: Vec<(u32, String, Arc<Skill>)> = skills
313 .values()
314 .filter(|s| Self::is_discoverable_skill(s))
315 .filter_map(|skill| {
316 let score = Self::skill_search_score(skill, &query_lower, &query_tokens);
317 if score == 0 {
318 None
319 } else {
320 Some((score, skill.name.clone(), Arc::clone(skill)))
321 }
322 })
323 .collect();
324
325 scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
326 scored
327 .into_iter()
328 .take(limit.max(1))
329 .map(|(_, _, skill)| skill)
330 .collect()
331 }
332
333 fn is_discoverable_skill(skill: &Skill) -> bool {
334 skill.kind == super::SkillKind::Instruction || skill.kind == super::SkillKind::Tool
335 }
336
337 fn skill_search_score(skill: &Skill, query_lower: &str, query_tokens: &[&str]) -> u32 {
338 if query_lower.trim().is_empty() {
339 return 1;
340 }
341
342 let name = skill.name.to_lowercase();
343 let description = skill.description.to_lowercase();
344 let tags: Vec<String> = skill.tags.iter().map(|t| t.to_lowercase()).collect();
345 let content = skill.content.to_lowercase();
346 let mut score = 0;
347
348 if query_lower.contains(&name) {
349 score += 100;
350 }
351 if tags.iter().any(|tag| query_lower.contains(tag)) {
352 score += 80;
353 }
354
355 for token in query_tokens {
356 if name.contains(token) {
357 score += 20;
358 }
359 if tags.iter().any(|tag| tag.contains(token)) {
360 score += 15;
361 }
362 if description.contains(token) {
363 score += 8;
364 }
365 if content.contains(token) {
366 score += 2;
367 }
368 }
369
370 score
371 }
372
373 pub fn to_system_prompt(&self) -> String {
383 let skills = self.skills.read().unwrap();
384
385 let has_discoverable_skill = skills.values().any(|s| Self::is_discoverable_skill(s));
386
387 if !has_discoverable_skill {
388 return String::new();
389 }
390
391 String::from(crate::prompts::SKILLS_CATALOG_HEADER)
392 }
393
394 pub fn match_skills(&self, user_input: &str) -> String {
399 let matched = self.search(user_input, 3);
400
401 if matched.is_empty() {
402 return String::new();
403 }
404
405 let mut out = String::from("# Skill Instructions\n\n");
406 for skill in matched {
407 out.push_str(&skill.to_system_prompt());
408 out.push_str("\n\n---\n\n");
409 }
410 out
411 }
412}
413
414impl Default for SkillRegistry {
415 fn default() -> Self {
416 Self::new()
417 }
418}
419
420#[cfg(test)]
421#[path = "registry/tests.rs"]
422mod tests;