# 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.

Source: https://caique.interlace.tools/docs

**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](https://burgee.interlace.tools/docs/compatibility); what is still to come
follows [`.sdlc/intents/caique/`](https://github.com/ofri-peretz/burgee/tree/main/.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.

```js
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:

```js
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:

```js
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:

```js
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:

```js
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.
- **`--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 suite | Cases | Their own package | `caique` |
| :-- | --: | --: | --: |
| [`@inquirer/core` 12.0.3](https://github.com/SBoudrias/Inquirer.js) | 41 | 41 (100%) | **0 (0.0%)** |
| [`@clack/prompts` 1.8.1](https://github.com/bombshell-dev/clack) | 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](https://burgee.interlace.tools/docs/compatibility).

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/`](https://github.com/ofri-peretz/burgee/tree/main/.sdlc/intents/caique)
  — the intent and the design.
- [Open an issue](https://github.com/ofri-peretz/burgee/issues) if a prompt in your CLI
  cannot be expressed as a flag. That case is the interesting one.

---

Part of the [burgee](https://github.com/ofri-peretz/burgee) family: a CLI on
[burgee](https://www.npmjs.com/package/burgee) declares what it is,
[roundel](https://www.npmjs.com/package/roundel) carries its colours,
[flagstaff](https://www.npmjs.com/package/flagstaff) flies it, and caique answers back.
caique installs one of them: [closeout](https://www.npmjs.com/package/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](https://github.com/ofri-peretz/burgee/blob/main/packages/caique/LICENSE).

## Benchmarks

Every number here is produced by `npm run bench` and published at [/docs/benchmarks](https://burgee.interlace.tools/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,447 bytes** against **182,472** for the incumbents it replaces — a ratio of **0.6710** (@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`.
