검토 기준: 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 타입 불필요 |
cli.ts가 startup 단계를 Layer 0~4로 명시적으로 분리하고, 각 단계를 독립 모듈(preflight → config-phase → provider-setup → session-setup → mode)로 위임한 구조는 명확하다. 각 모듈의 파일 크기가 모두 300줄 이하로 규칙을 준수한다.
process.exit 호출 지점에 // allow-fallback: terminal failure 주석이 달려 있어 의도된 종료와 무의식적 silent-fail을 구분할 수 있다. 규칙상 fallback 금지 원칙의 예외를 문서화한 패턴은 유지 가능하다.
IParsedCliArgs를 4개의 좁은 옵션 타입(IConfigPhaseOptions, ISessionRunOptions, IUserLocalCommandOptions, IStartupUpdatePolicyOptions)으로 분리하는 boundary 변환 계층은 각 startup 모듈이 필요 이상의 필드를 알지 않도록 잘 설계되어 있다.
ITerminalOutput 인터페이스를 주입받아 출력 사이드이펙트를 격리한다. 일부 위반(하단 참조)을 제외하면 DI 원칙을 따른다.
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 직접 의존도 동일하게 검토해야 한다.
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가 동일한 런타임 계약을 신뢰하게 된다.
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한다.
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으로 교체 가능해진다.
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으로 명확히 구분하여 전달한다.
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가 아니다.
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 모듈들은 그 값을 받아 사용한다.
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 플래그 자체를 비노출 상태로 만든다. 백로그 아이템으로 등록한다.
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한다.
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) |
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" 원칙이 완전히 구현된다.