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")]
144 pub origin: Option<String>,
145 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub origin_version: Option<String>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub origin_hash: Option<String>,
152
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub fleet: Option<String>,
156
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub team: Option<String>,
160
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub governance: Option<GovernanceRef>,
164
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub project: Option<String>,
168
169 #[serde(default)]
172 pub provenance: Provenance,
173
174 #[serde(default, skip_serializing_if = "Vec::is_empty")]
175 pub hosts: Vec<HostId>,
176
177 pub content: Content,
178
179 #[serde(default, skip_serializing_if = "Vec::is_empty")]
180 pub requires: Vec<Requirement>,
181
182 #[serde(default, skip_serializing_if = "Vec::is_empty")]
183 pub tags: Vec<String>,
184
185 #[serde(default, skip_serializing_if = "Vec::is_empty")]
186 pub triggers: Vec<Trigger>,
187
188 #[serde(default)]
189 pub priority: Priority,
190
191 #[serde(default, skip_serializing_if = "Vec::is_empty")]
193 pub evolution_log: Vec<EvolutionEvent>,
194
195 #[serde(default, skip_serializing_if = "Vec::is_empty")]
199 pub transfer_chain: Vec<String>,
200
201 #[serde(default, skip_serializing_if = "Vec::is_empty")]
207 pub mcp_requirements: Vec<McpRequirement>,
208
209 #[serde(default)]
213 pub updated_at: DateTime<Utc>,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
217pub struct Content {
218 pub r#abstract: String,
220
221 #[serde(default, skip_serializing_if = "Option::is_none")]
223 pub context: Option<String>,
224
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub procedure: Option<Procedure>,
227
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub command: Option<String>,
230
231 #[serde(default, skip_serializing_if = "Option::is_none")]
234 pub note: Option<String>,
235}
236
237impl Content {
238 pub fn mode(&self) -> Option<ContentMode> {
239 match (
240 self.context.is_some(),
241 self.procedure.is_some(),
242 self.command.is_some(),
243 self.note.is_some(),
244 ) {
245 (true, false, false, false) => Some(ContentMode::Context),
246 (false, true, false, false) => Some(ContentMode::Workflow),
247 (false, false, true, false) => Some(ContentMode::Command),
248 (false, false, false, true) => Some(ContentMode::Note),
249 _ => None,
250 }
251 }
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
255pub struct Procedure {
256 #[serde(default, skip_serializing_if = "Vec::is_empty")]
257 pub variables: Vec<Variable>,
258 pub steps: Vec<ProcedureStep>,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
263pub struct RetryConfig {
264 pub max_retries: u32,
265 #[serde(default)]
266 pub backoff_secs: Option<u64>,
267}
268
269#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
271#[serde(rename_all = "lowercase")]
272pub enum FailureAction {
273 Skip,
275 #[default]
277 Abort,
278 Retry,
280}
281
282#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
283pub struct Variable {
284 pub name: String,
285 #[serde(rename = "type", default)]
286 pub var_type: VarType,
287 #[serde(default)]
288 pub required: bool,
289 #[serde(
293 default,
294 alias = "default_value",
295 skip_serializing_if = "Option::is_none"
296 )]
297 pub default: Option<String>,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub description: Option<String>,
300 #[serde(default, skip_serializing_if = "Vec::is_empty")]
302 pub choices: Vec<String>,
303}
304
305#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
308#[serde(rename_all = "lowercase")]
309pub enum VarType {
310 #[default]
311 String,
312 Path,
313 Url,
314 Number,
315 Bool,
316 Array,
318}
319
320impl std::fmt::Display for VarType {
321 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
322 match self {
323 VarType::String => write!(f, "string"),
324 VarType::Path => write!(f, "path"),
325 VarType::Url => write!(f, "url"),
326 VarType::Number => write!(f, "number"),
327 VarType::Bool => write!(f, "bool"),
328 VarType::Array => write!(f, "array"),
329 }
330 }
331}
332
333#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
334pub struct ProcedureStep {
335 pub description: String,
336
337 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub tool: Option<String>,
341
342 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub intent: Option<String>,
348
349 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub tool_hint: Option<String>,
354
355 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub id: Option<String>,
361
362 #[serde(default, skip_serializing_if = "Vec::is_empty")]
365 pub depends_on: Vec<String>,
366
367 #[serde(default, skip_serializing_if = "Option::is_none")]
371 pub command: Option<String>,
372
373 #[serde(default)]
374 pub on_failure: FailureAction,
375
376 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub retry: Option<RetryConfig>,
378
379 #[serde(default, skip_serializing_if = "Option::is_none")]
380 pub timeout_secs: Option<u64>,
381
382 #[serde(default)]
386 pub needs_approval: bool,
387
388 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub delegate_to: Option<String>,
395
396 #[serde(default, skip_serializing_if = "Option::is_none")]
399 pub risk: Option<crate::hitl::RiskTier>,
400}
401
402#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
403pub struct Trigger {
404 #[serde(rename = "type")]
405 pub kind: TriggerKind,
406 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub pattern: Option<String>,
408}
409
410impl Trigger {
411 pub fn exact_keyword(&self) -> Option<&str> {
413 if matches!(self.kind, TriggerKind::Keyword) {
414 self.pattern.as_deref()
415 } else {
416 None
417 }
418 }
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
422pub struct Requirement {
423 pub name: String,
424 #[serde(default = "default_any_version")]
425 pub version: String,
426}
427
428fn default_any_version() -> String {
429 "*".to_string()
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 #[test]
437 fn visibility_defaults_to_indexed_and_parses_on_demand() {
438 let yaml = r#"
439name: vis-default
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_eq!(m.visibility, Visibility::Indexed);
449
450 let yaml2 = format!("{yaml}visibility: on_demand\n");
451 let m2: SkillManifest = serde_yaml_ng::from_str(&yaml2).unwrap();
452 assert_eq!(m2.visibility, Visibility::OnDemand);
453
454 let out = serde_yaml_ng::to_string(&m).unwrap();
456 assert!(!out.contains("visibility"));
457 let out2 = serde_yaml_ng::to_string(&m2).unwrap();
458 assert!(out2.contains("visibility: on_demand"));
459 }
460
461 #[test]
462 fn procedure_step_dag_fields_roundtrip() {
463 let yaml = r#"
464description: deploy the app
465command: "fly deploy --app {{app_name}}"
466id: deploy
467depends_on: [build, test]
468on_failure: retry
469retry:
470 max_retries: 2
471 backoff_secs: 5
472timeout_secs: 300
473needs_approval: true
474"#;
475 let step: ProcedureStep = serde_yaml_ng::from_str(yaml).unwrap();
476 assert_eq!(step.id.as_deref(), Some("deploy"));
477 assert_eq!(step.depends_on, vec!["build", "test"]);
478 assert_eq!(step.on_failure, FailureAction::Retry);
479 assert_eq!(step.retry.as_ref().unwrap().max_retries, 2);
480 assert_eq!(step.timeout_secs, Some(300));
481 assert!(step.needs_approval);
482
483 let legacy: ProcedureStep =
485 serde_yaml_ng::from_str("description: run tests\ntool: Bash\n").unwrap();
486 assert!(legacy.id.is_none());
487 assert!(legacy.depends_on.is_empty());
488 assert_eq!(legacy.on_failure, FailureAction::Abort);
489 assert!(!legacy.needs_approval);
490 }
491
492 #[test]
493 fn procedure_step_parses_delegate_to() {
494 let yaml = "description: hand off to qa\ndelegate_to: qa\n";
495 let s: ProcedureStep = serde_yaml_ng::from_str(yaml).unwrap();
496 assert_eq!(s.delegate_to.as_deref(), Some("qa"));
497 let s2: ProcedureStep = serde_yaml_ng::from_str("description: local step\n").unwrap();
499 assert_eq!(s2.delegate_to, None);
500 }
501
502 #[test]
503 fn variable_accepts_legacy_default_value_alias() {
504 let v: Variable = serde_yaml_ng::from_str(
506 "name: app\ntype: string\nrequired: true\ndefault_value: my-api\n",
507 )
508 .unwrap();
509 assert_eq!(v.default.as_deref(), Some("my-api"));
510 assert_eq!(v.var_type, VarType::String);
511
512 let v2: Variable =
514 serde_yaml_ng::from_str("name: env\ntype: string\ndefault: prod\n").unwrap();
515 assert_eq!(v2.default.as_deref(), Some("prod"));
516 assert!(v2.choices.is_empty());
517 }
518
519 #[test]
520 fn variable_all_vartypes_parse() {
521 for t in ["string", "path", "url", "number", "bool", "array"] {
522 let v: Variable = serde_yaml_ng::from_str(&format!("name: x\ntype: {t}\n")).unwrap();
523 assert_eq!(v.var_type.to_string(), t);
524 }
525 }
526
527 #[test]
528 fn full_manifest_roundtrips() {
529 let yaml = r#"
530name: research-prices
531version: 1.0.0
532publisher: human:david
533description: Search product prices
534category: workflow
535hosts: [mur-agent]
536content:
537 abstract: Searches product prices.
538 procedure:
539 variables:
540 - name: product_name
541 type: string
542 required: true
543 steps:
544 - description: Navigate
545 tool: browser.navigate
546triggers:
547 - type: command
548 pattern: /research-prices
549priority: normal
550"#;
551 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
552 assert_eq!(m.name, "research-prices");
553 assert_eq!(m.category, Category::Workflow);
554 assert_eq!(m.content.mode(), Some(ContentMode::Workflow));
555 let back = serde_yaml_ng::to_string(&m).unwrap();
556 let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
557 assert_eq!(m2.name, m.name);
558 }
559
560 #[test]
561 fn context_mode_detected() {
562 let c = Content {
563 r#abstract: "a".into(),
564 context: Some("ctx".into()),
565 procedure: None,
566 command: None,
567 note: None,
568 };
569 assert_eq!(c.mode(), Some(ContentMode::Context));
570 }
571
572 #[test]
573 fn empty_content_returns_no_mode() {
574 let c = Content {
575 r#abstract: "a".into(),
576 context: None,
577 procedure: None,
578 command: None,
579 note: None,
580 };
581 assert_eq!(c.mode(), None);
582 }
583
584 #[test]
585 fn mode_returns_note_when_only_note_populated() {
586 let c = Content {
587 r#abstract: "a".into(),
588 context: None,
589 procedure: None,
590 command: None,
591 note: Some("# body".into()),
592 };
593 assert_eq!(c.mode(), Some(ContentMode::Note));
594 }
595
596 #[test]
597 fn mode_returns_none_when_note_and_context_both_populated() {
598 let c = Content {
599 r#abstract: "a".into(),
600 context: Some("ctx".into()),
601 procedure: None,
602 command: None,
603 note: Some("# body".into()),
604 };
605 assert_eq!(c.mode(), None);
606 }
607
608 #[test]
609 fn skill_without_evolution_log_defaults_to_empty() {
610 let yaml = r#"
612name: no-evol
613version: 0.1.0
614publisher: human:test
615description: test
616category: workflow
617content:
618 abstract: test
619"#;
620 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
621 assert!(m.evolution_log.is_empty());
622 }
623
624 #[test]
625 fn skill_with_evolution_log_roundtrips() {
626 let yaml = r#"
627name: with-evol
628version: 0.1.0
629publisher: human:test
630description: test
631category: workflow
632content:
633 abstract: test
634evolution_log:
635 - version: "0.1.0"
636 generation: 0
637 source: "human:test"
638 changes: "Initial"
639 timestamp: "2026-01-01T00:00:00Z"
640"#;
641 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
642 assert_eq!(m.evolution_log.len(), 1);
643 assert_eq!(m.evolution_log[0].version, "0.1.0");
644 let back = serde_yaml_ng::to_string(&m).unwrap();
646 let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
647 assert_eq!(m2.evolution_log.len(), 1);
648 assert_eq!(m2.evolution_log[0].generation, 0);
649 }
650
651 #[test]
652 fn exact_keyword_returns_pattern_for_keyword_triggers() {
653 let t = Trigger {
654 kind: TriggerKind::Keyword,
655 pattern: Some("search".into()),
656 };
657 assert_eq!(t.exact_keyword(), Some("search"));
658 }
659
660 #[test]
661 fn exact_keyword_returns_none_for_non_keyword_triggers() {
662 let t = Trigger {
663 kind: TriggerKind::Command,
664 pattern: Some("run".into()),
665 };
666 assert_eq!(t.exact_keyword(), None);
667
668 let t = Trigger {
669 kind: TriggerKind::SessionStart,
670 pattern: None,
671 };
672 assert_eq!(t.exact_keyword(), None);
673
674 let t = Trigger {
675 kind: TriggerKind::Manual,
676 pattern: None,
677 };
678 assert_eq!(t.exact_keyword(), None);
679 }
680
681 #[test]
682 fn exact_keyword_returns_none_when_pattern_is_none() {
683 let t = Trigger {
684 kind: TriggerKind::Keyword,
685 pattern: None,
686 };
687 assert_eq!(t.exact_keyword(), None);
688 }
689
690 #[test]
691 fn skill_scope_serde_and_default() {
692 assert_eq!(SkillScope::default(), SkillScope::User);
694 assert!(SkillScope::User.is_user());
695 assert!(!SkillScope::Project.is_user());
696 assert!(!SkillScope::Fleet.is_user());
697
698 let yaml = r#"
700name: scoped-skill
701version: 0.1.0
702publisher: human:test
703description: test
704category: workflow
705scope: fleet
706fleet: prod
707project: null
708content:
709 abstract: test
710"#;
711 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
712 assert_eq!(m.scope, SkillScope::Fleet);
713 assert_eq!(m.fleet, Some("prod".into()));
714 assert_eq!(m.project, None);
715
716 let back = serde_yaml_ng::to_string(&m).unwrap();
718 let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
719 assert_eq!(m2.scope, SkillScope::Fleet);
720 assert_eq!(m2.fleet, Some("prod".into()));
721
722 let yaml_no_scope = r#"
724name: default-scope
725version: 0.1.0
726publisher: human:test
727description: test
728category: workflow
729content:
730 abstract: test
731"#;
732 let m3: SkillManifest = serde_yaml_ng::from_str(yaml_no_scope).unwrap();
733 assert_eq!(m3.scope, SkillScope::User);
734 assert!(m3.fleet.is_none());
735 assert!(m3.project.is_none());
736 }
737
738 #[test]
739 fn scope_visible_matrix() {
740 assert!(scope_visible(
742 SkillScope::User,
743 None,
744 None,
745 None,
746 None,
747 None,
748 None
749 ));
750 assert!(scope_visible(
751 SkillScope::Enterprise,
752 None,
753 None,
754 None,
755 None,
756 None,
757 None
758 ));
759 assert!(scope_visible(
761 SkillScope::Fleet,
762 Some("dev"),
763 None,
764 None,
765 Some("dev"),
766 None,
767 None
768 ));
769 assert!(!scope_visible(
770 SkillScope::Fleet,
771 Some("dev"),
772 None,
773 None,
774 Some("ops"),
775 None,
776 None
777 ));
778 assert!(!scope_visible(
779 SkillScope::Fleet,
780 Some("dev"),
781 None,
782 None,
783 None,
784 None,
785 None
786 ));
787 assert!(scope_visible(
789 SkillScope::Project,
790 None,
791 Some("/p"),
792 None,
793 None,
794 Some("/p"),
795 None
796 ));
797 assert!(!scope_visible(
798 SkillScope::Project,
799 None,
800 Some("/p"),
801 None,
802 None,
803 Some("/q"),
804 None
805 ));
806 }
807
808 #[test]
809 fn team_scope_visibility() {
810 assert!(scope_visible(
812 SkillScope::Team,
813 None,
814 None,
815 Some("org-xyz"),
816 None,
817 None,
818 Some("org-xyz"),
819 ));
820 assert!(!scope_visible(
822 SkillScope::Team,
823 None,
824 None,
825 Some("org-abc"),
826 None,
827 None,
828 Some("org-xyz"),
829 ));
830 assert!(!scope_visible(
832 SkillScope::Team,
833 None,
834 None,
835 Some("org-xyz"),
836 None,
837 None,
838 None,
839 ));
840 assert!(!scope_visible(
842 SkillScope::Team,
843 None,
844 None,
845 None,
846 None,
847 None,
848 Some("org-xyz"),
849 ));
850 }
851
852 #[test]
853 fn governance_ref_roundtrip() {
854 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";
855 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
856 let g = m.governance.unwrap();
857 assert_eq!(g.org_id, "org-1");
858 assert_eq!(g.constitution_hash, "abc");
859 }
860
861 #[test]
862 fn governance_ref_absent_is_none() {
863 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();
864 assert!(m.governance.is_none());
865 }
866
867 #[test]
868 fn team_field_roundtrip() {
869 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";
870 let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
871 assert_eq!(m.scope, SkillScope::Team);
872 assert_eq!(m.team.as_deref(), Some("org-1"));
873 }
874}