Discovery
Back to browse

cli-to-js - turn any CLI into a JS API

Wraps any command-line tool as a typed JavaScript API agents can call directly. Saves writing a custom MCP for every CLI you want to expose.

5 min readView source ↗

cli-to-js answers a question that comes up the moment you try to build agent tooling: "I need the agent to call this CLI - do I really have to wrap every flag and subcommand by hand?" The library reads --help on any binary, parses the output, and hands you back a typed JavaScript object where subcommands are methods, flags are options, and everything just works.

The author marks the project experimental up front, but the shape of it is the right shape. For an agent that needs to drive git, claude, kubectl, or anything else CLI-shaped, this saves you writing a custom MCP server or a hand-rolled wrapper for every single tool.

import { convertCliToJs } from "cli-to-js";

const git = await convertCliToJs("git");
const claude = await convertCliToJs("claude");

const { stdout } = await git.diff({ nameOnly: true, _: ["HEAD~1"] });
const changedFiles = stdout.trim().split("\n");

for (const file of changedFiles) {
  const review = await claude({
    print: true,
    model: "sonnet",
    _: [`Review ${file} for bugs and suggest fixes`],
  });
  if (review.stdout.includes("no issues")) continue;
  console.log(`${file}:`, review.stdout);
}

That's a real working example from the README - the git diff and claude --print paths both go through the same Proxy-based API that was generated from --help parsing.

Why this matters for agents

The piece that makes this more than a developer convenience: $validate catches hallucinated flag names before spawning a process. The model says --massage instead of --message? You get a structured error back with a Levenshtein-based suggestion the agent can self-correct from in one retry, instead of a confused subprocess and a wasted turn.

For multi-turn agent loops driving CLIs, that single feature is the difference between "this works" and "this barely works."

Quick start

npm install cli-to-js

The default usage runs --help on the binary, parses the output into a schema, and returns a Proxy where every subcommand is a method:

import { convertCliToJs } from "cli-to-js";

const api = await convertCliToJs("my-tool");

const result = await api.build({ output: "dist", minify: true });
// -> my-tool build --output dist --minify

console.log(result.stdout);
console.log(result.exitCode);

The flag mapping is the part that's just opinionated enough to be useful and predictable:

JS optionCLI output
{ verbose: true }--verbose
{ verbose: false }(omitted)
{ output: "file.txt" }--output file.txt
{ dryRun: true }--dry-run
{ v: true }-v
{ include: ["a", "b"] }--include a --include b
{ _: ["file.txt"] }file.txt

camelCase becomes kebab-case, single-letter keys become short flags, the _ array becomes positional arguments. No surprises.

TypeScript types, two ways

Out of the box, every subcommand returns Promise<CommandResult> with proper typing on $schema, $parse, $spawn. No codegen needed for the basic surface.

For tighter per-subcommand types, pass a generic:

const git = await convertCliToJs<{
  commit: { message?: string; all?: boolean; amend?: boolean };
  push: { force?: boolean; setUpstream?: string };
}>("git");

git.commit({ message: "hello" });    // autocompletes
git.push({ foobar: true });           // type error

Or generate a .d.ts from the parsed schema and check it in:

npx cli-to-js git --dts --subcommands -o git.d.ts

The latter is the right move for production agents - you commit the types and the agent gets autocomplete plus validation without re-parsing --help on every cold start.

Subcommand parsing

By default only the root --help is parsed. Enable subcommands: true to walk all subcommand help texts up front:

const git = await convertCliToJs("git", { subcommands: true });

Or parse on demand:

const git = await convertCliToJs("git");
const commitSchema = await git.$parse("commit");
await git.$parse();   // parse all discovered subcommands

Handles commander-style aliases like init|setup and add|install - the primary name is canonical.

Validation - the agent-safety story

Before running a command, validate options against the parsed schema:

const errors = git.$validate("commit", { massage: "fix typo" });
// [{
//   kind: "unknown-flag",
//   name: "massage",
//   suggestion: "message",
//   message: 'Unknown flag "massage". Did you mean "message"?'
// }]

if (errors.length === 0) {
  await git.commit({ message: "fix typo" });
}

The validator catches:

  • Unknown flags with Levenshtein-based did-you-mean suggestions.
  • Type mismatches - boolean flags getting values, value-taking flags getting booleans.
  • Missing required positionals.
  • Too many positionals.

Empty array means valid. Non-empty array means the agent should retry with the suggestions instead of spawning a guaranteed-to-fail process.

When to reach for it

  • You're building agent tooling that needs to call multiple CLIs and don't want to hand-wrap each one.
  • You want types and validation for CLI surfaces that change between versions of the underlying tool.
  • You're testing CLI tooling from JS and want a typed surface instead of child_process.spawn strings.
  • You'd otherwise write a custom MCP server for every CLI you want an agent to drive.

When not to

  • One-shot scripts. child_process.spawn is shorter.
  • CLIs with broken or non-standard --help output. The parser is good but it's not magic.
  • Workflows where you specifically want raw stdout/stderr handling that doesn't fit the CommandResult shape (rare; if it applies, you'll know).

Trade-offs to know

The "experimental" label at the top of the README is real - APIs may change without notice. Pin a specific version if you build production agent infrastructure on this.

--help parsing is heuristic by nature. Highly customised help output (rich formatted tables, banners, footers) sometimes confuses the parser. The fromHelpText(name, helpTextString) escape hatch lets you pre-process the help text yourself if needed:

import { fromHelpText } from "cli-to-js";

const api = fromHelpText("my-tool", helpTextString);
await api.build({ watch: true });

For agents at scale, the right pattern is probably: generate the .d.ts once, commit it, refresh on tool upgrades. That's how you get the typing benefit without paying the --help parsing latency on every cold start.

The $validate step is the killer feature for agent reliability - if you only take one thing from this library, take that. Wrap every agent-issued CLI call in a validate-then-spawn pattern and you'll cut a meaningful fraction of "agent typed the wrong flag" failures.

Related entries