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(
38 Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema,
39)]
40#[serde(rename_all = "snake_case")]
41pub enum Visibility {
42 #[default]
44 Indexed,
45 OnDemand,
48}
49
50impl Visibility {
51 pub fn is_indexed(&self) -> bool {
53 matches!(self, Visibility::Indexed)
54 }
55}
56
57#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
64#[serde(default)]
65pub struct GovernanceRef {
66 #[serde(default, skip_serializing_if = "String::is_empty")]
67 pub org_id: String,
68 #[serde(default, skip_serializing_if = "String::is_empty")]
69 pub constitution_hash: String,
70}
71
72pub fn scope_visible(
79 scope: SkillScope,
80 skill_fleet: Option<&str>,
81 skill_project: Option<&str>,
82 skill_team: Option<&str>, active_fleet: Option<&str>,
84 active_project: Option<&str>,
85 active_team: Option<&str>, ) -> bool {
87 match scope {
88 SkillScope::User => true,
89 SkillScope::Enterprise => true,
90 SkillScope::Project => skill_project.is_some() && active_project == skill_project,
91 SkillScope::Fleet => skill_fleet.is_some() && active_fleet == skill_fleet,
92 SkillScope::Team => skill_team.is_some() && active_team == skill_team,
93 }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct Skill {
100 #[serde(flatten)]
101 pub manifest: SkillManifest,
102
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub content_sha256: Option<String>,
106
107 #[serde(default)]
109 pub trust_level: TrustLevel,
110
111 #[serde(default, skip_serializing_if = "Vec::is_empty")]
113 pub capabilities_declared: Vec<String>,
114
115 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub publisher_signature: Option<String>,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
124pub struct SkillManifest {
125 pub name: String,
126 pub version: String,
127 pub publisher: String,
128 pub description: String,
129 pub category: Category,
130
131 #[serde(default, skip_serializing_if = "SkillScope::is_user")]
134 pub scope: SkillScope,
135
136 #[serde(default, skip_serializing_if = "Visibility::is_indexed")]
139 pub visibility: Visibility,
140
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub fleet: Option<String>,
144
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub team: Option<String>,
148
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub governance: Option<GovernanceRef>,
152
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub project: Option<String>,
156
157 #[serde(default)]
160 pub provenance: Provenance,
161
162 #[serde(default, skip_serializing_if = "Vec::is_empty")]
163 pub hosts: Vec<HostId>,
164
165 pub content: Content,
166
167 #[serde(default, skip_serializing_if = "Vec::is_empty")]
168 pub requires: Vec<Requirement>,
169
170 #[serde(default, skip_serializing_if = "Vec::is_empty")]
171 pub tags: Vec<String>,
172
173 #[serde(default, skip_serializing_if = "Vec::is_empty")]
174 pub triggers: Vec<Trigger>,
175
176 #[serde(default)]
177 pub priority: Priority,
178
179 #[serde(default, skip_serializing_if = "Vec::is_empty")]
181 pub evolution_log: Vec<EvolutionEvent>,
182
183 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 pub transfer_chain: Vec<String>,
188
189 #[serde(default, skip_serializing_if = "Vec::is_empty")]
195 pub mcp_requirements: Vec<McpRequirement>,
196
197 #[serde(default)]
201 pub updated_at: DateTime<Utc>,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
205pub struct Content {
206 pub r#abstract: String,
208
209 #[serde(default, skip_serializing_if = "Option::is_none")]
211 pub context: Option<String>,
212
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub procedure: Option<Procedure>,
215
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub command: Option<String>,
218
219 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub note: Option<String>,
223}
224
225impl Content {
226 pub fn mode(&self) -> Option<ContentMode> {
227 match (
228 self.context.is_some(),
229 self.procedure.is_some(),
230 self.command.is_some(),
231 self.note.is_some(),
232 ) {
233 (true, false, false, false) => Some(ContentMode::Context),
234 (false, true, false, false) => Some(ContentMode::Workflow),
235 (false, false, true, false) => Some(ContentMode::Command),
236 (false, false, false, true) => Some(ContentMode::Note),
237 _ => None,
238 }
239 }
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
243pub struct Procedure {
244 #[serde(default, skip_serializing_if = "Vec::is_empty")]
245 pub variables: Vec<Variable>,
246 pub steps: Vec<ProcedureStep>,
247}
248
249#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
251pub struct RetryConfig {
252 pub max_retries: u32,
253 #[serde(default)]
254 pub backoff_secs: Option<u64>,
255}
256
257#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
259#[serde(rename_all = "lowercase")]
260pub enum FailureAction {
261 Skip,
263 #[default]
265 Abort,
266 Retry,
268}
269
270#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
271pub struct Variable {
272 pub name: String,
273 #[serde(rename = "type", default)]
274 pub var_type: VarType,
275 #[serde(default)]
276 pub required: bool,
277 #[serde(
281 default,
282 alias = "default_value",
283 skip_serializing_if = "Option::is_none"
284 )]
285 pub default: Option<String>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub description: Option<String>,
288 #[serde(default, skip_serializing_if = "Vec::is_empty")]
290 pub choices: Vec<String>,
291}
292
293#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
296#[serde(rename_all = "lowercase")]
297pub enum VarType {
298 #[default]
299 String,
300 Path,
301 Url,
302 Number,
303 Bool,
304 Array,
306}
307
308impl std::fmt::Display for VarType {
309 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
310 match self {
311 VarType::String => write!(f, "string"),
312 VarType::Path => write!(f, "path"),
313 VarType::Url => write!(f, "url"),
314 VarType::Number => write!(f, "number"),
315 VarType::Bool => write!(f, "bool"),
316 VarType::Array => write!(f, "array"),
317 }
318 }
319}
320
321#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
322pub struct ProcedureStep {
323 pub description: String,
324
325 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub tool: Option<String>,
329
330 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub intent: Option<String>,
336
337 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub tool_hint: Option<String>,
342
343 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub id: Option<String>,
349
350 #[serde(default, skip_serializing_if = "Vec::is_empty")]
353 pub depends_on: Vec<String>,
354
355 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub command: Option<String>,
360
361 #[serde(default)]
362 pub on_failure: FailureAction,
363
364 #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub retry: Option<RetryConfig>,
366
367 #[serde(default, skip_serializing_if = "Option::is_none")]
368 pub timeout_secs: Option<u64>,
369
370 #[serde(default)]
374 pub needs_approval: bool,
375
376 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub delegate_to: Option<String>,
383
384 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub risk: Option<crate::hitl::RiskTier>,
388}
389
390#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
391pub struct Trigger {
392 #[serde(rename = "type")]
393 pub kind: TriggerKind,
394 #[serde(default, skip_serializing_if = "Option::is_none")]
395 pub pattern: Option<String>,
396}
397
398impl Trigger {
399 pub fn exact_keyword(&self) -> Option<&str> {
401 if matches!(self.kind, TriggerKind::Keyword) {
402 self.pattern.as_deref()
403 } else {
404 None
405 }
406 }
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
410pub struct Requirement {
411 pub name: String,
412 #[serde(default = "default_any_version")]
413 pub version: String,
414}
415
416fn default_any_version() -> String {
417 "*".to_string()
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn visibility_defaults_to_indexed_and_parses_on_demand() {
426 let yaml = r#"
427name: vis-default
428version: 0.1.0
429publisher: human:test
430description: test
431category: workflow
432content:
433 abstract: test
434"#;
435 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
436 assert_eq!(m.visibility, Visibility::Indexed);
437
438 let yaml2 = format!("{yaml}visibility: on_demand\n");
439 let m2: SkillManifest = serde_yaml_ng::from_str(&yaml2).unwrap();
440 assert_eq!(m2.visibility, Visibility::OnDemand);
441
442 let out = serde_yaml_ng::to_string(&m).unwrap();
444 assert!(!out.contains("visibility"));
445 let out2 = serde_yaml_ng::to_string(&m2).unwrap();
446 assert!(out2.contains("visibility: on_demand"));
447 }
448
449 #[test]
450 fn procedure_step_dag_fields_roundtrip() {
451 let yaml = r#"
452description: deploy the app
453command: "fly deploy --app {{app_name}}"
454id: deploy
455depends_on: [build, test]
456on_failure: retry
457retry:
458 max_retries: 2
459 backoff_secs: 5
460timeout_secs: 300
461needs_approval: true
462"#;
463 let step: ProcedureStep = serde_yaml_ng::from_str(yaml).unwrap();
464 assert_eq!(step.id.as_deref(), Some("deploy"));
465 assert_eq!(step.depends_on, vec!["build", "test"]);
466 assert_eq!(step.on_failure, FailureAction::Retry);
467 assert_eq!(step.retry.as_ref().unwrap().max_retries, 2);
468 assert_eq!(step.timeout_secs, Some(300));
469 assert!(step.needs_approval);
470
471 let legacy: ProcedureStep =
473 serde_yaml_ng::from_str("description: run tests\ntool: Bash\n").unwrap();
474 assert!(legacy.id.is_none());
475 assert!(legacy.depends_on.is_empty());
476 assert_eq!(legacy.on_failure, FailureAction::Abort);
477 assert!(!legacy.needs_approval);
478 }
479
480 #[test]
481 fn procedure_step_parses_delegate_to() {
482 let yaml = "description: hand off to qa\ndelegate_to: qa\n";
483 let s: ProcedureStep = serde_yaml_ng::from_str(yaml).unwrap();
484 assert_eq!(s.delegate_to.as_deref(), Some("qa"));
485 let s2: ProcedureStep = serde_yaml_ng::from_str("description: local step\n").unwrap();
487 assert_eq!(s2.delegate_to, None);
488 }
489
490 #[test]
491 fn variable_accepts_legacy_default_value_alias() {
492 let v: Variable = serde_yaml_ng::from_str(
494 "name: app\ntype: string\nrequired: true\ndefault_value: my-api\n",
495 )
496 .unwrap();
497 assert_eq!(v.default.as_deref(), Some("my-api"));
498 assert_eq!(v.var_type, VarType::String);
499
500 let v2: Variable =
502 serde_yaml_ng::from_str("name: env\ntype: string\ndefault: prod\n").unwrap();
503 assert_eq!(v2.default.as_deref(), Some("prod"));
504 assert!(v2.choices.is_empty());
505 }
506
507 #[test]
508 fn variable_all_vartypes_parse() {
509 for t in ["string", "path", "url", "number", "bool", "array"] {
510 let v: Variable = serde_yaml_ng::from_str(&format!("name: x\ntype: {t}\n")).unwrap();
511 assert_eq!(v.var_type.to_string(), t);
512 }
513 }
514
515 #[test]
516 fn full_manifest_roundtrips() {
517 let yaml = r#"
518name: research-prices
519version: 1.0.0
520publisher: human:david
521description: Search product prices
522category: workflow
523hosts: [mur-agent]
524content:
525 abstract: Searches product prices.
526 procedure:
527 variables:
528 - name: product_name
529 type: string
530 required: true
531 steps:
532 - description: Navigate
533 tool: browser.navigate
534triggers:
535 - type: command
536 pattern: /research-prices
537priority: normal
538"#;
539 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
540 assert_eq!(m.name, "research-prices");
541 assert_eq!(m.category, Category::Workflow);
542 assert_eq!(m.content.mode(), Some(ContentMode::Workflow));
543 let back = serde_yaml_ng::to_string(&m).unwrap();
544 let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
545 assert_eq!(m2.name, m.name);
546 }
547
548 #[test]
549 fn context_mode_detected() {
550 let c = Content {
551 r#abstract: "a".into(),
552 context: Some("ctx".into()),
553 procedure: None,
554 command: None,
555 note: None,
556 };
557 assert_eq!(c.mode(), Some(ContentMode::Context));
558 }
559
560 #[test]
561 fn empty_content_returns_no_mode() {
562 let c = Content {
563 r#abstract: "a".into(),
564 context: None,
565 procedure: None,
566 command: None,
567 note: None,
568 };
569 assert_eq!(c.mode(), None);
570 }
571
572 #[test]
573 fn mode_returns_note_when_only_note_populated() {
574 let c = Content {
575 r#abstract: "a".into(),
576 context: None,
577 procedure: None,
578 command: None,
579 note: Some("# body".into()),
580 };
581 assert_eq!(c.mode(), Some(ContentMode::Note));
582 }
583
584 #[test]
585 fn mode_returns_none_when_note_and_context_both_populated() {
586 let c = Content {
587 r#abstract: "a".into(),
588 context: Some("ctx".into()),
589 procedure: None,
590 command: None,
591 note: Some("# body".into()),
592 };
593 assert_eq!(c.mode(), None);
594 }
595
596 #[test]
597 fn skill_without_evolution_log_defaults_to_empty() {
598 let yaml = r#"
600name: no-evol
601version: 0.1.0
602publisher: human:test
603description: test
604category: workflow
605content:
606 abstract: test
607"#;
608 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
609 assert!(m.evolution_log.is_empty());
610 }
611
612 #[test]
613 fn skill_with_evolution_log_roundtrips() {
614 let yaml = r#"
615name: with-evol
616version: 0.1.0
617publisher: human:test
618description: test
619category: workflow
620content:
621 abstract: test
622evolution_log:
623 - version: "0.1.0"
624 generation: 0
625 source: "human:test"
626 changes: "Initial"
627 timestamp: "2026-01-01T00:00:00Z"
628"#;
629 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
630 assert_eq!(m.evolution_log.len(), 1);
631 assert_eq!(m.evolution_log[0].version, "0.1.0");
632 let back = serde_yaml_ng::to_string(&m).unwrap();
634 let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
635 assert_eq!(m2.evolution_log.len(), 1);
636 assert_eq!(m2.evolution_log[0].generation, 0);
637 }
638
639 #[test]
640 fn exact_keyword_returns_pattern_for_keyword_triggers() {
641 let t = Trigger {
642 kind: TriggerKind::Keyword,
643 pattern: Some("search".into()),
644 };
645 assert_eq!(t.exact_keyword(), Some("search"));
646 }
647
648 #[test]
649 fn exact_keyword_returns_none_for_non_keyword_triggers() {
650 let t = Trigger {
651 kind: TriggerKind::Command,
652 pattern: Some("run".into()),
653 };
654 assert_eq!(t.exact_keyword(), None);
655
656 let t = Trigger {
657 kind: TriggerKind::SessionStart,
658 pattern: None,
659 };
660 assert_eq!(t.exact_keyword(), None);
661
662 let t = Trigger {
663 kind: TriggerKind::Manual,
664 pattern: None,
665 };
666 assert_eq!(t.exact_keyword(), None);
667 }
668
669 #[test]
670 fn exact_keyword_returns_none_when_pattern_is_none() {
671 let t = Trigger {
672 kind: TriggerKind::Keyword,
673 pattern: None,
674 };
675 assert_eq!(t.exact_keyword(), None);
676 }
677
678 #[test]
679 fn skill_scope_serde_and_default() {
680 assert_eq!(SkillScope::default(), SkillScope::User);
682 assert!(SkillScope::User.is_user());
683 assert!(!SkillScope::Project.is_user());
684 assert!(!SkillScope::Fleet.is_user());
685
686 let yaml = r#"
688name: scoped-skill
689version: 0.1.0
690publisher: human:test
691description: test
692category: workflow
693scope: fleet
694fleet: prod
695project: null
696content:
697 abstract: test
698"#;
699 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
700 assert_eq!(m.scope, SkillScope::Fleet);
701 assert_eq!(m.fleet, Some("prod".into()));
702 assert_eq!(m.project, None);
703
704 let back = serde_yaml_ng::to_string(&m).unwrap();
706 let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
707 assert_eq!(m2.scope, SkillScope::Fleet);
708 assert_eq!(m2.fleet, Some("prod".into()));
709
710 let yaml_no_scope = r#"
712name: default-scope
713version: 0.1.0
714publisher: human:test
715description: test
716category: workflow
717content:
718 abstract: test
719"#;
720 let m3: SkillManifest = serde_yaml_ng::from_str(yaml_no_scope).unwrap();
721 assert_eq!(m3.scope, SkillScope::User);
722 assert!(m3.fleet.is_none());
723 assert!(m3.project.is_none());
724 }
725
726 #[test]
727 fn scope_visible_matrix() {
728 assert!(scope_visible(
730 SkillScope::User,
731 None,
732 None,
733 None,
734 None,
735 None,
736 None
737 ));
738 assert!(scope_visible(
739 SkillScope::Enterprise,
740 None,
741 None,
742 None,
743 None,
744 None,
745 None
746 ));
747 assert!(scope_visible(
749 SkillScope::Fleet,
750 Some("dev"),
751 None,
752 None,
753 Some("dev"),
754 None,
755 None
756 ));
757 assert!(!scope_visible(
758 SkillScope::Fleet,
759 Some("dev"),
760 None,
761 None,
762 Some("ops"),
763 None,
764 None
765 ));
766 assert!(!scope_visible(
767 SkillScope::Fleet,
768 Some("dev"),
769 None,
770 None,
771 None,
772 None,
773 None
774 ));
775 assert!(scope_visible(
777 SkillScope::Project,
778 None,
779 Some("/p"),
780 None,
781 None,
782 Some("/p"),
783 None
784 ));
785 assert!(!scope_visible(
786 SkillScope::Project,
787 None,
788 Some("/p"),
789 None,
790 None,
791 Some("/q"),
792 None
793 ));
794 }
795
796 #[test]
797 fn team_scope_visibility() {
798 assert!(scope_visible(
800 SkillScope::Team,
801 None,
802 None,
803 Some("org-xyz"),
804 None,
805 None,
806 Some("org-xyz"),
807 ));
808 assert!(!scope_visible(
810 SkillScope::Team,
811 None,
812 None,
813 Some("org-abc"),
814 None,
815 None,
816 Some("org-xyz"),
817 ));
818 assert!(!scope_visible(
820 SkillScope::Team,
821 None,
822 None,
823 Some("org-xyz"),
824 None,
825 None,
826 None,
827 ));
828 assert!(!scope_visible(
830 SkillScope::Team,
831 None,
832 None,
833 None,
834 None,
835 None,
836 Some("org-xyz"),
837 ));
838 }
839
840 #[test]
841 fn governance_ref_roundtrip() {
842 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";
843 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
844 let g = m.governance.unwrap();
845 assert_eq!(g.org_id, "org-1");
846 assert_eq!(g.constitution_hash, "abc");
847 }
848
849 #[test]
850 fn governance_ref_absent_is_none() {
851 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();
852 assert!(m.governance.is_none());
853 }
854
855 #[test]
856 fn team_field_roundtrip() {
857 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";
858 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
859 assert_eq!(m.scope, SkillScope::Team);
860 assert_eq!(m.team.as_deref(), Some("org-1"));
861 }
862}