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 Team,
24 Enterprise,
26}
27
28impl SkillScope {
29 pub fn is_user(&self) -> bool {
31 matches!(self, SkillScope::User)
32 }
33}
34
35#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
42#[serde(default)]
43pub struct GovernanceRef {
44 #[serde(default, skip_serializing_if = "String::is_empty")]
45 pub org_id: String,
46 #[serde(default, skip_serializing_if = "String::is_empty")]
47 pub constitution_hash: String,
48}
49
50pub fn scope_visible(
57 scope: SkillScope,
58 skill_fleet: Option<&str>,
59 skill_project: Option<&str>,
60 skill_team: Option<&str>, active_fleet: Option<&str>,
62 active_project: Option<&str>,
63 active_team: Option<&str>, ) -> bool {
65 match scope {
66 SkillScope::User => true,
67 SkillScope::Enterprise => true,
68 SkillScope::Project => skill_project.is_some() && active_project == skill_project,
69 SkillScope::Fleet => skill_fleet.is_some() && active_fleet == skill_fleet,
70 SkillScope::Team => skill_team.is_some() && active_team == skill_team,
71 }
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct Skill {
78 #[serde(flatten)]
79 pub manifest: SkillManifest,
80
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub content_sha256: Option<String>,
84
85 #[serde(default)]
87 pub trust_level: TrustLevel,
88
89 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub capabilities_declared: Vec<String>,
92
93 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub publisher_signature: Option<String>,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
102pub struct SkillManifest {
103 pub name: String,
104 pub version: String,
105 pub publisher: String,
106 pub description: String,
107 pub category: Category,
108
109 #[serde(default, skip_serializing_if = "SkillScope::is_user")]
112 pub scope: SkillScope,
113
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub fleet: Option<String>,
117
118 #[serde(default, skip_serializing_if = "Option::is_none")]
120 pub team: Option<String>,
121
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub governance: Option<GovernanceRef>,
125
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub project: Option<String>,
129
130 #[serde(default)]
133 pub provenance: Provenance,
134
135 #[serde(default, skip_serializing_if = "Vec::is_empty")]
136 pub hosts: Vec<HostId>,
137
138 pub content: Content,
139
140 #[serde(default, skip_serializing_if = "Vec::is_empty")]
141 pub requires: Vec<Requirement>,
142
143 #[serde(default, skip_serializing_if = "Vec::is_empty")]
144 pub tags: Vec<String>,
145
146 #[serde(default, skip_serializing_if = "Vec::is_empty")]
147 pub triggers: Vec<Trigger>,
148
149 #[serde(default)]
150 pub priority: Priority,
151
152 #[serde(default, skip_serializing_if = "Vec::is_empty")]
154 pub evolution_log: Vec<EvolutionEvent>,
155
156 #[serde(default, skip_serializing_if = "Vec::is_empty")]
160 pub transfer_chain: Vec<String>,
161
162 #[serde(default, skip_serializing_if = "Vec::is_empty")]
168 pub mcp_requirements: Vec<McpRequirement>,
169
170 #[serde(default)]
174 pub updated_at: DateTime<Utc>,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
178pub struct Content {
179 pub r#abstract: String,
181
182 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub context: Option<String>,
185
186 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub procedure: Option<Procedure>,
188
189 #[serde(default, skip_serializing_if = "Option::is_none")]
190 pub command: Option<String>,
191
192 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub note: Option<String>,
196}
197
198impl Content {
199 pub fn mode(&self) -> Option<ContentMode> {
200 match (
201 self.context.is_some(),
202 self.procedure.is_some(),
203 self.command.is_some(),
204 self.note.is_some(),
205 ) {
206 (true, false, false, false) => Some(ContentMode::Context),
207 (false, true, false, false) => Some(ContentMode::Workflow),
208 (false, false, true, false) => Some(ContentMode::Command),
209 (false, false, false, true) => Some(ContentMode::Note),
210 _ => None,
211 }
212 }
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
216pub struct Procedure {
217 #[serde(default, skip_serializing_if = "Vec::is_empty")]
218 pub variables: Vec<Variable>,
219 pub steps: Vec<ProcedureStep>,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
224pub struct RetryConfig {
225 pub max_retries: u32,
226 #[serde(default)]
227 pub backoff_secs: Option<u64>,
228}
229
230#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
232#[serde(rename_all = "lowercase")]
233pub enum FailureAction {
234 Skip,
236 #[default]
238 Abort,
239 Retry,
241}
242
243#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
244pub struct Variable {
245 pub name: String,
246 #[serde(rename = "type", default)]
247 pub var_type: VarType,
248 #[serde(default)]
249 pub required: bool,
250 #[serde(
254 default,
255 alias = "default_value",
256 skip_serializing_if = "Option::is_none"
257 )]
258 pub default: Option<String>,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub description: Option<String>,
261 #[serde(default, skip_serializing_if = "Vec::is_empty")]
263 pub choices: Vec<String>,
264}
265
266#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
269#[serde(rename_all = "lowercase")]
270pub enum VarType {
271 #[default]
272 String,
273 Path,
274 Url,
275 Number,
276 Bool,
277 Array,
279}
280
281impl std::fmt::Display for VarType {
282 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283 match self {
284 VarType::String => write!(f, "string"),
285 VarType::Path => write!(f, "path"),
286 VarType::Url => write!(f, "url"),
287 VarType::Number => write!(f, "number"),
288 VarType::Bool => write!(f, "bool"),
289 VarType::Array => write!(f, "array"),
290 }
291 }
292}
293
294#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
295pub struct ProcedureStep {
296 pub description: String,
297
298 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub tool: Option<String>,
302
303 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub intent: Option<String>,
309
310 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub tool_hint: Option<String>,
315
316 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub id: Option<String>,
322
323 #[serde(default, skip_serializing_if = "Vec::is_empty")]
326 pub depends_on: Vec<String>,
327
328 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub command: Option<String>,
333
334 #[serde(default)]
335 pub on_failure: FailureAction,
336
337 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub retry: Option<RetryConfig>,
339
340 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub timeout_secs: Option<u64>,
342
343 #[serde(default)]
347 pub needs_approval: bool,
348
349 #[serde(default, skip_serializing_if = "Option::is_none")]
355 pub delegate_to: Option<String>,
356
357 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub risk: Option<crate::hitl::RiskTier>,
361}
362
363#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
364pub struct Trigger {
365 #[serde(rename = "type")]
366 pub kind: TriggerKind,
367 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub pattern: Option<String>,
369}
370
371impl Trigger {
372 pub fn exact_keyword(&self) -> Option<&str> {
374 if matches!(self.kind, TriggerKind::Keyword) {
375 self.pattern.as_deref()
376 } else {
377 None
378 }
379 }
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
383pub struct Requirement {
384 pub name: String,
385 #[serde(default = "default_any_version")]
386 pub version: String,
387}
388
389fn default_any_version() -> String {
390 "*".to_string()
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn procedure_step_dag_fields_roundtrip() {
399 let yaml = r#"
400description: deploy the app
401command: "fly deploy --app {{app_name}}"
402id: deploy
403depends_on: [build, test]
404on_failure: retry
405retry:
406 max_retries: 2
407 backoff_secs: 5
408timeout_secs: 300
409needs_approval: true
410"#;
411 let step: ProcedureStep = serde_yaml_ng::from_str(yaml).unwrap();
412 assert_eq!(step.id.as_deref(), Some("deploy"));
413 assert_eq!(step.depends_on, vec!["build", "test"]);
414 assert_eq!(step.on_failure, FailureAction::Retry);
415 assert_eq!(step.retry.as_ref().unwrap().max_retries, 2);
416 assert_eq!(step.timeout_secs, Some(300));
417 assert!(step.needs_approval);
418
419 let legacy: ProcedureStep =
421 serde_yaml_ng::from_str("description: run tests\ntool: Bash\n").unwrap();
422 assert!(legacy.id.is_none());
423 assert!(legacy.depends_on.is_empty());
424 assert_eq!(legacy.on_failure, FailureAction::Abort);
425 assert!(!legacy.needs_approval);
426 }
427
428 #[test]
429 fn procedure_step_parses_delegate_to() {
430 let yaml = "description: hand off to qa\ndelegate_to: qa\n";
431 let s: ProcedureStep = serde_yaml_ng::from_str(yaml).unwrap();
432 assert_eq!(s.delegate_to.as_deref(), Some("qa"));
433 let s2: ProcedureStep = serde_yaml_ng::from_str("description: local step\n").unwrap();
435 assert_eq!(s2.delegate_to, None);
436 }
437
438 #[test]
439 fn variable_accepts_legacy_default_value_alias() {
440 let v: Variable = serde_yaml_ng::from_str(
442 "name: app\ntype: string\nrequired: true\ndefault_value: my-api\n",
443 )
444 .unwrap();
445 assert_eq!(v.default.as_deref(), Some("my-api"));
446 assert_eq!(v.var_type, VarType::String);
447
448 let v2: Variable =
450 serde_yaml_ng::from_str("name: env\ntype: string\ndefault: prod\n").unwrap();
451 assert_eq!(v2.default.as_deref(), Some("prod"));
452 assert!(v2.choices.is_empty());
453 }
454
455 #[test]
456 fn variable_all_vartypes_parse() {
457 for t in ["string", "path", "url", "number", "bool", "array"] {
458 let v: Variable = serde_yaml_ng::from_str(&format!("name: x\ntype: {t}\n")).unwrap();
459 assert_eq!(v.var_type.to_string(), t);
460 }
461 }
462
463 #[test]
464 fn full_manifest_roundtrips() {
465 let yaml = r#"
466name: research-prices
467version: 1.0.0
468publisher: human:david
469description: Search product prices
470category: workflow
471hosts: [mur-agent]
472content:
473 abstract: Searches product prices.
474 procedure:
475 variables:
476 - name: product_name
477 type: string
478 required: true
479 steps:
480 - description: Navigate
481 tool: browser.navigate
482triggers:
483 - type: command
484 pattern: /research-prices
485priority: normal
486"#;
487 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
488 assert_eq!(m.name, "research-prices");
489 assert_eq!(m.category, Category::Workflow);
490 assert_eq!(m.content.mode(), Some(ContentMode::Workflow));
491 let back = serde_yaml_ng::to_string(&m).unwrap();
492 let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
493 assert_eq!(m2.name, m.name);
494 }
495
496 #[test]
497 fn context_mode_detected() {
498 let c = Content {
499 r#abstract: "a".into(),
500 context: Some("ctx".into()),
501 procedure: None,
502 command: None,
503 note: None,
504 };
505 assert_eq!(c.mode(), Some(ContentMode::Context));
506 }
507
508 #[test]
509 fn empty_content_returns_no_mode() {
510 let c = Content {
511 r#abstract: "a".into(),
512 context: None,
513 procedure: None,
514 command: None,
515 note: None,
516 };
517 assert_eq!(c.mode(), None);
518 }
519
520 #[test]
521 fn mode_returns_note_when_only_note_populated() {
522 let c = Content {
523 r#abstract: "a".into(),
524 context: None,
525 procedure: None,
526 command: None,
527 note: Some("# body".into()),
528 };
529 assert_eq!(c.mode(), Some(ContentMode::Note));
530 }
531
532 #[test]
533 fn mode_returns_none_when_note_and_context_both_populated() {
534 let c = Content {
535 r#abstract: "a".into(),
536 context: Some("ctx".into()),
537 procedure: None,
538 command: None,
539 note: Some("# body".into()),
540 };
541 assert_eq!(c.mode(), None);
542 }
543
544 #[test]
545 fn skill_without_evolution_log_defaults_to_empty() {
546 let yaml = r#"
548name: no-evol
549version: 0.1.0
550publisher: human:test
551description: test
552category: workflow
553content:
554 abstract: test
555"#;
556 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
557 assert!(m.evolution_log.is_empty());
558 }
559
560 #[test]
561 fn skill_with_evolution_log_roundtrips() {
562 let yaml = r#"
563name: with-evol
564version: 0.1.0
565publisher: human:test
566description: test
567category: workflow
568content:
569 abstract: test
570evolution_log:
571 - version: "0.1.0"
572 generation: 0
573 source: "human:test"
574 changes: "Initial"
575 timestamp: "2026-01-01T00:00:00Z"
576"#;
577 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
578 assert_eq!(m.evolution_log.len(), 1);
579 assert_eq!(m.evolution_log[0].version, "0.1.0");
580 let back = serde_yaml_ng::to_string(&m).unwrap();
582 let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
583 assert_eq!(m2.evolution_log.len(), 1);
584 assert_eq!(m2.evolution_log[0].generation, 0);
585 }
586
587 #[test]
588 fn exact_keyword_returns_pattern_for_keyword_triggers() {
589 let t = Trigger {
590 kind: TriggerKind::Keyword,
591 pattern: Some("search".into()),
592 };
593 assert_eq!(t.exact_keyword(), Some("search"));
594 }
595
596 #[test]
597 fn exact_keyword_returns_none_for_non_keyword_triggers() {
598 let t = Trigger {
599 kind: TriggerKind::Command,
600 pattern: Some("run".into()),
601 };
602 assert_eq!(t.exact_keyword(), None);
603
604 let t = Trigger {
605 kind: TriggerKind::SessionStart,
606 pattern: None,
607 };
608 assert_eq!(t.exact_keyword(), None);
609
610 let t = Trigger {
611 kind: TriggerKind::Manual,
612 pattern: None,
613 };
614 assert_eq!(t.exact_keyword(), None);
615 }
616
617 #[test]
618 fn exact_keyword_returns_none_when_pattern_is_none() {
619 let t = Trigger {
620 kind: TriggerKind::Keyword,
621 pattern: None,
622 };
623 assert_eq!(t.exact_keyword(), None);
624 }
625
626 #[test]
627 fn skill_scope_serde_and_default() {
628 assert_eq!(SkillScope::default(), SkillScope::User);
630 assert!(SkillScope::User.is_user());
631 assert!(!SkillScope::Project.is_user());
632 assert!(!SkillScope::Fleet.is_user());
633
634 let yaml = r#"
636name: scoped-skill
637version: 0.1.0
638publisher: human:test
639description: test
640category: workflow
641scope: fleet
642fleet: prod
643project: null
644content:
645 abstract: test
646"#;
647 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
648 assert_eq!(m.scope, SkillScope::Fleet);
649 assert_eq!(m.fleet, Some("prod".into()));
650 assert_eq!(m.project, None);
651
652 let back = serde_yaml_ng::to_string(&m).unwrap();
654 let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
655 assert_eq!(m2.scope, SkillScope::Fleet);
656 assert_eq!(m2.fleet, Some("prod".into()));
657
658 let yaml_no_scope = r#"
660name: default-scope
661version: 0.1.0
662publisher: human:test
663description: test
664category: workflow
665content:
666 abstract: test
667"#;
668 let m3: SkillManifest = serde_yaml_ng::from_str(yaml_no_scope).unwrap();
669 assert_eq!(m3.scope, SkillScope::User);
670 assert!(m3.fleet.is_none());
671 assert!(m3.project.is_none());
672 }
673
674 #[test]
675 fn scope_visible_matrix() {
676 assert!(scope_visible(
678 SkillScope::User,
679 None,
680 None,
681 None,
682 None,
683 None,
684 None
685 ));
686 assert!(scope_visible(
687 SkillScope::Enterprise,
688 None,
689 None,
690 None,
691 None,
692 None,
693 None
694 ));
695 assert!(scope_visible(
697 SkillScope::Fleet,
698 Some("dev"),
699 None,
700 None,
701 Some("dev"),
702 None,
703 None
704 ));
705 assert!(!scope_visible(
706 SkillScope::Fleet,
707 Some("dev"),
708 None,
709 None,
710 Some("ops"),
711 None,
712 None
713 ));
714 assert!(!scope_visible(
715 SkillScope::Fleet,
716 Some("dev"),
717 None,
718 None,
719 None,
720 None,
721 None
722 ));
723 assert!(scope_visible(
725 SkillScope::Project,
726 None,
727 Some("/p"),
728 None,
729 None,
730 Some("/p"),
731 None
732 ));
733 assert!(!scope_visible(
734 SkillScope::Project,
735 None,
736 Some("/p"),
737 None,
738 None,
739 Some("/q"),
740 None
741 ));
742 }
743
744 #[test]
745 fn team_scope_visibility() {
746 assert!(scope_visible(
748 SkillScope::Team,
749 None,
750 None,
751 Some("org-xyz"),
752 None,
753 None,
754 Some("org-xyz"),
755 ));
756 assert!(!scope_visible(
758 SkillScope::Team,
759 None,
760 None,
761 Some("org-abc"),
762 None,
763 None,
764 Some("org-xyz"),
765 ));
766 assert!(!scope_visible(
768 SkillScope::Team,
769 None,
770 None,
771 Some("org-xyz"),
772 None,
773 None,
774 None,
775 ));
776 assert!(!scope_visible(
778 SkillScope::Team,
779 None,
780 None,
781 None,
782 None,
783 None,
784 Some("org-xyz"),
785 ));
786 }
787
788 #[test]
789 fn governance_ref_roundtrip() {
790 let yaml = "name: t\nversion: 1.0.0\npublisher: human:test\ndescription: t\ncategory: workflow\ncontent:\n abstract: t\ngovernance:\n org_id: org-1\n constitution_hash: abc\n";
791 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
792 let g = m.governance.unwrap();
793 assert_eq!(g.org_id, "org-1");
794 assert_eq!(g.constitution_hash, "abc");
795 }
796
797 #[test]
798 fn governance_ref_absent_is_none() {
799 let m: SkillManifest = serde_yaml_ng::from_str("name: t\nversion: 1.0.0\npublisher: human:test\ndescription: t\ncategory: workflow\ncontent:\n abstract: t\n").unwrap();
800 assert!(m.governance.is_none());
801 }
802
803 #[test]
804 fn team_field_roundtrip() {
805 let yaml = "name: t\nversion: 1.0.0\npublisher: human:test\ndescription: t\ncategory: workflow\ncontent:\n abstract: t\nscope: team\nteam: org-1\n";
806 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
807 assert_eq!(m.scope, SkillScope::Team);
808 assert_eq!(m.team.as_deref(), Some("org-1"));
809 }
810}