Build Interactive CLI Prompts with Validation and Safe Defaults
Ask questions only when arguments are absent, validate answers in the loop, and make non-interactive mode the escape hatch that always works.
Task
Add an interactive prompt layer with re-ask on invalid input, an explicit default, and a clean non-interactive path for scripts and CI.
When to use this
An init command prompts for a project name, accepts anything including the empty string, and hangs forever when a CI job runs it with no TTY.
Prerequisites
- Node.js 18+ (or Bash with read -r -p)
- A command that currently requires arguments users never remember
- A test path without a TTY for the non-interactive check
Steps
1. Detect a TTY and refuse interactive mode without one.
// prompt.mjs
import { createInterface } from 'node:readline/promises';
import { stdin, stdout } from 'node:process';
export const isInteractive = stdin.isTTY === true;
export function failNoTty(flagName) {
console.error(
`error: ${flagName} is required when stdin is not a TTY\n` +
`pass --${flagName} explicitly in scripts and CI`
);
process.exit(2);
}
Expected output: Running under CI or a pipe now exits with a clear message instead of hanging on a prompt nobody can answer.
2. Prompt only for missing values, with a visible default.
async function ask(question, { default: def, validate } = {}) {
if (!isInteractive) failNoTty('see individual flags');
const rl = createInterface({ input: stdin, output: stdout });
try {
for (;;) {
const suffix = def !== undefined ? ` (${def})` : '';
const raw = (await rl.question(`${question}${suffix}: `)).trim();
const val = raw === '' && def !== undefined ? def : raw;
const err = validate?.(val);
if (!err) return val;
console.error(` invalid: ${err}`);
}
} finally {
rl.close();
}
}
const validateName = (v) =>
!v ? 'name is required' :
!/^[a-z0-9-]+$/.test(v) ? 'lowercase letters, digits and dashes only' :
v.length > 40 ? 'max 40 chars' : undefined;
Expected output: Invalid answers re-prompt with the specific reason shown; empty input falls back to the default, and Ctrl-C still exits cleanly via the finally block.
3. Wire prompts behind absent flags, never in front of them.
import { parseArgs } from 'node:util';
const { values: args } = parseArgs({
options: {
name: { type: 'string' },
region: { type: 'string', default: 'us-east-1' },
yes: { type: 'boolean', short: 'y' },
},
});
const name = args.name ?? await ask('project name', { validate: validateName });
const region = args.region; // never prompted: has a default
if (!args.yes && isInteractive) {
const ok = await ask(`create ${name} in ${region}?`, { default: 'yes' });
if (!/^y(es)?$/i.test(ok)) { console.error('aborted'); process.exit(1); }
}
Expected output: Flags drive automation with no prompts at all; humans get asked only for what they omitted, and the confirm step respects a --yes bypass.
4. Prove the non-interactive path works under a pipe.
echo "" | node cli.mjs --name demo-proj --yes 2>&1 | tail -2; node cli.mjs < /dev/null 2>&1 | head -2; echo "exit=$?"Expected output: The piped run completes with the flags given; the TTY-less run without required flags exits 2 with the actionable message, and nothing hangs.
5. Add a scripted end-to-end check for the prompt loop itself.
// test-prompt.mjs -- drive the loop through a pipe
import { ExecFile } from 'node:child_process';
import { promisify } from 'node:util';
const exec = promisify(ExecFile);
const bad = await exec('node', ['cli.mjs'], {
input: 'Bad Name!\n',
});
// a real test would assert the retry message appeared, then a valid name succeeded
Expected output: The driven run exercises invalid-then-valid input through stdin, verifying the re-ask loop without a human at the keyboard.
Troubleshooting
Problem
Prompt hangs in CI
Solution
The TTY check is missing or checked too late; test with < /dev/null in CI before shipping, and exit early with the required-flag message whenever stdin is not a terminal.
Problem
Answers with spaces or quotes break the script
Solution
Read with read -r in shell or readline in Node, never bare read; quote every interpolation, and validate the shape before use so punctuation never reaches downstream commands.
Problem
Users fat-finger destructive confirmations
Solution
For destroy actions require typing the resource name (type-to-confirm), not y; defaults belong on safe operations only, and destructive prompts should default to abort.
Limitations
- Interactive prompts are for humans only; every prompt is one more thing a script cannot answer, so flags remain the contract.
- Type-to-confirm patterns need exact-match input, which screen-recording tools and paste may circumvent; keep hard deletes behind an explicit id, not just a word.
Every prompt is a question a script cannot answer
Interactive CLIs are friendly until they meet a pipe. The architecture that survives both worlds: flags as the contract, prompts only for absent values, validation inside the ask loop, and a hard TTY check that turns a hang into an actionable error message.
interactive session:
$ node cli.mjs
project name (demo-app): BAD NAME!
invalid: lowercase letters, digits and dashes only
project name (demo-app): demo-app
create demo-app in us-east-1? (yes): yes
created.
non-interactive session (CI):
$ node cli.mjs < /dev/null
error: --name is required when stdin is not a TTY
pass --name explicitly in scripts and CI
exit=2 (2 seconds, not a hang)
Validate at the prompt, not after
An answer accepted without validation becomes a bad argument with a stack trace three layers later. The ask loop with a validate callback rejects bad input immediately with the specific reason, so the user corrects it at the point of entry while their intent is still fresh.
Safe defaults, unsafe confirmations
Defaults belong on reversible choices: region, verbosity, template. Destructive actions invert the polarity: no default (empty aborts), or type-the-name-to-confirm. The asymmetry is deliberate, because the cost of a wrong enter-key is not symmetric either.