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(
11 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
12)]
13#[serde(rename_all = "lowercase")]
14pub enum SkillScope {
15 #[default]
17 User,
18 Project,
20 Fleet,
22 Enterprise,
24}
25
26impl SkillScope {
27 pub fn is_user(&self) -> bool {
29 matches!(self, SkillScope::User)
30 }
31}
32
33pub fn scope_visible(
40 scope: SkillScope,
41 skill_fleet: Option<&str>,
42 skill_project: Option<&str>,
43 active_fleet: Option<&str>,
44 active_project: Option<&str>,
45) -> bool {
46 match scope {
47 SkillScope::User | SkillScope::Enterprise => true,
48 SkillScope::Fleet => matches!((skill_fleet, active_fleet), (Some(f), Some(a)) if f == a),
49 SkillScope::Project => {
50 matches!((skill_project, active_project), (Some(p), Some(a)) if p == a)
51 }
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct Skill {
59 #[serde(flatten)]
60 pub manifest: SkillManifest,
61
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub content_sha256: Option<String>,
65
66 #[serde(default)]
68 pub trust_level: TrustLevel,
69
70 #[serde(default, skip_serializing_if = "Vec::is_empty")]
72 pub capabilities_declared: Vec<String>,
73
74 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub publisher_signature: Option<String>,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
83pub struct SkillManifest {
84 pub name: String,
85 pub version: String,
86 pub publisher: String,
87 pub description: String,
88 pub category: Category,
89
90 #[serde(default, skip_serializing_if = "SkillScope::is_user")]
93 pub scope: SkillScope,
94
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub fleet: Option<String>,
98
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub project: Option<String>,
102
103 #[serde(default)]
106 pub provenance: Provenance,
107
108 #[serde(default, skip_serializing_if = "Vec::is_empty")]
109 pub hosts: Vec<HostId>,
110
111 pub content: Content,
112
113 #[serde(default, skip_serializing_if = "Vec::is_empty")]
114 pub requires: Vec<Requirement>,
115
116 #[serde(default, skip_serializing_if = "Vec::is_empty")]
117 pub tags: Vec<String>,
118
119 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub triggers: Vec<Trigger>,
121
122 #[serde(default)]
123 pub priority: Priority,
124
125 #[serde(default, skip_serializing_if = "Vec::is_empty")]
127 pub evolution_log: Vec<EvolutionEvent>,
128
129 #[serde(default, skip_serializing_if = "Vec::is_empty")]
133 pub transfer_chain: Vec<String>,
134
135 #[serde(default, skip_serializing_if = "Vec::is_empty")]
141 pub mcp_requirements: Vec<McpRequirement>,
142
143 #[serde(default)]
147 pub updated_at: DateTime<Utc>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
151pub struct Content {
152 pub r#abstract: String,
154
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub context: Option<String>,
158
159 #[serde(default, skip_serializing_if = "Option::is_none")]
160 pub procedure: Option<Procedure>,
161
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub command: Option<String>,
164
165 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub note: Option<String>,
169}
170
171impl Content {
172 pub fn mode(&self) -> Option<ContentMode> {
173 match (
174 self.context.is_some(),
175 self.procedure.is_some(),
176 self.command.is_some(),
177 self.note.is_some(),
178 ) {
179 (true, false, false, false) => Some(ContentMode::Context),
180 (false, true, false, false) => Some(ContentMode::Workflow),
181 (false, false, true, false) => Some(ContentMode::Command),
182 (false, false, false, true) => Some(ContentMode::Note),
183 _ => None,
184 }
185 }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
189pub struct Procedure {
190 #[serde(default, skip_serializing_if = "Vec::is_empty")]
191 pub variables: Vec<Variable>,
192 pub steps: Vec<ProcedureStep>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
197pub struct RetryConfig {
198 pub max_retries: u32,
199 #[serde(default)]
200 pub backoff_secs: Option<u64>,
201}
202
203#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
205#[serde(rename_all = "lowercase")]
206pub enum FailureAction {
207 Skip,
209 #[default]
211 Abort,
212 Retry,
214}
215
216#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
217pub struct Variable {
218 pub name: String,
219 #[serde(rename = "type", default)]
220 pub var_type: VarType,
221 #[serde(default)]
222 pub required: bool,
223 #[serde(
227 default,
228 alias = "default_value",
229 skip_serializing_if = "Option::is_none"
230 )]
231 pub default: Option<String>,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub description: Option<String>,
234 #[serde(default, skip_serializing_if = "Vec::is_empty")]
236 pub choices: Vec<String>,
237}
238
239#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
242#[serde(rename_all = "lowercase")]
243pub enum VarType {
244 #[default]
245 String,
246 Path,
247 Url,
248 Number,
249 Bool,
250 Array,
252}
253
254impl std::fmt::Display for VarType {
255 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 match self {
257 VarType::String => write!(f, "string"),
258 VarType::Path => write!(f, "path"),
259 VarType::Url => write!(f, "url"),
260 VarType::Number => write!(f, "number"),
261 VarType::Bool => write!(f, "bool"),
262 VarType::Array => write!(f, "array"),
263 }
264 }
265}
266
267#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
268pub struct ProcedureStep {
269 pub description: String,
270
271 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub tool: Option<String>,
275
276 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub intent: Option<String>,
282
283 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub tool_hint: Option<String>,
288
289 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub id: Option<String>,
295
296 #[serde(default, skip_serializing_if = "Vec::is_empty")]
299 pub depends_on: Vec<String>,
300
301 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub command: Option<String>,
306
307 #[serde(default)]
308 pub on_failure: FailureAction,
309
310 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub retry: Option<RetryConfig>,
312
313 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub timeout_secs: Option<u64>,
315
316 #[serde(default)]
320 pub needs_approval: bool,
321
322 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub delegate_to: Option<String>,
329
330 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub risk: Option<crate::hitl::RiskTier>,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
337pub struct Trigger {
338 #[serde(rename = "type")]
339 pub kind: TriggerKind,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub pattern: Option<String>,
342}
343
344impl Trigger {
345 pub fn exact_keyword(&self) -> Option<&str> {
347 if matches!(self.kind, TriggerKind::Keyword) {
348 self.pattern.as_deref()
349 } else {
350 None
351 }
352 }
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
356pub struct Requirement {
357 pub name: String,
358 #[serde(default = "default_any_version")]
359 pub version: String,
360}
361
362fn default_any_version() -> String {
363 "*".to_string()
364}
365
366#[cfg(test)]
367mod tests {
368 use super::*;
369
370 #[test]
371 fn procedure_step_dag_fields_roundtrip() {
372 let yaml = r#"
373description: deploy the app
374command: "fly deploy --app {{app_name}}"
375id: deploy
376depends_on: [build, test]
377on_failure: retry
378retry:
379 max_retries: 2
380 backoff_secs: 5
381timeout_secs: 300
382needs_approval: true
383"#;
384 let step: ProcedureStep = serde_yaml_ng::from_str(yaml).unwrap();
385 assert_eq!(step.id.as_deref(), Some("deploy"));
386 assert_eq!(step.depends_on, vec!["build", "test"]);
387 assert_eq!(step.on_failure, FailureAction::Retry);
388 assert_eq!(step.retry.as_ref().unwrap().max_retries, 2);
389 assert_eq!(step.timeout_secs, Some(300));
390 assert!(step.needs_approval);
391
392 let legacy: ProcedureStep =
394 serde_yaml_ng::from_str("description: run tests\ntool: Bash\n").unwrap();
395 assert!(legacy.id.is_none());
396 assert!(legacy.depends_on.is_empty());
397 assert_eq!(legacy.on_failure, FailureAction::Abort);
398 assert!(!legacy.needs_approval);
399 }
400
401 #[test]
402 fn procedure_step_parses_delegate_to() {
403 let yaml = "description: hand off to qa\ndelegate_to: qa\n";
404 let s: ProcedureStep = serde_yaml_ng::from_str(yaml).unwrap();
405 assert_eq!(s.delegate_to.as_deref(), Some("qa"));
406 let s2: ProcedureStep = serde_yaml_ng::from_str("description: local step\n").unwrap();
408 assert_eq!(s2.delegate_to, None);
409 }
410
411 #[test]
412 fn variable_accepts_legacy_default_value_alias() {
413 let v: Variable = serde_yaml_ng::from_str(
415 "name: app\ntype: string\nrequired: true\ndefault_value: my-api\n",
416 )
417 .unwrap();
418 assert_eq!(v.default.as_deref(), Some("my-api"));
419 assert_eq!(v.var_type, VarType::String);
420
421 let v2: Variable =
423 serde_yaml_ng::from_str("name: env\ntype: string\ndefault: prod\n").unwrap();
424 assert_eq!(v2.default.as_deref(), Some("prod"));
425 assert!(v2.choices.is_empty());
426 }
427
428 #[test]
429 fn variable_all_vartypes_parse() {
430 for t in ["string", "path", "url", "number", "bool", "array"] {
431 let v: Variable = serde_yaml_ng::from_str(&format!("name: x\ntype: {t}\n")).unwrap();
432 assert_eq!(v.var_type.to_string(), t);
433 }
434 }
435
436 #[test]
437 fn full_manifest_roundtrips() {
438 let yaml = r#"
439name: research-prices
440version: 1.0.0
441publisher: human:david
442description: Search product prices
443category: workflow
444hosts: [mur-agent]
445content:
446 abstract: Searches product prices.
447 procedure:
448 variables:
449 - name: product_name
450 type: string
451 required: true
452 steps:
453 - description: Navigate
454 tool: browser.navigate
455triggers:
456 - type: command
457 pattern: /research-prices
458priority: normal
459"#;
460 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
461 assert_eq!(m.name, "research-prices");
462 assert_eq!(m.category, Category::Workflow);
463 assert_eq!(m.content.mode(), Some(ContentMode::Workflow));
464 let back = serde_yaml_ng::to_string(&m).unwrap();
465 let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
466 assert_eq!(m2.name, m.name);
467 }
468
469 #[test]
470 fn context_mode_detected() {
471 let c = Content {
472 r#abstract: "a".into(),
473 context: Some("ctx".into()),
474 procedure: None,
475 command: None,
476 note: None,
477 };
478 assert_eq!(c.mode(), Some(ContentMode::Context));
479 }
480
481 #[test]
482 fn empty_content_returns_no_mode() {
483 let c = Content {
484 r#abstract: "a".into(),
485 context: None,
486 procedure: None,
487 command: None,
488 note: None,
489 };
490 assert_eq!(c.mode(), None);
491 }
492
493 #[test]
494 fn mode_returns_note_when_only_note_populated() {
495 let c = Content {
496 r#abstract: "a".into(),
497 context: None,
498 procedure: None,
499 command: None,
500 note: Some("# body".into()),
501 };
502 assert_eq!(c.mode(), Some(ContentMode::Note));
503 }
504
505 #[test]
506 fn mode_returns_none_when_note_and_context_both_populated() {
507 let c = Content {
508 r#abstract: "a".into(),
509 context: Some("ctx".into()),
510 procedure: None,
511 command: None,
512 note: Some("# body".into()),
513 };
514 assert_eq!(c.mode(), None);
515 }
516
517 #[test]
518 fn skill_without_evolution_log_defaults_to_empty() {
519 let yaml = r#"
521name: no-evol
522version: 0.1.0
523publisher: human:test
524description: test
525category: workflow
526content:
527 abstract: test
528"#;
529 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
530 assert!(m.evolution_log.is_empty());
531 }
532
533 #[test]
534 fn skill_with_evolution_log_roundtrips() {
535 let yaml = r#"
536name: with-evol
537version: 0.1.0
538publisher: human:test
539description: test
540category: workflow
541content:
542 abstract: test
543evolution_log:
544 - version: "0.1.0"
545 generation: 0
546 source: "human:test"
547 changes: "Initial"
548 timestamp: "2026-01-01T00:00:00Z"
549"#;
550 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
551 assert_eq!(m.evolution_log.len(), 1);
552 assert_eq!(m.evolution_log[0].version, "0.1.0");
553 let back = serde_yaml_ng::to_string(&m).unwrap();
555 let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
556 assert_eq!(m2.evolution_log.len(), 1);
557 assert_eq!(m2.evolution_log[0].generation, 0);
558 }
559
560 #[test]
561 fn exact_keyword_returns_pattern_for_keyword_triggers() {
562 let t = Trigger {
563 kind: TriggerKind::Keyword,
564 pattern: Some("search".into()),
565 };
566 assert_eq!(t.exact_keyword(), Some("search"));
567 }
568
569 #[test]
570 fn exact_keyword_returns_none_for_non_keyword_triggers() {
571 let t = Trigger {
572 kind: TriggerKind::Command,
573 pattern: Some("run".into()),
574 };
575 assert_eq!(t.exact_keyword(), None);
576
577 let t = Trigger {
578 kind: TriggerKind::SessionStart,
579 pattern: None,
580 };
581 assert_eq!(t.exact_keyword(), None);
582
583 let t = Trigger {
584 kind: TriggerKind::Manual,
585 pattern: None,
586 };
587 assert_eq!(t.exact_keyword(), None);
588 }
589
590 #[test]
591 fn exact_keyword_returns_none_when_pattern_is_none() {
592 let t = Trigger {
593 kind: TriggerKind::Keyword,
594 pattern: None,
595 };
596 assert_eq!(t.exact_keyword(), None);
597 }
598
599 #[test]
600 fn skill_scope_serde_and_default() {
601 assert_eq!(SkillScope::default(), SkillScope::User);
603 assert!(SkillScope::User.is_user());
604 assert!(!SkillScope::Project.is_user());
605 assert!(!SkillScope::Fleet.is_user());
606
607 let yaml = r#"
609name: scoped-skill
610version: 0.1.0
611publisher: human:test
612description: test
613category: workflow
614scope: fleet
615fleet: prod
616project: null
617content:
618 abstract: test
619"#;
620 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
621 assert_eq!(m.scope, SkillScope::Fleet);
622 assert_eq!(m.fleet, Some("prod".into()));
623 assert_eq!(m.project, None);
624
625 let back = serde_yaml_ng::to_string(&m).unwrap();
627 let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
628 assert_eq!(m2.scope, SkillScope::Fleet);
629 assert_eq!(m2.fleet, Some("prod".into()));
630
631 let yaml_no_scope = r#"
633name: default-scope
634version: 0.1.0
635publisher: human:test
636description: test
637category: workflow
638content:
639 abstract: test
640"#;
641 let m3: SkillManifest = serde_yaml_ng::from_str(yaml_no_scope).unwrap();
642 assert_eq!(m3.scope, SkillScope::User);
643 assert!(m3.fleet.is_none());
644 assert!(m3.project.is_none());
645 }
646
647 #[test]
648 fn scope_visible_matrix() {
649 assert!(scope_visible(SkillScope::User, None, None, None, None));
651 assert!(scope_visible(
652 SkillScope::Enterprise,
653 None,
654 None,
655 None,
656 None
657 ));
658 assert!(scope_visible(
660 SkillScope::Fleet,
661 Some("dev"),
662 None,
663 Some("dev"),
664 None
665 ));
666 assert!(!scope_visible(
667 SkillScope::Fleet,
668 Some("dev"),
669 None,
670 Some("ops"),
671 None
672 ));
673 assert!(!scope_visible(
674 SkillScope::Fleet,
675 Some("dev"),
676 None,
677 None,
678 None
679 ));
680 assert!(scope_visible(
682 SkillScope::Project,
683 None,
684 Some("/p"),
685 None,
686 Some("/p")
687 ));
688 assert!(!scope_visible(
689 SkillScope::Project,
690 None,
691 Some("/p"),
692 None,
693 Some("/q")
694 ));
695 }
696}