Design principles
Five principles govern the codebase. They are not stylistic preferences; each one is a response to a failure mode observed in comparable projects.
1. Extreme object orientation
Section titled “1. Extreme object orientation”Behaviour lives with the data it acts on. The counter-example that motivated this is concrete: the largest competing plugin exports free functions such as handleClaudeCommand(command, args, stdinData) and dispatches through a large switch, passing state along as arguments. With six CLIs attached that way, no common contract emerged — each provider’s send takes different parameters.
The visible consequence in ActiveCLI is that there are no utility modules. There is no paths.ts of exported helpers; there is a Path namespace of classes. There is no killTree() function; there is an AbstractProcessTree with a subclass per platform.
2. Everything is an instance
Section titled “2. Everything is an instance”A value becomes an instance at the shortest correct distance from where it enters, and only instances are passed between objects. A plain object is not treated as a first-class value.
The boundaries and their conversions:
| Boundary | What arrives | Instance it becomes | Converted by |
|---|---|---|---|
| A line of CLI stdout | A JSON string | An AbstractEvent subclass |
EventParser |
| A file path | string |
An AbstractPath subclass |
PathParser |
| A permission request | control_request JSON |
PermissionRequestEvent |
EventParser |
| A permission mode | A flag string | PermissionMode |
ClaudePermissionFlag |
| A registry file | Parsed JSON | RegistryEntry |
RegistryEntry.fromJSON |
Why paths are the clearest case
Section titled “Why paths are the clearest case”A path travelling as a string cannot answer “are you a Windows drive path or a Linux one?” Every caller that needs to know re-derives it, and the derivations drift apart. As a class, the answer is the type:
import { PathParser } from 'activecli';
const path = new PathParser().parse('//wsl.localhost/Ubuntu/home/user/proj');
path.isSpawnableAsCwd(); // false — the type knowspath.toWslPath(); // '/home/user/proj'Ask once, at the boundary, and carry the instance from then on.
Where the principle is deliberately relaxed
Section titled “Where the principle is deliberately relaxed”Three places return Record<string, unknown> rather than an instance, and all are at the outermost edge:
Session.send(payload)and thetoPayload()family, which produce the exact JSON line written to stdin.AbstractEvent.raw, which is the CLI’s own entry, held unedited.PermissionResponder’s return value, which is the reply line — produced byapprove()/deny()on the request itself.
These are the wire format itself. Wrapping the bytes that leave the process would add a layer without adding an answer.
Command.describe() returns one too, and for a different reason: what a CLI installation can do — its slash commands, its models, the settings in force for this directory — differs per install and per project, and modelling a shape that varies by version would be inventing a contract the CLI never offered.
The raw payload is kept whole
Section titled “The raw payload is kept whole”A CLI streams richer entries than any one host renders, and the temptation is to pick out the fields in use and forward only those. That loses information permanently — the next feature needs a field dropped somewhere upstream, and nobody can tell what the CLI actually said.
So subclasses interpret the payload without replacing it:
// A typed question where one exists.event.type; // 'result'event.reportedPermissionFlag(); // on SystemEvent
// And the CLI's own entry for everything else — no field removed or renamed.event.raw['cost_usd'];event.raw['unknown_future_field'];3. Classes over interfaces
Section titled “3. Classes over interfaces”Where a class can serve as the type, a class is used. An interface describes a shape; an abstract class can also carry behaviour and enforce an invariant, and several places in this codebase depend on exactly that.
AbstractProcessTree is the clearest example. The base class implements the public kill(), applies a safety guard, and only then delegates to the abstract method:
export abstract class AbstractProcessTree { kill(proc: ChildProcess, signal: NodeJS.Signals = 'SIGTERM'): void { if (!proc.pid) return; if (proc.exitCode !== null || proc.signalCode !== null) return; this.killTree(proc, signal); }
protected abstract killTree(proc: ChildProcess, signal: NodeJS.Signals): void;}The guard refuses a process that has already been reaped, because its pid is a stale number the OS may have reused — and both tree-wide mechanisms (kill(-pid) and taskkill /T) would then tear down an unrelated process tree. An interface could not have enforced that; every implementor would have had to remember it.
The same pattern appears in ControlRequest, where the base owns the envelope and subclasses supply only their subtype and extra fields, and in AbstractEvent, where the base owns raw and toJSON().
Closed sets are classes too
Section titled “Closed sets are classes too”The pattern recurs wherever a value must survive translation in both directions: PermissionMode, McpServerStatus, McpServerScope, McpTransport, LoginMethod. Each is a class with a private constructor and a fixed set of static instances rather than a string union.
PermissionMode is the clearest:
PermissionMode.PLAN;PermissionMode.ASK_BEFORE_EDIT;PermissionMode.AUTO_EDIT;PermissionMode.AUTO;PermissionMode.BYPASS;
PermissionMode.named('plan'); // => PermissionMode.PLANPermissionMode.named('nonsense'); // => nullEach mode survives two translations — into the flag a CLI is spawned with, and back out of what the CLI reports about itself. A bare string would let the two drift apart silently. The private constructor means no sixth mode can appear that the adapters do not know how to spell.
4. Accumulate, then execute
Section titled “4. Accumulate, then execute”Borrowed from ActiveRecord’s Relation. Setters record intent; one method runs it; another shows what would run without running it.
const command = Command.open(Claude, { workingDir: '/proj' });
command.setModel('opus').setPermissions('plan').setMessage('Plan the migration');// Nothing has started. No process, no adapter, no spawn options.
command.toArgv(); // what would runcommand.toPayload(); // what would be written to stdin
await command.send(); // nowThe payoff is not aesthetic. Three things fall out of it that would otherwise each need their own mechanism:
| Consequence | Why deferral gives it |
|---|---|
command.provider = Codex is free |
A process is chosen when there is finally something to run, not when the caller changed their mind. A CLI process cannot become another one, so an eager design would have to spawn and discard. |
| The command line is inspectable | toArgv() is the counterpart of to_sql. A host can log, display, or assert the exact invocation without side effects. |
| Spawn-time flags can still change | --permission-mode is fixed at spawn. Because intent is held rather than applied, send() can notice the change and respawn — the caller never learns that this flag is different from the others. |
The rule that makes it work is that there is exactly one execution point. send() is the only place anything reaches the CLI, which is why Command can promise that reading a property never has a side effect.
The setter pair
Section titled “The setter pair”Every property has both an assignment and a set-prefixed method returning this. That is two ways to say one thing, which normally violates the first principle — it is kept because the two read differently at different lengths:
command.model = 'opus'; // one thing, said plainlycommand.setModel('opus').setPermissions('plan').setMessage('…'); // intent, chainedThe assignment delegates to the setter, so there is one implementation and no second path to keep correct.
5. Namespaces are folders
Section titled “5. Namespaces are folders”A class name tells you its path, and the folder structure is the namespace.
ActiveCli.Adapter.Claude.ClaudeAdapter → src/ActiveCli/Adapter/Claude/ClaudeAdapter.ts
ActiveCli.Process.Launcher.WindowsLauncher → src/ActiveCli/Process/Launcher/WindowsLauncher.tsOne class per file, the file named for the class. There are no barrel files inside the tree — src/index.ts is the single public surface, and every internal import names the file it wants.
The documented command wins over the structured channel
Section titled “The documented command wins over the structured channel”A corollary that cuts across all five principles, and the one most likely to look wrong at first glance.
A running CLI exposes a control channel carrying structured JSON. Printed output is prose meant for people. Choosing the prose looks like choosing the worse contract — and this library chooses it anyway, because stability is not the same as structure.
| Documented command | Undocumented control subtype | |
|---|---|---|
| Who else depends on it | Every user who types it | Whoever reverse-engineered it |
| Breaking it | Is a visible regression | Is invisible until something silently stops working |
| Parsing cost | A parser we own | None |
So command.mcp.list() runs claude mcp list and reads what it printed, and the cost — McpOutputParser — is confined to one class so the rest of the library deals in McpServer instances.
The control channel is still used, for the things no command can do: interrupting a turn already in flight, retargeting a live process’s model, asking a running CLI to describe itself. It is used as an optimisation with a fallback, never as the only path:
command.interrupt(); // control channelcommand.close(); // the fallback that always existsControlRequest says so in its own doc comment, which is the honest place for it: a subclass is how the CLI is driven while it is already running, and a host should treat that as an optimisation over the official path rather than the only way it knows to work.
Platform differences are subclasses, not branches
Section titled “Platform differences are subclasses, not branches”A corollary of all the principles, and the rule with the widest reach in this codebase: process.platform is asked once, by a factory, and never again at a call site.
| Abstraction | win32 | Elsewhere | Chosen by |
|---|---|---|---|
AbstractLauncher |
WindowsLauncher |
PosixLauncher |
LauncherFactory |
AbstractProcessTree |
WindowsProcessTree |
PosixProcessTree |
ProcessTreeFactory |
AbstractPath |
WindowsPath, WslUncPath |
PosixPath |
PathParser |
The payoff is testability. Because the factory takes the platform as a parameter, Windows behaviour can be asserted from a macOS or Linux runner:
new LauncherFactory().create('win32'); // WindowsLaunchernew ProcessTreeFactory().create('linux'); // PosixProcessTreeAnd because the platform-specific logic is a method rather than an inline branch, the parts that matter most are directly assertable — WindowsLauncher.pick() is public precisely so the PATHEXT rule can be tested without a real Windows host.
Dependency injection by constructor default
Section titled “Dependency injection by constructor default”Every collaborator is a constructor parameter with a sensible default. Production code constructs nothing; tests substitute freely.
export class ClaudeAdapter extends AbstractAdapter { constructor( private readonly argv: ClaudeArgv = new ClaudeArgv(), private readonly augmentedPath: AugmentedPath = AugmentedPath.shared, private readonly eventParser: EventParser = new EventParser(), private readonly jobSpawner: JobSpawner = new JobSpawner(), private readonly platform: NodeJS.Platform = process.platform, private readonly permissionFlag: ClaudePermissionFlag = new ClaudePermissionFlag(), private readonly authEnv: ClaudeAuthEnv = new ClaudeAuthEnv(), ) { super(); }}new ClaudeAdapter() is all a caller needs, while a test can pin the platform to 'win32' and pass a recording job spawner. The one shared singleton, AugmentedPath.shared, exists because building it costs a bash subshell and the answer is identical for every CLI in the process.