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