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}
139
140#[derive(Debug, Clone)]
147pub struct FsSkillSource {
148 pub root: PathBuf,
149 pub layer: Layer,
150 pub namespace: Option<String>,
155}
156
157impl FsSkillSource {
158 pub fn new(root: impl Into<PathBuf>, layer: Layer) -> Self {
159 Self {
160 root: root.into(),
161 layer,
162 namespace: None,
163 }
164 }
165
166 pub fn with_namespace(mut self, namespace: impl Into<String>) -> Self {
167 let ns = namespace.into();
168 self.namespace = if ns.is_empty() { None } else { Some(ns) };
169 self
170 }
171
172 fn iter_skill_dirs(&self) -> Vec<PathBuf> {
173 let mut results = Vec::new();
174 if !self.root.is_dir() {
175 return results;
176 }
177 if self.root.join("SKILL.md").is_file() {
180 results.push(self.root.clone());
181 return results;
182 }
183 let Ok(entries) = fs::read_dir(&self.root) else {
185 return results;
186 };
187 for entry in entries.flatten() {
188 let path = entry.path();
189 if !path.is_dir() {
190 continue;
191 }
192 if path.join("SKILL.md").is_file() {
193 results.push(path);
194 }
195 }
196 results.sort();
197 results
198 }
199
200 fn load_from_dir(&self, dir: &Path) -> Result<Skill, String> {
201 let skill_file = dir.join("SKILL.md");
202 let source = fs::read_to_string(&skill_file)
203 .map_err(|e| format!("failed to read {}: {e}", skill_file.display()))?;
204 let (fm, body) = split_frontmatter(&source);
205 let parsed = parse_frontmatter(fm).map_err(|e| format!("{}: {e}", skill_file.display()))?;
206 let mut manifest = parsed.manifest;
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 let skill = Skill {
213 body: body.to_string(),
214 skill_dir: Some(dir.to_path_buf()),
215 layer: self.layer,
216 namespace: self.namespace.clone(),
217 unknown_fields: parsed.unknown_fields,
218 manifest,
219 };
220 if skill.manifest.name.is_empty() {
221 return Err(format!(
222 "{}: SKILL.md has no `name` field and directory has no basename",
223 skill_file.display()
224 ));
225 }
226 Ok(skill)
227 }
228}
229
230impl SkillSource for FsSkillSource {
231 fn list(&self) -> Vec<SkillManifestRef> {
232 let mut out = Vec::new();
233 for dir in self.iter_skill_dirs() {
234 match self.load_from_dir(&dir) {
235 Ok(skill) => {
236 let id = skill.id();
237 out.push(SkillManifestRef {
238 id,
239 manifest: skill.manifest,
240 layer: skill.layer,
241 namespace: skill.namespace,
242 origin: dir.display().to_string(),
243 });
244 }
245 Err(err) => {
246 eprintln!("warning: skills: {err}");
247 }
248 }
249 }
250 out
251 }
252
253 fn fetch(&self, id: &str) -> Result<Skill, String> {
254 let target_name = match id.rsplit_once('/') {
255 Some((_, n)) => n,
256 None => id,
257 };
258 for dir in self.iter_skill_dirs() {
259 let skill = self.load_from_dir(&dir)?;
260 if skill.id() == id || skill.manifest.name == target_name {
261 return Ok(skill);
262 }
263 }
264 Err(format!(
265 "skill '{id}' not found under {}",
266 self.root.display()
267 ))
268 }
269
270 fn layer(&self) -> Layer {
271 self.layer
272 }
273
274 fn describe(&self) -> String {
275 match &self.namespace {
276 Some(ns) => format!("{} [{}] ns={ns}", self.root.display(), self.layer.label()),
277 None => format!("{} [{}]", self.root.display(), self.layer.label()),
278 }
279 }
280}
281
282pub type HostSkillLister = Arc<dyn Fn() -> Vec<SkillManifestRef> + Send + Sync>;
285
286pub type HostSkillFetcher = Arc<dyn Fn(&str) -> Result<Skill, String> + Send + Sync>;
289
290pub struct HostSkillSource {
294 loader: HostSkillLister,
295 fetcher: HostSkillFetcher,
296}
297
298impl HostSkillSource {
299 pub fn new<L, F>(loader: L, fetcher: F) -> Self
300 where
301 L: Fn() -> Vec<SkillManifestRef> + Send + Sync + 'static,
302 F: Fn(&str) -> Result<Skill, String> + Send + Sync + 'static,
303 {
304 Self {
305 loader: Arc::new(loader),
306 fetcher: Arc::new(fetcher),
307 }
308 }
309}
310
311impl std::fmt::Debug for HostSkillSource {
312 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
313 f.debug_struct("HostSkillSource").finish_non_exhaustive()
314 }
315}
316
317impl SkillSource for HostSkillSource {
318 fn list(&self) -> Vec<SkillManifestRef> {
319 (self.loader)()
320 }
321
322 fn fetch(&self, id: &str) -> Result<Skill, String> {
323 (self.fetcher)(id)
324 }
325
326 fn layer(&self) -> Layer {
327 Layer::Host
328 }
329
330 fn describe(&self) -> String {
331 "host-provided [host]".to_string()
332 }
333}
334
335pub fn skill_entry_to_vm(skill: &Skill) -> crate::value::VmValue {
339 use crate::value::VmValue;
340 use std::rc::Rc;
341
342 let mut entry: BTreeMap<String, VmValue> = BTreeMap::new();
343 entry.insert(
344 "name".to_string(),
345 VmValue::String(Rc::from(skill.manifest.name.as_str())),
346 );
347 entry.insert(
348 "description".to_string(),
349 VmValue::String(Rc::from(skill.manifest.description.as_str())),
350 );
351 if let Some(when) = &skill.manifest.when_to_use {
352 entry.insert(
353 "when_to_use".to_string(),
354 VmValue::String(Rc::from(when.as_str())),
355 );
356 }
357 if skill.manifest.disable_model_invocation {
358 entry.insert("disable_model_invocation".to_string(), VmValue::Bool(true));
359 }
360 if !skill.manifest.allowed_tools.is_empty() {
361 entry.insert(
362 "allowed_tools".to_string(),
363 VmValue::List(Rc::new(
364 skill
365 .manifest
366 .allowed_tools
367 .iter()
368 .map(|t| VmValue::String(Rc::from(t.as_str())))
369 .collect(),
370 )),
371 );
372 }
373 if skill.manifest.user_invocable {
374 entry.insert("user_invocable".to_string(), VmValue::Bool(true));
375 }
376 if !skill.manifest.paths.is_empty() {
377 entry.insert(
378 "paths".to_string(),
379 VmValue::List(Rc::new(
380 skill
381 .manifest
382 .paths
383 .iter()
384 .map(|p| VmValue::String(Rc::from(p.as_str())))
385 .collect(),
386 )),
387 );
388 }
389 if let Some(context) = &skill.manifest.context {
390 entry.insert(
391 "context".to_string(),
392 VmValue::String(Rc::from(context.as_str())),
393 );
394 }
395 if let Some(agent) = &skill.manifest.agent {
396 entry.insert(
397 "agent".to_string(),
398 VmValue::String(Rc::from(agent.as_str())),
399 );
400 }
401 if !skill.manifest.hooks.is_empty() {
402 let mut hooks: BTreeMap<String, VmValue> = BTreeMap::new();
403 for (k, v) in &skill.manifest.hooks {
404 hooks.insert(k.clone(), VmValue::String(Rc::from(v.as_str())));
405 }
406 entry.insert("hooks".to_string(), VmValue::Dict(Rc::new(hooks)));
407 }
408 if let Some(model) = &skill.manifest.model {
409 entry.insert(
410 "model".to_string(),
411 VmValue::String(Rc::from(model.as_str())),
412 );
413 }
414 if let Some(effort) = &skill.manifest.effort {
415 entry.insert(
416 "effort".to_string(),
417 VmValue::String(Rc::from(effort.as_str())),
418 );
419 }
420 if let Some(shell) = &skill.manifest.shell {
421 entry.insert(
422 "shell".to_string(),
423 VmValue::String(Rc::from(shell.as_str())),
424 );
425 }
426 if let Some(hint) = &skill.manifest.argument_hint {
427 entry.insert(
428 "argument_hint".to_string(),
429 VmValue::String(Rc::from(hint.as_str())),
430 );
431 }
432 entry.insert(
433 "body".to_string(),
434 VmValue::String(Rc::from(skill.body.as_str())),
435 );
436 if let Some(dir) = &skill.skill_dir {
437 entry.insert(
438 "skill_dir".to_string(),
439 VmValue::String(Rc::from(dir.display().to_string().as_str())),
440 );
441 }
442 entry.insert(
443 "source".to_string(),
444 VmValue::String(Rc::from(skill.layer.label())),
445 );
446 if let Some(ns) = &skill.namespace {
447 entry.insert(
448 "namespace".to_string(),
449 VmValue::String(Rc::from(ns.as_str())),
450 );
451 }
452 VmValue::Dict(Rc::new(entry))
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458 use std::fs;
459
460 fn write(tmp: &Path, rel: &str, body: &str) {
461 let p = tmp.join(rel);
462 fs::create_dir_all(p.parent().unwrap()).unwrap();
463 fs::write(p, body).unwrap();
464 }
465
466 #[test]
467 fn fs_source_walks_one_level_deep() {
468 let tmp = tempfile::tempdir().unwrap();
469 write(
470 tmp.path(),
471 "deploy/SKILL.md",
472 "---\nname: deploy\ndescription: ship it\n---\nrun deploy",
473 );
474 write(
475 tmp.path(),
476 "review/SKILL.md",
477 "---\nname: review\n---\nbody",
478 );
479 write(tmp.path(), "not-a-skill.txt", "no");
480
481 let src = FsSkillSource::new(tmp.path(), Layer::Project);
482 let listed = src.list();
483 assert_eq!(listed.len(), 2);
484 let names: Vec<_> = listed.iter().map(|s| s.manifest.name.clone()).collect();
485 assert!(names.contains(&"deploy".to_string()));
486 assert!(names.contains(&"review".to_string()));
487
488 let skill = src.fetch("deploy").unwrap();
489 assert_eq!(skill.manifest.description, "ship it");
490 assert_eq!(skill.body, "run deploy");
491 }
492
493 #[test]
494 fn fs_source_accepts_root_as_single_skill() {
495 let tmp = tempfile::tempdir().unwrap();
496 write(tmp.path(), "SKILL.md", "---\nname: solo\n---\n(body)");
497 let src = FsSkillSource::new(tmp.path(), Layer::Cli);
498 let listed = src.list();
499 assert_eq!(listed.len(), 1);
500 assert_eq!(listed[0].manifest.name, "solo");
501 }
502
503 #[test]
504 fn fs_source_defaults_name_to_directory() {
505 let tmp = tempfile::tempdir().unwrap();
506 write(tmp.path(), "nameless/SKILL.md", "---\n---\nbody only");
507 let src = FsSkillSource::new(tmp.path(), Layer::User);
508 let skill = src.fetch("nameless").unwrap();
509 assert_eq!(skill.manifest.name, "nameless");
510 }
511
512 #[test]
513 fn fs_source_namespace_prefixes_id() {
514 let tmp = tempfile::tempdir().unwrap();
515 write(
516 tmp.path(),
517 "deploy/SKILL.md",
518 "---\nname: deploy\n---\nbody",
519 );
520 let src = FsSkillSource::new(tmp.path(), Layer::Manifest).with_namespace("acme/ops");
521 let listed = src.list();
522 assert_eq!(listed[0].id, "acme/ops/deploy");
523 let skill = src.fetch("acme/ops/deploy").unwrap();
524 assert_eq!(skill.id(), "acme/ops/deploy");
525 }
526
527 #[test]
528 fn fs_source_missing_root_is_empty_not_error() {
529 let src = FsSkillSource::new("/does/not/exist/anywhere", Layer::System);
530 assert!(src.list().is_empty());
531 assert!(src.fetch("nope").is_err());
532 }
533
534 #[test]
535 fn host_source_wraps_closures() {
536 let host = HostSkillSource::new(
537 || {
538 vec![SkillManifestRef {
539 id: "h1".into(),
540 manifest: SkillManifest {
541 name: "h1".into(),
542 ..Default::default()
543 },
544 layer: Layer::Host,
545 namespace: None,
546 origin: "host".into(),
547 }]
548 },
549 |id| {
550 Ok(Skill {
551 manifest: SkillManifest {
552 name: id.to_string(),
553 ..Default::default()
554 },
555 body: "host body".into(),
556 skill_dir: None,
557 layer: Layer::Host,
558 namespace: None,
559 unknown_fields: Vec::new(),
560 })
561 },
562 );
563 assert_eq!(host.list().len(), 1);
564 let s = host.fetch("h1").unwrap();
565 assert_eq!(s.body, "host body");
566 assert_eq!(s.layer, Layer::Host);
567 }
568
569 #[test]
570 fn layer_label_roundtrips() {
571 for layer in Layer::all() {
572 assert_eq!(Layer::from_label(layer.label()), Some(*layer));
573 }
574 }
575}