caique

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.

SubpathBytesReaches
caique (everything)25,627no package at all
caique/spec1,571a leaf — declare prompts without loading a widget
caique/decide4,986the spec only
caique/ask8,564the six widgets, no terminal, no raw mode
caique/raw14,796line mode, which it sits on top of
caique/binding15,475the decision and the widgets
caique/terminal11,975the 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.
  • --interactive asks for every missing required option in one pass; --yes accepts every confirmation; cancellation exits CANCELLED and restores the terminal.
  • Accessible mode falls back to line input with no live redraw.
  • A migration path from @inquirer/prompts and @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 legacy inquirer.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/core is 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 suiteCasesTheir own packagecaique
@inquirer/core 12.0.34141 (100%)0 (0.0%)
@clack/prompts 1.8.1606576 (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 yetcaique 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:


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:

suitepassing
clack14 / 17
inquirer-core41 / 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.

On this page