1use wyvern_schema::{ErrorCode, SerializeError, StderrError, ValidationError};
4
5use super::{EmitError, LoadError, UsageErrorKind};
6
7#[cfg(test)]
8thread_local! {
9 static FORCE_EMIT_STDOUT_FAIL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
11}
12
13#[cfg(test)]
15pub(super) struct ForceEmitStdoutFailGuard;
16
17#[cfg(test)]
18impl ForceEmitStdoutFailGuard {
19 pub(super) fn arm() -> Self {
20 FORCE_EMIT_STDOUT_FAIL.with(|f| f.set(true));
21 Self
22 }
23}
24
25#[cfg(test)]
26impl Drop for ForceEmitStdoutFailGuard {
27 fn drop(&mut self) {
28 FORCE_EMIT_STDOUT_FAIL.with(|f| f.set(false));
29 }
30}
31
32pub fn emit_extension_error(err: &crate::extensions::ExtensionError) -> Result<String, EmitError> {
38 use crate::extensions::ExtensionError;
39 let (code, message, cause, recovery) = match err {
40 ExtensionError::InvalidRegistry { message } => (
41 ErrorCode::ParseError,
42 message.clone(),
43 "Extension registry JSON could not be loaded".to_string(),
44 vec![
45 "Fix share/wyvern/extensions.json or .wyvern/extensions.json".into(),
46 "Registry must be version 1 JSON with an extensions array".into(),
47 ],
48 ),
49 ExtensionError::MissingArgs {
50 missing,
51 extension_id,
52 example,
53 help_command,
54 ..
55 } => (
56 ErrorCode::ValidationError,
57 format!(
58 "missing required arguments {} for '{extension_id}'",
59 missing.join(", ")
60 ),
61 format!("'{extension_id}' requires {}", missing.join(" and ")),
62 vec![
63 format!("Pass {} after the extension prefix", missing.join(" ")),
64 format!("Example: {example}"),
65 format!("Run {help_command}"),
66 "Run wyvern --help to list skills".into(),
67 ],
68 ),
69 ExtensionError::UnexpectedArg {
70 token,
71 declared,
72 extension_id,
73 help_command,
74 } => {
75 let accepted = if declared.is_empty() {
76 format!("Run {help_command}")
77 } else {
78 format!(
79 "Accepted flags: {}",
80 declared
81 .iter()
82 .map(|name| format!("--{name}"))
83 .collect::<Vec<_>>()
84 .join(", ")
85 )
86 };
87 (
88 ErrorCode::ValidationError,
89 format!("unexpected argument after extension match: {token}"),
90 format!("'{extension_id}' does not accept leftover token '{token}'"),
91 vec![
92 format!("Remove unexpected argument `{token}`"),
93 accepted,
94 format!("Run {help_command}"),
95 "Run wyvern --help to list skills".into(),
96 ],
97 )
98 }
99 ExtensionError::PathVarWithoutPath { var } => (
100 ErrorCode::ValidationError,
101 format!("template {{{var}}} requires a matched file path"),
102 "This extension is prefix-only and has no {{path}}".to_string(),
103 vec!["Use a suffix or prefix+suffix match when expanding path variables".into()],
104 ),
105 ExtensionError::Template { kind, message, .. } => {
106 let (cause, recovery) = match kind {
107 crate::extensions::TemplateErrorKind::UnclosedBrace => (
108 "Template contains an unclosed `{` brace".to_string(),
109 vec!["Close every `{variable}` in the registry expand/preexec templates".into()],
110 ),
111 crate::extensions::TemplateErrorKind::UnknownVariable => (
112 "Template references an unknown `{variable}`".to_string(),
113 vec!["Use only documented template variables from cli-extensions-contract.md".into()],
114 ),
115 crate::extensions::TemplateErrorKind::PhaseRestricted => (
116 "Template variable is not allowed in this expansion phase".to_string(),
117 vec!["Move path-only variables to expand phase-1; use phase-2 for preexec stdout vars".into()],
118 ),
119 crate::extensions::TemplateErrorKind::Unavailable => (
120 "Template variable is not available in this match context".to_string(),
121 vec!["Ensure the match provides path/tmpdir/preexec stdout before using this variable".into()],
122 ),
123 crate::extensions::TemplateErrorKind::InvalidSpec => (
124 "Expand/preexec spec is incomplete or contradictory".to_string(),
125 vec!["Check command_from_file, preexec cmd/args, and host overrides in the registry".into()],
126 ),
127 };
128 (ErrorCode::ValidationError, message.clone(), cause, recovery)
129 }
130 ExtensionError::Preexec { kind, message, .. } => {
131 use crate::extensions::PreexecFailureKind;
132 let (cause, recovery) = match kind {
133 Some(PreexecFailureKind::SpawnNotFound { cmd }) => (
134 format!("Could not spawn preexec helper '{cmd}'"),
135 vec![
136 format!("Install '{cmd}' or add it to PATH"),
137 "Run wyvern extensions list to see requires".into(),
138 "Run wyvern --help to list skills".into(),
139 ],
140 ),
141 Some(PreexecFailureKind::NonZeroExit { stderr_tail, code }) => {
142 let cause = if stderr_tail.is_empty() {
143 format!("Preexec helper exited with status {code}")
144 } else {
145 stderr_tail.clone()
146 };
147 (
148 cause,
149 vec![
150 "Inspect the helper stderr in cause and fix the input path or flags"
151 .into(),
152 "Retry after correcting the file or arguments".into(),
153 "Run wyvern --help to list skills".into(),
154 ],
155 )
156 }
157 Some(PreexecFailureKind::Timeout { cmd, timeout_secs }) => (
158 format!("Preexec helper '{cmd}' timed out after {timeout_secs}s"),
159 vec![
160 format!(
161 "Increase WYVERN_PREEXEC_TIMEOUT_SECS (current {timeout_secs}) if the helper needs more time"
162 ),
163 "Or fix a hung helper or blocked input path".into(),
164 "Run wyvern --help to list skills".into(),
165 ],
166 ),
167 None => (
168 "Extension preexec subprocess failed".to_string(),
169 vec![
170 "Inspect the helper output in the error message".into(),
171 "Retry after correcting the input path or flags".into(),
172 "Run wyvern --help to list skills".into(),
173 ],
174 ),
175 };
176 (ErrorCode::IoError, message.clone(), cause, recovery)
177 }
178 ExtensionError::InvalidCommand { source } => {
179 return emit_validation_error(source);
180 }
181 ExtensionError::Io { message, .. } => (
182 ErrorCode::IoError,
183 message.clone(),
184 "Extension engine filesystem operation failed".to_string(),
185 vec!["Check paths in the registry and working directory permissions".into()],
186 ),
187 };
188 let mut envelope = StderrError::new(code, message)
189 .cause(cause)
190 .docs("docs/plans/phase-F/cli-extensions-contract.md");
191 for step in recovery {
192 envelope = envelope.recovery(step);
193 }
194 envelope.to_json_string().map_err(EmitError::Serialize)
195}
196
197pub fn emit_usage_message(message: &str) -> Result<String, EmitError> {
203 StderrError::new(ErrorCode::ParseError, message.to_string())
204 .cause("CLI argv was not a valid command or extension invocation")
205 .recovery("Pass a JSON command, a .json file, or a path handled by an extension")
206 .recovery("Run wyvern extensions list to see file-type and prefix extensions")
207 .recovery("Run wyvern --help for host flags")
208 .docs("docs/wyvern/requirements.md (REQ-0130)")
209 .to_json_string()
210 .map_err(EmitError::Serialize)
211}
212
213pub fn emit_usage_error(err: &LoadError) -> Result<String, EmitError> {
220 let LoadError::Usage { kind, message } = err else {
221 debug_assert!(matches!(err, LoadError::Usage { .. }));
222 return Err(EmitError::Serialize(SerializeError {
223 message: "emit_usage_error: expected Usage".into(),
224 }));
225 };
226 let (cause, recovery, docs) = match kind {
227 UsageErrorKind::Generic => (
228 "CLI argv was not a valid command or extension invocation".to_string(),
229 vec![
230 "Pass a JSON command, a .json file, or a path handled by an extension".into(),
231 "Run wyvern extensions list to see file-type and prefix extensions".into(),
232 "Run wyvern --help for host flags".into(),
233 ],
234 "docs/wyvern/requirements.md (REQ-0130)",
235 ),
236 UsageErrorKind::InvalidBind { .. } => (
237 "The --bind value is not a valid socket address".to_string(),
238 vec![
239 "Use host:port form (example: 127.0.0.1:0 for an ephemeral loopback port)".into(),
240 "For 0.0.0.0 / LAN binds, also pass --allow-non-loopback".into(),
241 "Check the address is a valid IPv4/IPv6 socket address".into(),
242 ],
243 "docs/plans/phase-F/README.md",
244 ),
245 UsageErrorKind::MissingFlagValue { flag } => (
246 format!("Host flag {flag} requires a value"),
247 vec![
248 format!("Pass {flag} VALUE on the command line"),
249 "Use --bind=ADDR:PORT or --viewer=MODE inline forms when preferred".into(),
250 ],
251 "docs/plans/phase-F/README.md",
252 ),
253 UsageErrorKind::InvalidViewer { .. } => (
254 "The --viewer value is not a supported viewer mode".to_string(),
255 vec![
256 "Use one of: embedded, none, system, chrome, safari, edge, firefox".into(),
257 "Omit --viewer to use embedded (default)".into(),
258 "Set WYVERN_VIEWER=none for headless / CI".into(),
259 ],
260 "docs/plans/phase-C/http-viewer-contract.md",
261 ),
262 UsageErrorKind::InvalidWyvernViewerEnv { .. } => (
263 "WYVERN_VIEWER is set but not a valid viewer mode".to_string(),
264 vec![
265 "Use one of: embedded, none, system, chrome, safari, edge, firefox".into(),
266 "Unset WYVERN_VIEWER to use embedded (default)".into(),
267 "Use WYVERN_VIEWER=none for headless / CI".into(),
268 ],
269 "docs/plans/phase-C/http-viewer-contract.md",
270 ),
271 UsageErrorKind::InvalidWyvernViewerUnicode => (
272 "WYVERN_VIEWER is not valid Unicode".to_string(),
273 vec![
274 "Set WYVERN_VIEWER to ASCII viewer mode names only".into(),
275 "Unset WYVERN_VIEWER to use embedded (default)".into(),
276 ],
277 "docs/plans/phase-C/http-viewer-contract.md",
278 ),
279 UsageErrorKind::UnknownSubcommand { domain, token } => (
280 format!("'{token}' is not a valid {domain} subcommand"),
281 match domain {
282 super::BuiltinDomain::Browsers => vec![
283 "Use wyvern browsers list or wyvern browsers refresh".into(),
284 "Run wyvern browsers --help".into(),
285 ],
286 super::BuiltinDomain::Extensions => vec![
287 "Use wyvern extensions list or wyvern extensions show <id>".into(),
288 "Run wyvern extensions --help".into(),
289 ],
290 },
291 "docs/wyvern/requirements.md (REQ-0134)",
292 ),
293 UsageErrorKind::MissingExtensionId => (
294 "extensions show requires a shipped or project extension id".to_string(),
295 vec![
296 "Pass an id: wyvern extensions show <id>".into(),
297 "Run wyvern extensions list to see available ids".into(),
298 "Run wyvern extensions list --json for machine-readable ids".into(),
299 "Run wyvern extensions --help".into(),
300 ],
301 "docs/wyvern/requirements.md (REQ-0132)",
302 ),
303 };
304 let mut envelope = StderrError::new(ErrorCode::ParseError, message.clone())
305 .cause(cause)
306 .docs(docs);
307 for step in recovery {
308 envelope = envelope.recovery(step);
309 }
310 envelope.to_json_string().map_err(EmitError::Serialize)
311}
312
313pub fn emit_parse_error(err: &LoadError) -> Result<String, EmitError> {
320 let LoadError::Parse { message } = err else {
321 debug_assert!(matches!(err, LoadError::Parse { .. }));
322 return Err(EmitError::Serialize(SerializeError {
323 message: "emit_parse_error: expected Parse".into(),
324 }));
325 };
326 StderrError::new(ErrorCode::ParseError, message.clone())
327 .cause("Input was not valid JSON")
328 .recovery("Ensure input is valid JSON")
329 .recovery("Check for trailing commas, unquoted keys, or truncated input")
330 .docs("docs/wyvern-schema/requirements.md (REQ-0069)")
331 .to_json_string()
332 .map_err(EmitError::Serialize)
333}
334
335pub fn emit_io_error(err: &LoadError) -> Result<String, EmitError> {
342 let LoadError::Io { field, message, .. } = err else {
343 debug_assert!(matches!(err, LoadError::Io { .. }));
344 return Err(EmitError::Serialize(SerializeError {
345 message: "emit_io_error: expected Io".into(),
346 }));
347 };
348 StderrError::new(ErrorCode::IoError, message.clone())
349 .field(field.clone())
350 .cause(format!("Failed to read input from '{}'", field.as_str()))
351 .recovery("Verify the file path exists and is readable")
352 .recovery("Pass JSON inline as an argv string or via stdin")
353 .docs("docs/wyvern-schema/requirements.md (REQ-0071)")
354 .to_json_string()
355 .map_err(EmitError::Serialize)
356}
357
358pub fn emit_validation_error(err: &ValidationError) -> Result<String, EmitError> {
364 let envelope = match err {
365 ValidationError::Validation { field, message } => {
366 let mut envelope = StderrError::new(ErrorCode::ValidationError, message.clone())
367 .field(field.clone())
368 .cause(format!("Command JSON failed schema checks on '{field}'"))
369 .docs("docs/wyvern-schema/requirements.md (REQ-0051, REQ-0070)");
370 for step in validation_recovery(field.as_str(), message) {
371 envelope = envelope.recovery(step);
372 }
373 envelope
374 }
375 ValidationError::State { field, message } => {
376 StderrError::new(ErrorCode::StateError, message.clone())
377 .field(field.clone())
378 .cause("Lifecycle action used outside interactive mode")
379 .recovery("Run with --interactive to use lifecycle actions (show/hide/exit)")
380 .recovery("Omit the action field for one-shot chrome commands")
381 .docs("docs/wyvern-schema/requirements.md (REQ-0072)")
382 }
383 };
384 envelope.to_json_string().map_err(EmitError::Serialize)
385}
386
387fn validation_recovery(field: &str, message: &str) -> Vec<String> {
388 if field == "title" && message.contains("missing required field") {
389 return vec![
390 "Add required field \"title\" with a string value".into(),
391 "Example: {\"type\":\"chrome\",\"title\":\"Foundation\"}".into(),
392 ];
393 }
394 if field == "type" && message.contains("missing required field") {
395 return vec![
396 "Add required field \"type\" with value \"chrome\"".into(),
397 "Example: {\"type\":\"chrome\",\"title\":\"Foundation\"}".into(),
398 ];
399 }
400 if field == "type" && message.contains("expected one of") {
401 return vec![
402 "Set \"type\" to one of: chrome, message, input, markdown, question, wizard".into(),
403 "Example: {\"type\":\"wizard\",\"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}}"
404 .into(),
405 ];
406 }
407 if field == "page" && message.contains("missing required field") {
408 return vec![
409 "Add required object field \"page\" with id, title, and html".into(),
410 "Example: {\"type\":\"wizard\",\"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}}"
411 .into(),
412 ];
413 }
414 if field == "page" && message.contains("expected object") {
415 return vec!["Provide \"page\" as a JSON object with id, title, and html".into()];
416 }
417 if field == "page.id" {
418 return vec![
419 "Set \"page.id\" to a non-empty string page identity".into(),
420 "Example: \"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}"
421 .into(),
422 ];
423 }
424 if field == "page.title" {
425 return vec![
426 "Set \"page.title\" to a non-empty string display title".into(),
427 "Example: \"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}"
428 .into(),
429 ];
430 }
431 if field == "page.html" {
432 return vec![
433 "Set \"page.html\" to a non-empty path relative to --ui-root".into(),
434 "Example: \"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}"
435 .into(),
436 ];
437 }
438 if field == "page.layout" {
439 return vec!["Set \"page.layout\" to one of: dialog, workspace (or omit the field)".into()];
440 }
441 if field.starts_with("page.") && message.contains("unknown field") {
442 return vec![format!(
443 "Remove unknown field \"{field}\"; page allows only id, title, html, and layout"
444 )];
445 }
446 if field == "buttons" {
447 return vec![
448 "Set \"buttons\" to one of: ok, ok_cancel, yes_no, yes_no_cancel, retry_cancel, custom"
449 .into(),
450 ];
451 }
452 if field == "level" {
453 return vec!["Set \"level\" to one of: info, warning, error, question".into()];
454 }
455 if field == "custom_buttons" {
456 return vec![
457 "Provide \"custom_buttons\" as a string array only when \"buttons\" is \"custom\""
458 .into(),
459 ];
460 }
461 if field == "default_button" {
462 return vec![
463 "Set \"default_button\" to a 0-based index within the active button list".into(),
464 ];
465 }
466 if field == "markdown" {
467 return vec!["Provide \"markdown\" as a JSON boolean (true or false)".into()];
468 }
469 if field == "file" && message.contains("exactly one of") {
470 return vec![
471 "Provide exactly one of \"file\" or \"content\" for markdown commands".into(),
472 "Example: {\"type\":\"markdown\",\"file\":\"doc.md\"}".into(),
473 "Example: {\"type\":\"markdown\",\"content\":\"# Hello\"}".into(),
474 ];
475 }
476 if message.contains("expected string") {
477 return vec![format!("Provide field \"{field}\" as a JSON string")];
478 }
479 if message.contains("unknown field") {
480 return vec![format!(
481 "Remove unknown field \"{field}\"; check the schema for this command type"
482 )];
483 }
484 if message.contains("expected JSON object") {
485 return vec!["Pass a single JSON object as the command payload".into()];
486 }
487 vec![format!(
488 "Fix field \"{field}\" to match the current phase command schema"
489 )]
490}
491
492pub fn emit_stdout(result: &wyvern_schema::CommandResult) -> Result<String, EmitError> {
498 #[cfg(test)]
499 {
500 if FORCE_EMIT_STDOUT_FAIL.with(std::cell::Cell::get) {
501 return Err(EmitError::Serialize(SerializeError {
502 message: "forced".into(),
503 }));
504 }
505 }
506 serde_json::to_string(result).map_err(|e| {
507 EmitError::Serialize(SerializeError {
508 message: e.to_string(),
509 })
510 })
511}
512
513pub fn emit_host_error(err: &wyvern_host::HostError) -> Result<String, EmitError> {
519 use wyvern_host::HostError;
520 let (code, message, cause, recovery, docs) = match err {
521 HostError::Bind { message, source } => {
522 let message = match source {
523 Some(err) => format!("{message}: {err}"),
524 None => message.clone(),
525 };
526 (
527 ErrorCode::HostBindError,
528 message,
529 "Failed to bind the dialog HTTP server".to_string(),
530 vec![
531 "Check that --bind is a valid address".into(),
532 "Try --bind 127.0.0.1:0 for an ephemeral port".into(),
533 ],
534 "docs/wyvern-host/requirements.md (REQ-0091)",
535 )
536 }
537 HostError::UiNotFound { path, source } => {
538 let message = match source {
539 Some(err) => format!("UI not found at '{}': {err}", path.display()),
540 None => format!("UI not found at '{}'", path.display()),
541 };
542 (
543 ErrorCode::UiNotFound,
544 message,
545 "Packaged UI root, dialog template, or wizard page HTML is missing".to_string(),
546 vec![
547 "Pass --ui-root pointing at a directory with message/, input/, markdown/, question/, and chrome/ templates".into(),
548 "For wizard commands, ensure page.html exists relative to --ui-root (served under /wizard/**)".into(),
549 "Ensure ui/{message,input,markdown,question,chrome}/ exist in the workspace for development".into(),
550 ],
551 "docs/wyvern-host/requirements.md (REQ-0093, REQ-0100)",
552 )
553 }
554 HostError::UnsupportedType { type_name } => (
555 ErrorCode::UnsupportedType,
556 format!("dialog type '{type_name}' is not implemented on the HTTP host yet"),
557 "Schema validation passed; host matrix supports chrome, message, input, markdown, question, and wizard".to_string(),
558 vec![
559 "Use one of: chrome, message, input, markdown, question, wizard".into(),
560 ],
561 "docs/plans/phase-C/http-dialog-contract.md",
562 ),
563 HostError::InvalidResult { message } => (
564 ErrorCode::HostError,
565 message.clone(),
566 "POST /api/result body was invalid for the active dialog".to_string(),
567 vec!["Submit a body matching the dialog CommandResult wire shape".into()],
568 "docs/plans/phase-C/http-post-schema.md",
569 ),
570 HostError::ViewerNotFound { id, hint } => (
571 ErrorCode::HostViewerError,
572 format!("viewer '{id}' not found"),
573 hint.clone(),
574 vec![
575 format!("Install {id} or use --viewer system"),
576 "Use --viewer none for headless / CI".into(),
577 ],
578 "docs/plans/phase-C/http-viewer-contract.md",
579 ),
580 HostError::ViewerUnsupported { mode } => (
581 ErrorCode::HostViewerError,
582 format!(
583 "viewer mode '{}' is not supported by host::run",
584 mode.as_str()
585 ),
586 "Embedded one-shot must use begin + wyvern-viewer spawn (CLI pipeline)".to_string(),
587 vec![
588 "Omit --viewer or use --viewer embedded (CLI default)".into(),
589 "Use --viewer none for headless / CI".into(),
590 ],
591 "docs/plans/phase-C/http-viewer-contract.md",
592 ),
593 HostError::Registry { message } => (
594 ErrorCode::HostError,
595 message.clone(),
596 "Browser registry cache read/write failed".to_string(),
597 vec![
598 "Run `wyvern browsers refresh` to rebuild the cache".into(),
599 "Check WYVERN_BROWSERS_FILE path and cache directory permissions".into(),
600 "Delete a corrupt browsers.json and retry".into(),
601 ],
602 "docs/plans/phase-C/http-viewer-contract.md",
603 ),
604 HostError::Internal { message } => (
605 ErrorCode::HostError,
606 message.clone(),
607 "Internal HTTP host failure".to_string(),
608 vec![
609 "Retry the command".into(),
610 "Report a bug if it persists".into(),
611 ],
612 "docs/wyvern-host/architecture.md",
613 ),
614 HostError::Wizard { source } => {
615 let subcode = source.subcode();
616 (
617 ErrorCode::HostError,
618 format!("{subcode}: {source}"),
619 format!("{subcode}: wizard session failed during host setup or state access"),
620 vec![
621 format!("See wizard error sub-code {subcode} for the specific failure"),
622 "Ensure the command is type: wizard with a validated page object".into(),
623 "Retry the command; report a bug if a validated wizard has no session".into(),
624 ],
625 "docs/plans/phase-C/http-wizard-contract.md",
626 )
627 }
628 };
629
630 let mut envelope = StderrError::new(code, message).cause(cause).docs(docs);
631 for step in recovery {
632 envelope = envelope.recovery(step);
633 }
634 envelope.to_json_string().map_err(EmitError::Serialize)
635}
636
637pub fn emit_fatal_internal(err: &EmitError) -> ! {
642 let EmitError::Serialize(e) = err;
643 let msg_json =
644 serde_json::to_string(&e.message).unwrap_or_else(|_| "\"serialization failed\"".into());
645 eprintln!(
646 r#"{{"error":"internal","code":"INTERNAL_ERROR","message":{msg_json},"cause":"Stdout or stderr JSON serialization failed at the CLI emit boundary","recovery":["Retry the command","Report a bug if the payload is valid JSON but emit still fails"],"docs":"docs/wyvern-schema/requirements.md (REQ-0078)"}}"#
647 );
648 std::process::exit(ErrorCode::InternalError.exit_code());
649}