1use std::collections::BTreeMap;
15use std::fs;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18
19use super::frontmatter::{parse_frontmatter, split_frontmatter, SkillManifest};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
25pub enum Layer {
26 Cli,
27 Env,
28 Project,
29 Manifest,
30 User,
31 Package,
32 System,
33 Host,
34}
35
36impl Layer {
37 pub fn label(self) -> &'static str {
38 match self {
39 Layer::Cli => "cli",
40 Layer::Env => "env",
41 Layer::Project => "project",
42 Layer::Manifest => "manifest",
43 Layer::User => "user",
44 Layer::Package => "package",
45 Layer::System => "system",
46 Layer::Host => "host",
47 }
48 }
49
50 pub fn from_label(label: &str) -> Option<Layer> {
51 match label {
52 "cli" => Some(Layer::Cli),
53 "env" => Some(Layer::Env),
54 "project" => Some(Layer::Project),
55 "manifest" => Some(Layer::Manifest),
56 "user" => Some(Layer::User),
57 "package" => Some(Layer::Package),
58 "system" => Some(Layer::System),
59 "host" => Some(Layer::Host),
60 _ => None,
61 }
62 }
63
64 pub const fn all() -> &'static [Layer] {
65 &[
66 Layer::Cli,
67 Layer::Env,
68 Layer::Project,
69 Layer::Manifest,
70 Layer::User,
71 Layer::Package,
72 Layer::System,
73 Layer::Host,
74 ]
75 }
76}
77
78#[derive(Debug, Clone)]
81pub struct Skill {
82 pub manifest: SkillManifest,
83 pub body: String,
87 pub skill_dir: Option<PathBuf>,
90 pub layer: Layer,
92 pub namespace: Option<String>,
94 pub unknown_fields: Vec<String>,
97}
98
99impl Skill {
100 pub fn id(&self) -> String {
104 match &self.namespace {
105 Some(ns) if !ns.is_empty() => format!("{ns}/{}", self.manifest.name),
106 _ => self.manifest.name.clone(),
107 }
108 }
109}
110
111pub trait SkillSource: Send + Sync {
114 fn list(&self) -> Vec<SkillManifestRef>;
117
118 fn fetch(&self, id: &str) -> Result<Skill, String>;
121
122 fn layer(&self) -> Layer;
124
125 fn describe(&self) -> String;
127}
128
129#[derive(Debug, Clone)]
132pub struct SkillManifestRef {
133 pub id: String,
134 pub manifest: SkillManifest,
135 pub layer: Layer,
136 pub namespace: Option<String>,
137 pub origin: String,
138 pub unknown_fields: Vec<String>,
139}
140
141#[derive(Debug, Clone)]
148pub struct FsSkillSource {
149 pub root: PathBuf,
150 pub layer: Layer,
151 pub namespace: Option<String>,
156}
157
158impl FsSkillSource {
159 pub fn new(root: impl Into<PathBuf>, layer: Layer) -> Self {
160 Self {
161 root: root.into(),
162 layer,
163 namespace: None,
164 }
165 }
166
167 pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
168 let ns = namespace.into();
169 self.namespace = if ns.is_empty() { None } else { Some(ns) };
170 self
171 }
172
173 fn iter_skill_dirs(&self) -> Vec<PathBuf> {
174 let mut results = Vec::new();
175 if !self.root.is_dir() {
176 return results;
177 }
178 if self.root.join("SKILL.md").is_file() {
181 results.push(self.root.clone());
182 return results;
183 }
184 let Ok(entries) = fs::read_dir(&self.root) else {
186 return results;
187 };
188 for entry in entries.flatten() {
189 let path = entry.path();
190 if !path.is_dir() {
191 continue;
192 }
193 if path.join("SKILL.md").is_file() {
194 results.push(path);
195 }
196 }
197 results.sort();
198 results
199 }
200
201 fn finalize_manifest(
202 &self,
203 dir: &Path,
204 skill_file: &Path,
205 manifest: &mut SkillManifest,
206 ) -> Result<(), String> {
207 if manifest.name.is_empty() {
208 if let Some(name) = dir.file_name().and_then(|n| n.to_str()) {
209 manifest.name = name.to_string();
210 }
211 }
212 if manifest.name.is_empty() {
213 return Err(format!(
214 "{}: SKILL.md has no `name` field and directory has no basename",
215 skill_file.display()
216 ));
217 }
218 if manifest.short.trim().is_empty() {
219 return Err(format!(
220 "{}: SKILL.md requires a non-empty `short` field",
221 skill_file.display()
222 ));
223 }
224 Ok(())
225 }
226
227 fn load_manifest_from_dir(&self, dir: &Path) -> Result<SkillManifestRef, String> {
228 let skill_file = dir.join("SKILL.md");
229 let source = fs::read_to_string(&skill_file)
230 .map_err(|e| format!("failed to read {}: {e}", skill_file.display()))?;
231 let (fm, _) = split_frontmatter(&source);
232 let parsed = parse_frontmatter(fm).map_err(|e| format!("{}: {e}", skill_file.display()))?;
233 let mut manifest = parsed.manifest;
234 self.finalize_manifest(dir, &skill_file, &mut manifest)?;
235 let id = match &self.namespace {
236 Some(ns) if !ns.is_empty() => format!("{ns}/{}", manifest.name),
237 _ => manifest.name.clone(),
238 };
239 Ok(SkillManifestRef {
240 id,
241 manifest,
242 layer: self.layer,
243 namespace: self.namespace.clone(),
244 origin: dir.display().to_string(),
245 unknown_fields: parsed.unknown_fields,
246 })
247 }
248
249 fn load_from_dir(&self, dir: &Path) -> Result<Skill, String> {
250 let skill_file = dir.join("SKILL.md");
251 let source = fs::read_to_string(&skill_file)
252 .map_err(|e| format!("failed to read {}: {e}", skill_file.display()))?;
253 let (fm, body) = split_frontmatter(&source);
254 let parsed = parse_frontmatter(fm).map_err(|e| format!("{}: {e}", skill_file.display()))?;
255 let mut manifest = parsed.manifest;
256 self.finalize_manifest(dir, &skill_file, &mut manifest)?;
257 let skill = Skill {
258 body: body.to_string(),
259 skill_dir: Some(dir.to_path_buf()),
260 layer: self.layer,
261 namespace: self.namespace.clone(),
262 unknown_fields: parsed.unknown_fields,
263 manifest,
264 };
265 Ok(skill)
266 }
267}
268
269impl SkillSource for FsSkillSource {
270 fn list(&self) -> Vec<SkillManifestRef> {
271 let mut out = Vec::new();
272 for dir in self.iter_skill_dirs() {
273 match self.load_manifest_from_dir(&dir) {
274 Ok(skill) => {
275 out.push(skill);
276 }
277 Err(err) => {
278 eprintln!("warning: skills: {err}");
279 }
280 }
281 }
282 out
283 }
284
285 fn fetch(&self, id: &str) -> Result<Skill, String> {
286 for dir in self.iter_skill_dirs() {
287 let skill = self.load_from_dir(&dir)?;
288 if skill.id() == id || (self.namespace.is_none() && skill.manifest.name == id) {
289 return Ok(skill);
290 }
291 }
292 Err(format!(
293 "skill '{id}' not found under {}",
294 self.root.display()
295 ))
296 }
297
298 fn layer(&self) -> Layer {
299 self.layer
300 }
301
302 fn describe(&self) -> String {
303 match &self.namespace {
304 Some(ns) => format!("{} [{}] ns={ns}", self.root.display(), self.layer.label()),
305 None => format!("{} [{}]", self.root.display(), self.layer.label()),
306 }
307 }
308}
309
310pub type HostSkillLister = Arc<dyn Fn() -> Vec<SkillManifestRef> + Send + Sync>;
313
314pub type HostSkillFetcher = Arc<dyn Fn(&str) -> Result<Skill, String> + Send + Sync>;
317
318pub struct HostSkillSource {
322 loader: HostSkillLister,
323 fetcher: HostSkillFetcher,
324}
325
326impl HostSkillSource {
327 pub fn new<L, F>(loader: L, fetcher: F) -> Self
328 where
329 L: Fn() -> Vec<SkillManifestRef> + Send + Sync + 'static,
330 F: Fn(&str) -> Result<Skill, String> + Send + Sync + 'static,
331 {
332 Self {
333 loader: Arc::new(loader),
334 fetcher: Arc::new(fetcher),
335 }
336 }
337}
338
339impl std::fmt::Debug for HostSkillSource {
340 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
341 f.debug_struct("HostSkillSource").finish_non_exhaustive()
342 }
343}
344
345impl SkillSource for HostSkillSource {
346 fn list(&self) -> Vec<SkillManifestRef> {
347 (self.loader)()
348 }
349
350 fn fetch(&self, id: &str) -> Result<Skill, String> {
351 (self.fetcher)(id)
352 }
353
354 fn layer(&self) -> Layer {
355 Layer::Host
356 }
357
358 fn describe(&self) -> String {
359 "host-provided [host]".to_string()
360 }
361}
362
363pub fn skill_entry_to_vm(skill: &Skill) -> crate::value::VmValue {
367 use crate::value::VmValue;
368 use std::rc::Rc;
369
370 let mut entry: BTreeMap<String, VmValue> = BTreeMap::new();
371 entry.insert(
372 "name".to_string(),
373 VmValue::String(Rc::from(skill.manifest.name.as_str())),
374 );
375 entry.insert(
376 "short".to_string(),
377 VmValue::String(Rc::from(skill.manifest.short.as_str())),
378 );
379 entry.insert(
380 "description".to_string(),
381 VmValue::String(Rc::from(if skill.manifest.description.is_empty() {
382 skill.manifest.short.as_str()
383 } else {
384 skill.manifest.description.as_str()
385 })),
386 );
387 if let Some(when) = &skill.manifest.when_to_use {
388 entry.insert(
389 "when_to_use".to_string(),
390 VmValue::String(Rc::from(when.as_str())),
391 );
392 }
393 if skill.manifest.disable_model_invocation {
394 entry.insert("disable_model_invocation".to_string(), VmValue::Bool(true));
395 }
396 if !skill.manifest.allowed_tools.is_empty() {
397 entry.insert(
398 "allowed_tools".to_string(),
399 VmValue::List(Rc::new(
400 skill
401 .manifest
402 .allowed_tools
403 .iter()
404 .map(|t| VmValue::String(Rc::from(t.as_str())))
405 .collect(),
406 )),
407 );
408 }
409 if skill.manifest.user_invocable {
410 entry.insert("user_invocable".to_string(), VmValue::Bool(true));
411 }
412 if !skill.manifest.paths.is_empty() {
413 entry.insert(
414 "paths".to_string(),
415 VmValue::List(Rc::new(
416 skill
417 .manifest
418 .paths
419 .iter()
420 .map(|p| VmValue::String(Rc::from(p.as_str())))
421 .collect(),
422 )),
423 );
424 }
425 if let Some(context) = &skill.manifest.context {
426 entry.insert(
427 "context".to_string(),
428 VmValue::String(Rc::from(context.as_str())),
429 );
430 }
431 if let Some(agent) = &skill.manifest.agent {
432 entry.insert(
433 "agent".to_string(),
434 VmValue::String(Rc::from(agent.as_str())),
435 );
436 }
437 if !skill.manifest.hooks.is_empty() {
438 let mut hooks: BTreeMap<String, VmValue> = BTreeMap::new();
439 for (k, v) in &skill.manifest.hooks {
440 hooks.insert(k.clone(), VmValue::String(Rc::from(v.as_str())));
441 }
442 entry.insert("hooks".to_string(), VmValue::Dict(Rc::new(hooks)));
443 }
444 if let Some(model) = &skill.manifest.model {
445 entry.insert(
446 "model".to_string(),
447 VmValue::String(Rc::from(model.as_str())),
448 );
449 }
450 if let Some(effort) = &skill.manifest.effort {
451 entry.insert(
452 "effort".to_string(),
453 VmValue::String(Rc::from(effort.as_str())),
454 );
455 }
456 if skill.manifest.require_signature {
457 entry.insert("require_signature".to_string(), VmValue::Bool(true));
458 }
459 if !skill.manifest.trusted_signers.is_empty() {
460 entry.insert(
461 "trusted_signers".to_string(),
462 VmValue::List(Rc::new(
463 skill
464 .manifest
465 .trusted_signers
466 .iter()
467 .map(|fingerprint| VmValue::String(Rc::from(fingerprint.as_str())))
468 .collect(),
469 )),
470 );
471 }
472 if let Some(shell) = &skill.manifest.shell {
473 entry.insert(
474 "shell".to_string(),
475 VmValue::String(Rc::from(shell.as_str())),
476 );
477 }
478 if let Some(hint) = &skill.manifest.argument_hint {
479 entry.insert(
480 "argument_hint".to_string(),
481 VmValue::String(Rc::from(hint.as_str())),
482 );
483 }
484 entry.insert(
485 "body".to_string(),
486 VmValue::String(Rc::from(skill.body.as_str())),
487 );
488 if let Some(dir) = &skill.skill_dir {
489 entry.insert(
490 "skill_dir".to_string(),
491 VmValue::String(Rc::from(dir.display().to_string().as_str())),
492 );
493 }
494 entry.insert(
495 "source".to_string(),
496 VmValue::String(Rc::from(skill.layer.label())),
497 );
498 if let Some(ns) = &skill.namespace {
499 entry.insert(
500 "namespace".to_string(),
501 VmValue::String(Rc::from(ns.as_str())),
502 );
503 }
504 VmValue::Dict(Rc::new(entry))
505}
506
507pub fn skill_manifest_ref_to_vm(skill: &SkillManifestRef) -> crate::value::VmValue {
508 use crate::value::VmValue;
509 use std::rc::Rc;
510
511 let mut entry: BTreeMap<String, VmValue> = BTreeMap::new();
512 entry.insert(
513 "name".to_string(),
514 VmValue::String(Rc::from(skill.manifest.name.as_str())),
515 );
516 entry.insert(
517 "short".to_string(),
518 VmValue::String(Rc::from(skill.manifest.short.as_str())),
519 );
520 entry.insert(
521 "description".to_string(),
522 VmValue::String(Rc::from(if skill.manifest.description.is_empty() {
523 skill.manifest.short.as_str()
524 } else {
525 skill.manifest.description.as_str()
526 })),
527 );
528 if let Some(when) = &skill.manifest.when_to_use {
529 entry.insert(
530 "when_to_use".to_string(),
531 VmValue::String(Rc::from(when.as_str())),
532 );
533 }
534 if skill.manifest.disable_model_invocation {
535 entry.insert("disable_model_invocation".to_string(), VmValue::Bool(true));
536 }
537 if !skill.manifest.allowed_tools.is_empty() {
538 entry.insert(
539 "allowed_tools".to_string(),
540 VmValue::List(Rc::new(
541 skill
542 .manifest
543 .allowed_tools
544 .iter()
545 .map(|tool| VmValue::String(Rc::from(tool.as_str())))
546 .collect(),
547 )),
548 );
549 }
550 if skill.manifest.user_invocable {
551 entry.insert("user_invocable".to_string(), VmValue::Bool(true));
552 }
553 if !skill.manifest.paths.is_empty() {
554 entry.insert(
555 "paths".to_string(),
556 VmValue::List(Rc::new(
557 skill
558 .manifest
559 .paths
560 .iter()
561 .map(|path| VmValue::String(Rc::from(path.as_str())))
562 .collect(),
563 )),
564 );
565 }
566 if let Some(context) = &skill.manifest.context {
567 entry.insert(
568 "context".to_string(),
569 VmValue::String(Rc::from(context.as_str())),
570 );
571 }
572 if let Some(agent) = &skill.manifest.agent {
573 entry.insert(
574 "agent".to_string(),
575 VmValue::String(Rc::from(agent.as_str())),
576 );
577 }
578 if !skill.manifest.hooks.is_empty() {
579 let mut hooks: BTreeMap<String, VmValue> = BTreeMap::new();
580 for (key, value) in &skill.manifest.hooks {
581 hooks.insert(key.clone(), VmValue::String(Rc::from(value.as_str())));
582 }
583 entry.insert("hooks".to_string(), VmValue::Dict(Rc::new(hooks)));
584 }
585 if let Some(model) = &skill.manifest.model {
586 entry.insert(
587 "model".to_string(),
588 VmValue::String(Rc::from(model.as_str())),
589 );
590 }
591 if let Some(effort) = &skill.manifest.effort {
592 entry.insert(
593 "effort".to_string(),
594 VmValue::String(Rc::from(effort.as_str())),
595 );
596 }
597 if let Some(shell) = &skill.manifest.shell {
598 entry.insert(
599 "shell".to_string(),
600 VmValue::String(Rc::from(shell.as_str())),
601 );
602 }
603 if let Some(hint) = &skill.manifest.argument_hint {
604 entry.insert(
605 "argument_hint".to_string(),
606 VmValue::String(Rc::from(hint.as_str())),
607 );
608 }
609 entry.insert(
610 "source".to_string(),
611 VmValue::String(Rc::from(skill.layer.label())),
612 );
613 if let Some(ns) = &skill.namespace {
614 entry.insert(
615 "namespace".to_string(),
616 VmValue::String(Rc::from(ns.as_str())),
617 );
618 }
619 VmValue::Dict(Rc::new(entry))
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625 use std::fs;
626
627 fn write(tmp: &Path, rel: &str, body: &str) {
628 let p = tmp.join(rel);
629 fs::create_dir_all(p.parent().unwrap()).unwrap();
630 fs::write(p, body).unwrap();
631 }
632
633 #[test]
634 fn fs_source_walks_one_level_deep() {
635 let tmp = tempfile::tempdir().unwrap();
636 write(
637 tmp.path(),
638 "deploy/SKILL.md",
639 "---\nname: deploy\nshort: deploy the service\ndescription: ship it\n---\nrun deploy",
640 );
641 write(
642 tmp.path(),
643 "review/SKILL.md",
644 "---\nname: review\nshort: review a pull request\n---\nbody",
645 );
646 write(tmp.path(), "not-a-skill.txt", "no");
647
648 let src = FsSkillSource::new(tmp.path(), Layer::Project);
649 let listed = src.list();
650 assert_eq!(listed.len(), 2);
651 let names: Vec<_> = listed.iter().map(|s| s.manifest.name.clone()).collect();
652 assert!(names.contains(&"deploy".to_string()));
653 assert!(names.contains(&"review".to_string()));
654
655 let skill = src.fetch("deploy").unwrap();
656 assert_eq!(skill.manifest.short, "deploy the service");
657 assert_eq!(skill.manifest.description, "ship it");
658 assert_eq!(skill.body, "run deploy");
659 }
660
661 #[test]
662 fn fs_source_accepts_root_as_single_skill() {
663 let tmp = tempfile::tempdir().unwrap();
664 write(
665 tmp.path(),
666 "SKILL.md",
667 "---\nname: solo\nshort: single skill bundle\n---\n(body)",
668 );
669 let src = FsSkillSource::new(tmp.path(), Layer::Cli);
670 let listed = src.list();
671 assert_eq!(listed.len(), 1);
672 assert_eq!(listed[0].manifest.name, "solo");
673 }
674
675 #[test]
676 fn fs_source_defaults_name_to_directory() {
677 let tmp = tempfile::tempdir().unwrap();
678 write(
679 tmp.path(),
680 "nameless/SKILL.md",
681 "---\nshort: fallback to the directory name\n---\nbody only",
682 );
683 let src = FsSkillSource::new(tmp.path(), Layer::User);
684 let skill = src.fetch("nameless").unwrap();
685 assert_eq!(skill.manifest.name, "nameless");
686 }
687
688 #[test]
689 fn fs_source_namespace_prefixes_id() {
690 let tmp = tempfile::tempdir().unwrap();
691 write(
692 tmp.path(),
693 "deploy/SKILL.md",
694 "---\nname: deploy\nshort: deploy the service\n---\nbody",
695 );
696 let src = FsSkillSource::new(tmp.path(), Layer::Manifest).with_namespace("acme/ops");
697 let listed = src.list();
698 assert_eq!(listed[0].id, "acme/ops/deploy");
699 let skill = src.fetch("acme/ops/deploy").unwrap();
700 assert_eq!(skill.id(), "acme/ops/deploy");
701 }
702
703 #[test]
704 fn fs_source_namespaced_fetch_requires_qualified_id() {
705 let tmp = tempfile::tempdir().unwrap();
706 write(
707 tmp.path(),
708 "deploy/SKILL.md",
709 "---\nname: deploy\nshort: deploy the service\n---\nbody",
710 );
711 let src = FsSkillSource::new(tmp.path(), Layer::Manifest).with_namespace("acme/ops");
712
713 assert!(src.fetch("deploy").is_err());
714 assert!(src.fetch("other/deploy").is_err());
715 assert_eq!(
716 src.fetch("acme/ops/deploy").unwrap().id(),
717 "acme/ops/deploy"
718 );
719 }
720
721 #[test]
722 fn fs_source_missing_root_is_empty_not_error() {
723 let src = FsSkillSource::new("/does/not/exist/anywhere", Layer::System);
724 assert!(src.list().is_empty());
725 assert!(src.fetch("nope").is_err());
726 }
727
728 #[test]
729 fn fs_source_requires_short_card() {
730 let tmp = tempfile::tempdir().unwrap();
731 write(
732 tmp.path(),
733 "broken/SKILL.md",
734 "---\nname: broken\n---\nbody",
735 );
736 let src = FsSkillSource::new(tmp.path(), Layer::Project);
737 assert!(src.list().is_empty());
738 let err = src.fetch("broken").unwrap_err();
739 assert!(err.contains("`short`"), "{err}");
740 }
741
742 #[test]
743 fn host_source_wraps_closures() {
744 let host = HostSkillSource::new(
745 || {
746 vec![SkillManifestRef {
747 id: "h1".into(),
748 manifest: SkillManifest {
749 name: "h1".into(),
750 short: "host-provided skill".into(),
751 ..Default::default()
752 },
753 layer: Layer::Host,
754 namespace: None,
755 origin: "host".into(),
756 unknown_fields: Vec::new(),
757 }]
758 },
759 |id| {
760 Ok(Skill {
761 manifest: SkillManifest {
762 name: id.to_string(),
763 short: "host-provided skill".into(),
764 ..Default::default()
765 },
766 body: "host body".into(),
767 skill_dir: None,
768 layer: Layer::Host,
769 namespace: None,
770 unknown_fields: Vec::new(),
771 })
772 },
773 );
774 assert_eq!(host.list().len(), 1);
775 let s = host.fetch("h1").unwrap();
776 assert_eq!(s.body, "host body");
777 assert_eq!(s.layer, Layer::Host);
778 }
779
780 #[test]
781 fn layer_label_roundtrips() {
782 for layer in Layer::all() {
783 assert_eq!(Layer::from_label(layer.label()), Some(*layer));
784 }
785 }
786}