1use super::evolution::EvolutionEvent;
4use super::mcp::McpRequirement;
5use super::types::{Category, ContentMode, HostId, Priority, TriggerKind, TrustLevel};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Skill {
12 #[serde(flatten)]
13 pub manifest: SkillManifest,
14
15 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub content_sha256: Option<String>,
18
19 #[serde(default)]
21 pub trust_level: TrustLevel,
22
23 #[serde(default, skip_serializing_if = "Vec::is_empty")]
25 pub capabilities_declared: Vec<String>,
26
27 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub publisher_signature: Option<String>,
31}
32
33#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
62 pub evolution_log: Vec<EvolutionEvent>,
63
64 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub transfer_chain: Vec<String>,
69
70 #[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 pub r#abstract: String,
83
84 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub tool: Option<String>,
138
139 #[serde(default, skip_serializing_if = "Option::is_none")]
144 pub intent: Option<String>,
145
146 #[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 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 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 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}