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: viewer.clone(),
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: session_timeout_for_viewer(&viewer),
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 session_timeout_for_viewer(viewer: &ViewerMode) -> std::time::Duration {
127    if matches!(viewer, ViewerMode::None) {
128        wyvern_host::DEFAULT_HEADLESS_SESSION_TIMEOUT
129    } else {
130        wyvern_host::DEFAULT_SESSION_TIMEOUT
131    }
132}
133
134fn parse_bind(value: &str) -> Result<SocketAddr, LoadError> {
135    value.parse().map_err(|e| LoadError::Usage {
136        kind: UsageErrorKind::InvalidBind {
137            value: value.to_string(),
138        },
139        message: format!("invalid --bind '{value}': {e}"),
140    })
141}
142
143fn require_flag_value<'a>(
144    args: &'a [String],
145    index: usize,
146    flag: &str,
147) -> Result<&'a str, LoadError> {
148    args.get(index + 1)
149        .map(String::as_str)
150        .ok_or_else(|| LoadError::Usage {
151            kind: UsageErrorKind::MissingFlagValue {
152                flag: flag.to_string(),
153            },
154            message: format!("missing value for {flag}"),
155        })
156}
157
158fn parse_viewer(value: &str) -> Result<ViewerMode, LoadError> {
159    ViewerMode::parse(value).ok_or_else(|| LoadError::Usage {
160        kind: UsageErrorKind::InvalidViewer {
161            value: value.to_string(),
162        },
163        message: format!(
164            "invalid --viewer '{value}' (expected embedded|none|system|chrome|safari|edge|firefox)"
165        ),
166    })
167}
168
169fn resolve_default_viewer() -> Result<ViewerMode, LoadError> {
170    match std::env::var("WYVERN_VIEWER") {
171        Err(std::env::VarError::NotPresent) => Ok(ViewerMode::Embedded),
172        Err(std::env::VarError::NotUnicode(err)) => Err(LoadError::Usage {
173            kind: UsageErrorKind::InvalidWyvernViewerUnicode,
174            message: format!("WYVERN_VIEWER is not valid Unicode: {err:?}"),
175        }),
176        Ok(raw) => {
177            if raw.is_empty() {
178                Ok(ViewerMode::Embedded)
179            } else {
180                ViewerMode::parse(&raw).ok_or_else(|| LoadError::Usage {
181                    kind: UsageErrorKind::InvalidWyvernViewerEnv {
182                        value: raw.clone(),
183                    },
184                    message: format!(
185                        "invalid WYVERN_VIEWER={raw:?}; expected embedded, none, system, or a named viewer path"
186                    ),
187                })
188            }
189        }
190    }
191}
192
193/// Default UI root discovery order:
194///
195/// 1. `WYVERN_UI_ROOT` environment variable
196/// 2. `./ui` (dev workspace — cwd contains ui/)
197/// 3. `./share/wyvern/ui` (cwd install layout)
198/// 4. `<exe_dir>/share/wyvern/ui` (release tarball layout — REQ-0093 / REQ-0116)
199/// 5. `<exe_dir>/ui` (sibling to binary)
200/// 6. Embedded assets extracted to platform cache dir (`cargo install` layout)
201/// 7. Fallback `./ui` — caller receives a clear "UI not found" error downstream
202pub fn default_ui_root() -> PathBuf {
203    default_ui_root_with(
204        std::env::var("WYVERN_UI_ROOT").ok().as_deref(),
205        std::env::current_dir().ok().as_deref(),
206        std::env::current_exe()
207            .ok()
208            .as_deref()
209            .and_then(|p| p.parent()),
210        true,
211    )
212}
213
214/// Resolve the default UI root from injectable inputs (QA-001 — no `set_var` in tests).
215#[must_use]
216pub fn default_ui_root_with(
217    ui_root_var: Option<&str>,
218    cwd: Option<&Path>,
219    exe_dir: Option<&Path>,
220    use_embedded_cache: bool,
221) -> PathBuf {
222    if let Some(path) = ui_root_var {
223        return PathBuf::from(path);
224    }
225    if let Some(cwd) = cwd {
226        let cwd_ui = cwd.join("ui");
227        if cwd_ui.is_dir() {
228            return cwd_ui;
229        }
230        let cwd_share = cwd.join("share/wyvern/ui");
231        if cwd_share.is_dir() {
232            return cwd_share;
233        }
234    } else {
235        let cwd_ui = PathBuf::from("ui");
236        if cwd_ui.is_dir() {
237            return cwd_ui;
238        }
239        let cwd_share = PathBuf::from("share/wyvern/ui");
240        if cwd_share.is_dir() {
241            return cwd_share;
242        }
243    }
244    if let Some(exe_dir) = exe_dir {
245        let share = exe_dir.join("share/wyvern/ui");
246        if share.is_dir() {
247            return share;
248        }
249        let sibling_ui = exe_dir.join("ui");
250        if sibling_ui.is_dir() {
251            return sibling_ui;
252        }
253    }
254    if use_embedded_cache {
255        if let Some(cached) = crate::embedded_ui::extract_to_cache() {
256            return cached;
257        }
258    }
259    PathBuf::from("ui")
260}
261
262/// Canonical usage text for `--help` / `-h` / `help` and invalid argv.
263pub fn usage_message() -> String {
264    let mut text = concat!(
265        "Usage: wyvern --help | -h | help\n",
266        "       wyvern '<json>' | <file.json> | <file.md> | <page.html> | <panel.xhtml> | wizard.json [options]\n",
267        "       echo '<json>' | wyvern [options]\n",
268        "       wyvern browsers list|refresh\n",
269        "       wyvern extensions list|show\n",
270        "       wyvern examples list\n",
271        "       wyvern wizard lint <path> [<path>...]   Static nav-button lint for wizard packages\n",
272        "       wyvern --version\n",
273        "\n",
274        "Options:\n",
275        "  --bind <ADDR:PORT>         HTTP bind (default 127.0.0.1:0)\n",
276        "  --allow-non-loopback       Permit non-loopback --bind (0.0.0.0 / LAN)\n",
277        "  --ui-root <PATH>           Packaged UI root (default: share/wyvern/ui beside binary).\n",
278        "                             For .html / wizard.json, ui-root is inferred from the\n",
279        "                             directory that contains wizard.json or pages/. An\n",
280        "                             extension host.ui_root replaces this flag.\n",
281        "  --viewer <MODE>            embedded|none|system|chrome|safari|edge|firefox\n",
282        "                             (default: embedded; CI: WYVERN_VIEWER=none)\n",
283        "  --workflow-dry-run         Append --dry-run to wizard workflow pre/post scripts\n",
284        "\n",
285        "Extensions (see `wyvern extensions list`):\n",
286        "  wyvern guide                   # visual feature guide (welcome wizard)\n",
287        "  wyvern doc.md\n",
288        "  wyvern page.html\n",
289        "  wyvern panel.xhtml\n",
290        "  wyvern report-xhtml <manifest.json>  # title, optional mode, panels[{path,label,role}]\n",
291        "  wyvern report-xhtml --review <manifest.json>  # comments + Approve/Cancel finish\n",
292        "  wyvern path/to/wizard.json\n",
293        "  wyvern data.csv\n",
294        "  wyvern table data.csv          # same interactive table as data.csv\n",
295        "  wyvern md data.csv             # CSV as a markdown dialog\n",
296        "  wyvern compose render --root DIR --file FILE.j2 [--var k=v] [--var-file vars.json] [--env-prefix PREFIX]\n",
297        "\n",
298        "Environment:\n",
299        "  WYVERN_VIEWER              Override --viewer default\n",
300        "  WYVERN_UI_ROOT             Override default UI root discovery\n",
301        "  WYVERN_SHARE               Override share/wyvern root (extensions + scripts)\n",
302        "\n",
303        "Pass a JSON string, .json file, or a path handled by an extension; or pipe JSON on stdin.\n",
304        "  See `wyvern extensions list` for the skill index.\n",
305        "  See `wyvern examples list` for bundled example READMEs.\n",
306        "  Prefix skills answer --help (example: wyvern compose render --help).\n",
307    )
308    .to_string();
309    if let Ok(registry) = ExtensionRegistry::from_json_str(SHIPPED_EXTENSIONS_JSON) {
310        let ids = registry
311            .extensions()
312            .iter()
313            .map(|ext| ext.id.to_string())
314            .collect::<Vec<_>>()
315            .join(", ");
316        if !ids.is_empty() {
317            text.push_str("Catalog ids for `wyvern extensions show <id>` (not argv commands): ");
318            text.push_str(&ids);
319            text.push('\n');
320        }
321    }
322    text
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    fn viewer_from_env_with(value: Option<&str>) -> Option<ViewerMode> {
330        value.and_then(ViewerMode::parse)
331    }
332
333    fn args(items: &[&str]) -> Vec<String> {
334        items.iter().map(|s| (*s).to_string()).collect()
335    }
336
337    #[test]
338    fn viewer_from_env_parses_embedded() {
339        assert_eq!(
340            viewer_from_env_with(Some("embedded")),
341            Some(ViewerMode::Embedded)
342        );
343    }
344
345    #[test]
346    fn default_viewer_mode_when_env_unset() {
347        assert_eq!(viewer_from_env_with(None), None);
348        assert_eq!(
349            viewer_from_env_with(None).unwrap_or(ViewerMode::Embedded),
350            ViewerMode::Embedded
351        );
352    }
353
354    #[test]
355    fn invalid_wyvern_viewer_env_is_usage_error() {
356        let err = resolve_default_viewer_with(Some("not-a-viewer-mode")).expect_err("invalid");
357        assert!(matches!(err, LoadError::Usage { .. }));
358    }
359
360    fn resolve_default_viewer_with(value: Option<&str>) -> Result<ViewerMode, LoadError> {
361        match value {
362            None => Ok(ViewerMode::Embedded),
363            Some("") => Ok(ViewerMode::Embedded),
364            Some(raw) => ViewerMode::parse(raw).ok_or_else(|| LoadError::Usage {
365                kind: UsageErrorKind::InvalidWyvernViewerEnv {
366                    value: raw.to_string(),
367                },
368                message: format!(
369                    "invalid WYVERN_VIEWER={raw:?}; expected embedded, none, system, or a named viewer path"
370                ),
371            }),
372        }
373    }
374
375    #[test]
376    fn parse_viewer_none_explicit() {
377        let parsed =
378            parse_cli_args(&args(&[r#"{"type":"message"}"#, "--viewer", "none"])).expect("parse");
379        assert_eq!(parsed.host.viewer, ViewerMode::None);
380        assert!(parsed.host.dialog_url_env);
381        assert_eq!(
382            parsed.host.session_timeout,
383            wyvern_host::DEFAULT_HEADLESS_SESSION_TIMEOUT
384        );
385    }
386
387    #[test]
388    fn parse_viewer_embedded_uses_product_session_timeout() {
389        let parsed = parse_cli_args(&args(&[r#"{"type":"message"}"#, "--viewer", "embedded"]))
390            .expect("parse");
391        assert_eq!(parsed.host.viewer, ViewerMode::Embedded);
392        assert_eq!(
393            parsed.host.session_timeout,
394            wyvern_host::DEFAULT_SESSION_TIMEOUT
395        );
396    }
397
398    #[test]
399    fn parse_ui_root_and_bind() {
400        let parsed = parse_cli_args(&args(&[
401            "--ui-root",
402            "./custom-ui",
403            "--bind",
404            "127.0.0.1:0",
405            r#"{"type":"message"}"#,
406        ]))
407        .expect("parse");
408        assert_eq!(parsed.host.ui_root, PathBuf::from("./custom-ui"));
409        assert_eq!(parsed.positionals.len(), 1);
410    }
411
412    #[test]
413    fn parse_bind_rejects_invalid_with_structured_recovery() {
414        use crate::error::emit_usage_error;
415
416        let err = parse_cli_args(&args(&["--bind", "not-an-addr"])).expect_err("bind");
417        let LoadError::Usage { kind, message } = err else {
418            panic!("expected Usage");
419        };
420        assert!(matches!(kind, UsageErrorKind::InvalidBind { .. }));
421        assert!(message.contains("invalid --bind"), "{message}");
422        assert!(!message.contains("Recovery:"), "{message}");
423
424        let out = emit_usage_error(&LoadError::Usage { kind, message }).expect("emit");
425        let value: serde_json::Value = serde_json::from_str(&out).expect("valid JSON");
426        assert!(value["recovery"]
427            .as_array()
428            .unwrap()
429            .iter()
430            .any(|s| s.as_str().unwrap().contains("--allow-non-loopback")));
431    }
432
433    #[test]
434    fn parse_keeps_unknown_flag_in_remainder() {
435        let parsed =
436            parse_cli_args(&args(&["compose", "render", "--root", "/tmp"])).expect("parse");
437        assert_eq!(
438            parsed.positionals,
439            args(&["compose", "render", "--root", "/tmp"])
440        );
441    }
442
443    #[test]
444    fn parse_strips_host_flags_from_remainder() {
445        let parsed = parse_cli_args(&args(&[
446            "--viewer",
447            "none",
448            "--ui-root",
449            "./custom-ui",
450            "compose",
451            "render",
452            "--root",
453            "/tmp",
454        ]))
455        .expect("parse");
456        assert_eq!(parsed.host.viewer, ViewerMode::None);
457        assert_eq!(parsed.host.ui_root, PathBuf::from("./custom-ui"));
458        assert_eq!(
459            parsed.positionals,
460            args(&["compose", "render", "--root", "/tmp"])
461        );
462    }
463
464    #[test]
465    fn default_ui_root_prefers_env_override() {
466        let tmp = tempfile::tempdir().expect("tempdir");
467        let custom = tmp.path().join("custom-ui");
468        std::fs::create_dir_all(&custom).expect("mkdir");
469        let root = default_ui_root_with(Some(custom.to_str().expect("utf8")), None, None, false);
470        assert_eq!(root, custom);
471    }
472
473    #[test]
474    fn default_ui_root_falls_back_to_ui_when_nothing_found() {
475        let tmp = tempfile::tempdir().expect("tempdir");
476        let root = default_ui_root_with(None, Some(tmp.path()), None, false);
477        assert_eq!(root, PathBuf::from("ui"));
478    }
479
480    #[test]
481    fn usage_message_lists_every_shipped_skill() {
482        let text = usage_message();
483        assert!(text.contains(".csv"), "{text}");
484        assert!(text.contains("table"), "{text}");
485        assert!(text.contains("md data.csv"), "{text}");
486        assert!(text.contains("compose render"), "{text}");
487        assert!(text.contains("--env-prefix"), "{text}");
488        assert!(text.contains("WYVERN_VIEWER"), "{text}");
489        assert!(text.contains("wizard.json or pages/"), "{text}");
490        assert!(text.contains("wyvern guide"), "{text}");
491        assert!(text.contains("panel.xhtml"), "{text}");
492        assert!(text.contains(".xhtml"), "{text}");
493        assert!(text.contains("report-xhtml <manifest.json>"), "{text}");
494        assert!(
495            text.contains("report-xhtml --review <manifest.json>"),
496            "{text}"
497        );
498        assert!(text.contains("panels["), "{text}");
499        assert!(text.contains("--workflow-dry-run"), "{text}");
500        assert!(text.contains("wyvern wizard lint"), "{text}");
501    }
502
503    #[test]
504    fn parse_workflow_dry_run_is_on_cli_args_not_host() {
505        let parsed = parse_cli_args(&args(&[
506            "--workflow-dry-run",
507            "--viewer",
508            "none",
509            r#"{"type":"wizard"}"#,
510        ]))
511        .expect("parse");
512        assert!(parsed.workflow_dry_run);
513        assert_eq!(parsed.positionals, args(&[r#"{"type":"wizard"}"#]));
514    }
515}