cli-to-js: turning a binary's --help output into a typed JavaScript API
Turn any CLI into a JavaScript API
At a glance
- What is it?
- cli-to-js introspects a CLI with --help and returns a Proxy-based object where subcommands are methods and flags are options. It is aimed at agents and scripts that would otherwise build shell strings by hand, and it is explicitly experimental.
- Who is it for?
- Adopt cli-to-js when you are wiring an agent or a script to binaries whose help text is stable and whose flags you want validated before a process is spawned. Do not adopt it for tools that print non-standard help, for anything where a wrong flag has irreversible effects, or where you need a stable API surface: the README states the project is very experimental and that APIs may change without notice.
- Can I use it commercially?
- Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
- Is it still maintained?
- Yes. The repository last received commits 165 days ago.
- What is it written in?
- Mainly TypeScript, according to GitHub's language statistics.
Answers come from the project's GitHub data, last synced on September 29, 2026, and from our analysis. They are not legal advice.
Editorial analysis
The problem cli-to-js addresses: agents calling binaries without hand-written wrappers
Any tool that shells out to a binary has to know that binary's flag names, which flags take values, which take booleans, and which positionals are required. That knowledge normally lives in a hand-written wrapper, and it drifts every time the underlying tool changes its interface. cli-to-js proposes a different route: read the binary's own --help output at runtime and derive the calling surface from it. The README frames the audience narrowly. Agents need to call CLI tools, but they work best with structured APIs rather than raw shell strings, and cli-to-js lets an agent introspect any binary on the system, get a typed interface, and call it safely. The same argument holds for a human writing a build script, though the agent case is where the design choices make most sense: an agent that guesses a flag name wastes a process spawn, while an agent that can validate first and read a did-you-mean suggestion can correct itself in a single retry. The project is published on npm as cli-to-js, is written in TypeScript, and carries the MIT licence. The repository is a pnpm workspace with a packages/ directory and a root package.json named @cli-to-js/monorepo, so the published package is one of several workspace packages. The README opens with a warning that the project is very experimental and that APIs may change without notice. Treat that as the accurate description of its maturity rather than a formality.
How the Proxy, the parsed schema and $validate fit together
The entry point is convertCliToJs. According to the README, it runs --help on the binary, parses the output into a schema, and returns a Proxy-based API where every subcommand is a method and every flag is an option. So there are three layers. The first is a one-time introspection step that captures help text. The second is a parsed schema, reachable at $schema, describing commands and flags. The third is the Proxy, which turns property access like git.diff into a callable that assembles arguments and spawns the process. Option keys map to flags by convention: a camelCase key becomes a dashed flag, a boolean true emits the flag, a boolean false omits it, an array repeats the flag once per element, and the special _ key supplies positional arguments. That convention is the whole interface, and it is why the README can claim no codegen is needed for typing. Subcommand discovery is opt-in. By default only the root --help is parsed; passing { subcommands: true } parses every subcommand's help text and populates its flags, or you can call $parse("commit") to enrich one subcommand lazily. There is a real consequence here: $validate against a subcommand requires that subcommand to have been enriched first, either through the option or through $parse. Validation itself checks unknown flags with Levenshtein-based suggestions, type mismatches between boolean and value-taking flags, missing required positionals, and too many positionals, and returns an array of structured errors where empty means valid. The README's example is a misspelling: $validate("commit", { massage: "fix typo" }) returns an unknown-flag error with suggestion "message". That is the mechanism the agent story rests on. Nothing in this pipeline re-reads help text after the initial parse, so the schema is a snapshot of one binary version.
Install and a first real call
The README gives one install command, npm install cli-to-js. The root package.json declares engines of node >=22 and pnpm >=10 for working in the repository itself, so if you clone and build the monorepo rather than install from npm, expect Node 22 or newer and pnpm 10 or newer. The published package is what you import; the workspace scripts (pnpm -r build, pnpm -r test, pnpm -r typecheck) are for contributors.
Start by converting a binary you already have on the machine. The README's own example uses git:
import { convertCliToJs } from "cli-to-js";
const git = await convertCliToJs("git");
const { stdout } = await git.diff({ nameOnly: true, _: ["HEAD~1"] });
const changedFiles = stdout.trim().split("\n");That call assembles git diff --name-only HEAD~1. The await resolves to a CommandResult with stdout and exitCode, so you can read the raw string or the exit status directly.
Validate before you spawn. This is the step that distinguishes the library from a string builder, and it needs subcommands enabled for anything below the root:
const git = await convertCliToJs("git", { subcommands: true });
const errors = git.$validate("commit", { massage: "fix typo" });
// [{ kind: "unknown-flag", name: "massage", suggestion: "message", ... }]
if (errors.length === 0) {
await git.commit({ message: "fix typo" });
}You should see a non-empty errors array for the misspelling and an empty one once the key is corrected to message.
If you want a typed declaration file instead of runtime inference, the README documents a CLI of its own:
npx cli-to-js git --dts --subcommands -o git.d.tsThat writes a .d.ts generated from the parsed schema. For per-subcommand option types without codegen, pass a generic to convertCliToJs, as the README shows with commit and push shapes.
Streaming, command strings and the script() helper
Two execution modes exist beyond the default buffered call. The first is callbacks: pass { onStdout, onStderr } as a second argument and you receive output in real time while still getting the buffered CommandResult back when the promise resolves. The second is an async iterator. $spawn and spawnCommand return a CommandProcess whose iterator yields stdout lines, so a for await loop over the process consumes output as it arrives and proc.exitCode is awaited afterwards. For piping and long-running processes the iterator is the more natural shape than callbacks.
There is also a mode that never spawns anything. $command returns the shell string a call would produce, which is useful when you want to log, review or hand the command to something else. The README's example shows git.$command.commit({ message: "fix", all: true }) producing "git commit --message fix --all". Note the flag spelling there: --message, not --message=, and --all rather than a shorthand. The script() helper composes several such strings into one runnable unit, joined with &&, and deploy.run() executes them sequentially and stops on failure. That stop-on-failure behaviour is the documented contract, and it is the right default for deploy chains, though it also means a script cannot express "continue regardless" without you splitting it. The general shape of this API is a thin layer over child process spawning: the library decides the argument vector, and the underlying binary decides everything else, including exit codes, signal handling and output buffering.
Where cli-to-js breaks: help text is not a contract
The central limitation follows directly from the design. Everything depends on --help output, and --help output is written for humans, not parsed as a specification. A binary that prints usage in an unusual layout, hides flags from help, documents flags only in a man page, or changes its help text between versions will produce a schema that is incomplete or wrong. The README does not document a fallback for unparseable help, and it does not document how parse failures surface, so you should test against the exact binary version you intend to call. The README does state that commander-style aliases such as init|setup and add|install are handled and that the primary name is used, which tells you the parser targets a particular family of help formats rather than help text in general.
Validation has its own boundary. $validate checks flag names, boolean-versus-value types, and positional counts. It does not check whether a flag value is semantically valid, whether a path exists, or whether the command is safe to run. An agent that passes a correctly spelled but destructive flag gets a clean validation result and a spawned process. The README positions $validate as catching hallucinated flag names before spawning, which is a narrower guarantee than "safe to call" and should be read that way.
Finally, the README's own warning is the strongest limitation on the page: the project is very experimental and APIs may change without notice. There are no retrieved releases in the project's published history, and the repository uses Changesets (.changeset/, changeset version, changeset publish), which is a versioning workflow rather than a stability promise. If your integration must survive dependency upgrades without edits, this is the wrong tool today.
Alternatives and how their approach differs
The obvious alternative is a hand-written wrapper around child_process, or a small helper such as execa, where you spell out each command you support. The difference is where the knowledge lives. A wrapper encodes flags you chose, at the version you chose, and it fails loudly with a TypeScript error when someone passes a flag you did not declare. cli-to-js encodes whatever the installed binary advertises at runtime, which covers flags you never thought about and also covers flags that were removed. Wrappers are more work per command and far more predictable.
The second alternative is a CLI framework on the other side of the boundary. Commander is the relevant reference point because cli-to-js explicitly handles commander-style aliases, which means it is reading help text produced by that family of tools. A framework defines the interface and generates the help; cli-to-js consumes the help and reconstructs the interface. Using both is coherent: the framework owns the contract, and cli-to-js is a client for it.
The third alternative, for agents specifically, is to let the model emit shell strings and run them. That is what cli-to-js is arguing against, and the argument is concrete: a hallucinated flag becomes a spawned process and a confusing error instead of a structured error object with a suggestion. If your agent already has a reliable tool-calling schema for the handful of commands it needs, the wrapper route gives you the same safety with less runtime introspection.
Maintenance, licence and what upgrading costs you
The repository is not archived. Its last push was on 2026-04-17, which is more than six months before today, so there is no basis for describing it as actively maintained. Judge it on that date rather than on the topics list, which includes agent, api, cli and node.
The project is MIT licensed, which permits commercial use, modification and redistribution provided the copyright notice and permission notice are included. That is the licence text, not legal advice; if you redistribute the package inside a product, have your own counsel confirm the notice requirements are met.
Upgrade cost is the interesting part, and it is unusually low in one direction and unusually high in another. Because there is no codegen step required, upgrading the library does not force you to regenerate bindings; the schema is built at runtime from whatever binary is installed. But that same property means an upgrade of the underlying CLI, not of cli-to-js, can silently change your API surface. The repository uses Changesets with a release script that builds the workspace and publishes, so version bumps are managed, but the README warns that APIs may change without notice. Pin the package version, and treat the binary version as part of your dependency set. The README does not document rollback behaviour or a deprecation policy.
Editorial conclusion
Adopt cli-to-js when you are wiring an agent or a script to binaries whose help text is stable and whose flags you want validated before a process is spawned. Do not adopt it for tools that print non-standard help, for anything where a wrong flag has irreversible effects, or where you need a stable API surface: the README states the project is very experimental and that APIs may change without notice. Before you commit, run convertCliToJs against the exact binary version you ship and inspect $schema, because flag coverage depends entirely on what that version's --help prints.
Frequently asked questions
What is cli-to-js and what does it do?
It turns a CLI into a JavaScript API by running the binary's --help, parsing the output into a schema, and returning a Proxy-based object where subcommands are methods and flags are options. The README describes it as very experimental.
How do I install cli-to-js?
The README gives a single install command: npm install cli-to-js. The repository itself is a pnpm workspace that declares node >=22 and pnpm >=10 in its root package.json, which applies if you build from source rather than install the published package.
How does cli-to-js validate options before running a command?
$validate returns an array of structured errors and an empty array means the options are valid. It checks unknown flags with Levenshtein-based suggestions, boolean versus value-taking mismatches, missing required positionals and too many positionals. Subcommands must be enriched first via { subcommands: true } or $parse("name").
Can cli-to-js stream output from a long-running command?
Yes. You can pass onStdout and onStderr callbacks and still receive the buffered result, or use $spawn and spawnCommand, which return a CommandProcess with an async iterator that yields stdout lines and an exitCode you can await.
Does cli-to-js work with any CLI tool?
It depends on the tool's --help output, since that is the only source it parses. The README states it handles commander-style aliases like init|setup and uses the primary name, and it does not document a fallback when help text cannot be parsed, so unusual help formats are a risk.
Official sources
Add this badge to your README
If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.
[](https://hysenlabs.com/projects/millionco-cli-to-js)