caique
Guides

Binding prompts to a CLI

resolvePrompts() is the pass a framework runs once flags, environment and config have had their turn: it asks in declaration order, only what is missing, and stops at the first refusal.

resolvePrompts() from caique/binding is the pass a CLI framework calls after every other source of a value — flags, environment, config, defaults — has had its turn. A CLI on burgee runs it from its preAction hook; any other framework calls it with the same four things.

binding.mjs
import { resolvePrompts } from 'caique/binding';

import { scripted } from './scripted.mjs';

const options = {
  name: { required: true, prompt: { kind: 'text', message: 'Project name?' } },
  force: { required: true, prompt: { kind: 'confirm', message: 'Overwrite?' } },
  template: { required: true, prompt: { kind: 'select', message: 'Template?', choices: [{ value: 'cli' }, { value: 'lib' }] } },
  description: { prompt: { kind: 'text', message: 'Description?' } },
};
const terminal = { env: {}, isTTY: { stdin: true, stdout: true } };

const asked = await resolvePrompts({ options, values: { name: 'demo' }, runtime: terminal, flags: { yes: true }, io: scripted(['2']) });
console.log(JSON.stringify(asked));

const agent = { env: {}, isTTY: { stdin: false, stdout: false } };
const refused = await resolvePrompts({ options, values: { name: 'demo' }, runtime: agent, flags: { yes: true }, io: scripted([]) });
console.log(JSON.stringify(refused));
node binding.mjs
Template?
  1) cli
  2) lib
  enter a number (1-2): 2
{"values":{"name":"demo","force":true,"template":"lib"}}
{"values":{"name":"demo","force":true},"failure":{"option":"template","code":"USAGE","message":"--template is required when there is no terminal","fix":"pass --template; it would have been asked as \"Template?\""}}

On the terminal, name was supplied so it was not asked, --yes answered the confirm, the select was asked, and the optional description was left alone. (--yes answers every confirm, optional ones included.) For the agent, the same pass refused at template — the first option it would have had to ask about.

What it guarantees

  • Declaration order. Options are asked in the order they were declared, which is the order the help lists them.
  • Only what has to be asked. Every option goes through decide() first.
  • The first refusal stops it. A caller about to exit is better served by one actionable message than by six; failure is that one, with a code of USAGE or CANCELLED, a message and a fix.
  • A spec that cannot be drawn is a usage error — a select with no choices is refused before anything is asked, rather than drawn as an empty list and waited on.
  • The input is not mutated. values comes back as a new object with the answers written in.

One binding, not one per framework

What the pass needs is a record of options, the values so far, a runtime and an io. None of it is any framework's types, so caique imports none, and the same pass serves commander, yargs, burgee or a hand-rolled parser.

Wrapping a command: promptFor

caique/prompt-for puts that pass where it belongs, in front of a command's handler. promptFor(command, 'name', spec) declares the prompt on the option or positional called name and wraps the handler: before it runs, every declared prompt with no value is asked for, or refused, and the handler never sees a missing value. promptFor(command) does the same for the prompt: a command already declares.

greet.mjs
import { promptFor } from 'caique/prompt-for';

const greet = promptFor(
  { arguments: [{ name: 'name' }], run: ({ args }) => `Hello, ${args.name}!` },
  'name',
  { kind: 'text', message: 'Who should I greet?' },
);

// What a framework hands its handler. burgee builds this context; any other framework can too.
const ctx = (over) => ({ options: {}, args: {}, positionals: [], env: process.env, interactive: false, json: false, ...over });

console.log(await greet.run(ctx({ args: { name: 'ada' }, positionals: ['ada'] })));
try {
  await greet.run(ctx());
} catch (refusal) {
  console.log(`${refusal.code}: ${refusal.message}`);
  console.log(`fix: ${refusal.fix}`);
}
node greet.mjs
Hello, ada!
USAGE: <name> is required when there is no terminal
fix: pass <name>; it would have been asked as "Who should I greet?"

The refusal names the value the way it is passed: <name> for a positional, --dry-run for an option keyed dryRun. On burgee it leaves with exit 2, and a cancelled question with exit 4.

  • Who is asked. ctx.interactive is the host's answer — a terminal on stdin, no CI, no agent — and ctx.json is true under --json and in every --mcp tool call, where nobody is there to type.
  • Where. On stdin, with the question on stderr, so it never lands in the data on stdout.
  • For how long. The terminal is opened only to ask, handed to the host's exit path while it is open, and closed before the handler runs. Ctrl+C at the question cancels it and gives the terminal back out of raw mode.
  • What a program reading --schema sees. Not the prompt: it is left out of the command's JSON, because a program is never asked. Declare prompts through promptFor, so this holds.

On this page