Never hangs
decide() is the rule: a supplied value is never asked for, --json, CI and agents never prompt, --yes answers a confirm, and with nobody on stdin a missing value is an error naming the flag.
A prompt that waits for a person who is not there is a hang, and a hung agent is a failed
task. decide() from caique/decide rules on each prompt before anything is drawn, as a
pure function: a value, a prompt spec, the option's name, a runtime and the run's flags in, a
verdict out.
import { decide } from 'caique/decide';
const spec = { kind: 'confirm', message: 'Overwrite the existing config?' };
const terminal = { env: {}, isTTY: { stdin: true } };
const pipe = { env: {}, isTTY: { stdin: false } };
const ci = { env: { CI: 'true' }, isTTY: { stdin: true } };
const agent = { env: { CLAUDECODE: '1' }, isTTY: { stdin: true } };
const cases = [
['a value was passed', { value: false, runtime: pipe }],
['a terminal, required', { value: undefined, runtime: terminal, required: true }],
['a terminal, optional', { value: undefined, runtime: terminal }],
['a terminal, optional, --interactive', { value: undefined, runtime: terminal, flags: { interactive: true } }],
['no terminal, required', { value: undefined, runtime: pipe, required: true }],
['CI on a terminal, required', { value: undefined, runtime: ci, required: true }],
['an agent on a terminal, required', { value: undefined, runtime: agent, required: true }],
['--json on a terminal', { value: undefined, runtime: terminal, required: true, flags: { json: true } }],
['--yes, no terminal', { value: undefined, runtime: pipe, required: true, flags: { yes: true } }],
];
for (const [name, input] of cases) {
const { action, value, message } = decide({ spec, option: 'force', ...input });
console.log(`${name.padEnd(36)} ${action}${value === undefined ? '' : ` ${value}`}${message === undefined ? '' : ` — ${message}`}`);
}a value was passed skip
a terminal, required prompt
a terminal, optional skip
a terminal, optional, --interactive prompt
no terminal, required error — --force is required when there is no terminal
CI on a terminal, required error — --force is required when nobody is there to answer
an agent on a terminal, required error — --force is required when nobody is there to answer
--json on a terminal error — --force is required under --json
--yes, no terminal answer trueWhy each row is where it is
- A value from any source is never asked for —
false,0and''included. Only "nothing was supplied" is not an answer. --jsoncomes before everything else,--yesand--interactiveincluded: they cannot make a machine type.--yesanswers aconfirm, and only aconfirm. Nothing has to be typed, so it works with no terminal too. It never answers atextor aselect, because there is no "yes" to a question with more than two answers.- No terminal on stdin,
CIset, or an agent driving the process is nobody there. An agent —CLAUDECODE,CURSOR_AGENT,CODEX_THREAD_ID,GEMINI_CLI,AI_AGENT— may well have a terminal; what it does not have is a person. This is roundel'sinteractive(), andFORCE_TTY=1is its one override. Then a missing value is an error whosefixnames the flag and quotes the question it would have asked.--interactivecannot conjure a person, and the error says so when it was passed, rather than looking as if the flag was ignored. - Otherwise, only what is required is asked, unless the run passed
--interactive, which asks for optional values too.
decide.test.ts enumerates every one of the 256 combinations of value, kind, terminal, CI,
--json, --yes, --interactive and required, against the rule written a second time,
independently. The case that would be a bug report if it broke is named on its own: no
terminal and no value is never a prompt.
The runtime
decide() reads env — CI, the agent variables, FORCE_TTY — and isTTY.stdin from the runtime it is handed, and never process.
processRuntime() from caique builds one from the real process; a test passes a literal.
Getting started
Install caique, bind two prompts to flags, and see the same program ask a person on a terminal and refuse — naming the flag — when nobody is there.
Asking
ask() is the six kinds — text, confirm, select, multiselect, password and path — each a question written and a line read back, with no raw mode and no escape sequence: the accessible rendering is the only one.