Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions tools/loop/dist/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions tools/loop/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,17 @@ async function main(): Promise<void> {
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;
Expand Down
30 changes: 29 additions & 1 deletion tools/loop/test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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');
Expand Down