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 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub delegate_to: Option<String>,
270
271 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub risk: Option<crate::hitl::RiskTier>,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
278pub struct Trigger {
279 #[serde(rename = "type")]
280 pub kind: TriggerKind,
281 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub pattern: Option<String>,
283}
284
285impl Trigger {
286 pub fn exact_keyword(&self) -> Option<&str> {
288 if matches!(self.kind, TriggerKind::Keyword) {
289 self.pattern.as_deref()
290 } else {
291 None
292 }
293 }
294}
295
296#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
297pub struct Requirement {
298 pub name: String,
299 #[serde(default = "default_any_version")]
300 pub version: String,
301}
302
303fn default_any_version() -> String {
304 "*".to_string()
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn procedure_step_dag_fields_roundtrip() {
313 let yaml = r#"
314description: deploy the app
315command: "fly deploy --app {{app_name}}"
316id: deploy
317depends_on: [build, test]
318on_failure: retry
319retry:
320 max_retries: 2
321 backoff_secs: 5
322timeout_secs: 300
323needs_approval: true
324"#;
325 let step: ProcedureStep = serde_yaml_ng::from_str(yaml).unwrap();
326 assert_eq!(step.id.as_deref(), Some("deploy"));
327 assert_eq!(step.depends_on, vec!["build", "test"]);
328 assert_eq!(step.on_failure, FailureAction::Retry);
329 assert_eq!(step.retry.as_ref().unwrap().max_retries, 2);
330 assert_eq!(step.timeout_secs, Some(300));
331 assert!(step.needs_approval);
332
333 let legacy: ProcedureStep =
335 serde_yaml_ng::from_str("description: run tests\ntool: Bash\n").unwrap();
336 assert!(legacy.id.is_none());
337 assert!(legacy.depends_on.is_empty());
338 assert_eq!(legacy.on_failure, FailureAction::Abort);
339 assert!(!legacy.needs_approval);
340 }
341
342 #[test]
343 fn procedure_step_parses_delegate_to() {
344 let yaml = "description: hand off to qa\ndelegate_to: qa\n";
345 let s: ProcedureStep = serde_yaml_ng::from_str(yaml).unwrap();
346 assert_eq!(s.delegate_to.as_deref(), Some("qa"));
347 let s2: ProcedureStep = serde_yaml_ng::from_str("description: local step\n").unwrap();
349 assert_eq!(s2.delegate_to, None);
350 }
351
352 #[test]
353 fn variable_accepts_legacy_default_value_alias() {
354 let v: Variable = serde_yaml_ng::from_str(
356 "name: app\ntype: string\nrequired: true\ndefault_value: my-api\n",
357 )
358 .unwrap();
359 assert_eq!(v.default.as_deref(), Some("my-api"));
360 assert_eq!(v.var_type, VarType::String);
361
362 let v2: Variable =
364 serde_yaml_ng::from_str("name: env\ntype: string\ndefault: prod\n").unwrap();
365 assert_eq!(v2.default.as_deref(), Some("prod"));
366 assert!(v2.choices.is_empty());
367 }
368
369 #[test]
370 fn variable_all_vartypes_parse() {
371 for t in ["string", "path", "url", "number", "bool", "array"] {
372 let v: Variable = serde_yaml_ng::from_str(&format!("name: x\ntype: {t}\n")).unwrap();
373 assert_eq!(v.var_type.to_string(), t);
374 }
375 }
376
377 #[test]
378 fn full_manifest_roundtrips() {
379 let yaml = r#"
380name: research-prices
381version: 1.0.0
382publisher: human:david
383description: Search product prices
384category: workflow
385hosts: [mur-agent]
386content:
387 abstract: Searches product prices.
388 procedure:
389 variables:
390 - name: product_name
391 type: string
392 required: true
393 steps:
394 - description: Navigate
395 tool: browser.navigate
396triggers:
397 - type: command
398 pattern: /research-prices
399priority: normal
400"#;
401 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
402 assert_eq!(m.name, "research-prices");
403 assert_eq!(m.category, Category::Workflow);
404 assert_eq!(m.content.mode(), Some(ContentMode::Workflow));
405 let back = serde_yaml_ng::to_string(&m).unwrap();
406 let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
407 assert_eq!(m2.name, m.name);
408 }
409
410 #[test]
411 fn context_mode_detected() {
412 let c = Content {
413 r#abstract: "a".into(),
414 context: Some("ctx".into()),
415 procedure: None,
416 command: None,
417 note: None,
418 };
419 assert_eq!(c.mode(), Some(ContentMode::Context));
420 }
421
422 #[test]
423 fn empty_content_returns_no_mode() {
424 let c = Content {
425 r#abstract: "a".into(),
426 context: None,
427 procedure: None,
428 command: None,
429 note: None,
430 };
431 assert_eq!(c.mode(), None);
432 }
433
434 #[test]
435 fn mode_returns_note_when_only_note_populated() {
436 let c = Content {
437 r#abstract: "a".into(),
438 context: None,
439 procedure: None,
440 command: None,
441 note: Some("# body".into()),
442 };
443 assert_eq!(c.mode(), Some(ContentMode::Note));
444 }
445
446 #[test]
447 fn mode_returns_none_when_note_and_context_both_populated() {
448 let c = Content {
449 r#abstract: "a".into(),
450 context: Some("ctx".into()),
451 procedure: None,
452 command: None,
453 note: Some("# body".into()),
454 };
455 assert_eq!(c.mode(), None);
456 }
457
458 #[test]
459 fn skill_without_evolution_log_defaults_to_empty() {
460 let yaml = r#"
462name: no-evol
463version: 0.1.0
464publisher: human:test
465description: test
466category: workflow
467content:
468 abstract: test
469"#;
470 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
471 assert!(m.evolution_log.is_empty());
472 }
473
474 #[test]
475 fn skill_with_evolution_log_roundtrips() {
476 let yaml = r#"
477name: with-evol
478version: 0.1.0
479publisher: human:test
480description: test
481category: workflow
482content:
483 abstract: test
484evolution_log:
485 - version: "0.1.0"
486 generation: 0
487 source: "human:test"
488 changes: "Initial"
489 timestamp: "2026-01-01T00:00:00Z"
490"#;
491 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
492 assert_eq!(m.evolution_log.len(), 1);
493 assert_eq!(m.evolution_log[0].version, "0.1.0");
494 let back = serde_yaml_ng::to_string(&m).unwrap();
496 let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
497 assert_eq!(m2.evolution_log.len(), 1);
498 assert_eq!(m2.evolution_log[0].generation, 0);
499 }
500
501 #[test]
502 fn exact_keyword_returns_pattern_for_keyword_triggers() {
503 let t = Trigger {
504 kind: TriggerKind::Keyword,
505 pattern: Some("search".into()),
506 };
507 assert_eq!(t.exact_keyword(), Some("search"));
508 }
509
510 #[test]
511 fn exact_keyword_returns_none_for_non_keyword_triggers() {
512 let t = Trigger {
513 kind: TriggerKind::Command,
514 pattern: Some("run".into()),
515 };
516 assert_eq!(t.exact_keyword(), None);
517
518 let t = Trigger {
519 kind: TriggerKind::SessionStart,
520 pattern: None,
521 };
522 assert_eq!(t.exact_keyword(), None);
523
524 let t = Trigger {
525 kind: TriggerKind::Manual,
526 pattern: None,
527 };
528 assert_eq!(t.exact_keyword(), None);
529 }
530
531 #[test]
532 fn exact_keyword_returns_none_when_pattern_is_none() {
533 let t = Trigger {
534 kind: TriggerKind::Keyword,
535 pattern: None,
536 };
537 assert_eq!(t.exact_keyword(), None);
538 }
539}