Skip to content

Abstract provider

AbstractProvider is the base class every CLI’s identity extends. It is small on purpose: two abstract members to fill in, and one method that comes for free.

export abstract class AbstractProvider {
/** The CLI's command name, as a user would type it. */
abstract get name(): string;
/** A fresh adapter for this CLI. */
abstract createAdapter(): AbstractAdapter;
async ask(question: string, options?: CommandOptions): Promise<Answer>;
toString(): string;
}
Member Who provides it What it is for
name The subclass The CLI’s command name, as a user would type it — claude, codex
createAdapter() The subclass A fresh adapter, minted when a command finally needs one
ask() The base class One question, one whole answer
toString() The base class The name, so a provider prints as itself

A subclass that fills in the two abstract members gets everything else. That is the whole obligation.

export class ClaudeProvider extends AbstractProvider {
override get name(): string {
return 'claude';
}
override createAdapter(): ClaudeAdapter {
return new ClaudeAdapter();
}
}
/** The `claude` CLI. */
export const Claude = new ClaudeProvider();

name is used for more than display. The one-off subcommands — command.auth, command.mcp, command.version() — are constructed with it, so they run against the CLI the provider names.

ask() is the shortcut for when there is no conversation to have. It opens a command, sends, collects the assistant’s text until the turn ends, and closes.

const answer = await Claude.ask('what does this do?', { workingDir: '/proj' });
console.log(answer.text);

It resolves with an Answer, which carries the assembled text plus the CLI’s own result entry — so a caller can reach cost, duration, or anything else the CLI reported without this class having to model it.

The deliberate omission is the permission mode. ask() runs with no permission mode of its own, so the CLI applies the user’s configured default.

The reasoning is the same one that governs ClaudeArgv: omitting a flag defers to the user’s setting, while passing one overrides it. There is no value ask() could pass that would mean “follow the user’s settings” — --permission-mode default names a specific mode, it does not mean deference. So it passes nothing.

For anything with a second turn, permission handling, or streaming, open a Command and keep it.

Under every provider is an adapter, and that contract is five members:

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 a second adapter is attached, not before.

Provider differences only become visible at that point. Codex keys a conversation on a threadId where Claude keys it on a sessionId; one CLI’s permission vocabulary has more entries than another’s. Writing a contract that anticipates those differences before seeing them produces an abstraction fitted to a guess.

The intended order is therefore:

  1. Port ClaudeAdapter accurately.
  2. Attach a second adapter, and extract the common contract from the two.
  3. Only then fix AbstractAdapter.

ActiveRecord also began with MySQL alone.

What the small contract already guarantees

Section titled “What the small contract already guarantees”

Small is not the same as loose. Two of the five members are load-bearing in ways worth naming.

buildArgv is separate from spawn, and spawn is defined in terms of it. That is what lets Command.toArgv() show a caller the exact command line without starting a process — the counterpart of to_sql. Assembling argv inside spawn would make the two able to describe different commands.

reportedMode is on the adapter because the vocabulary is the CLI’s. One spells a mode acceptEdits; another will spell the same idea differently. Translating is the only place that difference should be visible, which is what keeps Session.mode and Session.requiresRestartFor() speaking in the shared vocabulary rather than in any CLI’s.

What an adapter does not own is equally fixed: finding the executable, augmenting PATH, and killing process trees are the same problem for every CLI, so they live in Process/ and are handed to the adapter rather than reimplemented per provider.

  • Claude provider — the one implementation that exists today.
  • Writing a provider — the seven steps, ending in extracting the contract.
  • Roadmap — which provider is expected to apply the pressure first.