zeph_skills/extensions.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Optional platform-extension manifest for skills.
5//!
6//! The `extensions:` YAML block inside a `SKILL.md` frontmatter lets a skill declare
7//! UI elements, keybindings, and background monitors that a host platform may wire up.
8//! Parsing is best-effort: any error in this block logs a warning and falls back to
9//! `None` so that the skill continues to load normally.
10//!
11//! # SKILL.md Format
12//!
13//! ```text
14//! ---
15//! name: my-skill
16//! description: Does something useful.
17//! extensions:
18//! ui:
19//! - type: toolbar_button
20//! label: Run My Skill
21//! icon: play
22//! keybindings:
23//! - chord: ctrl+shift+r
24//! action: run-my-skill
25//! monitors:
26//! - trigger: file_changed
27//! action: reload-my-skill
28//! ---
29//! ```
30
31/// A UI element declared by a skill extension manifest.
32///
33/// The `type` tag selects which variant is deserialized. Currently supported variants
34/// are `toolbar_button` and `menu_item`.
35///
36/// # Examples
37///
38/// ```rust
39/// use zeph_skills::extensions::SkillUiElement;
40///
41/// let yaml = r#"
42/// - type: toolbar_button
43/// label: Run
44/// icon: play
45/// - type: menu_item
46/// label: Open Skill
47/// action: open-skill
48/// "#;
49/// let elements: Vec<SkillUiElement> = serde_norway::from_str(yaml).unwrap();
50/// assert!(matches!(elements[0], SkillUiElement::ToolbarButton { .. }));
51/// assert!(matches!(elements[1], SkillUiElement::MenuItem { .. }));
52/// ```
53#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
54#[serde(tag = "type", rename_all = "snake_case")]
55#[non_exhaustive]
56pub enum SkillUiElement {
57 /// A button rendered in the host application's toolbar.
58 ToolbarButton {
59 /// Display label for the button.
60 label: String,
61 /// Optional icon identifier (platform-defined).
62 icon: Option<String>,
63 },
64 /// An item injected into a host application menu.
65 MenuItem {
66 /// Display label for the menu item.
67 label: String,
68 /// Action identifier dispatched when the item is selected.
69 action: String,
70 },
71}
72
73/// A keyboard shortcut declared by a skill extension manifest.
74///
75/// # Examples
76///
77/// ```rust
78/// use zeph_skills::extensions::SkillKeybinding;
79///
80/// let yaml = "chord: ctrl+shift+r\naction: run-my-skill\n";
81/// let kb: SkillKeybinding = serde_norway::from_str(yaml).unwrap();
82/// assert_eq!(kb.chord, "ctrl+shift+r");
83/// assert_eq!(kb.action, "run-my-skill");
84/// ```
85#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
86#[non_exhaustive]
87pub struct SkillKeybinding {
88 /// Key chord string in platform-normalized notation (e.g. `"ctrl+shift+r"`).
89 pub chord: String,
90 /// Action identifier dispatched when the chord is pressed.
91 pub action: String,
92}
93
94/// A background monitor declared by a skill extension manifest.
95///
96/// Monitors let a skill react to host events without explicit user invocation.
97///
98/// # Examples
99///
100/// ```rust
101/// use zeph_skills::extensions::SkillMonitor;
102///
103/// let yaml = "trigger: file_changed\naction: reload-my-skill\n";
104/// let mon: SkillMonitor = serde_norway::from_str(yaml).unwrap();
105/// assert_eq!(mon.trigger, "file_changed");
106/// assert_eq!(mon.action, "reload-my-skill");
107/// ```
108#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
109#[non_exhaustive]
110pub struct SkillMonitor {
111 /// Event name that activates this monitor (platform-defined).
112 pub trigger: String,
113 /// Action identifier dispatched when the trigger fires.
114 pub action: String,
115}
116
117/// Optional platform-extension manifest parsed from a skill's `SKILL.md` `extensions:` block.
118///
119/// All fields default to empty. When the `extensions:` block is absent from a SKILL.md,
120/// [`parse_extensions`] returns `None` rather than a default value.
121///
122/// # Examples
123///
124/// ```rust
125/// use zeph_skills::extensions::SkillExtensions;
126///
127/// let yaml = r#"
128/// ui:
129/// - type: toolbar_button
130/// label: "Quick Refactor"
131/// keybindings:
132/// - chord: "cmd+shift+r"
133/// action: "refactor-selected"
134/// "#;
135/// let ext: SkillExtensions = serde_norway::from_str(yaml).unwrap();
136/// assert_eq!(ext.keybindings[0].chord, "cmd+shift+r");
137/// assert_eq!(ext.ui.len(), 1);
138/// ```
139#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, PartialEq)]
140#[non_exhaustive]
141pub struct SkillExtensions {
142 /// UI elements (toolbar buttons, menu items) the skill contributes to the host UI.
143 #[serde(default, skip_serializing_if = "Vec::is_empty")]
144 pub ui: Vec<SkillUiElement>,
145 /// Keybindings the skill registers with the host.
146 #[serde(default, skip_serializing_if = "Vec::is_empty")]
147 pub keybindings: Vec<SkillKeybinding>,
148 /// Background monitors the skill wants the host to activate.
149 #[serde(default, skip_serializing_if = "Vec::is_empty")]
150 pub monitors: Vec<SkillMonitor>,
151}
152
153/// Extract an `extensions:` YAML sub-block from a raw SKILL.md frontmatter string and
154/// parse it into [`SkillExtensions`].
155///
156/// Returns `None` if the block is absent or if parsing fails (a warning is logged).
157/// This function never panics and never propagates errors — failures are always `None`.
158#[must_use]
159pub fn parse_extensions(yaml_str: &str) -> Option<SkillExtensions> {
160 let block = extract_extensions_block(yaml_str)?;
161 if block.len() > 8 * 1024 {
162 tracing::warn!("'extensions:' block exceeds 8 KiB, skipping");
163 return None;
164 }
165 match serde_norway::from_str::<SkillExtensions>(&block) {
166 Ok(ext) => Some(ext),
167 Err(e) => {
168 tracing::warn!("failed to parse 'extensions:' block in SKILL.md frontmatter: {e}");
169 None
170 }
171 }
172}
173
174/// Pull the indented content under `extensions:` from a raw YAML frontmatter string.
175///
176/// Returns a YAML string suitable for deserializing into [`SkillExtensions`], or `None`
177/// if the `extensions:` key is not present.
178fn extract_extensions_block(yaml_str: &str) -> Option<String> {
179 let mut in_block = false;
180 let mut lines = Vec::new();
181
182 for line in yaml_str.lines() {
183 if in_block {
184 if line.starts_with(' ') || line.starts_with('\t') || line.trim().is_empty() {
185 // Collect indented content; strip one level of indentation (2 spaces).
186 let stripped = line.strip_prefix(" ").unwrap_or(line);
187 lines.push(stripped);
188 } else {
189 // A non-indented line means we've exited the block.
190 break;
191 }
192 } else if line.trim_start() == "extensions:" || line.starts_with("extensions:") {
193 in_block = true;
194 }
195 }
196
197 if lines.is_empty() {
198 return None;
199 }
200
201 Some(lines.join("\n"))
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn parse_full_extensions_block() {
210 let yaml = "\
211name: my-skill
212description: A skill.
213extensions:
214 ui:
215 - type: toolbar_button
216 label: Run
217 icon: play
218 keybindings:
219 - chord: ctrl+r
220 action: run
221 monitors:
222 - trigger: file_changed
223 action: reload
224";
225 let ext = parse_extensions(yaml).expect("should parse extensions");
226 assert_eq!(ext.ui.len(), 1);
227 assert!(matches!(
228 &ext.ui[0],
229 SkillUiElement::ToolbarButton { label, icon }
230 if label == "Run" && icon.as_deref() == Some("play")
231 ));
232 assert_eq!(ext.keybindings.len(), 1);
233 assert_eq!(ext.keybindings[0].chord, "ctrl+r");
234 assert_eq!(ext.keybindings[0].action, "run");
235 assert_eq!(ext.monitors.len(), 1);
236 assert_eq!(ext.monitors[0].trigger, "file_changed");
237 assert_eq!(ext.monitors[0].action, "reload");
238 }
239
240 #[test]
241 fn parse_extensions_absent_returns_none() {
242 let yaml = "name: my-skill\ndescription: A skill.\n";
243 assert!(parse_extensions(yaml).is_none());
244 }
245
246 #[test]
247 fn parse_extensions_empty_block_returns_default() {
248 // An `extensions:` key with no sub-keys deserializes to Default.
249 let yaml = "name: my-skill\ndescription: desc.\nextensions:\nother: field\n";
250 // Block is empty → None (no lines collected).
251 let result = parse_extensions(yaml);
252 // Empty extensions block → None (nothing to parse).
253 assert!(result.is_none());
254 }
255
256 #[test]
257 fn parse_extensions_invalid_yaml_returns_none() {
258 // Malformed YAML in extensions block must not fail skill loading.
259 let yaml = "extensions:\n ui:\n - type: !!invalid\n";
260 // Should not panic; may return None.
261 let _ = parse_extensions(yaml);
262 }
263
264 #[test]
265 fn parse_extensions_only_ui() {
266 let yaml = "\
267extensions:
268 ui:
269 - type: menu_item
270 label: Open
271 action: open-skill
272";
273 let ext = parse_extensions(yaml).expect("should parse");
274 assert_eq!(ext.ui.len(), 1);
275 assert!(matches!(
276 &ext.ui[0],
277 SkillUiElement::MenuItem { label, action }
278 if label == "Open" && action == "open-skill"
279 ));
280 assert!(ext.keybindings.is_empty());
281 assert!(ext.monitors.is_empty());
282 }
283
284 #[test]
285 fn parse_extensions_toolbar_button_no_icon() {
286 let yaml = "\
287extensions:
288 ui:
289 - type: toolbar_button
290 label: Run
291";
292 let ext = parse_extensions(yaml).expect("should parse");
293 assert!(matches!(
294 &ext.ui[0],
295 SkillUiElement::ToolbarButton { label, icon }
296 if label == "Run" && icon.is_none()
297 ));
298 }
299
300 #[test]
301 fn roundtrip_yaml() {
302 let ext = SkillExtensions {
303 ui: vec![SkillUiElement::ToolbarButton {
304 label: "Run".into(),
305 icon: Some("play".into()),
306 }],
307 keybindings: vec![SkillKeybinding {
308 chord: "ctrl+r".into(),
309 action: "run".into(),
310 }],
311 monitors: vec![SkillMonitor {
312 trigger: "file_changed".into(),
313 action: "reload".into(),
314 }],
315 };
316 let yaml = serde_norway::to_string(&ext).expect("serialize");
317 let parsed: SkillExtensions = serde_norway::from_str(&yaml).expect("deserialize");
318 assert_eq!(ext, parsed);
319 }
320
321 #[test]
322 fn default_is_all_empty() {
323 let ext = SkillExtensions::default();
324 assert!(ext.ui.is_empty());
325 assert!(ext.keybindings.is_empty());
326 assert!(ext.monitors.is_empty());
327 }
328
329 #[test]
330 fn extensions_block_stops_at_next_top_level_field() {
331 // After the extensions block, other frontmatter fields must not bleed into it.
332 let yaml = "\
333extensions:
334 keybindings:
335 - chord: ctrl+k
336 action: do-something
337other-field: should-not-appear
338";
339 let ext = parse_extensions(yaml).expect("should parse");
340 assert_eq!(ext.keybindings.len(), 1);
341 assert_eq!(ext.keybindings[0].chord, "ctrl+k");
342 }
343}