Skip to content

fix(cli): keep init --json stdout to a parseable envelope - #313

Open
thecodedrift wants to merge 2 commits into
mainfrom
fix/init-json-envelope
Open

fix(cli): keep init --json stdout to a parseable envelope#313
thecodedrift wants to merge 2 commits into
mainfrom
fix/init-json-envelope

Conversation

@thecodedrift

Copy link
Copy Markdown
Member

Summary

init --json (including init --no-interactive --json) wrote human prose to stdout ahead of the JSON envelope, so taskless init --json | jq . failed to parse. runNonInteractive, the shared body behind init and init --no-interactive, unconditionally console.log'd:

  • the "No tools detected. Using fallback: …" notice, and
  • the per-target skill/command summary ("wrote N skill canonical file(s)", the written/removed skill and command lists).

Both now route through a log helper that is console.error under --json and console.log otherwise — matching the convention ensureTasklessDirectory's migration notices and verify/test already use, and matching the reload notice's existing (already correct) handling in this same file.

Where the prose goes under --json

stderr. It stays visible to a person watching the terminal (stderr renders in a normal terminal same as stdout) without corrupting a machine consumer's view of stdout. This information is not on the JSON envelope (finer-grained than migrated/commandsInstalled), so dropping it silently was not an option — moving it off stdout was.

Test fix (the other half, from #284)

test/migrated-envelope.test.ts's parseEnvelope helper only ever read stdout's last line via .trim().split("\n").at(-1). A helper shaped that way cannot fail on anything printed before the envelope — which is exactly how the init --json prose-on-stdout bug went undetected by this file's own tests. It now does JSON.parse(stdout.trim()) on the whole thing, so any extra output anywhere in stdout fails the parse.

Mutation check

  • Reintroduced the bug (const log = console.log;, ignoring the json option) and rebuilt.
  • The fixed parseEnvelope (whole-stdout parse): failed, SyntaxError: Unexpected token 'N', "No tools d"... is not valid JSON, on both init --json tests in the file.
  • The OLD .at(-1) helper, run by hand against the same corrupted stdout: parsed successfully — it read only the JSON line and never saw the prose above it. This is the concrete demonstration that the test-helper fix is the load-bearing half of this PR, not just the production fix.
  • Reverted the mutation, rebuilt: full suite green again (83 files / 1345 tests).

Other tests with the same blind spot

packages/cli/test/no-implicit-migration.test.ts uses the identical stdout.trim().split("\n").at(-1) idiom in four places (lines 117, 234, 281, 334) plus once on stderr (303). Same shape of bug, same fix would apply, but that file is outside this PR's assigned scope — noting it here rather than editing it.

Verification

  • pnpm typecheck — pass
  • pnpm test — 83 files / 1345 tests pass
  • pnpm lint (builds first, runs pnpm cli check) — pass, "No issues found."
  • Built CLI, ran init --no-interactive --json -d . in a scratch dir, piped stdout through JSON.parse — parses cleanly:
    stdout: {"success":true,"commandsInstalled":false,"migrated":{"from":0,"to":6,...}}
    stderr: Detected non-interactive context...
    Migrating .taskless/...
    ...
    No tools detected...
    Taskless canonical store (.taskless/): wrote 1 skill canonical file(s)
      - taskless
    ...
    

Fixes #279
Refs #284

runNonInteractive (the shared body of `init` and `init --no-interactive`)
unconditionally console.log'd the "no tools detected" fallback notice and
the per-target skill/command summary, so `init --json` wrote prose to
stdout ahead of the JSON envelope and `taskless init --json | jq .` failed
to parse.

That prose now goes to stderr under --json (stdout otherwise, matching
the existing human output), following the same convention already used by
ensureTasklessDirectory's migration notices and by verify/test.

Also fixes test/migrated-envelope.test.ts's parseEnvelope helper, which
only ever read stdout's LAST line via .at(-1) and so could never detect
anything printed before it -- exactly how this bug went unnoticed. It now
JSON.parses the whole trimmed stdout.
…nd too

`no-implicit-migration.test.ts` carried the same `stdout.trim().split("\n")
.at(-1)` idiom in four places. A helper that reads only the last line cannot
fail on anything printed before it, which is exactly how the bug this PR fixes
stayed invisible, so leaving four more of them in place leaves four more places
for it to hide.

Measured rather than assumed, twice. Widening them to parse the whole of stdout
leaves all 25 tests passing, so no command in that file prints prose ahead of
its envelope today. Reintroducing the `--json` bug then fails four of them,
where the old helpers passed it silently.

The remaining `.at(-1)` in that file reads the last line of STDERR to check a
message, which is a legitimate use and is left alone.

Refs #284
@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 2m 28s —— View job


Code Review

Review mode: incremental — read 0 prior review thread(s) before reviewing. .prior-review.json contains no comments, reviews, or review threads for this PR, so this is the first review of PR #313; everything below is assessed fresh.

  • Read .prior-review.json (empty — first review)
  • Read PR diff (gh pr diff) and metadata
  • Reviewed correctness, security, reuse/simplification, altitude/conventions, and test adequacy
  • Verified findings against full file contents (not just diff hunks)
  • Posted inline comments and summary

Summary

The fix does what it says: runNonInteractive's per-target summary and the "no tools detected" notice now route through log (console.error under --json, console.log otherwise), and migrated-envelope.test.ts / no-implicit-migration.test.ts's parseEnvelope helpers were correctly widened from .at(-1) (blind to anything printed before the last line) to JSON.parse(stdout.trim()) on the whole string. I verified the ternary direction, flag threading, and that every console.log inside runNonInteractive was converted — no missed call sites, no inverted condition. The changeset is correctly scoped as patch.

Three [New] findings from a closer read:

  1. packages/cli/src/commands/init.ts:263 — The comment says this diff "matches … the verify/test convention of routing prose off stdout under --json." But ensureTasklessDirectory(cwd) (line 277) is called with no onNotice, so its migration prose always goes to console.error unconditionally, regardless of --json. verify.ts (75-79) actually suppresses that notice entirely under --json, and EnsureOptions.onNotice's own doc comment says callers under --json should do exactly that, since the same info is already on the envelope's migrated field. Doesn't corrupt stdout (default is already stderr), but the comment overstates what convention is actually being followed here.
  2. packages/cli/test/no-implicit-migration.test.ts:117 — The JSON.parse(stdout.trim()) fix is copy-pasted at 4 call sites (117, 234, 281, 334) instead of factoring it into a shared parseEnvelope-style helper the way migrated-envelope.test.ts does in this same diff (whose comment documents why whole-string parsing matters — the regression guard for this exact bug). Also, narrowing to JSON.parse(stdout.trim()) dropped the ?? "{}" empty-stdout fallback uniformly at all 4 sites; minor, since the test still fails either way, just with a less legible error on genuinely empty stdout.
  3. packages/cli/test/error-envelope.test.ts (not modified by this PR, so no inline anchor exists) — its parseEnvelope (lines 44-51) still filters stdout to lines starting with { and takes the last match, which is blinder than even the pre-fix .at(-1) idiom (it would silently accept a stray {-prefixed line anywhere in stdout). The companion commit's message ("widen the other last-line envelope parsers, which were blind too") only targeted no-implicit-migration.test.ts's four call sites explicitly, so this may just be a residual out-of-scope case rather than an oversight — flagging for awareness rather than as a defect in this diff. Separately, the PR body's "outside this PR's assigned scope" note about no-implicit-migration.test.ts appears stale, since the second commit did in fact fix that file — worth reconciling the description.

Nothing else stood out as a correctness, security, or performance issue. The remaining candidate observations from my review passes (e.g., no shared Reporter/Logger abstraction across init.ts/check.ts/verify.ts for --json-gated output, or the single-caller options: { json?: boolean } parameter object) are architectural preferences rather than defects, and I didn't post them as findings.

async (command) => {
const { stdout } = await runCli([command, "--json", "-d", directory]);
const envelope = JSON.parse(stdout.trim().split("\n").at(-1) ?? "{}") as {
const envelope = JSON.parse(stdout.trim()) as {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[New] The JSON.parse(stdout.trim()) fix is duplicated at 4 call sites here (117, 234, 281, 334), each with its own inline result type, instead of extracting a shared helper the way migrated-envelope.test.ts (touched in this same diff) factors it into a documented parseEnvelope. That helper's comment explains why whole-string parsing matters — it's the regression guard for the exact bug this PR fixes — but that explanation isn't attached to any of the 4 sites here. A future edit could reintroduce .split("\n").at(-1) at one of them without anyone noticing the parallel in the other file.

Related: narrowing to JSON.parse(stdout.trim()) also dropped the ?? "{}" fallback that used to guard empty stdout, uniformly across all 4 sites. Minor — the test still fails either way — but on genuinely empty stdout it now throws SyntaxError: Unexpected end of JSON input instead of a legible expect(...) failure.

// machine consumer parsing stdout. Matches `ensureTasklessDirectory`'s own
// default (`runMigrations` falls back to `console.error`) and the
// `verify`/`test` convention of routing prose off stdout under `--json`.
const log = options.json ? console.error : console.log;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[New] The comment above (256-262) claims this diff "matches … the verify/test convention of routing prose off stdout under --json." But further down, ensureTasklessDirectory(cwd) (line 277) is called with no onNotice option, so its migration prose ("Migrating .taskless/…" / "Migrated .taskless/…") always falls back to console.error unconditionally — regardless of --json. That's not what verify.ts does: it passes an onNotice that suppresses the message entirely under --json (verify.ts:75-79):

onNotice: (message: string) => {
  if (!json) console.error(message);
},

and EnsureOptions.onNotice's own doc comment (filesystem/directory.ts:14-16) says explicitly: "Callers that emit --json should pass a callback that suppresses output under that flag: the same information is on the envelope's migrated field…"

init --json's envelope does carry a migrated field (line 106-108), so this call site is exactly the case that doc comment describes, and it isn't wired that way. This doesn't corrupt stdout (the default already targets stderr), but it's a real gap between the stated convention and the code, and worth either wiring an onNotice here for consistency or correcting the comment.

Sign up for free to 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.

init --json writes prose to stdout, so the envelope cannot be parsed

1 participant