# Claude Usage Status Plan ## 이 파일을 읽는 구현 에이전트에게 **구현 완료 후 `CODE_REVIEW-*-G??.md`의 모든 섹션을 채우는 것이 필수 최종 단계입니다. 이 파일을 채우기 전까지 작업은 완료가 아닙니다.** 아래 체크리스트와 중간/최종 검증을 실제로 실행하고, 구현 내용과 명령 출력을 `CODE_REVIEW-cloud-G07.md`에 기록하세요. `CODE_REVIEW-cloud-G07.md`의 `이 파일을 읽는 리뷰 에이전트에게` 섹션에 있는 아카이브 지시(`*.log` 변경, `complete.log` 작성)는 구현 에이전트가 수행하지 않습니다. ## 배경 `/status` console 명령은 node command 경로와 Codex TUI `/status` 파서까지 연결되어 있지만, 기본 `configs/edge.yaml` target인 `claude`는 `claude status not implemented`를 반환한다. 실제 Claude Code `2.1.142` TUI probe에서는 `/usage` 화면에 `Current session`, `Current week`, `% used`, reset 시각이 표시되며, `100 - used`로 남은 사용량을 계산할 수 있었다. 이번 작업은 Codex 구현 방식을 따라 Claude TUI를 PTY로 조작하고, `./bin/edge.sh` + `./bin/node.sh` 수동 검증에서 `/status`가 남은 사용량과 초기화 시각을 출력하게 만든다. ## 분석 결과 ### 읽은 파일 - `agent-ops/rules/project/rules.md` - `agent-ops/rules/project/domain/node/rules.md` - `agent-ops/rules/project/domain/edge/rules.md` - `agent-ops/skills/common/router.md` - `agent-ops/skills/common/plan/SKILL.md` - `agent-task/cli_usage_status_command/plan_0.log` - `agent-task/cli_usage_status_command/plan_1.log` - `bin/edge.sh` - `bin/node.sh` - `configs/edge.yaml` - `configs/node.yaml` - `apps/node/internal/adapters/cli/status/status.go` - `apps/node/internal/adapters/cli/status/parser.go` - `apps/node/internal/adapters/cli/status/codex.go` - `apps/node/internal/adapters/cli/status/claude.go` - `apps/node/internal/adapters/cli/status/gemini.go` - `apps/node/internal/adapters/cli/status/status_test.go` - `apps/node/internal/adapters/cli/status/parser_test.go` - `apps/node/internal/adapters/cli/status/codex_test.go` - `apps/edge/cmd/edge/console.go` - `apps/edge/cmd/edge/console_test.go` - `apps/node/internal/runtime/types.go` - `proto/iop/runtime.proto` ### 테스트 커버리지 공백 - Claude checker 선택: 기존 `TestClaudeReturnsNotImplemented`는 미구현 error만 검증한다. `NewChecker("claude", profile)`가 profile command를 보존해 `ClaudeChecker`를 만드는 테스트가 필요하다. - Claude `/usage` 파싱: 기존 parser test는 Codex `5h limit`/`Weekly limit`만 검증한다. Claude의 `Current session`, `Current week`, `% used`, `Resets ...`를 남은 퍼센트로 변환하는 회귀 테스트가 필요하다. - Claude PTY 조작: Codex는 fake TUI로 `/status` 입력을 검증하지만 Claude에는 동일 테스트가 없다. fake `claude` script로 `/usage` 입력, usage block 파싱, 종료 입력 흐름을 검증한다. - Edge 출력 문구: 기존 console test는 `Daily limit`/`Weekly limit`만 확인한다. Claude metadata label을 사용해 `Current session: 98% remaining (resets ...)` 형태로 출력되는 테스트가 필요하다. - 실제 bin smoke: 기존 unit test만으로 `./bin/edge.sh`와 `./bin/node.sh`의 console `/status` end-to-end는 보장하지 못한다. 최종 검증에 실제 bin shell 기반 smoke command를 포함한다. ### 심볼 참조 - renamed/removed symbol 없음. - 참조 확인 명령: `rg --sort path -n "NewClaudeChecker|ClaudeChecker|ParseStatusOutput|cleanANSI|DailyLimit|WeeklyLimit|formatUsageStatus|CheckUsage|NewChecker" apps/node/internal/adapters/cli apps/edge/cmd/edge packages configs proto` - `NewClaudeChecker` 호출부는 `apps/node/internal/adapters/cli/status/status.go`뿐이며, 시그니처 변경 시 해당 호출과 status tests를 함께 갱신한다. - `formatUsageStatus` 호출부는 `apps/edge/cmd/edge/console.go` 내부 1곳과 `apps/edge/cmd/edge/console_test.go` 테스트 2곳이다. ### 범위 결정 근거 - `proto/iop/runtime.proto`와 `proto/gen/iop/*.pb.go`는 수정하지 않는다. `AgentUsageStatus.metadata`가 이미 있으므로 Claude label 전달은 기존 계약으로 충분하다. - `configs/edge.yaml`은 수정하지 않는다. 현재 console target은 이미 `claude`이며, 작업 전부터 dirty 상태이므로 unrelated config 변경을 섞지 않는다. - `apps/node/internal/adapters/cli/cli.go`의 command dispatch는 수정하지 않는다. `HandleCommand`가 이미 `status.CheckUsage(ctx, req.Target, profile)`을 호출한다. - Gemini status는 계속 미구현으로 둔다. 이번 요청은 Claude `/usage` 지원에 한정한다. - CLI execution prompt 경로(`claude -p ...`)는 변경하지 않는다. status 조회는 TUI command path만 재사용하고 profile args는 사용하지 않는다. ### 빌드 등급 - build lane: `local-G06` — 변경 범위는 status parser/checker와 edge 출력에 한정되며 fake TUI unit test가 가능하다. - review lane: `cloud-G07` — 실제 TUI/PTY와 bin smoke 검증이 포함되어 환경 의존성이 있으므로 리뷰에서 출력 신뢰성과 범위 준수를 더 강하게 확인한다. ## 의존 관계 및 구현 순서 1. `API-1`에서 status schema metadata와 Claude parser를 먼저 추가한다. 2. `API-2`에서 Claude TUI checker가 `/usage`를 실행해 `API-1` parser를 호출하게 한다. 3. `API-3`에서 edge console 출력과 bin smoke 검증을 Claude 결과에 맞춘다. ### [API-1] Claude `/usage` 출력 파서와 usage metadata 추가 #### 문제 `apps/node/internal/adapters/cli/status/parser.go:8-13`은 Codex의 `5h limit`/`Weekly limit` 형식만 찾는다. Claude `/usage`는 `Current session`, `Current week (all models)`, `2% used`, `Resets 10am (Asia/Seoul)`처럼 출력되어 기존 정규식으로는 `DailyLimit`과 `WeeklyLimit`이 비어 edge가 raw fallback을 출력한다. 또한 `apps/node/internal/adapters/cli/status/status.go:13-19`의 local `UsageStatus`에는 metadata 필드가 없어 이미 존재하는 runtime/proto metadata에 “Current session” 같은 label을 전달할 수 없다. Before (`apps/node/internal/adapters/cli/status/parser.go:8`): ```go var ( // Regex patterns based on the Codex output format // "5h limit: [████████████████████] 98% left (resets 18:38)" // "Weekly limit: [████░░░░░░░░░░░░░░░░] 22% left (resets 10:20 on 8 May)" dailyLimitRegex = regexp.MustCompile(`5h limit:.*?\]\s*([^%\n]+%)[^\(]*\(resets\s+([^\)]+)\)`) weeklyLimitRegex = regexp.MustCompile(`Weekly limit:.*?\]\s*([^%\n]+%)[^\(]*\(resets\s+([^\)]+)\)`) ) ``` Before (`apps/node/internal/adapters/cli/status/status.go:13`): ```go type UsageStatus struct { RawOutput string `json:"raw_output"` DailyLimit string `json:"daily_limit,omitempty"` DailyResetTime string `json:"daily_reset_time,omitempty"` WeeklyLimit string `json:"weekly_limit,omitempty"` WeeklyResetTime string `json:"weekly_reset_time,omitempty"` // Additional fields can be added as needed } ``` #### 해결 방법 `UsageStatus`에 `Metadata map[string]string`을 추가하고 `ToRuntime()`이 복사하게 한다. Codex 파싱 시 label metadata를 `daily_label=5h limit`, `weekly_label=Weekly limit`로 채우고, Claude 파싱 시 `daily_label=Current session`, `weekly_label=Current week`로 채운다. `ParseStatusOutput`은 기존 Codex regex를 먼저 유지하고, 없으면 Claude regex로 `used` 값을 찾아 `100-used`를 `%` 문자열로 저장한다. ANSI 정리는 Claude TUI의 cursor-forward escape를 공백으로 바꾼 뒤 OSC/CSI escape를 제거하도록 보강한다. 이 변경은 Codex parser에도 적용되므로 기존 Codex parser tests가 계속 통과해야 한다. After: ```go type UsageStatus struct { RawOutput string `json:"raw_output"` DailyLimit string `json:"daily_limit,omitempty"` DailyResetTime string `json:"daily_reset_time,omitempty"` WeeklyLimit string `json:"weekly_limit,omitempty"` WeeklyResetTime string `json:"weekly_reset_time,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` } ``` ```go var ( claudeSessionRegex = regexp.MustCompile(`(?s)Current session.*?(\d+)% used\s+Resets\s+([^\n]+)`) claudeWeekRegex = regexp.MustCompile(`(?s)Current week(?: \(all models\))?.*?(\d+)% used\s+Resets\s+([^\n]+)`) ) ``` #### 수정 파일 및 체크리스트 - [ ] `apps/node/internal/adapters/cli/status/status.go` - `UsageStatus.Metadata` 추가, `ToRuntime()` metadata 복사 - [ ] `apps/node/internal/adapters/cli/status/parser.go` - Claude regex, `used` → remaining percent 변환 helper, metadata label 설정 추가 - [ ] `apps/node/internal/adapters/cli/status/parser.go` - `cleanANSI`가 OSC/CSI 전체와 cursor-forward spacing을 처리하도록 보강 - [ ] `apps/node/internal/adapters/cli/status/parser_test.go` - Claude `/usage` sample test 추가 - [ ] `apps/node/internal/adapters/cli/status/parser_test.go` - ANSI cursor-forward가 섞인 Claude sample test 추가 - [ ] `apps/node/internal/adapters/cli/status/codex_test.go` - Codex parser/checker 기존 assertion이 깨지지 않는지 유지 #### 테스트 작성 - `apps/node/internal/adapters/cli/status/parser_test.go` - `TestParseStatusOutput_ClaudeUsage`: `Current session`의 `2% used`가 `DailyLimit == "98%"`, reset `10am (Asia/Seoul)`로 변환되고 `Current week`의 `0% used`가 `WeeklyLimit == "100%"`, reset `May 16, 6pm (Asia/Seoul)`로 변환되는지 검증한다. - `apps/node/internal/adapters/cli/status/parser_test.go` - `TestParseStatusOutput_ClaudeUsageWithANSICursorSpacing`: `Current\x1b[1Csession`, `2%\x1b[1Cused` 같은 TUI escape가 있어도 같은 값이 파싱되는지 검증한다. #### 중간 검증 ```bash go test ./apps/node/internal/adapters/cli/status/... -run 'TestParseStatusOutput' -count=1 ``` 기대 결과: Codex parser test와 새 Claude parser tests가 모두 통과한다. 캐시 출력은 허용하지 않으므로 `-count=1`을 유지한다. ### [API-2] ClaudeChecker를 PTY 기반 `/usage` 조회로 구현 #### 문제 `apps/node/internal/adapters/cli/status/claude.go:8-16`은 stub이라 `/status`가 `cli/claude` target에 대해 항상 `claude status not implemented` error를 반환한다. `apps/node/internal/adapters/cli/status/status.go:57-58`도 `NewClaudeChecker()`에 profile command를 전달하지 않아, config에서 `claude` command path가 바뀌면 status checker가 이를 사용할 수 없다. Before (`apps/node/internal/adapters/cli/status/claude.go:8`): ```go type ClaudeChecker struct{} func NewClaudeChecker() *ClaudeChecker { return &ClaudeChecker{} } func (c *ClaudeChecker) Check(ctx context.Context) (*UsageStatus, error) { return nil, fmt.Errorf("claude status not implemented") } ``` Before (`apps/node/internal/adapters/cli/status/status.go:57`): ```go case agent == "claude" || cmdBase == "claude": return NewClaudeChecker(), nil ``` #### 해결 방법 Codex checker 구조를 참고해 `ClaudeChecker`에 `command string`을 보관하고, `NewClaudeChecker(command string)`이 빈 command를 `"claude"`로 보정한다. `Check`는 `exec.CommandContext(ctx, c.command)`를 `pty.StartWithSize`로 띄우고 `TERM=xterm-256color`를 설정한다. Profile args는 실행 prompt용 `-p`/`stream-json`이므로 status TUI 조회에는 사용하지 않는다. 실행 흐름: 1. startup ready regex `Claude Code|Try|Welcome back|/effort` 중 하나를 기다린다. 2. `\x15/usage\r`를 입력한다. 3. `Current session`과 `Current week`가 모두 clean output에 나타날 때까지 기다린다. 4. 짧게 flush 후 `ParseStatusOutput(fullOutput)`을 호출한다. 5. `Esc`, `Ctrl+U`, `/exit\r` 순서로 종료를 시도하고 defer kill은 유지한다. After: ```go type ClaudeChecker struct { command string } func NewClaudeChecker(command string) *ClaudeChecker { if command == "" { command = "claude" } return &ClaudeChecker{command: command} } ``` #### 수정 파일 및 체크리스트 - [ ] `apps/node/internal/adapters/cli/status/claude.go` - imports 추가: `context`, `fmt`, `io`, `os`, `os/exec`, `regexp`, `strings`, `time`, `github.com/creack/pty` - [ ] `apps/node/internal/adapters/cli/status/claude.go` - command field와 constructor 보정 추가 - [ ] `apps/node/internal/adapters/cli/status/claude.go` - PTY startup, waitFor helper, sendText helper, `/usage` 입력, output flush, graceful exit 구현 - [ ] `apps/node/internal/adapters/cli/status/status.go` - `NewClaudeChecker(profile.Command)` 호출로 변경 - [ ] `apps/node/internal/adapters/cli/status/status_test.go` - `TestNewChecker_ClaudeUsesProfileCommand` 추가 - [ ] `apps/node/internal/adapters/cli/status/status_test.go` - `TestClaudeReturnsNotImplemented` 제거 또는 성공 checker 선택 테스트로 교체 - [ ] `apps/node/internal/adapters/cli/status/claude_test.go` - fake TUI 기반 checker test 추가 #### 테스트 작성 - `apps/node/internal/adapters/cli/status/status_test.go` - `TestNewChecker_ClaudeUsesProfileCommand`: `NewChecker("claude", config.CLIProfileConf{Command: "/tmp/myclaude"})`가 `*ClaudeChecker`를 반환하고 `command == "/tmp/myclaude"`인지 검증한다. - `apps/node/internal/adapters/cli/status/claude_test.go` - `TestClaudeCheckerRequestsUsageAndParsesLimits`: fake shell script가 ready text를 출력하고 `/usage` 입력을 받은 뒤 Claude usage block을 출력한다. `Check` 결과가 `DailyLimit == "98%"`, `DailyResetTime == "10am (Asia/Seoul)"`, `WeeklyLimit == "100%"`, `WeeklyResetTime == "May 16, 6pm (Asia/Seoul)"`인지 검증한다. - `apps/node/internal/adapters/cli/status/claude_test.go` - fake script는 `/usage`가 아닌 입력을 받으면 exit code 3으로 실패하게 해 checker가 실제 slash command를 보낸다는 점을 고정한다. #### 중간 검증 ```bash go test ./apps/node/internal/adapters/cli/status/... -run 'TestNewChecker_Claude|TestClaudeChecker|TestParseStatusOutput_Claude' -count=1 ``` 기대 결과: 외부 Claude binary 없이 fake TUI 테스트가 통과한다. 캐시 출력은 허용하지 않는다. ### [API-3] Edge `/status` 출력에 remaining label을 반영하고 bin smoke 검증 추가 #### 문제 `apps/edge/cmd/edge/console.go:365-370`은 parsed status를 `Daily limit: ...`와 `Weekly limit: ...`로만 출력한다. Claude에서 `DailyLimit`에 매핑되는 값은 TUI의 `Current session` remaining이므로, 사용자가 `./bin/edge.sh`에서 `/status`를 입력했을 때 “남은 사용량”과 “언제 초기화되는지”를 바로 읽기 어렵다. Before (`apps/edge/cmd/edge/console.go:365`): ```go if status.GetDailyLimit() != "" { fmt.Fprintf(out, "Daily limit: %s (resets %s)\n", status.GetDailyLimit(), status.GetDailyResetTime()) hasParsedLimits = true } if status.GetWeeklyLimit() != "" { fmt.Fprintf(out, "Weekly limit: %s (resets %s)\n", status.GetWeeklyLimit(), status.GetWeeklyResetTime()) hasParsedLimits = true } ``` #### 해결 방법 `status.GetMetadata()`의 `daily_label`/`weekly_label`을 출력 label로 사용하고, 값 뒤에는 `remaining`을 붙인다. Metadata가 없는 기존 response는 `Daily limit`/`Weekly limit`으로 fallback한다. reset time이 비어 있으면 `(resets )`를 출력하지 않도록 작은 formatting helper를 둔다. After: ```go dailyLabel := usageStatusLabel(status.GetMetadata(), "daily_label", "Daily limit") if status.GetDailyLimit() != "" { fmt.Fprintf(out, "%s: %s remaining%s\n", dailyLabel, status.GetDailyLimit(), resetSuffix(status.GetDailyResetTime())) hasParsedLimits = true } ``` #### 수정 파일 및 체크리스트 - [ ] `apps/edge/cmd/edge/console.go` - metadata label fallback helper와 reset suffix helper 추가 - [ ] `apps/edge/cmd/edge/console.go` - parsed output을 `