npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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, plus color / disableColors.

Install

pnpm add @george43g/cli-kit commander

Node.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:

  • raw works. The tokenizer used to consume every quote character as shell quoting, so raw {"name":"x"} reached JSON.parse as {name:x} and threw. Since raw was the only route to a tool without a registered shortcut, that made those tools unreachable.
  • <tool> <json> dispatch exists. The docblock and help had advertised it for a long time; nothing implemented it. Any tool the dispatcher lists is now callable by name, so raw is 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 getLogs is 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.
  • parseConsoleInput is now exported and returns { cmd, rest, args }. rest is the remainder of the line verbatim — read JSON from it; args is 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:

  • runRepl is 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") is true, so CI=false yourtool somecmd was treated as running in CI and forced JSON output on a real terminal. Now matches ink's is-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, and semantic-release's default commit analyzer maps any breaking change to a major bump without clamping 0.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.1 is >=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=ts

Sizes 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.