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, schemars::JsonSchema)]
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, schemars::JsonSchema)]
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, schemars::JsonSchema)]
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, schemars::JsonSchema)]
138pub struct RetryConfig {
139 pub max_retries: u32,
140 #[serde(default)]
141 pub backoff_secs: Option<u64>,
142}
143
144#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
146#[serde(rename_all = "lowercase")]
147pub enum FailureAction {
148 Skip,
150 #[default]
152 Abort,
153 Retry,
155}
156
157#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
158pub struct Variable {
159 pub name: String,
160 #[serde(rename = "type", default)]
161 pub var_type: VarType,
162 #[serde(default)]
163 pub required: bool,
164 #[serde(
168 default,
169 alias = "default_value",
170 skip_serializing_if = "Option::is_none"
171 )]
172 pub default: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub description: Option<String>,
175 #[serde(default, skip_serializing_if = "Vec::is_empty")]
177 pub choices: Vec<String>,
178}
179
180#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
183#[serde(rename_all = "lowercase")]
184pub enum VarType {
185 #[default]
186 String,
187 Path,
188 Url,
189 Number,
190 Bool,
191 Array,
193}
194
195impl std::fmt::Display for VarType {
196 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 match self {
198 VarType::String => write!(f, "string"),
199 VarType::Path => write!(f, "path"),
200 VarType::Url => write!(f, "url"),
201 VarType::Number => write!(f, "number"),
202 VarType::Bool => write!(f, "bool"),
203 VarType::Array => write!(f, "array"),
204 }
205 }
206}
207
208#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
209pub struct ProcedureStep {
210 pub description: String,
211
212 #[serde(default, skip_serializing_if = "Option::is_none")]
215 pub tool: Option<String>,
216
217 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub intent: Option<String>,
223
224 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub tool_hint: Option<String>,
229
230 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub id: Option<String>,
236
237 #[serde(default, skip_serializing_if = "Vec::is_empty")]
240 pub depends_on: Vec<String>,
241
242 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub command: Option<String>,
247
248 #[serde(default)]
249 pub on_failure: FailureAction,
250
251 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub retry: Option<RetryConfig>,
253
254 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub timeout_secs: Option<u64>,
256
257 #[serde(default)]
261 pub needs_approval: bool,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
265pub struct Trigger {
266 #[serde(rename = "type")]
267 pub kind: TriggerKind,
268 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub pattern: Option<String>,
270}
271
272impl Trigger {
273 pub fn exact_keyword(&self) -> Option<&str> {
275 if matches!(self.kind, TriggerKind::Keyword) {
276 self.pattern.as_deref()
277 } else {
278 None
279 }
280 }
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
284pub struct Requirement {
285 pub name: String,
286 #[serde(default = "default_any_version")]
287 pub version: String,
288}
289
290fn default_any_version() -> String {
291 "*".to_string()
292}
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 #[test]
299 fn procedure_step_dag_fields_roundtrip() {
300 let yaml = r#"
301description: deploy the app
302command: "fly deploy --app {{app_name}}"
303id: deploy
304depends_on: [build, test]
305on_failure: retry
306retry:
307 max_retries: 2
308 backoff_secs: 5
309timeout_secs: 300
310needs_approval: true
311"#;
312 let step: ProcedureStep = serde_yaml_ng::from_str(yaml).unwrap();
313 assert_eq!(step.id.as_deref(), Some("deploy"));
314 assert_eq!(step.depends_on, vec!["build", "test"]);
315 assert_eq!(step.on_failure, FailureAction::Retry);
316 assert_eq!(step.retry.as_ref().unwrap().max_retries, 2);
317 assert_eq!(step.timeout_secs, Some(300));
318 assert!(step.needs_approval);
319
320 let legacy: ProcedureStep =
322 serde_yaml_ng::from_str("description: run tests\ntool: Bash\n").unwrap();
323 assert!(legacy.id.is_none());
324 assert!(legacy.depends_on.is_empty());
325 assert_eq!(legacy.on_failure, FailureAction::Abort);
326 assert!(!legacy.needs_approval);
327 }
328
329 #[test]
330 fn variable_accepts_legacy_default_value_alias() {
331 let v: Variable = serde_yaml_ng::from_str(
333 "name: app\ntype: string\nrequired: true\ndefault_value: my-api\n",
334 )
335 .unwrap();
336 assert_eq!(v.default.as_deref(), Some("my-api"));
337 assert_eq!(v.var_type, VarType::String);
338
339 let v2: Variable =
341 serde_yaml_ng::from_str("name: env\ntype: string\ndefault: prod\n").unwrap();
342 assert_eq!(v2.default.as_deref(), Some("prod"));
343 assert!(v2.choices.is_empty());
344 }
345
346 #[test]
347 fn variable_all_vartypes_parse() {
348 for t in ["string", "path", "url", "number", "bool", "array"] {
349 let v: Variable = serde_yaml_ng::from_str(&format!("name: x\ntype: {t}\n")).unwrap();
350 assert_eq!(v.var_type.to_string(), t);
351 }
352 }
353
354 #[test]
355 fn full_manifest_roundtrips() {
356 let yaml = r#"
357name: research-prices
358version: 1.0.0
359publisher: human:david
360description: Search product prices
361category: workflow
362hosts: [mur-agent]
363content:
364 abstract: Searches product prices.
365 procedure:
366 variables:
367 - name: product_name
368 type: string
369 required: true
370 steps:
371 - description: Navigate
372 tool: browser.navigate
373triggers:
374 - type: command
375 pattern: /research-prices
376priority: normal
377"#;
378 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
379 assert_eq!(m.name, "research-prices");
380 assert_eq!(m.category, Category::Workflow);
381 assert_eq!(m.content.mode(), Some(ContentMode::Workflow));
382 let back = serde_yaml_ng::to_string(&m).unwrap();
383 let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
384 assert_eq!(m2.name, m.name);
385 }
386
387 #[test]
388 fn context_mode_detected() {
389 let c = Content {
390 r#abstract: "a".into(),
391 context: Some("ctx".into()),
392 procedure: None,
393 command: None,
394 note: None,
395 };
396 assert_eq!(c.mode(), Some(ContentMode::Context));
397 }
398
399 #[test]
400 fn empty_content_returns_no_mode() {
401 let c = Content {
402 r#abstract: "a".into(),
403 context: None,
404 procedure: None,
405 command: None,
406 note: None,
407 };
408 assert_eq!(c.mode(), None);
409 }
410
411 #[test]
412 fn mode_returns_note_when_only_note_populated() {
413 let c = Content {
414 r#abstract: "a".into(),
415 context: None,
416 procedure: None,
417 command: None,
418 note: Some("# body".into()),
419 };
420 assert_eq!(c.mode(), Some(ContentMode::Note));
421 }
422
423 #[test]
424 fn mode_returns_none_when_note_and_context_both_populated() {
425 let c = Content {
426 r#abstract: "a".into(),
427 context: Some("ctx".into()),
428 procedure: None,
429 command: None,
430 note: Some("# body".into()),
431 };
432 assert_eq!(c.mode(), None);
433 }
434
435 #[test]
436 fn skill_without_evolution_log_defaults_to_empty() {
437 let yaml = r#"
439name: no-evol
440version: 0.1.0
441publisher: human:test
442description: test
443category: workflow
444content:
445 abstract: test
446"#;
447 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
448 assert!(m.evolution_log.is_empty());
449 }
450
451 #[test]
452 fn skill_with_evolution_log_roundtrips() {
453 let yaml = r#"
454name: with-evol
455version: 0.1.0
456publisher: human:test
457description: test
458category: workflow
459content:
460 abstract: test
461evolution_log:
462 - version: "0.1.0"
463 generation: 0
464 source: "human:test"
465 changes: "Initial"
466 timestamp: "2026-01-01T00:00:00Z"
467"#;
468 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
469 assert_eq!(m.evolution_log.len(), 1);
470 assert_eq!(m.evolution_log[0].version, "0.1.0");
471 let back = serde_yaml_ng::to_string(&m).unwrap();
473 let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
474 assert_eq!(m2.evolution_log.len(), 1);
475 assert_eq!(m2.evolution_log[0].generation, 0);
476 }
477
478 #[test]
479 fn exact_keyword_returns_pattern_for_keyword_triggers() {
480 let t = Trigger {
481 kind: TriggerKind::Keyword,
482 pattern: Some("search".into()),
483 };
484 assert_eq!(t.exact_keyword(), Some("search"));
485 }
486
487 #[test]
488 fn exact_keyword_returns_none_for_non_keyword_triggers() {
489 let t = Trigger {
490 kind: TriggerKind::Command,
491 pattern: Some("run".into()),
492 };
493 assert_eq!(t.exact_keyword(), None);
494
495 let t = Trigger {
496 kind: TriggerKind::SessionStart,
497 pattern: None,
498 };
499 assert_eq!(t.exact_keyword(), None);
500
501 let t = Trigger {
502 kind: TriggerKind::Manual,
503 pattern: None,
504 };
505 assert_eq!(t.exact_keyword(), None);
506 }
507
508 #[test]
509 fn exact_keyword_returns_none_when_pattern_is_none() {
510 let t = Trigger {
511 kind: TriggerKind::Keyword,
512 pattern: None,
513 };
514 assert_eq!(t.exact_keyword(), None);
515 }
516}