1use crate::materialize::{load_asset_material, load_reference_material, run_script_material};
2use crate::skill_md::{parse_allowed_tools, parse_skill_md, SkillFrontmatter};
3use crate::{
4 ScriptResult, Skill, SkillError, SkillMaterializeError, SkillMeta, SkillResource,
5 SkillResourceKind, SkillWarning,
6};
7use async_trait::async_trait;
8use std::collections::HashMap;
9use std::fs;
10use std::io::{BufRead, BufReader};
11use std::path::{Path, PathBuf};
12use std::sync::mpsc;
13use std::sync::{Arc, Mutex, RwLock, Weak};
14use std::thread::JoinHandle;
15use std::time::Duration;
16use unicode_normalization::UnicodeNormalization;
17
18#[derive(Debug, Clone)]
26pub struct FsSkill {
27 meta: SkillMeta,
28 root_dir: PathBuf,
29 skill_md_path: PathBuf,
30}
31
32#[derive(Debug, Clone)]
34pub struct DiscoveryResult {
35 pub skills: Vec<FsSkill>,
36 pub warnings: Vec<SkillWarning>,
37}
38
39pub trait SkillRegistry: Send + Sync {
40 fn len(&self) -> usize;
41
42 fn is_empty(&self) -> bool {
43 self.len() == 0
44 }
45
46 fn get(&self, id: &str) -> Option<Arc<dyn Skill>>;
47
48 fn ids(&self) -> Vec<String>;
49
50 fn snapshot(&self) -> HashMap<String, Arc<dyn Skill>>;
51
52 fn start_periodic_refresh(&self, _interval: Duration) -> Result<(), SkillRegistryManagerError> {
53 Ok(())
54 }
55
56 fn stop_periodic_refresh(&self) -> bool {
57 false
58 }
59
60 fn periodic_refresh_running(&self) -> bool {
61 false
62 }
63}
64
65#[derive(Debug, thiserror::Error)]
66pub enum SkillRegistryError {
67 #[error("skill id must be non-empty")]
68 EmptySkillId,
69
70 #[error("duplicate skill id: {0}")]
71 DuplicateSkillId(String),
72}
73
74#[derive(Clone, Default)]
75pub struct InMemorySkillRegistry {
76 skills: HashMap<String, Arc<dyn Skill>>,
77}
78
79impl std::fmt::Debug for InMemorySkillRegistry {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 f.debug_struct("InMemorySkillRegistry")
82 .field("len", &self.skills.len())
83 .finish()
84 }
85}
86
87impl InMemorySkillRegistry {
88 pub fn new() -> Self {
89 Self::default()
90 }
91
92 pub fn is_empty(&self) -> bool {
93 self.skills.is_empty()
94 }
95
96 pub fn from_skills(skills: Vec<Arc<dyn Skill>>) -> Self {
97 let mut registry = Self::new();
98 registry.extend_upsert(skills);
99 registry
100 }
101
102 pub fn register(&mut self, skill: Arc<dyn Skill>) -> Result<(), SkillRegistryError> {
103 let id = skill.meta().id.trim().to_string();
104 if id.is_empty() {
105 return Err(SkillRegistryError::EmptySkillId);
106 }
107 if self.skills.contains_key(&id) {
108 return Err(SkillRegistryError::DuplicateSkillId(id));
109 }
110 self.skills.insert(id, skill);
111 Ok(())
112 }
113
114 pub fn extend_upsert(&mut self, skills: Vec<Arc<dyn Skill>>) {
115 for skill in skills {
116 let id = skill.meta().id.trim().to_string();
117 if id.is_empty() {
118 continue;
119 }
120 self.skills.insert(id, skill);
121 }
122 }
123
124 pub fn extend_registry(&mut self, other: &dyn SkillRegistry) -> Result<(), SkillRegistryError> {
125 for (_, skill) in other.snapshot() {
126 self.register(skill)?;
127 }
128 Ok(())
129 }
130}
131
132impl SkillRegistry for InMemorySkillRegistry {
133 fn len(&self) -> usize {
134 self.skills.len()
135 }
136
137 fn get(&self, id: &str) -> Option<Arc<dyn Skill>> {
138 self.skills.get(id).cloned()
139 }
140
141 fn ids(&self) -> Vec<String> {
142 let mut ids: Vec<String> = self.skills.keys().cloned().collect();
143 ids.sort();
144 ids
145 }
146
147 fn snapshot(&self) -> HashMap<String, Arc<dyn Skill>> {
148 self.skills.clone()
149 }
150}
151
152#[derive(Clone, Default)]
153pub struct CompositeSkillRegistry {
154 registries: Vec<Arc<dyn SkillRegistry>>,
155 cached_snapshot: Arc<RwLock<HashMap<String, Arc<dyn Skill>>>>,
156}
157
158impl std::fmt::Debug for CompositeSkillRegistry {
159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 let snapshot = read_lock(&self.cached_snapshot);
161 f.debug_struct("CompositeSkillRegistry")
162 .field("registries", &self.registries.len())
163 .field("len", &snapshot.len())
164 .finish()
165 }
166}
167
168impl CompositeSkillRegistry {
169 pub fn try_new(
170 regs: impl IntoIterator<Item = Arc<dyn SkillRegistry>>,
171 ) -> Result<Self, SkillRegistryError> {
172 let registries: Vec<Arc<dyn SkillRegistry>> = regs.into_iter().collect();
173 let merged = Self::merge_snapshots(®istries)?;
174 Ok(Self {
175 registries,
176 cached_snapshot: Arc::new(RwLock::new(merged)),
177 })
178 }
179
180 fn merge_snapshots(
181 registries: &[Arc<dyn SkillRegistry>],
182 ) -> Result<HashMap<String, Arc<dyn Skill>>, SkillRegistryError> {
183 let mut merged = InMemorySkillRegistry::new();
184 for reg in registries {
185 merged.extend_registry(reg.as_ref())?;
186 }
187 Ok(merged.snapshot())
188 }
189
190 fn refresh_snapshot(&self) -> Result<HashMap<String, Arc<dyn Skill>>, SkillRegistryError> {
191 Self::merge_snapshots(&self.registries)
192 }
193
194 fn read_cached_snapshot(&self) -> HashMap<String, Arc<dyn Skill>> {
195 read_lock(&self.cached_snapshot).clone()
196 }
197
198 fn write_cached_snapshot(&self, snapshot: HashMap<String, Arc<dyn Skill>>) {
199 *write_lock(&self.cached_snapshot) = snapshot;
200 }
201}
202
203impl SkillRegistry for CompositeSkillRegistry {
204 fn len(&self) -> usize {
205 self.snapshot().len()
206 }
207
208 fn get(&self, id: &str) -> Option<Arc<dyn Skill>> {
209 self.snapshot().get(id).cloned()
210 }
211
212 fn ids(&self) -> Vec<String> {
213 let mut ids: Vec<String> = self.snapshot().keys().cloned().collect();
214 ids.sort();
215 ids
216 }
217
218 fn snapshot(&self) -> HashMap<String, Arc<dyn Skill>> {
219 match self.refresh_snapshot() {
220 Ok(snapshot) => {
221 self.write_cached_snapshot(snapshot.clone());
222 snapshot
223 }
224 Err(_) => self.read_cached_snapshot(),
225 }
226 }
227
228 fn start_periodic_refresh(&self, interval: Duration) -> Result<(), SkillRegistryManagerError> {
229 let mut started: Vec<Arc<dyn SkillRegistry>> = Vec::new();
230 for registry in &self.registries {
231 match registry.start_periodic_refresh(interval) {
232 Ok(()) => started.push(registry.clone()),
233 Err(SkillRegistryManagerError::PeriodicRefreshAlreadyRunning) => {}
234 Err(err) => {
235 for reg in started {
236 let _ = reg.stop_periodic_refresh();
237 }
238 return Err(err);
239 }
240 }
241 }
242 Ok(())
243 }
244
245 fn stop_periodic_refresh(&self) -> bool {
246 let mut stopped = false;
247 for registry in &self.registries {
248 stopped |= registry.stop_periodic_refresh();
249 }
250 stopped
251 }
252
253 fn periodic_refresh_running(&self) -> bool {
254 self.registries
255 .iter()
256 .any(|registry| registry.periodic_refresh_running())
257 }
258}
259
260#[derive(Debug, thiserror::Error)]
261pub enum SkillRegistryManagerError {
262 #[error(transparent)]
263 Skill(#[from] SkillError),
264
265 #[error(transparent)]
266 Registry(#[from] SkillRegistryError),
267
268 #[error("skill roots list must be non-empty")]
269 EmptyRoots,
270
271 #[error("periodic refresh interval must be > 0")]
272 InvalidRefreshInterval,
273
274 #[error("periodic refresh loop is already running")]
275 PeriodicRefreshAlreadyRunning,
276
277 #[error("periodic refresh join failed: {0}")]
278 Join(String),
279}
280
281#[derive(Clone, Default)]
282struct SkillRegistrySnapshot {
283 version: u64,
284 skills: HashMap<String, Arc<dyn Skill>>,
285 warnings: Vec<SkillWarning>,
286}
287
288struct PeriodicRefreshRuntime {
289 stop_tx: Option<mpsc::Sender<()>>,
290 join: JoinHandle<()>,
291}
292
293struct SkillRegistryState {
294 roots: Vec<PathBuf>,
295 snapshot: RwLock<SkillRegistrySnapshot>,
296 periodic_refresh: Mutex<Option<PeriodicRefreshRuntime>>,
297}
298
299fn read_lock<T>(lock: &RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
300 match lock.read() {
301 Ok(guard) => guard,
302 Err(poisoned) => poisoned.into_inner(),
303 }
304}
305
306fn write_lock<T>(lock: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
307 match lock.write() {
308 Ok(guard) => guard,
309 Err(poisoned) => poisoned.into_inner(),
310 }
311}
312
313fn mutex_lock<T>(lock: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
314 match lock.lock() {
315 Ok(guard) => guard,
316 Err(poisoned) => poisoned.into_inner(),
317 }
318}
319
320fn is_periodic_refresh_running(state: &SkillRegistryState) -> bool {
321 let mut runtime = mutex_lock(&state.periodic_refresh);
322 if runtime
323 .as_ref()
324 .is_some_and(|running| running.join.is_finished())
325 {
326 if let Some(finished) = runtime.take() {
327 let _ = finished.join.join();
328 }
329 return false;
330 }
331 runtime.is_some()
332}
333
334type DiscoverSnapshot = (HashMap<String, Arc<dyn Skill>>, Vec<SkillWarning>);
335
336fn discover_snapshot_from_roots(
337 roots: &[PathBuf],
338) -> Result<DiscoverSnapshot, SkillRegistryManagerError> {
339 let discovered = FsSkill::discover_roots(roots.to_vec())?;
340 let mut map: HashMap<String, Arc<dyn Skill>> = HashMap::new();
341 for skill in discovered.skills {
342 let arc = Arc::new(skill) as Arc<dyn Skill>;
343 let id = arc.meta().id.trim().to_string();
344 if id.is_empty() {
345 return Err(SkillRegistryError::EmptySkillId.into());
346 }
347 if map.insert(id.clone(), arc).is_some() {
348 return Err(SkillRegistryError::DuplicateSkillId(id).into());
349 }
350 }
351 Ok((map, discovered.warnings))
352}
353
354async fn refresh_state(state: &SkillRegistryState) -> Result<u64, SkillRegistryManagerError> {
355 let roots = state.roots.clone();
356 let handle = tokio::task::spawn_blocking(move || discover_snapshot_from_roots(&roots));
357 let (skills, warnings) = handle
358 .await
359 .map_err(|e| SkillRegistryManagerError::Join(e.to_string()))??;
360 Ok(apply_snapshot(state, skills, warnings))
361}
362
363fn apply_snapshot(
364 state: &SkillRegistryState,
365 skills: HashMap<String, Arc<dyn Skill>>,
366 warnings: Vec<SkillWarning>,
367) -> u64 {
368 let mut snapshot = write_lock(&state.snapshot);
369 let version = snapshot.version.saturating_add(1);
370 *snapshot = SkillRegistrySnapshot {
371 version,
372 skills,
373 warnings,
374 };
375 version
376}
377
378fn refresh_state_blocking(state: &SkillRegistryState) -> Result<u64, SkillRegistryManagerError> {
379 let (skills, warnings) = discover_snapshot_from_roots(&state.roots)?;
380 Ok(apply_snapshot(state, skills, warnings))
381}
382
383fn periodic_refresh_loop(
384 state: Weak<SkillRegistryState>,
385 interval: Duration,
386 stop_rx: mpsc::Receiver<()>,
387) {
388 loop {
389 match stop_rx.recv_timeout(interval) {
390 Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => break,
391 Err(mpsc::RecvTimeoutError::Timeout) => {
392 let Some(state) = state.upgrade() else {
393 break;
394 };
395 let _ = refresh_state_blocking(state.as_ref());
396 }
397 }
398 }
399}
400
401#[derive(Clone)]
402pub struct FsSkillRegistryManager {
403 state: Arc<SkillRegistryState>,
404}
405
406impl std::fmt::Debug for FsSkillRegistryManager {
407 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
408 let snapshot = read_lock(&self.state.snapshot);
409 let periodic_running = is_periodic_refresh_running(self.state.as_ref());
410 f.debug_struct("FsSkillRegistryManager")
411 .field("roots", &self.state.roots.len())
412 .field("skills", &snapshot.skills.len())
413 .field("warnings", &snapshot.warnings.len())
414 .field("version", &snapshot.version)
415 .field("periodic_refresh_running", &periodic_running)
416 .finish()
417 }
418}
419
420impl FsSkillRegistryManager {
421 pub fn discover_roots(roots: Vec<PathBuf>) -> Result<Self, SkillRegistryManagerError> {
422 if roots.is_empty() {
423 return Err(SkillRegistryManagerError::EmptyRoots);
424 }
425 let (skills, warnings) = discover_snapshot_from_roots(&roots)?;
426 let snapshot = SkillRegistrySnapshot {
427 version: 1,
428 skills,
429 warnings,
430 };
431 Ok(Self {
432 state: Arc::new(SkillRegistryState {
433 roots,
434 snapshot: RwLock::new(snapshot),
435 periodic_refresh: Mutex::new(None),
436 }),
437 })
438 }
439
440 pub async fn refresh(&self) -> Result<u64, SkillRegistryManagerError> {
441 refresh_state(self.state.as_ref()).await
442 }
443
444 pub fn start_periodic_refresh(
445 &self,
446 interval: Duration,
447 ) -> Result<(), SkillRegistryManagerError> {
448 if interval.is_zero() {
449 return Err(SkillRegistryManagerError::InvalidRefreshInterval);
450 }
451 let mut runtime = mutex_lock(&self.state.periodic_refresh);
452 if runtime
453 .as_ref()
454 .is_some_and(|running| !running.join.is_finished())
455 {
456 return Err(SkillRegistryManagerError::PeriodicRefreshAlreadyRunning);
457 }
458 if let Some(finished) = runtime.take() {
459 let _ = finished.join.join();
460 }
461 let (stop_tx, stop_rx) = mpsc::channel();
462 let weak_state = Arc::downgrade(&self.state);
463 let join = std::thread::Builder::new()
464 .name("tirea-skills-registry-refresh".to_string())
465 .spawn(move || periodic_refresh_loop(weak_state, interval, stop_rx))
466 .map_err(|e| SkillRegistryManagerError::Join(e.to_string()))?;
467 *runtime = Some(PeriodicRefreshRuntime {
468 stop_tx: Some(stop_tx),
469 join,
470 });
471 Ok(())
472 }
473
474 pub fn stop_periodic_refresh(&self) -> bool {
475 let runtime = {
476 let mut guard = mutex_lock(&self.state.periodic_refresh);
477 guard.take()
478 };
479 let Some(mut runtime) = runtime else {
480 return false;
481 };
482 if let Some(stop_tx) = runtime.stop_tx.take() {
483 let _ = stop_tx.send(());
484 }
485 let _ = runtime.join.join();
486 true
487 }
488
489 pub fn periodic_refresh_running(&self) -> bool {
490 is_periodic_refresh_running(self.state.as_ref())
491 }
492
493 pub fn version(&self) -> u64 {
494 read_lock(&self.state.snapshot).version
495 }
496
497 pub fn warnings(&self) -> Vec<SkillWarning> {
498 read_lock(&self.state.snapshot).warnings.clone()
499 }
500}
501
502impl SkillRegistry for FsSkillRegistryManager {
503 fn len(&self) -> usize {
504 read_lock(&self.state.snapshot).skills.len()
505 }
506
507 fn get(&self, id: &str) -> Option<Arc<dyn Skill>> {
508 read_lock(&self.state.snapshot).skills.get(id).cloned()
509 }
510
511 fn ids(&self) -> Vec<String> {
512 let snapshot = read_lock(&self.state.snapshot);
513 let mut ids: Vec<String> = snapshot.skills.keys().cloned().collect();
514 ids.sort();
515 ids
516 }
517
518 fn snapshot(&self) -> HashMap<String, Arc<dyn Skill>> {
519 read_lock(&self.state.snapshot).skills.clone()
520 }
521
522 fn start_periodic_refresh(&self, interval: Duration) -> Result<(), SkillRegistryManagerError> {
523 FsSkillRegistryManager::start_periodic_refresh(self, interval)
524 }
525
526 fn stop_periodic_refresh(&self) -> bool {
527 FsSkillRegistryManager::stop_periodic_refresh(self)
528 }
529
530 fn periodic_refresh_running(&self) -> bool {
531 FsSkillRegistryManager::periodic_refresh_running(self)
532 }
533}
534
535impl FsSkill {
536 pub fn discover(root: impl Into<PathBuf>) -> Result<DiscoveryResult, SkillError> {
538 Self::discover_roots(vec![root.into()])
539 }
540
541 pub fn discover_roots(roots: Vec<PathBuf>) -> Result<DiscoveryResult, SkillError> {
545 let mut skills: Vec<FsSkill> = Vec::new();
546 let mut warnings: Vec<SkillWarning> = Vec::new();
547 let mut seen_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
548
549 for root in roots {
550 let (found, w) = discover_under_root(&root)?;
551 warnings.extend(w);
552
553 for skill in found {
554 if !seen_ids.insert(skill.meta.id.clone()) {
555 return Err(SkillError::DuplicateSkillId(skill.meta.id));
556 }
557 skills.push(skill);
558 }
559 }
560
561 skills.sort_by(|a, b| a.meta.id.cmp(&b.meta.id));
562 warnings.sort_by(|a, b| a.path.cmp(&b.path));
563
564 Ok(DiscoveryResult { skills, warnings })
565 }
566
567 pub fn into_arc_skills(skills: Vec<FsSkill>) -> Vec<Arc<dyn Skill>> {
569 skills
570 .into_iter()
571 .map(|s| Arc::new(s) as Arc<dyn Skill>)
572 .collect()
573 }
574}
575
576#[async_trait]
577impl Skill for FsSkill {
578 fn meta(&self) -> &SkillMeta {
579 &self.meta
580 }
581
582 async fn read_instructions(&self) -> Result<String, SkillError> {
583 fs::read_to_string(&self.skill_md_path).map_err(|e| {
584 SkillError::Io(format!(
585 "failed to read SKILL.md for skill '{}': {e}",
586 self.meta.id
587 ))
588 })
589 }
590
591 async fn load_resource(
592 &self,
593 kind: SkillResourceKind,
594 path: &str,
595 ) -> Result<SkillResource, SkillError> {
596 let root = self.root_dir.clone();
597 let skill_id = self.meta.id.clone();
598 let path = path.to_string();
599
600 let materialized: Result<SkillResource, SkillMaterializeError> =
601 tokio::task::spawn_blocking(move || match kind {
602 SkillResourceKind::Reference => {
603 load_reference_material(&skill_id, &root, &path).map(SkillResource::Reference)
604 }
605 SkillResourceKind::Asset => {
606 load_asset_material(&skill_id, &root, &path).map(SkillResource::Asset)
607 }
608 })
609 .await
610 .map_err(|e| SkillError::Io(e.to_string()))?;
611
612 materialized.map_err(SkillError::from)
613 }
614
615 async fn run_script(&self, script: &str, args: &[String]) -> Result<ScriptResult, SkillError> {
616 let result: Result<ScriptResult, SkillMaterializeError> =
617 run_script_material(&self.meta.id, &self.root_dir, script, args).await;
618 result.map_err(SkillError::from)
619 }
620}
621
622fn discover_under_root(root: &Path) -> Result<(Vec<FsSkill>, Vec<SkillWarning>), SkillError> {
623 let mut skills: Vec<FsSkill> = Vec::new();
624 let mut warnings: Vec<SkillWarning> = Vec::new();
625
626 let root = fs::canonicalize(root)
627 .map_err(|e| SkillError::Io(format!("failed to access skills root: {e}")))?;
628
629 let entries = fs::read_dir(&root)
630 .map_err(|e| SkillError::Io(format!("failed to read skills root: {e}")))?;
631
632 for entry in entries.flatten() {
633 let path = entry.path();
634 let ft = match entry.file_type() {
635 Ok(ft) => ft,
636 Err(_) => continue,
637 };
638 if !ft.is_dir() {
639 continue;
640 }
641 let dir_name_raw = match path.file_name().and_then(|s| s.to_str()) {
642 Some(s) => s.to_string(),
643 None => continue,
644 };
645 if dir_name_raw.starts_with('.') {
646 continue;
647 }
648
649 let dir_name = normalize_name(&dir_name_raw);
650 if let Err(reason) = validate_dir_name(&dir_name) {
651 warnings.push(SkillWarning {
652 path: path.clone(),
653 reason,
654 });
655 continue;
656 }
657
658 let skill_md = path.join("SKILL.md");
659 if !skill_md.is_file() {
660 warnings.push(SkillWarning {
661 path: path.clone(),
662 reason: "missing SKILL.md".to_string(),
663 });
664 continue;
665 }
666
667 match build_fs_skill(&dir_name, &skill_md) {
668 Ok(skill) => skills.push(skill),
669 Err(reason) => warnings.push(SkillWarning {
670 path: skill_md,
671 reason,
672 }),
673 }
674 }
675
676 Ok((skills, warnings))
677}
678
679fn build_fs_skill(dir_name: &str, skill_md: &Path) -> Result<FsSkill, String> {
680 let root_dir = skill_md
681 .parent()
682 .ok_or_else(|| "invalid skill path".to_string())?
683 .to_path_buf();
684
685 let SkillFrontmatter {
686 name,
687 description,
688 allowed_tools,
689 ..
690 } = read_frontmatter_from_skill_md_path(skill_md)?;
691
692 if name != dir_name {
694 return Err(format!(
695 "frontmatter name '{name}' does not match directory '{dir_name}'"
696 ));
697 }
698
699 let allowed_tools = allowed_tools
700 .as_deref()
701 .map(parse_allowed_tools)
702 .transpose()
703 .map_err(|e| e.to_string())?
704 .unwrap_or_default()
705 .into_iter()
706 .map(|t| t.raw)
707 .collect::<Vec<_>>();
708
709 Ok(FsSkill {
710 meta: SkillMeta {
711 id: name.clone(),
712 name,
713 description,
714 allowed_tools,
715 },
716 root_dir,
717 skill_md_path: skill_md.to_path_buf(),
718 })
719}
720
721fn read_frontmatter_from_skill_md_path(skill_md: &Path) -> Result<SkillFrontmatter, String> {
722 let file = fs::File::open(skill_md).map_err(|e| e.to_string())?;
723 let mut reader = BufReader::new(file);
724
725 let mut first = String::new();
726 let n = reader.read_line(&mut first).map_err(|e| e.to_string())?;
727 if n == 0 || trim_line_ending(&first) != "---" {
728 return Err("missing YAML frontmatter (expected leading '---' fence)".to_string());
729 }
730
731 let mut fm = String::new();
732 loop {
733 let mut line = String::new();
734 let read = reader.read_line(&mut line).map_err(|e| e.to_string())?;
735 if read == 0 {
736 return Err("unterminated YAML frontmatter (missing closing '---' fence)".to_string());
737 }
738 if trim_line_ending(&line) == "---" {
739 break;
740 }
741 fm.push_str(&line);
742 }
743
744 let synthetic = format!("---\n{}---\n", fm);
746 parse_skill_md(&synthetic)
747 .map(|doc| doc.frontmatter)
748 .map_err(|e| e.to_string())
749}
750
751fn trim_line_ending(line: &str) -> &str {
752 line.trim_end_matches(['\n', '\r'])
753}
754
755fn normalize_name(s: &str) -> String {
756 s.trim().nfkc().collect::<String>()
757}
758
759fn validate_dir_name(dir_name: &str) -> Result<(), String> {
760 if dir_name.is_empty() {
761 return Err("directory name must be non-empty".to_string());
762 }
763 if dir_name.chars().count() > 64 {
764 return Err("directory name must be <= 64 characters".to_string());
765 }
766 if dir_name != dir_name.to_lowercase() {
767 return Err("directory name must be lowercase".to_string());
768 }
769 if dir_name.starts_with('-') {
770 return Err("directory name must not start with '-'".to_string());
771 }
772 if dir_name.ends_with('-') {
773 return Err("directory name must not end with '-'".to_string());
774 }
775 if dir_name.contains("--") {
776 return Err("directory name must not contain consecutive '-'".to_string());
777 }
778 if !dir_name.chars().all(|c| c.is_alphanumeric() || c == '-') {
779 return Err(
780 "directory name contains invalid characters (only letters, digits, and '-' are allowed)"
781 .to_string(),
782 );
783 }
784 Ok(())
785}
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790 use async_trait::async_trait;
791 use std::collections::HashMap;
792 use std::sync::{Arc, RwLock};
793 use tempfile::TempDir;
794
795 #[test]
796 fn discover_skills_and_parses_frontmatter() {
797 let td = TempDir::new().unwrap();
798 let skills_root = td.path().join("skills");
799 fs::create_dir_all(skills_root.join("docx-processing")).unwrap();
800 fs::write(
801 skills_root.join("docx-processing").join("SKILL.md"),
802 r#"---
803name: docx-processing
804description: Docs
805allowed-tools: read_file
806---
807Body
808"#,
809 )
810 .unwrap();
811
812 let result = FsSkill::discover(&skills_root).unwrap();
813 assert_eq!(result.skills.len(), 1);
814 assert_eq!(result.skills[0].meta.id, "docx-processing");
815 assert_eq!(result.skills[0].meta.name, "docx-processing");
816 assert_eq!(
817 result.skills[0].meta.allowed_tools,
818 vec!["read_file".to_string()]
819 );
820 }
821
822 #[test]
823 fn discover_skips_invalid_skills_and_reports_warnings() {
824 let td = TempDir::new().unwrap();
825 let skills_root = td.path().join("skills");
826 fs::create_dir_all(skills_root.join("good-skill")).unwrap();
827 fs::create_dir_all(skills_root.join("BadSkill")).unwrap();
828 fs::create_dir_all(skills_root.join("missing-skill-md")).unwrap();
829 fs::write(
830 skills_root.join("good-skill").join("SKILL.md"),
831 "---\nname: good-skill\ndescription: ok\n---\nBody\n",
832 )
833 .unwrap();
834 fs::write(
835 skills_root.join("BadSkill").join("SKILL.md"),
836 "---\nname: badskill\ndescription: x\n---\nBody\n",
837 )
838 .unwrap();
839
840 let result = FsSkill::discover(&skills_root).unwrap();
841 assert_eq!(result.skills.len(), 1);
842 assert_eq!(result.skills[0].meta.id, "good-skill");
843 assert!(!result.warnings.is_empty());
844 assert!(result
845 .warnings
846 .iter()
847 .any(|w| w.reason.contains("directory name")));
848 assert!(result
849 .warnings
850 .iter()
851 .any(|w| w.reason.contains("missing SKILL.md")));
852 }
853
854 #[test]
855 fn discover_skips_name_mismatch() {
856 let td = TempDir::new().unwrap();
857 let skills_root = td.path().join("skills");
858 fs::create_dir_all(skills_root.join("good-skill")).unwrap();
859 fs::write(
860 skills_root.join("good-skill").join("SKILL.md"),
861 "---\nname: other-skill\ndescription: ok\n---\nBody\n",
862 )
863 .unwrap();
864
865 let result = FsSkill::discover(&skills_root).unwrap();
866 assert!(result.skills.is_empty());
867 assert!(result
868 .warnings
869 .iter()
870 .any(|w| w.reason.contains("does not match directory")));
871 }
872
873 #[test]
874 fn discover_does_not_recurse_into_nested_dirs() {
875 let td = TempDir::new().unwrap();
876 let skills_root = td.path().join("skills");
877 fs::create_dir_all(skills_root.join("good-skill").join("nested")).unwrap();
878 fs::write(
879 skills_root.join("good-skill").join("SKILL.md"),
880 "---\nname: good-skill\ndescription: ok\n---\nBody\n",
881 )
882 .unwrap();
883 fs::write(
884 skills_root
885 .join("good-skill")
886 .join("nested")
887 .join("SKILL.md"),
888 "---\nname: nested-skill\ndescription: ok\n---\nBody\n",
889 )
890 .unwrap();
891
892 let result = FsSkill::discover(&skills_root).unwrap();
893 assert_eq!(result.skills.len(), 1);
894 assert_eq!(result.skills[0].meta.id, "good-skill");
895 }
896
897 #[test]
898 fn discover_skips_hidden_dirs_and_root_files() {
899 let td = TempDir::new().unwrap();
900 let skills_root = td.path().join("skills");
901 fs::create_dir_all(skills_root.join(".hidden")).unwrap();
902 fs::write(
903 skills_root.join(".hidden").join("SKILL.md"),
904 "---\nname: hidden\ndescription: ok\n---\nBody\n",
905 )
906 .unwrap();
907 fs::write(
908 skills_root.join("not-a-skill"),
909 "---\nname: not-a-skill\ndescription: ok\n---\nBody\n",
910 )
911 .unwrap();
912
913 let result = FsSkill::discover(&skills_root).unwrap();
914 assert!(result.skills.is_empty());
915 }
916
917 #[test]
918 fn discover_allows_i18n_directory_names() {
919 let td = TempDir::new().unwrap();
920 let skills_root = td.path().join("skills");
921 fs::create_dir_all(skills_root.join("技能")).unwrap();
922 fs::write(
923 skills_root.join("技能").join("SKILL.md"),
924 "---\nname: 技能\ndescription: ok\n---\nBody\n",
925 )
926 .unwrap();
927
928 let result = FsSkill::discover(&skills_root).unwrap();
929 assert_eq!(result.skills.len(), 1);
930 assert_eq!(result.skills[0].meta.id, "技能");
931 }
932
933 #[tokio::test]
934 async fn read_instructions_reads_from_disk_lazily() {
935 let td = TempDir::new().unwrap();
936 let skills_root = td.path().join("skills");
937 fs::create_dir_all(skills_root.join("s1")).unwrap();
938 let skill_md = skills_root.join("s1").join("SKILL.md");
939 fs::write(&skill_md, "---\nname: s1\ndescription: ok\n---\nBody\n").unwrap();
940
941 let result = FsSkill::discover(&skills_root).unwrap();
942 let skill = &result.skills[0];
943 fs::remove_file(&skill_md).unwrap();
944
945 let err = skill.read_instructions().await.unwrap_err();
946 assert!(matches!(err, SkillError::Io(_)));
947 }
948
949 #[test]
950 fn discover_does_not_parse_markdown_body() {
951 let td = TempDir::new().unwrap();
952 let skills_root = td.path().join("skills");
953 fs::create_dir_all(skills_root.join("s1")).unwrap();
954 let skill_md = skills_root.join("s1").join("SKILL.md");
955 let mut bytes = b"---\nname: s1\ndescription: ok\n---\n".to_vec();
956 bytes.extend_from_slice(&[0xff, 0xfe, 0xfd]); fs::write(&skill_md, bytes).unwrap();
958
959 let result = FsSkill::discover(&skills_root).unwrap();
960 assert_eq!(result.skills.len(), 1);
961 assert_eq!(result.skills[0].meta.id, "s1");
962 }
963
964 #[test]
965 fn discover_errors_for_missing_root() {
966 let td = TempDir::new().unwrap();
967 let missing = td.path().join("missing");
968 let err = FsSkill::discover(&missing).unwrap_err();
969 assert!(matches!(err, SkillError::Io(_)));
970 }
971
972 #[test]
973 fn discover_rejects_duplicate_ids_across_roots() {
974 let td = TempDir::new().unwrap();
975 let root1 = td.path().join("skills1");
976 let root2 = td.path().join("skills2");
977 fs::create_dir_all(root1.join("s1")).unwrap();
978 fs::create_dir_all(root2.join("s1")).unwrap();
979 fs::write(
980 root1.join("s1").join("SKILL.md"),
981 "---\nname: s1\ndescription: ok\n---\nBody\n",
982 )
983 .unwrap();
984 fs::write(
985 root2.join("s1").join("SKILL.md"),
986 "---\nname: s1\ndescription: ok\n---\nBody\n",
987 )
988 .unwrap();
989
990 let err = FsSkill::discover_roots(vec![root1, root2]).unwrap_err();
991 assert!(matches!(err, SkillError::DuplicateSkillId(ref id) if id == "s1"));
992 }
993
994 #[test]
995 fn normalize_relative_name_nfkc() {
996 let s = "a\u{FF0D}b";
997 let norm = normalize_name(s);
998 assert!(!norm.is_empty());
999 }
1000
1001 #[test]
1002 fn validate_dir_name_rejects_parent_dir_like_segments() {
1003 assert!(validate_dir_name("a/b").is_err());
1004 assert!(validate_dir_name("..").is_err());
1005 }
1006
1007 #[tokio::test]
1008 async fn registry_manager_refresh_discovers_new_skill_without_rebuild() {
1009 let td = TempDir::new().unwrap();
1010 let root = td.path().join("skills");
1011 fs::create_dir_all(root.join("s1")).unwrap();
1012 fs::write(
1013 root.join("s1").join("SKILL.md"),
1014 "---\nname: s1\ndescription: ok\n---\nBody\n",
1015 )
1016 .unwrap();
1017
1018 let manager = FsSkillRegistryManager::discover_roots(vec![root.clone()]).unwrap();
1019 assert_eq!(manager.version(), 1);
1020 assert_eq!(manager.ids(), vec!["s1".to_string()]);
1021
1022 fs::create_dir_all(root.join("s2")).unwrap();
1023 fs::write(
1024 root.join("s2").join("SKILL.md"),
1025 "---\nname: s2\ndescription: ok\n---\nBody\n",
1026 )
1027 .unwrap();
1028
1029 let version = manager.refresh().await.unwrap();
1030 assert_eq!(version, 2);
1031 assert_eq!(manager.ids(), vec!["s1".to_string(), "s2".to_string()]);
1032 }
1033
1034 #[tokio::test]
1035 async fn registry_manager_failed_refresh_keeps_last_good_snapshot() {
1036 let td = TempDir::new().unwrap();
1037 let root = td.path().join("skills");
1038 fs::create_dir_all(root.join("s1")).unwrap();
1039 fs::write(
1040 root.join("s1").join("SKILL.md"),
1041 "---\nname: s1\ndescription: ok\n---\nBody\n",
1042 )
1043 .unwrap();
1044
1045 let manager = FsSkillRegistryManager::discover_roots(vec![root.clone()]).unwrap();
1046 let initial_ids = manager.ids();
1047 fs::remove_dir_all(&root).unwrap();
1048
1049 let err = manager.refresh().await.err().unwrap();
1050 assert!(matches!(err, SkillRegistryManagerError::Skill(_)));
1051 assert_eq!(manager.version(), 1);
1052 assert_eq!(manager.ids(), initial_ids);
1053 }
1054
1055 #[derive(Debug)]
1056 struct MockSkill {
1057 meta: SkillMeta,
1058 }
1059
1060 impl MockSkill {
1061 fn new(id: &str) -> Self {
1062 Self {
1063 meta: SkillMeta {
1064 id: id.to_string(),
1065 name: id.to_string(),
1066 description: format!("{id} desc"),
1067 allowed_tools: Vec::new(),
1068 },
1069 }
1070 }
1071 }
1072
1073 #[async_trait]
1074 impl Skill for MockSkill {
1075 fn meta(&self) -> &SkillMeta {
1076 &self.meta
1077 }
1078
1079 async fn read_instructions(&self) -> Result<String, SkillError> {
1080 Ok(format!(
1081 "---\nname: {}\ndescription: ok\n---\nBody\n",
1082 self.meta.id
1083 ))
1084 }
1085
1086 async fn load_resource(
1087 &self,
1088 _kind: SkillResourceKind,
1089 _path: &str,
1090 ) -> Result<SkillResource, SkillError> {
1091 Err(SkillError::Unsupported("not used".to_string()))
1092 }
1093
1094 async fn run_script(
1095 &self,
1096 _script: &str,
1097 _args: &[String],
1098 ) -> Result<ScriptResult, SkillError> {
1099 Err(SkillError::Unsupported("not used".to_string()))
1100 }
1101 }
1102
1103 #[derive(Default)]
1104 struct MutableSkillRegistry {
1105 skills: RwLock<HashMap<String, Arc<dyn Skill>>>,
1106 }
1107
1108 impl MutableSkillRegistry {
1109 fn replace_ids(&self, ids: &[&str]) {
1110 let mut map: HashMap<String, Arc<dyn Skill>> = HashMap::new();
1111 for id in ids {
1112 map.insert(
1113 (*id).to_string(),
1114 Arc::new(MockSkill::new(id)) as Arc<dyn Skill>,
1115 );
1116 }
1117 match self.skills.write() {
1118 Ok(mut guard) => *guard = map,
1119 Err(poisoned) => *poisoned.into_inner() = map,
1120 }
1121 }
1122 }
1123
1124 impl SkillRegistry for MutableSkillRegistry {
1125 fn len(&self) -> usize {
1126 self.snapshot().len()
1127 }
1128
1129 fn get(&self, id: &str) -> Option<Arc<dyn Skill>> {
1130 self.snapshot().get(id).cloned()
1131 }
1132
1133 fn ids(&self) -> Vec<String> {
1134 let mut ids: Vec<String> = self.snapshot().keys().cloned().collect();
1135 ids.sort();
1136 ids
1137 }
1138
1139 fn snapshot(&self) -> HashMap<String, Arc<dyn Skill>> {
1140 match self.skills.read() {
1141 Ok(guard) => guard.clone(),
1142 Err(poisoned) => poisoned.into_inner().clone(),
1143 }
1144 }
1145 }
1146
1147 #[test]
1148 fn composite_skill_registry_reads_live_updates_from_source_registries() {
1149 let dynamic = Arc::new(MutableSkillRegistry::default());
1150 dynamic.replace_ids(&["s1"]);
1151
1152 let mut static_registry = InMemorySkillRegistry::new();
1153 static_registry
1154 .register(Arc::new(MockSkill::new("s_static")) as Arc<dyn Skill>)
1155 .expect("register static skill");
1156
1157 let composite = CompositeSkillRegistry::try_new(vec![
1158 dynamic.clone() as Arc<dyn SkillRegistry>,
1159 Arc::new(static_registry) as Arc<dyn SkillRegistry>,
1160 ])
1161 .expect("compose registries");
1162
1163 assert!(composite.ids().contains(&"s1".to_string()));
1164 assert!(composite.ids().contains(&"s_static".to_string()));
1165
1166 dynamic.replace_ids(&["s1", "s2"]);
1167 let ids = composite.ids();
1168 assert!(ids.contains(&"s1".to_string()));
1169 assert!(ids.contains(&"s2".to_string()));
1170 assert!(ids.contains(&"s_static".to_string()));
1171 }
1172
1173 #[test]
1174 fn composite_skill_registry_keeps_last_good_snapshot_on_runtime_conflict() {
1175 let reg_a = Arc::new(MutableSkillRegistry::default());
1176 reg_a.replace_ids(&["s1"]);
1177 let reg_b = Arc::new(MutableSkillRegistry::default());
1178 reg_b.replace_ids(&["s2"]);
1179
1180 let composite = CompositeSkillRegistry::try_new(vec![
1181 reg_a.clone() as Arc<dyn SkillRegistry>,
1182 reg_b.clone() as Arc<dyn SkillRegistry>,
1183 ])
1184 .expect("compose registries");
1185
1186 let initial_ids = composite.ids();
1187 assert_eq!(initial_ids, vec!["s1".to_string(), "s2".to_string()]);
1188
1189 reg_b.replace_ids(&["s1"]);
1190 assert_eq!(composite.ids(), initial_ids);
1191 assert!(composite.get("s2").is_some());
1192 }
1193}