# @robota-sdk/agent-cli 아키텍처 코드 리뷰

검토 기준: AGENTS.md / .agents/rules/code-quality.md / .agents/project-structure.md / packages/agent-cli/docs/SPEC.md

## 요약 테이블

심각도 | 건수 | 카테고리
--- | --- | ---
critical | 2 | 계층 위반 (agent-subagent-runner 직접 의존), InteractiveSession 직접 생성
high | 3 | process.* 사이드이펙트 분산, shellExec 중복, providerSettings.name 의미 혼동
medium | 3 | createDefaultProviderDefinitions 중복 호출, TTY 검출 인라인 하드코딩, TODO 미해결 기능
low | 2 | agentName 하드코딩, TUniversalValue catch 타입 불필요

## 잘된 점 (긍정 평가)

startup/ 레이어 분해 구조
`cli.ts`가 startup 단계를 Layer 0~4로 명시적으로 분리하고, 각 단계를 독립 모듈(preflight → config-phase → provider-setup → session-setup → mode)로 위임한 구조는 명확하다. 각 모듈의 파일 크기가 모두 300줄 이하로 규칙을 준수한다.
allow-fallback 주석 규율
모든 `process.exit` 호출 지점에 `// allow-fallback: terminal failure` 주석이 달려 있어 의도된 종료와 무의식적 silent-fail을 구분할 수 있다. 규칙상 fallback 금지 원칙의 예외를 문서화한 패턴은 유지 가능하다.
args-to-options.ts 경계 설계
`IParsedCliArgs`를 4개의 좁은 옵션 타입(`IConfigPhaseOptions`, `ISessionRunOptions`, `IUserLocalCommandOptions`, `IStartupUpdatePolicyOptions`)으로 분리하는 boundary 변환 계층은 각 startup 모듈이 필요 이상의 필드를 알지 않도록 잘 설계되어 있다.
ITerminalOutput DI 패턴
대부분의 startup 모듈이 `ITerminalOutput` 인터페이스를 주입받아 출력 사이드이펙트를 격리한다. 일부 위반(하단 참조)을 제외하면 DI 원칙을 따른다.

## 발견된 문제

critical #C-01 계층 위반 — agent-subagent-runner 직접 의존
packages/agent-cli/src/startup/subagent-setup.ts : 6
문제 설명

`subagent-setup.ts`가 `@robota-sdk/agent-subagent-runner`를 직접 import한다. `agent-subagent-runner`는 `agent-framework` 위에 선택적으로 올라오는 패키지다. 그러나 `agent-cli`는 이 패키지에 직접 의존함으로써, `agent-cli → agent-framework → agent-subagent-runner` 가 되어야 할 단방향 의존 관계를 `agent-cli`가 두 층을 동시에 쥐는 구조로 만든다. `package.json`의 `dependencies`에도 `@robota-sdk/agent-subagent-runner`가 명시되어 있어 배포 번들 의존성이 직접 노출된다.

SPEC.md는 "OWNS: Concrete local host adapters (background runner, child-process subagent, Git worktree, settings I/O)"라고 명시하므로 CLI가 subagent runner 어댑터를 소유하는 것은 SPEC 범위에 있다. 그러나 현재 구현은 runner 팩토리 생성 로직과 worker 경로 해석까지 CLI 코드 안에 두어 `getDefaultSubagentWorkerPath()`와 `createChildProcessSubagentRunnerFactory()`를 CLI가 직접 호출한다. 이 두 함수가 `agent-framework`를 통해 재수출되거나, CLI가 받아야 할 것은 팩토리 자체가 아니라 설정값뿐이어야 한다.

규칙 참조

`code-quality.md` — "No layer skipping. CLI must not directly use internals that should be wired through agent-sessions or agent-sdk."

`project-structure.md` — "Orchestrator/adapter split. Lifecycle orchestration and handoff metadata belong in reusable lower layers."

권장 조치

옵션 A (선호): `agent-framework`가 `createChildProcessSubagentRunnerFactory`와 `getDefaultSubagentWorkerPath`를 re-export하거나, `createAgentRuntime` 팩토리가 worker 경로를 자동 해석하는 기본 구현을 제공한다. CLI는 `agent-subagent-runner`를 직접 import하지 않는다.

옵션 B: CLI가 runner를 소유해야 한다면, 어댑터 인터페이스(`ISubagentRunnerAdapter` 또는 기존 `TSubagentRunnerFactory`)만 주입받고 실제 팩토리 생성은 `agent-subagent-runner` 패키지가 직접 공개하는 진입점을 통해서만 한다. `package.json`에서 `agent-executor` 직접 의존도 동일하게 검토해야 한다.

critical #C-02 InteractiveSession 직접 생성 — print-mode.ts가 런타임을 우회
packages/agent-cli/src/modes/print-mode.ts : 50–71
문제 설명

`runPrintMode`는 `new InteractiveSession({ ... })`을 직접 호출하여 세션을 생성한다. 이에 반해 `cli.ts`는 `createAgentRuntime()`으로 `IAgentRuntime`을 조립한 뒤 이를 `runPrintMode`에 주입한다. 그런데 `runPrintMode` 내부에서는 전달받은 `runtime`의 필드를 꺼내 `InteractiveSession` 생성자에 직접 넘긴다. 결과적으로 `IAgentRuntime` 추상화가 무의미해지고, print-mode는 `createAgentRuntime`이 계산한 기본값 (예: `backgroundTaskRunners`의 기본 구현)을 신뢰하지 않고 런타임 조립을 부분적으로 재수행한다.

SPEC.md는 CLI가 `InteractiveSession`을 직접 소유하지 말 것을 명시하지는 않지만, "`agent-framework`의 `InteractiveSession`이 세션 생명주기를 소유한다"는 원칙 하에 CLI는 `IAgentRuntime`을 통해 세션 팩토리를 호출해야 한다. 현재 구조에서 `print-mode.ts`는 `IAgentRuntime`을 주입받는다고 선언하지만 실제로는 세션 생성의 책임을 직접 가져간다. 이는 "Composition over integration" 및 "Interface-first extension" 규칙에 어긋난다.

추가 문제: `InteractiveSession`에 전달하는 `agentName: 'robota-cli'`는 하드코딩된 제품명이다(#L-01 참조).

규칙 참조

`code-quality.md` — "CLI/TUI command thinness. agent-cli may not own command-specific state machines or setup flows."

`agent-framework SPEC` — "creates InteractiveSession({ cwd, provider, commandModules }) ... subscribes to events → renders to terminal"

권장 조치

`IAgentRuntime`에 `createSession(opts)` 팩토리 메서드를 추가하거나, `agent-framework`의 `InteractiveSession` 생성자가 `IAgentRuntime`을 직접 받도록 오버로드를 제공한다. `runPrintMode`는 세션을 직접 구성하는 대신 `runtime.createSession(opts)`를 호출하는 방식으로 단순화한다. 이로써 print-mode와 tui-mode가 동일한 런타임 계약을 신뢰하게 된다.

high #H-01 process.* 사이드이펙트가 startup 모듈 전체에 분산
packages/agent-cli/src/startup/session-setup.ts : 29–30 | packages/agent-cli/src/startup/config-phase.ts : 39–40 | packages/agent-cli/src/startup/append-system-prompt.ts : 30–31 | packages/agent-cli/src/startup/preflight.ts : 19–34
문제 설명

`process.exit()`, `process.stderr.write()`, `process.stdout.write()` 호출이 `cli.ts`와 `bin.ts`에만 있는 것이 아니라 `startup/` 하위 5개 모듈에 산재해 있다. 이는 두 가지 심각한 결과를 낳는다.

1) 테스트 불가능성: 각 startup 함수를 단위 테스트하려면 `process.exit`를 목(mock)해야 한다. 실제로 `cli-update-check.test.ts`와 `cli-command-composition.test.ts`를 보면 `vi.spyOn(process.exit)`나 `process.stdout.write`를 직접 패치하는 코드가 반복되어 있다. 이는 DI 없이 사이드이펙트를 직접 호출한 결과다.

2) 책임 혼재: `session-setup.ts`의 경우 세션 ID 해석이라는 순수 로직 함수가 `process.exit(1)`를 직접 호출한다. 이 모듈은 exit 정책을 알 필요가 없다. 반환값으로 오류를 표현(예: `Result<ISessionSetup, Error>` 또는 예외 throw)하고 exit 결정은 `cli.ts`에서 일괄 처리해야 한다.

규칙 참조

`code-quality.md` — "ALWAYS use dependency injection for logging and side concerns."

`code-quality.md` — "Separate core behavior from side concerns."

권장 조치

startup 모듈들은 오류를 throw로 표현하거나 `Result` 타입을 반환하고, `process.exit`와 `process.stderr.write` 호출은 `cli.ts`의 최상위 try-catch 또는 `bin.ts`의 catch 핸들러에 집중한다. `session-setup.ts`의 경우 `resolveSessionIdByIdOrName`가 `undefined`를 반환하면 `createSessionSetup`이 `new Error('Session not found: ...')`를 throw하고, `cli.ts`에서 catch하여 exit한다.

high #H-02 shellExec 클로저 중복 — print-mode와 tui-mode에 동일 코드
packages/agent-cli/src/modes/print-mode.ts : 10, 43–48 | packages/agent-cli/src/modes/tui-mode.ts : 5, 47–52
문제 설명

`SHELL_EXEC_TIMEOUT_MS = 5_000` 상수와 `execSync` 기반 `shellExec` 클로저가 `print-mode.ts`와 `tui-mode.ts` 두 파일에 완전히 동일하게 정의되어 있다. 이는 "No magic numbers or strings" 규칙 위반이며, `Parallel collection invariant`와 동일한 위험인 '동일 로직의 평행 복사본'을 만든다. timeout 값 변경 시 두 파일을 모두 찾아 수정해야 하며, 하나를 놓치는 버그가 발생할 수 있다.

    // print-mode.ts
    const SHELL_EXEC_TIMEOUT_MS = 5_000;
    const shellExec = (command: string): string =>
      execSync(command, { timeout: SHELL_EXEC_TIMEOUT_MS, encoding: 'utf-8', stdio: 'pipe' }).trimEnd();

    // tui-mode.ts — 동일한 코드
    const SHELL_EXEC_TIMEOUT_MS = 5_000;
    shellExec: (command: string): string =>
      execSync(command, { timeout: SHELL_EXEC_TIMEOUT_MS, encoding: 'utf-8', stdio: 'pipe' }).trimEnd(),

규칙 참조

`code-quality.md` — "No magic numbers or strings. Use named constants." / Anti-monolith: 중복 로직은 단일 소유 모듈로 분리해야 한다.

권장 조치

`packages/agent-cli/src/startup/shell-exec.ts`와 같은 단일 유틸 모듈을 만들어 `SHELL_EXEC_TIMEOUT_MS`와 `createShellExec()` 팩토리를 내보내고, 두 mode 파일이 이를 import한다. 더 나아가 이 어댑터는 CLI의 "concrete local host adapter" 소유 범위이므로 startup/ 계층에서 생성하여 mode 함수에 주입하면 테스트 시 mock으로 교체 가능해진다.

high #H-03 providerSettings.name을 providerOverride와 providerType 양쪽에 사용 — 의미론적 혼동
packages/agent-cli/src/modes/tui-mode.ts : 36–37
문제 설명

`tui-mode.ts`는 `TuiTransport`에 아래처럼 전달한다:

    providerOverride: providerSetup.providerSettings.name,
    providerType:     providerSetup.providerSettings.name,

`IProviderConfig.name`은 실제로 provider type 문자열이다 (예: `"anthropic"`, `"openai"`). 이는 `provider-merge.ts`의 `resolveActiveProvider`에서 `name: profile.type`으로 할당됨으로써 확인된다.

반면 `TuiTransport`의 `providerOverride`는 `useSideEffects.ts`에서 `applyActiveModelChange`에 전달되어 `{ providerOverride }` 옵션으로 사용된다. `applyProviderSwitch`나 설정 파일 기준으로는 프로파일 이름(예: `"my-anthropic"`)이 키가 된다. type 문자열을 프로파일 키로 사용하면 모델 전환 시 잘못된 프로파일을 조회하거나 무시하게 된다.

`providerType`의 경우 `SessionStatusBar`가 `cliAdapter.getProviderDisplayName(providerType)`을 호출하므로 type 문자열을 넘기는 것은 올바르다. 그러나 `providerOverride`에도 type 문자열을 넘기는 것은 오류다. 클리닉 사례가 드러나지 않는 이유는 대부분의 사용자가 type과 동일한 이름의 프로파일을 쓰거나, 모델 전환 기능이 특정 상황에서만 활성화되기 때문이다.

권장 조치

`IProviderSetup`에 `activeProfileName: string | undefined` 필드를 추가하고, `createProviderSetup`에서 `opts.provider`(프로파일 이름 override) 또는 설정에서 읽은 `merged.currentProvider`를 그 필드에 저장한다. `tui-mode.ts`는 `providerOverride: providerSetup.activeProfileName`과 `providerType: providerSetup.providerSettings.name`으로 명확히 구분하여 전달한다.

medium #M-01 createDefaultProviderDefinitions() 반복 호출 — 기본 인자 폭포 패턴
packages/agent-cli/src/startup/provider-startup.ts : 31, 58, 78, 106
문제 설명

`provider-startup.ts`에서 4개 함수(`handleProviderConfigurationArgs`, `ensureConfig`, `runInteractiveProviderSetup`, `formatMissingProviderConfigMessage`)가 각각 `providerDefinitions: readonly IProviderDefinition[] = createDefaultProviderDefinitions()`를 기본 인자로 선언한다. 이로 인해 각 함수를 단독으로 호출할 때마다 provider definitions를 새로 생성한다.

실제 호출 지점(`config-phase.ts`)에서는 `commandSetup.providerDefinitions`를 인자로 전달하므로 기본값이 실행 경로에서 사용되지는 않는다. 그러나 이 패턴은: 1) 함수 시그니처가 "기본값으로 작동할 수 있다"는 거짓 계약을 표시하고, 2) `@robota-sdk/agent-provider`에 대한 암묵적 결합을 4곳에 복제하며, 3) "factory context auto-forwarding" 규칙에서 명시한 "callers must not be required to manually extract and forward values" 원칙의 정반대로, 호출자가 값을 가져야 하는데 기본값이 흡수해버린다.

규칙 참조

`code-quality.md` — "No cross-package type duplication" / "Factory context auto-forwarding."

권장 조치

`provider-startup.ts`의 모든 함수에서 `providerDefinitions` 기본 인자를 제거하고, 파라미터를 필수로 변경한다. 호출 계층인 `config-phase.ts`가 항상 `commandSetup.providerDefinitions`를 명시적으로 전달하도록 한다. 이는 현재 코드에서 이미 그렇게 동작하므로 Breaking Change가 아니다.

medium #M-02 TTY 인터랙티브 검출이 startup 모듈 내부에 인라인 하드코딩
packages/agent-cli/src/startup/provider-startup.ts : 68
문제 설명

`ensureConfig` 함수가 `ensureProviderConfig`에 아래를 전달한다:

    isInteractive: () => process.stdin.isTTY === true && process.stdout.isTTY === true,

이 람다는 `process.stdin`과 `process.stdout`을 직접 참조한다. 결과적으로 `ensureConfig`를 단위 테스트할 때 TTY 상태를 제어하려면 `Object.defineProperty(process.stdout, 'isTTY', ...)`를 사용해야 한다. 실제로 `provider-startup.test.ts`에서 이 방식으로 테스트한다. 이는 취약한 환경 조작 방식이며, 실제 환경과 다른 스트림(예: HTTP 서버에서 CLI 기능을 재사용)에서 오작동한다.

또한 `print-mode.ts`에서도 stdin pipe 감지(`!process.stdin.isTTY`)가 인라인으로 있다. 이 두 TTY 검출 방식이 일관된 추상화 없이 분산되어 있다.

권장 조치

`ITerminalEnvironment` 인터페이스(또는 단순히 `{ isInteractive: boolean, hasStdinData: boolean }`)를 startup 단계에서 한 번 생성하여 필요한 모듈에 주입한다. `cli.ts`에서 `process.stdin.isTTY`와 `process.stdout.isTTY`를 읽어 객체로 만들고, startup 모듈들은 그 값을 받아 사용한다.

medium #M-03 --system-prompt 미구현 기능이 경고로만 처리되어 릴리스 코드에 잔존
packages/agent-cli/src/modes/print-mode.ts : 38–41
문제 설명

`print-mode.ts`에 아래 코드가 있다:

    // TODO: wire --system-prompt once IInteractiveSessionStandardOptions adds systemPrompt field
    if (opts.systemPrompt) {
      process.stderr.write('Warning: --system-prompt is not yet functional and will be ignored.\n');
    }

사용자가 `--system-prompt` 옵션을 제공했을 때 아무 일도 일어나지 않으며 경고만 출력된다. `IParsedCliArgs`에 `systemPrompt` 필드가 있고 `ISessionRunOptions`에도 포함되어 있으나 print-mode에서는 무시된다. TUI 모드에서도 이 옵션이 실제로 연결되는지 확인이 필요하다. 미구현 기능이 CLI 인터페이스에 노출되면 사용자 혼란을 유발한다.

규칙 참조

AGENTS.md — "deprecated 금지. 외부 소비자 없으면 삭제, 내부 소비자 있으면 마이그레이션 완료." (미구현 노출 기능도 동일 원칙 적용)

권장 조치

옵션 A: `IInteractiveSessionStandardOptions`에 `systemPrompt` 필드를 추가하고 print-mode에서 연결한다. 옵션 B: 구현이 불가능한 현 시점이라면 `cli-args.ts`에서 해당 옵션을 파싱하지 않고 제거하여 `--system-prompt` 플래그 자체를 비노출 상태로 만든다. 백로그 아이템으로 등록한다.

low #L-01 agentName 하드코딩 — 'robota-cli' 문자열이 두 곳에 분리 존재
packages/agent-cli/src/modes/print-mode.ts : 70 | packages/agent-cli/src/modes/tui-mode.ts : 58
문제 설명

`agentName: 'robota-cli'`가 두 모드 파일에 문자열 리터럴로 중복 정의되어 있다. AGENTS.md의 "No magic numbers or strings" 규칙에 따라 이 값은 단일 상수로 관리되어야 한다. 또한 `readVersion()`으로 이미 CLI 버전을 읽고 있으므로, 패키지 이름도 같은 방식으로 `package.json`에서 읽거나 상수로 추출할 수 있다.

권장 조치

`packages/agent-cli/src/constants.ts` 또는 `startup/version.ts`에 `export const AGENT_NAME = 'robota-cli'`를 선언하고 두 파일이 import한다.

low #L-02 bin.ts의 catch 타입 선언 — TUniversalValue import 불필요
packages/agent-cli/src/bin.ts : 12–13, 33
문제 설명

`bin.ts`에서 `@robota-sdk/agent-core`로부터 `TUniversalValue`를 import하고 `catch((err: Error | TUniversalValue) => ...)` 타입 선언에 사용한다. Promise의 `.catch()` 핸들러는 TypeScript에서 항상 `unknown`이므로 이 타입 선언은 실제 타입 안전성을 제공하지 않는다(TypeScript가 명시적 타입 annotation을 허용하더라도 narrowing은 여전히 필요하다). `TUniversalValue` import는 이를 위해서만 존재하므로 불필요한 크로스-패키지 의존성이다.

실제로 핸들러 내부는 `err instanceof Error ? err.message : String(err)`로 올바르게 narrowing하므로 타입 선언 없이 `err: unknown`으로도 동작한다.

권장 조치

타입 선언을 `(err: unknown) =>`으로 변경하고 `TUniversalValue` import를 제거한다.

## 추가 관찰 — 계층 의존성 검토

파일 | import 대상 | 평가
--- | --- | ---
`command-setup.ts` | `@robota-sdk/agent-provider` | 허용 — SPEC에서 provider definition assembly는 CLI 소유
`provider-startup.ts` | `@robota-sdk/agent-provider` (x4 기본 인자) | 허용이나 중복 (#M-01)
`subagent-setup.ts` | `@robota-sdk/agent-subagent-runner` | 계층 위반 (#C-01)
`print-mode.ts` | `new InteractiveSession` | 런타임 우회 (#C-02)
`bin.ts` | `@robota-sdk/agent-core / TUniversalValue` | 허용이나 불필요 (#L-02)
모든 startup 모듈 | `process.*` 직접 호출 | DI 원칙 위반 (#H-01)

## startup 흐름 순서 의존성 검토

`cli.ts`의 실행 순서(Layer 0 → 4)는 주석으로 명시되어 있으며, 각 단계의 입출력이 타입으로 표현되어 있다. 흐름 자체의 숨겨진 순서 의존성은 발견되지 않았다. 단, `createProviderSetup`은 `configPhaseOpts`의 config 단계 완료를 암묵적으로 가정하며, `ensureConfig`가 실패해도 `provider-setup`은 여전히 호출될 수 있다. 그러나 `config-phase.ts`가 오류 시 `process.exit(1)`을 호출하므로 실질적인 잘못된 순서 실행은 발생하지 않는다. 이 보호가 #H-01의 수정 후에도 유지되도록 주의해야 한다.

## 결론

`agent-cli`는 전반적으로 startup/ 분해, args-to-options 경계, DI 패턴 적용 등에서 명확한 설계 의도를 보여준다. 300줄 이하 파일 크기, allow-fallback 주석 규율, 인터페이스 타입 사용 등 프로젝트 규칙 준수율이 높다.

주요 개선 필요 영역은 두 곳이다: (1) agent-subagent-runner 직접 의존으로 인한 계층 위반과 (2) print-mode의 InteractiveSession 직접 생성으로 인한 IAgentRuntime 추상화 우회. 이 두 문제를 해결하면 CLI의 "thin layer" 원칙이 완전히 구현된다.