Skip to content

Writing a provider

What an adapter owes, and what it gets for free

Section titled “What an adapter owes, and what it gets for free”

An adapter owns everything its CLI spells differently. It does not own the operating-system problems, because those are identical for every CLI.

Yours to implement Provided, do not reimplement
The argv your CLI is spawned with Finding the executable (AbstractLauncher)
Which flag starts a conversation versus resumes one The PATH a GUI process should have had (AugmentedPath)
Your CLI’s permission-flag vocabulary Killing process trees (AbstractProcessTree)
Classifying your CLI’s output lines Reassembling lines from pipe chunks (NdjsonBuffer)
Whether a spawn needs a Job Object Windows quoting (WindowsCommandLine), Job Objects (JobSpawner)
Path flavours (PathParser), orphan tracking (CliRegistry)

Five members. That is the whole of it.

abstract class AbstractAdapter {
abstract get command(): string;
abstract buildArgv(options: SpawnOptions): string[];
abstract spawn(options: SpawnOptions): ChildProcess;
abstract parseLine(line: string): AbstractEvent | null;
abstract reportedMode(event: AbstractEvent): PermissionMode | null;
}

It is deliberately this small. A contract drawn from a single implementation is a guess, and the shape worth committing to is the one that survives a second — so the contract is expected to grow when you attach your adapter, not before.

A caller names a provider, never an adapter. It is a small class, and it is what makes command.provider = YourCli read as naming something rather than as handing over machinery.

import { AbstractProvider } from '../AbstractProvider.js';
import { CodexAdapter } from '../../Adapter/Codex/CodexAdapter.js';
export class CodexProvider extends AbstractProvider {
override get name(): string {
return 'codex';
}
override createAdapter(): CodexAdapter {
return new CodexAdapter();
}
}
/** The `codex` CLI. */
export const Codex = new CodexProvider();

Export the singleton, not just the class — there is one codex CLI, and Command.open(Codex, …) should read as naming it. ask() comes from the base class for free.

With that in place, every one of these works against your CLI without further code:

await Codex.ask('what does this do?', { workingDir: '/proj' });
const command = Command.open(Codex, { workingDir: '/proj' });
await command.setModel('o3').setMessage('fix the test').send();
command.provider = Claude; // and back again

A class name tells you its path, so a new adapter is a new folder under Adapter/:

src/ActiveCli/Adapter/
AbstractAdapter.ts
SpawnOptions.ts
Claude/
ClaudeAdapter.ts
ClaudeArgv.ts
ClaudePermissionFlag.ts
ClaudeAuthEnv.ts
Codex/ <- your new namespace
CodexAdapter.ts
CodexArgv.ts
CodexPermissionFlag.ts
src/ActiveCli/Provider/
AbstractProvider.ts
ClaudeProvider.ts
CodexProvider.ts <- and the identity a caller names

One class per file, the file named for the class. Export the new classes from src/index.ts, which is the single public surface.

Build argv as an object rather than assembling it at the spawn site, so the flag composition can be asserted without starting a process.

import type { PermissionMode } from '../../Session/PermissionMode.js';
import { CodexPermissionFlag } from './CodexPermissionFlag.js';
export class CodexArgv {
/** Flags that make the CLI a controllable stream rather than a terminal UI. */
private static readonly STREAM_FLAGS = [
'--json',
'--stdin',
];
constructor(private readonly permissionFlag = new CodexPermissionFlag()) {}
build(
threadFlag: string,
threadId: string,
mode?: PermissionMode | null,
model?: string | null,
): string[] {
const args = [...CodexArgv.STREAM_FLAGS, threadFlag, threadId];
if (mode) args.push('--sandbox', this.permissionFlag.forMode(mode));
if (model) args.push('--model', model);
return args;
}
}

Two rules carried from ClaudeArgv are worth repeating, because both are easy to get wrong:

Rule Reason
Omit a flag entirely when the caller expressed no preference Passing your CLI’s word for “the default” overrides the user’s configured setting rather than deferring to it. --permission-mode default names a specific mode; it does not mean “follow the user’s settings”.
Pin the model explicitly when one was chosen A control-channel retarget only reaches a live process. Without the flag, a model chosen while idle is lost at the next spawn.

PermissionMode is the shared vocabulary; your CLI has its own spelling. Map both ways, and derive the reverse map from the forward one so the two cannot drift.

import { PermissionMode } from '../../Session/PermissionMode.js';
export class CodexPermissionFlag {
private static readonly TO_FLAG = new Map<PermissionMode, string>([
[PermissionMode.PLAN, 'read-only'],
[PermissionMode.ASK_BEFORE_EDIT, 'workspace-write'],
[PermissionMode.AUTO_EDIT, 'workspace-write'],
[PermissionMode.AUTO, 'danger-full-access'],
[PermissionMode.BYPASS, 'danger-full-access'],
]);
private static readonly FROM_FLAG = new Map<string, PermissionMode>(
[...CodexPermissionFlag.TO_FLAG].map(([mode, flag]) => [flag, mode]),
);
forMode(mode: PermissionMode): string {
return CodexPermissionFlag.TO_FLAG.get(mode) as string;
}
toMode(flag: string | undefined | null): PermissionMode | null {
if (!flag) return null;
return CodexPermissionFlag.FROM_FLAG.get(flag) ?? null;
}
}

Both directions are needed because you spawn with the flag and the CLI then reports its own mode back — which is how a host stays in step when the CLI changes mode by itself. The reverse direction is what reportedMode() calls, and what makes Session.mode and Session.requiresRestartFor() work for your CLI.

Compose. Almost nothing here should be new code.

import { spawn, type ChildProcess, type SpawnOptions as NodeSpawnOptions } from 'node:child_process';
import type { AbstractEvent } from '../../Event/AbstractEvent.js';
import { EventParser } from '../../Event/EventParser.js';
import { JobSpawner } from '../../Process/Job/JobSpawner.js';
import { AugmentedPath } from '../../Process/Path/AugmentedPath.js';
import { AbstractAdapter } from '../AbstractAdapter.js';
import type { SpawnOptions } from '../SpawnOptions.js';
import { CodexArgv } from './CodexArgv.js';
export class CodexAdapter extends AbstractAdapter {
private static readonly RESUME_FLAG = '--resume';
private static readonly NEW_THREAD_FLAG = '--thread-id';
constructor(
private readonly argv: CodexArgv = new CodexArgv(),
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,
) {
super();
}
override get command(): string {
return 'codex';
}
override buildArgv(options: SpawnOptions): string[] {
const sessionId = options.sessionId;
if (!sessionId) {
throw new Error('A session id is required: a conversation that cannot be resumed is not a session');
}
return this.argv.build(
options.isResuming() ? CodexAdapter.RESUME_FLAG : CodexAdapter.NEW_THREAD_FLAG,
sessionId,
options.permissionMode,
options.model,
options.ephemeral,
);
}
override spawn(options: SpawnOptions): ChildProcess {
const args = this.buildArgv(options);
// Safe after buildArgv, which refuses without one.
const sessionId = options.sessionId as string;
const nodeOptions: NodeSpawnOptions = {
// A path that refuses to be a cwd is left off entirely: handing it over
// fails the spawn with ENOENT naming the directory, not the binary.
...(options.workingDirectory?.isSpawnableAsCwd()
? { cwd: options.workingDirectory.toWslPath() }
: {}),
// The protocol env first as a floor, then the caller's own, then any
// credential strip — an inherited token must not survive whoever passed it.
env: this.augmentedPath.toEnv({
...CodexAdapter.PROTOCOL_ENV,
...options.env,
}),
stdio: ['pipe', 'pipe', 'pipe'],
};
if (this.platform === 'win32') {
return this.jobSpawner.spawn(this.command, args, sessionId, nodeOptions);
}
return spawn(this.command, args, { ...nodeOptions, detached: true });
}
override parseLine(line: string): AbstractEvent | null {
return this.eventParser.parse(line);
}
/** Translated out of your CLI's own spelling — see step 2. */
override reportedMode(event: AbstractEvent): PermissionMode | null {
if (!(event instanceof SystemEvent)) return null;
return this.permissionFlag.toMode(event.reportedPermissionFlag());
}
}

Five things in that method are load-bearing, and none of them are optional:

Line Why it must stay
spawn calls buildArgv Otherwise Command.toArgv() can show a caller a command line that is not the one that runs.
Throwing without a session id A conversation that cannot be resumed is not a session. There is no correct case for omitting it.
A TERM=dumb floor in the env A CLI that thinks it has a capable terminal emits ANSI escapes onto the same stream as its protocol, and a decorated JSON line fails to parse.
options.isResuming() chooses the flag Deriving “resuming” from the id being present makes every session a resume, and the CLI answers No conversation found for each one. This was a real regression, caught only against the real CLI.
isSpawnableAsCwd() before setting cwd A WSL UNC path fails the spawn with ENOENT naming the directory, which reads like a missing binary.
augmentedPath.toEnv(options.env) Without it, a CLI installed via nvm or homebrew is invisible to a GUI-launched process.
detached: true off win32 It is what makes the CLI a process-group leader, which is the only reason PosixProcessTree can signal the whole tree.

If your CLI emits NDJSON with a type discriminator, EventParser already works. If it does not, parseLine is where you translate — and the rules to honour are the ones that keep a host from silently losing data.

Rule Consequence of breaking it
Return null for non-protocol output CLIs write warnings to the same stream. Treating those as failures breaks on a version that got chattier.
Return GenericEvent for entries you do not model Refusing them means a host loses events every time its CLI gets ahead of this library.
Never edit raw It is the CLI’s own entry. Picking out only the fields in use loses information permanently.

If your CLI needs a new typed event, add it beside the existing ones and teach EventParser.classify() about it. Adding a subclass is not a breaking change for anyone already reaching raw.

export class ThreadEvent extends AbstractEvent {
static matches(raw: Readonly<Record<string, unknown>>): boolean {
return raw['type'] === 'thread.started';
}
get threadId(): string | null {
const id = this.raw['thread_id'];
return typeof id === 'string' ? id : null;
}
}

The contract is small enough to implement in a test, so a session can be driven end to end against a scripted process — a genuine process, genuine pipes, a genuine exit — without any CLI installed.

import { spawn, type ChildProcess } from 'node:child_process';
import { AbstractAdapter } from '../../Adapter/AbstractAdapter.js';
import { EventParser } from '../../Event/EventParser.js';
/** An adapter that runs `node -e <script>` instead of a real CLI. */
class ScriptedAdapter extends AbstractAdapter {
private readonly parser = new EventParser();
constructor(private readonly script: string) {
super();
}
override get command(): string {
return process.execPath;
}
override spawn(): ChildProcess {
return spawn(process.execPath, ['-e', this.script], { stdio: ['pipe', 'pipe', 'pipe'] });
}
override parseLine(line: string): AbstractEvent | null {
return this.parser.parse(line);
}
override reportedMode(event: AbstractEvent): PermissionMode | null {
return PermissionMode.named(
typeof event.raw['permissionMode'] === 'string' ? (event.raw['permissionMode'] as string) : null,
);
}
}

For the argv decisions, record the calls instead of launching anything. Pin the platform to 'win32' so the spawn is routed through a stubbed job spawner:

it('starts a new conversation under an id rather than resuming it', () => {
const argv = { build: vi.fn().mockReturnValue([]) };
const jobSpawner = { spawn: vi.fn().mockReturnValue({ pid: 1, on: vi.fn() }) };
const adapter = new CodexAdapter(argv as never, undefined, undefined, jobSpawner as never, 'win32');
adapter.spawn(new SpawnOptions({ sessionId: 'abc' }));
expect(argv.build).toHaveBeenCalledWith('--thread-id', 'abc', null, null);
});

And assert the flag vocabulary round-trips, so spawning and reporting cannot disagree:

it('round-trips every mode', () => {
const flag = new CodexPermissionFlag();
for (const mode of [PermissionMode.PLAN, PermissionMode.AUTO, PermissionMode.BYPASS]) {
expect(flag.toMode(flag.forMode(mode))).toBe(mode);
}
});

Step 6 — The subcommands your CLI spells differently

Section titled “Step 6 — The subcommands your CLI spells differently”

command.auth and command.mcp are currently Claude-shaped: they run claude auth status --json and claude mcp list and parse what those print. They are constructed with the provider’s name, so they will run against your CLI — and produce nothing useful if it spells its subcommands differently.

The rule that does carry over regardless of CLI is the one about which channel to trust:

Do Do not
Run the documented subcommand and parse its output Reach an undocumented control subtype because it returns JSON
Use the control channel for what no command can do — interrupting a live turn Depend on it without a fallback
Confine parsing to one class Spread string-handling through the adapter

A documented command is a contract the CLI’s maintainers must not casually break, because their own users read that output every day. An internal channel has no such protection.

Step 7 — Extract, then commit to the contract

Section titled “Step 7 — Extract, then commit to the contract”

This is the part that is easy to skip and should not be.

Once a second adapter exists, the differences become visible — one CLI keys on a session id, another on a thread id; one has five permission modes, another three. That is the moment to lift the common shape into AbstractAdapter, not before.

  1. Port the adapter accurately, matching your CLI’s real behaviour.
  2. Note every place you had to work around the existing contract — especially in Auth/ and Mcp/, and anywhere PermissionMode did not fit.
  3. Extract the common shape from the two implementations.
  4. Only then widen AbstractAdapter.

Command is the part that should need the least changing. It speaks in intent — a message, a model, a permission mode — and none of those words are Claude’s. If porting a second CLI forces a change there, that is the most interesting finding of the whole exercise and worth writing down.

ActiveRecord also began with MySQL alone. A contract invented in advance of its second implementation is a guess, and a wrong guess is more expensive than a small contract.