콘텐츠로 이동

설계 원칙

다섯 가지 원칙이 코드베이스를 지배합니다. 이는 취향의 문제가 아니라, 각각이 비교 대상 프로젝트에서 관찰된 실패 양상에 대한 대응입니다.

행위는 그것이 다루는 데이터와 함께 삽니다. 이 원칙을 촉발한 반대 사례는 구체적입니다. 최대 경쟁 플러그인은 handleClaudeCommand(command, args, stdinData) 같은 자유 함수를 export하고 거대한 switch로 분기하며, 상태를 인자로 계속 전달합니다. 그런 방식으로 6종의 CLI를 붙였지만 공통 계약은 나타나지 않았고, 각 프로바이더의 send는 서로 다른 파라미터를 받습니다.

ActiveCLI에서 눈에 보이는 결과는 유틸리티 모듈이 없다는 것입니다. export된 헬퍼들이 담긴 paths.ts는 없고, 클래스들로 이루어진 Path 네임스페이스가 있습니다. killTree() 함수는 없고, 플랫폼마다 서브클래스를 갖는 AbstractProcessTree가 있습니다.

값은 그것이 들어오는 지점으로부터 설계상 올바른 최단거리에서 인스턴스가 되며, 객체 사이에는 인스턴스만 전달됩니다. plain object는 1급 값으로 취급하지 않습니다.

경계와 그 변환은 다음과 같습니다.

경계 도착하는 것 변환되는 인스턴스 변환 주체
CLI stdout 한 줄 JSON 문자열 AbstractEvent 서브클래스 EventParser
파일 경로 string AbstractPath 서브클래스 PathParser
권한 요청 control_request JSON PermissionRequestEvent EventParser
권한 모드 플래그 문자열 PermissionMode ClaudePermissionFlag
레지스트리 파일 파싱된 JSON RegistryEntry RegistryEntry.fromJSON

경로가 가장 명확한 사례인 이유

섹션 제목: “경로가 가장 명확한 사례인 이유”

string으로 떠도는 경로는 “너는 Windows 드라이브 경로인가 Linux 경로인가?“에 답하지 못합니다. 그것을 알아야 하는 모든 호출자가 매번 다시 추론하고, 그 추론들은 서로 어긋나기 시작합니다. 클래스라면 그 답이 타입입니다.

import { PathParser } from 'activecli';
const path = new PathParser().parse('//wsl.localhost/Ubuntu/home/user/proj');
path.isSpawnableAsCwd(); // false — 타입이 답을 안다
path.toWslPath(); // '/home/user/proj'

경계에서 한 번만 묻고, 그다음부터는 인스턴스를 들고 다닙니다.

세 곳은 인스턴스 대신 Record<string, unknown>을 반환하며, 모두 가장 바깥 가장자리에 있습니다.

  • Session.send(payload)toPayload() 계열. stdin에 쓰이는 정확한 JSON 한 줄을 만들어 냅니다.
  • AbstractEvent.raw. CLI 자신의 항목을 편집하지 않은 채 보관합니다.
  • PermissionResponder의 반환값. 응답 한 줄이며, 요청 객체 자신의 approve() / deny()가 만들어 냅니다.

이것들은 wire format 그 자체입니다. 프로세스를 떠나는 바이트를 감싸는 일은 답을 더하지 않은 채 계층만 더합니다.

Command.describe()도 하나를 반환하는데, 이유가 다릅니다. CLI 설치본이 무엇을 할 수 있는지 — 슬래시 명령, 모델 목록, 이 디렉터리에 적용되는 설정 — 는 설치본마다 프로젝트마다 다르며, 버전에 따라 달라지는 형태를 모델링하는 것은 CLI가 제공한 적 없는 계약을 지어내는 일입니다.

raw 페이로드는 통째로 보존됩니다

섹션 제목: “raw 페이로드는 통째로 보존됩니다”

CLI는 어떤 호스트가 렌더링하는 것보다 풍부한 항목을 스트리밍하며, 여기서 지금 쓰는 필드만 골라 전달하고 싶은 유혹이 생깁니다. 그렇게 하면 정보가 영구히 사라집니다. 다음 기능이 상류 어딘가에서 버려진 필드를 필요로 하게 되고, CLI가 실제로 무엇을 말했는지 아무도 알 수 없게 됩니다.

그래서 서브클래스는 페이로드를 대체하지 않고 해석합니다.

// 타입이 존재하는 질문에는 타입으로 답합니다.
event.type; // 'result'
event.reportedPermissionFlag(); // SystemEvent에 존재
// 그 외 모든 것은 CLI 자신의 항목 그대로 — 어떤 필드도 제거하거나 이름을 바꾸지 않습니다.
event.raw['cost_usd'];
event.raw['unknown_future_field'];

클래스가 타입 노릇을 할 수 있는 곳에서는 클래스를 씁니다. interface는 형태를 기술할 뿐이지만, 추상 클래스는 행위를 담고 불변식을 강제할 수도 있으며, 이 코드베이스의 여러 곳이 바로 그 점에 기대고 있습니다.

AbstractProcessTree가 가장 명확한 예입니다. 베이스 클래스가 공개 메서드인 kill()을 구현하고, 안전 가드를 적용한 다음에야 추상 메서드에 위임합니다.

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;
}

이 가드는 이미 회수된 프로세스를 거부합니다. 그 pid는 OS가 재사용했을 수도 있는 낡은 숫자이고, 트리 전체를 대상으로 하는 두 메커니즘(kill(-pid)taskkill /T)이 그렇게 되면 무관한 프로세스 트리를 통째로 무너뜨리기 때문입니다. interface는 이것을 강제할 수 없었을 것이고, 모든 구현자가 매번 기억해야 했을 것입니다.

같은 패턴이 ControlRequest에도 나타납니다. 베이스가 봉투를 소유하고 서브클래스는 자신의 subtype과 추가 필드만 제공합니다. AbstractEvent에서도 마찬가지로 베이스가 rawtoJSON()을 소유합니다.

값이 양방향 번역을 견뎌야 하는 곳마다 이 패턴이 반복됩니다. PermissionMode, McpServerStatus, McpServerScope, McpTransport, LoginMethod. 각각은 문자열 union이 아니라 private 생성자와 고정된 static 인스턴스 집합을 갖는 클래스입니다.

PermissionMode가 가장 명확합니다.

PermissionMode.PLAN;
PermissionMode.ASK_BEFORE_EDIT;
PermissionMode.AUTO_EDIT;
PermissionMode.AUTO;
PermissionMode.BYPASS;
PermissionMode.named('plan'); // => PermissionMode.PLAN
PermissionMode.named('nonsense'); // => null

각 모드는 두 번의 번역을 견딥니다. CLI를 spawn할 때 쓰이는 플래그로 한 번, 그리고 CLI가 스스로에 대해 보고하는 것에서 되돌아 나올 때 또 한 번입니다. 맨 문자열이라면 그 둘이 조용히 어긋날 수 있습니다. private 생성자는 어댑터들이 표기할 줄 모르는 여섯 번째 모드가 나타날 수 없게 합니다.

ActiveRecord의 Relation에서 빌려온 원칙입니다. 세터는 의도를 기록하고, 한 메서드가 그것을 실행하며, 다른 메서드가 실행하지 않은 채 무엇이 실행될지 보여 줍니다.

const command = Command.open(Claude, { workingDir: '/proj' });
command.setModel('opus').setPermissions('plan').setMessage('Plan the migration');
// 아직 아무것도 시작되지 않았습니다. 프로세스도, 어댑터도, spawn 옵션도 없습니다.
command.toArgv(); // 무엇이 실행될지
command.toPayload(); // stdin에 무엇이 쓰일지
await command.send(); // 지금

이득은 미학적인 것이 아닙니다. 그렇지 않았다면 각각 별도의 메커니즘이 필요했을 세 가지가 여기서 따라 나옵니다.

따라 나오는 것 지연이 그것을 주는 이유
command.provider = Codex가 공짜다 프로세스는 호출자가 마음을 바꾼 시점이 아니라 마침내 실행할 것이 생긴 시점에 선택됩니다. CLI 프로세스는 다른 것으로 변할 수 없으므로, 즉시 실행 설계였다면 spawn했다가 버려야 했을 것입니다.
커맨드 라인을 들여다볼 수 있다 toArgv()to_sql의 대응물입니다. 호스트는 부수 효과 없이 정확한 호출을 로깅하거나, 표시하거나, 단언할 수 있습니다.
spawn 시점 플래그도 여전히 바뀔 수 있다 --permission-mode는 spawn 시점에 고정됩니다. 의도가 적용되지 않고 보관되기 때문에 send()가 변경을 알아채고 재spawn할 수 있으며, 호출자는 이 플래그가 다른 플래그들과 다르다는 사실을 끝내 알 필요가 없습니다.

이것을 작동하게 하는 규칙은 실행 지점이 정확히 하나뿐이라는 것입니다. send()만이 CLI에 무언가가 닿는 유일한 지점이며, 그래서 Command는 속성을 읽는 일에 결코 부수 효과가 없다고 약속할 수 있습니다.

모든 속성은 대입과 this를 반환하는 set 접두 메서드를 함께 제공합니다. 이는 한 가지를 말하는 두 가지 방법이고 보통은 첫 번째 원칙에 어긋나지만, 길이에 따라 둘이 다르게 읽히기 때문에 유지합니다.

command.model = 'opus'; // 한 가지를, 평이하게
command.setModel('opus').setPermissions('plan').setMessage(''); // 의도를, 체이닝으로

대입은 세터에 위임하므로 구현은 하나뿐이고, 올바르게 유지해야 할 두 번째 경로는 없습니다.

클래스 이름이 그 경로를 알려 주며, 폴더 구조가 곧 네임스페이스입니다.

ActiveCli.Adapter.Claude.ClaudeAdapter
→ src/ActiveCli/Adapter/Claude/ClaudeAdapter.ts
ActiveCli.Process.Launcher.WindowsLauncher
→ src/ActiveCli/Process/Launcher/WindowsLauncher.ts

파일 하나에 클래스 하나, 파일 이름은 클래스 이름을 따릅니다. 트리 내부에 barrel 파일은 없습니다 — src/index.ts가 유일한 공개 표면이며, 내부 import는 모두 원하는 파일을 직접 지목합니다.

문서화된 명령이 구조화된 채널을 이깁니다

섹션 제목: “문서화된 명령이 구조화된 채널을 이깁니다”

다섯 원칙 모두를 가로지르는 따름정리이며, 처음 볼 때 가장 틀려 보이기 쉬운 것이기도 합니다.

실행 중인 CLI는 구조화된 JSON을 실어 나르는 제어 채널을 노출합니다. 출력된 텍스트는 사람을 위한 산문입니다. 산문을 고르는 것은 더 나쁜 계약을 고르는 것처럼 보이지만, 이 라이브러리는 그럼에도 산문을 고릅니다. 안정성은 구조성과 같은 것이 아니기 때문입니다.

문서화된 명령 문서화되지 않은 제어 서브타입
그것에 또 누가 의존하는가 그것을 타이핑하는 모든 사용자 그것을 리버스 엔지니어링한 사람
그것이 깨지면 눈에 보이는 회귀가 된다 무언가가 조용히 동작을 멈출 때까지 보이지 않는다
파싱 비용 우리가 소유하는 파서 없음

그래서 command.mcp.list()claude mcp list를 실행하고 출력된 것을 읽으며, 그 대가인 McpOutputParser는 클래스 하나에 가둬 두어 라이브러리의 나머지는 McpServer 인스턴스만 다룹니다.

제어 채널은 어떤 명령으로도 할 수 없는 일에는 여전히 사용됩니다. 이미 진행 중인 턴을 중단시키는 일, 살아 있는 프로세스의 모델을 바꾸는 일, 실행 중인 CLI에게 스스로를 기술하도록 요청하는 일입니다. 유일한 경로가 아니라 폴백을 갖춘 최적화로 사용됩니다.

command.interrupt(); // 제어 채널
command.close(); // 항상 존재하는 폴백

ControlRequest는 자신의 doc 주석에서 그렇게 말하며, 그것이 정직한 자리입니다. 서브클래스는 CLI가 이미 실행 중일 때 그것을 구동하는 방법이고, 호스트는 그것을 아는 유일한 동작 방식이 아니라 공식 경로에 대한 최적화로 취급해야 합니다.

플랫폼 차이는 분기가 아니라 서브클래스입니다

섹션 제목: “플랫폼 차이는 분기가 아니라 서브클래스입니다”

모든 원칙의 따름정리이며, 이 코드베이스에서 가장 넓게 미치는 규칙입니다. process.platform은 팩토리가 한 번만 묻고, 호출부에서는 다시 묻지 않습니다.

추상 win32 그 외 선택 주체
AbstractLauncher WindowsLauncher PosixLauncher LauncherFactory
AbstractProcessTree WindowsProcessTree PosixProcessTree ProcessTreeFactory
AbstractPath WindowsPath, WslUncPath PosixPath PathParser

이득은 테스트 가능성입니다. 팩토리가 플랫폼을 파라미터로 받기 때문에, Windows 동작을 macOS나 Linux 러너에서 단언할 수 있습니다.

new LauncherFactory().create('win32'); // WindowsLauncher
new ProcessTreeFactory().create('linux'); // PosixProcessTree

또한 플랫폼별 로직이 인라인 분기가 아니라 메서드이기 때문에, 가장 중요한 부분을 직접 단언할 수 있습니다. WindowsLauncher.pick()이 public인 것은 실제 Windows 호스트 없이도 PATHEXT 규칙을 테스트할 수 있도록 하기 위해서입니다.

생성자 기본값을 통한 의존성 주입

섹션 제목: “생성자 기본값을 통한 의존성 주입”

모든 협력 객체는 합리적인 기본값을 가진 생성자 파라미터입니다. 프로덕션 코드는 아무것도 직접 생성하지 않고, 테스트는 자유롭게 대체합니다.

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()뿐이며, 테스트는 플랫폼을 'win32'로 고정하고 기록용 job spawner를 넘길 수 있습니다. 유일한 공유 싱글턴인 AugmentedPath.shared가 존재하는 이유는, 그것을 만드는 데 bash 서브셸 비용이 들고 그 답이 프로세스 내 모든 CLI에 동일하기 때문입니다.