use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "kebab-case")]
pub struct SkillFrontmatter {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub argument_hint: Option<String>,
#[serde(default)]
pub disable_model_invocation: bool,
#[serde(default = "default_true")]
pub user_invocable: bool,
#[serde(default)]
pub allowed_tools: Option<Vec<String>>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub context: Option<SkillContext>,
#[serde(default)]
pub agent: Option<String>,
#[serde(default)]
pub hooks: Option<SkillHooks>,
#[serde(default)]
pub version: Option<String>,
#[serde(default)]
pub author: Option<String>,
#[serde(default)]
pub price: Option<PriceModel>,
#[serde(default)]
pub requires_approval: bool,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub min_thulp_version: Option<String>,
}
fn default_true() -> bool {
true
}
impl Default for SkillFrontmatter {
fn default() -> Self {
Self {
name: None,
description: None,
argument_hint: None,
disable_model_invocation: false,
user_invocable: true, allowed_tools: None,
model: None,
context: None,
agent: None,
hooks: None,
version: None,
author: None,
price: None,
requires_approval: false,
tags: Vec::new(),
min_thulp_version: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum SkillContext {
#[default]
Inline,
Fork,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum PriceModel {
Free,
PerCall(f64),
Subscription {
monthly: f64,
},
Custom(String),
}
#[allow(clippy::derivable_impls)]
impl Default for PriceModel {
fn default() -> Self {
Self::Free
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
pub struct SkillHooks {
#[serde(default)]
pub pre_execute: Option<String>,
#[serde(default)]
pub post_execute: Option<String>,
#[serde(default)]
pub on_error: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_frontmatter() {
let fm = SkillFrontmatter::default();
assert!(fm.name.is_none());
assert!(fm.user_invocable); assert!(!fm.disable_model_invocation);
assert!(!fm.requires_approval);
}
#[test]
fn test_parse_minimal_frontmatter() {
let yaml = r#"
name: test-skill
description: A test skill
"#;
let fm: SkillFrontmatter = serde_yaml::from_str(yaml).unwrap();
assert_eq!(fm.name, Some("test-skill".to_string()));
assert_eq!(fm.description, Some("A test skill".to_string()));
}
#[test]
fn test_parse_full_frontmatter() {
let yaml = r#"
name: advanced-skill
description: An advanced skill with all options
argument-hint: <file-path>
disable-model-invocation: true
user-invocable: false
allowed-tools:
- Read
- Write
- Bash
model: claude-sonnet-4-20250514
context: fork
agent: code-reviewer
requires-approval: true
tags:
- code
- review
version: 1.2.3
author: dirmacs
"#;
let fm: SkillFrontmatter = serde_yaml::from_str(yaml).unwrap();
assert_eq!(fm.name, Some("advanced-skill".to_string()));
assert!(fm.disable_model_invocation);
assert!(!fm.user_invocable);
assert_eq!(
fm.allowed_tools,
Some(vec![
"Read".to_string(),
"Write".to_string(),
"Bash".to_string()
])
);
assert_eq!(fm.context, Some(SkillContext::Fork));
assert_eq!(fm.agent, Some("code-reviewer".to_string()));
assert!(fm.requires_approval);
assert_eq!(fm.tags, vec!["code".to_string(), "review".to_string()]);
}
#[test]
fn test_skill_context_serialization() {
assert_eq!(
serde_yaml::to_string(&SkillContext::Fork).unwrap().trim(),
"fork"
);
assert_eq!(
serde_yaml::to_string(&SkillContext::Inline).unwrap().trim(),
"inline"
);
}
}