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}
19
20pub fn parse_cli_args(args: &[String]) -> Result<CliArgs, LoadError> {
31 let mut bind = SocketAddr::from(([127, 0, 0, 1], 0));
32 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 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
108pub 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
174pub 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#[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
243pub 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}