@cnc-tools/cli-kit
Terminal look-and-feel for Node CLIs: capability detection, ANSI stripping, a small theme, a live-region stream sink, and a phase-run renderer for multi-step commands.
A phase run shows a checklist that updates in place on a TTY:
Deploying web
✓ Build bundle ready
compiling…
⠹ Push Pushing layer 3/5
uploading…
• Verify pending
sha256:3f2a… done
Superseded labels stay beneath their row as dim history. When output is piped or captured, the run prints one plain settled frame instead.
The kit's only runtime dependency is log-update, and only the stream and phase-run entry points
load it. The clinkr entry point draws for a Clinkr
application and imports @vercel/clinkr, an optional peer dependency; no other entry point does.
Install
npm install @cnc-tools/cli-kit
Requires Node 22.19 or newer. The package is ESM-only and ships compiled JavaScript with type declarations.
Entry points
| Import | What it gives you |
|---|---|
@cnc-tools/cli-kit/caps | Caps, resolveCaps, resolveProcessCaps, resolveStreamCaps, resolveSettledNonInteractiveCaps |
@cnc-tools/cli-kit/ansi | stripAnsi |
@cnc-tools/cli-kit/clinkr | renderOutcome, ui, path, muted, status, capsFor: cli-kit's look for a Clinkr application |
@cnc-tools/cli-kit/theme | paint, dim, bold, glyph, renderTable, renderResultBlock, statusLine, text helpers |
@cnc-tools/cli-kit/stream | createStreamSink, runStream: the live region and its non-TTY contract |
@cnc-tools/cli-kit/phases | PhaseEvent, PhaseSpec, PhaseState, createPhaseStateStore (pure, no rendering) |
@cnc-tools/cli-kit/phase-run | startPhaseRun, createPhaseRun, and the frame, lifecycle, and tail building blocks |
Clinkr applications
Clinkr resolves the terminal, applies the color precedence, chooses streams, and writes JSON and what a script reads.
cli-kit draws what a person sees. Call its renderOutcome in every result's render:
import { muted, path, renderOutcome } from "@cnc-tools/cli-kit/clinkr";
// In a command's outcomes:
render: ({ worktreePath, slotName }) =>
renderOutcome({ done: ["Moved to", path(worktreePath)], rows: [["Slot", slotName ?? muted("—")]] }),
Pass its ui to createApplication so every error, Clinkr's own and the application's, and canceled share the
look. The layout is cli-kit's own: a 2-column gutter, label/value rows with values from column 18, ✓ for the completed
result, ! for warnings, ✗ for an error headline, at most 5 list items, and at most 2 recovery steps. With color off
it prints that layout with no escape codes; with color on it paints glyphs, labels, values, and the table header for
the stream each part goes to. Glyphs fall back to ASCII when the locale can't render Unicode.
renderOutcome(content)renders a result's content with the default limits. Content takes Clinkr'sResultContent, and styled values where it has strings: indone, rows, sections, and table cells. A table cell may also be Clinkr's link,{ text, url }: with color on it draws as an OSC 8 hyperlink over its text in accent, a link's color, its column measured on the text; with color off, and in a pipe, it is the text alone.path(text),muted(text), andstatus(text, tone)are the styled values: a file system path, recessive text such as a placeholder, and a status word in a tone. Each keeps its plain text for JSON and for what a script reads.uiis the application's UI with the default limits.createOutcomeRenderer({ maximumListItems })andcreateUi({ maximumListItems, maximumRecoverySteps })make your own with other limits. Each limit is a positive safe integer; anything else throws aRangeError.
Capabilities
Every renderer takes Caps explicitly and returns a string, so output is deterministic in tests.
import { resolveCaps, resolveProcessCaps, resolveStreamCaps } from "@cnc-tools/cli-kit/caps";
const caps = resolveProcessCaps(); // reads process.stdout and process.env
const stderrCaps = resolveStreamCaps(process.stderr); // for progress drawn on stderr
const testCaps = resolveCaps({ isTty: false, columns: 100, env: {} });
isTty and columns describe the stream you resolved against. A CLI that keeps stdout
machine-readable and draws progress on stderr should resolve caps from process.stderr.
Color degrades from truecolor to 256 and 16 colors and finally to none. A nonempty NO_COLOR turns
color off and outranks FORCE_COLOR. A non-TTY gets no color unless FORCE_COLOR asks for it. Glyphs
fall back to ASCII when the locale, LC_ALL, then LC_CTYPE, then LANG, is not UTF-8; an empty one
counts as unset. Use resolveSettledNonInteractiveCaps() for sinks whose
terminal affordances are unknown, such as callbacks and captured output.
Theme
import { renderResultBlock, renderTable, cell } from "@cnc-tools/cli-kit/theme";
console.log(renderResultBlock(caps, { kind: "success", headline: "Deployed.", body: "3 services" }));
console.log(
renderTable({
caps,
columns: [
{ header: "SERVICE", width: "auto" },
{ header: "STATUS", width: "fill" },
],
rows: [[cell("web"), cell("ok")]],
}).join("\n"),
);
statusLine renders one phase row. Its name column pads to 13 by default; pass nameWidth to
change it.
Layout measures terminal cells, not string length. terminalWidth counts CJK and most emoji as two
cells and combining marks as zero, and truncation never splits a grapheme. It is an approximation:
terminals still disagree about some emoji and ambiguous-width characters.
Stream sink
createStreamSink(caps, deps) renders frames it is handed. It does not sequence work.
- TTY: redraws a
log-updatelive region, hides and restores the cursor, and spins held frames every 90 ms. Live redraws suspend terminal autowrap so a stale width cannot leave orphan rows. - Non-TTY: no animation, no cursor codes, no redraw escapes. Transient lines go to
onOutput. The settled frame is written exactly once, stripped of ANSI. Hosts that capture output depend on this.
Every seam is injectable: writer (defaults to stdout through log-update), clock (defaults to
setTimeout), onOutput (defaults to writing each transient line through writer, so a writer
pointed at stderr carries transients too), and dwellMs, the pacing rule for each hold. The default dwells one
base tick; pass your own rule to linger on slow steps:
const sink = createStreamSink(caps, {
dwellMs: (transient, tickMs) => (transient?.startsWith("Uploading") ? tickMs * 3 : tickMs),
});
Use runStream(caps, deps, body) to get the cursor restored even when body throws.
sink.emitTransient(line) surfaces one transient line at once: a non-TTY sink passes it to
onOutput, and a TTY sink writes nothing, so the live region stays the only writer. A throw from
onOutput reaches the caller of emitTransient synchronously; through hold, it rejects the
returned promise.
sink.finish(finalLines, { persist: false }) erases the live region instead of persisting it, and a
non-TTY sink writes no settled frame. Only finalLines are written. A custom writer implements
clear() for this path.
Phase-run quickstart
import { startPhaseRun } from "@cnc-tools/cli-kit/phase-run";
const run = startPhaseRun({
title: "Deploying web",
phases: [
{ key: "build", name: "Build", label: "compiling…", detail: "bundle ready" },
{ key: "push", name: "Push", label: "uploading…", detail: "image pushed" },
{ key: "verify", name: "Verify", label: "checking health…", detail: "healthy" },
],
});
try {
run.start("build");
run.done("build");
run.start("push");
run.progress("push", "Pushing layer 3/5");
run.note("sha256:3f2a… done\n");
run.done("push");
await run.finish({ finalLines: ["Deployed web."] });
} finally {
await run.stop();
}
finish settles every phase still open. If you call fail() first, the failed row stays red and
later phases stay pending. skip(key, detail?) and cancel(key, detail?) settle a row as skipped
(–) or cancelled (⊖, for interrupted work). note feeds raw subprocess output into a dim tail line on a TTY, so the
live region stays the only writer. startPhaseRun also accepts caps, writer, clock,
onOutput, dwellMs, showRuntimeDetails (settled rows show the detail passed to done),
nameWidth, sequencing, and collapse.
Pass finish({ persist: false }) when the live checklist is transient and something else, such as a
report on stdout, is the permanent record. The live region is erased and no settled frame is
written, on a TTY or elsewhere.
If the TTY spinner fails in the background (a throwing writer.redraw or a rejecting
clock.sleep), the run records the first error, stops redrawing, and never lets it escape as an
unhandled rejection. finish still attempts the settled frame and cursor restoration, and stop
still attempts restoration; both then reject with that spinner error. Restoration through a broken
writer can fail, so the finally above matters: a later stop retries it.
If painting the first frame throws, startPhaseRun (or run.begin) attempts cursor restoration,
then throws the paint error, even when restoration also fails. startPhaseRun returns no session
then, so the quickstart's finally never runs; after a failed run.begin, run.stop() retries
restoration. Outside a TTY, a throwing onOutput (or the default writer.write)
throws synchronously from the helper or emit call that surfaced the transient line, after phase
state has advanced; the finally still runs stop.
Substeps
A phase's substeps declares one level of child rows. Substeps do not nest: the PhaseSpec type
rejects a substep with substeps, and the state store throws on one at construction or on a
phases-declared event, which then leaves the title and rows unchanged.
Events
A producer reports progress with these PhaseEvents:
| Event | Effect |
|---|---|
phases-declared | Replaces the title and the row set. |
title-changed | Replaces the title. |
phase-started | Marks a row active, with an optional label. |
phase-progress | Changes an active row's label. history: "replace" drops the old label instead of keeping it. |
phase-notes | Replaces a row's notes: plain text shown beneath it, wrapped rather than cut. |
phase-done | Settles a row done. |
phase-skipped | Settles a row skipped: it never ran. |
phase-cancelled | Settles a row cancelled: it was interrupted. |
phase-failed | Settles a row failed, with the cause as its label. |
Use history: "replace" for fast-changing status, such as a stage name inside a step. The row's
history keeps only the labels you meant to keep, and a non-TTY run logs no transient line for it.
Use phase-notes for text a reader needs in full, such as why a step retried. History and the tail
line are cut to the terminal width; notes wrap, with continuation lines indented two more cells. A
row keeps its notes after it settles, and they change nothing about its state.
Sequencing
The default sequencing: "implicit" suits linear workflows. Starting a row marks every earlier open
row done, settling a parent settles its open substeps, and a failed substep fails its parent.
A run that continues past failures, such as a test runner, should pass sequencing: "explicit".
Each row then settles only through its own event. Starting a substep activates a pending parent but
settles nothing else. A failed substep leaves its parent alone, and finish marks every row still
open as skipped, whatever failed. The producer and every store that renders its events must use the
same mode.
Collapsing tall checklists
collapse: true keeps long checklists short. A settled row renders as one line, plus any substeps
that failed or carry notes. A pending row hides its substeps until it starts, and only active rows
expand. Inside an active row, settled substeps drop their history. Frames taller than the terminal still lose their top rows to log-update's clipping.
If your workflow already emits PhaseEvents, use createPhaseRun({ caps, phases, deps }) and feed
events to run.emit(event). For a custom display, such as a host widget, use
createPhaseStateStore from phases and render its views yourself.