From 803c402d63ee1c10a80866ae86627f7fd7ab4500 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 7 Sep 2026 23:18:08 -0700 Subject: [PATCH 1/4] fix(cli): keep init --json stdout to a parseable envelope 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. --- .changeset/init-json-envelope.md | 13 ++++++++ packages/cli/src/commands/init.ts | 37 ++++++++++++--------- packages/cli/test/migrated-envelope.test.ts | 13 ++++++-- 3 files changed, 46 insertions(+), 17 deletions(-) create mode 100644 .changeset/init-json-envelope.md diff --git a/.changeset/init-json-envelope.md b/.changeset/init-json-envelope.md new file mode 100644 index 00000000..5194f842 --- /dev/null +++ b/.changeset/init-json-envelope.md @@ -0,0 +1,13 @@ +--- +"@taskless/cli": patch +--- + +`init --json` now writes only the parseable envelope to stdout. Previously, +the non-interactive install path (also reached from `init --no-interactive +--json`) unconditionally logged human-readable prose — the "no tools +detected" fallback notice and the per-target skill/command summary — to +stdout ahead of the JSON envelope, so `taskless init --json | jq .` failed +with a JSON parse error. That prose now goes to stderr, where it stays +visible to a person watching the terminal without corrupting a machine +consumer's view of stdout, matching how `verify`/`test` and the migration +notice already behave under `--json`. diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 58cd4c86..a8f76b8e 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -94,7 +94,7 @@ export const initCommand = defineCommand({ ); } - const result = await runNonInteractive(cwd); + const result = await runNonInteractive(cwd, { json: args.json }); if (args.json) { console.log( JSON.stringify({ @@ -245,11 +245,22 @@ export const updateCommand = defineCommand({ }, }); -async function runNonInteractive(cwd: string): Promise<{ +async function runNonInteractive( + cwd: string, + options: { json?: boolean } = {} +): Promise<{ commandsInstalled: boolean; reloadNotice: string | undefined; migrated: MigrationReport | undefined; }> { + // Under `--json`, stdout carries only the envelope printed by the caller. + // This per-target summary is not on that envelope (it is finer-grained than + // `migrated`/`commandsInstalled`), so rather than drop it, it goes to + // stderr — visible to a person watching the terminal, invisible to a + // 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; // Sampled BEFORE the directory is created, and that order is the whole // point. `ensureTasklessDirectory` mkdir -p's, so afterwards a pre-existing // project is indistinguishable from a fresh one. @@ -291,7 +302,7 @@ async function runNonInteractive(cwd: string): Promise<{ const reloadNotice = getReloadNotice({ previousCliVersion, cliVersion }); if (detected.length === 0) { - console.log(`No tools detected. Using fallback: ${DEFAULT_SHIM_DIR}/`); + log(`No tools detected. Using fallback: ${DEFAULT_SHIM_DIR}/`); } const skillsByTarget = groupValuesByTarget( @@ -332,33 +343,29 @@ async function runNonInteractive(cwd: string): Promise<{ removedSkills.length === 0 && removedCommands.length === 0 ) { - console.log(`${target.label} (${target.dir}/): up to date`); + log(`${target.label} (${target.dir}/): up to date`); continue; } - console.log( + log( `${target.label} (${target.dir}/): wrote ${String(writtenSkills.length)} skill ${noun}(s)` ); for (const name of writtenSkills) { - console.log(` - ${name}`); + log(` - ${name}`); } if (writtenCommands.length > 0) { - console.log(` + ${String(writtenCommands.length)} command ${noun}(s)`); + log(` + ${String(writtenCommands.length)} command ${noun}(s)`); } if (removedSkills.length > 0) { - console.log( - ` removed ${String(removedSkills.length)} obsolete skill(s):` - ); + log(` removed ${String(removedSkills.length)} obsolete skill(s):`); for (const name of removedSkills) { - console.log(` - ${name}`); + log(` - ${name}`); } } if (removedCommands.length > 0) { - console.log( - ` removed ${String(removedCommands.length)} obsolete command(s):` - ); + log(` removed ${String(removedCommands.length)} obsolete command(s):`); for (const name of removedCommands) { - console.log(` - ${name}`); + log(` - ${name}`); } } } diff --git a/packages/cli/test/migrated-envelope.test.ts b/packages/cli/test/migrated-envelope.test.ts index e0e36a48..40fda968 100644 --- a/packages/cli/test/migrated-envelope.test.ts +++ b/packages/cli/test/migrated-envelope.test.ts @@ -36,9 +36,18 @@ async function runCli( } } +/** + * Parse `--json` stdout as the WHOLE envelope, not just its last line. + * + * `stdout.trim().split("\n").at(-1)` was the earlier shape of this helper, + * and it is exactly why #279 (`init --json` printing prose ahead of the + * envelope) stayed invisible: a helper that only ever reads the last line + * cannot fail on anything printed before it. `JSON.parse` on the trimmed + * whole string fails loudly the moment stdout carries a second thing, + * whichever end it lands on. + */ function parseEnvelope(stdout: string): Record { - const line = stdout.trim().split("\n").at(-1) ?? ""; - return JSON.parse(line) as Record; + return JSON.parse(stdout.trim()) as Record; } /** The versions a project seeded at 3 must be carried through. */ From 51dff755d0df72168a38586f4ab3f302e5fc27c8 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Mon, 7 Sep 2026 23:21:41 -0700 Subject: [PATCH 2/4] test(cli): widen the other last-line envelope parsers, which were blind 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 --- packages/cli/test/no-implicit-migration.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/test/no-implicit-migration.test.ts b/packages/cli/test/no-implicit-migration.test.ts index d93d5097..a5fe6c99 100644 --- a/packages/cli/test/no-implicit-migration.test.ts +++ b/packages/cli/test/no-implicit-migration.test.ts @@ -114,7 +114,7 @@ describe("a reporting command never migrates", () => { "%s --json carries the code an agent branches on", 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 { ok?: boolean; code?: string; }; @@ -231,7 +231,7 @@ describe("a reporting command never migrates", () => { directory, ]); - const envelope = JSON.parse(stdout.trim().split("\n").at(-1) ?? "{}") as { + const envelope = JSON.parse(stdout.trim()) as { migrated?: { from: number; to: number }; }; expect(envelope.migrated?.from).toBe(3); @@ -278,7 +278,7 @@ describe("a manifest that cannot be parsed", () => { "%s --json reports the file, not a version it guessed", 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 { ok?: boolean; code?: string; message?: string; @@ -331,7 +331,7 @@ describe("a manifest that cannot be parsed", () => { "-d", bare, ]); - const envelope = JSON.parse(stdout.trim().split("\n").at(-1) ?? "{}") as { + const envelope = JSON.parse(stdout.trim()) as { migrated?: { from: number; to: number }; }; expect(envelope.migrated?.from).toBe(0); From a88a11129badfcbaf97cca2a657453019b08c1b0 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 8 Sep 2026 09:42:53 -0700 Subject: [PATCH 3/4] fix(cli): wire onNotice so migration prose is suppressed under --json, and share the whole-stdout envelope parser Review feedback on #313: 1. init.ts's comment claimed this diff matches the verify/test convention of routing --json prose off stdout, but ensureTasklessDirectory(cwd) was called with no onNotice, so its migration notice always fell back to an unconditional console.error regardless of --json. verify.ts actually SUPPRESSES that notice entirely under --json (its info already lives on the envelope), matching EnsureOptions.onNotice's own doc comment. Wired the same onNotice here so the code matches what the comment says, added a regression test asserting stderr carries nothing about the migration under --json, and mutation-checked it (dropping the !json guard fails the new test; restoring it passes). 2. no-implicit-migration.test.ts's four JSON.parse(stdout.trim()) call sites (from a prior commit widening them off the old blind .at(-1) read) are now a single parseEnvelope helper, documented the same way migrated-envelope.test.ts's version is, so the reasoning for whole-string parsing isn't duplicated four times without its rationale attached. Mutation-checked: reintroducing the original init --json stdout-prose bug fails both init --json tests in this file through the shared helper. --- packages/cli/src/commands/init.ts | 23 +++++++++++--- packages/cli/test/migrated-envelope.test.ts | 20 ++++++++++++ .../cli/test/no-implicit-migration.test.ts | 31 ++++++++++++++----- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index a8f76b8e..eb7f520c 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -257,9 +257,7 @@ async function runNonInteractive( // This per-target summary is not on that envelope (it is finer-grained than // `migrated`/`commandsInstalled`), so rather than drop it, it goes to // stderr — visible to a person watching the terminal, invisible to a - // 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`. + // machine consumer parsing stdout. const log = options.json ? console.error : console.log; // Sampled BEFORE the directory is created, and that order is the whole // point. `ensureTasklessDirectory` mkdir -p's, so afterwards a pre-existing @@ -274,7 +272,24 @@ async function runNonInteractive( // can report what a migration moved. `check`, `verify` and `test` used to // carry this on their own envelopes and refuse rather than migrate now, so // the field followed the behaviour rather than being dropped. - const migrated = await ensureTasklessDirectory(cwd); + // + // The migration notice is suppressed entirely under `--json`, rather than + // moved to stderr like the per-target summary above: unlike that summary, + // this information IS already on the envelope, as `migrated`, so printing + // it a second time would just be noise. This is the actual `verify`/`test` + // convention (`verify.ts`'s `onNotice: (message) => { if (!json) + // console.error(message); }`), and the case `EnsureOptions.onNotice`'s own + // doc comment describes: "callers that emit `--json` should pass a + // callback that suppresses output under that flag: the same information is + // on the envelope's `migrated` field". Omitting `onNotice` here, as before, + // left it on the default fallback (unconditional `console.error`), which + // never corrupts stdout but doesn't suppress the duplicate under `--json` + // either — the gap a reviewer of this PR caught. + const migrated = await ensureTasklessDirectory(cwd, { + onNotice: (message: string) => { + if (!options.json) console.error(message); + }, + }); if (wasNewProject) { // A project this CLI just created has no entries to walk: everything the // ledger describes is already true of the scaffold it wrote. diff --git a/packages/cli/test/migrated-envelope.test.ts b/packages/cli/test/migrated-envelope.test.ts index 40fda968..ae026e5d 100644 --- a/packages/cli/test/migrated-envelope.test.ts +++ b/packages/cli/test/migrated-envelope.test.ts @@ -129,6 +129,26 @@ describe("who migrates, and who refuses", () => { expectSeededMigration(envelope.migrated); }); + it("init --json reports the migration on stdout and stays silent about it on stderr", async () => { + // The migration notice duplicates the envelope's `migrated` field, so + // under `--json` it is suppressed rather than moved to stderr - unlike + // the per-target install summary, which stderr DOES carry under `--json` + // because that detail has no field of its own. Catches a regression that + // routes this notice back through the unconditional `console.error` + // fallback `ensureTasklessDirectory` uses when no `onNotice` is passed. + await seedVersion3(); + + const { stderr } = await runCli([ + "init", + "--no-interactive", + "--json", + "-d", + temporaryDirectory, + ]); + + expect(stderr).not.toContain("Migrat"); + }); + it("init --json omits the field when nothing migrated", async () => { // Absence is the signal, so a consumer never reads empty arrays to decide. await seedVersion3(); diff --git a/packages/cli/test/no-implicit-migration.test.ts b/packages/cli/test/no-implicit-migration.test.ts index a5fe6c99..0cf04bb2 100644 --- a/packages/cli/test/no-implicit-migration.test.ts +++ b/packages/cli/test/no-implicit-migration.test.ts @@ -57,6 +57,21 @@ async function runCli( } } +/** + * Parse `--json` stdout as the WHOLE envelope, not just its last line. + * + * The earlier shape of every call site here was + * `JSON.parse(stdout.trim().split("\n").at(-1) ?? "{}")`, and that is exactly + * why `init --json` printing prose ahead of its envelope (#279) went + * undetected: a helper that only ever reads the last line cannot fail on + * anything printed before it. `JSON.parse` on the trimmed whole string fails + * loudly the moment stdout carries a second thing, whichever end it lands on + * — do not narrow this back to a last-line read. + */ +function parseEnvelope(stdout: string): T { + return JSON.parse(stdout.trim()) as T; +} + const FLAT_RULE = "id: no-eval\nlanguage: TypeScript\nseverity: error\nmessage: no eval\nrule:\n pattern: eval($A)\n"; @@ -114,10 +129,10 @@ describe("a reporting command never migrates", () => { "%s --json carries the code an agent branches on", async (command) => { const { stdout } = await runCli([command, "--json", "-d", directory]); - const envelope = JSON.parse(stdout.trim()) as { + const envelope = parseEnvelope<{ ok?: boolean; code?: string; - }; + }>(stdout); expect(envelope.ok).toBe(false); // Distinct from SCAFFOLD_VERSION_MISMATCH, which is the opposite // direction and asks the caller to upgrade the CLI instead. @@ -231,9 +246,9 @@ describe("a reporting command never migrates", () => { directory, ]); - const envelope = JSON.parse(stdout.trim()) as { + const envelope = parseEnvelope<{ migrated?: { from: number; to: number }; - }; + }>(stdout); expect(envelope.migrated?.from).toBe(3); expect(envelope.migrated?.to).toBe(LATEST_SCHEMA_VERSION); await expect( @@ -278,11 +293,11 @@ describe("a manifest that cannot be parsed", () => { "%s --json reports the file, not a version it guessed", async (command) => { const { stdout } = await runCli([command, "--json", "-d", directory]); - const envelope = JSON.parse(stdout.trim()) as { + const envelope = parseEnvelope<{ ok?: boolean; code?: string; message?: string; - }; + }>(stdout); expect(envelope.ok).toBe(false); expect(envelope.code).toBe("SCAFFOLD_MANIFEST_UNREADABLE"); expect(envelope.message).toContain("taskless.json"); @@ -331,9 +346,9 @@ describe("a manifest that cannot be parsed", () => { "-d", bare, ]); - const envelope = JSON.parse(stdout.trim()) as { + const envelope = parseEnvelope<{ migrated?: { from: number; to: number }; - }; + }>(stdout); expect(envelope.migrated?.from).toBe(0); expect(envelope.migrated?.to).toBe(LATEST_SCHEMA_VERSION); } finally { From 5c7c1709a05bc0f9d428a3f9f92d0f3408360ea0 Mon Sep 17 00:00:00 2001 From: Jakob Heuser Date: Tue, 8 Sep 2026 09:46:57 -0700 Subject: [PATCH 4/4] test(cli): tighten the last blind envelope parser in the suite `error-envelope.test.ts` filtered stdout to lines starting with `{` and took the last one. Like the two helpers already fixed here, it cannot fail on anything printed beside the envelope, which is precisely how #279 stayed invisible. Its comment is worth recording, because it is what made the gap easy to miss: it said stderr progress was being ignored, which is true and harmless, while the filter it described was quietly dropping STDOUT prose, which is neither. A comment that explains a lesser thing the code also does reads as a justification for the whole line. Measured before changing it: all 24 tests pass against the whole string, so no command in this file prints anything beside its envelope today. The parse now fails loudly the moment one starts. That is the third and last instance of this idiom in the suite. Refs #284 --- packages/cli/test/error-envelope.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/cli/test/error-envelope.test.ts b/packages/cli/test/error-envelope.test.ts index 55d56494..2c738b80 100644 --- a/packages/cli/test/error-envelope.test.ts +++ b/packages/cli/test/error-envelope.test.ts @@ -44,10 +44,7 @@ async function runCli( function parseEnvelope(stdout: string): ErrorEnvelope { // The envelope is the last JSON line in stdout. (Some commands also // print progress to stderr, so we ignore that.) - const lines = stdout.split("\n").filter((l) => l.trim().startsWith("{")); - expect(lines.length).toBeGreaterThan(0); - const last = lines.at(-1)!; - return JSON.parse(last) as ErrorEnvelope; + return JSON.parse(stdout.trim()) as ErrorEnvelope; } describe("standardized error envelope (--json)", () => {