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 = noyalib::to_string(&frontmatter)?;
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 = noyalib::from_str(yaml_str)?;
173
174 Ok((frontmatter, body.to_string()))
175 }
176}
177
178#[derive(Debug, Clone, Default)]
180pub struct PromptTriggers {
181 patterns: Vec<String>,
182 globs: Option<GlobSet>,
183}
184
185impl PromptTriggers {
186 pub(crate) fn new(glob_patterns: Vec<String>) -> Result<Self, PromptFileError> {
187 if glob_patterns.is_empty() {
188 return Ok(Self { patterns: Vec::new(), globs: None });
189 }
190
191 let mut builder = GlobSetBuilder::new();
192 for pattern in &glob_patterns {
193 let glob = Glob::new(pattern)
194 .map_err(|e| PromptFileError::InvalidTriggerGlob { pattern: pattern.clone(), error: e.to_string() })?;
195 builder.add(glob);
196 }
197
198 let globs = builder.build().map_err(|e| PromptFileError::InvalidTriggerGlob {
199 pattern: glob_patterns.join(", "),
200 error: e.to_string(),
201 })?;
202
203 Ok(Self { patterns: glob_patterns, globs: Some(globs) })
204 }
205
206 pub fn patterns(&self) -> &[String] {
207 &self.patterns
208 }
209
210 pub fn is_empty(&self) -> bool {
211 self.globs.is_none()
212 }
213
214 pub fn matches_read(&self, relative_path: &str) -> bool {
216 self.globs.as_ref().is_some_and(|gs| gs.is_match(relative_path))
217 }
218}
219
220#[derive(Debug, thiserror::Error)]
221pub enum PromptFileError {
222 #[error("IO error: {0}")]
223 Io(#[from] std::io::Error),
224 #[error("YAML error: {0}")]
225 Yaml(#[from] noyalib::Error),
226 #[error("missing YAML frontmatter")]
227 MissingFrontmatter,
228 #[error("skill '{name}' has an empty description")]
229 MissingDescription { name: String },
230 #[error("skill '{name}' must have at least one of: user-invocable, agent-invocable, triggers, globs, or paths")]
231 NoActivationSurface { name: String },
232 #[error("invalid trigger glob '{pattern}': {error}")]
233 InvalidTriggerGlob { pattern: String, error: String },
234 #[error("skill not found: {0}")]
235 NotFound(String),
236 #[error("skill '{0}' is not agent-authored and cannot be modified")]
237 NotAgentAuthored(String),
238}
239
240fn normalize_frontmatter_yaml(yaml: &str) -> &str {
241 let yaml = yaml.trim();
242 let yaml = yaml.strip_prefix("---\n").unwrap_or(yaml);
243 yaml.strip_suffix("\n...").unwrap_or(yaml).trim()
244}
245
246#[expect(clippy::trivially_copy_pass_by_ref)]
247fn not(b: &bool) -> bool {
248 !b
249}
250
251#[expect(clippy::trivially_copy_pass_by_ref)]
252fn zero(n: &u32) -> bool {
253 *n == 0
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use crate::testing::{project, prompt_file};
260
261 #[test]
262 fn parse_skill_frontmatter_and_body() {
263 let dir = project().skill(
264 "my-skill",
265 "---\ndescription: Test skill\ntags:\n - rust\nagent_authored: true\nhelpful: 3\nharmful: 1\n---\n# My Skill\n\nSome content here.",
266 );
267
268 let parsed = PromptFile::parse(&dir.root().join("my-skill").join(SKILL_FILENAME)).unwrap();
269 assert_eq!(parsed.name, "my-skill");
270 assert_eq!(parsed.description, "Test skill");
271 assert_eq!(parsed.tags, vec!["rust"]);
272 assert!(parsed.agent_authored);
273 assert_eq!(parsed.helpful, 3);
274 assert_eq!(parsed.harmful, 1);
275 assert!(parsed.body.contains("# My Skill"));
276 assert!(parsed.body.contains("Some content here."));
277 }
278
279 #[test]
280 fn backward_compat_old_frontmatter() {
281 let dir = project().skill("old-skill", "---\ndescription: An old skill\n---\nBody.");
282
283 let parsed = PromptFile::parse(&dir.root().join("old-skill").join(SKILL_FILENAME)).unwrap();
284 assert_eq!(parsed.description, "An old skill");
285 assert_eq!(parsed.tags.len(), 0);
286 assert!(!parsed.agent_authored);
287 assert_eq!(parsed.helpful, 0);
288 assert_eq!(parsed.harmful, 0);
289 }
290
291 #[test]
292 fn confidence() {
293 let prompt = |helpful, harmful| prompt_file("test").ratings(helpful, harmful).build();
294
295 assert!((prompt(0, 0).confidence() - 0.0).abs() < f64::EPSILON);
296 assert!((prompt(7, 1).confidence() - 7.0 / 9.0).abs() < f64::EPSILON);
297 assert!((prompt(0, 5).confidence() - 0.0).abs() < f64::EPSILON);
298 assert!((prompt(3, 0).confidence() - 3.0 / 4.0).abs() < f64::EPSILON);
299 }
300
301 #[test]
302 fn write_and_parse_roundtrip() {
303 let project = project();
304 let skill_path = project.root().join("my-skill").join(SKILL_FILENAME);
305
306 prompt_file("my-skill")
307 .description("Test skill")
308 .body("# My Skill\n\nSome content here.")
309 .tags(&["convention"])
310 .agent_authored(true)
311 .ratings(2, 1)
312 .build()
313 .write(&skill_path)
314 .unwrap();
315
316 let parsed = PromptFile::parse(&skill_path).unwrap();
317 assert_eq!(parsed.description, "Test skill");
318 assert_eq!(parsed.tags, vec!["convention"]);
319 assert!(parsed.agent_authored);
320 assert_eq!(parsed.helpful, 2);
321 assert_eq!(parsed.harmful, 1);
322 assert!(parsed.body.contains("# My Skill"));
323 assert!(parsed.body.contains("Some content here."));
324 }
325
326 #[test]
327 fn write_empty_body() {
328 let project = project();
329 let skill_path = project.root().join("empty-body").join(SKILL_FILENAME);
330
331 prompt_file("empty-body").description("Empty").body("").build().write(&skill_path).unwrap();
332
333 let raw = std::fs::read_to_string(&skill_path).unwrap();
334 assert!(raw.starts_with("---\n"));
335 assert!(raw.contains("description: Empty"));
336 }
337
338 #[test]
339 fn write_and_parse_roundtrip_with_triggers() {
340 let project = project();
341 let skill_path = project.root().join("rust-rules").join(SKILL_FILENAME);
342
343 prompt_file("rust-rules")
344 .description("Rust conventions")
345 .body("Follow Rust conventions.")
346 .user_invocable(false)
347 .agent_invocable(false)
348 .read_triggers(&["src/**/*.rs", "tests/**/*.rs"])
349 .build()
350 .write(&skill_path)
351 .unwrap();
352
353 let parsed = PromptFile::parse(&skill_path).unwrap();
354 assert_eq!(parsed.description, "Rust conventions");
355 assert!(!parsed.triggers.is_empty());
356 assert!(parsed.triggers.matches_read("src/main.rs"));
357 assert!(parsed.triggers.matches_read("tests/integration.rs"));
358 assert!(!parsed.triggers.matches_read("README.md"));
359 assert_eq!(parsed.triggers.patterns(), ["src/**/*.rs", "tests/**/*.rs"]);
360 }
361
362 #[test]
363 fn write_rejects_empty_description() {
364 let project = project();
365 let skill_path = project.root().join("bad").join(SKILL_FILENAME);
366
367 let result = prompt_file("bad").description("").build().write(&skill_path);
368 assert!(matches!(result, Err(PromptFileError::MissingDescription { .. })));
369 }
370
371 #[test]
372 fn write_rejects_no_activation_surface() {
373 let project = project();
374 let skill_path = project.root().join("noop").join(SKILL_FILENAME);
375
376 let result = prompt_file("noop").user_invocable(false).agent_invocable(false).build().write(&skill_path);
377 assert!(matches!(result, Err(PromptFileError::NoActivationSurface { .. })));
378 }
379
380 #[test]
381 fn write_skips_default_frontmatter_fields() {
382 let project = project();
383 let skill_path = project.root().join("minimal").join(SKILL_FILENAME);
384
385 prompt_file("minimal").build().write(&skill_path).unwrap();
386
387 let raw = std::fs::read_to_string(&skill_path).unwrap();
388 assert!(raw.contains("description: minimal skill"));
389 assert!(!raw.contains("tags"));
390 assert!(!raw.contains("agent_authored"));
391 assert!(!raw.contains("agent-invocable"));
392 assert!(!raw.contains("argument-hint"));
393 assert!(!raw.contains("helpful"));
394 assert!(!raw.contains("harmful"));
395 assert!(!raw.contains("triggers"));
396 }
397
398 #[test]
399 fn parse_globs_key() {
400 let dir = project().skill(
401 "ts-conventions",
402 "---\ndescription: TS conventions\nglobs:\n - \"src/**/*.ts\"\n - \"src/**/*.tsx\"\n---\nUse strict TypeScript.",
403 );
404
405 let parsed = PromptFile::parse(&dir.root().join("ts-conventions").join(SKILL_FILENAME)).unwrap();
406 assert_eq!(parsed.triggers.patterns(), ["src/**/*.ts", "src/**/*.tsx"]);
407 assert!(parsed.triggers.matches_read("src/main.ts"));
408 assert!(parsed.body.contains("Use strict TypeScript."));
409 }
410
411 #[test]
412 fn parse_paths_key() {
413 let dir = project().skill(
414 "rust-rules",
415 "---\ndescription: Rust rules\npaths:\n - \"**/*.rs\"\n---\nFollow Rust conventions.",
416 );
417
418 let parsed = PromptFile::parse(&dir.root().join("rust-rules").join(SKILL_FILENAME)).unwrap();
419 assert_eq!(parsed.triggers.patterns(), ["**/*.rs"]);
420 assert!(parsed.triggers.matches_read("src/lib.rs"));
421 }
422
423 #[test]
424 fn parse_merges_all_glob_sources() {
425 let dir = project().skill(
426 "merged-rules",
427 "---\ndescription: Merged\ntriggers:\n read:\n - \"src/**/*.rs\"\nglobs:\n - \"lib/**/*.ts\"\npaths:\n - \"app/**/*.py\"\n---\nMerged rules.",
428 );
429
430 let parsed = PromptFile::parse(&dir.root().join("merged-rules").join(SKILL_FILENAME)).unwrap();
431 assert!(parsed.triggers.matches_read("src/main.rs"));
432 assert!(parsed.triggers.matches_read("lib/index.ts"));
433 assert!(parsed.triggers.matches_read("app/main.py"));
434 }
435
436 #[test]
437 fn parse_globs_as_activation_surface() {
438 let dir = project()
439 .file("globs-only.md", "---\ndescription: TS rules\nglobs:\n - \"**/*.ts\"\n---\nTypeScript rules.");
440
441 let parsed = PromptFile::parse(&dir.root().join("globs-only.md")).unwrap();
442 assert_eq!(parsed.name, "globs-only");
443 assert!(parsed.triggers.matches_read("src/index.ts"));
444 }
445
446 #[test]
447 fn name_from_file_stem_for_non_skill_md() {
448 let dir = project().file(
449 "rust-conventions.md",
450 "---\ndescription: Rust conventions\nglobs:\n - \"**/*.rs\"\n---\nFollow Rust conventions.",
451 );
452
453 let parsed = PromptFile::parse(&dir.root().join("rust-conventions.md")).unwrap();
454 assert_eq!(parsed.name, "rust-conventions");
455 }
456
457 #[test]
458 fn empty_description_defaults_to_name() {
459 let dir = project().file("my-rule.md", "---\nglobs:\n - \"**/*.rs\"\n---\nRule body.");
460
461 let parsed = PromptFile::parse(&dir.root().join("my-rule.md")).unwrap();
462 assert_eq!(parsed.name, "my-rule");
463 assert_eq!(parsed.description, "my-rule");
464 }
465
466 #[test]
467 fn skill_file_defaults_user_invocable_true_when_missing() {
468 let dir = project().skill("compat-skill", "---\ndescription: Claude-style skill\n---\nSkill body.");
469
470 let parsed = PromptFile::parse(&dir.root().join("compat-skill").join(SKILL_FILENAME)).unwrap();
471 assert!(parsed.user_invocable);
472 assert!(parsed.agent_invocable);
473 }
474
475 #[test]
476 fn non_skill_md_without_activation_surface_still_rejected() {
477 let dir = project().file("noop.md", "---\ndescription: No activation\nagent-invocable: false\n---\nRule body.");
478
479 let result = PromptFile::parse(&dir.root().join("noop.md"));
480 assert!(matches!(result, Err(PromptFileError::NoActivationSurface { .. })));
481 }
482}