Skip to main content

wyvern/extensions/
list.rs

1//! `wyvern extensions list` / `show` catalog commands (REQ-0132).
2
3use super::{
4    build_skill_record, build_skill_records, format_skill_card, ExtensionError, ExtensionId,
5    ExtensionRegistry, PathRequiresProbe, SkillRecord,
6};
7use crate::error::{BuiltinDomain, EmitError, UsageErrorKind};
8use wyvern_schema::{ErrorCode, SerializeError, StderrError};
9
10/// Failure from the `extensions` built-in.
11#[derive(Debug)]
12pub enum ExtensionsCmdError {
13    /// Bad argv.
14    Usage {
15        /// Discriminated usage class for structured stderr recovery.
16        kind: UsageErrorKind,
17        /// Plain-text usage.
18        message: String,
19    },
20    /// Registry load or emit failure with stderr JSON.
21    Stage {
22        /// Stderr JSON.
23        stderr: String,
24        /// Process exit code.
25        exit_code: i32,
26    },
27    /// Emit-boundary serialize failure.
28    Emit(EmitError),
29}
30
31/// Usage text for `wyvern extensions --help` / `-h`.
32#[must_use]
33pub fn extensions_usage_message() -> String {
34    concat!(
35        "Usage: wyvern extensions [list] [--json]\n",
36        "       wyvern extensions show <id> [--json]\n",
37        "       wyvern extensions --help\n",
38        "\n",
39        "Commands:\n",
40        "  list         List shipped and project CLI extensions\n",
41        "  show <id>    Print one skill (text or --json object)\n",
42        "\n",
43        "Options:\n",
44        "  --json       Print SkillRecord JSON (array for list, object for show)\n",
45        "\n",
46        "Warning: `.wyvern/extensions.json` is trusted preexec; review it before running wyvern.\n",
47        "See also: wyvern --help\n",
48    )
49    .to_string()
50}
51
52/// Run `wyvern extensions …`; returns stdout text on success.
53///
54/// # Errors
55///
56/// Returns usage text or structured stderr for invalid argv / registry load.
57pub fn run_extensions_command(args: &[String]) -> Result<String, ExtensionsCmdError> {
58    if args
59        .first()
60        .is_some_and(|token| token == "--help" || token == "-h")
61    {
62        return Ok(extensions_usage_message());
63    }
64    match args.first().map(String::as_str) {
65        None => run_list(args),
66        Some("list") => run_list(&args[1..]),
67        Some("show") => run_show(&args[1..]),
68        Some("--json") => run_list(args),
69        Some(other) if other.starts_with('-') => Err(unknown_flag(other)),
70        Some(other) => Err(ExtensionsCmdError::Usage {
71            kind: UsageErrorKind::UnknownSubcommand {
72                domain: BuiltinDomain::Extensions,
73                token: other.to_string(),
74            },
75            message: format!(
76                "unknown extensions subcommand '{other}'\n{}",
77                extensions_usage_message()
78            ),
79        }),
80    }
81}
82
83fn run_list(args: &[String]) -> Result<String, ExtensionsCmdError> {
84    if wants_help(args) {
85        return Ok(extensions_usage_message());
86    }
87    let json = parse_list_flags(args)?;
88    let registry = ExtensionRegistry::load_default().map_err(map_ext)?;
89    let records = build_skill_records(&registry, &PathRequiresProbe);
90    if json {
91        serialize_records_json(&records)
92    } else {
93        Ok(format_skill_cards(&records))
94    }
95}
96
97fn run_show(args: &[String]) -> Result<String, ExtensionsCmdError> {
98    if wants_help(args) {
99        return Ok(extensions_usage_message());
100    }
101    let (id, json) = parse_show_args(args)?;
102    let registry = ExtensionRegistry::load_default().map_err(map_ext)?;
103    let Some(ext) = registry.extensions().iter().find(|ext| ext.id == id) else {
104        return Err(unknown_id(id.as_str()));
105    };
106    let record = build_skill_record(ext, &PathRequiresProbe);
107    if json {
108        serialize_record_json(&record)
109    } else {
110        Ok(format_skill_card(&record))
111    }
112}
113
114fn wants_help(args: &[String]) -> bool {
115    args.iter().any(|arg| arg == "--help" || arg == "-h")
116}
117
118fn parse_list_flags(args: &[String]) -> Result<bool, ExtensionsCmdError> {
119    let mut json = false;
120    for arg in args {
121        match arg.as_str() {
122            "--json" => json = true,
123            other => return Err(unknown_flag(other)),
124        }
125    }
126    Ok(json)
127}
128
129fn parse_show_args(args: &[String]) -> Result<(ExtensionId, bool), ExtensionsCmdError> {
130    let mut json = false;
131    let mut id = None;
132    for arg in args {
133        match arg.as_str() {
134            "--json" => json = true,
135            other if other.starts_with('-') => return Err(unknown_flag(other)),
136            other if id.is_none() => {
137                id = Some(ExtensionId::try_from(other.to_string()).map_err(|_| missing_id())?);
138            }
139            other => return Err(unknown_flag(other)),
140        }
141    }
142    let Some(id) = id else {
143        return Err(missing_id());
144    };
145    Ok((id, json))
146}
147
148fn missing_id() -> ExtensionsCmdError {
149    ExtensionsCmdError::Usage {
150        kind: UsageErrorKind::MissingExtensionId,
151        message: format!(
152            "extensions show requires an extension id\n{}",
153            extensions_usage_message()
154        ),
155    }
156}
157
158/// Format each extension as a [`format_skill_card`] block.
159#[must_use]
160pub fn format_extensions_list(registry: &ExtensionRegistry) -> String {
161    format_skill_cards(&build_skill_records(registry, &PathRequiresProbe))
162}
163
164fn format_skill_cards(records: &[SkillRecord]) -> String {
165    records
166        .iter()
167        .map(format_skill_card)
168        .collect::<Vec<_>>()
169        .join("\n")
170}
171
172fn serialize_records_json(records: &[SkillRecord]) -> Result<String, ExtensionsCmdError> {
173    match serde_json::to_string_pretty(records) {
174        Ok(mut text) => {
175            if !text.ends_with('\n') {
176                text.push('\n');
177            }
178            Ok(text)
179        }
180        Err(err) => Err(ExtensionsCmdError::Emit(EmitError::Serialize(
181            SerializeError {
182                message: err.to_string(),
183            },
184        ))),
185    }
186}
187
188fn serialize_record_json(record: &SkillRecord) -> Result<String, ExtensionsCmdError> {
189    match serde_json::to_string_pretty(record) {
190        Ok(mut text) => {
191            if !text.ends_with('\n') {
192                text.push('\n');
193            }
194            Ok(text)
195        }
196        Err(err) => Err(ExtensionsCmdError::Emit(EmitError::Serialize(
197            SerializeError {
198                message: err.to_string(),
199            },
200        ))),
201    }
202}
203
204fn unknown_flag(flag: &str) -> ExtensionsCmdError {
205    match StderrError::new(ErrorCode::ValidationError, format!("unknown flag '{flag}'"))
206        .cause("extensions list/show accept only --json")
207        .recovery("Run wyvern extensions list")
208        .recovery("Run wyvern extensions list --json")
209        .recovery("Run wyvern extensions show <id>")
210        .recovery("Run wyvern extensions --help")
211        .docs("docs/wyvern/requirements.md (REQ-0132)")
212        .to_json_string()
213    {
214        Ok(stderr) => ExtensionsCmdError::Stage {
215            stderr,
216            exit_code: ErrorCode::ValidationError.exit_code(),
217        },
218        Err(err) => ExtensionsCmdError::Emit(EmitError::Serialize(err)),
219    }
220}
221
222fn unknown_id(id: &str) -> ExtensionsCmdError {
223    match StderrError::new(
224        ErrorCode::ValidationError,
225        format!("unknown extension id '{id}'"),
226    )
227    .cause("No shipped or project extension has that id")
228    .recovery("Run wyvern extensions list")
229    .recovery("Run wyvern extensions list --json")
230    .recovery("Run wyvern extensions --help")
231    .docs("docs/wyvern/requirements.md (REQ-0132)")
232    .to_json_string()
233    {
234        Ok(stderr) => ExtensionsCmdError::Stage {
235            stderr,
236            exit_code: ErrorCode::ValidationError.exit_code(),
237        },
238        Err(err) => ExtensionsCmdError::Emit(EmitError::Serialize(err)),
239    }
240}
241
242fn map_ext(err: ExtensionError) -> ExtensionsCmdError {
243    match crate::error::emit_extension_error(&err) {
244        Ok(stderr) => ExtensionsCmdError::Stage {
245            stderr,
246            exit_code: err.exit_code(),
247        },
248        Err(e) => ExtensionsCmdError::Emit(e),
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[test]
257    fn list_includes_markdown_suffix() {
258        let registry = ExtensionRegistry::from_json_str(super::super::SHIPPED_EXTENSIONS_JSON)
259            .expect("shipped");
260        let text = format_extensions_list(&registry);
261        assert!(text.contains("markdown-suffix"), "{text}");
262        assert!(text.contains("suffix: .md"), "{text}");
263    }
264
265    #[test]
266    fn extensions_help_mentions_list_and_show() {
267        let text = extensions_usage_message();
268        assert!(text.contains("list"), "{text}");
269        assert!(text.contains("show"), "{text}");
270        assert!(
271            text.contains(".wyvern/extensions.json") && text.contains("trusted preexec"),
272            "{text}"
273        );
274    }
275
276    #[test]
277    fn bare_extensions_defaults_to_list() {
278        let text = run_extensions_command(&[]).expect("list");
279        assert!(text.contains("markdown-suffix"), "{text}");
280    }
281
282    #[test]
283    fn show_without_id_is_missing_extension_id() {
284        let err = run_extensions_command(&["show".into()]).expect_err("usage");
285        match err {
286            ExtensionsCmdError::Usage { kind, message } => {
287                assert_eq!(kind, UsageErrorKind::MissingExtensionId);
288                assert!(
289                    message.contains("extensions show requires an extension id"),
290                    "{message}"
291                );
292            }
293            other => panic!("expected Usage, got {other:?}"),
294        }
295    }
296
297    #[test]
298    fn unknown_extensions_subcommand_is_discriminated() {
299        let err = run_extensions_command(&["dump".into()]).expect_err("usage");
300        match err {
301            ExtensionsCmdError::Usage { kind, message } => {
302                assert!(matches!(
303                    kind,
304                    UsageErrorKind::UnknownSubcommand { domain, ref token }
305                        if domain == BuiltinDomain::Extensions && token == "dump"
306                ));
307                assert!(
308                    message.contains("unknown extensions subcommand"),
309                    "{message}"
310                );
311            }
312            other => panic!("expected Usage, got {other:?}"),
313        }
314    }
315
316    #[test]
317    fn list_prints_requires_availability() {
318        let json = r#"{
319          "version": 1,
320          "extensions": [
321            {
322              "id": "compose-render",
323              "match": { "argv_prefix": ["compose", "render"] },
324              "preexec": { "cmd": "sc-compose", "requires": ["sc-compose"] },
325              "expand": { "command": { "type": "markdown", "content": "x" } }
326            }
327          ]
328        }"#;
329        let registry = ExtensionRegistry::from_json_str(json).expect("parse");
330        let text = format_extensions_list(&registry);
331        assert!(text.contains("compose-render"), "{text}");
332        assert!(text.contains("prefix: compose render"), "{text}");
333        assert!(text.contains("sc-compose"), "{text}");
334        assert!(
335            text.contains("[available]") || text.contains("[missing]"),
336            "{text}"
337        );
338    }
339
340    #[test]
341    fn list_unknown_flag_is_validation_error() {
342        let err = run_extensions_command(&["list".into(), "--foo".into()]).expect_err("flag");
343        match err {
344            ExtensionsCmdError::Stage { stderr, exit_code } => {
345                assert_eq!(exit_code, 4);
346                let value: serde_json::Value = serde_json::from_str(&stderr).expect("json");
347                assert_eq!(value["code"], "VALIDATION_ERROR");
348                assert!(
349                    value["message"].as_str().unwrap_or("").contains("--foo"),
350                    "{stderr}"
351                );
352            }
353            other => panic!("expected Stage, got {other:?}"),
354        }
355    }
356}