1use 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#[derive(Debug, Clone)]
13pub struct CliArgs {
14 pub host: HostOptions,
16 pub positionals: Vec<String>,
18 pub workflow_dry_run: bool,
22}
23
24pub fn parse_cli_args(args: &[String]) -> Result<CliArgs, LoadError> {
35 let mut bind = SocketAddr::from(([127, 0, 0, 1], 0));
36 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 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
119pub 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
193pub 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#[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
262pub 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}