feat(cli): structured error envelope (extra + next_steps on the wire)#21
Merged
vvillait88 merged 3 commits intomainfrom May 7, 2026
Merged
feat(cli): structured error envelope (extra + next_steps on the wire)#21vvillait88 merged 3 commits intomainfrom
vvillait88 merged 3 commits intomainfrom
Conversation
CliError already carries `extra: Record<string, unknown>` and
`nextSteps: { action, suggestion? }` for deterministic agent recovery,
but neither field reached the wire. The bridge from CliError to incur's
IncurError accepts only `{code, message, hint?, retryable?, exitCode?}`
and incur's wire renderer surfaces only `{code, message, retryable,
fieldErrors?}`. Result: agents reading `--json` output got `code` and
`message` but never the structured context needed to recover (e.g.
`extra.valid_keys`, `extra.chain`, `extra.held_chains`,
`next_steps.action`).
Wrap incur's `cli.serve()` so the originating CliError is captured
before the bridge erases it, then re-emit the wire envelope through
incur's own `Formatter.format(...)` with `extra` and `next_steps`
spread alongside the existing fields. Format negotiation (toon / json
/ yaml / md / jsonl) and `--full-output` wrapping stay consistent with
the success path. For `--full-output` JSON we parse incur's actual
output and swap just the `error` body, preserving the real
`meta.command` (e.g. "wallet remove") and `meta.duration`.
Carve-outs that pass through verbatim instead of enriching:
- `--mcp` stdio mode loops forever; per-tool-call enrichment isn't
reachable, and a single post-serve enrichment would emit a stale
envelope after the session closes
- Human-TTY context (no explicit format) preserves incur's friendly
`Error (code): message` one-liner; multi-line TOON dumps are an
agent need, not a human one
- Token / pagination flags (`--token-count`, `--token-limit`,
`--token-offset`) ask incur for a sized or sliced rendering;
enrichment would defeat the purpose
- Non-CliError errors (Parse, Validation, COMMAND_NOT_FOUND) flow
through incur unchanged
Also fixes a sync-throw bypass: `withCliErrors(() =>
fundEstimate({headers: parseHeaders(...)}))` was a non-async arrow,
so a sync `parseHeaders` throw bypassed `.catch()` and reached incur
as a raw Error → `code: UNKNOWN`. Switched to
`Promise.resolve().then(fn).catch(...)` which routes both sync and
async throws through the same handler. All 21 non-async call sites
benefit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three pre-existing inaccuracies the wrap above made false-claims into
true-claims, plus one new contract entry agents now need.
README:
- 'Errors emit { code, message, retryable, hint? } on stderr' was wrong
on both counts: errors emit on stdout (same channel as success), and
`hint?` was always discarded by incur's renderer. Now states the real
shape `{ code, message, retryable, extra?, next_steps? }` on stdout
with a one-line description of what `extra` and `next_steps` carry.
- Python and TypeScript code examples were reading the error envelope
from `proc.stderr` / `err.stderr`. Switched to `proc.stdout` /
`err.stdout` and corrected `err['error']['code']` → `err['code']`
(compact mode is flat, not envelope-wrapped).
- 'every byte on stdout is parseable JSON, every error on stderr…'
rewritten to reflect that both success and error envelopes go to
stdout; stderr is reserved for human-only chrome.
agent-guide:
- `json_mode` repeated the README's stderr / hint? mistake. Fixed to
reference stdout, `extra`, and `next_steps`.
- Added a top-level `error_envelope` field with `channel`, `shape`,
`full_output_shape`, and `human_tty_shape`. Agents that read
agent-guide --json now have an explicit contract for the wire shape
alongside the existing `identity_error_recovery` and `exit_codes`
blocks.
Tests:
- Two regression tests in tests/agent-guide.test.ts lock in
`error_envelope.channel === 'stdout'`, the shape descriptions, and
that `json_mode` mentions stdout (not stderr) and references
`extra` + `next_steps` (not `hint?`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The serveCli wrap depends on incur internals: the wire envelope shape, `Formatter.format` API, `IncurError.Options`, `cli.serve` overrides, `Mcp.serve`, `formatHumanError` rendering, and the left-to-right last-wins format-flag precedence in `extractBuiltinFlags`. Any minor or patch incur bump could silently shift any of these. Drop the caret to exact-pin incur at 0.4.5, mirroring the same pattern viem 2.48.7 uses elsewhere in this repo for load-bearing deps where internal behavior matters. The full upgrade verification protocol is captured in the team's incur-upgrade-checklist memory. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
CliError carries
extra(structured recovery context) andnextSteps(action + suggestion) for deterministic agent recovery, but neither field reached the wire. The bridge to incur'sIncurErroraccepts only{code, message, hint?, retryable?, exitCode?}and incur's renderer surfaces{code, message, retryable, fieldErrors?}. Agents reading--jsongotcodeandmessagebut never the structured context they needed (e.g.extra.valid_keys,extra.chain,extra.held_chains,next_steps.action).This PR wraps
cli.serve()so the originating CliError is captured before the bridge erases it, then re-emits the wire envelope through incur's ownFormatter.format(...)withextraandnext_stepsas proper fields. Format negotiation (toon/json/yaml/md/jsonl) and--full-outputwrapping stay consistent with the success path.Before:
{ "code": "no_wallet", "message": "No keystore for base (X).", "retryable": false }After:
{ "code": "no_wallet", "message": "No keystore for base (X).", "retryable": false, "extra": { "chain": "base", "name": "X" } }Full-output JSON parses incur's actual envelope and swaps just the
errorbody, preserving the realmeta.command(e.g."wallet remove") andmeta.duration.Carve-outs (intentional passthrough — no enrichment)
--mcpstdio modeError (code): messageone-liner; multi-line TOON dumps with extras are an agent need, not a human one--token-count/--token-limit/--token-offsetSync-throw bypass (also fixed)
withCliErrors(() => fundEstimate({headers: parseHeaders(...)}))was a non-async arrow. A syncparseHeadersthrow bypassed.catch()(the throw happened before the inner promise was returned), reaching incur as a raw Error →code: UNKNOWN. Switched toPromise.resolve().then(fn).catch(...)so both sync and async throws route through the same handler. All 21 non-async call sites are uniformly protected. Verified live:fund-estimatewith malformed-Hnow correctly returnscode: invalid_inputinstead ofUNKNOWN.Doc cleanup
Three pre-existing inaccuracies became false-claims-into-true-claims with this wrap:
README.md:73—Errors emit { code, message, retryable, hint? } on stderrwas wrong on both counts. Now states the real shape on stdout withextra?/next_steps?. Thehint?field was never reachable by the renderer.README.md:184/210— Python and TypeScript code examples read the error envelope from stderr; switched to stdout and corrected the compact-mode shape (err['code'], noterr['error']['code']).agent-guide.ts:json_mode— same stderr / hint? mistake; corrected.Plus a new contract entry:
agent-guidenow has a top-levelerror_envelopefield withchannel/shape/full_output_shape/human_tty_shape. Agents readingagent-guide --jsonget an explicit wire-shape contract alongside the existingidentity_error_recoveryandexit_codesblocks.Pin
incur ^0.4.5→0.4.5. The wrap depends on incur internals (wire envelope shape,Formatter.formatAPI,IncurError.Options,cli.serveoverrides,Mcp.serve,formatHumanError, format flag precedence). Any minor / patch bump could silently shift any of these. Mirrors theviem 2.48.7exact-pin pattern for load-bearing deps. Upgrade verification protocol captured in the team's incur-upgrade-checklist memory.Tests
tests/error-envelope.test.ts— 67 tests across 9 describe blocks:Formatter.formatmeta.command+meta.duration; YAML synthesizesexitCodeForErrormapping (27) — every ErrorCode → expected exit codeisRetryable(3) — retryable / non-retryable codes, RETRYABLE_CODES set consistency-H--token-count,--token-limit,--token-offseton errorsformatHumanError; TTY +--jsonenriches; pipe + no flag enrichespendingErrorleak prevention, exit-code-1 across multiple error pathsPlus 2 new tests in
tests/agent-guide.test.tslocking in the doc fixes.Sabotage-verified non-spurious: temporarily clearing the
pendingErrorstash makes 15 wrap-dependent tests fail; restoring makes all 67 pass.Test plan
bun run typecheckcleanbun run lintcleanbunx vitest run— 434 pass / 2 skipped / 0 fail across 42 test filesscript), --version, --help, --llms, COMMAND_NOT_FOUND, and the sync-throw fixv0.1.0-rc.19after merge to trigger npm publish🤖 Generated with Claude Code