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