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,
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
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 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
185pub 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#[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
254pub 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}