1use super::evolution::EvolutionEvent;
4use super::mcp::McpRequirement;
5use super::types::{Category, ContentMode, HostId, Priority, Provenance, TriggerKind, TrustLevel};
6use chrono::{DateTime, Utc};
7use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Skill {
13 #[serde(flatten)]
14 pub manifest: SkillManifest,
15
16 #[serde(default, skip_serializing_if = "Option::is_none")]
18 pub content_sha256: Option<String>,
19
20 #[serde(default)]
22 pub trust_level: TrustLevel,
23
24 #[serde(default, skip_serializing_if = "Vec::is_empty")]
26 pub capabilities_declared: Vec<String>,
27
28 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub publisher_signature: Option<String>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct SkillManifest {
38 pub name: String,
39 pub version: String,
40 pub publisher: String,
41 pub description: String,
42 pub category: Category,
43
44 #[serde(default)]
47 pub provenance: Provenance,
48
49 #[serde(default, skip_serializing_if = "Vec::is_empty")]
50 pub hosts: Vec<HostId>,
51
52 pub content: Content,
53
54 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub requires: Vec<Requirement>,
56
57 #[serde(default, skip_serializing_if = "Vec::is_empty")]
58 pub tags: Vec<String>,
59
60 #[serde(default, skip_serializing_if = "Vec::is_empty")]
61 pub triggers: Vec<Trigger>,
62
63 #[serde(default)]
64 pub priority: Priority,
65
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub evolution_log: Vec<EvolutionEvent>,
69
70 #[serde(default, skip_serializing_if = "Vec::is_empty")]
74 pub transfer_chain: Vec<String>,
75
76 #[serde(default, skip_serializing_if = "Vec::is_empty")]
82 pub mcp_requirements: Vec<McpRequirement>,
83
84 #[serde(default)]
88 pub updated_at: DateTime<Utc>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct Content {
93 pub r#abstract: String,
95
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub context: Option<String>,
99
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub procedure: Option<Procedure>,
102
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub command: Option<String>,
105
106 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub note: Option<String>,
110}
111
112impl Content {
113 pub fn mode(&self) -> Option<ContentMode> {
114 match (
115 self.context.is_some(),
116 self.procedure.is_some(),
117 self.command.is_some(),
118 self.note.is_some(),
119 ) {
120 (true, false, false, false) => Some(ContentMode::Context),
121 (false, true, false, false) => Some(ContentMode::Workflow),
122 (false, false, true, false) => Some(ContentMode::Command),
123 (false, false, false, true) => Some(ContentMode::Note),
124 _ => None,
125 }
126 }
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct Procedure {
131 #[serde(default, skip_serializing_if = "Vec::is_empty")]
132 pub variables: Vec<Variable>,
133 pub steps: Vec<ProcedureStep>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct Variable {
138 pub name: String,
139 #[serde(rename = "type")]
140 pub var_type: String,
141 #[serde(default)]
142 pub required: bool,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub default: Option<serde_yaml_ng::Value>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub description: Option<String>,
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct ProcedureStep {
151 pub description: String,
152
153 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub tool: Option<String>,
157
158 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub intent: Option<String>,
164
165 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub tool_hint: Option<String>,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct Trigger {
174 #[serde(rename = "type")]
175 pub kind: TriggerKind,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub pattern: Option<String>,
178}
179
180impl Trigger {
181 pub fn exact_keyword(&self) -> Option<&str> {
183 if matches!(self.kind, TriggerKind::Keyword) {
184 self.pattern.as_deref()
185 } else {
186 None
187 }
188 }
189}
190
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct Requirement {
193 pub name: String,
194 #[serde(default = "default_any_version")]
195 pub version: String,
196}
197
198fn default_any_version() -> String {
199 "*".to_string()
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn full_manifest_roundtrips() {
208 let yaml = r#"
209name: research-prices
210version: 1.0.0
211publisher: human:david
212description: Search product prices
213category: workflow
214hosts: [mur-agent]
215content:
216 abstract: Searches product prices.
217 procedure:
218 variables:
219 - name: product_name
220 type: string
221 required: true
222 steps:
223 - description: Navigate
224 tool: browser.navigate
225triggers:
226 - type: command
227 pattern: /research-prices
228priority: normal
229"#;
230 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
231 assert_eq!(m.name, "research-prices");
232 assert_eq!(m.category, Category::Workflow);
233 assert_eq!(m.content.mode(), Some(ContentMode::Workflow));
234 let back = serde_yaml_ng::to_string(&m).unwrap();
235 let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
236 assert_eq!(m2.name, m.name);
237 }
238
239 #[test]
240 fn context_mode_detected() {
241 let c = Content {
242 r#abstract: "a".into(),
243 context: Some("ctx".into()),
244 procedure: None,
245 command: None,
246 note: None,
247 };
248 assert_eq!(c.mode(), Some(ContentMode::Context));
249 }
250
251 #[test]
252 fn empty_content_returns_no_mode() {
253 let c = Content {
254 r#abstract: "a".into(),
255 context: None,
256 procedure: None,
257 command: None,
258 note: None,
259 };
260 assert_eq!(c.mode(), None);
261 }
262
263 #[test]
264 fn mode_returns_note_when_only_note_populated() {
265 let c = Content {
266 r#abstract: "a".into(),
267 context: None,
268 procedure: None,
269 command: None,
270 note: Some("# body".into()),
271 };
272 assert_eq!(c.mode(), Some(ContentMode::Note));
273 }
274
275 #[test]
276 fn mode_returns_none_when_note_and_context_both_populated() {
277 let c = Content {
278 r#abstract: "a".into(),
279 context: Some("ctx".into()),
280 procedure: None,
281 command: None,
282 note: Some("# body".into()),
283 };
284 assert_eq!(c.mode(), None);
285 }
286
287 #[test]
288 fn skill_without_evolution_log_defaults_to_empty() {
289 let yaml = r#"
291name: no-evol
292version: 0.1.0
293publisher: human:test
294description: test
295category: workflow
296content:
297 abstract: test
298"#;
299 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
300 assert!(m.evolution_log.is_empty());
301 }
302
303 #[test]
304 fn skill_with_evolution_log_roundtrips() {
305 let yaml = r#"
306name: with-evol
307version: 0.1.0
308publisher: human:test
309description: test
310category: workflow
311content:
312 abstract: test
313evolution_log:
314 - version: "0.1.0"
315 generation: 0
316 source: "human:test"
317 changes: "Initial"
318 timestamp: "2026-01-01T00:00:00Z"
319"#;
320 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
321 assert_eq!(m.evolution_log.len(), 1);
322 assert_eq!(m.evolution_log[0].version, "0.1.0");
323 let back = serde_yaml_ng::to_string(&m).unwrap();
325 let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
326 assert_eq!(m2.evolution_log.len(), 1);
327 assert_eq!(m2.evolution_log[0].generation, 0);
328 }
329
330 #[test]
331 fn exact_keyword_returns_pattern_for_keyword_triggers() {
332 let t = Trigger {
333 kind: TriggerKind::Keyword,
334 pattern: Some("search".into()),
335 };
336 assert_eq!(t.exact_keyword(), Some("search"));
337 }
338
339 #[test]
340 fn exact_keyword_returns_none_for_non_keyword_triggers() {
341 let t = Trigger {
342 kind: TriggerKind::Command,
343 pattern: Some("run".into()),
344 };
345 assert_eq!(t.exact_keyword(), None);
346
347 let t = Trigger {
348 kind: TriggerKind::SessionStart,
349 pattern: None,
350 };
351 assert_eq!(t.exact_keyword(), None);
352
353 let t = Trigger {
354 kind: TriggerKind::Manual,
355 pattern: None,
356 };
357 assert_eq!(t.exact_keyword(), None);
358 }
359
360 #[test]
361 fn exact_keyword_returns_none_when_pattern_is_none() {
362 let t = Trigger {
363 kind: TriggerKind::Keyword,
364 pattern: None,
365 };
366 assert_eq!(t.exact_keyword(), None);
367 }
368}