cli-to-js: turning --help output into a typed JavaScript API
Turn any CLI into a JavaScript API
At a glance
- What is it?
- cli-to-js runs a binary's --help, parses it into a schema, and returns a Proxy where subcommands are methods and flags are options. It is explicitly experimental, and its accuracy is bounded by how well a tool documents itself.
- Who is it for?
- Adopt cli-to-js if you are building an agent or script that has to drive several different binaries and you want flag validation before a process is spawned. Do not adopt it for a single CLI you control, or for any tool whose --help is terse, since the schema is only as good as the help text.
- 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 151 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 15, 2026, and from our analysis. They are not legal advice.
DEEP OPEN-SOURCE ANALYSIS
The problem: agents call binaries through shell strings
The README frames the motivation narrowly: agents need to call CLI tools, and they work best with structured APIs rather than raw shell strings. A model that emits `git diff --name-only HEAD~1` has no way to know whether `--name-only` is a real flag on the installed version until the process runs and fails. cli-to-js moves that check earlier. The target audience is people writing agent loops or Node scripts that shell out to tools they did not write and cannot change. If you own the CLI, you already know its flags and you can export a typed wrapper yourself; the value here is in the long tail of binaries you do not own.
How the schema gets built from --help
convertCliToJs runs `--help` on the named binary, parses the output into a schema, and returns a Proxy-based API. That is the whole mechanism, and it is worth being blunt about the consequence: the schema is a parse of human-written help text, not a machine-readable interface. By default only the root `--help` is parsed. Passing `subcommands: true` also parses each subcommand's help text and populates its flags, so `git.$schema.command.subcommands.find((s) => s.name === "commit")?.flags` returns the flags for commit. You can defer that work with `git.$parse("commit")` for one subcommand, or `await git.$parse()` for all discovered ones. The README notes commander-style aliases such as `init|setup` are handled, with the primary name used. Option keys are translated on the way out: `{ verbose: true }` becomes `--verbose`, `{ verbose: false }` is omitted entirely, `{ dryRun: true }` becomes `--dry-run`, `{ v: true }` becomes `-v`, arrays repeat the flag (`--include a --include b`), and the `_` key supplies positionals. That false-is-omitted rule is a real design decision: you cannot pass an explicit negative flag through this mapping, only the absence of one.
Installing it and getting a first call to run
Installation is a single command: `npm install cli-to-js`. The quick start is three lines. Import `convertCliToJs`, await it with a binary name, then call a subcommand as a method: `const result = await api.build({ output: "dist", minify: true })` which the README says maps to `my-tool build --output dist --minify`. The return value carries `result.stdout` and `result.exitCode`. If you already have help text in hand and do not want the binary lookup, `fromHelpText("my-tool", helpTextString)` builds the same API from a string. For editor support there is a CLI of its own: `npx cli-to-js git --dts --subcommands -o git.d.ts` writes a declaration file from the parsed schema. The README also shows a generic parameter for per-subcommand option types, where `git.commit({ message: "hello" })` autocompletes the message as a string and `git.push({ foobar: true })` is a type error. Note that the generic is a hand-written type you supply; it is not generated from the schema at compile time.
$validate is the part that earns its keep
Validation runs against the parsed schema before anything is spawned. `git.$validate("commit", { massage: "fix typo" })` returns `[{ kind: "unknown-flag", name: "massage", suggestion: "message", message: 'Unknown flag "massage". Did you mean "message"?' }]`. An empty array means valid. The README states the checks cover unknown flags with Levenshtein-based suggestions, type mismatches such as boolean versus value-taking, missing required positionals, and too many positionals. Two constraints matter here. Subcommand validation requires that subcommand to have been enriched first, either through `subcommands: true` at construction or an explicit `$parse("name")` call, otherwise there is no schema to check against. Root-level validation is different: you pass options directly, as in `git.$validate({ unknownFlag: true })`. There is also a standalone `validateOptions(schema.command, { verbose: "wrong" })` for use against any ParsedCommand you already hold. The suggestion field is the interesting output, because it gives an agent something concrete to retry with rather than a bare failure.
Reading output, composing commands, and streaming
Every command returns a CommandPromise with `.text()`, `.lines()`, and `.json()`. The README's examples are `await git.branch({ showCurrent: true }).text()` returning a string, `await git.diff({ nameOnly: true, _: ["HEAD~1"] }).lines()` returning an array, and `await npm.outdated({ json: true }).json<Record<string, { current: string }>>()` for typed JSON. The `.json()` generic is a cast you assert, not something inferred from the command. If you want the string without running it, `git.$command.commit({ message: "fix", all: true })` returns `"git commit --message fix --all"`. The `script()` helper joins several of those into one runnable object: `deploy.run()` executes them sequentially and stops on failure, and stringifying it produces `"git commit --message deploy --all && git push"`. For long-running processes there are two paths. You can pass `onStdout` and `onStderr` callbacks to a normal awaited call and still get the buffered result. Or use `api.$spawn.test({ _: ["--watch"] })`, which returns a CommandProcess whose async iterator yields stdout lines, with `await proc.exitCode` available after the loop.
Where the help-text approach breaks
The README's own warning is the first limitation: the project is very experimental and APIs may change without notice. There are no releases listed in the repository metadata, so there is no version history to pin against. Beyond that, the failure mode is structural. A binary with no `--help`, or with help text that omits flags, produces a schema missing those flags, and `$validate` will then reject a correct call as an unknown flag. Tools that use subcommand-style flags, `--` separators, or flags whose meaning depends on position are not described anywhere in the material, so treat them as unverified. The `{ verbose: false }` to omitted mapping means boolean flags that accept an explicit false value cannot be expressed. And the Levenshtein suggestion is a string-distance heuristic: it will suggest a plausible-looking wrong flag when the real one is absent from the parsed schema. This is the wrong tool when you control the CLI, because a hand-written wrapper is exact and costs about the same effort as debugging a parse.
Against writing wrappers by hand, and against execa
The obvious alternative is a thin process wrapper such as execa, or Node's own child_process, plus a hand-written function per command. The difference is where the knowledge lives. execa gives you `execa('git', ['diff', '--name-only', 'HEAD~1'])` and does not care what those arguments mean; you are responsible for the flag names, and a typo surfaces as a non-zero exit code at runtime. cli-to-js moves flag knowledge into a schema derived from the binary itself, which is the only reason `$validate` can return a suggestion before spawning. That trade is only worth taking when the binary is not yours and the set of binaries is large. When it is one binary you maintain, execa plus a typed wrapper is more predictable, because there is no parse step between you and the process. The other distinction is output handling: execa returns buffers and streams you shape yourself, while cli-to-js layers `.text()`, `.lines()`, and `.json()` on top and adds `$command` and `script()` for building command strings without executing them.
Maintenance, licence, and what to check first
The repository is MIT licensed, which permits commercial use, modification, and redistribution provided the copyright notice and permission notice are retained. That is a summary of the identifier, not legal advice; read the LICENSE file if the terms matter to your distribution. The maintenance signal available here is the last push date of 2026-04-17 and the absence of retrieved releases, which together suggest active commits without a tagged version to depend on. Practically, that means pinning to a commit rather than a caret range, since the README warns the API may change without notice. The upgrade cost is concentrated in the surface you actually use: `convertCliToJs`, `fromHelpText`, `validateOptions`, `script`, and the `$schema`, `$parse`, `$validate`, `$spawn`, `$command` members. A schema shape change would break code that reads `$schema.command.subcommands` directly. Before adopting, run the conversion against each binary you intend to drive and read the resulting schema for the specific subcommands you call.
Editorial conclusion
Adopt cli-to-js if you are building an agent or script that has to drive several different binaries and you want flag validation before a process is spawned. Do not adopt it for a single CLI you control, or for any tool whose --help is terse, since the schema is only as good as the help text. Before relying on it, run convertCliToJs against your target binary with subcommands: true and inspect $schema for the commands and flags you actually call, then check that $validate catches a deliberately misspelled flag.
Community notes