Wrap any Commander.js TS CLI as an importable @mirage-cli/<vendor> package for mirage runtimes and Cloudflare Workers. No subprocess, no tree introspection, no upstream forks needed in most cases — just import { firecrawlCommand } from "@mirage-cli/firecrawl" and register.
| Package | What it is |
|---|---|
@mirage-cli/core |
The in-process runner. Streaming stdout/stderr, ByteSource stdin, ALS-isolated calls. |
@mirage-cli/firecrawl |
Wraps the published firecrawl-cli npm package (scrape, crawl, map, search, …). Auto-parse interception included. |
dataforseo-cli |
DataForSEO CLI source (lives in this monorepo). Exports buildProgram(). |
@mirage-cli/dataforseo |
Thin wrapper around dataforseo-cli's buildProgram — buildProgram + dataforseoCommand. |
ahrefs-cli |
Ahrefs API v3 CLI source (lives in this monorepo). Exports buildProgram(). |
@mirage-cli/ahrefs |
Thin wrapper around ahrefs-cli's buildProgram — buildProgram + ahrefsCommand. |
Source packages vs wrapper packages. A *-cli package is the CLI itself (the binary + its programmatic API). A @mirage-cli/<vendor> package is the thin adapter that surfaces it as buildProgram + <vendor>Command for mirage / worker consumption.
For vendor CLIs you own, refactor them to export function buildProgram(): Command and the wrapper shrinks to ~15 LOC (see @mirage-cli/dataforseo and @mirage-cli/ahrefs). For third-party CLIs that auto-parse on import (firecrawl), the wrapper does a one-time Command.prototype.parseAsync capture (see @mirage-cli/firecrawl).
bun add @mirage-cli/dataforseo dataforseo-cli # both wrapper and source are published
bun add @mirage-cli/ahrefs ahrefs-cli
bun add @mirage-cli/firecrawl firecrawl-cliEach wrapper exports an async <vendor>Resource() factory that returns a mirage Resource. Drop it into a Workspace via addMount and the CLI is reachable as a general (resource-less) command — ws.execute("dfs --version") just works.
import { Workspace } from "@struktoai/mirage-node";
import { RAMResource } from "@struktoai/mirage-core";
import { dataforseoResource } from "@mirage-cli/dataforseo";
import { ahrefsResource } from "@mirage-cli/ahrefs";
import { firecrawlResource } from "@mirage-cli/firecrawl";
const ws = new Workspace({ "/": new RAMResource() });
ws.addMount("/cli/dfs", await dataforseoResource());
ws.addMount("/cli/ahrefs", await ahrefsResource());
ws.addMount("/cli/firecrawl", await firecrawlResource());
const r = await ws.execute("dfs --version");
console.log(r.exitCode, r.stdoutText); // 0 "0.3.0"<vendor>Resource() is async because it lazy-imports @struktoai/mirage-core — keeps that an optional peer dep, so non-mirage worker consumers (just want buildProgram/runCommander) can skip it.
import { buildProgram } from "@mirage-cli/dataforseo";
import { runCommander, streamCommander } from "@mirage-cli/core";
const program = buildProgram();
const result = await runCommander(program, ["keywords", "ideas", "--seed", "shoes"]);
// → { stdout: Uint8Array, stderr: Uint8Array, exitCode: 0, error: null }import { streamCommander } from "@mirage-cli/core";
import { buildProgram } from "@mirage-cli/dataforseo";
const program = buildProgram();
export default {
async fetch(req: Request) {
const argv = await req.json() as string[];
const { stdout, done } = streamCommander(program, argv);
return new Response(stdout, { headers: { "content-type": "text/plain" } });
}
};Our <vendor>Resource() factories construct RegisteredCommand directly instead of going through mirage's command() factory. The reason: mirage's command() runs every spec through withHelpSupport, which adds a --help option to the spec and wraps the fn so any --help in argv short-circuits to a spec-based help renderer. With our minimal spec (rest: TEXT + a one-line description), that renderer outputs essentially nothing useful — dfs: DataForSEO CLI and no subcommand list.
Bypassing the factory keeps --help in texts. Our CommandFn forwards the whole argv (including --help) to commander, which renders its full, real help — subcommand list, options, descriptions, the works. The RegisteredCommand is still registered into mirage normally via the Resource's commands() method.
Tradeoff: we lose mirage's spec-based autocomplete (if mirage adds one later). For execute-and-render workloads — which is what these wrappers are for — that's a clean win.
We target @struktoai/mirage-core@0.0.1 (latest npm, published 2026-05-06). The mirage repo has unreleased changes on main — the API may evolve before the next publish. When mirage cuts a new release we'll re-validate and bump the peer dep. The structural shapes we rely on (Resource, CommandSpec, Operand/OperandKind, IOResult, RegisteredCommand constructor, MountRegistry.mount) have been stable across versions.
A worked end-to-end example using a real Workspace: examples/mirage-workspace.ts.
-
@mirage-cli/corepatchesprocess.{stdout,stderr,stdin,exit}andconsole.*once on first call, routes throughAsyncLocalStorageper-invocation, and returnsReadableStreams the caller can plug straight into aResponse. Concurrent calls in the same isolate stay isolated. -
Each
@mirage-cli/<vendor>wrapper does the minimum work needed to convert "an npm-published CLI that auto-parses on import" into "aCommandinstance you can hand tostreamCommander." Typical shape: monkey-patch the vendor's commander prototype to capture the program on its firstparseAsynccall, plant the env vars needed to skip interactive auth, dynamic-import the CLI, cache and return.
bun install
bun test # all packages
bun typecheck
bun changeset # author a release intent
bun release # build + publishMIT (matches upstream mirage-commander).