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 let target_name = match id.rsplit_once('/') {
287 Some((_, n)) => n,
288 None => id,
289 };
290 for dir in self.iter_skill_dirs() {
291 let skill = self.load_from_dir(&dir)?;
292 if skill.id() == id || skill.manifest.name == target_name {
293 return Ok(skill);
294 }
295 }
296 Err(format!(
297 "skill '{id}' not found under {}",
298 self.root.display()
299 ))
300 }
301
302 fn layer(&self) -> Layer {
303 self.layer
304 }
305
306 fn describe(&self) -> String {
307 match &self.namespace {
308 Some(ns) => format!("{} [{}] ns={ns}", self.root.display(), self.layer.label()),
309 None => format!("{} [{}]", self.root.display(), self.layer.label()),
310 }
311 }
312}
313
314pub type HostSkillLister = Arc<dyn Fn() -> Vec<SkillManifestRef> + Send + Sync>;
317
318pub type HostSkillFetcher = Arc<dyn Fn(&str) -> Result<Skill, String> + Send + Sync>;
321
322pub struct HostSkillSource {
326 loader: HostSkillLister,
327 fetcher: HostSkillFetcher,
328}
329
330impl HostSkillSource {
331 pub fn new<L, F>(loader: L, fetcher: F) -> Self
332 where
333 L: Fn() -> Vec<SkillManifestRef> + Send + Sync + 'static,
334 F: Fn(&str) -> Result<Skill, String> + Send + Sync + 'static,
335 {
336 Self {
337 loader: Arc::new(loader),
338 fetcher: Arc::new(fetcher),
339 }
340 }
341}
342
343impl std::fmt::Debug for HostSkillSource {
344 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
345 f.debug_struct("HostSkillSource").finish_non_exhaustive()
346 }
347}
348
349impl SkillSource for HostSkillSource {
350 fn list(&self) -> Vec<SkillManifestRef> {
351 (self.loader)()
352 }
353
354 fn fetch(&self, id: &str) -> Result<Skill, String> {
355 (self.fetcher)(id)
356 }
357
358 fn layer(&self) -> Layer {
359 Layer::Host
360 }
361
362 fn describe(&self) -> String {
363 "host-provided [host]".to_string()
364 }
365}
366
367pub fn skill_entry_to_vm(skill: &Skill) -> crate::value::VmValue {
371 use crate::value::VmValue;
372 use std::rc::Rc;
373
374 let mut entry: BTreeMap<String, VmValue> = BTreeMap::new();
375 entry.insert(
376 "name".to_string(),
377 VmValue::String(Rc::from(skill.manifest.name.as_str())),
378 );
379 entry.insert(
380 "short".to_string(),
381 VmValue::String(Rc::from(skill.manifest.short.as_str())),
382 );
383 entry.insert(
384 "description".to_string(),
385 VmValue::String(Rc::from(if skill.manifest.description.is_empty() {
386 skill.manifest.short.as_str()
387 } else {
388 skill.manifest.description.as_str()
389 })),
390 );
391 if let Some(when) = &skill.manifest.when_to_use {
392 entry.insert(
393 "when_to_use".to_string(),
394 VmValue::String(Rc::from(when.as_str())),
395 );
396 }
397 if skill.manifest.disable_model_invocation {
398 entry.insert("disable_model_invocation".to_string(), VmValue::Bool(true));
399 }
400 if !skill.manifest.allowed_tools.is_empty() {
401 entry.insert(
402 "allowed_tools".to_string(),
403 VmValue::List(Rc::new(
404 skill
405 .manifest
406 .allowed_tools
407 .iter()
408 .map(|t| VmValue::String(Rc::from(t.as_str())))
409 .collect(),
410 )),
411 );
412 }
413 if skill.manifest.user_invocable {
414 entry.insert("user_invocable".to_string(), VmValue::Bool(true));
415 }
416 if !skill.manifest.paths.is_empty() {
417 entry.insert(
418 "paths".to_string(),
419 VmValue::List(Rc::new(
420 skill
421 .manifest
422 .paths
423 .iter()
424 .map(|p| VmValue::String(Rc::from(p.as_str())))
425 .collect(),
426 )),
427 );
428 }
429 if let Some(context) = &skill.manifest.context {
430 entry.insert(
431 "context".to_string(),
432 VmValue::String(Rc::from(context.as_str())),
433 );
434 }
435 if let Some(agent) = &skill.manifest.agent {
436 entry.insert(
437 "agent".to_string(),
438 VmValue::String(Rc::from(agent.as_str())),
439 );
440 }
441 if !skill.manifest.hooks.is_empty() {
442 let mut hooks: BTreeMap<String, VmValue> = BTreeMap::new();
443 for (k, v) in &skill.manifest.hooks {
444 hooks.insert(k.clone(), VmValue::String(Rc::from(v.as_str())));
445 }
446 entry.insert("hooks".to_string(), VmValue::Dict(Rc::new(hooks)));
447 }
448 if let Some(model) = &skill.manifest.model {
449 entry.insert(
450 "model".to_string(),
451 VmValue::String(Rc::from(model.as_str())),
452 );
453 }
454 if let Some(effort) = &skill.manifest.effort {
455 entry.insert(
456 "effort".to_string(),
457 VmValue::String(Rc::from(effort.as_str())),
458 );
459 }
460 if skill.manifest.require_signature {
461 entry.insert("require_signature".to_string(), VmValue::Bool(true));
462 }
463 if !skill.manifest.trusted_signers.is_empty() {
464 entry.insert(
465 "trusted_signers".to_string(),
466 VmValue::List(Rc::new(
467 skill
468 .manifest
469 .trusted_signers
470 .iter()
471 .map(|fingerprint| VmValue::String(Rc::from(fingerprint.as_str())))
472 .collect(),
473 )),
474 );
475 }
476 if let Some(shell) = &skill.manifest.shell {
477 entry.insert(
478 "shell".to_string(),
479 VmValue::String(Rc::from(shell.as_str())),
480 );
481 }
482 if let Some(hint) = &skill.manifest.argument_hint {
483 entry.insert(
484 "argument_hint".to_string(),
485 VmValue::String(Rc::from(hint.as_str())),
486 );
487 }
488 entry.insert(
489 "body".to_string(),
490 VmValue::String(Rc::from(skill.body.as_str())),
491 );
492 if let Some(dir) = &skill.skill_dir {
493 entry.insert(
494 "skill_dir".to_string(),
495 VmValue::String(Rc::from(dir.display().to_string().as_str())),
496 );
497 }
498 entry.insert(
499 "source".to_string(),
500 VmValue::String(Rc::from(skill.layer.label())),
501 );
502 if let Some(ns) = &skill.namespace {
503 entry.insert(
504 "namespace".to_string(),
505 VmValue::String(Rc::from(ns.as_str())),
506 );
507 }
508 VmValue::Dict(Rc::new(entry))
509}
510
511pub fn skill_manifest_ref_to_vm(skill: &SkillManifestRef) -> crate::value::VmValue {
512 use crate::value::VmValue;
513 use std::rc::Rc;
514
515 let mut entry: BTreeMap<String, VmValue> = BTreeMap::new();
516 entry.insert(
517 "name".to_string(),
518 VmValue::String(Rc::from(skill.manifest.name.as_str())),
519 );
520 entry.insert(
521 "short".to_string(),
522 VmValue::String(Rc::from(skill.manifest.short.as_str())),
523 );
524 entry.insert(
525 "description".to_string(),
526 VmValue::String(Rc::from(if skill.manifest.description.is_empty() {
527 skill.manifest.short.as_str()
528 } else {
529 skill.manifest.description.as_str()
530 })),
531 );
532 if let Some(when) = &skill.manifest.when_to_use {
533 entry.insert(
534 "when_to_use".to_string(),
535 VmValue::String(Rc::from(when.as_str())),
536 );
537 }
538 if skill.manifest.disable_model_invocation {
539 entry.insert("disable_model_invocation".to_string(), VmValue::Bool(true));
540 }
541 if !skill.manifest.allowed_tools.is_empty() {
542 entry.insert(
543 "allowed_tools".to_string(),
544 VmValue::List(Rc::new(
545 skill
546 .manifest
547 .allowed_tools
548 .iter()
549 .map(|tool| VmValue::String(Rc::from(tool.as_str())))
550 .collect(),
551 )),
552 );
553 }
554 if skill.manifest.user_invocable {
555 entry.insert("user_invocable".to_string(), VmValue::Bool(true));
556 }
557 if !skill.manifest.paths.is_empty() {
558 entry.insert(
559 "paths".to_string(),
560 VmValue::List(Rc::new(
561 skill
562 .manifest
563 .paths
564 .iter()
565 .map(|path| VmValue::String(Rc::from(path.as_str())))
566 .collect(),
567 )),
568 );
569 }
570 if let Some(context) = &skill.manifest.context {
571 entry.insert(
572 "context".to_string(),
573 VmValue::String(Rc::from(context.as_str())),
574 );
575 }
576 if let Some(agent) = &skill.manifest.agent {
577 entry.insert(
578 "agent".to_string(),
579 VmValue::String(Rc::from(agent.as_str())),
580 );
581 }
582 if !skill.manifest.hooks.is_empty() {
583 let mut hooks: BTreeMap<String, VmValue> = BTreeMap::new();
584 for (key, value) in &skill.manifest.hooks {
585 hooks.insert(key.clone(), VmValue::String(Rc::from(value.as_str())));
586 }
587 entry.insert("hooks".to_string(), VmValue::Dict(Rc::new(hooks)));
588 }
589 if let Some(model) = &skill.manifest.model {
590 entry.insert(
591 "model".to_string(),
592 VmValue::String(Rc::from(model.as_str())),
593 );
594 }
595 if let Some(effort) = &skill.manifest.effort {
596 entry.insert(
597 "effort".to_string(),
598 VmValue::String(Rc::from(effort.as_str())),
599 );
600 }
601 if let Some(shell) = &skill.manifest.shell {
602 entry.insert(
603 "shell".to_string(),
604 VmValue::String(Rc::from(shell.as_str())),
605 );
606 }
607 if let Some(hint) = &skill.manifest.argument_hint {
608 entry.insert(
609 "argument_hint".to_string(),
610 VmValue::String(Rc::from(hint.as_str())),
611 );
612 }
613 entry.insert(
614 "source".to_string(),
615 VmValue::String(Rc::from(skill.layer.label())),
616 );
617 if let Some(ns) = &skill.namespace {
618 entry.insert(
619 "namespace".to_string(),
620 VmValue::String(Rc::from(ns.as_str())),
621 );
622 }
623 VmValue::Dict(Rc::new(entry))
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629 use std::fs;
630
631 fn write(tmp: &Path, rel: &str, body: &str) {
632 let p = tmp.join(rel);
633 fs::create_dir_all(p.parent().unwrap()).unwrap();
634 fs::write(p, body).unwrap();
635 }
636
637 #[test]
638 fn fs_source_walks_one_level_deep() {
639 let tmp = tempfile::tempdir().unwrap();
640 write(
641 tmp.path(),
642 "deploy/SKILL.md",
643 "---\nname: deploy\nshort: deploy the service\ndescription: ship it\n---\nrun deploy",
644 );
645 write(
646 tmp.path(),
647 "review/SKILL.md",
648 "---\nname: review\nshort: review a pull request\n---\nbody",
649 );
650 write(tmp.path(), "not-a-skill.txt", "no");
651
652 let src = FsSkillSource::new(tmp.path(), Layer::Project);
653 let listed = src.list();
654 assert_eq!(listed.len(), 2);
655 let names: Vec<_> = listed.iter().map(|s| s.manifest.name.clone()).collect();
656 assert!(names.contains(&"deploy".to_string()));
657 assert!(names.contains(&"review".to_string()));
658
659 let skill = src.fetch("deploy").unwrap();
660 assert_eq!(skill.manifest.short, "deploy the service");
661 assert_eq!(skill.manifest.description, "ship it");
662 assert_eq!(skill.body, "run deploy");
663 }
664
665 #[test]
666 fn fs_source_accepts_root_as_single_skill() {
667 let tmp = tempfile::tempdir().unwrap();
668 write(
669 tmp.path(),
670 "SKILL.md",
671 "---\nname: solo\nshort: single skill bundle\n---\n(body)",
672 );
673 let src = FsSkillSource::new(tmp.path(), Layer::Cli);
674 let listed = src.list();
675 assert_eq!(listed.len(), 1);
676 assert_eq!(listed[0].manifest.name, "solo");
677 }
678
679 #[test]
680 fn fs_source_defaults_name_to_directory() {
681 let tmp = tempfile::tempdir().unwrap();
682 write(
683 tmp.path(),
684 "nameless/SKILL.md",
685 "---\nshort: fallback to the directory name\n---\nbody only",
686 );
687 let src = FsSkillSource::new(tmp.path(), Layer::User);
688 let skill = src.fetch("nameless").unwrap();
689 assert_eq!(skill.manifest.name, "nameless");
690 }
691
692 #[test]
693 fn fs_source_namespace_prefixes_id() {
694 let tmp = tempfile::tempdir().unwrap();
695 write(
696 tmp.path(),
697 "deploy/SKILL.md",
698 "---\nname: deploy\nshort: deploy the service\n---\nbody",
699 );
700 let src = FsSkillSource::new(tmp.path(), Layer::Manifest).with_namespace("acme/ops");
701 let listed = src.list();
702 assert_eq!(listed[0].id, "acme/ops/deploy");
703 let skill = src.fetch("acme/ops/deploy").unwrap();
704 assert_eq!(skill.id(), "acme/ops/deploy");
705 }
706
707 #[test]
708 fn fs_source_missing_root_is_empty_not_error() {
709 let src = FsSkillSource::new("/does/not/exist/anywhere", Layer::System);
710 assert!(src.list().is_empty());
711 assert!(src.fetch("nope").is_err());
712 }
713
714 #[test]
715 fn fs_source_requires_short_card() {
716 let tmp = tempfile::tempdir().unwrap();
717 write(
718 tmp.path(),
719 "broken/SKILL.md",
720 "---\nname: broken\n---\nbody",
721 );
722 let src = FsSkillSource::new(tmp.path(), Layer::Project);
723 assert!(src.list().is_empty());
724 let err = src.fetch("broken").unwrap_err();
725 assert!(err.contains("`short`"), "{err}");
726 }
727
728 #[test]
729 fn host_source_wraps_closures() {
730 let host = HostSkillSource::new(
731 || {
732 vec![SkillManifestRef {
733 id: "h1".into(),
734 manifest: SkillManifest {
735 name: "h1".into(),
736 short: "host-provided skill".into(),
737 ..Default::default()
738 },
739 layer: Layer::Host,
740 namespace: None,
741 origin: "host".into(),
742 unknown_fields: Vec::new(),
743 }]
744 },
745 |id| {
746 Ok(Skill {
747 manifest: SkillManifest {
748 name: id.to_string(),
749 short: "host-provided skill".into(),
750 ..Default::default()
751 },
752 body: "host body".into(),
753 skill_dir: None,
754 layer: Layer::Host,
755 namespace: None,
756 unknown_fields: Vec::new(),
757 })
758 },
759 );
760 assert_eq!(host.list().len(), 1);
761 let s = host.fetch("h1").unwrap();
762 assert_eq!(s.body, "host body");
763 assert_eq!(s.layer, Layer::Host);
764 }
765
766 #[test]
767 fn layer_label_roundtrips() {
768 for layer in Layer::all() {
769 assert_eq!(Layer::from_label(layer.label()), Some(*layer));
770 }
771 }
772}