fix(cli): report built-in command errors instead of crashing - #363

Merged
ankitranjan7 merged 2 commits into
mainfrom
fix/runcli-error-handler
Aug 19, 2026
Merged

fix(cli): report built-in command errors instead of crashing#363
ankitranjan7 merged 2 commits into
mainfrom
fix/runcli-error-handler

Conversation

@ankitranjan7

Copy link
Copy Markdown
Contributor

Finding #1 from the CLI audit. Independent of #361 — branched from main, no overlapping hunks.

The bug

runCli was createProgram(...).parse() with no error handling. Anything a built-in command threw escaped: sync throws printed a raw Node stack trace, async ones surfaced as unhandled rejections, and either way the exitCode the error carried was thrown away.

$ webcmd adapter path hackernews/top
file:///…/dist/src/cli.js:1635
throw new ArgumentError(`Adapter source is unavailable …`);
^
ArgumentError: Adapter source is unavailable for hackernews/top.
at localAdapterPath (file:///…/dist/src/cli.js:1635:19)
…

Adapter commands never had this problem — execution.ts wraps them and renders the shared error envelope. Only built-ins were unprotected.

Five commands this fixes

commandbeforeafter
adapter path <site>/<cmd>stack trace, exit 1envelope, exit 2
adapter source get <site>/<cmd>stack trace, exit 1envelope, exit 2
adapter source put <a>/<b> <path>stack trace, exit 1envelope, exit 2
site fixture get <site>/<cmd>stack trace, exit 1envelope, exit 66
session close <bad-id>stack trace, exit 1envelope, exit 2
$ webcmd adapter path hackernews/top
ok: false
error:
code: ARGUMENT
message: Adapter source is unavailable for hackernews/top.
exitCode: 2
$ webcmd session close bogus
ok: false
error:
code: INVALID_SESSION_SELECTOR
message: 'Session selector must be an opaque Session ID: bogus'
help: Run `webcmd session create` and pass the returned `session_...` ID.
exitCode: 2

Same envelope adapters already emit, on stderr, so an agent parses built-in and adapter failures the same way. Exit codes now come from the error instead of being lost, which finally applies the taxonomy already declared in errors.tssite fixture get on a missing fixture exits 66 (EMPTY_RESULT), not 1.

Stacks stay off unless WEBCMD_DEBUG is set.

Reuse, not new machinery

toEnvelope() (errors.ts) and formatErrorEnvelope() (output.ts) already existed and are what commanderAdapter.ts uses for adapter errors. reportCliError just wires them to the built-in path; it takes an injectable stream so it is directly testable.

Two consequences worth reviewing

parse()parseAsync(), and main.ts awaits. This is what lets an async rejection reach the handler at all.

Signal cancellation now covers the actual run.parse() returned as soon as it kicked off an async action, so main.ts's finally { uninstallSignalCancellation() } tore down the SIGINT handler while the run was still in flight — Ctrl-C during an adapter run never cancelled the daemon run. Awaiting keeps it installed for the real duration. This is a behaviour fix that falls out of the same change; calling it out because it is not obvious from the diff.

Verification

  • npm run typecheck — clean
  • npm test — 5687 passed, 1 failed: src/doctor.test.ts:218 (profile alias rendering), pre-existing on main, unrelated
  • All five commands above exercised against a local build; exit codes confirmed with $?
  • Happy paths re-checked (hackernews top, list, --help) — still exit 0
  • New src/cli-error-report.test.ts covers the envelope, the hint, the UNKNOWN fallback, and the WEBCMD_DEBUG stack toggle

Tests went in a new file rather than cli.test.ts so this branch and #361 stay conflict-free.

Not in this PR

adapter path still fails for every command that is not already a local override (finding #2) — that is a separate bug in resolveAdapterSourcePath. This PR makes it report the failure properly instead of crashing; it does not make the command work.

🤖 Generated with Claude Code

`runCli` was `createProgram(...).parse()` with no error handling, so anything a
built-in command threw escaped: sync throws printed a raw Node stack trace, and
async ones surfaced as unhandled rejections. Either way the `exitCode` the error
carried was discarded.
Five commands hit this in practice:
$ webcmd adapter path hackernews/top
file:///…/dist/src/cli.js:1635
throw new ArgumentError(`Adapter source is unavailable …`);
^
ArgumentError: Adapter source is unavailable for hackernews/top.
at localAdapterPath (file:///…/dist/src/cli.js:1635:19)
…
Adapter commands never had this problem — execution.ts wraps them and renders
the shared error envelope. Use the same envelope here, so built-ins and adapters
report failures identically:
$ webcmd adapter path hackernews/top
ok: false
error:
code: ARGUMENT
message: Adapter source is unavailable for hackernews/top.
exitCode: 2
Exit codes now come from the error rather than being lost, which also settles
the taxonomy in errors.ts for these paths — `site fixture get` on a missing
fixture exits 66 (EMPTY_RESULT) instead of 1, and `session close <bad-id>`
exits 2 (USAGE_ERROR). Stacks stay off unless WEBCMD_DEBUG is set.
`parse()` -> `parseAsync()` is what lets async rejections reach the handler, and
main.ts now awaits runCli. That also keeps the daemon-run signal cancellation
installed for the real duration of a run: `parse()` returned as soon as it
kicked off an async action, so main.ts's `finally` uninstalled the SIGINT
handler while the run was still in flight, and Ctrl-C never cancelled it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actionsBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

🟠 Maintainer review suggested — low confidence

The automated review could not reach a fully supported conclusion.

Limitations

  • The automated review returned an invalid structured result.

This review is advisory and does not block merging.

Doctor rendering was reading ~/.webcmd aliases. Windows git fixtures were hitting the 5s default timeout.
@ankitranjan7
ankitranjan7 merged commit ef149b1 into mainAug 19, 2026
37 checks passed
ankitranjan7 added a commit that referenced this pull request Aug 19, 2026
Keep #327's local-mode checklist after #363. Drop wrapAction — runCli and
the hosted runner already envelope these throws, and swallowing them would
mark hosted site errors as success.
@ankitranjan7
ankitranjan7 deleted the fix/runcli-error-handler branch September 1, 2026 12:41
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@ankitranjan7
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(cli): report built-in command errors instead of crashing - #363

Merged
ankitranjan7 merged 2 commits into
mainfrom
fix/runcli-error-handler
Aug 19, 2026
Merged

fix(cli): report built-in command errors instead of crashing#363
ankitranjan7 merged 2 commits into
mainfrom
fix/runcli-error-handler

Conversation

@ankitranjan7

Copy link
Copy Markdown
Contributor

Finding #1 from the CLI audit. Independent of #361 — branched from main, no overlapping hunks.

The bug

runCli was createProgram(...).parse() with no error handling. Anything a built-in command threw escaped: sync throws printed a raw Node stack trace, async ones surfaced as unhandled rejections, and either way the exitCode the error carried was thrown away.

$ webcmd adapter path hackernews/top
file:///…/dist/src/cli.js:1635
throw new ArgumentError(`Adapter source is unavailable …`);
^
ArgumentError: Adapter source is unavailable for hackernews/top.
at localAdapterPath (file:///…/dist/src/cli.js:1635:19)
…

Adapter commands never had this problem — execution.ts wraps them and renders the shared error envelope. Only built-ins were unprotected.

Five commands this fixes

commandbeforeafter
adapter path <site>/<cmd>stack trace, exit 1envelope, exit 2
adapter source get <site>/<cmd>stack trace, exit 1envelope, exit 2
adapter source put <a>/<b> <path>stack trace, exit 1envelope, exit 2
site fixture get <site>/<cmd>stack trace, exit 1envelope, exit 66
session close <bad-id>stack trace, exit 1envelope, exit 2
$ webcmd adapter path hackernews/top
ok: false
error:
code: ARGUMENT
message: Adapter source is unavailable for hackernews/top.
exitCode: 2
$ webcmd session close bogus
ok: false
error:
code: INVALID_SESSION_SELECTOR
message: 'Session selector must be an opaque Session ID: bogus'
help: Run `webcmd session create` and pass the returned `session_...` ID.
exitCode: 2

Same envelope adapters already emit, on stderr, so an agent parses built-in and adapter failures the same way. Exit codes now come from the error instead of being lost, which finally applies the taxonomy already declared in errors.tssite fixture get on a missing fixture exits 66 (EMPTY_RESULT), not 1.

Stacks stay off unless WEBCMD_DEBUG is set.

Reuse, not new machinery

toEnvelope() (errors.ts) and formatErrorEnvelope() (output.ts) already existed and are what commanderAdapter.ts uses for adapter errors. reportCliError just wires them to the built-in path; it takes an injectable stream so it is directly testable.

Two consequences worth reviewing

parse()parseAsync(), and main.ts awaits. This is what lets an async rejection reach the handler at all.

Signal cancellation now covers the actual run.parse() returned as soon as it kicked off an async action, so main.ts's finally { uninstallSignalCancellation() } tore down the SIGINT handler while the run was still in flight — Ctrl-C during an adapter run never cancelled the daemon run. Awaiting keeps it installed for the real duration. This is a behaviour fix that falls out of the same change; calling it out because it is not obvious from the diff.

Verification

  • npm run typecheck — clean
  • npm test — 5687 passed, 1 failed: src/doctor.test.ts:218 (profile alias rendering), pre-existing on main, unrelated
  • All five commands above exercised against a local build; exit codes confirmed with $?
  • Happy paths re-checked (hackernews top, list, --help) — still exit 0
  • New src/cli-error-report.test.ts covers the envelope, the hint, the UNKNOWN fallback, and the WEBCMD_DEBUG stack toggle

Tests went in a new file rather than cli.test.ts so this branch and #361 stay conflict-free.

Not in this PR

adapter path still fails for every command that is not already a local override (finding #2) — that is a separate bug in resolveAdapterSourcePath. This PR makes it report the failure properly instead of crashing; it does not make the command work.

🤖 Generated with Claude Code

`runCli` was `createProgram(...).parse()` with no error handling, so anything a
built-in command threw escaped: sync throws printed a raw Node stack trace, and
async ones surfaced as unhandled rejections. Either way the `exitCode` the error
carried was discarded.
Five commands hit this in practice:
$ webcmd adapter path hackernews/top
file:///…/dist/src/cli.js:1635
throw new ArgumentError(`Adapter source is unavailable …`);
^
ArgumentError: Adapter source is unavailable for hackernews/top.
at localAdapterPath (file:///…/dist/src/cli.js:1635:19)
…
Adapter commands never had this problem — execution.ts wraps them and renders
the shared error envelope. Use the same envelope here, so built-ins and adapters
report failures identically:
$ webcmd adapter path hackernews/top
ok: false
error:
code: ARGUMENT
message: Adapter source is unavailable for hackernews/top.
exitCode: 2
Exit codes now come from the error rather than being lost, which also settles
the taxonomy in errors.ts for these paths — `site fixture get` on a missing
fixture exits 66 (EMPTY_RESULT) instead of 1, and `session close <bad-id>`
exits 2 (USAGE_ERROR). Stacks stay off unless WEBCMD_DEBUG is set.
`parse()` -> `parseAsync()` is what lets async rejections reach the handler, and
main.ts now awaits runCli. That also keeps the daemon-run signal cancellation
installed for the real duration of a run: `parse()` returned as soon as it
kicked off an async action, so main.ts's `finally` uninstalled the SIGINT
handler while the run was still in flight, and Ctrl-C never cancelled it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actionsBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

🟠 Maintainer review suggested — low confidence

The automated review could not reach a fully supported conclusion.

Limitations

  • The automated review returned an invalid structured result.

This review is advisory and does not block merging.

Doctor rendering was reading ~/.webcmd aliases. Windows git fixtures were hitting the 5s default timeout.
@ankitranjan7
ankitranjan7 merged commit ef149b1 into mainAug 19, 2026
37 checks passed
ankitranjan7 added a commit that referenced this pull request Aug 19, 2026
Keep #327's local-mode checklist after #363. Drop wrapAction — runCli and
the hosted runner already envelope these throws, and swallowing them would
mark hosted site errors as success.
@ankitranjan7
ankitranjan7 deleted the fix/runcli-error-handler branch September 1, 2026 12:41
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@ankitranjan7
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): report built-in command errors instead of crashing - #363

Merged
ankitranjan7 merged 2 commits into
mainfrom
fix/runcli-error-handler
Aug 19, 2026
Merged

fix(cli): report built-in command errors instead of crashing#363
ankitranjan7 merged 2 commits into
mainfrom
fix/runcli-error-handler

Conversation

@ankitranjan7

Copy link
Copy Markdown
Contributor

Finding #1 from the CLI audit. Independent of #361 — branched from main, no overlapping hunks.

The bug

runCli was createProgram(...).parse() with no error handling. Anything a built-in command threw escaped: sync throws printed a raw Node stack trace, async ones surfaced as unhandled rejections, and either way the exitCode the error carried was thrown away.

$ webcmd adapter path hackernews/top
file:///…/dist/src/cli.js:1635
throw new ArgumentError(`Adapter source is unavailable …`);
^
ArgumentError: Adapter source is unavailable for hackernews/top.
at localAdapterPath (file:///…/dist/src/cli.js:1635:19)
…

Adapter commands never had this problem — execution.ts wraps them and renders the shared error envelope. Only built-ins were unprotected.

Five commands this fixes

commandbeforeafter
adapter path <site>/<cmd>stack trace, exit 1envelope, exit 2
adapter source get <site>/<cmd>stack trace, exit 1envelope, exit 2
adapter source put <a>/<b> <path>stack trace, exit 1envelope, exit 2
site fixture get <site>/<cmd>stack trace, exit 1envelope, exit 66
session close <bad-id>stack trace, exit 1envelope, exit 2
$ webcmd adapter path hackernews/top
ok: false
error:
code: ARGUMENT
message: Adapter source is unavailable for hackernews/top.
exitCode: 2
$ webcmd session close bogus
ok: false
error:
code: INVALID_SESSION_SELECTOR
message: 'Session selector must be an opaque Session ID: bogus'
help: Run `webcmd session create` and pass the returned `session_...` ID.
exitCode: 2

Same envelope adapters already emit, on stderr, so an agent parses built-in and adapter failures the same way. Exit codes now come from the error instead of being lost, which finally applies the taxonomy already declared in errors.tssite fixture get on a missing fixture exits 66 (EMPTY_RESULT), not 1.

Stacks stay off unless WEBCMD_DEBUG is set.

Reuse, not new machinery

toEnvelope() (errors.ts) and formatErrorEnvelope() (output.ts) already existed and are what commanderAdapter.ts uses for adapter errors. reportCliError just wires them to the built-in path; it takes an injectable stream so it is directly testable.

Two consequences worth reviewing

parse()parseAsync(), and main.ts awaits. This is what lets an async rejection reach the handler at all.

Signal cancellation now covers the actual run.parse() returned as soon as it kicked off an async action, so main.ts's finally { uninstallSignalCancellation() } tore down the SIGINT handler while the run was still in flight — Ctrl-C during an adapter run never cancelled the daemon run. Awaiting keeps it installed for the real duration. This is a behaviour fix that falls out of the same change; calling it out because it is not obvious from the diff.

Verification

  • npm run typecheck — clean
  • npm test — 5687 passed, 1 failed: src/doctor.test.ts:218 (profile alias rendering), pre-existing on main, unrelated
  • All five commands above exercised against a local build; exit codes confirmed with $?
  • Happy paths re-checked (hackernews top, list, --help) — still exit 0
  • New src/cli-error-report.test.ts covers the envelope, the hint, the UNKNOWN fallback, and the WEBCMD_DEBUG stack toggle

Tests went in a new file rather than cli.test.ts so this branch and #361 stay conflict-free.

Not in this PR

adapter path still fails for every command that is not already a local override (finding #2) — that is a separate bug in resolveAdapterSourcePath. This PR makes it report the failure properly instead of crashing; it does not make the command work.

🤖 Generated with Claude Code

`runCli` was `createProgram(...).parse()` with no error handling, so anything a
built-in command threw escaped: sync throws printed a raw Node stack trace, and
async ones surfaced as unhandled rejections. Either way the `exitCode` the error
carried was discarded.
Five commands hit this in practice:
$ webcmd adapter path hackernews/top
file:///…/dist/src/cli.js:1635
throw new ArgumentError(`Adapter source is unavailable …`);
^
ArgumentError: Adapter source is unavailable for hackernews/top.
at localAdapterPath (file:///…/dist/src/cli.js:1635:19)
…
Adapter commands never had this problem — execution.ts wraps them and renders
the shared error envelope. Use the same envelope here, so built-ins and adapters
report failures identically:
$ webcmd adapter path hackernews/top
ok: false
error:
code: ARGUMENT
message: Adapter source is unavailable for hackernews/top.
exitCode: 2
Exit codes now come from the error rather than being lost, which also settles
the taxonomy in errors.ts for these paths — `site fixture get` on a missing
fixture exits 66 (EMPTY_RESULT) instead of 1, and `session close <bad-id>`
exits 2 (USAGE_ERROR). Stacks stay off unless WEBCMD_DEBUG is set.
`parse()` -> `parseAsync()` is what lets async rejections reach the handler, and
main.ts now awaits runCli. That also keeps the daemon-run signal cancellation
installed for the real duration of a run: `parse()` returned as soon as it
kicked off an async action, so main.ts's `finally` uninstalled the SIGINT
handler while the run was still in flight, and Ctrl-C never cancelled it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actionsBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

🟠 Maintainer review suggested — low confidence

The automated review could not reach a fully supported conclusion.

Limitations

  • The automated review returned an invalid structured result.

This review is advisory and does not block merging.

Doctor rendering was reading ~/.webcmd aliases. Windows git fixtures were hitting the 5s default timeout.
@ankitranjan7
ankitranjan7 merged commit ef149b1 into mainAug 19, 2026
37 checks passed
ankitranjan7 added a commit that referenced this pull request Aug 19, 2026
Keep #327's local-mode checklist after #363. Drop wrapAction — runCli and
the hosted runner already envelope these throws, and swallowing them would
mark hosted site errors as success.
@ankitranjan7
ankitranjan7 deleted the fix/runcli-error-handler branch September 1, 2026 12:41
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@ankitranjan7
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): report built-in command errors instead of crashing - #363

Merged
ankitranjan7 merged 2 commits into
mainfrom
fix/runcli-error-handler
Aug 19, 2026
Merged

fix(cli): report built-in command errors instead of crashing#363
ankitranjan7 merged 2 commits into
mainfrom
fix/runcli-error-handler

Conversation

@ankitranjan7

Copy link
Copy Markdown
Contributor

Finding #1 from the CLI audit. Independent of #361 — branched from main, no overlapping hunks.

The bug

runCli was createProgram(...).parse() with no error handling. Anything a built-in command threw escaped: sync throws printed a raw Node stack trace, async ones surfaced as unhandled rejections, and either way the exitCode the error carried was thrown away.

$ webcmd adapter path hackernews/top
file:///…/dist/src/cli.js:1635
throw new ArgumentError(`Adapter source is unavailable …`);
^
ArgumentError: Adapter source is unavailable for hackernews/top.
at localAdapterPath (file:///…/dist/src/cli.js:1635:19)
…

Adapter commands never had this problem — execution.ts wraps them and renders the shared error envelope. Only built-ins were unprotected.

Five commands this fixes

commandbeforeafter
adapter path <site>/<cmd>stack trace, exit 1envelope, exit 2
adapter source get <site>/<cmd>stack trace, exit 1envelope, exit 2
adapter source put <a>/<b> <path>stack trace, exit 1envelope, exit 2
site fixture get <site>/<cmd>stack trace, exit 1envelope, exit 66
session close <bad-id>stack trace, exit 1envelope, exit 2
$ webcmd adapter path hackernews/top
ok: false
error:
code: ARGUMENT
message: Adapter source is unavailable for hackernews/top.
exitCode: 2
$ webcmd session close bogus
ok: false
error:
code: INVALID_SESSION_SELECTOR
message: 'Session selector must be an opaque Session ID: bogus'
help: Run `webcmd session create` and pass the returned `session_...` ID.
exitCode: 2

Same envelope adapters already emit, on stderr, so an agent parses built-in and adapter failures the same way. Exit codes now come from the error instead of being lost, which finally applies the taxonomy already declared in errors.tssite fixture get on a missing fixture exits 66 (EMPTY_RESULT), not 1.

Stacks stay off unless WEBCMD_DEBUG is set.

Reuse, not new machinery

toEnvelope() (errors.ts) and formatErrorEnvelope() (output.ts) already existed and are what commanderAdapter.ts uses for adapter errors. reportCliError just wires them to the built-in path; it takes an injectable stream so it is directly testable.

Two consequences worth reviewing

parse()parseAsync(), and main.ts awaits. This is what lets an async rejection reach the handler at all.

Signal cancellation now covers the actual run.parse() returned as soon as it kicked off an async action, so main.ts's finally { uninstallSignalCancellation() } tore down the SIGINT handler while the run was still in flight — Ctrl-C during an adapter run never cancelled the daemon run. Awaiting keeps it installed for the real duration. This is a behaviour fix that falls out of the same change; calling it out because it is not obvious from the diff.

Verification

  • npm run typecheck — clean
  • npm test — 5687 passed, 1 failed: src/doctor.test.ts:218 (profile alias rendering), pre-existing on main, unrelated
  • All five commands above exercised against a local build; exit codes confirmed with $?
  • Happy paths re-checked (hackernews top, list, --help) — still exit 0
  • New src/cli-error-report.test.ts covers the envelope, the hint, the UNKNOWN fallback, and the WEBCMD_DEBUG stack toggle

Tests went in a new file rather than cli.test.ts so this branch and #361 stay conflict-free.

Not in this PR

adapter path still fails for every command that is not already a local override (finding #2) — that is a separate bug in resolveAdapterSourcePath. This PR makes it report the failure properly instead of crashing; it does not make the command work.

🤖 Generated with Claude Code

`runCli` was `createProgram(...).parse()` with no error handling, so anything a
built-in command threw escaped: sync throws printed a raw Node stack trace, and
async ones surfaced as unhandled rejections. Either way the `exitCode` the error
carried was discarded.
Five commands hit this in practice:
$ webcmd adapter path hackernews/top
file:///…/dist/src/cli.js:1635
throw new ArgumentError(`Adapter source is unavailable …`);
^
ArgumentError: Adapter source is unavailable for hackernews/top.
at localAdapterPath (file:///…/dist/src/cli.js:1635:19)
…
Adapter commands never had this problem — execution.ts wraps them and renders
the shared error envelope. Use the same envelope here, so built-ins and adapters
report failures identically:
$ webcmd adapter path hackernews/top
ok: false
error:
code: ARGUMENT
message: Adapter source is unavailable for hackernews/top.
exitCode: 2
Exit codes now come from the error rather than being lost, which also settles
the taxonomy in errors.ts for these paths — `site fixture get` on a missing
fixture exits 66 (EMPTY_RESULT) instead of 1, and `session close <bad-id>`
exits 2 (USAGE_ERROR). Stacks stay off unless WEBCMD_DEBUG is set.
`parse()` -> `parseAsync()` is what lets async rejections reach the handler, and
main.ts now awaits runCli. That also keeps the daemon-run signal cancellation
installed for the real duration of a run: `parse()` returned as soon as it
kicked off an async action, so main.ts's `finally` uninstalled the SIGINT
handler while the run was still in flight, and Ctrl-C never cancelled it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actionsBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

🟠 Maintainer review suggested — low confidence

The automated review could not reach a fully supported conclusion.

Limitations

  • The automated review returned an invalid structured result.

This review is advisory and does not block merging.

Doctor rendering was reading ~/.webcmd aliases. Windows git fixtures were hitting the 5s default timeout.
@ankitranjan7
ankitranjan7 merged commit ef149b1 into mainAug 19, 2026
37 checks passed
ankitranjan7 added a commit that referenced this pull request Aug 19, 2026
Keep #327's local-mode checklist after #363. Drop wrapAction — runCli and
the hosted runner already envelope these throws, and swallowing them would
mark hosted site errors as success.
@ankitranjan7
ankitranjan7 deleted the fix/runcli-error-handler branch September 1, 2026 12:41
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@ankitranjan7
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(cli): report built-in command errors instead of crashing - #363

Merged
ankitranjan7 merged 2 commits into
mainfrom
fix/runcli-error-handler
Aug 19, 2026
Merged

fix(cli): report built-in command errors instead of crashing#363
ankitranjan7 merged 2 commits into
mainfrom
fix/runcli-error-handler

Conversation

@ankitranjan7

Copy link
Copy Markdown
Contributor

Finding #1 from the CLI audit. Independent of #361 — branched from main, no overlapping hunks.

The bug

runCli was createProgram(...).parse() with no error handling. Anything a built-in command threw escaped: sync throws printed a raw Node stack trace, async ones surfaced as unhandled rejections, and either way the exitCode the error carried was thrown away.

$ webcmd adapter path hackernews/top
file:///…/dist/src/cli.js:1635
throw new ArgumentError(`Adapter source is unavailable …`);
^
ArgumentError: Adapter source is unavailable for hackernews/top.
at localAdapterPath (file:///…/dist/src/cli.js:1635:19)
…

Adapter commands never had this problem — execution.ts wraps them and renders the shared error envelope. Only built-ins were unprotected.

Five commands this fixes

commandbeforeafter
adapter path <site>/<cmd>stack trace, exit 1envelope, exit 2
adapter source get <site>/<cmd>stack trace, exit 1envelope, exit 2
adapter source put <a>/<b> <path>stack trace, exit 1envelope, exit 2
site fixture get <site>/<cmd>stack trace, exit 1envelope, exit 66
session close <bad-id>stack trace, exit 1envelope, exit 2
$ webcmd adapter path hackernews/top
ok: false
error:
code: ARGUMENT
message: Adapter source is unavailable for hackernews/top.
exitCode: 2
$ webcmd session close bogus
ok: false
error:
code: INVALID_SESSION_SELECTOR
message: 'Session selector must be an opaque Session ID: bogus'
help: Run `webcmd session create` and pass the returned `session_...` ID.
exitCode: 2

Same envelope adapters already emit, on stderr, so an agent parses built-in and adapter failures the same way. Exit codes now come from the error instead of being lost, which finally applies the taxonomy already declared in errors.tssite fixture get on a missing fixture exits 66 (EMPTY_RESULT), not 1.

Stacks stay off unless WEBCMD_DEBUG is set.

Reuse, not new machinery

toEnvelope() (errors.ts) and formatErrorEnvelope() (output.ts) already existed and are what commanderAdapter.ts uses for adapter errors. reportCliError just wires them to the built-in path; it takes an injectable stream so it is directly testable.

Two consequences worth reviewing

parse()parseAsync(), and main.ts awaits. This is what lets an async rejection reach the handler at all.

Signal cancellation now covers the actual run.parse() returned as soon as it kicked off an async action, so main.ts's finally { uninstallSignalCancellation() } tore down the SIGINT handler while the run was still in flight — Ctrl-C during an adapter run never cancelled the daemon run. Awaiting keeps it installed for the real duration. This is a behaviour fix that falls out of the same change; calling it out because it is not obvious from the diff.

Verification

  • npm run typecheck — clean
  • npm test — 5687 passed, 1 failed: src/doctor.test.ts:218 (profile alias rendering), pre-existing on main, unrelated
  • All five commands above exercised against a local build; exit codes confirmed with $?
  • Happy paths re-checked (hackernews top, list, --help) — still exit 0
  • New src/cli-error-report.test.ts covers the envelope, the hint, the UNKNOWN fallback, and the WEBCMD_DEBUG stack toggle

Tests went in a new file rather than cli.test.ts so this branch and #361 stay conflict-free.

Not in this PR

adapter path still fails for every command that is not already a local override (finding #2) — that is a separate bug in resolveAdapterSourcePath. This PR makes it report the failure properly instead of crashing; it does not make the command work.

🤖 Generated with Claude Code

`runCli` was `createProgram(...).parse()` with no error handling, so anything a
built-in command threw escaped: sync throws printed a raw Node stack trace, and
async ones surfaced as unhandled rejections. Either way the `exitCode` the error
carried was discarded.
Five commands hit this in practice:
$ webcmd adapter path hackernews/top
file:///…/dist/src/cli.js:1635
throw new ArgumentError(`Adapter source is unavailable …`);
^
ArgumentError: Adapter source is unavailable for hackernews/top.
at localAdapterPath (file:///…/dist/src/cli.js:1635:19)
…
Adapter commands never had this problem — execution.ts wraps them and renders
the shared error envelope. Use the same envelope here, so built-ins and adapters
report failures identically:
$ webcmd adapter path hackernews/top
ok: false
error:
code: ARGUMENT
message: Adapter source is unavailable for hackernews/top.
exitCode: 2
Exit codes now come from the error rather than being lost, which also settles
the taxonomy in errors.ts for these paths — `site fixture get` on a missing
fixture exits 66 (EMPTY_RESULT) instead of 1, and `session close <bad-id>`
exits 2 (USAGE_ERROR). Stacks stay off unless WEBCMD_DEBUG is set.
`parse()` -> `parseAsync()` is what lets async rejections reach the handler, and
main.ts now awaits runCli. That also keeps the daemon-run signal cancellation
installed for the real duration of a run: `parse()` returned as soon as it
kicked off an async action, so main.ts's `finally` uninstalled the SIGINT
handler while the run was still in flight, and Ctrl-C never cancelled it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actionsBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

🟠 Maintainer review suggested — low confidence

The automated review could not reach a fully supported conclusion.

Limitations

  • The automated review returned an invalid structured result.

This review is advisory and does not block merging.

Doctor rendering was reading ~/.webcmd aliases. Windows git fixtures were hitting the 5s default timeout.
@ankitranjan7
ankitranjan7 merged commit ef149b1 into mainAug 19, 2026
37 checks passed
ankitranjan7 added a commit that referenced this pull request Aug 19, 2026
Keep #327's local-mode checklist after #363. Drop wrapAction — runCli and
the hosted runner already envelope these throws, and swallowing them would
mark hosted site errors as success.
@ankitranjan7
ankitranjan7 deleted the fix/runcli-error-handler branch September 1, 2026 12:41
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@ankitranjan7
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): report built-in command errors instead of crashing - #363

Merged
ankitranjan7 merged 2 commits into
mainfrom
fix/runcli-error-handler
Aug 19, 2026
Merged

fix(cli): report built-in command errors instead of crashing#363
ankitranjan7 merged 2 commits into
mainfrom
fix/runcli-error-handler

Conversation

@ankitranjan7

Copy link
Copy Markdown
Contributor

Finding #1 from the CLI audit. Independent of #361 — branched from main, no overlapping hunks.

The bug

runCli was createProgram(...).parse() with no error handling. Anything a built-in command threw escaped: sync throws printed a raw Node stack trace, async ones surfaced as unhandled rejections, and either way the exitCode the error carried was thrown away.

$ webcmd adapter path hackernews/top
file:///…/dist/src/cli.js:1635
throw new ArgumentError(`Adapter source is unavailable …`);
^
ArgumentError: Adapter source is unavailable for hackernews/top.
at localAdapterPath (file:///…/dist/src/cli.js:1635:19)
…

Adapter commands never had this problem — execution.ts wraps them and renders the shared error envelope. Only built-ins were unprotected.

Five commands this fixes

commandbeforeafter
adapter path <site>/<cmd>stack trace, exit 1envelope, exit 2
adapter source get <site>/<cmd>stack trace, exit 1envelope, exit 2
adapter source put <a>/<b> <path>stack trace, exit 1envelope, exit 2
site fixture get <site>/<cmd>stack trace, exit 1envelope, exit 66
session close <bad-id>stack trace, exit 1envelope, exit 2
$ webcmd adapter path hackernews/top
ok: false
error:
code: ARGUMENT
message: Adapter source is unavailable for hackernews/top.
exitCode: 2
$ webcmd session close bogus
ok: false
error:
code: INVALID_SESSION_SELECTOR
message: 'Session selector must be an opaque Session ID: bogus'
help: Run `webcmd session create` and pass the returned `session_...` ID.
exitCode: 2

Same envelope adapters already emit, on stderr, so an agent parses built-in and adapter failures the same way. Exit codes now come from the error instead of being lost, which finally applies the taxonomy already declared in errors.tssite fixture get on a missing fixture exits 66 (EMPTY_RESULT), not 1.

Stacks stay off unless WEBCMD_DEBUG is set.

Reuse, not new machinery

toEnvelope() (errors.ts) and formatErrorEnvelope() (output.ts) already existed and are what commanderAdapter.ts uses for adapter errors. reportCliError just wires them to the built-in path; it takes an injectable stream so it is directly testable.

Two consequences worth reviewing

parse()parseAsync(), and main.ts awaits. This is what lets an async rejection reach the handler at all.

Signal cancellation now covers the actual run.parse() returned as soon as it kicked off an async action, so main.ts's finally { uninstallSignalCancellation() } tore down the SIGINT handler while the run was still in flight — Ctrl-C during an adapter run never cancelled the daemon run. Awaiting keeps it installed for the real duration. This is a behaviour fix that falls out of the same change; calling it out because it is not obvious from the diff.

Verification

  • npm run typecheck — clean
  • npm test — 5687 passed, 1 failed: src/doctor.test.ts:218 (profile alias rendering), pre-existing on main, unrelated
  • All five commands above exercised against a local build; exit codes confirmed with $?
  • Happy paths re-checked (hackernews top, list, --help) — still exit 0
  • New src/cli-error-report.test.ts covers the envelope, the hint, the UNKNOWN fallback, and the WEBCMD_DEBUG stack toggle

Tests went in a new file rather than cli.test.ts so this branch and #361 stay conflict-free.

Not in this PR

adapter path still fails for every command that is not already a local override (finding #2) — that is a separate bug in resolveAdapterSourcePath. This PR makes it report the failure properly instead of crashing; it does not make the command work.

🤖 Generated with Claude Code

`runCli` was `createProgram(...).parse()` with no error handling, so anything a
built-in command threw escaped: sync throws printed a raw Node stack trace, and
async ones surfaced as unhandled rejections. Either way the `exitCode` the error
carried was discarded.
Five commands hit this in practice:
$ webcmd adapter path hackernews/top
file:///…/dist/src/cli.js:1635
throw new ArgumentError(`Adapter source is unavailable …`);
^
ArgumentError: Adapter source is unavailable for hackernews/top.
at localAdapterPath (file:///…/dist/src/cli.js:1635:19)
…
Adapter commands never had this problem — execution.ts wraps them and renders
the shared error envelope. Use the same envelope here, so built-ins and adapters
report failures identically:
$ webcmd adapter path hackernews/top
ok: false
error:
code: ARGUMENT
message: Adapter source is unavailable for hackernews/top.
exitCode: 2
Exit codes now come from the error rather than being lost, which also settles
the taxonomy in errors.ts for these paths — `site fixture get` on a missing
fixture exits 66 (EMPTY_RESULT) instead of 1, and `session close <bad-id>`
exits 2 (USAGE_ERROR). Stacks stay off unless WEBCMD_DEBUG is set.
`parse()` -> `parseAsync()` is what lets async rejections reach the handler, and
main.ts now awaits runCli. That also keeps the daemon-run signal cancellation
installed for the real duration of a run: `parse()` returned as soon as it
kicked off an async action, so main.ts's `finally` uninstalled the SIGINT
handler while the run was still in flight, and Ctrl-C never cancelled it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actionsBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

🟠 Maintainer review suggested — low confidence

The automated review could not reach a fully supported conclusion.

Limitations

  • The automated review returned an invalid structured result.

This review is advisory and does not block merging.

Doctor rendering was reading ~/.webcmd aliases. Windows git fixtures were hitting the 5s default timeout.
@ankitranjan7
ankitranjan7 merged commit ef149b1 into mainAug 19, 2026
37 checks passed
ankitranjan7 added a commit that referenced this pull request Aug 19, 2026
Keep #327's local-mode checklist after #363. Drop wrapAction — runCli and
the hosted runner already envelope these throws, and swallowing them would
mark hosted site errors as success.
@ankitranjan7
ankitranjan7 deleted the fix/runcli-error-handler branch September 1, 2026 12:41
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@ankitranjan7
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(cli): report built-in command errors instead of crashing - #363

Merged
ankitranjan7 merged 2 commits into
mainfrom
fix/runcli-error-handler
Aug 19, 2026
Merged

fix(cli): report built-in command errors instead of crashing#363
ankitranjan7 merged 2 commits into
mainfrom
fix/runcli-error-handler

Conversation

@ankitranjan7

Copy link
Copy Markdown
Contributor

Finding #1 from the CLI audit. Independent of #361 — branched from main, no overlapping hunks.

The bug

runCli was createProgram(...).parse() with no error handling. Anything a built-in command threw escaped: sync throws printed a raw Node stack trace, async ones surfaced as unhandled rejections, and either way the exitCode the error carried was thrown away.

$ webcmd adapter path hackernews/top
file:///…/dist/src/cli.js:1635
throw new ArgumentError(`Adapter source is unavailable …`);
^
ArgumentError: Adapter source is unavailable for hackernews/top.
at localAdapterPath (file:///…/dist/src/cli.js:1635:19)
…

Adapter commands never had this problem — execution.ts wraps them and renders the shared error envelope. Only built-ins were unprotected.

Five commands this fixes

commandbeforeafter
adapter path <site>/<cmd>stack trace, exit 1envelope, exit 2
adapter source get <site>/<cmd>stack trace, exit 1envelope, exit 2
adapter source put <a>/<b> <path>stack trace, exit 1envelope, exit 2
site fixture get <site>/<cmd>stack trace, exit 1envelope, exit 66
session close <bad-id>stack trace, exit 1envelope, exit 2
$ webcmd adapter path hackernews/top
ok: false
error:
code: ARGUMENT
message: Adapter source is unavailable for hackernews/top.
exitCode: 2
$ webcmd session close bogus
ok: false
error:
code: INVALID_SESSION_SELECTOR
message: 'Session selector must be an opaque Session ID: bogus'
help: Run `webcmd session create` and pass the returned `session_...` ID.
exitCode: 2

Same envelope adapters already emit, on stderr, so an agent parses built-in and adapter failures the same way. Exit codes now come from the error instead of being lost, which finally applies the taxonomy already declared in errors.tssite fixture get on a missing fixture exits 66 (EMPTY_RESULT), not 1.

Stacks stay off unless WEBCMD_DEBUG is set.

Reuse, not new machinery

toEnvelope() (errors.ts) and formatErrorEnvelope() (output.ts) already existed and are what commanderAdapter.ts uses for adapter errors. reportCliError just wires them to the built-in path; it takes an injectable stream so it is directly testable.

Two consequences worth reviewing

parse()parseAsync(), and main.ts awaits. This is what lets an async rejection reach the handler at all.

Signal cancellation now covers the actual run.parse() returned as soon as it kicked off an async action, so main.ts's finally { uninstallSignalCancellation() } tore down the SIGINT handler while the run was still in flight — Ctrl-C during an adapter run never cancelled the daemon run. Awaiting keeps it installed for the real duration. This is a behaviour fix that falls out of the same change; calling it out because it is not obvious from the diff.

Verification

  • npm run typecheck — clean
  • npm test — 5687 passed, 1 failed: src/doctor.test.ts:218 (profile alias rendering), pre-existing on main, unrelated
  • All five commands above exercised against a local build; exit codes confirmed with $?
  • Happy paths re-checked (hackernews top, list, --help) — still exit 0
  • New src/cli-error-report.test.ts covers the envelope, the hint, the UNKNOWN fallback, and the WEBCMD_DEBUG stack toggle

Tests went in a new file rather than cli.test.ts so this branch and #361 stay conflict-free.

Not in this PR

adapter path still fails for every command that is not already a local override (finding #2) — that is a separate bug in resolveAdapterSourcePath. This PR makes it report the failure properly instead of crashing; it does not make the command work.

🤖 Generated with Claude Code

`runCli` was `createProgram(...).parse()` with no error handling, so anything a
built-in command threw escaped: sync throws printed a raw Node stack trace, and
async ones surfaced as unhandled rejections. Either way the `exitCode` the error
carried was discarded.
Five commands hit this in practice:
$ webcmd adapter path hackernews/top
file:///…/dist/src/cli.js:1635
throw new ArgumentError(`Adapter source is unavailable …`);
^
ArgumentError: Adapter source is unavailable for hackernews/top.
at localAdapterPath (file:///…/dist/src/cli.js:1635:19)
…
Adapter commands never had this problem — execution.ts wraps them and renders
the shared error envelope. Use the same envelope here, so built-ins and adapters
report failures identically:
$ webcmd adapter path hackernews/top
ok: false
error:
code: ARGUMENT
message: Adapter source is unavailable for hackernews/top.
exitCode: 2
Exit codes now come from the error rather than being lost, which also settles
the taxonomy in errors.ts for these paths — `site fixture get` on a missing
fixture exits 66 (EMPTY_RESULT) instead of 1, and `session close <bad-id>`
exits 2 (USAGE_ERROR). Stacks stay off unless WEBCMD_DEBUG is set.
`parse()` -> `parseAsync()` is what lets async rejections reach the handler, and
main.ts now awaits runCli. That also keeps the daemon-run signal cancellation
installed for the real duration of a run: `parse()` returned as soon as it
kicked off an async action, so main.ts's `finally` uninstalled the SIGINT
handler while the run was still in flight, and Ctrl-C never cancelled it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actionsBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

🟠 Maintainer review suggested — low confidence

The automated review could not reach a fully supported conclusion.

Limitations

  • The automated review returned an invalid structured result.

This review is advisory and does not block merging.

Doctor rendering was reading ~/.webcmd aliases. Windows git fixtures were hitting the 5s default timeout.
@ankitranjan7
ankitranjan7 merged commit ef149b1 into mainAug 19, 2026
37 checks passed
ankitranjan7 added a commit that referenced this pull request Aug 19, 2026
Keep #327's local-mode checklist after #363. Drop wrapAction — runCli and
the hosted runner already envelope these throws, and swallowing them would
mark hosted site errors as success.
@ankitranjan7
ankitranjan7 deleted the fix/runcli-error-handler branch September 1, 2026 12:41
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@ankitranjan7
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(cli): report built-in command errors instead of crashing - #363

Merged
ankitranjan7 merged 2 commits into
mainfrom
fix/runcli-error-handler
Aug 19, 2026
Merged

fix(cli): report built-in command errors instead of crashing#363
ankitranjan7 merged 2 commits into
mainfrom
fix/runcli-error-handler

Conversation

@ankitranjan7

Copy link
Copy Markdown
Contributor

Finding #1 from the CLI audit. Independent of #361 — branched from main, no overlapping hunks.

The bug

runCli was createProgram(...).parse() with no error handling. Anything a built-in command threw escaped: sync throws printed a raw Node stack trace, async ones surfaced as unhandled rejections, and either way the exitCode the error carried was thrown away.

$ webcmd adapter path hackernews/top
file:///…/dist/src/cli.js:1635
throw new ArgumentError(`Adapter source is unavailable …`);
^
ArgumentError: Adapter source is unavailable for hackernews/top.
at localAdapterPath (file:///…/dist/src/cli.js:1635:19)
…

Adapter commands never had this problem — execution.ts wraps them and renders the shared error envelope. Only built-ins were unprotected.

Five commands this fixes

commandbeforeafter
adapter path <site>/<cmd>stack trace, exit 1envelope, exit 2
adapter source get <site>/<cmd>stack trace, exit 1envelope, exit 2
adapter source put <a>/<b> <path>stack trace, exit 1envelope, exit 2
site fixture get <site>/<cmd>stack trace, exit 1envelope, exit 66
session close <bad-id>stack trace, exit 1envelope, exit 2
$ webcmd adapter path hackernews/top
ok: false
error:
code: ARGUMENT
message: Adapter source is unavailable for hackernews/top.
exitCode: 2
$ webcmd session close bogus
ok: false
error:
code: INVALID_SESSION_SELECTOR
message: 'Session selector must be an opaque Session ID: bogus'
help: Run `webcmd session create` and pass the returned `session_...` ID.
exitCode: 2

Same envelope adapters already emit, on stderr, so an agent parses built-in and adapter failures the same way. Exit codes now come from the error instead of being lost, which finally applies the taxonomy already declared in errors.tssite fixture get on a missing fixture exits 66 (EMPTY_RESULT), not 1.

Stacks stay off unless WEBCMD_DEBUG is set.

Reuse, not new machinery

toEnvelope() (errors.ts) and formatErrorEnvelope() (output.ts) already existed and are what commanderAdapter.ts uses for adapter errors. reportCliError just wires them to the built-in path; it takes an injectable stream so it is directly testable.

Two consequences worth reviewing

parse()parseAsync(), and main.ts awaits. This is what lets an async rejection reach the handler at all.

Signal cancellation now covers the actual run.parse() returned as soon as it kicked off an async action, so main.ts's finally { uninstallSignalCancellation() } tore down the SIGINT handler while the run was still in flight — Ctrl-C during an adapter run never cancelled the daemon run. Awaiting keeps it installed for the real duration. This is a behaviour fix that falls out of the same change; calling it out because it is not obvious from the diff.

Verification

  • npm run typecheck — clean
  • npm test — 5687 passed, 1 failed: src/doctor.test.ts:218 (profile alias rendering), pre-existing on main, unrelated
  • All five commands above exercised against a local build; exit codes confirmed with $?
  • Happy paths re-checked (hackernews top, list, --help) — still exit 0
  • New src/cli-error-report.test.ts covers the envelope, the hint, the UNKNOWN fallback, and the WEBCMD_DEBUG stack toggle

Tests went in a new file rather than cli.test.ts so this branch and #361 stay conflict-free.

Not in this PR

adapter path still fails for every command that is not already a local override (finding #2) — that is a separate bug in resolveAdapterSourcePath. This PR makes it report the failure properly instead of crashing; it does not make the command work.

🤖 Generated with Claude Code

`runCli` was `createProgram(...).parse()` with no error handling, so anything a
built-in command threw escaped: sync throws printed a raw Node stack trace, and
async ones surfaced as unhandled rejections. Either way the `exitCode` the error
carried was discarded.
Five commands hit this in practice:
$ webcmd adapter path hackernews/top
file:///…/dist/src/cli.js:1635
throw new ArgumentError(`Adapter source is unavailable …`);
^
ArgumentError: Adapter source is unavailable for hackernews/top.
at localAdapterPath (file:///…/dist/src/cli.js:1635:19)
…
Adapter commands never had this problem — execution.ts wraps them and renders
the shared error envelope. Use the same envelope here, so built-ins and adapters
report failures identically:
$ webcmd adapter path hackernews/top
ok: false
error:
code: ARGUMENT
message: Adapter source is unavailable for hackernews/top.
exitCode: 2
Exit codes now come from the error rather than being lost, which also settles
the taxonomy in errors.ts for these paths — `site fixture get` on a missing
fixture exits 66 (EMPTY_RESULT) instead of 1, and `session close <bad-id>`
exits 2 (USAGE_ERROR). Stacks stay off unless WEBCMD_DEBUG is set.
`parse()` -> `parseAsync()` is what lets async rejections reach the handler, and
main.ts now awaits runCli. That also keeps the daemon-run signal cancellation
installed for the real duration of a run: `parse()` returned as soon as it
kicked off an async action, so main.ts's `finally` uninstalled the SIGINT
handler while the run was still in flight, and Ctrl-C never cancelled it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actionsBot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

🟠 Maintainer review suggested — low confidence

The automated review could not reach a fully supported conclusion.

Limitations

  • The automated review returned an invalid structured result.

This review is advisory and does not block merging.

Doctor rendering was reading ~/.webcmd aliases. Windows git fixtures were hitting the 5s default timeout.
@ankitranjan7
ankitranjan7 merged commit ef149b1 into mainAug 19, 2026
37 checks passed
ankitranjan7 added a commit that referenced this pull request Aug 19, 2026
Keep #327's local-mode checklist after #363. Drop wrapAction — runCli and
the hosted runner already envelope these throws, and swallowing them would
mark hosted site errors as success.
@ankitranjan7
ankitranjan7 deleted the fix/runcli-error-handler branch September 1, 2026 12:41
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@ankitranjan7