Context
This repo already has an established JSON error-contract convention for CLIs. In
packages/loopover-miner/lib/cli-error.js:
/** Shared CLI failure output (#4836): when `--json` is set, emit a parseable `{ ok: false, error }`
* object on stdout (matching each command's success-path JSON stream); otherwise log plain text to
* stderr. */
export function reportCliFailure(wantsJson, message, exitCode = 2) {
if (wantsJson) {
console.log(JSON.stringify({ ok: false, error: message }, null, 2));
} else {
console.error(message);
}
return exitCode;
}
packages/loopover-miner/lib/cli.js wires this in at its top-level dispatch (reportCliFailure(argsWantJson(cliArgs), message, 1)), so any loopover-miner <cmd> --json failure still emits valid,
parseable JSON on stdout.
packages/loopover-mcp/bin/loopover-mcp.js — the sibling @loopover/mcp CLI in the same
monorepo, with its own --json flag threaded through every success path (login, logout,
whoami, status, doctor, decision-pack, analyze-branch, etc.) — has no equivalent. Its
entrypoint is:
if (cliArgs[0] && cliArgs[0] !== "--stdio") {
const exitCode = await runCli(cliArgs);
process.exit(typeof exitCode === "number" ? exitCode : 0);
}
There is no try/catch around this await, and no process.on("unhandledRejection", ...)
handler anywhere in the file. Every thrown Error inside runCli's command dispatch (missing
--login, unknown command, malformed --repo, a rejected apiFetch call, etc.) becomes an
uncaught exception: Node prints a full stack trace to stderr and exits 1, regardless of whether
--json was passed. This is confirmed by the test suite itself —
test/unit/mcp-cli-basics.test.ts line 117 asserts on a plain regex match against the thrown
message even when --json is in the argument list:
expect(() => run(["init-client", "--print", "codex", "--agent-profile", profile, "--json"])).toThrow(/Unsupported agent profile/);
— i.e. today's test suite documents (rather than catches) the fact that --json has zero effect on
error output. A caller scripting against loopover-mcp ... --json to parse machine-readable output
gets a raw stack trace on any failure instead of { ok: false, error: "..." }, unlike
loopover-miner's equivalent CLI.
Requirements
- Add a
packages/loopover-mcp/bin/loopover-mcp.js-local equivalent of
reportCliFailure/argsWantJson/describeCliError (or extract a tiny shared module reused by
both CLIs, if that's cleaner — the two implementations in packages/loopover-miner/lib/cli-error.js
are trivial enough to duplicate or share, either is acceptable) that emits
{ ok: false, error: <message> } as pretty-printed JSON on stdout when --json/--json=... is
present anywhere in process.argv, and the existing plain-text stderr message otherwise.
- Wrap the top-level
await runCli(cliArgs) call (and any other path that can let a runCli promise
rejection reach the module top level) in a try/catch that calls this helper and sets
process.exitCode (or calls process.exit) instead of letting Node print a raw stack trace.
- Preserve the existing exit code behavior for the success path (
process.exit(typeof exitCode === "number" ? exitCode : 0)) — only the failure branch changes.
- Non-Error thrown values (rare, e.g. a rejected promise with a plain string) must still produce a
usable message via a describeCliError-equivalent normalizer, not "[object Object]" or similar.
Deliverables
Test Coverage Requirements
packages/loopover-mcp/bin/loopover-mcp.js is outside this repo's Codecov coverage.include
(vitest.config.ts's coverage.include covers src/**/*.ts, packages/loopover-engine/src/**/*.ts,
packages/loopover-miner/lib/**/*.js, and one review-enrichment file only —
packages/loopover-mcp/** is not listed), so this change is not numerically gated by Codecov's 99%
patch requirement. It IS still exercised by npm run test:mcp-pack/test:ci's
test/unit/mcp-cli-*.test.ts subprocess suite, which every existing .toThrow(...) assertion already
depends on — add new tests there (through test/unit/support/mcp-cli-harness.ts's
run()/runAsync(), so both stdout and the process exit code are exercised, not just in-process
function calls) covering both the --json and non---json branches of the new failure path.
Expected Outcome
Any loopover-mcp <command> --json invocation that fails prints a single parseable JSON object
{ "ok": false, "error": "<message>" } to stdout, never a raw Node stack trace — matching the
{ ok: false, error } contract @loopover/miner's CLI already guarantees, and making --json
mode reliable for scripting/CI use of @loopover/mcp regardless of success or failure.
Links & Resources
packages/loopover-miner/lib/cli-error.js — the existing convention to mirror.
packages/loopover-miner/lib/cli.js line 1 (import) and line 75 (reportCliFailure(...) call
site) — how the sibling CLI wires it in.
packages/loopover-mcp/bin/loopover-mcp.js line 32 (cliArgs), line ~590
(const exitCode = await runCli(cliArgs); process.exit(...)), runCli (~L1825).
test/unit/mcp-cli-basics.test.ts line 117 — the existing test that documents today's gap.
Context
This repo already has an established JSON error-contract convention for CLIs. In
packages/loopover-miner/lib/cli-error.js:packages/loopover-miner/lib/cli.jswires this in at its top-level dispatch (reportCliFailure(argsWantJson(cliArgs), message, 1)), so anyloopover-miner <cmd> --jsonfailure still emits valid,parseable JSON on stdout.
packages/loopover-mcp/bin/loopover-mcp.js— the sibling@loopover/mcpCLI in the samemonorepo, with its own
--jsonflag threaded through every success path (login,logout,whoami,status,doctor,decision-pack,analyze-branch, etc.) — has no equivalent. Itsentrypoint is:
There is no
try/catcharound thisawait, and noprocess.on("unhandledRejection", ...)handler anywhere in the file. Every thrown
ErrorinsiderunCli's command dispatch (missing--login, unknown command, malformed--repo, a rejectedapiFetchcall, etc.) becomes anuncaught exception: Node prints a full stack trace to stderr and exits 1, regardless of whether
--jsonwas passed. This is confirmed by the test suite itself —test/unit/mcp-cli-basics.test.tsline 117 asserts on a plain regex match against the thrownmessage even when
--jsonis in the argument list:— i.e. today's test suite documents (rather than catches) the fact that
--jsonhas zero effect onerror output. A caller scripting against
loopover-mcp ... --jsonto parse machine-readable outputgets a raw stack trace on any failure instead of
{ ok: false, error: "..." }, unlikeloopover-miner's equivalent CLI.Requirements
packages/loopover-mcp/bin/loopover-mcp.js-local equivalent ofreportCliFailure/argsWantJson/describeCliError(or extract a tiny shared module reused byboth CLIs, if that's cleaner — the two implementations in
packages/loopover-miner/lib/cli-error.jsare trivial enough to duplicate or share, either is acceptable) that emits
{ ok: false, error: <message> }as pretty-printed JSON on stdout when--json/--json=...ispresent anywhere in
process.argv, and the existing plain-textstderrmessage otherwise.await runCli(cliArgs)call (and any other path that can let arunClipromiserejection reach the module top level) in a
try/catchthat calls this helper and setsprocess.exitCode(or callsprocess.exit) instead of letting Node print a raw stack trace.process.exit(typeof exitCode === "number" ? exitCode : 0)) — only the failure branch changes.usable message via a
describeCliError-equivalent normalizer, not"[object Object]"or similar.Deliverables
packages/loopover-mcp/bin/loopover-mcp.js,mirroring
packages/loopover-miner/lib/cli-error.js's{ ok: false, error }shape.loopover-mcp.jswrapsrunCli(cliArgs)so no command's failurepath can produce a raw Node stack trace.
test/unit/mcp-cli-*.test.tsassertion that currently matches a bare errormessage via
toThrow(/.../)continues to pass unchanged for the non---jsoncase (nobehavior change to stderr text), plus new tests asserting the
--jsoncase: running anyfailing command with
--jsonprints valid, parseable{ "ok": false, "error": "..." }JSONon stdout and a non-zero exit code, for at least: an unknown command, a missing
--login, andan unsupported
--agent-profilevalue.Test Coverage Requirements
packages/loopover-mcp/bin/loopover-mcp.jsis outside this repo's Codecovcoverage.include(
vitest.config.ts'scoverage.includecoverssrc/**/*.ts,packages/loopover-engine/src/**/*.ts,packages/loopover-miner/lib/**/*.js, and onereview-enrichmentfile only —packages/loopover-mcp/**is not listed), so this change is not numerically gated by Codecov's 99%patch requirement. It IS still exercised by
npm run test:mcp-pack/test:ci'stest/unit/mcp-cli-*.test.tssubprocess suite, which every existing.toThrow(...)assertion alreadydepends on — add new tests there (through
test/unit/support/mcp-cli-harness.ts'srun()/runAsync(), so both stdout and the process exit code are exercised, not just in-processfunction calls) covering both the
--jsonand non---jsonbranches of the new failure path.Expected Outcome
Any
loopover-mcp <command> --jsoninvocation that fails prints a single parseable JSON object{ "ok": false, "error": "<message>" }to stdout, never a raw Node stack trace — matching the{ ok: false, error }contract@loopover/miner's CLI already guarantees, and making--jsonmode reliable for scripting/CI use of
@loopover/mcpregardless of success or failure.Links & Resources
packages/loopover-miner/lib/cli-error.js— the existing convention to mirror.packages/loopover-miner/lib/cli.jsline 1 (import) and line 75 (reportCliFailure(...)callsite) — how the sibling CLI wires it in.
packages/loopover-mcp/bin/loopover-mcp.jsline 32 (cliArgs), line ~590(
const exitCode = await runCli(cliArgs); process.exit(...)),runCli(~L1825).test/unit/mcp-cli-basics.test.tsline 117 — the existing test that documents today's gap.