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.
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));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;
failureis that one, with acodeofUSAGEorCANCELLED, amessageand afix. - A spec that cannot be drawn is a usage error — a
selectwith no choices is refused before anything is asked, rather than drawn as an empty list and waited on. - The input is not mutated.
valuescomes 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.
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}`);
}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.interactiveis the host's answer — a terminal on stdin, noCI, no agent — andctx.jsonistrueunder--jsonand in every--mcptool 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
--schemasees. Not the prompt: it is left out of the command's JSON, because a program is never asked. Declare prompts throughpromptFor, so this holds.
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.
The terminal
createIo() reads lines from a real terminal and hides a password without turning echo off; askList() adds arrow keys where there is a terminal, and puts raw mode back exactly as it found it.