Skip to main content

wyvern/error/
emit.rs

1//! JSON emission helpers for load, validation, host, and extension errors.
2
3use wyvern_schema::{ErrorCode, SerializeError, StderrError, ValidationError};
4
5use super::{EmitError, LoadError, UsageErrorKind};
6
7#[cfg(test)]
8thread_local! {
9    /// Scoped test seam: only the arming thread sees forced stdout emit failures.
10    static FORCE_EMIT_STDOUT_FAIL: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
11}
12
13/// RAII guard that forces [`emit_stdout`] to fail on this thread.
14#[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
32/// Serialize an extension-engine error as stderr JSON.
33///
34/// # Errors
35///
36/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized.
37pub 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
197/// Serialize a usage / unknown-subcommand error as stderr JSON (exit 2).
198///
199/// # Errors
200///
201/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized.
202pub 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
213/// Serialize [`LoadError::Usage`] as stderr JSON with flag-specific recovery.
214///
215/// # Errors
216///
217/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized, or
218/// when `err` is not [`LoadError::Usage`] (miswire).
219pub 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                super::BuiltinDomain::Examples => vec![
291                    "Use wyvern examples list or wyvern examples list --json".into(),
292                    "Run wyvern examples --help".into(),
293                ],
294                super::BuiltinDomain::Wizard => vec![
295                    "Use wyvern wizard lint <path>".into(),
296                    "Run wyvern wizard --help".into(),
297                ],
298            },
299            "docs/wyvern/requirements.md (REQ-0134)",
300        ),
301        UsageErrorKind::MissingExtensionId => (
302            "extensions show requires a shipped or project extension id".to_string(),
303            vec![
304                "Pass an id: wyvern extensions show <id>".into(),
305                "Run wyvern extensions list to see available ids".into(),
306                "Run wyvern extensions list --json for machine-readable ids".into(),
307                "Run wyvern extensions --help".into(),
308            ],
309            "docs/wyvern/requirements.md (REQ-0132)",
310        ),
311    };
312    let mut envelope = StderrError::new(ErrorCode::ParseError, message.clone())
313        .cause(cause)
314        .docs(docs);
315    for step in recovery {
316        envelope = envelope.recovery(step);
317    }
318    envelope.to_json_string().map_err(EmitError::Serialize)
319}
320
321/// Serialize a parse load error as stderr JSON.
322///
323/// # Errors
324///
325/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized, or
326/// when `err` is not [`LoadError::Parse`] (miswire).
327pub fn emit_parse_error(err: &LoadError) -> Result<String, EmitError> {
328    let LoadError::Parse { message } = err else {
329        debug_assert!(matches!(err, LoadError::Parse { .. }));
330        return Err(EmitError::Serialize(SerializeError {
331            message: "emit_parse_error: expected Parse".into(),
332        }));
333    };
334    StderrError::new(ErrorCode::ParseError, message.clone())
335        .cause("Input was not valid JSON")
336        .recovery("Ensure input is valid JSON")
337        .recovery("Check for trailing commas, unquoted keys, or truncated input")
338        .docs("docs/wyvern-schema/requirements.md (REQ-0069)")
339        .to_json_string()
340        .map_err(EmitError::Serialize)
341}
342
343/// Serialize an I/O load error as stderr JSON.
344///
345/// # Errors
346///
347/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized, or
348/// when `err` is not [`LoadError::Io`] (miswire).
349pub fn emit_io_error(err: &LoadError) -> Result<String, EmitError> {
350    let LoadError::Io { field, message, .. } = err else {
351        debug_assert!(matches!(err, LoadError::Io { .. }));
352        return Err(EmitError::Serialize(SerializeError {
353            message: "emit_io_error: expected Io".into(),
354        }));
355    };
356    StderrError::new(ErrorCode::IoError, message.clone())
357        .field(field.clone())
358        .cause(format!("Failed to read input from '{}'", field.as_str()))
359        .recovery("Verify the file path exists and is readable")
360        .recovery("Pass JSON inline as an argv string or via stdin")
361        .docs("docs/wyvern-schema/requirements.md (REQ-0071)")
362        .to_json_string()
363        .map_err(EmitError::Serialize)
364}
365
366/// Serialize a validation/state error as stderr JSON.
367///
368/// # Errors
369///
370/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized.
371pub fn emit_validation_error(err: &ValidationError) -> Result<String, EmitError> {
372    let envelope = match err {
373        ValidationError::Validation { field, message } => {
374            let mut envelope = StderrError::new(ErrorCode::ValidationError, message.clone())
375                .field(field.clone())
376                .cause(format!("Command JSON failed schema checks on '{field}'"))
377                .docs("docs/wyvern-schema/requirements.md (REQ-0051, REQ-0070)");
378            for step in validation_recovery(field.as_str(), message) {
379                envelope = envelope.recovery(step);
380            }
381            envelope
382        }
383        ValidationError::State { field, message } => {
384            StderrError::new(ErrorCode::StateError, message.clone())
385                .field(field.clone())
386                .cause("Lifecycle action used outside interactive mode")
387                .recovery("Run with --interactive to use lifecycle actions (show/hide/exit)")
388                .recovery("Omit the action field for one-shot chrome commands")
389                .docs("docs/wyvern-schema/requirements.md (REQ-0072)")
390        }
391    };
392    envelope.to_json_string().map_err(EmitError::Serialize)
393}
394
395fn validation_recovery(field: &str, message: &str) -> Vec<String> {
396    if field == "title" && message.contains("missing required field") {
397        return vec![
398            "Add required field \"title\" with a string value".into(),
399            "Example: {\"type\":\"chrome\",\"title\":\"Foundation\"}".into(),
400        ];
401    }
402    if field == "type" && message.contains("missing required field") {
403        return vec![
404            "Add required field \"type\" with value \"chrome\"".into(),
405            "Example: {\"type\":\"chrome\",\"title\":\"Foundation\"}".into(),
406        ];
407    }
408    if field == "type" && message.contains("expected one of") {
409        return vec![
410            "Set \"type\" to one of: chrome, message, input, markdown, question, wizard, report"
411                .into(),
412            "Example: {\"type\":\"report\",\"title\":\"Panel\",\"page\":\"pages/view.xhtml\",\"mode\":\"view\"}"
413                .into(),
414            "Example: {\"type\":\"wizard\",\"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}}"
415                .into(),
416        ];
417    }
418    if field == "page" && message.contains("missing required field") {
419        return vec![
420            "For report commands, set \"page\" to a .html or .xhtml path string relative to --ui-root"
421                .into(),
422            "Example: {\"type\":\"report\",\"title\":\"Panel\",\"page\":\"pages/view.xhtml\",\"mode\":\"view\"}"
423                .into(),
424            "For wizard commands, add required object field \"page\" with id, title, and html"
425                .into(),
426            "Example: {\"type\":\"wizard\",\"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}}"
427                .into(),
428        ];
429    }
430    if field == "page"
431        && (message.contains("must end with")
432            || message.contains("expected string")
433            || message.contains("non-empty string"))
434    {
435        return vec![
436            "Set report \"page\" to a non-empty .html or .xhtml path string relative to --ui-root"
437                .into(),
438            "Example: {\"type\":\"report\",\"title\":\"Panel\",\"page\":\"pages/view.xhtml\",\"mode\":\"view\"}"
439                .into(),
440        ];
441    }
442    if field == "page" && message.contains("expected object") {
443        return vec!["Provide \"page\" as a JSON object with id, title, and html".into()];
444    }
445    if field == "page.id" {
446        return vec![
447            "Set \"page.id\" to a non-empty string page identity".into(),
448            "Example: \"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}"
449                .into(),
450        ];
451    }
452    if field == "page.title" {
453        return vec![
454            "Set \"page.title\" to a non-empty string display title".into(),
455            "Example: \"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}"
456                .into(),
457        ];
458    }
459    if field == "page.html" {
460        return vec![
461            "Set \"page.html\" to a non-empty path relative to --ui-root".into(),
462            "Example: \"page\":{\"id\":\"start\",\"title\":\"Start\",\"html\":\"pages/start.html\"}"
463                .into(),
464        ];
465    }
466    if field == "page.layout" {
467        return vec!["Set \"page.layout\" to one of: dialog, workspace (or omit the field)".into()];
468    }
469    if field.starts_with("page.") && message.contains("unknown field") {
470        return vec![format!(
471            "Remove unknown field \"{field}\"; page allows only id, title, html, and layout"
472        )];
473    }
474    if field == "buttons" {
475        return vec![
476            "Set \"buttons\" to one of: ok, ok_cancel, yes_no, yes_no_cancel, retry_cancel, custom"
477                .into(),
478        ];
479    }
480    if field == "level" {
481        return vec!["Set \"level\" to one of: info, warning, error, question".into()];
482    }
483    if field == "custom_buttons" {
484        return vec![
485            "Provide \"custom_buttons\" as a string array only when \"buttons\" is \"custom\""
486                .into(),
487        ];
488    }
489    if field == "default_button" {
490        return vec![
491            "Set \"default_button\" to a 0-based index within the active button list".into(),
492        ];
493    }
494    if field == "markdown" {
495        return vec!["Provide \"markdown\" as a JSON boolean (true or false)".into()];
496    }
497    if field == "file" && message.contains("exactly one of") {
498        return vec![
499            "Provide exactly one of \"file\" or \"content\" for markdown commands".into(),
500            "Example: {\"type\":\"markdown\",\"file\":\"doc.md\"}".into(),
501            "Example: {\"type\":\"markdown\",\"content\":\"# Hello\"}".into(),
502        ];
503    }
504    if message.contains("expected string") {
505        return vec![format!("Provide field \"{field}\" as a JSON string")];
506    }
507    if message.contains("unknown field") {
508        return vec![format!(
509            "Remove unknown field \"{field}\"; check the schema for this command type"
510        )];
511    }
512    if message.contains("expected JSON object") {
513        return vec!["Pass a single JSON object as the command payload".into()];
514    }
515    vec![format!(
516        "Fix field \"{field}\" to match the current phase command schema"
517    )]
518}
519
520/// Serialize a successful [`wyvern_schema::CommandResult`] for stdout.
521///
522/// # Errors
523///
524/// Returns [`EmitError::Serialize`] when `result` cannot be serialized.
525pub fn emit_stdout(result: &wyvern_schema::CommandResult) -> Result<String, EmitError> {
526    #[cfg(test)]
527    {
528        if FORCE_EMIT_STDOUT_FAIL.with(std::cell::Cell::get) {
529            return Err(EmitError::Serialize(SerializeError {
530                message: "forced".into(),
531            }));
532        }
533    }
534    serde_json::to_string(result).map_err(|e| {
535        EmitError::Serialize(SerializeError {
536            message: e.to_string(),
537        })
538    })
539}
540
541/// Serialize a [`wyvern_host::HostError`] as stderr JSON (REQ-0073).
542///
543/// # Errors
544///
545/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized.
546pub fn emit_host_error(err: &wyvern_host::HostError) -> Result<String, EmitError> {
547    use wyvern_host::HostError;
548    let (code, message, cause, recovery, docs) = match err {
549        HostError::Bind { message, source } => {
550            let message = match source {
551                Some(err) => format!("{message}: {err}"),
552                None => message.clone(),
553            };
554            (
555                ErrorCode::HostBindError,
556                message,
557                "Failed to bind the dialog HTTP server".to_string(),
558                vec![
559                    "Check that --bind is a valid address".into(),
560                    "Try --bind 127.0.0.1:0 for an ephemeral port".into(),
561                ],
562                "docs/wyvern-host/requirements.md (REQ-0091)",
563            )
564        }
565        HostError::UiNotFound { path, source } => {
566            let message = match source {
567                Some(err) => format!("UI not found at '{}': {err}", path.display()),
568                None => format!("UI not found at '{}'", path.display()),
569            };
570            (
571                ErrorCode::UiNotFound,
572                message,
573                "Packaged UI root, dialog template, wizard page HTML, or report page is missing".to_string(),
574                vec![
575                    "Pass --ui-root pointing at a directory with message/, input/, markdown/, question/, and chrome/ templates".into(),
576                    "For wizard commands, ensure page.html exists relative to --ui-root (served under /wizard/**)".into(),
577                    "For report commands, ensure page exists relative to --ui-root (served under /report/**)".into(),
578                    "Ensure ui/{message,input,markdown,question,chrome}/ exist in the workspace for development".into(),
579                ],
580                "docs/wyvern-host/requirements.md (REQ-0093, REQ-0100)",
581            )
582        }
583        HostError::UnsupportedType { type_name } => (
584            ErrorCode::UnsupportedType,
585            format!("dialog type '{type_name}' is not implemented on the HTTP host yet"),
586            "Schema validation passed; host matrix supports chrome, message, input, markdown, question, wizard, and report".to_string(),
587            vec![
588                "Use one of: chrome, message, input, markdown, question, wizard, report".into(),
589            ],
590            "docs/plans/phase-C/http-dialog-contract.md",
591        ),
592        HostError::InvalidResult { message } => (
593            ErrorCode::HostError,
594            message.clone(),
595            "POST /api/result body was invalid for the active dialog".to_string(),
596            vec!["Submit a body matching the dialog CommandResult wire shape".into()],
597            "docs/plans/phase-C/http-post-schema.md",
598        ),
599        HostError::ViewerNotFound { id, hint } => (
600            ErrorCode::HostViewerError,
601            format!("viewer '{id}' not found"),
602            hint.clone(),
603            vec![
604                format!("Install {id} or use --viewer system"),
605                "Use --viewer none for headless / CI".into(),
606            ],
607            "docs/plans/phase-C/http-viewer-contract.md",
608        ),
609        HostError::ViewerUnsupported { mode } => (
610            ErrorCode::HostViewerError,
611            format!(
612                "viewer mode '{}' is not supported by host::run",
613                mode.as_str()
614            ),
615            "Embedded one-shot must use begin + wyvern-viewer spawn (CLI pipeline)".to_string(),
616            vec![
617                "Omit --viewer or use --viewer embedded (CLI default)".into(),
618                "Use --viewer none for headless / CI".into(),
619            ],
620            "docs/plans/phase-C/http-viewer-contract.md",
621        ),
622        HostError::Registry { message } => (
623            ErrorCode::HostError,
624            message.clone(),
625            "Browser registry cache read/write failed".to_string(),
626            vec![
627                "Run `wyvern browsers refresh` to rebuild the cache".into(),
628                "Check WYVERN_BROWSERS_FILE path and cache directory permissions".into(),
629                "Delete a corrupt browsers.json and retry".into(),
630            ],
631            "docs/plans/phase-C/http-viewer-contract.md",
632        ),
633        HostError::Internal { message } => (
634            ErrorCode::HostError,
635            message.clone(),
636            "Internal HTTP host failure".to_string(),
637            vec![
638                "Retry the command".into(),
639                "Report a bug if it persists".into(),
640            ],
641            "docs/wyvern-host/architecture.md",
642        ),
643        HostError::Wizard { source } => {
644            let subcode = source.subcode();
645            (
646                ErrorCode::HostError,
647                format!("{subcode}: {source}"),
648                format!("{subcode}: wizard session failed during host setup or state access"),
649                vec![
650                    format!("See wizard error sub-code {subcode} for the specific failure"),
651                    "Ensure the command is type: wizard with a validated page object".into(),
652                    "Retry the command; report a bug if a validated wizard has no session".into(),
653                ],
654                "docs/plans/phase-C/http-wizard-contract.md",
655            )
656        }
657    };
658
659    let mut envelope = StderrError::new(code, message).cause(cause).docs(docs);
660    if let HostError::Wizard { source } = err {
661        envelope = envelope.subcode(source.subcode());
662    }
663    for step in recovery {
664        envelope = envelope.recovery(step);
665    }
666    envelope.to_json_string().map_err(EmitError::Serialize)
667}
668
669/// Serialize a wizard lint stage failure as stderr JSON.
670///
671/// Maps [`WizardLintStageError`] variants to `IoError`, `ParseError`, or
672/// `ValidationError` with distinct subcodes and recovery steps (RBP-F002).
673///
674/// # Errors
675///
676/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized.
677pub fn emit_wizard_lint_stage_error(
678    err: &crate::wizard_cmd::WizardLintStageError,
679) -> Result<String, EmitError> {
680    use crate::wizard_cmd::WizardLintStageError;
681    const DOCS: &str =
682        ".claude/skills/creating-wyvern-wizard/references/core/validation-and-lint.md";
683    let (code, message, cause, recovery, field) = match err {
684        WizardLintStageError::Io { path, message } => (
685            ErrorCode::IoError,
686            message.clone(),
687            format!("wyvern wizard lint could not read '{}'", path.display()),
688            vec![
689                "Verify the path contains wizard.json and all referenced pages exist".into(),
690                "Run `wyvern wizard lint --help` for usage".into(),
691            ],
692            None,
693        ),
694        WizardLintStageError::Parse { path, message } => (
695            ErrorCode::ParseError,
696            message.clone(),
697            format!("wizard.json at '{}' is not valid JSON", path.display()),
698            vec![
699                "Ensure wizard.json is valid JSON".into(),
700                "Check for trailing commas, unquoted keys, or truncated input".into(),
701                "Run `wyvern wizard lint --help` for usage".into(),
702            ],
703            None,
704        ),
705        WizardLintStageError::Validation {
706            path,
707            field,
708            message,
709        } => (
710            ErrorCode::ValidationError,
711            message.clone(),
712            format!(
713                "wizard.json at '{}' failed field checks on '{field}'",
714                path.display()
715            ),
716            vec![
717                format!("Fix field '{field}' to a non-empty string"),
718                "page.id and page.html must be non-empty".into(),
719                "Run `wyvern wizard lint --help` for usage".into(),
720            ],
721            Some(field.clone()),
722        ),
723    };
724    let mut envelope = StderrError::new(code, message)
725        .subcode(err.subcode())
726        .cause(cause)
727        .docs(DOCS);
728    if let Some(field) = field {
729        envelope = envelope.field(field);
730    }
731    for step in recovery {
732        envelope = envelope.recovery(step);
733    }
734    envelope.to_json_string().map_err(EmitError::Serialize)
735}
736
737/// Serialize a workflow / chain failure as stderr JSON (`WORKFLOW_ERROR`, exit 9).
738///
739/// Always uses [`ErrorCode::WorkflowError`] — no hand-built slug.
740///
741/// # Errors
742///
743/// Returns [`EmitError::Serialize`] when the envelope cannot be serialized.
744pub fn emit_workflow_error(err: &crate::workflow::WorkflowError) -> Result<String, EmitError> {
745    let mut envelope = StderrError::new(ErrorCode::WorkflowError, err.to_string())
746        .cause(err.cause())
747        .subcode(err.subcode())
748        .docs("docs/plans/phase-G/wizard-workflow-architecture.md");
749    for step in err.recovery() {
750        envelope = envelope.recovery(step);
751    }
752    envelope.to_json_string().map_err(EmitError::Serialize)
753}
754
755/// Emit static internal stderr JSON and exit with code 8 (REQ-0078).
756///
757/// Uses a hand-built JSON string so a serialize failure cannot recurse.
758/// Includes `cause` / `recovery` / `docs` per the stderr contract (RBP-F004).
759pub fn emit_fatal_internal(err: &EmitError) -> ! {
760    let EmitError::Serialize(e) = err;
761    let msg_json =
762        serde_json::to_string(&e.message).unwrap_or_else(|_| "\"serialization failed\"".into());
763    eprintln!(
764        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)"}}"#
765    );
766    std::process::exit(ErrorCode::InternalError.exit_code());
767}