프로바이더 작성하기
어댑터가 지는 의무와 공짜로 받는 것
섹션 제목: “어댑터가 지는 의무와 공짜로 받는 것”어댑터는 자신의 CLI가 다르게 표기하는 모든 것을 소유합니다. 운영체제 문제는 소유하지 않습니다. 그것들은 모든 CLI에 동일하기 때문입니다.
| 직접 구현할 것 | 제공되므로 다시 구현하지 말 것 |
|---|---|
| 당신의 CLI가 spawn될 argv | 실행 파일 찾기 (AbstractLauncher) |
| 어느 플래그가 대화를 시작하고 어느 것이 재개하는지 | GUI 프로세스가 가졌어야 할 PATH (AugmentedPath) |
| 당신의 CLI의 권한 플래그 어휘 | 프로세스 트리 종료 (AbstractProcessTree) |
| 당신의 CLI의 출력 줄 분류 | 파이프 청크에서 줄 재조립 (NdjsonBuffer) |
| spawn에 Job Object가 필요한지 여부 | Windows 인용 (WindowsCommandLine), Job Object (JobSpawner) |
경로 종류 (PathParser), 고아 프로세스 추적 (CliRegistry) |
멤버 다섯 개. 그것이 전부입니다.
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;}의도적으로 이만큼 작습니다. 단일 구현에서 뽑아낸 계약은 추측이며, 커밋할 가치가 있는 형태는 두 번째를 견뎌 낸 것입니다. 그래서 이 계약은 당신이 어댑터를 붙일 때 자라날 것으로 예상되며, 그 전에 자라지 않습니다.
그리고 프로바이더 클래스 하나
섹션 제목: “그리고 프로바이더 클래스 하나”호출자는 어댑터가 아니라 프로바이더를 이름으로 부릅니다. 작은 클래스이며, command.provider = YourCli가 기계를 넘기는 문장이 아니라 무언가를 지목하는 문장으로 읽히게 하는 것이 이것입니다.
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하십시오 — codex CLI는 하나뿐이며, Command.open(Codex, …)는 그것을 지목하는 문장으로 읽혀야 합니다. ask()는 기반 클래스에서 공짜로 옵니다.
이것만 갖추면 아래 전부가 추가 코드 없이 당신의 CLI를 상대로 동작합니다.
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파일이 놓이는 곳
섹션 제목: “파일이 놓이는 곳”클래스 이름이 그 경로를 알려 주므로, 새 어댑터는 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파일 하나에 클래스 하나, 파일 이름은 클래스 이름을 따릅니다. 새 클래스들을 유일한 공개 표면인 src/index.ts에서 export하십시오.
1단계 — argv 클래스
섹션 제목: “1단계 — argv 클래스”spawn 지점에서 조립하는 대신 argv를 객체로 구축하십시오. 그래야 프로세스를 시작하지 않고도 플래그 구성을 단언할 수 있습니다.
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; }}ClaudeArgv에서 이어지는 두 규칙은 반복할 가치가 있습니다. 둘 다 틀리기 쉽습니다.
| 규칙 | 이유 |
|---|---|
| 호출자가 선호를 표현하지 않았다면 플래그를 통째로 생략한다 | 당신의 CLI가 쓰는 “기본값”이라는 단어를 넘기는 것은 사용자의 설정에 양보하는 것이 아니라 그것을 덮어씁니다. --permission-mode default는 특정 모드를 지칭할 뿐 “사용자 설정을 따르라”를 뜻하지 않습니다. |
| 모델이 선택되었다면 명시적으로 고정한다 | 컨트롤 채널을 통한 재지정은 살아 있는 프로세스에만 닿습니다. 이 플래그가 없으면 유휴 상태에서 고른 모델이 다음 spawn에서 사라집니다. |
2단계 — 권한 플래그 맵
섹션 제목: “2단계 — 권한 플래그 맵”PermissionMode는 공유 어휘이고, 당신의 CLI는 자신만의 철자를 가집니다. 양방향으로 매핑하되, 역방향 맵을 정방향에서 파생시켜 둘이 어긋날 수 없게 하십시오.
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; }}두 방향 모두 필요합니다. 당신은 플래그로 spawn하고, CLI는 자신의 모드를 되보고합니다 — 그것이 CLI가 스스로 모드를 바꿨을 때 호스트가 보조를 맞추는 방법입니다. 역방향이 reportedMode()가 호출하는 것이며, 당신의 CLI에서 Session.mode와 Session.requiresRestartFor()를 동작하게 하는 것입니다.
3단계 — 어댑터
섹션 제목: “3단계 — 어댑터”조합하십시오. 여기서 새로 쓰이는 코드는 거의 없어야 합니다.
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()); }}이 메서드에서 하중을 받는 것이 다섯 가지 있으며, 그중 선택 사항은 없습니다.
| 줄 | 왜 남아야 하는가 |
|---|---|
spawn이 buildArgv를 호출한다 |
그러지 않으면 Command.toArgv()가 실제로 실행되는 것과 다른 커맨드 라인을 호출자에게 보여 줄 수 있습니다. |
| 세션 id 없이 throw한다 | 재개할 수 없는 대화는 세션이 아닙니다. 생략이 옳은 경우는 없습니다. |
환경변수의 TERM=dumb 바닥 |
자신이 유능한 터미널을 가졌다고 여기는 CLI는 프로토콜과 같은 스트림에 ANSI 이스케이프를 방출하고, 장식된 JSON 줄은 파싱에 실패합니다. |
options.isResuming()이 플래그를 고른다 |
id가 존재한다는 것으로부터 “재개 중”을 도출하면 모든 세션이 재개가 되고, CLI는 그 각각에 No conversation found로 답합니다. 이것은 실제 회귀였고, 실제 CLI를 상대로 해야만 잡혔습니다. |
cwd를 설정하기 전 isSpawnableAsCwd() |
WSL UNC 경로는 디렉토리 이름을 담은 ENOENT로 spawn을 실패시키는데, 그것은 실행 파일이 없는 것처럼 읽힙니다. |
augmentedPath.toEnv(options.env) |
이것이 없으면 nvm이나 homebrew로 설치된 CLI가 GUI로 띄워진 프로세스에게 보이지 않습니다. |
win32가 아닌 곳에서 detached: true |
이것이 CLI를 프로세스 그룹 리더로 만들며, PosixProcessTree가 트리 전체에 시그널을 보낼 수 있는 유일한 이유입니다. |
4단계 — 출력 분류
섹션 제목: “4단계 — 출력 분류”당신의 CLI가 type 판별자를 가진 NDJSON을 방출한다면 EventParser가 이미 동작합니다. 그렇지 않다면 parseLine이 번역하는 자리이며, 지켜야 할 규칙은 호스트가 데이터를 조용히 잃지 않게 하는 것들입니다.
| 규칙 | 어겼을 때의 결과 |
|---|---|
프로토콜이 아닌 출력에는 null을 반환한다 |
CLI는 같은 스트림에 경고를 씁니다. 그것을 실패로 취급하면 더 수다스러워진 버전에서 깨집니다. |
모델링하지 않은 엔트리에는 GenericEvent를 반환한다 |
거부한다는 것은 CLI가 이 라이브러리보다 앞서 나갈 때마다 호스트가 이벤트를 잃는다는 뜻입니다. |
raw를 절대 편집하지 않는다 |
그것은 CLI 자신의 엔트리입니다. 사용 중인 필드만 골라내면 정보가 영구히 사라집니다. |
당신의 CLI에 새 타입 이벤트가 필요하다면 기존 것들 옆에 추가하고 EventParser.classify()에 알려 주십시오. 하위 클래스를 추가하는 것은 이미 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; }}5단계 — 테스트하기
섹션 제목: “5단계 — 테스트하기”계약은 테스트 안에서 구현할 수 있을 만큼 작으므로, 스크립트로 만든 프로세스를 상대로 세션을 처음부터 끝까지 구동할 수 있습니다 — 진짜 프로세스, 진짜 파이프, 진짜 종료를 가지고, CLI를 하나도 설치하지 않은 채로.
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, ); }}argv 결정에 대해서는 아무것도 실행하지 말고 호출을 기록하십시오. 플랫폼을 'win32'로 고정해 spawn이 스텁 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);});그리고 플래그 어휘가 왕복하는지 단언해, spawn과 보고가 서로 어긋날 수 없게 하십시오.
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); }});6단계 — 당신의 CLI가 다르게 표기하는 서브커맨드
섹션 제목: “6단계 — 당신의 CLI가 다르게 표기하는 서브커맨드”command.auth와 command.mcp는 현재 Claude 형태입니다. claude auth status --json과 claude mcp list를 실행하고 그 출력을 파싱합니다. 프로바이더의 name으로 생성되므로 당신의 CLI를 상대로 실행되기는 하지만, 그 CLI가 서브커맨드를 다르게 표기한다면 쓸모 있는 것을 내놓지 못합니다.
CLI와 무관하게 이어지는 규칙은 어느 채널을 신뢰할 것인가에 관한 것입니다.
| 할 것 | 하지 말 것 |
|---|---|
| 문서화된 서브커맨드를 실행하고 그 출력을 파싱한다 | JSON을 반환한다는 이유로 문서화되지 않은 control subtype에 손을 뻗는다 |
| 어떤 커맨드로도 할 수 없는 일 — 진행 중인 턴 중단 — 에 컨트롤 채널을 쓴다 | 폴백 없이 그것에 의존한다 |
| 파싱을 한 클래스에 가둔다 | 문자열 처리를 어댑터 전반에 흩뿌린다 |
문서화된 커맨드는 CLI 관리자들이 함부로 깰 수 없는 계약입니다. 그들의 사용자가 그 출력을 매일 읽기 때문입니다. 내부 채널에는 그런 보호가 없습니다.
7단계 — 추출한 뒤 계약을 확정하기
섹션 제목: “7단계 — 추출한 뒤 계약을 확정하기”건너뛰기 쉽지만 그래서는 안 되는 부분입니다.
두 번째 어댑터가 존재하고 나면 차이가 보이기 시작합니다 — 어떤 CLI는 session id를, 다른 CLI는 thread id를 키로 삼고, 어떤 것은 권한 모드가 다섯이고 다른 것은 셋입니다. 그때가 공통 형태를 AbstractAdapter로 끌어올릴 순간이며, 그 전이 아닙니다.
- CLI의 실제 동작에 맞춰 어댑터를 정확히 이식한다.
- 기존 계약을 우회해야 했던 모든 지점을 기록한다 — 특히
Auth/와Mcp/, 그리고PermissionMode가 맞지 않았던 모든 곳. - 두 구현에서 공통 형태를 추출한다.
- 그 뒤에야
AbstractAdapter를 넓힌다.
Command는 가장 적게 바뀌어야 할 부분입니다. 이것은 의도로 말합니다 — 메시지, 모델, 권한 모드 — 그리고 그중 어느 단어도 Claude의 것이 아닙니다. 두 번째 CLI를 이식하면서 거기에 변경이 강요된다면, 그것이 이 작업 전체에서 가장 흥미로운 발견이며 기록해 둘 가치가 있습니다.
ActiveRecord 역시 MySQL 하나로 시작했습니다. 두 번째 구현보다 앞서 발명된 계약은 추측이며, 틀린 추측은 작은 계약보다 비쌉니다.
다음으로 볼 것
섹션 제목: “다음으로 볼 것”- Claude 프로바이더 — 이식의 기준이 되는 참조 구현.
- 아키텍처 —
Process/에 무엇이 살고 왜 그런지. - 기여하기 — 우회하는 대신 발견을 제기하기.