Skip to main content

thulp_skill_files/
frontmatter.rs

1//! YAML frontmatter types for SKILL.md files.
2//!
3//! This module defines the structure of the YAML frontmatter that appears
4//! at the top of SKILL.md files, between `---` delimiters.
5
6use serde::{Deserialize, Serialize};
7
8/// YAML frontmatter configuration for a skill file.
9///
10/// All fields are optional - skills can be defined with just content
11/// and no frontmatter at all.
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13#[serde(rename_all = "kebab-case")]
14pub struct SkillFrontmatter {
15    /// Display name for the skill (defaults to directory name).
16    #[serde(default)]
17    pub name: Option<String>,
18
19    /// What the skill does and when to use it.
20    #[serde(default)]
21    pub description: Option<String>,
22
23    /// Hint shown during autocomplete for expected arguments.
24    #[serde(default)]
25    pub argument_hint: Option<String>,
26
27    /// Prevent agent from automatically invoking this skill.
28    #[serde(default)]
29    pub disable_model_invocation: bool,
30
31    /// Whether users can invoke this skill directly (default: true).
32    #[serde(default = "default_true")]
33    pub user_invocable: bool,
34
35    /// Tools the skill is allowed to use.
36    /// If None, all tools are allowed.
37    #[serde(default)]
38    pub allowed_tools: Option<Vec<String>>,
39
40    /// Model to use when this skill is active.
41    #[serde(default)]
42    pub model: Option<String>,
43
44    /// Execution context: "fork" for subagent, "inline" for current context.
45    #[serde(default)]
46    pub context: Option<SkillContext>,
47
48    /// Which subagent type to use when context is "fork".
49    #[serde(default)]
50    pub agent: Option<String>,
51
52    /// Hooks scoped to this skill's lifecycle.
53    #[serde(default)]
54    pub hooks: Option<SkillHooks>,
55
56    // === DOT DOT Marketplace Extensions ===
57    /// Skill version (semver).
58    #[serde(default)]
59    pub version: Option<String>,
60
61    /// Author identifier.
62    #[serde(default)]
63    pub author: Option<String>,
64
65    /// Pricing model for marketplace.
66    #[serde(default)]
67    pub price: Option<PriceModel>,
68
69    /// Whether this skill requires user approval before execution.
70    #[serde(default)]
71    pub requires_approval: bool,
72
73    /// Tags for categorization.
74    #[serde(default)]
75    pub tags: Vec<String>,
76
77    /// Minimum Thulp version required.
78    #[serde(default)]
79    pub min_thulp_version: Option<String>,
80}
81
82fn default_true() -> bool {
83    true
84}
85
86impl Default for SkillFrontmatter {
87    fn default() -> Self {
88        Self {
89            name: None,
90            description: None,
91            argument_hint: None,
92            disable_model_invocation: false,
93            user_invocable: true, // Default to true
94            allowed_tools: None,
95            model: None,
96            context: None,
97            agent: None,
98            hooks: None,
99            version: None,
100            author: None,
101            price: None,
102            requires_approval: false,
103            tags: Vec::new(),
104            min_thulp_version: None,
105        }
106    }
107}
108
109/// Execution context for skills.
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
111#[serde(rename_all = "lowercase")]
112pub enum SkillContext {
113    /// Run in current conversation context.
114    #[default]
115    Inline,
116    /// Run in isolated subagent context.
117    Fork,
118}
119
120/// Pricing model for marketplace skills.
121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
122#[serde(untagged)]
123pub enum PriceModel {
124    /// Free skill (represented as "free" string).
125    Free,
126    /// Fixed price per call.
127    PerCall(f64),
128    /// Subscription-based pricing.
129    Subscription {
130        /// Monthly subscription cost.
131        monthly: f64,
132    },
133    /// Custom pricing string.
134    Custom(String),
135}
136
137// Note: Can't use #[derive(Default)] with #[serde(untagged)] - serde parsing breaks
138#[allow(clippy::derivable_impls)]
139impl Default for PriceModel {
140    fn default() -> Self {
141        Self::Free
142    }
143}
144
145/// Lifecycle hooks for skills.
146#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
147pub struct SkillHooks {
148    /// Run before skill starts.
149    #[serde(default)]
150    pub pre_execute: Option<String>,
151
152    /// Run after skill completes.
153    #[serde(default)]
154    pub post_execute: Option<String>,
155
156    /// Run on error.
157    #[serde(default)]
158    pub on_error: Option<String>,
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn test_default_frontmatter() {
167        let fm = SkillFrontmatter::default();
168        assert!(fm.name.is_none());
169        assert!(fm.user_invocable); // Should default to true
170        assert!(!fm.disable_model_invocation);
171        assert!(!fm.requires_approval);
172    }
173
174    #[test]
175    fn test_parse_minimal_frontmatter() {
176        let yaml = r#"
177name: test-skill
178description: A test skill
179"#;
180        let fm: SkillFrontmatter = serde_yaml::from_str(yaml).unwrap();
181        assert_eq!(fm.name, Some("test-skill".to_string()));
182        assert_eq!(fm.description, Some("A test skill".to_string()));
183    }
184
185    #[test]
186    fn test_parse_full_frontmatter() {
187        let yaml = r#"
188name: advanced-skill
189description: An advanced skill with all options
190argument-hint: <file-path>
191disable-model-invocation: true
192user-invocable: false
193allowed-tools:
194  - Read
195  - Write
196  - Bash
197model: claude-sonnet-4-20250514
198context: fork
199agent: code-reviewer
200requires-approval: true
201tags:
202  - code
203  - review
204version: 1.2.3
205author: dirmacs
206"#;
207        let fm: SkillFrontmatter = serde_yaml::from_str(yaml).unwrap();
208        assert_eq!(fm.name, Some("advanced-skill".to_string()));
209        assert!(fm.disable_model_invocation);
210        assert!(!fm.user_invocable);
211        assert_eq!(
212            fm.allowed_tools,
213            Some(vec![
214                "Read".to_string(),
215                "Write".to_string(),
216                "Bash".to_string()
217            ])
218        );
219        assert_eq!(fm.context, Some(SkillContext::Fork));
220        assert_eq!(fm.agent, Some("code-reviewer".to_string()));
221        assert!(fm.requires_approval);
222        assert_eq!(fm.tags, vec!["code".to_string(), "review".to_string()]);
223    }
224
225    #[test]
226    fn test_skill_context_serialization() {
227        assert_eq!(
228            serde_yaml::to_string(&SkillContext::Fork).unwrap().trim(),
229            "fork"
230        );
231        assert_eq!(
232            serde_yaml::to_string(&SkillContext::Inline).unwrap().trim(),
233            "inline"
234        );
235    }
236}