<!-- task=cli_claude_usage_status plan=0 tag=API -->

# 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을 `<label>: <percent> remaining (resets <time>)`로 변경
- [ ] `apps/edge/cmd/edge/console_test.go` - 기존 `TestFormatUsageStatus_PrintsParsedLimits`를 새 문구로 갱신
- [ ] `apps/edge/cmd/edge/console_test.go` - Claude metadata labels가 `Current session`/`Current week`로 출력되는 test 추가
- [ ] `bin/edge.sh`/`bin/node.sh` - 수정하지 않고 최종 smoke에 사용

#### 테스트 작성

- `apps/edge/cmd/edge/console_test.go` - `TestFormatUsageStatus_PrintsParsedLimits`: metadata 없이 `Daily limit: 98% remaining (resets 18:38)`, `Weekly limit: 22% remaining ...` fallback이 출력되는지 검증한다.
- `apps/edge/cmd/edge/console_test.go` - `TestFormatUsageStatus_PrintsClaudeLabels`: metadata `daily_label=Current session`, `weekly_label=Current week`가 있으면 해당 label로 출력되는지 검증한다.

#### 중간 검증

```bash
go test ./apps/edge/cmd/edge -run 'TestFormatUsageStatus' -count=1
```

기대 결과: 기존 raw fallback test와 새 remaining label tests가 통과한다. 캐시 출력은 허용하지 않는다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `apps/node/internal/adapters/cli/status/status.go` | API-1, API-2 |
| `apps/node/internal/adapters/cli/status/parser.go` | API-1 |
| `apps/node/internal/adapters/cli/status/parser_test.go` | API-1 |
| `apps/node/internal/adapters/cli/status/claude.go` | API-2 |
| `apps/node/internal/adapters/cli/status/claude_test.go` | API-2 |
| `apps/node/internal/adapters/cli/status/status_test.go` | API-2 |
| `apps/edge/cmd/edge/console.go` | API-3 |
| `apps/edge/cmd/edge/console_test.go` | API-3 |

## 최종 검증

```bash
go test ./apps/node/internal/adapters/cli/status/... -count=1
go test ./apps/edge/cmd/edge -count=1
gofmt -l apps/node/internal/adapters/cli/status/status.go apps/node/internal/adapters/cli/status/parser.go apps/node/internal/adapters/cli/status/claude.go apps/node/internal/adapters/cli/status/claude_test.go apps/node/internal/adapters/cli/status/status_test.go apps/node/internal/adapters/cli/status/parser_test.go apps/edge/cmd/edge/console.go apps/edge/cmd/edge/console_test.go
git diff --check
command -v claude
```

기대 결과: Go tests가 통과하고 `gofmt -l` 출력이 비어 있으며 `git diff --check`가 성공한다. `command -v claude`는 bin smoke 전제 확인용이며, 실패하면 bin smoke를 실행하지 말고 `CODE_REVIEW-cloud-G07.md`에 unavailable 사유와 출력을 그대로 기록한다.

실제 bin smoke는 아래 명령을 사용한다. 외부 Claude 인증/사용량 화면에 의존하므로 실패 시 stdout/stderr를 그대로 기록하고, unit tests 통과 여부와 분리해 판단한다.

```bash
tmpdir="$(mktemp -d /tmp/iop-claude-status.XXXXXX)"
mkfifo "$tmpdir/edge.in"
./bin/edge.sh < "$tmpdir/edge.in" > "$tmpdir/edge.out" 2>&1 &
edge_pid=$!
exec 9>"$tmpdir/edge.in"
sleep 2
./bin/node.sh > "$tmpdir/node.out" 2>&1 &
node_pid=$!
sleep 5
printf '/status\n/exit\n' >&9
timeout 120 bash -c 'while kill -0 "$0" 2>/dev/null; do sleep 1; done' "$edge_pid" || true
kill "$node_pid" "$edge_pid" 2>/dev/null || true
exec 9>&-
sed -n '1,220p' "$tmpdir/edge.out"
sed -n '1,220p' "$tmpdir/node.out"
rg --sort path -n 'Current session: [0-9]+% remaining|Daily limit: [0-9]+% remaining|Current week: [0-9]+% remaining|Weekly limit: [0-9]+% remaining|resets' "$tmpdir/edge.out"
```

기대 결과: `edge.out`에 `[edge] sent command=status ... target=claude`, `[node-...-status] target=claude`, 남은 사용량 percent, reset 시각이 출력된다.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 전체 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
