@george43g/cli-kit
v2.0.1
Published
Commander helpers + TTY/color/output utilities + env↔flag binder + interactive REPL. Building blocks for the `example-repo-cli` bin in any tool cloned from the starter.
Maintainers
Readme
@george43g/cli-kit
Commander building blocks for local MCP-adjacent CLIs: a preconfigured program factory, output-mode resolution, an env↔flag binder, TTY predicates, and an interactive REPL.
The package includes:
buildProgram()— a Commander program with the starter's global flags already wired:--json,-q/--quiet,-v/--verbose,--no-color.resolveOutputMode()/printAuto()/printJson()/printTable()— human-vs-JSON output resolution and the renderers that follow from it. Explicit requests (json,human) outrank the inferred signals (non-TTY,CI).bindEnvFlags()/applyEnvFromFlags()— declare a flag once and have the matching environment variable stay in sync.runRepl()/parseConsoleInput()— a dispatcher-driven interactive shell. Any tool the dispatcher lists is callable as<tool> <json>; shortcuts add positional-argument aliases on top. Works over a pipe as well as a terminal.isInteractive(),isCI(),isStdoutTTY(),colorEnabled()and friends — TTY/CI detection, pluscolor/disableColors.
Install
pnpm add @george43g/cli-kit commanderNode.js 24 or later and ESM are required.
commander is a peer dependency, not a bundled one. buildProgram()
returns a Command and applyEnvFromFlags() accepts one, so Commander's types
cross the public API boundary — if the consumer resolved a second copy, those
types would be structurally similar but nominally distinct, and instanceof
checks against it would fail. Declaring it as a peer makes the single shared
instance explicit. (ink and react are peers of @george43g/tui-kit for the
same reason.)
Upgrading from 0.1.x
commander moved from a regular dependency to a peer in 0.2.0. Add it to your
own package.json if it is not already there — most consumers of this package
already depend on Commander directly.
The REPL also changed, all of it fixes:
rawworks. The tokenizer used to consume every quote character as shell quoting, soraw {"name":"x"}reachedJSON.parseas{name:x}and threw. Sincerawwas the only route to a tool without a registered shortcut, that made those tools unreachable.<tool> <json>dispatch exists. The docblock andhelphad advertised it for a long time; nothing implemented it. Any tool the dispatcher lists is now callable by name, sorawis a fallback rather than the only route.- Backslash escapes are honoured, so
foo "she said \"hi\""yields one argument containing a literal quote. - The command word keeps its case, so a tool named
getLogsis reachable. Built-ins (help,quit, …) still match case-insensitively. - EOF exits. A piped or redirected stdin used to run out of input and leave the returned promise unsettled, hanging the process.
parseConsoleInputis now exported and returns{ cmd, rest, args }.restis the remainder of the line verbatim — read JSON from it;argsis the shell-style split, for positional shortcuts.
What arrived in 0.3.1
Everything in this section shipped in 0.3.1, which is a patch version
carrying four additive API changes — the release was mis-typed as a fix because
the headline was the bug below, and semantic-release reads the commit type, not
the diff. Nothing here is breaking, so ^0.3.0 consumers are unaffected; the
version simply under-signals what it contains.
Piped multi-command input works. Up to and including 0.3.0, runRepl read
lines with a recursive rl.question(). That arms a one-shot listener, so
while an async command was being awaited no listener existed and every line
readline had already buffered was emitted into nothing:
printf 'help\ntools\nquit\n' | mytool console # 0.3.0: ran only `help`EOF then closed the stream cleanly, so the loss was silent. Input is now consumed through a serial queue that also waits for the queue to drain before resolving at EOF. Two independent consumers reported this; if you worked around it by invoking one command per process, you can stop.
Four additions to runRepl, all opt-in and all backwards compatible:
| Addition | What it does |
|---|---|
| formatResult(result) | Pretty-print a successful result yourself. Receives the whole result, so it can read structuredContent. |
| showMeta: true | Print a dim · 12ms · engine=ts footer after each call, from the dispatcher's _meta. Both duration_ms and dur_ms are read. |
| json built-in | Toggles raw structuredContent output. Outranks formatResult — the point is to see what the tool actually returned. |
| last-error built-in | Reprints the last error, whether it came back as an isError result or was thrown while parsing. |
ToolCallResult gained structuredContent?: unknown and
_meta?: Record<string, unknown> to carry what those read. Both are optional,
so existing dispatchers still typecheck.
Fixed after 2.0.0: piped REPL output, and CI=false
Two fixes worth upgrading for, both reported by a downstream consumer:
runReplis now pipe-safe. The banner, the prompt, and readline's echo of piped input all went to stdout, so… | yourtool repl | jq .could never work. When stdin is not a TTY the REPL now emits only results. Interactive use is unchanged — it keeps readline's history, arrows and SIGINT handling.isCI()parses the value, not just the presence.Boolean("false")istrue, soCI=false yourtool somecmdwas treated as running in CI and forced JSON output on a real terminal. Now matches ink'sis-in-ci, with one deliberate difference: an empty value counts as not CI.
No API change in either.
2.0.0 is a no-op major — there is nothing to migrate from 1.0.0
If you are on 1.0.0, upgrading to 2.0.0 is a range edit and a lockfile
update. No code change, no API change. The published dist/ of 2.0.0 is
byte-identical to 1.0.0; the only differences in the tarball are this README and
the version field:
$ diff -r cli-kit-1.0.0/package/dist cli-kit-2.0.0/package/dist
(no output)It was published from a documentation-only commit. The commit body described the
0.4.0-vs-1.0.0 incident below and spelled a release-control marker in its prose;
semantic-release reads that marker anywhere in a body, including inside a
sentence about a past event, and cut a major from a docs change. The version is
immutable, and renumbering would mean a third major for zero API change — so
it stands. A CI check now rejects that marker in a message unless the subject
declares a breaking change too.
The real migration is 0.3.x → 1.0.0, below. If you are coming from 0.3.x,
do that one and land on 2.0.0 directly.
Upgrading from 0.3.x to 1.0.0
Why 1.0.0 and not 0.4.0. This release was intended as
0.4.0. It carries a single breaking change, andsemantic-release's default commit analyzer maps any breaking change to a major bump without clamping0.x— so the!marker took it to 1.0.0. The content below is the release that was planned; only the number is different.What the number now means for you, and it is not cosmetic: on
0.x, a caret range locks the MINOR (^0.3.1is>=0.3.1 <0.4.0), so every minor was an explicit opt-in. On^1.0.0, minors and patches arrive automatically. From here a breaking change must be a major.
One breaking change: ToolCallResult.content is now a discriminated union.
// before
content?: Array<{ type: string; text: string }>
// after
content?: ContentBlock[] | undefined
export type ContentBlock =
| { type: "text"; text: string }
| { type: "image"; data: string; mimeType: string };The old shape could not describe any MCP server with an image tool: every block
was required to carry text, so a screenshot had to be cast or faked. Two
consumers reported it under-modelled within a day of each other, which is what
moved it from "usage problem" to "the type is wrong".
What breaks: reading .text off a block without narrowing.
const text = result.content?.[0]?.text; // TS2339 from 1.0.0
const first = result.content?.[0]; // narrow instead
const text = first?.type === "text" ? first.text : undefined;That error is the point, and it lands exactly where a decision is needed: at the
render site, which now has to say what it does with a non-text block. The union
is deliberately closed — no { type: string; … } catch-all. A catch-all
overlaps type: "text", so narrowing would need a cast everywhere, and it would
silently accept block shapes nothing can render.
What you probably do not need to change: if you let the REPL render results,
nothing. runRepl now prints non-text blocks itself, one line each, in the order
the dispatcher returned them:
[image image/jpeg, 61.4 KB]
{"saved": true}
· 12ms · engine=tsSizes are decoded bytes, not base64 characters — the raw string length
overstates the real size by 4/3 and is not a unit anyone can act on. Block order
is preserved because it is a dispatcher contract: a dispatcher that appends its
text block last means a screenshot arrives as [image, text], and reordering
would misreport what the tool returned. The meta footer always comes last.
resource and audio blocks are not modelled yet — no known caller emits them,
and guessing their shape from the spec rather than from a real producer is how
the text-only version got written. If one arrives at runtime the renderer prints
a [resource] placeholder rather than crashing.
Also in 1.0.0, non-breaking: the optional fields are declared
?: T | undefined rather than ?: T, so a consumer compiling with
exactOptionalPropertyTypes can pass a result through verbatim instead of
rebuilding it with conditional spreads to avoid isError: undefined.
Basic usage
import { buildProgram, printAuto } from "@george43g/cli-kit";
const program = buildProgram({
name: "my-tool",
description: "Does the thing",
version: "1.0.0",
});
program.command("list").action(() => {
printAuto(
items,
{ head: ["Name", "Status"], rows: (i) => [i.name, i.status] },
program.opts(),
);
});
await program.parseAsync();Output mode
resolveOutputMode picks JSON or human form by this precedence, highest first:
| # | Signal | Result |
|---|---|---|
| 1 | json: true (bind to --json) | json |
| 2 | human: true (bind to --human / --no-json) | human |
| 3 | FORCE_HUMAN set to anything but 0/false/empty | human |
| 4 | stdout is not a TTY | json |
| 5 | CI is set | json |
| — | otherwise | human |
Levels 4–5 mean piping into jq needs no extra flag. Levels 2–3 are the
inverse that used to be missing: without them the human view was unreachable
the moment stdout was not a terminal, so mytool list | less was impossible
and testing a renderer meant running the CLI under a pty. Two contradictory
explicit requests resolve to json, on the grounds that something asking for
--json is probably a pipeline.
Stability
From 1.0.0 this package follows semver strictly: breaking changes require a major bump. Minors are additive, patches are fixes.
That is a stronger promise than the pre-1.0 one it replaces, and it changes how
upgrades reach you. Under 0.x a caret range locked the minor, so every minor
was an explicit opt-in; under ^1.x minors and patches arrive on a plain
pnpm update. Pin exactly if you need the old behaviour.
License
MIT — see LICENSE.
