Skip to content

Run coding CLIs as agents

Many teams run the vendors’ own agent CLIs on the subscriptions they already pay for. @warlock.js/ai-cli drives those CLIs as first-class agents: it launches one headless session per run, reads the CLI’s machine-readable stream, and turns it into one event model for every vendor. You get the final text, usage and a session reference to resume, plus cancellation that kills the CLI’s whole process tree.

Fourteen vendors ship as built-in adapters — Claude Code, Codex, Antigravity, Qwen Code, Kimi Code, Cursor, Copilot, Grok Build, OpenCode, Amp, Factory Droid, Mistral Vibe, Aider and Reasonix. See Vendor adapters for each one’s flags, stream format and how it was verified. To support another CLI, write an adapter.

Terminal window
npm install @warlock.js/ai @warlock.js/ai-cli

The package installs no vendor CLI. Install the ones you use yourself (for example npm install -g @anthropic-ai/claude-code), and log in or provide an API key.

import "@warlock.js/ai-cli";
import { ai } from "@warlock.js/ai";
const claude = ai.cli.claudeCode({ model: "sonnet", apiKey: process.env.ANTHROPIC_API_KEY });
const session = await claude.run("Fix the failing test in src/math.spec.ts", {
cwd: "/srv/acme-api",
});
for await (const event of session.events) {
if (event.type === "message" && event.delta) process.stdout.write(event.text);
if (event.type === "tool.call") console.log("\n→", event.name, event.input);
}
const result = await session.done();
console.log(result.status, result.text, result.usage, result.sessionRef);

Importing the package registers ai.cli. The same namespace is exported directly as cli (import { cli } from "@warlock.js/ai-cli"). There is one factory per vendor: cli.claudeCode, cli.codex, cli.antigravity, cli.qwenCode, cli.kimi, cli.cursor, cli.copilot, cli.grok, cli.opencode, cli.amp, cli.droid, cli.mistralVibe, cli.aider and cli.reasonix. cli.custom(adapter, options) takes your own adapter.

A factory takes defaults; run(prompt, options) overrides them for one run.

OptionWhereWhat it does
cwdbothWorking directory of the run (default process.cwd()).
modelbothPassed to the CLI’s model flag.
effortbothFramework effort (none … max), mapped to the vendor’s scale. Refused by CLIs without an effort control.
permissionboth"standard" (default) or "bypass" — see Permissions.
resumebothA sessionRef from an earlier run.
mcpbothPer-run MCP servers, written to a 0600 config file — see MCP servers.
apiKeybothExported as the adapter’s API-key variable (for example ANTHROPIC_API_KEY). Never placed in argv.
envbothExtra environment for the CLI (wins over inherited variables).
argsbothExtra argv, appended after the adapter’s flags. Do not put secrets here.
signalbothCancels the run and kills the process tree.
timeoutMsbothKills the process tree after this long (status timeout).
homefactory"private" (default), an absolute path you own, or "inherit" — see Config homes.
commandfactoryExecutable to run instead of the default (a name on PATH or an absolute path).
inheritEnvfactorytrue (default), false (minimal system set only) or a list of names to pass on top of the minimal set.
probefactoryRun the capability probe before the first run (default true).
killGraceMsfactoryGrace between SIGTERM and SIGKILL on POSIX (default 2000).

session.events is an async iterable of CliEvent, discriminated on type. Every iteration replays from the first event, so several consumers can read it, and it ends after session.ended.

typeFields
session.startedsessionRef?, model?
messagetext, delta (streamed fragment or complete message), final?
tool.callid, name, input
tool.resultid (pairs with the call), output, isError
usageusage (input, output, total, cache/reasoning tokens, costUSD?), cumulative
errormessage, fatal? (a fatal error ends the session failed)
session.endedstatus, exitCode, signal, sessionRef?, durationMs

Every event except session.ended also carries raw, the vendor’s original record, for debugging.

Not every CLI streams everything: some print whole messages only (no deltas), some report usage only at the end, Kimi and Copilot print no token counts in the stream, and Aider has no machine-readable output at all. The vendor table lists what each one provides.

session.done() resolves once the process has exited and the stream is drained. It never rejects; branch on status.

const result = await session.done();
switch (result.status) {
case "completed":
console.log(result.text); // the vendor's final record, else the last assistant message
break;
case "failed":
case "timeout":
console.error(result.error, result.exitCode, result.stderr);
break;
case "cancelled":
break;
}

run() itself rejects with a CliError only for setup problems, before anything starts. error.type is one of:

  • not-found — the executable is not on PATH.
  • capability — the installed version’s --help lacks a flag the run needs; see Capability probe.
  • probe-failed — the CLI’s help could not be read.
  • unsupported-option — the adapter cannot honour an option.
  • prompt-transport — the prompt fits neither argv nor stdin on this platform.

CliError extends the framework ProviderError.

const first = await (await claude.run("Plan the refactor of src/billing")).done();
const second = await claude.run("Now implement step 1", { resume: first.sessionRef });
const controller = new AbortController();
const third = await claude.run("Run the full test suite", {
signal: controller.signal,
timeoutMs: 600_000,
});
setTimeout(() => controller.abort(), 30_000); // or: third.cancel()
console.log((await third.done()).status); // "cancelled"

Cancellation and timeouts kill the whole process tree: the CLI, and every shell, language server and MCP server it started. A run whose signal is already aborted never spawns anything.

permission defaults to "standard": the CLI runs with its own non-interactive rules. Most CLIs refuse a tool call that would need a human approval, and none of them hang waiting for one. "bypass" switches on the vendor’s “approve everything” mode, such as --dangerously-skip-permissions, --dangerously-bypass-approvals-and-sandbox, --yolo or --always-approve. Only opt into bypass for a run you would let execute any command.

What "standard" allows depends on the vendor:

  • Read-only by default: Codex (its read-only sandbox) and Droid (read-only autonomy).
  • Permissive by default: OpenCode and Amp.
  • No standard mode: Kimi Code always approves everything in print mode, so its adapter refuses "standard" and requires an explicit "bypass".

The vendor table spells out each CLI.

const session = await cli.codex().run("Summarize open issues", {
mcp: {
tracker: {
type: "stdio",
command: "npx",
args: ["-y", "@acme/tracker-mcp"],
env: { TRACKER_TOKEN: process.env.TRACKER_TOKEN ?? "" },
},
docs: {
type: "http",
url: "https://mcp.acme.dev/mcp",
headers: { Authorization: `Bearer ${process.env.DOCS_TOKEN}` },
},
},
});

Servers use one vendor-neutral shape, { type: "stdio", command, args, env } or { type: "http" | "sse", url, headers }, and each adapter renders it into its CLI’s own format.

Tokens never reach argv. They are written into a mode-0600 file inside the run’s private directory, or inside the private home for CLIs that only read MCP from their home. That file is removed when the run ends. CLIs that read MCP only from their config home (Kimi, Grok, Cursor, Droid, Antigravity, Mistral Vibe, Reasonix) need the default private home for runs with mcp. With a shared home they fail closed rather than write tokens into your own config. Aider has no MCP support.

cli.model(agent, config), or the shorthand agent.model(config), wraps a CLI agent in a ModelContract. ai.agent, and its budget, guardrail, observer and fallback middleware, can then drive a CLI:

import { ai, budget } from "@warlock.js/ai";
import { cli } from "@warlock.js/ai-cli";
const reviewer = ai.agent({
model: cli.codex({ cwd: "/srv/acme-api" }).model({ name: "codex-reviewer" }),
middleware: [budget({ maxTokens: 200_000 })],
});
const { text, usage } = await reviewer.execute("Review the last commit for bugs.");

How the model behaves:

  • One fresh session per call. The conversation is rendered into one prompt: system messages first, then a single user turn as-is or a labelled transcript.
  • The answer is the session’s final text, and usage maps onto the framework Usage.
  • The CLI runs its own tools. A call that carries agent tools is refused; give the CLI MCP servers instead.
  • Effort from reasoning.effort is forwarded for adapters with an effort control.
  • Streaming yields the final answer once the session ends. For live progress, read run().events.
  • A failed run throws a CliError (type: "run-failed"), so fallbackModel can switch to another model.
  • Pass pricing (per million tokens) to get Usage.cost and USD budgets.