@j-o-r/cli
v3.1.0
Published
This project provides a command line interface (CLI) for creating interactive dialogs
Maintainers
Readme
Interactive CLI Framework
A CLI framework for building interactive command-line dialogs, built around one principle:
The terminal is a single-cursor resource with exactly one owner at a time. When the user owns it (free text, confirm, choose), nothing else may write — the cursor is occupied by the pending input. When the app owns it, it writes freely. The library — not the app — manages the turn alternation.
Features
- Turn-based terminal ownership — the loop alternates user's turn / app's turn automatically; apps never re-arm input manually
- Buffered output — writes during the user's turn are buffered and flushed when the app's turn begins; nothing prints into the input line, nothing is silently lost
- One
ask()primitive withconfirm()/choose()wrappers — nestable - Role-based output styling (log, system, error, assistant, tool, custom)
- Custom key mappings — a mapped key runs its handler as an app turn (live output; questions work inside handlers)
- Programmatic input —
inject()submits a line as if typed (initial context, agent handoff) - Built-in
>edit</>paste</>#!bash<commands (opt-in subpath) - Spinner, clear, exclusive-TTY execution for editors and pagers
- Zero runtime dependencies in the core; no side effects at import
Installation
npm install @j-o-r/cliThe core is dependency-free. The optional built-in commands need
@j-o-r/sh and @j-o-r/cache (optional peer dependencies):
npm install @j-o-r/sh @j-o-r/cache # only for the builtins subpathQuick Start
import cli from '@j-o-r/cli';
cli.onInput(async (text) => { // required: the app's turn
cli.role('assistant');
cli.write(`echo: ${text}`);
});
cli.start({ prompt: '> ' }); // starts the turn loopThe loop is: user's turn (ask) → app's turn (onInput) → repeat. Input is
re-armed automatically — there is no focus() call and no mode juggling.
The buffered-output rule
Output follows terminal ownership:
- App's turn —
write/log/errorprint live, with the current role's prefix. - User's turn — the same calls are buffered (e.g. a
setTimeoutfiring while a question is pending) and flushed in order when the app's turn begins. Nothing prints into the input line; nothing is dropped.
The spinner is a no-op during the user's turn.
Questions
ask is the single input primitive; confirm and choose build on it.
All three are nestable inside onInput (and inside key handlers). All
resolve undefined when interrupted (key handler / stop()).
cli.onInput(async (text) => {
const name = await cli.ask('What is your name? '); // string
const ok = await cli.confirm(`${name}, are you sure? `); // boolean (y/yes/n/no)
const pick = await cli.choose('Why? ', ['a', 'b', 'c']); // selected item
cli.log({ name, ok, pick });
});ask(prompt, validate) accepts an optional validator:
{ ok: true, value } accepts, { ok: false } erases the line and re-asks.
API Reference
| Method | Description |
|--------|-------------|
| onInput(fn) | Register the input handler — the app's turn (required before start) |
| onExit(fn) | Register the exit handler (default: process.exit) |
| onKeys(keys) | Register key mappings ({ name, ctrl, meta, shift, handler }); active during the user's turn — a mapped key runs its handler as an app turn (live output, working questions). Ctrl+u / Ctrl+e are reserved |
| setRole(name, prefix, color) | Register or overwrite an output role |
| handleExit() | Opt-in signal handling: SIGINT/SIGTERM/SIGQUIT/SIGHUP run onExit; uncaught exceptions/rejections print loudly and exit(1) |
| start({ prompt }) | Start the turn loop (prompt defaults to '> ') |
| stop() | End the loop and release the terminal; does not process.exit |
| role(name) | Set the current output role (styling state only — nothing prints until the next write) |
| write(text, newline = true) | Write at the current role (buffered during the user's turn) |
| log(any) | One-line log-role shortcut |
| error(err) | One-line error-role shortcut; Error objects print their stack |
| clear() | Clear the screen |
| startSpinner() / stopSpinner() | Spinner control (stopped automatically when the user's turn begins; startSpinner() is a no-op during the user's turn) |
| ask(prompt, validate?) | Free-text question → string (undefined when interrupted) |
| confirm(prompt) | Yes/no question → boolean |
| choose(prompt, items) | Numbered-list selection → chosen item (undefined for an empty list) |
| execute(fn) | Run fn with exclusive TTY ownership (editors, pagers); restores CLI state afterwards |
| addCommand(name, fn) | Register a >name args< command (single-line input only); a returned non-empty string replaces the input line and becomes the app's input |
| inject(text) | Inject a programmatic input line — echoed as if typed, processed as an app turn; only queues during an app/key-handler turn |
createCli() is exported as a named export for additional isolated
instances; the default export is one shared instance (no side effects at
import).
Keys
cli.onKeys([
{ name: 'd', ctrl: true, handler: async () => { cli.stop(); process.exit(0); } },
{ name: 'r', ctrl: true, handler: async () => cli.clear() }
]);A mapped key ends the user's turn and runs the handler as an app turn:
output prints live, ask/confirm/choose work inside handlers, and
the loop waits for the handler before re-arming input. A handler that needs
the exclusive terminal (editor, pager) wraps its work in cli.execute(...).
Custom roles
cli.setRole('debug', '[DBG] ', 'cyan'); // color: ANSI name from the built-in table
cli.role('debug');
cli.write('Debug message'); // prefixed [DBG] in cyanThe built-in user role is special: its color styles every typed input
line (prompt + typed echo, and the "as if typed" echoes of inject() and
command results) — input never inherits a previously active role's color.
The default is reset; override with e.g.
cli.setRole('user', '> ', 'brightWhite'). The role's prefix is used only
for role('user') output writes; the input prompt text comes from
start({ prompt }) / ask(prompt).
Built-in commands
Registration is opt-in — import the subpath once:
import '@j-o-r/cli/builtins';Without it, >...< in user input is plain text passed to onInput.
>edit<— the editor as a multiline input field; the edited text becomes the input. Works inline:This is my prompt but i want to >edit<>paste<— clipboard content (xclip) into the input, reviewed in the editor>#!bash cmd args<— run a shell command, embed its output in a fenced block inside the input, reviewed in the editor
Every builtin ends in an editor review (exclusive TTY via execute); the
reviewed text replaces the original input line (on a TTY the echoed line is
erased first — best effort: a wrapped line erases only its last row) and is
handed to onInput as if typed. A >cmd< marker is recognized on a
single input line only — multiline input is plain text. Editor resolution is lazy
(first use, no shell calls at import): ~/.selected_editor → $EDITOR →
which vim → which nano; when no editor is found the command shows a
user-facing error and re-asks.
Shell safety:
>#!bash<executes raw shell. Arguments come straight from user input — never feed it untrusted input.
Exit handling
Nothing is registered globally at import or first use. Call handleExit()
to opt in: signals run your onExit handler (default process.exit), and
crashes print the error before exiting (a 5 s force-exit backstops hanging
cleanup). stop() only ends the loop — the app owns the process lifecycle.
Migrating from v2 to v3
v3 replaces mode-based focus juggling with the turn model. Mechanical mapping:
| v2 | v3 |
|----|----|
| cli.inputHandler = async (input) => ... | cli.onInput(async (text) => ...) |
| cli.exitHandler = async (code) => ... | cli.onExit(async (code) => ...) |
| cli.focus('assistant') | cli.role('assistant') (styling only — no printing, no turn switch) |
| cli.focus() to re-arm input | delete — the loop re-arms automatically |
| cli.question(prompt) | await cli.ask(prompt) |
| cli.yesNo(prompt) | await cli.confirm(prompt) |
| cli.select(prompt, choices) | await cli.choose(prompt, items) |
| cli.registerKeyMappings(keys) | cli.onKeys(keys) (same mapping shape) |
| programmatic input (manual inputHandler(s) calls) | cli.inject(text) — echoed as if typed, processed as an app turn |
| cli.start(mode, input) | cli.start({ prompt }) |
| auto-registered process hooks | cli.handleExit() — opt-in |
| MODES / restoreMode / the input flag | gone — output roles + turn state replace them |
Behavioral changes to watch:
- Output during the user's turn is buffered, not dropped (v2 silently
dropped log output while asking). Expect deferred
log/erroroutput to appear when the app's turn begins. stop()no longer exits the process; pair it with your own exit logic (e.g. in a Ctrl+d key handler oronExit).- Unhandled rejections are loud when
handleExit()is used (v2 swallowed them silently). - Built-ins moved to the
@j-o-r/cli/builtinssubpath; without that import,>edit<-style input is plain text. @j-o-r/toolsetis no longer used;@j-o-r/shand@j-o-r/cacheare optional peer dependencies (only the builtins need them).
License
Apache-2.0 — see LICENSE.
