What ActiveCLI is
ActiveCLI spawns coding agent CLIs as real processes and drives them through one interface. It does not call a vendor’s HTTP API, and it does not depend on an official SDK. The CLI that the user already has installed and logged into is the thing that runs.
That choice is deliberate. A CLI is a published, documented contract that a user can also invoke by hand. An internal protocol is not. When the library depends only on what the CLI itself offers, anything a user can do in a terminal remains reachable from a program.
Why ActiveRecord, and why twice
Section titled “Why ActiveRecord, and why twice”The design comes from Rails’ ActiveRecord, and it borrows two separate things from it.
The adapter. ActiveRecord defines an AbstractAdapter and ships one concrete subclass per database — mysql2, pg — so an application talks to the adapter it was given. Changing databases is choosing a different subclass, not rewriting every call site.
The relation. ActiveRecord builds a query by accumulating intent and executing at the end: where(...).limit(...) changes nothing until to_a runs, and to_sql shows what would run without running it. Command is the same arrangement for a command line — setters accumulate, toArgv() shows, send() executes.
| ActiveRecord | ActiveCLI |
|---|---|
Relation |
Command |
where(...).limit(...) — accumulates |
setModel(...).setMessage(...) — accumulates |
to_sql — shows without running |
toArgv() / toPayload() / inspect() |
to_a — executes |
send() |
| Switching the connection costs nothing until execution | command.provider = Codex costs nothing until the next send() |
That second borrowing is what makes the public surface small. A caller never constructs a process, an adapter, or spawn options; they describe what they want and say when.
ActiveCLI applies the adapter shape underneath that.
| ActiveRecord | ActiveCLI |
|---|---|
AbstractAdapter |
AbstractAdapter, minted by an AbstractProvider |
Mysql2Adapter, PostgreSQLAdapter |
ClaudeAdapter, and one per CLI to follow |
| SQL dialect differences live in the subclass | argv, permission-flag vocabulary and output classification live in the subclass |
| Connection pooling is shared machinery | Executable resolution, PATH, and process trees are shared machinery |
The division matters more than the analogy. A subclass owns everything its CLI spells differently. It does not own finding the executable, augmenting PATH, or killing process trees — those are the same problem for every CLI, so they live in Process/ and are handed to the adapter rather than reimplemented per provider.
Provider and adapter are not the same thing
Section titled “Provider and adapter are not the same thing”A caller names a provider (Claude), never an adapter. The distinction is deliberate: an adapter is a working object with collaborators and state, while a provider is the identity of a CLI — the thing you point at when you say “talk to claude” or “switch to codex”.
command.provider = Codex; // a value assignment, not handing over machineryThe provider mints an adapter when the command actually needs one, which is at send() and not before.
Why the CLI’s own commands, and not its internal channel
Section titled “Why the CLI’s own commands, and not its internal channel”A running CLI exposes an undocumented control channel, and it is tempting: it is more structured than printed output and easier to parse. This library treats it as an optimisation, never a dependency.
| Question | How this library answers it | Rejected alternative |
|---|---|---|
| Which MCP servers are configured? | claude mcp list, and parse what it printed |
An undocumented mcp_status control subtype |
| Is the user logged in? | claude auth status --json |
Reading credential files directly |
| Which version is installed? | claude --version |
Inspecting the install directory |
| Stop the current turn | Control channel, with stop() as the fallback that always exists |
Control channel alone |
The cost is a parser for human-facing prose, confined to McpOutputParser. The benefit is that the contract is the one the CLI’s own maintainers must not casually break, because their users read that output every day.
First consumer
Section titled “First consumer”Swttch, a Claude Code GUI plugin for JetBrains IDEs, is the first consumer. Its existing CLI-control code in backend/src/core/ is being lifted into this library.
| Existing code in Swttch | ActiveCLI class |
|---|---|
buildClaudeArgs() |
Adapter.Claude.ClaudeArgv |
| Assembling a turn and deciding when to spawn | Command |
claude mcp list output parsing |
Mcp.McpCommands, Mcp.McpOutputParser |
claude auth status / auth login |
Auth.AuthCommands, Auth.LoginSession |
| spawn + stream-json parsing | Adapter.Claude.ClaudeAdapter |
receiving control_request |
Event.PermissionRequestEvent |
sendControlResponseToProcess() |
PermissionRequestEvent#approve() / #deny() |
which-launcher.ts, claude-bin-paths.ts |
Process.Launcher.* |
win-exec.ts, win-job.ts |
Process.Launcher.WindowsLauncher, Process.Job.JobSpawner |
wsl-path.ts |
Path.WslUncPath |
parent-watchdog.ts |
Process.Watchdog.ParentWatchdog |
cli-registry.ts |
Registry.CliRegistry |
There is no new protocol to design here. The work is placing something that already runs into classes, according to the principles.
Why AbstractAdapter is still small
Section titled “Why AbstractAdapter is still small”Provider differences — Codex’s threadId against Claude’s sessionId — only become visible when a second adapter is attached. A contract drawn from a single implementation is a guess; the shape worth committing to is the one that survives a second.
This is also why Command is where the public surface stabilised first. It speaks in intent — a message, a model, a permission mode — and none of those words are Claude’s. A second provider changes what happens under send(), not what a caller writes above it.
The intended order is therefore:
- Port
ClaudeAdapteraccurately. - Attach a second adapter, and extract the common contract from the two.
- Only then fix
AbstractAdapter.
ActiveRecord also began with MySQL alone.