Skip to main content

wyvern/
cli_args.rs

1//! Host option flags (`--bind`, `--ui-root`, `--viewer`) and argv splitting.
2
3use std::net::SocketAddr;
4use std::path::{Path, PathBuf};
5
6use wyvern_host::{HostOptions, ViewerMode};
7
8use crate::error::{LoadError, UsageErrorKind};
9use crate::extensions::{ExtensionRegistry, SHIPPED_EXTENSIONS_JSON};
10
11/// Parsed CLI invocation: host options + remaining positional/stdin args.
12#[derive(Debug, Clone)]
13pub struct CliArgs {
14    /// Options passed to [`wyvern_host::run`] / [`wyvern_host::begin`].
15    pub host: HostOptions,
16    /// Non-flag argv entries (JSON / file path).
17    pub positionals: Vec<String>,
18    /// `--workflow-dry-run` — append `--dry-run` to workflow pre/post argv.
19    ///
20    /// Never a [`HostOptions`] field (ADR-0023).
21    pub workflow_dry_run: bool,
22}
23
24/// Split argv into host flags and positionals.
25///
26/// Product default (c.15+): omitted `--viewer` → [`ViewerMode::Embedded`].
27/// `WYVERN_VIEWER` overrides when set. Host-only flags (`--bind`, `--ui-root`,
28/// `--viewer`, `--allow-non-loopback`) are stripped; all other tokens stay in
29/// the extension remainder.
30///
31/// # Errors
32///
33/// Returns [`LoadError::Usage`] for bad flags or values.
34pub fn parse_cli_args(args: &[String]) -> Result<CliArgs, LoadError> {
35    let mut bind = SocketAddr::from(([127, 0, 0, 1], 0));
36    // Packaged shared assets are never overridden by `--ui-root` (d.1 dual mount).
37    let shared_ui_root = default_ui_root();
38    let mut ui_root = shared_ui_root.clone();
39    let mut viewer = resolve_default_viewer()?;
40    let mut allow_non_loopback = false;
41    let mut workflow_dry_run = false;
42    let mut positionals = Vec::new();
43
44    let mut i = 0;
45    while i < args.len() {
46        let arg = &args[i];
47        if arg == "--bind" {
48            let value = require_flag_value(args, i, "--bind")?;
49            bind = parse_bind(value)?;
50            i += 2;
51            continue;
52        }
53        if let Some(value) = arg.strip_prefix("--bind=") {
54            bind = parse_bind(value)?;
55            i += 1;
56            continue;
57        }
58        if arg == "--allow-non-loopback" {
59            allow_non_loopback = true;
60            i += 1;
61            continue;
62        }
63        if arg == "--workflow-dry-run" {
64            workflow_dry_run = true;
65            i += 1;
66            continue;
67        }
68        if arg == "--ui-root" {
69            let value = require_flag_value(args, i, "--ui-root")?;
70            ui_root = PathBuf::from(value);
71            i += 2;
72            continue;
73        }
74        if let Some(value) = arg.strip_prefix("--ui-root=") {
75            ui_root = PathBuf::from(value);
76            i += 1;
77            continue;
78        }
79        if arg == "--viewer" {
80            let value = require_flag_value(args, i, "--viewer")?;
81            viewer = parse_viewer(value)?;
82            i += 2;
83            continue;
84        }
85        if let Some(value) = arg.strip_prefix("--viewer=") {
86            viewer = parse_viewer(value)?;
87            i += 1;
88            continue;
89        }
90        if arg == "--version" || arg == "-V" {
91            positionals.push(arg.clone());
92            i += 1;
93            continue;
94        }
95        // Host-only flags are stripped above. All other tokens — including
96        // unknown flags such as `--root` — stay in the extension remainder.
97        positionals.push(arg.clone());
98        i += 1;
99    }
100
101    let dialog_url_env = matches!(viewer, ViewerMode::None);
102    Ok(CliArgs {
103        host: HostOptions {
104            bind,
105            ui_root,
106            shared_ui_root,
107            viewer,
108            dialog_url_env,
109            dialog_url_file: std::env::var_os("WYVERN_DIALOG_URL_FILE").map(PathBuf::from),
110            allow_non_loopback,
111            session_timeout: wyvern_host::DEFAULT_SESSION_TIMEOUT,
112            mock_picker: None,
113        },
114        positionals,
115        workflow_dry_run,
116    })
117}
118
119/// Apply extension `host.ui_root` over CLI `--ui-root` when set (contract §7).
120pub fn apply_host_overrides(host: &mut HostOptions, overrides: &crate::extensions::HostOverrides) {
121    if let Some(ui_root) = &overrides.ui_root {
122        host.ui_root = ui_root.clone();
123    }
124}
125
126fn parse_bind(value: &str) -> Result<SocketAddr, LoadError> {
127    value.parse().map_err(|e| LoadError::Usage {
128        kind: UsageErrorKind::InvalidBind {
129            value: value.to_string(),
130        },
131        message: format!("invalid --bind '{value}': {e}"),
132    })
133}
134
135fn require_flag_value<'a>(
136    args: &'a [String],
137    index: usize,
138    flag: &str,
139) -> Result<&'a str, LoadError> {
140    args.get(index + 1)
141        .map(String::as_str)
142        .ok_or_else(|| LoadError::Usage {
143            kind: UsageErrorKind::MissingFlagValue {
144                flag: flag.to_string(),
145            },
146            message: format!("missing value for {flag}"),
147        })
148}
149
150fn parse_viewer(value: &str) -> Result<ViewerMode, LoadError> {
151    ViewerMode::parse(value).ok_or_else(|| LoadError::Usage {
152        kind: UsageErrorKind::InvalidViewer {
153            value: value.to_string(),
154        },
155        message: format!(
156            "invalid --viewer '{value}' (expected embedded|none|system|chrome|safari|edge|firefox)"
157        ),
158    })
159}
160
161fn resolve_default_viewer() -> Result<ViewerMode, LoadError> {
162    match std::env::var("WYVERN_VIEWER") {
163        Err(std::env::VarError::NotPresent) => Ok(ViewerMode::Embedded),
164        Err(std::env::VarError::NotUnicode(err)) => Err(LoadError::Usage {
165            kind: UsageErrorKind::InvalidWyvernViewerUnicode,
166            message: format!("WYVERN_VIEWER is not valid Unicode: {err:?}"),
167        }),
168        Ok(raw) => {
169            if raw.is_empty() {
170                Ok(ViewerMode::Embedded)
171            } else {
172                ViewerMode::parse(&raw).ok_or_else(|| LoadError::Usage {
173                    kind: UsageErrorKind::InvalidWyvernViewerEnv {
174                        value: raw.clone(),
175                    },
176                    message: format!(
177                        "invalid WYVERN_VIEWER={raw:?}; expected embedded, none, system, or a named viewer path"
178                    ),
179                })
180            }
181        }
182    }
183}
184
185/// Default UI root discovery order:
186///
187/// 1. `WYVERN_UI_ROOT` environment variable
188/// 2. `./ui` (dev workspace — cwd contains ui/)
189/// 3. `./share/wyvern/ui` (cwd install layout)
190/// 4. `<exe_dir>/share/wyvern/ui` (release tarball layout — REQ-0093 / REQ-0116)
191/// 5. `<exe_dir>/ui` (sibling to binary)
192/// 6. Embedded assets extracted to platform cache dir (`cargo install` layout)
193/// 7. Fallback `./ui` — caller receives a clear "UI not found" error downstream
194pub fn default_ui_root() -> PathBuf {
195    default_ui_root_with(
196        std::env::var("WYVERN_UI_ROOT").ok().as_deref(),
197        std::env::current_dir().ok().as_deref(),
198        std::env::current_exe()
199            .ok()
200            .as_deref()
201            .and_then(|p| p.parent()),
202        true,
203    )
204}
205
206/// Resolve the default UI root from injectable inputs (QA-001 — no `set_var` in tests).
207#[must_use]
208pub fn default_ui_root_with(
209    ui_root_var: Option<&str>,
210    cwd: Option<&Path>,
211    exe_dir: Option<&Path>,
212    use_embedded_cache: bool,
213) -> PathBuf {
214    if let Some(path) = ui_root_var {
215        return PathBuf::from(path);
216    }
217    if let Some(cwd) = cwd {
218        let cwd_ui = cwd.join("ui");
219        if cwd_ui.is_dir() {
220            return cwd_ui;
221        }
222        let cwd_share = cwd.join("share/wyvern/ui");
223        if cwd_share.is_dir() {
224            return cwd_share;
225        }
226    } else {
227        let cwd_ui = PathBuf::from("ui");
228        if cwd_ui.is_dir() {
229            return cwd_ui;
230        }
231        let cwd_share = PathBuf::from("share/wyvern/ui");
232        if cwd_share.is_dir() {
233            return cwd_share;
234        }
235    }
236    if let Some(exe_dir) = exe_dir {
237        let share = exe_dir.join("share/wyvern/ui");
238        if share.is_dir() {
239            return share;
240        }
241        let sibling_ui = exe_dir.join("ui");
242        if sibling_ui.is_dir() {
243            return sibling_ui;
244        }
245    }
246    if use_embedded_cache {
247        if let Some(cached) = crate::embedded_ui::extract_to_cache() {
248            return cached;
249        }
250    }
251    PathBuf::from("ui")
252}
253
254/// Canonical usage text for `--help` / `-h` / `help` and invalid argv.
255pub fn usage_message() -> String {
256    let mut text = concat!(
257        "Usage: wyvern --help | -h | help\n",
258        "       wyvern '<json>' | <file.json> | <file.md> | <page.html> | <panel.xhtml> | wizard.json [options]\n",
259        "       echo '<json>' | wyvern [options]\n",
260        "       wyvern browsers list|refresh\n",
261        "       wyvern extensions list|show\n",
262        "       wyvern examples list\n",
263        "       wyvern wizard lint <path> [<path>...]   Static nav-button lint for wizard packages\n",
264        "       wyvern --version\n",
265        "\n",
266        "Options:\n",
267        "  --bind <ADDR:PORT>         HTTP bind (default 127.0.0.1:0)\n",
268        "  --allow-non-loopback       Permit non-loopback --bind (0.0.0.0 / LAN)\n",
269        "  --ui-root <PATH>           Packaged UI root (default: share/wyvern/ui beside binary).\n",
270        "                             For .html / wizard.json, ui-root is inferred from the\n",
271        "                             directory that contains wizard.json or pages/. An\n",
272        "                             extension host.ui_root replaces this flag.\n",
273        "  --viewer <MODE>            embedded|none|system|chrome|safari|edge|firefox\n",
274        "                             (default: embedded; CI: WYVERN_VIEWER=none)\n",
275        "  --workflow-dry-run         Append --dry-run to wizard workflow pre/post scripts\n",
276        "\n",
277        "Extensions (see `wyvern extensions list`):\n",
278        "  wyvern guide                   # visual feature guide (welcome wizard)\n",
279        "  wyvern doc.md\n",
280        "  wyvern page.html\n",
281        "  wyvern panel.xhtml\n",
282        "  wyvern report-xhtml <manifest.json>  # title, optional mode, panels[{path,label,role}]\n",
283        "  wyvern report-xhtml --review <manifest.json>  # comments + Approve/Cancel finish\n",
284        "  wyvern path/to/wizard.json\n",
285        "  wyvern data.csv\n",
286        "  wyvern table data.csv          # same interactive table as data.csv\n",
287        "  wyvern md data.csv             # CSV as a markdown dialog\n",
288        "  wyvern compose render --root DIR --file FILE.j2 [--var k=v] [--var-file vars.json] [--env-prefix PREFIX]\n",
289        "\n",
290        "Environment:\n",
291        "  WYVERN_VIEWER              Override --viewer default\n",
292        "  WYVERN_UI_ROOT             Override default UI root discovery\n",
293        "  WYVERN_SHARE               Override share/wyvern root (extensions + scripts)\n",
294        "\n",
295        "Pass a JSON string, .json file, or a path handled by an extension; or pipe JSON on stdin.\n",
296        "  See `wyvern extensions list` for the skill index.\n",
297        "  See `wyvern examples list` for bundled example READMEs.\n",
298        "  Prefix skills answer --help (example: wyvern compose render --help).\n",
299    )
300    .to_string();
301    if let Ok(registry) = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON) {
302        let ids = registry
303            .extensions()
304            .iter()
305            .map(|ext| ext.id.to_string())
306            .collect::<Vec<_>>()
307            .join(", ");
308        if !ids.is_empty() {
309            text.push_str("Catalog ids for `wyvern extensions show <id>` (not argv commands): ");
310            text.push_str(&ids);
311            text.push('\n');
312        }
313    }
314    text
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320
321    fn viewer_from_env_with(value: Option<&str>) -> Option<ViewerMode> {
322        value.and_then(ViewerMode::parse)
323    }
324
325    fn args(items: &[&str]) -> Vec<String> {
326        items.iter().map(|s| (*s).to_string()).collect()
327    }
328
329    #[test]
330    fn viewer_from_env_parses_embedded() {
331        assert_eq!(
332            viewer_from_env_with(Some("embedded")),
333            Some(ViewerMode::Embedded)
334        );
335    }
336
337    #[test]
338    fn default_viewer_mode_when_env_unset() {
339        assert_eq!(viewer_from_env_with(None), None);
340        assert_eq!(
341            viewer_from_env_with(None).unwrap_or(ViewerMode::Embedded),
342            ViewerMode::Embedded
343        );
344    }
345
346    #[test]
347    fn invalid_wyvern_viewer_env_is_usage_error() {
348        let err = resolve_default_viewer_with(Some("not-a-viewer-mode")).expect_err("invalid");
349        assert!(matches!(err, LoadError::Usage { .. }));
350    }
351
352    fn resolve_default_viewer_with(value: Option<&str>) -> Result<ViewerMode, LoadError> {
353        match value {
354            None => Ok(ViewerMode::Embedded),
355            Some("") => Ok(ViewerMode::Embedded),
356            Some(raw) => ViewerMode::parse(raw).ok_or_else(|| LoadError::Usage {
357                kind: UsageErrorKind::InvalidWyvernViewerEnv {
358                    value: raw.to_string(),
359                },
360                message: format!(
361                    "invalid WYVERN_VIEWER={raw:?}; expected embedded, none, system, or a named viewer path"
362                ),
363            }),
364        }
365    }
366
367    #[test]
368    fn parse_viewer_none_explicit() {
369        let parsed =
370            parse_cli_args(&args(&[r#"{"type":"message"}"#, "--viewer", "none"])).expect("parse");
371        assert_eq!(parsed.host.viewer, ViewerMode::None);
372        assert!(parsed.host.dialog_url_env);
373    }
374
375    #[test]
376    fn parse_ui_root_and_bind() {
377        let parsed = parse_cli_args(&args(&[
378            "--ui-root",
379            "./custom-ui",
380            "--bind",
381            "127.0.0.1:0",
382            r#"{"type":"message"}"#,
383        ]))
384        .expect("parse");
385        assert_eq!(parsed.host.ui_root, PathBuf::from("./custom-ui"));
386        assert_eq!(parsed.positionals.len(), 1);
387    }
388
389    #[test]
390    fn parse_bind_rejects_invalid_with_structured_recovery() {
391        use crate::error::emit_usage_error;
392
393        let err = parse_cli_args(&args(&["--bind", "not-an-addr"])).expect_err("bind");
394        let LoadError::Usage { kind, message } = err else {
395            panic!("expected Usage");
396        };
397        assert!(matches!(kind, UsageErrorKind::InvalidBind { .. }));
398        assert!(message.contains("invalid --bind"), "{message}");
399        assert!(!message.contains("Recovery:"), "{message}");
400
401        let out = emit_usage_error(&LoadError::Usage { kind, message }).expect("emit");
402        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
403        assert!(value["recovery"]
404            .as_array()
405            .unwrap()
406            .iter()
407            .any(|s| s.as_str().unwrap().contains("--allow-non-loopback")));
408    }
409
410    #[test]
411    fn parse_keeps_unknown_flag_in_remainder() {
412        let parsed =
413            parse_cli_args(&args(&["compose", "render", "--root", "/tmp"])).expect("parse");
414        assert_eq!(
415            parsed.positionals,
416            args(&["compose", "render", "--root", "/tmp"])
417        );
418    }
419
420    #[test]
421    fn parse_strips_host_flags_from_remainder() {
422        let parsed = parse_cli_args(&args(&[
423            "--viewer",
424            "none",
425            "--ui-root",
426            "./custom-ui",
427            "compose",
428            "render",
429            "--root",
430            "/tmp",
431        ]))
432        .expect("parse");
433        assert_eq!(parsed.host.viewer, ViewerMode::None);
434        assert_eq!(parsed.host.ui_root, PathBuf::from("./custom-ui"));
435        assert_eq!(
436            parsed.positionals,
437            args(&["compose", "render", "--root", "/tmp"])
438        );
439    }
440
441    #[test]
442    fn default_ui_root_prefers_env_override() {
443        let tmp = tempfile::tempdir().expect("tempdir");
444        let custom = tmp.path().join("custom-ui");
445        std::fs::create_dir_all(&custom).expect("mkdir");
446        let root = default_ui_root_with(Some(custom.to_str().expect("utf8")), None, None, false);
447        assert_eq!(root, custom);
448    }
449
450    #[test]
451    fn default_ui_root_falls_back_to_ui_when_nothing_found() {
452        let tmp = tempfile::tempdir().expect("tempdir");
453        let root = default_ui_root_with(None, Some(tmp.path()), None, false);
454        assert_eq!(root, PathBuf::from("ui"));
455    }
456
457    #[test]
458    fn usage_message_lists_every_shipped_skill() {
459        let text = usage_message();
460        assert!(text.contains(".csv"), "{text}");
461        assert!(text.contains("table"), "{text}");
462        assert!(text.contains("md data.csv"), "{text}");
463        assert!(text.contains("compose render"), "{text}");
464        assert!(text.contains("--env-prefix"), "{text}");
465        assert!(text.contains("WYVERN_VIEWER"), "{text}");
466        assert!(text.contains("wizard.json or pages/"), "{text}");
467        assert!(text.contains("wyvern guide"), "{text}");
468        assert!(text.contains("panel.xhtml"), "{text}");
469        assert!(text.contains(".xhtml"), "{text}");
470        assert!(text.contains("report-xhtml <manifest.json>"), "{text}");
471        assert!(
472            text.contains("report-xhtml --review <manifest.json>"),
473            "{text}"
474        );
475        assert!(text.contains("panels["), "{text}");
476        assert!(text.contains("--workflow-dry-run"), "{text}");
477        assert!(text.contains("wyvern wizard lint"), "{text}");
478    }
479
480    #[test]
481    fn parse_workflow_dry_run_is_on_cli_args_not_host() {
482        let parsed = parse_cli_args(&args(&[
483            "--workflow-dry-run",
484            "--viewer",
485            "none",
486            r#"{"type":"wizard"}"#,
487        ]))
488        .expect("parse");
489        assert!(parsed.workflow_dry_run);
490        assert_eq!(parsed.positionals, args(&[r#"{"type":"wizard"}"#]));
491    }
492}