@cnc-tools/cli-kit

中文文档
0.3.3发布于 2026/11/05MITTypeScriptES Module近 30 天下载 6090

Terminal look-and-feel for Node CLIs: capability detection, a small theme, a live-region stream sink, and a phase-run renderer

原始使用文档
文档目录11 节
  1. Install
  2. Entry points
  3. Clinkr applications
  4. Capabilities
  5. Theme
  6. Stream sink
  7. Phase-run quickstart
  8. Substeps
  9. Events
  10. Sequencing
  11. Collapsing tall checklists

@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

ImportWhat it gives you
@cnc-tools/cli-kit/capsCaps, resolveCaps, resolveProcessCaps, resolveStreamCaps, resolveSettledNonInteractiveCaps
@cnc-tools/cli-kit/ansistripAnsi
@cnc-tools/cli-kit/clinkrrenderOutcome, ui, path, muted, status, capsFor: cli-kit's look for a Clinkr application
@cnc-tools/cli-kit/themepaint, dim, bold, glyph, renderTable, renderResultBlock, statusLine, text helpers
@cnc-tools/cli-kit/streamcreateStreamSink, runStream: the live region and its non-TTY contract
@cnc-tools/cli-kit/phasesPhaseEvent, PhaseSpec, PhaseState, createPhaseStateStore (pure, no rendering)
@cnc-tools/cli-kit/phase-runstartPhaseRun, 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's ResultContent, and styled values where it has strings: in done, 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), and status(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.
  • ui is the application's UI with the default limits.
  • createOutcomeRenderer({ maximumListItems }) and createUi({ maximumListItems, maximumRecoverySteps }) make your own with other limits. Each limit is a positive safe integer; anything else throws a RangeError.

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-update live 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:

EventEffect
phases-declaredReplaces the title and the row set.
title-changedReplaces the title.
phase-startedMarks a row active, with an optional label.
phase-progressChanges an active row's label. history: "replace" drops the old label instead of keeping it.
phase-notesReplaces a row's notes: plain text shown beneath it, wrapped rather than cut.
phase-doneSettles a row done.
phase-skippedSettles a row skipped: it never ran.
phase-cancelledSettles a row cancelled: it was interrupted.
phase-failedSettles 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.

版本与发布记录

@cnc-tools/cli-kit 共发布 6 个版本,首次发布于 2026/10/09;过去 12 个月发布了 6 个版本。

版本发布日期距今
0.3.3latest2026/11/054 天前
0.3.22026/11/046 天前
0.3.12026/10/2911 天前
0.3.02026/10/091 个月前
0.2.02026/10/091 个月前
0.0.0-stage2026/10/091 个月前

关于 @cnc-tools/cli-kit 的常见问题

@cnc-tools/cli-kit 是什么?

Terminal look-and-feel for Node CLIs: capability detection, a small theme, a live-region stream sink, and a phase-run renderer

如何安装 @cnc-tools/cli-kit?

在项目目录运行 npm install @cnc-tools/cli-kit,或使用 pnpm add / yarn add / bun add。

@cnc-tools/cli-kit 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@cnc-tools/cli-kit 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@cnc-tools/cli-kit 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=22.19.0。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@cnc-tools/cli-kit 可以商用吗?

@cnc-tools/cli-kit 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@cnc-tools/cli-kit 的下载量有多少?

根据 npm 官方下载统计,@cnc-tools/cli-kit 在 2026/10/10 – 2026/11/08 这 30 天内被下载 6,090 次,日均约 203 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@cnc-tools/cli-kit 还在维护吗?

最新版本 0.3.3 发布于 2026/11/05(4 天前);至今共发布 6 个版本,其中过去 12 个月发布了 6 个;首次发布于 2026/10/09。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。