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