Skip to content

millionco/cli-to-js

Repository files navigation

cli-to-js

Warning: This project is very experimental. APIs may change without notice.

Turn any CLI into a JavaScript API, automatically.

Give it a binary name, it reads --help, and hands you back a fully typed object where subcommands are methods, flags are options, and everything just works.

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

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

// Get the files that changed in the last commit
const { stdout } = await git.diff({ nameOnly: true, _: ["HEAD~1"] });
const changedFiles = stdout.trim().split("\n");

// Ask Claude to review each changed file
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);
}

Why this matters for agents.

Agents need to call CLI tools, but they work best with structured APIs, not raw shell strings.

cli-to-js lets an agent introspect any binary on the system, get a typed interface, and call it safely. $validate catches hallucinated flag names before spawning a process and returns did-you-mean suggestions the agent can self-correct from in a single retry.

$spawn returns a standard async iterator, so streaming and piping is just a for await loop.

Install

npm install cli-to-js

Quick start

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

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

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

// Subcommand as a method
const result = await api.build({ output: "dist", minify: true });
// → my-tool build --output dist --minify

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

Here's how JS option keys map to CLI flags:

JS option CLI 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

TypeScript

The API is fully typed out of the box. Every subcommand returns Promise<CommandResult>, and $schema, $parse, $spawn are all properly typed. No codegen needed.

For per-subcommand option 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" }); // message autocompletes as string
git.push({ foobar: true }); // type error

Or generate a .d.ts from the parsed schema:

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

From a help text string

If you already have the help text, skip the binary lookup:

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

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

Subcommand parsing

By default, only the root --help is parsed. Enable subcommands to also parse every subcommand's help text and populate its flags in the schema:

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

// Schema now includes each subcommand's flags
const commitFlags = git.$schema.command.subcommands.find((s) => s.name === "commit")?.flags;

Or parse on demand:

const git = await convertCliToJs("git");

// Parse one subcommand lazily
const commitSchema = await git.$parse("commit");
console.log(commitSchema.flags);

// Parse all discovered subcommands
await git.$parse();

Handles commander-style aliases (init|setup, add|install). The primary name is used.

Validation

Validate options against the parsed schema before running a command. Returns an array of structured errors (empty means valid).

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

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" });
}

Checks for unknown flags (with Levenshtein-based suggestions), type mismatches (boolean vs value-taking), missing required positionals, and too many positionals.

For root command validation, pass options directly:

const errors = git.$validate({ unknownFlag: true });

For subcommand validation, the subcommand must be enriched first (via subcommands: true or $parse("name")).

Or use validateOptions directly with any ParsedCommand:

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

const errors = validateOptions(schema.command, { verbose: "wrong" });

Output parsing

Every command returns a CommandPromise with .text(), .lines(), and .json() for typed output:

const branch = await git.branch({ showCurrent: true }).text();
// "main"

const files = await git.diff({ nameOnly: true, _: ["HEAD~1"] }).lines();
// ["src/index.ts", "src/utils.ts"]

const packages = await npm.outdated({ json: true }).json<Record<string, { current: string }>>();
// { "lodash": { current: "4.17.20" }, ... }

You can also use the raw CommandResult directly:

const result = await git.status();
result.stdout; // raw string
result.exitCode; // number

Command strings

$command returns the shell string instead of executing:

git.$command.commit({ message: "fix", all: true });
// "git commit --message fix --all"

git.$command.push({ force: true });
// "git push --force"

Compose multiple commands into a runnable script with script():

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

const deploy = script(git.$command.commit({ message: "deploy", all: true }), git.$command.push());

deploy.run(); // executes sequentially, stops on failure
console.log(`${deploy}`); // "git commit --message deploy --all && git push"

Streaming

Callbacks

Get real-time output while still receiving the buffered result:

const result = await api.build(
  { watch: true },
  {
    onStdout: (data) => process.stdout.write(data),
    onStderr: (data) => process.stderr.write(data),
  },
);

Async iterator

spawnCommand and $spawn return a CommandProcess with raw streams and an async iterator that yields stdout lines:

const proc = api.$spawn.test({ _: ["--watch"] });

for await (const line of proc) {
  console.log(line);
}

console.log("exited with:", await proc.exitCode);

Or use spawnCommand directly:

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

const proc = spawnCommand("npm", ["run", "dev"]);
for await (const line of proc) {
  if (line.includes("ready")) console.log("Server is up");
}

stdio inherit

Pass stdio through to the parent terminal for interactive CLIs:

await api.login({}, { stdio: "inherit" });

Per-call config

Every method accepts an optional second argument for execution config:

const controller = new AbortController();

await api.build(
  {},
  {
    cwd: "/my/project",
    env: { NODE_ENV: "production" },
    timeout: 60_000,
    signal: controller.signal,
  },
);

CLI

Generate a standalone JS/TS wrapper for any CLI tool:

npx cli-to-js git                          # TypeScript to stdout
npx cli-to-js git -o git.ts               # write to file
npx cli-to-js git --js -o git.js          # plain JavaScript
npx cli-to-js git --subcommands -o git.ts  # include per-subcommand flags
npx cli-to-js git --dts -o git.d.ts       # generate type declarations only
npx cli-to-js git --json                   # dump raw schema as JSON

The generated code is standalone. It embeds a tiny runtime (spawn + options-to-args) and has zero dependencies on cli-to-js. Drop it into any project and it just works.

API

convertCliToJs<T>(binary, options?)

Runs --help, parses the output, returns the API proxy. Accepts an optional generic T for per-subcommand option types.

Option Type Default Description
helpFlag string "--help" Flag to get help text
timeout number 10000 Timeout for help text fetch (ms)
cwd string - Default working directory for all commands
env ProcessEnv - Default environment for all commands
subcommands boolean false Parse all subcommand help texts eagerly

fromHelpText<T>(binary, helpText, options?)

Same as convertCliToJs but from a static help text string. Accepts cwd and env options.

API proxy (CliApi<T>)

The returned proxy is both callable and has subcommand methods:

Access Description
api.sub({ flag: val }) Run subcommand, returns CommandPromise
api.sub(opts).text() Run and get trimmed stdout
api.sub(opts).lines() Run and get stdout as string[]
api.sub(opts).json<T>() Run and parse stdout as JSON
api("sub", { flag: val }) Run subcommand by name
api({ flag: val }) Run root command
api.$schema Parsed CliSchema
api.$validate(opts) Validate options against root schema
api.$validate("sub", opts) Validate options against subcommand
api.$command.sub(opts) Get shell string without executing
api.$spawn.sub(opts) Spawn subcommand, get CommandProcess
api.$parse("sub") Lazily parse a subcommand's help text
api.$parse() Parse all subcommand help texts

RunConfig

| Option | Type | Default | Description | | ---------- | ------------------------ | ---------- | ------------------------- | ---------- | | timeout | number | 30000 | Command timeout (ms) | | signal | AbortSignal | - | Abort signal | | cwd | string | - | Working directory | | env | ProcessEnv | - | Environment variables | | stdio | "pipe" | "inherit" | "pipe" | stdio mode | | onStdout | (data: string) => void | - | Real-time stdout callback | | onStderr | (data: string) => void | - | Real-time stderr callback |

Color output (FORCE_COLOR, CLICOLOR_FORCE) is auto-detected. It's enabled when streaming callbacks are provided and the parent process is connected to a TTY. To force it manually, pass env: { ...process.env, FORCE_COLOR: "1" }.

CommandResult

interface CommandResult {
  stdout: string;
  stderr: string;
  exitCode: number;
}

CommandProcess

Returned by spawnCommand and $spawn:

interface CommandProcess {
  stdin: Writable | null;
  stdout: Readable | null;
  stderr: Readable | null;
  pid: number | undefined;
  kill: (signal?) => boolean;
  exitCode: Promise<number>;
  [Symbol.asyncIterator](): AsyncIterableIterator<string>;
}

License

MIT © Million Software, Inc.

About

Turn any CLI into a JavaScript API

Topics

Resources

License

Stars

Watchers

Forks

Contributors