Skip to main content

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}