1use std::fs;
2use std::path::{Path, PathBuf};
3
4use globset::{Glob, GlobSet, GlobSetBuilder};
5use serde::{Deserialize, Serialize};
6
7pub const SKILL_FILENAME: &str = "SKILL.md";
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub(crate) struct PromptFrontmatter {
11 #[serde(default)]
12 pub description: String,
13 #[serde(default, skip_serializing_if = "Option::is_none")]
14 pub name: Option<String>,
15 #[serde(default, rename = "user-invocable", skip_serializing_if = "Option::is_none")]
16 pub user_invocable: Option<bool>,
17 #[serde(default, rename = "agent-invocable", skip_serializing_if = "Option::is_none")]
18 pub agent_invocable: Option<bool>,
19 #[serde(default, rename = "argument-hint", skip_serializing_if = "Option::is_none")]
20 pub argument_hint: Option<String>,
21 #[serde(default, skip_serializing_if = "Vec::is_empty")]
22 pub tags: Vec<String>,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub triggers: Option<Triggers>,
25 #[serde(default, skip_serializing_if = "Vec::is_empty")]
27 pub globs: Vec<String>,
28 #[serde(default, skip_serializing_if = "Vec::is_empty")]
30 pub paths: Vec<String>,
31 #[serde(default, skip_serializing_if = "not")]
32 pub agent_authored: bool,
33 #[serde(default, skip_serializing_if = "zero")]
34 pub helpful: u32,
35 #[serde(default, skip_serializing_if = "zero")]
36 pub harmful: u32,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
40pub struct Triggers {
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub read: Vec<String>,
43}
44
45#[derive(Debug, Clone)]
47pub struct PromptFile {
48 pub name: String,
49 pub description: String,
50 pub body: String,
51 pub path: PathBuf,
52 pub user_invocable: bool,
53 pub agent_invocable: bool,
54 pub argument_hint: Option<String>,
55 pub tags: Vec<String>,
56 pub triggers: PromptTriggers,
57 pub agent_authored: bool,
58 pub helpful: u32,
59 pub harmful: u32,
60}
61
62impl PromptFile {
63 pub fn parse(path: &Path) -> Result<Self, PromptFileError> {
67 let raw = fs::read_to_string(path)?;
68 let is_skill_file = path.file_name().is_some_and(|n| n == SKILL_FILENAME);
69
70 let (frontmatter, body) = Self::parse_frontmatter(raw.trim())?;
71
72 let default_name = if is_skill_file {
73 path.parent().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
74 } else {
75 path.file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
76 };
77
78 let name = frontmatter.name.unwrap_or(default_name);
79 let description = frontmatter.description.trim().to_string();
80 let description = if description.is_empty() { name.clone() } else { description };
81 let user_invocable = frontmatter.user_invocable.unwrap_or(is_skill_file);
82 let agent_invocable = frontmatter.agent_invocable.unwrap_or(true);
83
84 let mut read_globs = frontmatter.triggers.map(|t| t.read).unwrap_or_default();
85 read_globs.extend(frontmatter.globs);
86 read_globs.extend(frontmatter.paths);
87
88 if !user_invocable && !agent_invocable && read_globs.is_empty() {
89 return Err(PromptFileError::NoActivationSurface { name });
90 }
91
92 let triggers = PromptTriggers::new(read_globs)?;
93
94 Ok(Self {
95 name,
96 description,
97 body,
98 path: path.to_path_buf(),
99 user_invocable,
100 agent_invocable,
101 argument_hint: frontmatter.argument_hint,
102 tags: frontmatter.tags,
103 triggers,
104 agent_authored: frontmatter.agent_authored,
105 helpful: frontmatter.helpful,
106 harmful: frontmatter.harmful,
107 })
108 }
109
110 pub fn validate(&self) -> Result<(), PromptFileError> {
112 if self.description.trim().is_empty() {
113 return Err(PromptFileError::MissingDescription { name: self.name.clone() });
114 }
115
116 let has_read_triggers = !self.triggers.is_empty();
117 if !self.user_invocable && !self.agent_invocable && !has_read_triggers {
118 return Err(PromptFileError::NoActivationSurface { name: self.name.clone() });
119 }
120
121 Ok(())
122 }
123
124 pub fn write(&self, path: &Path) -> Result<(), PromptFileError> {
126 self.validate()?;
127
128 if let Some(parent) = path.parent() {
129 fs::create_dir_all(parent)?;
130 }
131
132 let triggers =
133 if self.triggers.is_empty() { None } else { Some(Triggers { read: self.triggers.patterns().to_vec() }) };
134
135 let frontmatter = PromptFrontmatter {
136 description: self.description.clone(),
137 name: Some(self.name.clone()),
138 user_invocable: self.user_invocable.then_some(true),
139 agent_invocable: (!self.agent_invocable).then_some(false),
140 argument_hint: self.argument_hint.clone(),
141 tags: self.tags.clone(),
142 triggers,
143 globs: vec![],
144 paths: vec![],
145 agent_authored: self.agent_authored,
146 helpful: self.helpful,
147 harmful: self.harmful,
148 };
149
150 let yaml = serde_yml::to_string(&frontmatter).map_err(|e| PromptFileError::Yaml(e.to_string()))?;
151 let yaml = normalize_frontmatter_yaml(&yaml);
152
153 let file_content = if self.body.is_empty() {
154 format!("---\n{yaml}\n---\n")
155 } else {
156 format!("---\n{yaml}\n---\n{}\n", self.body)
157 };
158 fs::write(path, file_content)?;
159 Ok(())
160 }
161
162 pub fn confidence(&self) -> f64 {
164 f64::from(self.helpful) / (f64::from(self.helpful) + f64::from(self.harmful) + 1.0)
165 }
166
167 fn parse_frontmatter(content: &str) -> Result<(PromptFrontmatter, String), PromptFileError> {
169 let (yaml_str, body) =
170 utils::markdown_file::split_frontmatter(content).ok_or(PromptFileError::MissingFrontmatter)?;
171
172 let frontmatter: PromptFrontmatter =
173 serde_yml::from_str(yaml_str).map_err(|e| PromptFileError::Yaml(e.to_string()))?;
174
175 Ok((frontmatter, body.to_string()))
176 }
177}
178
179#[derive(Debug, Clone, Default)]
181pub struct PromptTriggers {
182 patterns: Vec<String>,
183 globs: Option<GlobSet>,
184}
185
186impl PromptTriggers {
187 fn new(glob_patterns: Vec<String>) -> Result<Self, PromptFileError> {
188 if glob_patterns.is_empty() {
189 return Ok(Self { patterns: Vec::new(), globs: None });
190 }
191
192 let mut builder = GlobSetBuilder::new();
193 for pattern in &glob_patterns {
194 let glob = Glob::new(pattern)
195 .map_err(|e| PromptFileError::InvalidTriggerGlob { pattern: pattern.clone(), error: e.to_string() })?;
196 builder.add(glob);
197 }
198
199 let globs = builder.build().map_err(|e| PromptFileError::InvalidTriggerGlob {
200 pattern: glob_patterns.join(", "),
201 error: e.to_string(),
202 })?;
203
204 Ok(Self { patterns: glob_patterns, globs: Some(globs) })
205 }
206
207 pub fn patterns(&self) -> &[String] {
208 &self.patterns
209 }
210
211 pub fn is_empty(&self) -> bool {
212 self.globs.is_none()
213 }
214
215 pub fn matches_read(&self, relative_path: &str) -> bool {
217 self.globs.as_ref().is_some_and(|gs| gs.is_match(relative_path))
218 }
219}
220
221#[derive(Debug, thiserror::Error)]
222pub enum PromptFileError {
223 #[error("IO error: {0}")]
224 Io(#[from] std::io::Error),
225 #[error("YAML error: {0}")]
226 Yaml(String),
227 #[error("missing YAML frontmatter")]
228 MissingFrontmatter,
229 #[error("skill '{name}' has an empty description")]
230 MissingDescription { name: String },
231 #[error("skill '{name}' must have at least one of: user-invocable, agent-invocable, triggers, globs, or paths")]
232 NoActivationSurface { name: String },
233 #[error("invalid trigger glob '{pattern}': {error}")]
234 InvalidTriggerGlob { pattern: String, error: String },
235 #[error("skill not found: {0}")]
236 NotFound(String),
237 #[error("skill '{0}' is not agent-authored and cannot be modified")]
238 NotAgentAuthored(String),
239}
240
241fn normalize_frontmatter_yaml(yaml: &str) -> &str {
242 let yaml = yaml.trim();
243 let yaml = yaml.strip_prefix("---\n").unwrap_or(yaml);
244 yaml.strip_suffix("\n...").unwrap_or(yaml).trim()
245}
246
247#[expect(clippy::trivially_copy_pass_by_ref)]
248fn not(b: &bool) -> bool {
249 !b
250}
251
252#[expect(clippy::trivially_copy_pass_by_ref)]
253fn zero(n: &u32) -> bool {
254 *n == 0
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 use tempfile::TempDir;
261
262 fn minimal_frontmatter(description: &str) -> PromptFrontmatter {
263 PromptFrontmatter {
264 description: description.to_string(),
265 name: None,
266 user_invocable: None,
267 agent_invocable: None,
268 argument_hint: None,
269 tags: vec![],
270 triggers: None,
271 globs: vec![],
272 paths: vec![],
273 agent_authored: false,
274 helpful: 0,
275 harmful: 0,
276 }
277 }
278
279 #[test]
280 fn frontmatter_serde_roundtrip() {
281 let fm = minimal_frontmatter("A simple skill");
282
283 let yaml = serde_yml::to_string(&fm).unwrap();
284 let parsed: PromptFrontmatter = serde_yml::from_str(&yaml).unwrap();
285 assert_eq!(parsed.description, "A simple skill");
286 assert!(parsed.tags.is_empty());
287 assert!(!parsed.agent_authored);
288 }
289
290 #[test]
291 fn frontmatter_serde_with_all_fields() {
292 let mut fm = minimal_frontmatter("A full skill");
293 fm.tags = vec!["convention".to_string(), "testing".to_string()];
294 fm.agent_authored = true;
295 fm.helpful = 5;
296 fm.harmful = 2;
297
298 let yaml = serde_yml::to_string(&fm).unwrap();
299 let parsed: PromptFrontmatter = serde_yml::from_str(&yaml).unwrap();
300 assert_eq!(parsed.description, "A full skill");
301 assert_eq!(parsed.tags, vec!["convention", "testing"]);
302 assert!(parsed.agent_authored);
303 assert_eq!(parsed.helpful, 5);
304 assert_eq!(parsed.harmful, 2);
305 }
306
307 #[test]
308 fn backward_compat_old_frontmatter() {
309 let yaml = "description: An old skill\n";
310 let parsed: PromptFrontmatter = serde_yml::from_str(yaml).unwrap();
311 assert_eq!(parsed.description, "An old skill");
312 assert!(parsed.tags.is_empty());
313 assert!(!parsed.agent_authored);
314 assert_eq!(parsed.helpful, 0);
315 assert_eq!(parsed.harmful, 0);
316 }
317
318 #[test]
319 fn confidence() {
320 let pf = |helpful, harmful| PromptFile {
321 name: String::new(),
322 description: "test".to_string(),
323 body: String::new(),
324 path: PathBuf::new(),
325 user_invocable: false,
326 agent_invocable: false,
327 argument_hint: None,
328 tags: vec![],
329 triggers: PromptTriggers::default(),
330 agent_authored: true,
331 helpful,
332 harmful,
333 };
334
335 assert!((pf(0, 0).confidence() - 0.0).abs() < f64::EPSILON);
336 assert!((pf(7, 1).confidence() - 7.0 / 9.0).abs() < f64::EPSILON);
337 assert!((pf(0, 5).confidence() - 0.0).abs() < f64::EPSILON);
338 assert!((pf(3, 0).confidence() - 3.0 / 4.0).abs() < f64::EPSILON);
339 }
340
341 #[test]
342 fn parse_frontmatter_from_string() {
343 let content = "---\ndescription: Test skill\ntags:\n - rust\nagent_authored: true\nhelpful: 3\nharmful: 1\n---\n# My Skill\n\nSome content here.";
344 let (fm, body) = PromptFile::parse_frontmatter(content).unwrap();
345 assert_eq!(fm.description, "Test skill");
346 assert_eq!(fm.tags, vec!["rust"]);
347 assert!(fm.agent_authored);
348 assert_eq!(fm.helpful, 3);
349 assert_eq!(fm.harmful, 1);
350 assert!(body.contains("# My Skill"));
351 assert!(body.contains("Some content here."));
352 }
353
354 #[test]
355 fn write_and_parse_roundtrip() {
356 let temp_dir = TempDir::new().unwrap();
357 let skill_path = temp_dir.path().join("my-skill").join(SKILL_FILENAME);
358
359 let prompt = PromptFile {
360 name: "my-skill".to_string(),
361 description: "Test skill".to_string(),
362 body: "# My Skill\n\nSome content here.".to_string(),
363 path: skill_path.clone(),
364 user_invocable: false,
365 agent_invocable: true,
366 argument_hint: None,
367 tags: vec!["convention".to_string()],
368 triggers: PromptTriggers::default(),
369 agent_authored: true,
370 helpful: 2,
371 harmful: 1,
372 };
373 prompt.write(&skill_path).unwrap();
374
375 let parsed = PromptFile::parse(&skill_path).unwrap();
376 assert_eq!(parsed.description, "Test skill");
377 assert_eq!(parsed.tags, vec!["convention"]);
378 assert!(parsed.agent_authored);
379 assert_eq!(parsed.helpful, 2);
380 assert_eq!(parsed.harmful, 1);
381 assert!(parsed.body.contains("# My Skill"));
382 assert!(parsed.body.contains("Some content here."));
383 }
384
385 #[test]
386 fn write_empty_body() {
387 let temp_dir = TempDir::new().unwrap();
388 let skill_path = temp_dir.path().join("empty-body").join(SKILL_FILENAME);
389
390 let prompt = PromptFile {
391 name: "empty-body".to_string(),
392 description: "Empty".to_string(),
393 body: String::new(),
394 path: skill_path.clone(),
395 user_invocable: false,
396 agent_invocable: true,
397 argument_hint: None,
398 tags: vec![],
399 triggers: PromptTriggers::default(),
400 agent_authored: true,
401 helpful: 0,
402 harmful: 0,
403 };
404 prompt.write(&skill_path).unwrap();
405
406 let raw = std::fs::read_to_string(&skill_path).unwrap();
407 assert!(raw.starts_with("---\n"));
408 assert!(raw.contains("description: Empty"));
409 }
410
411 #[test]
412 fn write_and_parse_roundtrip_with_triggers() {
413 let temp_dir = TempDir::new().unwrap();
414 let skill_path = temp_dir.path().join("rust-rules").join(SKILL_FILENAME);
415
416 let triggers = PromptTriggers::new(vec!["src/**/*.rs".to_string(), "tests/**/*.rs".to_string()]).unwrap();
417
418 let prompt = PromptFile {
419 name: "rust-rules".to_string(),
420 description: "Rust conventions".to_string(),
421 body: "Follow Rust conventions.".to_string(),
422 path: skill_path.clone(),
423 user_invocable: false,
424 agent_invocable: false,
425 argument_hint: None,
426 tags: vec![],
427 triggers,
428 agent_authored: false,
429 helpful: 0,
430 harmful: 0,
431 };
432 prompt.write(&skill_path).unwrap();
433
434 let parsed = PromptFile::parse(&skill_path).unwrap();
435 assert_eq!(parsed.description, "Rust conventions");
436 assert!(!parsed.triggers.is_empty());
437 assert!(parsed.triggers.matches_read("src/main.rs"));
438 assert!(parsed.triggers.matches_read("tests/integration.rs"));
439 assert!(!parsed.triggers.matches_read("README.md"));
440 assert_eq!(parsed.triggers.patterns(), &["src/**/*.rs", "tests/**/*.rs"]);
441 }
442
443 #[test]
444 fn write_rejects_empty_description() {
445 let temp_dir = TempDir::new().unwrap();
446 let skill_path = temp_dir.path().join("bad").join(SKILL_FILENAME);
447
448 let prompt = PromptFile {
449 name: "bad".to_string(),
450 description: String::new(),
451 body: "content".to_string(),
452 path: skill_path.clone(),
453 user_invocable: true,
454 agent_invocable: false,
455 argument_hint: None,
456 tags: vec![],
457 triggers: PromptTriggers::default(),
458 agent_authored: true,
459 helpful: 0,
460 harmful: 0,
461 };
462 let result = prompt.write(&skill_path);
463 assert!(matches!(result, Err(PromptFileError::MissingDescription { .. })));
464 }
465
466 #[test]
467 fn write_rejects_no_activation_surface() {
468 let temp_dir = TempDir::new().unwrap();
469 let skill_path = temp_dir.path().join("noop").join(SKILL_FILENAME);
470
471 let prompt = PromptFile {
472 name: "noop".to_string(),
473 description: "Does nothing".to_string(),
474 body: "content".to_string(),
475 path: skill_path.clone(),
476 user_invocable: false,
477 agent_invocable: false,
478 argument_hint: None,
479 tags: vec![],
480 triggers: PromptTriggers::default(),
481 agent_authored: true,
482 helpful: 0,
483 harmful: 0,
484 };
485 let result = prompt.write(&skill_path);
486 assert!(matches!(result, Err(PromptFileError::NoActivationSurface { .. })));
487 }
488
489 #[test]
490 fn skip_serializing_defaults() {
491 let fm = minimal_frontmatter("Minimal");
492
493 let yaml = serde_yml::to_string(&fm).unwrap();
494 assert!(!yaml.contains("tags"));
495 assert!(!yaml.contains("agent_authored"));
496 assert!(!yaml.contains("helpful"));
497 assert!(!yaml.contains("harmful"));
498 }
499
500 #[test]
501 fn parse_globs_key() {
502 let content = r#"---
503description: TS conventions
504globs:
505 - "src/**/*.ts"
506 - "src/**/*.tsx"
507---
508Use strict TypeScript."#;
509 let (fm, body) = PromptFile::parse_frontmatter(content).unwrap();
510 assert_eq!(fm.globs, vec!["src/**/*.ts", "src/**/*.tsx"]);
511 assert!(fm.triggers.is_none());
512 assert!(body.contains("Use strict TypeScript."));
513 }
514
515 #[test]
516 fn parse_paths_key() {
517 let content = r#"---
518description: Rust rules
519paths:
520 - "**/*.rs"
521---
522Follow Rust conventions."#;
523 let (fm, _) = PromptFile::parse_frontmatter(content).unwrap();
524 assert_eq!(fm.paths, vec!["**/*.rs"]);
525 assert!(fm.triggers.is_none());
526 }
527
528 #[test]
529 fn parse_merges_all_glob_sources() {
530 let temp_dir = TempDir::new().unwrap();
531 let path = temp_dir.path().join("merged-rules").join(SKILL_FILENAME);
532 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
533 std::fs::write(
534 &path,
535 r#"---
536description: Merged
537triggers:
538 read:
539 - "src/**/*.rs"
540globs:
541 - "lib/**/*.ts"
542paths:
543 - "app/**/*.py"
544---
545Merged rules."#,
546 )
547 .unwrap();
548
549 let parsed = PromptFile::parse(&path).unwrap();
550 assert!(parsed.triggers.matches_read("src/main.rs"));
551 assert!(parsed.triggers.matches_read("lib/index.ts"));
552 assert!(parsed.triggers.matches_read("app/main.py"));
553 }
554
555 #[test]
556 fn parse_globs_as_activation_surface() {
557 let temp_dir = TempDir::new().unwrap();
558 let path = temp_dir.path().join("globs-only.md");
559 std::fs::write(
560 &path,
561 r#"---
562description: TS rules
563globs:
564 - "**/*.ts"
565---
566TypeScript rules."#,
567 )
568 .unwrap();
569
570 let parsed = PromptFile::parse(&path).unwrap();
571 assert_eq!(parsed.name, "globs-only");
572 assert!(parsed.triggers.matches_read("src/index.ts"));
573 }
574
575 #[test]
576 fn name_from_file_stem_for_non_skill_md() {
577 let temp_dir = TempDir::new().unwrap();
578 let path = temp_dir.path().join("rust-conventions.md");
579 std::fs::write(
580 &path,
581 r#"---
582description: Rust conventions
583globs:
584 - "**/*.rs"
585---
586Follow Rust conventions."#,
587 )
588 .unwrap();
589
590 let parsed = PromptFile::parse(&path).unwrap();
591 assert_eq!(parsed.name, "rust-conventions");
592 }
593
594 #[test]
595 fn empty_description_defaults_to_name() {
596 let temp_dir = TempDir::new().unwrap();
597 let path = temp_dir.path().join("my-rule.md");
598 std::fs::write(
599 &path,
600 r#"---
601globs:
602 - "**/*.rs"
603---
604Rule body."#,
605 )
606 .unwrap();
607
608 let parsed = PromptFile::parse(&path).unwrap();
609 assert_eq!(parsed.name, "my-rule");
610 assert_eq!(parsed.description, "my-rule");
611 }
612
613 #[test]
614 fn skill_file_defaults_user_invocable_true_when_missing() {
615 let temp_dir = TempDir::new().unwrap();
616 let path = temp_dir.path().join("compat-skill").join(SKILL_FILENAME);
617 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
618 std::fs::write(
619 &path,
620 r"---
621description: Claude-style skill
622---
623Skill body.",
624 )
625 .unwrap();
626
627 let parsed = PromptFile::parse(&path).unwrap();
628 assert!(parsed.user_invocable);
629 assert!(parsed.agent_invocable);
630 }
631
632 #[test]
633 fn non_skill_md_without_activation_surface_still_rejected() {
634 let temp_dir = TempDir::new().unwrap();
635 let path = temp_dir.path().join("noop.md");
636 std::fs::write(
637 &path,
638 r"---
639description: No activation
640agent-invocable: false
641---
642Rule body.",
643 )
644 .unwrap();
645
646 let result = PromptFile::parse(&path);
647 assert!(matches!(result, Err(PromptFileError::NoActivationSurface { .. })));
648 }
649}