1use std::fmt::{Display, Formatter};
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use globset::{Glob, GlobSet, GlobSetBuilder};
6use serde::{Deserialize, Serialize};
7
8pub const SKILL_FILENAME: &str = "SKILL.md";
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
11pub(crate) struct PromptFrontmatter {
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 = "not")]
16 pub user_invocable: bool,
17 #[serde(default, rename = "agent-invocable", skip_serializing_if = "not")]
18 pub agent_invocable: 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 = "not")]
26 pub agent_authored: bool,
27 #[serde(default, skip_serializing_if = "zero")]
28 pub helpful: u32,
29 #[serde(default, skip_serializing_if = "zero")]
30 pub harmful: u32,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
34pub struct Triggers {
35 #[serde(default, skip_serializing_if = "Vec::is_empty")]
36 pub read: Vec<String>,
37}
38
39#[derive(Debug, Clone)]
41pub struct PromptFile {
42 pub name: String,
43 pub description: String,
44 pub body: String,
45 pub path: PathBuf,
46 pub user_invocable: bool,
47 pub agent_invocable: bool,
48 pub argument_hint: Option<String>,
49 pub tags: Vec<String>,
50 pub triggers: PromptTriggers,
51 pub agent_authored: bool,
52 pub helpful: u32,
53 pub harmful: u32,
54}
55
56impl PromptFile {
57 pub fn parse(path: &Path) -> Result<Self, PromptFileError> {
61 let raw = fs::read_to_string(path)?;
62
63 let (frontmatter, body) = Self::parse_frontmatter(raw.trim())?;
64
65 let dir_name =
66 path.parent().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
67
68 let name = frontmatter.name.unwrap_or(dir_name);
69 let description = frontmatter.description.trim().to_string();
70
71 if description.is_empty() {
72 return Err(PromptFileError::MissingDescription { name });
73 }
74
75 let has_read_triggers = frontmatter.triggers.as_ref().is_some_and(|t| !t.read.is_empty());
76
77 if !frontmatter.user_invocable && !frontmatter.agent_invocable && !has_read_triggers {
78 return Err(PromptFileError::NoActivationSurface { name });
79 }
80
81 let read_globs = frontmatter.triggers.map(|t| t.read).unwrap_or_default();
82 let triggers = PromptTriggers::new(read_globs)?;
83
84 Ok(Self {
85 name,
86 description,
87 body,
88 path: path.to_path_buf(),
89 user_invocable: frontmatter.user_invocable,
90 agent_invocable: frontmatter.agent_invocable,
91 argument_hint: frontmatter.argument_hint,
92 tags: frontmatter.tags,
93 triggers,
94 agent_authored: frontmatter.agent_authored,
95 helpful: frontmatter.helpful,
96 harmful: frontmatter.harmful,
97 })
98 }
99
100 pub fn validate(&self) -> Result<(), PromptFileError> {
102 if self.description.trim().is_empty() {
103 return Err(PromptFileError::MissingDescription { name: self.name.clone() });
104 }
105
106 let has_read_triggers = !self.triggers.is_empty();
107 if !self.user_invocable && !self.agent_invocable && !has_read_triggers {
108 return Err(PromptFileError::NoActivationSurface { name: self.name.clone() });
109 }
110
111 Ok(())
112 }
113
114 pub fn write(&self, path: &Path) -> Result<(), PromptFileError> {
116 self.validate()?;
117
118 if let Some(parent) = path.parent() {
119 fs::create_dir_all(parent)?;
120 }
121
122 let triggers =
123 if self.triggers.is_empty() { None } else { Some(Triggers { read: self.triggers.patterns().to_vec() }) };
124
125 let frontmatter = PromptFrontmatter {
126 description: self.description.clone(),
127 name: Some(self.name.clone()),
128 user_invocable: self.user_invocable,
129 agent_invocable: self.agent_invocable,
130 argument_hint: self.argument_hint.clone(),
131 tags: self.tags.clone(),
132 triggers,
133 agent_authored: self.agent_authored,
134 helpful: self.helpful,
135 harmful: self.harmful,
136 };
137
138 let yaml = serde_yml::to_string(&frontmatter).map_err(|e| PromptFileError::Yaml(e.to_string()))?;
139
140 let file_content =
141 if self.body.is_empty() { format!("---\n{yaml}---\n") } else { format!("---\n{yaml}---\n{}\n", self.body) };
142 fs::write(path, file_content)?;
143 Ok(())
144 }
145
146 pub fn confidence(&self) -> f64 {
148 f64::from(self.helpful) / (f64::from(self.helpful) + f64::from(self.harmful) + 1.0)
149 }
150
151 fn parse_frontmatter(content: &str) -> Result<(PromptFrontmatter, String), PromptFileError> {
153 let (yaml_str, body) =
154 utils::markdown_file::split_frontmatter(content).ok_or(PromptFileError::MissingFrontmatter)?;
155
156 let frontmatter: PromptFrontmatter =
157 serde_yml::from_str(yaml_str).map_err(|e| PromptFileError::Yaml(e.to_string()))?;
158
159 Ok((frontmatter, body.to_string()))
160 }
161}
162
163#[derive(Debug, Clone, Default)]
165pub struct PromptTriggers {
166 patterns: Vec<String>,
167 globs: Option<GlobSet>,
168}
169
170impl PromptTriggers {
171 fn new(glob_patterns: Vec<String>) -> Result<Self, PromptFileError> {
172 if glob_patterns.is_empty() {
173 return Ok(Self { patterns: Vec::new(), globs: None });
174 }
175
176 let mut builder = GlobSetBuilder::new();
177 for pattern in &glob_patterns {
178 let glob = Glob::new(pattern)
179 .map_err(|e| PromptFileError::InvalidTriggerGlob { pattern: pattern.clone(), error: e.to_string() })?;
180 builder.add(glob);
181 }
182
183 let globs = builder.build().map_err(|e| PromptFileError::InvalidTriggerGlob {
184 pattern: glob_patterns.join(", "),
185 error: e.to_string(),
186 })?;
187
188 Ok(Self { patterns: glob_patterns, globs: Some(globs) })
189 }
190
191 pub fn patterns(&self) -> &[String] {
192 &self.patterns
193 }
194
195 pub fn is_empty(&self) -> bool {
196 self.globs.is_none()
197 }
198
199 pub fn matches_read(&self, relative_path: &str) -> bool {
201 self.globs.as_ref().is_some_and(|gs| gs.is_match(relative_path))
202 }
203}
204
205#[derive(Debug)]
206pub enum PromptFileError {
207 Io(std::io::Error),
208 Yaml(String),
209 MissingFrontmatter,
210 MissingDescription { name: String },
211 NoActivationSurface { name: String },
212 InvalidTriggerGlob { pattern: String, error: String },
213 NotFound(String),
214 NotAgentAuthored(String),
215}
216
217impl Display for PromptFileError {
218 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
219 match self {
220 PromptFileError::Io(e) => write!(f, "IO error: {e}"),
221 PromptFileError::Yaml(e) => write!(f, "YAML error: {e}"),
222 PromptFileError::MissingFrontmatter => write!(f, "missing YAML frontmatter"),
223 PromptFileError::MissingDescription { name } => {
224 write!(f, "skill '{name}' has an empty description")
225 }
226 PromptFileError::NoActivationSurface { name } => {
227 write!(f, "skill '{name}' must have at least one of: user-invocable, agent-invocable, or triggers.read")
228 }
229 PromptFileError::InvalidTriggerGlob { pattern, error } => {
230 write!(f, "invalid trigger glob '{pattern}': {error}")
231 }
232 PromptFileError::NotFound(name) => write!(f, "skill not found: {name}"),
233 PromptFileError::NotAgentAuthored(name) => {
234 write!(f, "skill '{name}' is not agent-authored and cannot be modified")
235 }
236 }
237 }
238}
239
240impl std::error::Error for PromptFileError {
241 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
242 match self {
243 PromptFileError::Io(e) => Some(e),
244 _ => None,
245 }
246 }
247}
248
249impl From<std::io::Error> for PromptFileError {
250 fn from(e: std::io::Error) -> Self {
251 PromptFileError::Io(e)
252 }
253}
254
255#[expect(clippy::trivially_copy_pass_by_ref)]
256fn not(b: &bool) -> bool {
257 !b
258}
259
260#[expect(clippy::trivially_copy_pass_by_ref)]
261fn zero(n: &u32) -> bool {
262 *n == 0
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268 use tempfile::TempDir;
269
270 fn minimal_frontmatter(description: &str) -> PromptFrontmatter {
271 PromptFrontmatter {
272 description: description.to_string(),
273 name: None,
274 user_invocable: false,
275 agent_invocable: false,
276 argument_hint: None,
277 tags: vec![],
278 triggers: None,
279 agent_authored: false,
280 helpful: 0,
281 harmful: 0,
282 }
283 }
284
285 #[test]
286 fn frontmatter_serde_roundtrip() {
287 let fm = minimal_frontmatter("A simple skill");
288
289 let yaml = serde_yml::to_string(&fm).unwrap();
290 let parsed: PromptFrontmatter = serde_yml::from_str(&yaml).unwrap();
291 assert_eq!(parsed.description, "A simple skill");
292 assert!(parsed.tags.is_empty());
293 assert!(!parsed.agent_authored);
294 }
295
296 #[test]
297 fn frontmatter_serde_with_all_fields() {
298 let mut fm = minimal_frontmatter("A full skill");
299 fm.tags = vec!["convention".to_string(), "testing".to_string()];
300 fm.agent_authored = true;
301 fm.helpful = 5;
302 fm.harmful = 2;
303
304 let yaml = serde_yml::to_string(&fm).unwrap();
305 let parsed: PromptFrontmatter = serde_yml::from_str(&yaml).unwrap();
306 assert_eq!(parsed.description, "A full skill");
307 assert_eq!(parsed.tags, vec!["convention", "testing"]);
308 assert!(parsed.agent_authored);
309 assert_eq!(parsed.helpful, 5);
310 assert_eq!(parsed.harmful, 2);
311 }
312
313 #[test]
314 fn backward_compat_old_frontmatter() {
315 let yaml = "description: An old skill\n";
316 let parsed: PromptFrontmatter = serde_yml::from_str(yaml).unwrap();
317 assert_eq!(parsed.description, "An old skill");
318 assert!(parsed.tags.is_empty());
319 assert!(!parsed.agent_authored);
320 assert_eq!(parsed.helpful, 0);
321 assert_eq!(parsed.harmful, 0);
322 }
323
324 #[test]
325 fn confidence() {
326 let pf = |helpful, harmful| PromptFile {
327 name: String::new(),
328 description: "test".to_string(),
329 body: String::new(),
330 path: PathBuf::new(),
331 user_invocable: false,
332 agent_invocable: false,
333 argument_hint: None,
334 tags: vec![],
335 triggers: PromptTriggers::default(),
336 agent_authored: true,
337 helpful,
338 harmful,
339 };
340
341 assert!((pf(0, 0).confidence() - 0.0).abs() < f64::EPSILON);
342 assert!((pf(7, 1).confidence() - 7.0 / 9.0).abs() < f64::EPSILON);
343 assert!((pf(0, 5).confidence() - 0.0).abs() < f64::EPSILON);
344 assert!((pf(3, 0).confidence() - 3.0 / 4.0).abs() < f64::EPSILON);
345 }
346
347 #[test]
348 fn parse_frontmatter_from_string() {
349 let content = "---\ndescription: Test skill\ntags:\n - rust\nagent_authored: true\nhelpful: 3\nharmful: 1\n---\n# My Skill\n\nSome content here.";
350 let (fm, body) = PromptFile::parse_frontmatter(content).unwrap();
351 assert_eq!(fm.description, "Test skill");
352 assert_eq!(fm.tags, vec!["rust"]);
353 assert!(fm.agent_authored);
354 assert_eq!(fm.helpful, 3);
355 assert_eq!(fm.harmful, 1);
356 assert!(body.contains("# My Skill"));
357 assert!(body.contains("Some content here."));
358 }
359
360 #[test]
361 fn write_and_parse_roundtrip() {
362 let temp_dir = TempDir::new().unwrap();
363 let skill_path = temp_dir.path().join("my-skill").join(SKILL_FILENAME);
364
365 let prompt = PromptFile {
366 name: "my-skill".to_string(),
367 description: "Test skill".to_string(),
368 body: "# My Skill\n\nSome content here.".to_string(),
369 path: skill_path.clone(),
370 user_invocable: false,
371 agent_invocable: true,
372 argument_hint: None,
373 tags: vec!["convention".to_string()],
374 triggers: PromptTriggers::default(),
375 agent_authored: true,
376 helpful: 2,
377 harmful: 1,
378 };
379 prompt.write(&skill_path).unwrap();
380
381 let parsed = PromptFile::parse(&skill_path).unwrap();
382 assert_eq!(parsed.description, "Test skill");
383 assert_eq!(parsed.tags, vec!["convention"]);
384 assert!(parsed.agent_authored);
385 assert_eq!(parsed.helpful, 2);
386 assert_eq!(parsed.harmful, 1);
387 assert!(parsed.body.contains("# My Skill"));
388 assert!(parsed.body.contains("Some content here."));
389 }
390
391 #[test]
392 fn write_empty_body() {
393 let temp_dir = TempDir::new().unwrap();
394 let skill_path = temp_dir.path().join("empty-body").join(SKILL_FILENAME);
395
396 let prompt = PromptFile {
397 name: "empty-body".to_string(),
398 description: "Empty".to_string(),
399 body: String::new(),
400 path: skill_path.clone(),
401 user_invocable: false,
402 agent_invocable: true,
403 argument_hint: None,
404 tags: vec![],
405 triggers: PromptTriggers::default(),
406 agent_authored: true,
407 helpful: 0,
408 harmful: 0,
409 };
410 prompt.write(&skill_path).unwrap();
411
412 let raw = std::fs::read_to_string(&skill_path).unwrap();
413 assert!(raw.starts_with("---\n"));
414 assert!(raw.contains("description: Empty"));
415 }
416
417 #[test]
418 fn write_and_parse_roundtrip_with_triggers() {
419 let temp_dir = TempDir::new().unwrap();
420 let skill_path = temp_dir.path().join("rust-rules").join(SKILL_FILENAME);
421
422 let triggers = PromptTriggers::new(vec!["src/**/*.rs".to_string(), "tests/**/*.rs".to_string()]).unwrap();
423
424 let prompt = PromptFile {
425 name: "rust-rules".to_string(),
426 description: "Rust conventions".to_string(),
427 body: "Follow Rust conventions.".to_string(),
428 path: skill_path.clone(),
429 user_invocable: false,
430 agent_invocable: false,
431 argument_hint: None,
432 tags: vec![],
433 triggers,
434 agent_authored: false,
435 helpful: 0,
436 harmful: 0,
437 };
438 prompt.write(&skill_path).unwrap();
439
440 let parsed = PromptFile::parse(&skill_path).unwrap();
441 assert_eq!(parsed.description, "Rust conventions");
442 assert!(!parsed.triggers.is_empty());
443 assert!(parsed.triggers.matches_read("src/main.rs"));
444 assert!(parsed.triggers.matches_read("tests/integration.rs"));
445 assert!(!parsed.triggers.matches_read("README.md"));
446 assert_eq!(parsed.triggers.patterns(), &["src/**/*.rs", "tests/**/*.rs"]);
447 }
448
449 #[test]
450 fn write_rejects_empty_description() {
451 let temp_dir = TempDir::new().unwrap();
452 let skill_path = temp_dir.path().join("bad").join(SKILL_FILENAME);
453
454 let prompt = PromptFile {
455 name: "bad".to_string(),
456 description: String::new(),
457 body: "content".to_string(),
458 path: skill_path.clone(),
459 user_invocable: true,
460 agent_invocable: false,
461 argument_hint: None,
462 tags: vec![],
463 triggers: PromptTriggers::default(),
464 agent_authored: true,
465 helpful: 0,
466 harmful: 0,
467 };
468 let result = prompt.write(&skill_path);
469 assert!(matches!(result, Err(PromptFileError::MissingDescription { .. })));
470 }
471
472 #[test]
473 fn write_rejects_no_activation_surface() {
474 let temp_dir = TempDir::new().unwrap();
475 let skill_path = temp_dir.path().join("noop").join(SKILL_FILENAME);
476
477 let prompt = PromptFile {
478 name: "noop".to_string(),
479 description: "Does nothing".to_string(),
480 body: "content".to_string(),
481 path: skill_path.clone(),
482 user_invocable: false,
483 agent_invocable: false,
484 argument_hint: None,
485 tags: vec![],
486 triggers: PromptTriggers::default(),
487 agent_authored: true,
488 helpful: 0,
489 harmful: 0,
490 };
491 let result = prompt.write(&skill_path);
492 assert!(matches!(result, Err(PromptFileError::NoActivationSurface { .. })));
493 }
494
495 #[test]
496 fn skip_serializing_defaults() {
497 let fm = minimal_frontmatter("Minimal");
498
499 let yaml = serde_yml::to_string(&fm).unwrap();
500 assert!(!yaml.contains("tags"));
501 assert!(!yaml.contains("agent_authored"));
502 assert!(!yaml.contains("helpful"));
503 assert!(!yaml.contains("harmful"));
504 }
505}