1use serde::{Deserialize, Serialize};
9
10#[derive(
12 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
13)]
14#[serde(rename_all = "snake_case")]
15pub enum SkillSpecContext {
16 #[default]
18 Inline,
19 Fork,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
25#[serde(deny_unknown_fields)]
26pub struct SkillArgumentSpec {
27 pub name: String,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub description: Option<String>,
30 #[serde(default)]
31 pub required: bool,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
36#[serde(deny_unknown_fields)]
37pub struct SkillSpec {
38 pub id: String,
40 pub name: String,
42 pub description: String,
44 pub instructions_md: String,
47 #[serde(default, skip_serializing_if = "Vec::is_empty")]
49 pub allowed_tools: Vec<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub when_to_use: Option<String>,
52 #[serde(default, skip_serializing_if = "Vec::is_empty")]
53 pub arguments: Vec<SkillArgumentSpec>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub argument_hint: Option<String>,
56 #[serde(default = "default_true")]
57 pub user_invocable: bool,
58 #[serde(default = "default_true")]
59 pub model_invocable: bool,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub model_override: Option<String>,
62 #[serde(default)]
63 pub context: SkillSpecContext,
64 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub paths: Vec<String>,
69}
70
71fn default_true() -> bool {
72 true
73}
74
75impl Default for SkillSpec {
76 fn default() -> Self {
77 Self {
78 id: String::new(),
79 name: String::new(),
80 description: String::new(),
81 instructions_md: String::new(),
82 allowed_tools: Vec::new(),
83 when_to_use: None,
84 arguments: Vec::new(),
85 argument_hint: None,
86 user_invocable: true,
87 model_invocable: true,
88 model_override: None,
89 context: SkillSpecContext::Inline,
90 paths: Vec::new(),
91 }
92 }
93}
94
95pub trait PreparedSkillSpecs: Send {
102 fn commit(self: Box<Self>);
103}
104
105pub trait SkillSpecSink: Send + Sync {
108 fn prepare_skill_specs(
110 &self,
111 specs: Vec<SkillSpec>,
112 ) -> Result<Box<dyn PreparedSkillSpecs>, String>;
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118 use serde_json::json;
119
120 #[test]
121 fn round_trip_preserves_fields() {
122 let spec = SkillSpec {
123 id: "db-management".into(),
124 name: "Database Management".into(),
125 description: "Helps with database operations".into(),
126 instructions_md: "Inspect schema before writing queries.".into(),
127 allowed_tools: vec!["db_query".into()],
128 when_to_use: Some("When the user asks about a database".into()),
129 arguments: vec![SkillArgumentSpec {
130 name: "dialect".into(),
131 description: Some("SQL dialect".into()),
132 required: false,
133 }],
134 argument_hint: Some("dialect=postgres".into()),
135 user_invocable: true,
136 model_invocable: false,
137 model_override: Some("fast".into()),
138 context: SkillSpecContext::Fork,
139 paths: vec!["migrations/**".into()],
140 };
141 let value = serde_json::to_value(&spec).unwrap();
142 let back: SkillSpec = serde_json::from_value(value).unwrap();
143 assert_eq!(spec, back);
144 }
145
146 #[test]
147 fn serde_defaults_match_runtime_defaults() {
148 let spec: SkillSpec = serde_json::from_value(json!({
149 "id": "db-management",
150 "name": "Database Management",
151 "description": "Helps with database operations",
152 "instructions_md": "Inspect schema before writing queries."
153 }))
154 .unwrap();
155 assert!(spec.user_invocable);
156 assert!(spec.model_invocable);
157 assert_eq!(spec.context, SkillSpecContext::Inline);
158 assert!(spec.allowed_tools.is_empty());
159 assert!(spec.paths.is_empty());
160 }
161
162 #[test]
163 fn unknown_field_is_rejected() {
164 let bad = json!({
165 "id": "x",
166 "name": "x",
167 "description": "x",
168 "instructions_md": "x",
169 "garbage": true
170 });
171 assert!(serde_json::from_value::<SkillSpec>(bad).is_err());
172 }
173}