Skip to content

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.

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.

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

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 knows
path.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 the toPayload() 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 by approve() / 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.

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'];

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().

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.PLAN
PermissionMode.named('nonsense'); // => null

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

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 run
command.toPayload(); // what would be written to stdin
await command.send(); // now

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

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 plainly
command.setModel('opus').setPermissions('plan').setMessage(''); // intent, chained

The assignment delegates to the setter, so there is one implementation and no second path to keep correct.

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

One 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 channel
command.close(); // the fallback that always exists

ControlRequest 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'); // WindowsLauncher
new ProcessTreeFactory().create('linux'); // PosixProcessTree

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