caique
The parrot that always answers back, and the boat that goes between ship and shore. Prompts that are flags first, so agents answer before they are asked and non-TTY callers get an error naming the flag, never a hang. Drop-in path for inquirer and clack.
Released, pre-1.0. decide(), ask() and the caique/inquirer and caique/clack
drop-in paths ship today, each path graded by its incumbent's own suite on the
compatibility page; what is still to come
follows .sdlc/intents/caique/.
A caique (kah-EEK) is a small, loud, never-silent parrot — and this one always answers back. It is also the light wooden boat of the Bosphorus and the Greek islands, the one that runs between the ship and the shore carrying people and messages across the gap. Both are true of this package: it is the go-between that carries a question from a program to whoever is calling, human or agent, and brings the answer back. It never hangs.
What ships today
decide() — the rule that decides whether a person can be asked at all, and the reason
this package can promise it never hangs. It is pure: a value, a runtime slice and the run's
flags in, a verdict out.
import { decide } from 'caique/decide';
import { processRuntime } from 'caique';
decide({
value: undefined, // nothing was passed
spec: { kind: 'text', message: 'Where should it go?' },
option: 'output-dir',
runtime: processRuntime(), // or your own { env, isTTY: { stdin } }
required: true,
});
// no terminal -> { action: 'error', code: 'USAGE',
// message: '--output-dir is required when there is no terminal',
// fix: 'pass --output-dir; it would have been asked as "Where should it go?"' }
// a terminal -> { action: 'prompt' }The order of the rule is the argument, and it is enumerated rather than described: every
one of the 256 combinations of value x kind x TTY x CI x --json x --yes x
--interactive x required is generated and checked in decide.test.ts, against the rule
written a second time, independently. The case that would be a bug report if it broke is
called out by name: no terminal and no value is never a prompt.
--interactive reaches past "we would not have asked", never past "there is nobody to
ask" — and when there is nobody, the refusal says so, rather than looking like the flag was
ignored.
Asking, once it is allowed
ask() is the six kinds — text, confirm, select, multiselect, password, path —
each written as a question and a line read back:
import { ask } from 'caique/ask';
await ask(
{ kind: 'select', message: 'Which host?', choices: [{ value: 'ora' }, { value: 'log-update' }] },
{ reader, writer },
);
// Which host?
// 1) ora
// 2) log-update
// enter a number (1-2):Line mode is not the fallback, it is the floor. No raw mode, no cursor movement, no escape sequence, no redraw — so this is the accessible rendering rather than a second implementation of it, and a screen reader gets the same bytes a terminal does. The raw-mode renderer that arrows and highlights will sit on top and answer the same questions.
A stream that ends is a cancellation, not an empty answer: Ctrl-D and a closed pipe
both mean nobody is going to type, and reading that as '' is how a program writes to a
path nobody chose. Invalid input is re-asked five times and then gives up, because a loop
against a stream that keeps answering wrongly is the same hang wearing a hat.
projection(spec) gives the question without the conversation, for a gallery, a --help
or a transcript in an issue.
Wiring it to a CLI
resolvePrompts() is the pass a framework calls from its preAction hook, once the flags,
environment and config have had their turn:
import { resolvePrompts } from 'caique/binding';
const { values, failure } = await resolvePrompts({
options, // { name: { required: true, prompt: { kind: 'text', message: 'Project name?' } } }
values, // what every other source resolved
runtime: processRuntime(),
flags: { json, yes, interactive },
io: { reader, writer },
});
if (failure) throw new CliError(failure.code, failure.message, { fix: failure.fix });It walks the options in declaration order — the order the help listed — asks only what has to be asked, and stops at the first refusal, because a caller about to exit is better served by one actionable message than six.
There is one binding, not one per host. What a framework supplies is a record of options,
the values so far and a runtime; none of that needs any particular framework's types, so
caique imports none of them.
On a real terminal
createIo() is the reader and writer over actual streams — the only file in the package
that touches a terminal:
import { createIo } from 'caique/terminal';
import { ask } from 'caique/ask';
const io = createIo(); // the terminal the program was started in
await ask({ kind: 'password', message: 'Token?' }, io);
io.close();A password prompt is not echoed, and that lives here rather than in the widgets: this is
the only layer that knows what echo is, and no widget can leak a secret by writing it
back, because no widget writes what it read. The echo is suppressed for the duration of the
question rather than by turning the terminal's echo off — which would leave it off if the
process died mid-prompt.
Arrow keys, where there is a terminal to take them
askList() draws select and multiselect with a moving highlight and repaints in place.
It answers the same question ask() does and returns the same value, so it is a swap and
not a second implementation:
import { askList, canRender } from 'caique/raw';
import { createIo, streamsOf } from 'caique/terminal';
import { processRuntime } from 'caique';
const rt = processRuntime();
const io = { ...createIo(streamsOf(rt)), keys: rt.stdin };
const spec = { kind: 'select', message: 'Which host?', choices: [{ value: 'ora' }, { value: 'chalk' }] };
const answer = canRender(io.keys) ? await askList(spec, io) : await ask(spec, io);Line mode is the floor, not the fallback: this is decoration on top of it, and the suite
proves the two agree by running the same spec through both and comparing the answers.
Ctrl-C cancels — in raw mode it arrives as a byte rather than a signal — and the terminal
is put back the way it was found either way.
Weight
Every subpath is a lock, not a convention, and the numbers below are asserted by
weight.test.ts against dist/, not estimated. The ceiling is clack: @clack/prompts
1.8.0 is 101,684 B across six packages — itself, @clack/core, fast-string-width,
fast-string-truncated-width, fast-wrap-ansi and sisteransi.
| Subpath | Bytes | Reaches |
|---|---|---|
caique (everything) | 25,627 | no package at all |
caique/spec | 1,571 | a leaf — declare prompts without loading a widget |
caique/decide | 4,986 | the spec only |
caique/ask | 8,564 | the six widgets, no terminal, no raw mode |
caique/raw | 14,796 | line mode, which it sits on top of |
caique/binding | 15,475 | the decision and the widgets |
caique/terminal | 11,975 | the one file that touches a stream |
The whole package is a quarter of the lightest incumbent, and it reaches nothing:
allow is empty for every entry, asserted rather than claimed. Deciding not to ask costs
4,986 B and never loads the machinery of asking — which is the case an agent hits.
Measured 2026-09-09, the same way every bill in this family is: shipped code and data
(.js/.mjs/.cjs plus imported .json, package.json never counted), each competitor
counted whole across its own resolved tree.
What it will be
- Every prompt is a flag first. A caller who passes the flag is never asked. An agent answers before the question, on the command line, in one pass.
- Non-TTY never hangs. No human on deck means an error that names the flag, exit 2, with a fix a machine can apply and retry.
--interactiveasks for every missing required option in one pass;--yesaccepts every confirmation; cancellation exitsCANCELLEDand restores the terminal.- Accessible mode falls back to line input with no live redraw.
- A migration path from
@inquirer/promptsand@clack/prompts, graded by their own suites. The current grade is generated under Benchmarks, below.
Which incumbents this is measured against
@inquirer/prompts (28.8 M/wk) and @clack/prompts. Those two, and not the
package whose download count is larger:
inquirer(34.3 M/wk) is out of scope, deliberately. Its 34 million are the legacyinquirer.prompt([...])façade, an API its own maintainer moved off; a new CLI written today writes@inquirer/prompts. Reproducing the legacy object API would be work spent on a shape nobody new adopts, and it is not on this package's roadmap.@inquirer/coreis what the compatibility oracle grades, because it is where the prompt loop — the keypress state machine both façades sit on — is actually tested.inquirer's own npm tarball ships no tests at all, so there is nothing there to grade.
| Incumbent's suite | Cases | Their own package | caique |
|---|---|---|---|
@inquirer/core 12.0.3 | 41 | 41 (100%) | 0 (0.0%) |
@clack/prompts 1.8.1 | 606 | 576 (95.0%) | 0 (0.0%) |
The two caique cells are the 2026-09-14 record, kept as written and superseded since: the
caique/inquirer and caique/clack façades have shipped, and their current rates are
generated under Benchmarks below and on the
compatibility page.
Measured 2026-09-14 by npm run compat, which runs each incumbent's own unedited suite
twice: once against the incumbent (the control — the column that proves the gate works)
and once against caique. Both caique columns are zero, and they are zero because no
façade exists yet — caique exports ask, decide and spec, not createPrompt or
text in their spelling. The row is here because a claimed replacement with no number
beside it is a claim; this is the number, and it can only go up from here.
The 30 cases @clack/prompts fails against itself are path.test.ts, which mocks
node:fs in a way this repository's vitest does not reproduce — a harness divergence,
recorded with its reason in packages/compat-oracle/src/hosts.ts rather than rounded away.
Following along
The intent and design are committed before the code is, so you can read what it will be — and argue with it — before it exists:
.sdlc/intents/caique/— the intent and the design.- Open an issue if a prompt in your CLI cannot be expressed as a flag. That case is the interesting one.
Part of the burgee family: a CLI on burgee declares what it is, roundel carries its colours, flagstaff flies it, and caique answers back. caique installs one of them: closeout, because a prompt hides the cursor and owes it back however the process dies, and there is exactly one correct implementation of that. Nothing outside this repository is installed.
MIT © Ofri Peretz — see LICENSE.
Benchmarks
Every number here is produced by npm run bench and published at /docs/benchmarks.
Graded by the incumbent's own test suite:
| suite | passing |
|---|---|
clack | 14 / 17 |
inquirer-core | 41 / 41 |
Weight, installed and tree-inclusive: 122,362 bytes against 182,472 for the incumbents it replaces — a ratio of 0.6706 (@inquirer/core measured but left out of the ceiling, so it is understated).
Where it sits
Plugins register under the widgets key, against the one schema the whole family shares.
Nothing in this family builds on it yet, and it builds on closeout and linegauge.