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