Skip to main content

clawft_types/
skill.rs

1//! Unified skill definition types.
2//!
3//! Skills are reusable LLM instruction bundles that can be loaded from two
4//! formats:
5//!
6//! - **Legacy**: `skill.json` metadata + `prompt.md` instructions (per the
7//!   original [`SkillsLoader`](crate) convention).
8//! - **SKILL.md**: A single markdown file with YAML frontmatter containing
9//!   metadata and the body containing LLM instructions.
10//!
11//! The [`SkillDefinition`] struct is the unified representation regardless
12//! of source format.
13
14use std::collections::HashMap;
15use std::path::PathBuf;
16
17use serde::{Deserialize, Serialize};
18
19/// How the skill was loaded.
20#[non_exhaustive]
21#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
22pub enum SkillFormat {
23    /// Legacy: `skill.json` + `prompt.md`.
24    #[default]
25    Legacy,
26    /// `SKILL.md` with YAML frontmatter.
27    SkillMd,
28}
29
30/// Unified skill definition.
31///
32/// Combines metadata (name, description, variables, tool permissions) with
33/// the LLM instructions body. The [`format`](SkillDefinition::format) field
34/// records which on-disk layout the skill was loaded from.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SkillDefinition {
37    /// Skill identifier (typically matches the directory name).
38    pub name: String,
39
40    /// Human-readable description shown in skill listings.
41    pub description: String,
42
43    /// Semantic version of the skill definition.
44    #[serde(default)]
45    pub version: String,
46
47    /// Template variable names expected by the skill prompt.
48    #[serde(default)]
49    pub variables: Vec<String>,
50
51    /// Hint text shown for the slash-command argument (e.g. `"PR URL or number"`).
52    #[serde(default)]
53    pub argument_hint: Option<String>,
54
55    /// Tools the skill is allowed to use.
56    #[serde(default)]
57    pub allowed_tools: Vec<String>,
58
59    /// Whether end-users can invoke this skill directly (e.g. via `/skill`).
60    #[serde(default)]
61    pub user_invocable: bool,
62
63    /// If true, the LLM cannot invoke this skill -- only humans can.
64    #[serde(default)]
65    pub disable_model_invocation: bool,
66
67    /// The actual LLM instructions (markdown body).
68    #[serde(skip)]
69    pub instructions: String,
70
71    /// Format this skill was loaded from.
72    #[serde(skip)]
73    pub format: SkillFormat,
74
75    /// Source path on disk.
76    #[serde(skip)]
77    pub source_path: Option<PathBuf>,
78
79    /// Additional metadata not captured by named fields.
80    #[serde(default, flatten)]
81    pub metadata: HashMap<String, serde_json::Value>,
82}
83
84impl SkillDefinition {
85    /// Create a minimal skill definition (for testing or built-in skills).
86    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
87        Self {
88            name: name.into(),
89            description: description.into(),
90            version: String::new(),
91            variables: Vec::new(),
92            argument_hint: None,
93            allowed_tools: Vec::new(),
94            user_invocable: false,
95            disable_model_invocation: false,
96            instructions: String::new(),
97            format: SkillFormat::default(),
98            source_path: None,
99            metadata: HashMap::new(),
100        }
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn skill_definition_new() {
110        let skill = SkillDefinition::new("test", "A test skill");
111        assert_eq!(skill.name, "test");
112        assert_eq!(skill.description, "A test skill");
113        assert!(skill.version.is_empty());
114        assert!(skill.variables.is_empty());
115        assert!(skill.instructions.is_empty());
116        assert_eq!(skill.format, SkillFormat::Legacy);
117        assert!(skill.source_path.is_none());
118    }
119
120    #[test]
121    fn skill_format_default() {
122        assert_eq!(SkillFormat::default(), SkillFormat::Legacy);
123    }
124
125    #[test]
126    fn skill_definition_serde_roundtrip() {
127        let mut skill = SkillDefinition::new("roundtrip", "Roundtrip test");
128        skill.version = "2.0.0".into();
129        skill.variables = vec!["var1".into(), "var2".into()];
130        skill.user_invocable = true;
131        skill.instructions = "These should be skipped".into();
132
133        let json = serde_json::to_string(&skill).unwrap();
134        let restored: SkillDefinition = serde_json::from_str(&json).unwrap();
135
136        assert_eq!(restored.name, "roundtrip");
137        assert_eq!(restored.version, "2.0.0");
138        assert_eq!(restored.variables, vec!["var1", "var2"]);
139        assert!(restored.user_invocable);
140        // instructions is #[serde(skip)] so it should be empty after deser
141        assert!(restored.instructions.is_empty());
142        // format is #[serde(skip)]
143        assert_eq!(restored.format, SkillFormat::Legacy);
144    }
145
146    #[test]
147    fn skill_definition_from_json_with_extras() {
148        let json = r#"{
149            "name": "extra",
150            "description": "Has extra fields",
151            "custom_field": "custom_value",
152            "priority": 5
153        }"#;
154        let skill: SkillDefinition = serde_json::from_str(json).unwrap();
155        assert_eq!(skill.name, "extra");
156        assert_eq!(
157            skill.metadata.get("custom_field"),
158            Some(&serde_json::json!("custom_value"))
159        );
160        assert_eq!(skill.metadata.get("priority"), Some(&serde_json::json!(5)));
161    }
162}