Skip to main content

wyvern/extensions/
catalog.rs

1//! Skill catalog: [`SkillRecord`], list/show JSON, and the sole text formatter.
2//!
3//! `wyvern --help`, extension prefix `--help`, `extensions list`, and
4//! `extensions show` all call [`format_skill_card`] after
5//! [`build_skill_record`]. There is no second formatter.
6
7use std::collections::BTreeSet;
8
9use serde::Serialize;
10use serde_json::Value;
11
12use super::{
13    match_kind_summary, ArgName, BinaryName, ExtensionDef, ExtensionId, ExtensionRegistry,
14    MatchToken, PreexecSpec, RequiresProbe, SkillSource,
15};
16
17/// One declared `{arg:name}` / `{arg:name:repeat}` flag.
18#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
19pub struct SkillArg {
20    /// Flag name without leading dashes.
21    pub name: ArgName,
22    /// `true` when the template uses `{arg:name}` (missing is an error).
23    pub required: bool,
24    /// `true` when the template uses `{arg:name:repeat}`.
25    pub repeat: bool,
26}
27
28/// One `preexec.requires` binary and its current PATH availability.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
30pub struct SkillRequire {
31    /// Bare binary name from the registry.
32    pub binary: BinaryName,
33    /// Result of [`RequiresProbe::binary_on_path`] at build time.
34    pub available: bool,
35}
36
37/// One catalog / help record for a resolved extension (REQ-0132).
38#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
39pub struct SkillRecord {
40    /// Extension id.
41    pub id: ExtensionId,
42    /// Human match DSL (`prefix: compose render`, `prefix+suffix: md .csv`).
43    pub match_kind: String,
44    /// Copy-paste invocation pattern, including declared flags.
45    pub invocation: String,
46    /// Required binaries and whether each is on `PATH`.
47    pub requires: Vec<SkillRequire>,
48    /// Declared `{arg:*}` flags.
49    pub args: Vec<SkillArg>,
50    /// Expand command `type`, or `"wizard"` when using `command_from_file`.
51    pub expands_to: String,
52    /// One-line agent-facing summary from the registry, if present.
53    pub description: Option<String>,
54    /// Copy-paste example lines.
55    pub examples: Vec<String>,
56    /// Parent extension id when `extends` is set; otherwise `null` on the wire.
57    pub extends: Option<ExtensionId>,
58    /// `shipped` defaults or `project` `.wyvern/extensions.json`.
59    pub source: SkillSource,
60}
61
62/// Build a help-oriented [`SkillRecord`] from a resolved extension.
63///
64/// `probe` is evaluated at call time (not cached). Help still builds a card
65/// when required binaries are missing.
66#[must_use]
67pub fn build_skill_record(ext: &ExtensionDef, probe: &dyn RequiresProbe) -> SkillRecord {
68    let args = declared_skill_args(ext);
69    let invocation = invocation_line(ext, &args);
70    let examples = if ext.examples.is_empty() {
71        vec![example_line(ext, &invocation, &args)]
72    } else {
73        ext.examples.clone()
74    };
75    SkillRecord {
76        id: ext.id.clone(),
77        match_kind: match_kind_summary(&ext.match_spec),
78        invocation,
79        requires: ext
80            .requires()
81            .iter()
82            .map(|binary| SkillRequire {
83                binary: binary.clone(),
84                available: probe.binary_on_path(binary.as_str()),
85            })
86            .collect(),
87        args,
88        expands_to: expands_to(ext),
89        description: ext
90            .description
91            .as_ref()
92            .map(|text| text.trim().to_string())
93            .filter(|text| !text.is_empty()),
94        examples,
95        extends: ext.extends.clone(),
96        source: ext.source,
97    }
98}
99
100/// Recovery `--help` using the invocation prefix, not the extension id.
101///
102/// Prefix skills: `wyvern compose render --help`. Suffix/filename skills:
103/// `wyvern extensions show <id>` (path `--help` also works at match time).
104#[must_use]
105pub fn skill_help_command(ext: &ExtensionDef) -> String {
106    if let Some(prefix) = &ext.match_spec.argv_prefix {
107        if !prefix.is_empty() {
108            return format!("wyvern {} --help", join_prefix(prefix));
109        }
110    }
111    format!("wyvern extensions show {}", ext.id)
112}
113
114/// Build a [`SkillRecord`] for every merged extension, in registry order.
115#[must_use]
116pub fn build_skill_records(
117    registry: &ExtensionRegistry,
118    probe: &dyn RequiresProbe,
119) -> Vec<SkillRecord> {
120    registry
121        .extensions()
122        .iter()
123        .map(|ext| build_skill_record(ext, probe))
124        .collect()
125}
126
127/// Format one skill as the single help / list / show text card.
128///
129/// g.1 `--help` and g.3 `list` / `show` must call this function. There is no
130/// second formatter.
131#[must_use]
132pub fn format_skill_card(record: &SkillRecord) -> String {
133    let mut out = String::new();
134    out.push_str(record.id.as_str());
135    out.push('\n');
136    out.push_str(&record.match_kind);
137    out.push('\n');
138    if let Some(description) = &record.description {
139        out.push_str(description);
140        out.push('\n');
141    }
142    out.push_str("Usage: ");
143    out.push_str(&record.invocation);
144    out.push('\n');
145    out.push_str("Requires: ");
146    if record.requires.is_empty() {
147        out.push_str("(none)");
148    } else {
149        out.push_str(
150            &record
151                .requires
152                .iter()
153                .map(|req| {
154                    let status = if req.available {
155                        "available"
156                    } else {
157                        "missing"
158                    };
159                    format!("{} [{status}]", req.binary)
160                })
161                .collect::<Vec<_>>()
162                .join(", "),
163        );
164    }
165    out.push('\n');
166    out.push_str("Expands to: ");
167    out.push_str(&record.expands_to);
168    out.push('\n');
169    if let Some(parent) = &record.extends {
170        out.push_str("Extends: ");
171        out.push_str(parent.as_str());
172        out.push_str(" (alias)\n");
173    }
174    out.push_str("Example: ");
175    if let Some(example) = record.examples.first() {
176        out.push_str(example);
177        for extra in record.examples.iter().skip(1) {
178            out.push('\n');
179            out.push_str("         ");
180            out.push_str(extra);
181        }
182    } else {
183        out.push_str(&record.invocation);
184    }
185    out.push('\n');
186    out
187}
188
189fn expands_to(ext: &ExtensionDef) -> String {
190    ext.expand
191        .as_ref()
192        .and_then(|spec| spec.command.as_ref())
193        .and_then(|command| command.get("type"))
194        .and_then(Value::as_str)
195        .unwrap_or("wizard")
196        .to_string()
197}
198
199fn invocation_line(ext: &ExtensionDef, args: &[SkillArg]) -> String {
200    let mut parts = vec!["wyvern".to_string()];
201    let spec = &ext.match_spec;
202    if let Some(prefix) = &spec.argv_prefix {
203        for token in prefix {
204            parts.push(token.as_str().to_string());
205        }
206        if let Some(suffix) = &spec.arg_suffix {
207            parts.push(format!("<file{}>", suffix.as_str()));
208        }
209        for arg in args {
210            parts.push(format_arg(arg));
211        }
212        return parts.join(" ");
213    }
214    if let Some(filename) = &spec.filename {
215        parts.push(format!("path/to/{}", filename.as_str()));
216        return parts.join(" ");
217    }
218    if let Some(suffix) = &spec.positional_suffix {
219        parts.push(format!("file{}", suffix.as_str()));
220        return parts.join(" ");
221    }
222    parts.join(" ")
223}
224
225fn example_line(ext: &ExtensionDef, invocation: &str, args: &[SkillArg]) -> String {
226    let spec = &ext.match_spec;
227    if let Some(prefix) = &spec.argv_prefix {
228        let prefix_s = join_prefix(prefix);
229        if let Some(suffix) = &spec.arg_suffix {
230            return format!("wyvern {prefix_s} data{}", suffix.as_str());
231        }
232        if !args.is_empty() {
233            return format!("wyvern {prefix_s} {}", example_args(args));
234        }
235        return format!("wyvern {prefix_s}");
236    }
237    invocation.to_string()
238}
239
240fn join_prefix(prefix: &[MatchToken]) -> String {
241    prefix
242        .iter()
243        .map(MatchToken::as_str)
244        .collect::<Vec<_>>()
245        .join(" ")
246}
247
248fn format_arg(arg: &SkillArg) -> String {
249    match (arg.name.as_str(), arg.required, arg.repeat) {
250        ("root", true, _) => "--root <DIR>".into(),
251        ("file", true, _) => "--file <FILE>".into(),
252        ("var", _, true) => "[--var k=v]".into(),
253        ("var-file", _, true) => "[--var-file vars.json]".into(),
254        ("env-prefix", _, true) => "[--env-prefix PREFIX]".into(),
255        (name, true, _) => format!("--{name} <{}>", placeholder(name)),
256        (name, false, true) => format!("[--{name} …]"),
257        (name, false, false) => format!("[--{name} <{}>]", placeholder(name)),
258    }
259}
260
261fn example_args(args: &[SkillArg]) -> String {
262    args.iter()
263        .map(|arg| match (arg.name.as_str(), arg.required, arg.repeat) {
264            ("root", true, _) => "--root DIR".into(),
265            ("file", true, _) => "--file FILE.j2".into(),
266            ("var", _, true) => "[--var k=v]".into(),
267            ("var-file", _, true) => "[--var-file vars.json]".into(),
268            ("env-prefix", _, true) => "[--env-prefix PREFIX]".into(),
269            (name, true, _) => format!("--{name} {}", placeholder(name)),
270            (name, false, true) => format!("[--{name} …]"),
271            (name, false, false) => format!("[--{name} {}]", placeholder(name)),
272        })
273        .collect::<Vec<_>>()
274        .join(" ")
275}
276
277fn placeholder(name: &str) -> String {
278    name.replace('-', "_").to_ascii_uppercase()
279}
280
281pub(crate) fn declared_skill_args(ext: &ExtensionDef) -> Vec<SkillArg> {
282    let mut vars = Vec::new();
283    let mut seen = BTreeSet::new();
284    if let Some(PreexecSpec { cmd, args, .. }) = &ext.preexec {
285        collect_template_vars(cmd, &mut vars, &mut seen);
286        for arg in args {
287            collect_template_vars(arg, &mut vars, &mut seen);
288        }
289    }
290    if let Some(exp) = &ext.expand {
291        if let Some(cmd) = &exp.command {
292            collect_value_vars(cmd, &mut vars, &mut seen);
293        }
294        if let Some(path) = &exp.command_from_file {
295            collect_template_vars(path, &mut vars, &mut seen);
296        }
297        if let Some(ui) = exp.host.as_ref().and_then(|h| h.ui_root.as_ref()) {
298            collect_template_vars(ui, &mut vars, &mut seen);
299        }
300    }
301    let mut ordered: Vec<SkillArg> = Vec::new();
302    for var in vars {
303        let Some(rest) = var.strip_prefix("arg:") else {
304            continue;
305        };
306        let (name, repeat) = match rest.strip_suffix(":repeat") {
307            Some(name) => (name, true),
308            None => (rest, false),
309        };
310        if let Some(existing) = ordered.iter_mut().find(|arg| arg.name.as_str() == name) {
311            if repeat {
312                existing.repeat = true;
313            } else {
314                existing.required = true;
315            }
316            continue;
317        }
318        let Some(arg_name) = ArgName::new(name) else {
319            continue;
320        };
321        ordered.push(SkillArg {
322            name: arg_name,
323            required: !repeat,
324            repeat,
325        });
326    }
327    ordered
328}
329
330fn collect_template_vars(template: &str, into: &mut Vec<String>, seen: &mut BTreeSet<String>) {
331    let mut rest = template;
332    while let Some(start) = rest.find('{') {
333        let after = &rest[start + 1..];
334        let Some(end) = after.find('}') else {
335            break;
336        };
337        let name = after[..end].to_string();
338        if seen.insert(name.clone()) {
339            into.push(name);
340        }
341        rest = &after[end + 1..];
342    }
343}
344
345fn collect_value_vars(value: &Value, into: &mut Vec<String>, seen: &mut BTreeSet<String>) {
346    match value {
347        Value::String(s) => collect_template_vars(s, into, seen),
348        Value::Array(items) => {
349            for item in items {
350                collect_value_vars(item, into, seen);
351            }
352        }
353        Value::Object(map) => {
354            for v in map.values() {
355                collect_value_vars(v, into, seen);
356            }
357        }
358        _ => {}
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365    use crate::extensions::{
366        ExtensionRegistry, RequiresProbe, SkillSource, SHIPPED_EXTENSIONS_JSON,
367    };
368
369    struct Absent;
370
371    impl RequiresProbe for Absent {
372        fn binary_on_path(&self, _name: &str) -> bool {
373            false
374        }
375    }
376
377    #[test]
378    fn compose_card_lists_flags_requires_and_example() {
379        let registry = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON).expect("shipped");
380        let ext = registry
381            .extensions()
382            .iter()
383            .find(|e| e.id.as_str() == "compose-render")
384            .expect("compose-render");
385        let record = build_skill_record(ext, &Absent);
386        assert_eq!(record.id.to_string(), "compose-render");
387        assert_eq!(record.source, SkillSource::Shipped);
388        assert_eq!(skill_help_command(ext), "wyvern compose render --help");
389        assert!(record.args.iter().any(|arg| arg.name.as_str() == "root"));
390        assert!(!record.requires.iter().any(|r| r.available));
391        let card = format_skill_card(&record);
392        let root_at = card.find("--root").expect("root");
393        let file_at = card.find("--file").expect("file");
394        assert!(root_at < file_at, "{card}");
395        assert!(card.contains("--env-prefix"), "{card}");
396        assert!(card.contains("Requires:"), "{card}");
397        assert!(card.contains("sc-compose"), "{card}");
398        assert!(card.contains("Example:"), "{card}");
399    }
400
401    #[test]
402    fn md_card_does_not_require_csv_path() {
403        let registry = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON).expect("shipped");
404        let ext = registry
405            .extensions()
406            .iter()
407            .find(|e| e.id.as_str() == "csv-md")
408            .expect("csv-md");
409        let card = format_skill_card(&build_skill_record(ext, &Absent));
410        assert_eq!(skill_help_command(ext), "wyvern md --help");
411        assert!(card.contains("wyvern md"), "{card}");
412        assert!(card.contains("Requires:"), "{card}");
413        assert!(card.contains("Example:"), "{card}");
414    }
415
416    #[test]
417    fn compose_render_shipped_preexec_uses_output_and_env_prefix() {
418        let shipped: Value = serde_json::from_str(SHIPPED_EXTENSIONS_JSON).expect("json");
419        let compose = shipped["extensions"]
420            .as_array()
421            .expect("extensions")
422            .iter()
423            .find(|ext| ext["id"] == "compose-render")
424            .expect("compose-render");
425        let args: Vec<&str> = compose["preexec"]["args"]
426            .as_array()
427            .expect("args")
428            .iter()
429            .filter_map(Value::as_str)
430            .collect();
431        assert!(args.contains(&"--output"), "{args:?}");
432        assert!(!args.contains(&"--out"), "{args:?}");
433        assert!(!args.contains(&"--env"), "{args:?}");
434        assert!(
435            args.iter().any(|token| token.contains("env-prefix")),
436            "{args:?}"
437        );
438        assert!(!args.contains(&"--format"), "{args:?}");
439        assert!(!args.contains(&"html"), "{args:?}");
440    }
441
442    #[test]
443    fn registry_accepts_missing_description_and_examples() {
444        let json = r#"{
445          "version": 1,
446          "extensions": [{
447            "id": "plain",
448            "match": { "positional_suffix": ".md" },
449            "expand": { "command": { "type": "markdown", "file": "{path}" } }
450          }]
451        }"#;
452        let registry = ExtensionRegistry::from_json_str(json).expect("parse");
453        let record = build_skill_record(&registry.extensions()[0], &Absent);
454        assert!(record.description.is_none());
455        assert_eq!(record.extends, None);
456        assert_eq!(record.examples.len(), 1);
457        assert_eq!(record.source, SkillSource::Shipped);
458        assert_eq!(
459            skill_help_command(&registry.extensions()[0]),
460            "wyvern extensions show plain"
461        );
462    }
463
464    #[test]
465    fn declared_skill_args_skips_empty_template_names() {
466        let json = r#"{
467          "version": 1,
468          "extensions": [{
469            "id": "empty-arg",
470            "match": { "positional_suffix": ".md" },
471            "preexec": { "cmd": "true", "args": ["{arg:}", "{arg:  }", "{arg:root}"] },
472            "expand": { "command": { "type": "markdown", "file": "{path}" } }
473          }]
474        }"#;
475        let registry = ExtensionRegistry::from_json_str(json).expect("parse");
476        let args = declared_skill_args(&registry.extensions()[0]);
477        assert_eq!(args.len(), 1, "{args:?}");
478        assert_eq!(args[0].name.as_str(), "root");
479    }
480
481    #[test]
482    fn skill_record_includes_catalog_fields() {
483        let registry = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON).expect("shipped");
484        let records = build_skill_records(&registry, &Absent);
485        assert!(records.len() >= 7, "{}", records.len());
486        let alias = records
487            .iter()
488            .find(|record| record.id.as_str() == "csv-table-alias")
489            .expect("csv-table-alias");
490        assert_eq!(
491            alias.extends.as_ref().map(ExtensionId::as_str),
492            Some("csv-suffix")
493        );
494        assert!(alias.description.as_ref().is_some_and(|d| !d.is_empty()));
495        assert!(!alias.examples.is_empty());
496        let card = format_skill_card(alias);
497        assert!(card.contains("Extends: csv-suffix (alias)"), "{card}");
498        assert!(card.contains("[missing]"), "{card}");
499    }
500}