콘텐츠로 이동

ActiveCLI란 무엇인가

ActiveCLI는 코딩 에이전트 CLI를 실제 프로세스로 spawn해 하나의 인터페이스로 다룹니다. 벤더의 HTTP API를 호출하지 않으며, 공식 SDK에 의존하지도 않습니다. 사용자가 이미 설치하고 로그인해 둔 그 CLI가 실제로 실행되는 대상입니다.

이는 의도된 선택입니다. CLI는 사용자가 직접 손으로 실행할 수도 있는, 공개되고 문서화된 계약입니다. 내부 프로토콜은 그렇지 않습니다. 라이브러리가 CLI 자신이 제공하는 것에만 의존할 때, 사용자가 터미널에서 할 수 있는 모든 것이 프로그램에서도 그대로 닿을 수 있습니다.

왜 ActiveRecord인가, 그리고 왜 두 번인가

섹션 제목: “왜 ActiveRecord인가, 그리고 왜 두 번인가”

설계는 Rails의 ActiveRecord에서 왔으며, 거기서 서로 다른 두 가지를 빌려옵니다.

어댑터. ActiveRecord는 AbstractAdapter를 정의하고 데이터베이스마다 구체 서브클래스를 하나씩 제공합니다 — mysql2, pg. 그래서 애플리케이션은 자신에게 주어진 어댑터와 대화합니다. 데이터베이스를 바꾸는 일은 모든 호출부를 다시 쓰는 것이 아니라 다른 서브클래스를 고르는 일입니다.

릴레이션. ActiveRecord는 의도를 쌓아 두었다가 마지막에 실행하는 방식으로 쿼리를 만듭니다. where(...).limit(...)to_a가 실행되기 전까지 아무것도 바꾸지 않고, to_sql은 실행하지 않은 채 무엇이 실행될지 보여 줍니다. Command는 커맨드 라인에 대한 동일한 구성입니다 — 세터가 쌓고, toArgv()가 보여 주고, send()가 실행합니다.

ActiveRecord ActiveCLI
Relation Command
where(...).limit(...) — 쌓는다 setModel(...).setMessage(...) — 쌓는다
to_sql — 실행하지 않고 보여 준다 toArgv() / toPayload() / inspect()
to_a — 실행한다 send()
커넥션을 바꾸는 데는 실행 전까지 비용이 들지 않는다 command.provider = Codex는 다음 send() 전까지 비용이 들지 않는다

두 번째로 빌려온 것이 공개 표면을 작게 만듭니다. 호출자는 프로세스도, 어댑터도, spawn 옵션도 직접 만들지 않습니다. 무엇을 원하는지 기술하고, 언제인지를 말할 뿐입니다.

ActiveCLI는 그 아래에 어댑터의 형태를 적용합니다.

ActiveRecord ActiveCLI
AbstractAdapter AbstractProvider가 찍어 내는 AbstractAdapter
Mysql2Adapter, PostgreSQLAdapter ClaudeAdapter, 그리고 앞으로 CLI마다 하나씩
SQL 방언 차이는 서브클래스에 산다 argv, 권한 플래그 어휘, 출력 분류가 서브클래스에 산다
커넥션 풀링은 공유 기계장치다 실행 파일 탐색, PATH, 프로세스 트리가 공유 기계장치다

비유보다 이 구분이 더 중요합니다. 서브클래스는 자기 CLI가 다르게 표기하는 모든 것을 소유합니다. 하지만 실행 파일을 찾는 일, PATH를 보강하는 일, 프로세스 트리를 죽이는 일은 소유하지 않습니다. 그것들은 모든 CLI에 동일한 문제이므로 Process/에 살면서 어댑터에 건네지며, 프로바이더마다 다시 구현되지 않습니다.

provider와 adapter는 같은 것이 아닙니다

섹션 제목: “provider와 adapter는 같은 것이 아닙니다”

호출자는 언제나 provider(Claude)를 지목하며, adapter를 지목하지 않습니다. 이 구분은 의도된 것입니다. adapter는 협력 객체와 상태를 가진 작동하는 객체인 반면, provider는 CLI의 정체성 — “claude와 대화해라” 또는 “codex로 바꿔라”라고 말할 때 가리키는 그 대상입니다.

command.provider = Codex; // 기계장치를 넘겨주는 것이 아니라 값 대입입니다

provider는 command가 실제로 adapter를 필요로 하는 시점, 즉 send()에서 adapter를 찍어 내며, 그 전에는 만들지 않습니다.

왜 CLI 자신의 명령이고, 내부 채널이 아닌가

섹션 제목: “왜 CLI 자신의 명령이고, 내부 채널이 아닌가”

실행 중인 CLI는 문서화되지 않은 제어 채널을 노출하며, 그것은 유혹적입니다. 출력된 텍스트보다 구조적이고 파싱하기도 쉽습니다. 이 라이브러리는 그것을 의존 대상이 아니라 최적화로 취급합니다.

질문 이 라이브러리가 답하는 방식 기각한 대안
어떤 MCP 서버가 설정되어 있는가? claude mcp list를 실행하고 출력된 것을 파싱한다 문서화되지 않은 mcp_status 제어 서브타입
사용자가 로그인되어 있는가? claude auth status --json 자격 증명 파일을 직접 읽기
어떤 버전이 설치되어 있는가? claude --version 설치 디렉터리 들여다보기
현재 턴을 멈추기 제어 채널, 다만 항상 존재하는 폴백으로 stop()을 둔다 제어 채널 단독

대가는 사람을 향해 쓰인 산문을 위한 파서이며, 이는 McpOutputParser 하나에 가둬 둡니다. 이득은, 그 계약이 CLI 자신의 메인테이너들이 함부로 깨뜨릴 수 없는 계약이라는 점입니다. 그들의 사용자가 매일 그 출력을 읽고 있기 때문입니다.

JetBrains IDE용 Claude Code GUI 플러그인인 Swttch가 첫 소비자입니다. backend/src/core/에 이미 있는 CLI 제어 코드가 이 라이브러리로 들어 올려지고 있습니다.

Swttch의 기존 코드 ActiveCLI 클래스
buildClaudeArgs() Adapter.Claude.ClaudeArgv
턴을 조립하고 언제 spawn할지 결정하는 부분 Command
claude mcp list 출력 파싱 Mcp.McpCommands, Mcp.McpOutputParser
claude auth status / auth login Auth.AuthCommands, Auth.LoginSession
spawn + stream-json 파싱 Adapter.Claude.ClaudeAdapter
control_request 수신 Event.PermissionRequestEvent
sendControlResponseToProcess() PermissionRequestEvent#approve() / #deny()
which-launcher.ts, claude-bin-paths.ts Process.Launcher.*
win-exec.ts, win-job.ts Process.Launcher.WindowsLauncher, Process.Job.JobSpawner
wsl-path.ts Path.WslUncPath
parent-watchdog.ts Process.Watchdog.ParentWatchdog
cli-registry.ts Registry.CliRegistry

여기에 새로 설계할 프로토콜은 없습니다. 이미 동작하는 것을 원칙에 따라 클래스에 배치하는 일입니다.

AbstractAdapter가 여전히 작은 이유

섹션 제목: “AbstractAdapter가 여전히 작은 이유”

프로바이더 간의 차이 — Codex의 threadId 대 Claude의 sessionId — 는 두 번째 어댑터를 붙일 때에야 비로소 드러납니다. 하나의 구현에서 뽑아낸 계약은 추측이며, 커밋할 가치가 있는 형태는 두 번째를 견뎌 낸 형태입니다.

이것이 Command에서 공개 표면이 먼저 안정된 이유이기도 합니다. Command는 의도로 말합니다 — 메시지, 모델, 권한 모드. 그중 어느 단어도 Claude의 것이 아닙니다. 두 번째 프로바이더는 send() 아래에서 일어나는 일을 바꿀 뿐, 호출자가 그 위에 쓰는 것을 바꾸지 않습니다.

따라서 의도한 순서는 다음과 같습니다.

  1. ClaudeAdapter를 정확히 옮긴다.
  2. 두 번째 어댑터를 붙이고, 둘로부터 공통 계약을 추출한다.
  3. 그런 다음에야 AbstractAdapter를 확정한다.

ActiveRecord 역시 MySQL 하나로 시작했습니다.