Skip to main content

mur_common/skill/
manifest.rs

1//! Skill manifest — full serde representation of canonical `skill.yaml`.
2
3use super::evolution::EvolutionEvent;
4use super::mcp::McpRequirement;
5use super::types::{Category, ContentMode, HostId, Priority, TriggerKind, TrustLevel};
6use serde::{Deserialize, Serialize};
7
8/// Top-level skill — wraps the manifest with security metadata that lives
9/// alongside (but separate from) the publisher-authored fields.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Skill {
12    #[serde(flatten)]
13    pub manifest: SkillManifest,
14
15    /// Computed at install time. Never serialized into the source YAML.
16    #[serde(default, skip_serializing_if = "Option::is_none")]
17    pub content_sha256: Option<String>,
18
19    /// Set by the trust store at install time, not by the publisher.
20    #[serde(default)]
21    pub trust_level: TrustLevel,
22
23    /// Capabilities the skill declares it needs.
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub capabilities_declared: Vec<String>,
26
27    /// DSSE envelope JSON (base64-encoded inside the envelope). `None` for
28    /// unsigned skills — they enter at Sandboxed and stay there.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub publisher_signature: Option<String>,
31}
32
33/// Publisher-authored fields. This is what gets signed and is the unit of
34/// content hashing.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SkillManifest {
37    pub name: String,
38    pub version: String,
39    pub publisher: String,
40    pub description: String,
41    pub category: Category,
42
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub hosts: Vec<HostId>,
45
46    pub content: Content,
47
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub requires: Vec<Requirement>,
50
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub tags: Vec<String>,
53
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub triggers: Vec<Trigger>,
56
57    #[serde(default)]
58    pub priority: Priority,
59
60    /// Evolution history — each entry records one generation.
61    #[serde(default, skip_serializing_if = "Vec::is_empty")]
62    pub evolution_log: Vec<EvolutionEvent>,
63
64    /// Peer transfer provenance — each entry is `agent://<name>`.
65    /// Last entry is the immediate source; first entry is the original publisher.
66    /// Empty for registry-installed and locally-authored skills.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    pub transfer_chain: Vec<String>,
69
70    /// MCP tool capabilities this skill needs at runtime. Optional; absent
71    /// in M3-era v2.0 manifests. Added in schema v2.1.
72    ///
73    /// **Signature scope:** signed as part of the manifest. Changing
74    /// `mcp_requirements` invalidates an existing publisher signature.
75    #[serde(default, skip_serializing_if = "Vec::is_empty")]
76    pub mcp_requirements: Vec<McpRequirement>,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct Content {
81    /// Layer 2 — injected into the system prompt at session start.
82    pub r#abstract: String,
83
84    /// Exactly one of the following is `Some`. Enforced by schema validation.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub context: Option<String>,
87
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub procedure: Option<Procedure>,
90
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub command: Option<String>,
93}
94
95impl Content {
96    pub fn mode(&self) -> Option<ContentMode> {
97        match (
98            self.context.is_some(),
99            self.procedure.is_some(),
100            self.command.is_some(),
101        ) {
102            (true, false, false) => Some(ContentMode::Context),
103            (false, true, false) => Some(ContentMode::Workflow),
104            (false, false, true) => Some(ContentMode::Command),
105            _ => None,
106        }
107    }
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct Procedure {
112    #[serde(default, skip_serializing_if = "Vec::is_empty")]
113    pub variables: Vec<Variable>,
114    pub steps: Vec<ProcedureStep>,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct Variable {
119    pub name: String,
120    #[serde(rename = "type")]
121    pub var_type: String,
122    #[serde(default)]
123    pub required: bool,
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub default: Option<serde_yaml_ng::Value>,
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub description: Option<String>,
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct ProcedureStep {
132    pub description: String,
133
134    /// Literal tool name. Pre-M6b behaviour: hard binding. Post-M6b: treated
135    /// as a hint when `intent` is also set; otherwise still a hard binding.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub tool: Option<String>,
138
139    /// What the step is trying to accomplish. Free-form string, no central
140    /// taxonomy. Resolved at inject time against the agent's MCP inventory.
141    /// When set, the resolver prefers a tool whose name matches a glob in
142    /// `mcp_requirements` over the literal `tool` field.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub intent: Option<String>,
145
146    /// Preferred tool name pattern (glob). Used as a tiebreaker among
147    /// resolver candidates. Falls back to literal `tool`, then to any
148    /// `mcp_requirements` match for the intent.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub tool_hint: Option<String>,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct Trigger {
155    #[serde(rename = "type")]
156    pub kind: TriggerKind,
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub pattern: Option<String>,
159}
160
161impl Trigger {
162    /// Returns the keyword string for `Keyword` triggers, `None` otherwise.
163    pub fn exact_keyword(&self) -> Option<&str> {
164        if matches!(self.kind, TriggerKind::Keyword) {
165            self.pattern.as_deref()
166        } else {
167            None
168        }
169    }
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct Requirement {
174    pub name: String,
175    #[serde(default = "default_any_version")]
176    pub version: String,
177}
178
179fn default_any_version() -> String {
180    "*".to_string()
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn full_manifest_roundtrips() {
189        let yaml = r#"
190name: research-prices
191version: 1.0.0
192publisher: human:david
193description: Search product prices
194category: workflow
195hosts: [mur-agent]
196content:
197  abstract: Searches product prices.
198  procedure:
199    variables:
200      - name: product_name
201        type: string
202        required: true
203    steps:
204      - description: Navigate
205        tool: browser.navigate
206triggers:
207  - type: command
208    pattern: /research-prices
209priority: normal
210"#;
211        let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
212        assert_eq!(m.name, "research-prices");
213        assert_eq!(m.category, Category::Workflow);
214        assert_eq!(m.content.mode(), Some(ContentMode::Workflow));
215        let back = serde_yaml_ng::to_string(&m).unwrap();
216        let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
217        assert_eq!(m2.name, m.name);
218    }
219
220    #[test]
221    fn context_mode_detected() {
222        let c = Content {
223            r#abstract: "a".into(),
224            context: Some("ctx".into()),
225            procedure: None,
226            command: None,
227        };
228        assert_eq!(c.mode(), Some(ContentMode::Context));
229    }
230
231    #[test]
232    fn empty_content_returns_no_mode() {
233        let c = Content {
234            r#abstract: "a".into(),
235            context: None,
236            procedure: None,
237            command: None,
238        };
239        assert_eq!(c.mode(), None);
240    }
241
242    #[test]
243    fn skill_without_evolution_log_defaults_to_empty() {
244        // YAML without evolution_log field must parse and default to vec![].
245        let yaml = r#"
246name: no-evol
247version: 0.1.0
248publisher: human:test
249description: test
250category: workflow
251content:
252  abstract: test
253"#;
254        let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
255        assert!(m.evolution_log.is_empty());
256    }
257
258    #[test]
259    fn skill_with_evolution_log_roundtrips() {
260        let yaml = r#"
261name: with-evol
262version: 0.1.0
263publisher: human:test
264description: test
265category: workflow
266content:
267  abstract: test
268evolution_log:
269  - version: "0.1.0"
270    generation: 0
271    source: "human:test"
272    changes: "Initial"
273    timestamp: "2026-01-01T00:00:00Z"
274"#;
275        let m: SkillManifest = serde_yaml_ng::from_str(yaml).unwrap();
276        assert_eq!(m.evolution_log.len(), 1);
277        assert_eq!(m.evolution_log[0].version, "0.1.0");
278        // Round-trip.
279        let back = serde_yaml_ng::to_string(&m).unwrap();
280        let m2: SkillManifest = serde_yaml_ng::from_str(&back).unwrap();
281        assert_eq!(m2.evolution_log.len(), 1);
282        assert_eq!(m2.evolution_log[0].generation, 0);
283    }
284
285    #[test]
286    fn exact_keyword_returns_pattern_for_keyword_triggers() {
287        let t = Trigger {
288            kind: TriggerKind::Keyword,
289            pattern: Some("search".into()),
290        };
291        assert_eq!(t.exact_keyword(), Some("search"));
292    }
293
294    #[test]
295    fn exact_keyword_returns_none_for_non_keyword_triggers() {
296        let t = Trigger {
297            kind: TriggerKind::Command,
298            pattern: Some("run".into()),
299        };
300        assert_eq!(t.exact_keyword(), None);
301
302        let t = Trigger {
303            kind: TriggerKind::SessionStart,
304            pattern: None,
305        };
306        assert_eq!(t.exact_keyword(), None);
307
308        let t = Trigger {
309            kind: TriggerKind::Manual,
310            pattern: None,
311        };
312        assert_eq!(t.exact_keyword(), None);
313    }
314
315    #[test]
316    fn exact_keyword_returns_none_when_pattern_is_none() {
317        let t = Trigger {
318            kind: TriggerKind::Keyword,
319            pattern: None,
320        };
321        assert_eq!(t.exact_keyword(), None);
322    }
323}