diff --git a/tools/loop/dist/cli.js b/tools/loop/dist/cli.js index 8ba15aa4..7be4bc02 100755 --- a/tools/loop/dist/cli.js +++ b/tools/loop/dist/cli.js @@ -139,6 +139,17 @@ async function main() { return; } if (cmd === '--interactive' || cmd === '-i') { + // Mirrors cmdWizard()'s TTY gate below: runInteractiveWizard() drives + // readline.question(), which resolves immediately with '' when stdin + // isn't a TTY (a CI step, piped input, `< /dev/null`) instead of + // waiting for a human. Without this guard, loop --interactive in a + // non-interactive context silently ran the wizard with every prompt + // answered blank and scaffolded files nobody asked for. + if (!process.stdin.isTTY) { + printNonInteractiveHelp(); + process.exitCode = 0; + return; + } const plan = await runInteractiveWizard(); process.exitCode = await executePlan(plan); return; diff --git a/tools/loop/src/cli.ts b/tools/loop/src/cli.ts index 441d3068..a9d5fca4 100644 --- a/tools/loop/src/cli.ts +++ b/tools/loop/src/cli.ts @@ -153,6 +153,17 @@ async function main(): Promise { return; } if (cmd === '--interactive' || cmd === '-i') { + // Mirrors cmdWizard()'s TTY gate below: runInteractiveWizard() drives + // readline.question(), which resolves immediately with '' when stdin + // isn't a TTY (a CI step, piped input, `< /dev/null`) instead of + // waiting for a human. Without this guard, loop --interactive in a + // non-interactive context silently ran the wizard with every prompt + // answered blank and scaffolded files nobody asked for. + if (!process.stdin.isTTY) { + printNonInteractiveHelp(); + process.exitCode = 0; + return; + } const plan = await runInteractiveWizard(); process.exitCode = await executePlan(plan); return; diff --git a/tools/loop/test/cli.test.mjs b/tools/loop/test/cli.test.mjs index 0b4f523a..2f39cef8 100644 --- a/tools/loop/test/cli.test.mjs +++ b/tools/loop/test/cli.test.mjs @@ -2,7 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { execFile } from 'node:child_process'; +import { execFile, execFileSync } from 'node:child_process'; import { promisify } from 'node:util'; import { access } from 'node:fs/promises'; @@ -75,6 +75,34 @@ test('loop status --json includes recentRuns array', async () => { assert.equal(typeof report.nextHint, 'string'); }); +test('loop --interactive with closed (non-TTY) stdin prints help instead of scaffolding', async () => { + const { mkdtemp, rm, readdir } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const dir = await mkdtemp(path.join(tmpdir(), 'loop-interactive-nontty-')); + try { + // execFileSync's input: '' pipes an empty stdin and closes it, the same + // shape as `loop --interactive < /dev/null` in CI -- readline's + // question() resolves immediately with '' instead of waiting on a + // human. Before the TTY guard, this ran the wizard's default plan + // (loop init . --pattern daily-triage --tool grok) against cwd. + const stdout = execFileSync(process.execPath, [CLI, '--interactive'], { + cwd: dir, + input: '', + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + assert.match(stdout, /Week-one path/); + // "→ loop init" only appears when executePlan() actually runs init; + // printNonInteractiveHelp()'s own copy-paste suggestions don't use the arrow. + assert.doesNotMatch(stdout, /→ loop init/); + + const entries = await readdir(dir); + assert.deepEqual(entries, [], 'must not scaffold files from a non-TTY --interactive run'); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + test('loop doctor on empty temp dir is blocked', async () => { const { mkdtemp, rm } = await import('node:fs/promises'); const { tmpdir } = await import('node:os');