diff --git a/docs/ai/design/2026-09-25-feature-claude-capacity.md b/docs/ai/design/2026-09-25-feature-claude-capacity.md new file mode 100644 index 00000000..9beac5e8 --- /dev/null +++ b/docs/ai/design/2026-09-25-feature-claude-capacity.md @@ -0,0 +1,107 @@ +--- +phase: design +title: Claude Capacity Design +description: Read-only Claude OAuth usage provider using existing capacity contracts +--- + +# Claude Capacity Design + +## Architecture Overview + +```mermaid +flowchart LR + CLI[capacity provider selection] --> Manager[getClaudeCapacityReport] + Manager --> Credentials[Environment then profile file then default-profile macOS Keychain] + Credentials --> Probe[Claude OAuth usage GET] + Probe --> Normalize[CapacityReport and CapacityWindow] + Normalize --> Renderer[Existing text or JSON renderer] +``` + +The change adds one provider module under `packages/agent-manager/src/capacity/` and one direct branch in the existing CLI selector. The provider module owns credential resolution, request construction, response/error handling, and normalization. No generic provider layer is added. + +## Data Models + +- `CapacityReport`: unchanged; Claude reports `harness: "claude"`, `provider: "anthropic"`, generation time, auth/availability state, windows, and `creditsRemaining: null`. +- `CapacityWindow`: unchanged. Session, weekly, and model scopes use percentage/reset fields. Extra usage additionally uses `limitType: "CREDIT_LIMIT"` and monetary `total`, `current`, and `remaining` values. +- Claude credential input: OAuth access token plus optional expiry, resolved without returning metadata to callers. +- Claude usage payload: treated as untrusted `unknown`; parsers accept only finite numbers, valid timestamps, non-empty model identity, and plain objects/arrays. + +## API Design + +`getClaudeCapacityReport(options?)` mirrors the existing z.ai entry point and accepts injectable `now`, `env`, `readFile`, `keychainRead`, `platform`, `fetch`, and `timeoutMs` boundaries. + +The provider sends: + +```text +GET https://api.anthropic.com/api/oauth/usage +Authorization: Bearer +Accept: application/json +Content-Type: application/json +anthropic-beta: oauth-2025-04-20 +User-Agent: claude-code/2.1.0 +``` + +The public report never contains the token, credential path, raw response body, or provider exception. + +### Normalization contract + +| Source | Window ID | Label | Duration | +| --------------------- | ---------------------------- | -------------------------- | -------------- | +| `five_hour` | `session` | Session | 300 minutes | +| `seven_day` | `weekly` | Weekly | 10,080 minutes | +| `seven_day_sonnet` | `claude:sonnet:weekly` | Sonnet weekly | 10,080 minutes | +| `seven_day_opus` | `claude:opus:weekly` | Opus weekly | 10,080 minutes | +| scoped `limits` entry | `claude:weekly:` | ` weekly` | 10,080 minutes | +| enabled `extra_usage` | `claude:extra-usage` | `Extra usage · ` | unknown | + +Window parsers keep a valid object even when utilization or reset is absent, setting the corresponding normalized field to `null`. Invalid optional entries are skipped. Scoped entries require the observed `weekly_scoped`/`weekly` classification and a non-all-model identity; first occurrence wins on duplicate IDs. + +Availability follows the existing capacity convention: `yes` when any window has utilization below 100, `no` when at least one utilization exists and all known utilizations are at least 100, and `unknown` when none is known. + +### Failure contract + +| Condition | Result | +| -------------------------------------- | ---------------------------------------------------------------- | +| Missing/malformed credential source | Sanitized credential error before fetch | +| Known expired file/Keychain credential | Sanitized expired-auth error before fetch | +| HTTP 401 | Sanitized unauthorized error | +| HTTP 403 | Sanitized forbidden error | +| HTTP 429 | Sanitized rate-limit error with normalized retry time when valid | +| Other non-2xx | Sanitized status-only request error | +| Network/abort | Sanitized request-failed error | +| Invalid JSON/non-object JSON | Sanitized malformed-response error | + +These failures use ordinary provider-local errors because existing CLI behavior already propagates a single-provider failure and warns while retaining successful reports for a multi-provider request. + +## Component Breakdown + +- `capacity/claude.ts`: constants, credential resolution, parsing, availability calculation, bounded fetch, Retry-After parsing, and safe provider errors. +- `capacity/claude.ts`: provider-owned `ClaudeCapacityOptions`, credential resolution, request handling, and normalization. +- `capacity/index.ts`: `getClaudeCapacityReport` wrapper and option-type re-export with injected clock. +- `agent-manager/src/index.ts`: public export. +- `cli/commands/capacity.ts`: add `claude` to the supported union/list and direct reader branch. +- `cli/commands/capacity/render.ts`: add the `anthropic` display label; existing window rendering is reused. +- Provider fixtures/tests: credentials and usage shapes plus HTTP/error cases. +- CLI tests: selection, default provider set, JSON/text rendering, and partial failure. + +## Design Decisions + +| Decision | Choice | Rationale | +| ------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------- | +| Credential sources | Environment, profile file, then Keychain | Preserves explicit/profile precedence while supporting normal macOS Claude Code login. | +| Keychain | Default macOS profile only | Avoids mixing one global item into an explicitly selected custom profile. | +| Claude CLI fallback | Defer | Auth status has no usage; TUI scraping is interactive and brittle. | +| User-Agent version | Fixed fallback version | Satisfies the endpoint without spawning Claude or adding detection machinery. | +| Monetary data | Existing credit-limit fields | Preserves returned extra-usage values without changing the public contract. | +| Model limits | Known flat fields plus scoped `limits` | Covers observed endpoint shapes without guessing arbitrary fields. | +| Partial payloads | Preserve valid entries | Missing optional provider data must not erase truthful windows or become zero. | +| Provider selection | One direct CLI branch | Three providers do not justify a registry or new abstraction. | + +## Non-Functional Requirements + +- One bounded request per Claude probe; default timeout matches existing capacity providers. +- No credential writes, refresh, caching, retries, or Claude CLI calls. Keychain access uses fixed `/usr/bin/security` arguments without a shell, a 1.5-second timeout, and a bounded output buffer. +- All error messages are stable and omit response bodies and credentials. +- 429 exposes only a normalized retry time when `Retry-After` is valid. +- Parsing time is linear in the number of returned limits, with deterministic de-duplication. +- New and changed code targets complete branch coverage where practical and must pass repository lint, build, focused tests, and full tests. diff --git a/docs/ai/implementation/2026-09-25-feature-claude-capacity.md b/docs/ai/implementation/2026-09-25-feature-claude-capacity.md new file mode 100644 index 00000000..faf0bc69 --- /dev/null +++ b/docs/ai/implementation/2026-09-25-feature-claude-capacity.md @@ -0,0 +1,87 @@ +--- +phase: implementation +title: Claude Capacity Implementation Record +description: Implementation details and validation evidence for Claude subscription capacity +--- + +# Claude Capacity Implementation Record + +## Development Setup + +- Active worktree: `.worktrees/feature-claude-capacity` +- Branch: `feature-claude-capacity` +- Bootstrap: `npm ci` +- Initial workspace build: `npm run build` +- No new dependencies or configuration files were added. + +## Code Structure + +- `packages/agent-manager/src/capacity/claude.ts`: provider-owned options, Claude credential resolution, usage parsing, request construction, timeout, and sanitized failures. +- `packages/agent-manager/src/capacity/index.ts`: clock-aware public report reader and option-type re-export. +- `packages/agent-manager/src/index.ts`: package-level public export. +- `packages/cli/src/commands/capacity.ts`: direct Claude provider selection alongside Codex and z.ai. +- `packages/cli/src/commands/capacity/render.ts`: Anthropic display label using the existing renderer. +- `packages/agent-manager/src/__tests__/capacity/fixtures/claude-*.json`: fake credential, complete, partial, and malformed fixtures. +- Provider and CLI capacity tests cover all requested behavior without live access. + +## Implementation Notes + +### Credential handling + +- `CLAUDE_CODE_OAUTH_TOKEN` has precedence and bypasses file reads. +- A non-empty `CLAUDE_CONFIG_DIR` selects the profile root; relative values resolve against the injected/current working directory. +- The fallback path is `$HOME/.claude/.credentials.json` (or the operating-system home when `HOME` is absent). +- On macOS, a missing or tokenless default profile falls back to the `Claude Code-credentials` generic-password service through bounded `/usr/bin/security` execution. +- macOS controls access approval and may display a Keychain prompt on first use; implementation and tests never invoke the real reader. +- A custom `CLAUDE_CONFIG_DIR` never falls back to the global Keychain item, preventing cross-profile credential mixing. +- Only `claudeAiOauth.accessToken` and optional numeric millisecond `expiresAt` are read. +- Known-expired credentials fail before the fetch. Missing expiry is accepted. +- No refresh token, credential write, or Claude subprocess path exists. +- Profile-file and Keychain parsing use separate immutable credential results, keeping source precedence explicit and preventing state from one source being reused as another. + +### Usage mapping + +- Five-hour and seven-day account windows map to session and weekly capacity. +- Flat Sonnet and Opus weekly fields map independently. +- Weekly scoped limits use stable model slugs, ignore all-model scopes, keep the first duplicate, and do not filter on `is_active`. +- Enabled extra usage maps to the existing `CREDIT_LIMIT` fields after converting both returned minor-unit amounts to major units. +- Missing/invalid optional fields remain `null` or are skipped; they never become token totals. +- Availability is derived only from known utilization values. + +### Request and errors + +- One bounded GET is made to the OAuth usage endpoint with the exact Bearer, beta, JSON, and Claude Code User-Agent headers. +- 401, 403, 429, other non-success statuses, network failures, and malformed JSON have provider-local sanitized errors. +- Numeric and HTTP-date Retry-After values normalize against the injected clock. +- Response bodies, paths, and tokens are never included in errors or reports. + +## Integration Points + +- The existing multi-provider loop remains sequential and keeps successful reports when another provider fails. +- Explicit `Claude` input normalizes case-insensitively to `claude`. +- Default capacity now probes `codex`, `zai`, and `claude`. +- The existing renderer sorts windows by duration and renders credit totals without a Claude-specific path. + +## Design Alignment + +The implementation matches the approved provider-local design and unchanged public capacity contracts. There are no material design deviations. The fixed `claude-code/2.1.0` User-Agent intentionally avoids version-detection subprocess behavior. + +## Validation Evidence + +- Red/green cycles were observed for the public entry point, environment request, profile credential, Keychain fallback/profile isolation, expiry, complete mapping, 401, 403, 429, malformed JSON, network failure, package export, and CLI provider selection. +- Latest focused provider run: 27 tests passed, 0 failed. +- Latest focused CLI capacity run: 13 tests passed, 0 failed. +- Full agent-manager run: 57 files and 689 tests passed. +- Full CLI run: 102 files and 1,207 tests passed. +- Full repository run: all six workspaces passed, totaling 199 files and 2,307 tests. +- Full agent-manager coverage passed its thresholds: 88.54% statements, 77.31% branches, 93.58% functions, and 91.58% lines. `claude.ts` reached 94.89% statements, 85.71% branches, 95.83% functions, and 95.86% lines; the uncovered paths are a rejected injected Keychain reader and the real `/usr/bin/security` boundary that tests deliberately avoid invoking. +- Workspace build, repository lint, feature lint, new-file formatting, and `git diff --check` passed. +- A focused-only coverage invocation exited nonzero because package-global thresholds include unrelated files omitted by that focused run; the subsequent full package coverage run passed. +- Repository-wide formatting remains red on the same 37 files as the base worktree. A formatter check over every new source, test, fixture, and lifecycle document passed; modified existing files retain their baseline style to avoid unrelated formatting churn. +- Final design-alignment review found no blocking, important, or nice-to-have findings. + +## Security Notes + +- Tests contain only obvious fake tokens and fixture data. +- No live account call, real credential read, Keychain prompt, refresh, commit, or push occurred. +- All external boundaries are injected and fixture-backed. diff --git a/docs/ai/planning/2026-09-25-feature-claude-capacity.md b/docs/ai/planning/2026-09-25-feature-claude-capacity.md new file mode 100644 index 00000000..27c827d7 --- /dev/null +++ b/docs/ai/planning/2026-09-25-feature-claude-capacity.md @@ -0,0 +1,106 @@ +--- +phase: planning +title: Claude Capacity Implementation Plan +description: Ordered implementation and verification tasks for Claude subscription capacity +--- + +# Claude Capacity Implementation Plan + +## Milestones + +- [x] Milestone 1: Provider contract is specified by failing fixture-based tests. +- [x] Milestone 2: Claude provider and public agent-manager integration pass focused tests. +- [x] Milestone 3: CLI integration, documentation, and full validation pass. + +## Task Breakdown + +### Phase 1: Provider contract and fixtures + +- [x] Task 1.1: Add obviously fake credential, complete usage, partial usage, and malformed usage fixtures. + - Outcome: tests never depend on live credentials or endpoints. + - Dependencies: approved requirements/design. + - Validation: fixtures parse only through test code and contain no real-looking account data. + - Scenarios: all Test Data items. +- [x] Task 1.2: Add failing credential-resolution tests for environment precedence, default/custom profile paths, missing/malformed auth, and expiry. + - Outcome: read-only credential contract is executable. + - Dependencies: Task 1.1 credential fixture. + - Validation: focused Vitest run fails for missing implementation rather than test setup. + - Scenarios: Credential resolution checklist. +- [x] Task 1.3: Add failing parser/request tests for all usage mappings, partial/malformed payloads, exact headers, timeout, sanitized status/network failures, and Retry-After forms. + - Outcome: API and normalization contracts are executable before production code. + - Dependencies: Task 1.1 usage fixtures. + - Validation: focused Vitest run demonstrates red state. + - Scenarios: Usage parsing and HTTP probing checklists. + +### Phase 2: Agent-manager implementation + +- [x] Task 2.1: Implement `capacity/claude.ts` credential resolution and parsing with injected boundaries. + - Outcome: environment/profile credentials resolve without writes; default-profile macOS users additionally receive bounded Keychain fallback. + - Dependencies: Task 1.2. + - Validation: credential tests pass. +- [x] Task 2.2: Implement usage normalization for core, model, scoped, and extra-usage windows. + - Outcome: endpoint data maps into unchanged capacity contracts without token-total inference. + - Dependencies: Task 1.3. + - Validation: parser tests pass, including partial fixtures. +- [x] Task 2.3: Implement the bounded request and safe HTTP/error mapping. + - Outcome: exact request contract and sanitized errors for 401/403/429/other/network/malformed JSON. + - Dependencies: Tasks 2.1-2.2. + - Validation: HTTP tests pass and assert no fake secret/body leakage. +- [x] Task 2.4: Export `getClaudeCapacityReport` and verify its injected clock/report integration. + - Outcome: agent-manager exposes the third capacity provider without a registry. + - Dependencies: Tasks 2.1-2.3. + - Validation: index integration and full agent-manager suites pass. + +### Phase 3: CLI integration and lifecycle evidence + +- [x] Task 3.1: Add Claude to supported provider selection and the existing direct reader. + - Outcome: explicit and default commands include Claude while preserving de-duplication and aliases. + - Dependencies: Task 2.4. + - Validation: CLI selection and failure-behavior tests pass. +- [x] Task 3.2: Add the Anthropic display label and mocked report rendering coverage. + - Outcome: text and JSON use the existing renderer without a Claude-specific presentation path. + - Dependencies: Task 3.1. + - Validation: renderer tests pass. +- [x] Task 3.3: Reconcile implementation/testing documents and checklist status. + - Outcome: lifecycle docs describe actual code, deviations, and fresh evidence. + - Dependencies: all implementation tasks. + - Validation: feature lint and `git diff --check` pass. +- [x] Task 3.4: Run focused tests, build, full repository tests, and final review. + - Outcome: implementation is evidence-backed and ready for user inspection. + - Dependencies: Tasks 3.1-3.3. + - Validation: commands listed in the testing strategy complete successfully. + +## Dependencies + +- Tasks execute in numeric order; CLI integration depends on the public agent-manager export. +- No external account, real credential, live network, Claude subprocess, migration, or new package dependency is required for implementation and tests. Keychain behavior is exercised only through an injected fake reader. +- Existing `CapacityReport`, `CapacityWindow`, renderer, and multi-provider failure behavior are load-bearing contracts and must remain backward compatible. + +## Timeline & Estimates + +- Provider tests and fixtures: small. +- Provider implementation: medium, with the largest risk in partial payload and error semantics. +- CLI integration: small. +- Full validation and review: medium because the monorepo suite must run. + +Work proceeds continuously in this lifecycle run; estimates communicate relative complexity rather than delivery dates. + +## Risks & Mitigation + +- Anthropic payload drift: parse untrusted values conservatively and retain valid partial data. +- Secret leakage: never include auth input or response bodies in errors; assert this with fake sentinel values. +- Profile mismatch: derive one credential path from the injected environment and never use the global Keychain fallback for a custom `CLAUDE_CONFIG_DIR`. +- Monetary-unit confusion: convert both returned amounts consistently and label the currency; never map them as tokens. +- Over-generalization: keep all new logic provider-local and add no flag, registry, base class, or fallback without a current caller. +- Regression in default command behavior: update exact provider-set and partial-failure CLI tests. + +## Resources Needed + +- Existing Node/TypeScript/Vitest workspace only. +- Supplied CodexBar Claude provider as the behavioral reference. +- Existing Codex and z.ai capacity implementations as repository conventions. +- Fixture-based mocks for all external boundaries. + +## Progress Summary + +All planned provider, CLI, documentation, and validation tasks are complete. The final review found no blocking or important issues. Fresh evidence includes 27 Claude provider tests, 13 capacity CLI tests, and the full 2,307-test repository suite passing. The approved Keychain extension remains provider-local and adds no general abstraction. diff --git a/docs/ai/requirements/2026-09-25-feature-claude-capacity.md b/docs/ai/requirements/2026-09-25-feature-claude-capacity.md new file mode 100644 index 00000000..14fd3c89 --- /dev/null +++ b/docs/ai/requirements/2026-09-25-feature-claude-capacity.md @@ -0,0 +1,67 @@ +--- +phase: requirements +title: Claude Capacity Requirements +description: Add read-only Claude subscription usage to the capacity command +--- + +# Claude Capacity Requirements + +## Problem Statement + +AI DevKit users can inspect Codex and z.ai capacity, but cannot inspect the Claude subscription limits that govern Claude Code work. They currently have to open Claude Code or another application and reconcile different usage windows manually before dispatching work. + +The `capacity` command needs a truthful, read-only Claude provider that uses the subscription OAuth usage endpoint without starting a model turn, refreshing or mutating credentials, or exposing secrets. + +## Goals & Objectives + +- Add `claude` to the existing multi-provider `capacity` command. +- Fetch current subscription usage from `GET https://api.anthropic.com/api/oauth/usage` with OAuth Bearer authentication, `anthropic-beta: oauth-2025-04-20`, and a `claude-code/` User-Agent. +- Reuse `CapacityReport` and `CapacityWindow` for five-hour, seven-day, model-weekly, scoped-limit, and optional extra-usage data. +- Resolve credentials from `CLAUDE_CODE_OAUTH_TOKEN`, then the active Claude Code profile's `.credentials.json`, then the default-profile macOS Claude Code Keychain item. +- Keep all tests deterministic and fixture-based. + +### Non-goals + +- Writing or refreshing macOS Keychain credentials, or associating the global Keychain item with a custom `CLAUDE_CONFIG_DIR` profile. +- Spawning Claude Code as an auth or usage fallback, parsing its TUI, refreshing OAuth, or starting a model turn. +- Supporting API-key, Bedrock, Vertex, Foundry, gateway, browser-cookie, or Admin API usage. +- Inventing absolute token totals, forecasting future capacity, caching responses, or adding provider-specific CLI flags. +- Introducing a provider registry, generic HTTP framework, generic credential framework, or other future-provider abstractions. + +## User Stories & Use Cases + +- As a Claude subscription user, I can run `ai-devkit capacity claude` and see current Claude usage windows and resets. +- As a multi-provider user, I can run `ai-devkit capacity` and see Claude alongside Codex and z.ai while retaining partial-failure behavior. +- As an automation user, I can supply `CLAUDE_CODE_OAUTH_TOKEN` without placing credentials in a repository. +- As a user with a custom Claude profile, I can set `CLAUDE_CONFIG_DIR` and have the matching `.credentials.json` inspected. +- As a default-profile macOS user, my existing `Claude Code-credentials` Keychain item is used when no environment or file token is available. +- As a user with missing, expired, rejected, forbidden, or rate-limited authentication, I receive a stable sanitized error and no secret or raw response-body leakage. + +## Success Criteria + +- `claude` is a supported provider and the no-argument command probes Codex, z.ai, and Claude. +- The request uses the exact endpoint and required headers. The OAuth token never appears in returned reports or errors. +- `five_hour` maps to a 300-minute window and `seven_day` maps to a 10,080-minute window. +- Known flat model-weekly fields (`seven_day_sonnet`, `seven_day_opus`) map independently when present. +- Valid `limits` entries with `kind: weekly_scoped`, `group: weekly`, and a model scope map to weekly windows. Duplicate scopes and all-model scopes are omitted; `is_active` does not suppress otherwise valid limits. +- Utilization maps to `usedPercent`; valid `resets_at` values normalize to ISO timestamps; absent or invalid optional values remain unknown. +- Enabled `extra_usage` maps to a `CREDIT_LIMIT` window using returned utilization, monthly limit, used credits, and currency. Monetary totals may be converted from the endpoint's minor units to major units; no value is represented as tokens. +- Missing and expired credentials fail before fetch. HTTP 401, 403, and 429 have distinct sanitized failures, and 429 recognizes delta-seconds and HTTP-date `Retry-After`. +- Environment and profile-file credentials take precedence over Keychain. Keychain lookup is macOS-only and is skipped for custom profiles to prevent cross-profile credential mixing. +- Invalid JSON and non-object payloads fail as malformed. Partial object payloads retain valid windows without manufacturing missing data. +- All new provider and CLI behavior is covered with fixtures and injected environment, file, clock, fetch, and version boundaries. No test accesses a real credential, account, Keychain, or network endpoint. + +## Constraints & Assumptions + +- `@ai-devkit/agent-manager` continues to own capacity probing, normalization, and public capacity types; the CLI remains a thin selector and renderer. +- Credential precedence is `CLAUDE_CODE_OAUTH_TOKEN`, then `/.credentials.json`, then the default-profile macOS Keychain service `Claude Code-credentials`. A non-empty `CLAUDE_CONFIG_DIR` is the profile root and deliberately disables the global Keychain fallback. +- Relative `CLAUDE_CONFIG_DIR` values resolve against the process working directory, matching Claude Code profile behavior. +- The credentials file shape is `claudeAiOauth.accessToken` with optional millisecond `expiresAt`. A known expiry at or before the injected clock is expired; a missing expiry is accepted because long-lived/setup tokens may not expose one. +- MVP uses a fixed valid Claude Code fallback version in the User-Agent. It does not execute Claude merely to discover a version. +- `extra_usage.monthly_limit` and `used_credits` use the minor currency-unit behavior documented by the supplied CodexBar reference and are converted together to major units. +- Existing sequential multi-provider probing and partial-failure behavior remain unchanged. +- Timeouts remain provider-local and bounded. + +## Questions & Open Items + +No material open items remain. Claude CLI fallback remains post-MVP because auth status does not return usage credentials and interactive subprocess parsing would be brittle. diff --git a/docs/ai/testing/2026-09-25-feature-claude-capacity.md b/docs/ai/testing/2026-09-25-feature-claude-capacity.md new file mode 100644 index 00000000..3ebc0843 --- /dev/null +++ b/docs/ai/testing/2026-09-25-feature-claude-capacity.md @@ -0,0 +1,100 @@ +--- +phase: testing +title: Claude Capacity Testing Strategy +description: Fixture-based coverage for Claude credentials, usage mapping, errors, and CLI integration +--- + +# Claude Capacity Testing Strategy + +## Test Coverage Goals + +- Cover 100% of meaningful new provider branches with unit tests where practical. +- Exercise the CLI/provider integration entirely through injected readers and fetch implementations. +- Run no live network, account, Claude CLI, or Keychain operations. +- Retain passing Codex, z.ai, renderer, and multi-provider regression tests. + +## Unit Tests + +### Credential resolution + +- [x] Prefer `CLAUDE_CODE_OAUTH_TOKEN` and do not read a file when it is set. +- [x] Resolve `.credentials.json` under a non-empty `CLAUDE_CONFIG_DIR`. +- [x] Resolve the default credentials file under `$HOME/.claude`. +- [x] Fall back to the `Claude Code-credentials` Keychain service for the default profile on macOS. +- [x] Keep environment/file precedence and skip global Keychain for custom profiles and non-macOS platforms. +- [x] Reject expired, malformed, and unavailable Keychain payloads without secret leakage or fetches. +- [x] Parse `claudeAiOauth.accessToken` and optional millisecond `expiresAt`. +- [x] Reject missing files, malformed JSON, missing tokens, and expired credentials with sanitized messages. + +### Usage parsing + +- [x] Map `five_hour` and `seven_day` utilization and resets. +- [x] Map flat Sonnet and Opus weekly windows independently. +- [x] Map valid model-scoped weekly `limits`, including entries whose `is_active` is false. +- [x] Skip all-model, duplicate, malformed, and non-weekly scoped limits. +- [x] Map enabled `extra_usage` as a credit-limit window in major currency units. +- [x] Preserve valid portions of partial payloads and unknown optional values. +- [x] Reject non-object payloads while never inventing absolute token totals. + +### HTTP probing + +- [x] Assert the exact endpoint, method, Bearer header, beta header, and Claude Code User-Agent. +- [x] Assert the timeout abort boundary is installed and cleared. +- [x] Distinguish sanitized 401, 403, 429, other HTTP, network, and malformed-JSON failures. +- [x] Parse numeric and HTTP-date `Retry-After` relative to an injected clock. +- [x] Verify fixture tokens and raw response bodies never appear in errors or reports. + +## Integration Tests + +- [x] `getClaudeCapacityReport` uses the injected clock and returns the normalized report. +- [x] `capacity claude` selects only Claude. +- [x] `capacity` includes Codex, z.ai, and Claude. +- [x] A Claude failure warns and preserves other providers in a multi-provider invocation. +- [x] Single-provider Claude failures propagate through the existing command behavior. +- [x] Text rendering labels Anthropic correctly; JSON emits the unchanged report contract. + +## End-to-End Tests + +- [x] Build the workspace and run focused agent-manager and CLI suites using fixtures only. +- [x] Run the complete repository test suite to catch adjacent regressions. +- [x] Run feature lint and verify no template placeholders or open lifecycle checks remain. + +## Test Data + +- A credentials fixture containing a fake OAuth token and future expiry. +- A complete usage fixture containing five-hour, seven-day, Sonnet/Opus fields, scoped limits, and extra usage. +- Partial and malformed usage fixtures. +- Inline mocked HTTP responses for status-specific behavior and Retry-After variants. + +Fixture strings must be obviously fake and assertions must prove they do not leak. + +## Test Reporting & Coverage + +- `npm test --workspace=@ai-devkit/agent-manager` +- `npm test --workspace=ai-devkit` +- `npm run build` +- `npm test` +- `npx ai-devkit@latest lint --feature claude-capacity` + +Record fresh pass/fail counts and command output in the implementation document and durable task evidence. + +Final results: + +- Agent-manager: 57 files and 689 tests passed. +- CLI: 102 files and 1,207 tests passed. +- Repository: 199 files and 2,307 tests passed across all six workspaces. +- Full agent-manager coverage: 88.55% statements, 77.31% branches, 93.56% functions, and 91.58% lines. The new Claude provider reached 95.96% statements, 85.71% branches, 95.23% functions, and 96.33% lines; tests intentionally replace the real Keychain subprocess boundary. +- Build, repository lint, feature lint, new-file formatting, and `git diff --check` passed. +- The repository-wide formatting check still reports the same 37 files as the base worktree. Every new feature file passes the formatter check; modified existing files retain their baseline style to avoid unrelated formatting churn. + +## Manual Testing + +No live-account smoke test is permitted for this feature. Review text and JSON behavior through mocked reports only. + +## Performance Testing + +No load test is required. Unit tests verify one fetch per probe and bounded timeout behavior. + +## Bug Tracking + +Any discovered regression is recorded in the planning checklist before implementation continues. Blocking failures return the lifecycle to design or implementation as appropriate. diff --git a/packages/agent-manager/src/__tests__/capacity/claude.test.ts b/packages/agent-manager/src/__tests__/capacity/claude.test.ts new file mode 100644 index 00000000..c4e5b92e --- /dev/null +++ b/packages/agent-manager/src/__tests__/capacity/claude.test.ts @@ -0,0 +1,467 @@ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it, vi } from "vitest"; +import * as capacity from "../../capacity/index.js"; +import { parseClaudeUsage } from "../../capacity/claude.js"; +import * as agentManager from "../../index.js"; + +const fixture = (name: string) => + readFile(fileURLToPath(new URL(`./fixtures/${name}`, import.meta.url)), "utf8"); + +describe("Claude capacity", () => { + it("exposes a Claude capacity report reader", () => { + expect(capacity).toHaveProperty("getClaudeCapacityReport"); + }); + + it("exports the Claude report reader from agent-manager", () => { + expect(agentManager).toHaveProperty("getClaudeCapacityReport"); + }); + + it("uses an environment OAuth token for the exact usage request", async () => { + const fetch = vi.fn( + async () => + new Response(JSON.stringify({ five_hour: { utilization: 20 } }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + const report = await capacity.getClaudeCapacityReport({ + now: () => new Date("2026-09-25T18:00:00.000Z"), + env: { CLAUDE_CODE_OAUTH_TOKEN: "fake-environment-token" }, + readFile: vi.fn(), + fetch, + }); + + expect(fetch).toHaveBeenCalledOnce(); + expect(fetch).toHaveBeenCalledWith( + "https://api.anthropic.com/api/oauth/usage", + expect.objectContaining({ + method: "GET", + headers: { + Accept: "application/json", + Authorization: "Bearer fake-environment-token", + "Content-Type": "application/json", + "User-Agent": "claude-code/2.1.0", + "anthropic-beta": "oauth-2025-04-20", + }, + }), + ); + expect(report).toMatchObject({ + harness: "claude", + provider: "anthropic", + authenticated: true, + }); + }); + + it("reads the OAuth token from the active Claude profile", async () => { + const readFile = vi.fn(async () => fixture("claude-credentials.json")); + const fetch = vi.fn(async () => new Response(JSON.stringify({}), { status: 200 })); + + await capacity.getClaudeCapacityReport({ + now: () => new Date("2026-09-25T18:00:00.000Z"), + env: { HOME: "/users/test", CLAUDE_CONFIG_DIR: "profiles/work" }, + cwd: "/workspace/project", + readFile, + fetch, + }); + + expect(readFile).toHaveBeenCalledWith( + "/workspace/project/profiles/work/.credentials.json", + "utf8", + ); + expect(fetch.mock.calls[0][1]?.headers).toMatchObject({ + Authorization: "Bearer fake-claude-oauth-token-for-tests", + }); + }); + + it("rejects an expired profile credential before fetching usage", async () => { + const fetch = vi.fn(); + await expect( + capacity.getClaudeCapacityReport({ + now: () => new Date("2026-09-25T18:00:00.000Z"), + env: { HOME: "/users/test" }, + platform: "linux", + readFile: async () => + JSON.stringify({ + claudeAiOauth: { + accessToken: "fake-expired-token", + expiresAt: 1, + }, + }), + fetch, + }), + ).rejects.toThrow("Claude OAuth credentials expired"); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("maps subscription, model, scoped, and extra-usage windows", async () => { + const report = await capacity.getClaudeCapacityReport({ + now: () => new Date("2026-09-25T18:00:00.000Z"), + env: { CLAUDE_CODE_OAUTH_TOKEN: "fake-mapping-token" }, + fetch: vi.fn(async () => new Response(await fixture("claude-usage.json"), { status: 200 })), + }); + + expect(report).toMatchObject({ + harness: "claude", + provider: "anthropic", + generatedAt: "2026-09-25T18:00:00.000Z", + authenticated: true, + available: "yes", + creditsRemaining: null, + }); + expect(report.windows).toEqual([ + { + id: "session", + label: "Session", + durationMinutes: 300, + usedPercent: 25, + resetsAt: "2026-09-25T20:00:00.000Z", + }, + { + id: "weekly", + label: "Weekly", + durationMinutes: 10080, + usedPercent: 55.5, + resetsAt: "2026-09-30T12:00:00.000Z", + }, + { + id: "claude:sonnet:weekly", + label: "Sonnet weekly", + durationMinutes: 10080, + usedPercent: 61, + resetsAt: "2026-09-30T12:00:00.000Z", + }, + { + id: "claude:opus:weekly", + label: "Opus weekly", + durationMinutes: 10080, + usedPercent: 72, + resetsAt: "2026-09-30T12:00:00.000Z", + }, + { + id: "claude:weekly:claude-fable", + label: "Fable weekly", + durationMinutes: 10080, + usedPercent: 33, + resetsAt: "2026-09-29T08:00:00.000Z", + }, + { + id: "claude:extra-usage", + label: "Extra usage · USD", + limitType: "CREDIT_LIMIT", + durationMinutes: null, + usedPercent: 25, + resetsAt: null, + total: 50, + current: 12.5, + remaining: 37.5, + }, + ]); + }); + + it("reports a rejected OAuth token as unauthorized without leaking it", async () => { + const token = "fake-rejected-token-never-expose"; + let error: unknown; + try { + await capacity.getClaudeCapacityReport({ + env: { CLAUDE_CODE_OAUTH_TOKEN: token }, + fetch: vi.fn(async () => new Response('{"private":"response-body"}', { status: 401 })), + }); + } catch (caught) { + error = caught; + } + expect(error).toBeInstanceOf(Error); + expect((error as Error).message).toBe("Claude OAuth request unauthorized (HTTP 401)"); + expect((error as Error).message).not.toContain(token); + expect((error as Error).message).not.toContain("response-body"); + }); + + it("reports forbidden subscription usage without returning the response body", async () => { + await expect( + capacity.getClaudeCapacityReport({ + env: { CLAUDE_CODE_OAUTH_TOKEN: "fake-forbidden-token" }, + fetch: vi.fn(async () => new Response("private forbidden details", { status: 403 })), + }), + ).rejects.toThrow("Claude OAuth request forbidden (HTTP 403)"); + }); + + it.each([ + ["120", "2026-09-25T18:02:00.000Z"], + ["Fri, 25 Sep 2026 19:00:00 GMT", "2026-09-25T19:00:00.000Z"], + ])("honors Retry-After %s", async (retryAfter, expected) => { + await expect( + capacity.getClaudeCapacityReport({ + now: () => new Date("2026-09-25T18:00:00.000Z"), + env: { CLAUDE_CODE_OAUTH_TOKEN: "fake-rate-limited-token" }, + fetch: vi.fn( + async () => + new Response("private rate-limit body", { + status: 429, + headers: { "Retry-After": retryAfter }, + }), + ), + }), + ).rejects.toThrow(`Claude OAuth usage rate limited until ${expected}`); + }); + + it("sanitizes malformed usage JSON", async () => { + await expect( + capacity.getClaudeCapacityReport({ + env: { CLAUDE_CODE_OAUTH_TOKEN: "fake-malformed-token" }, + fetch: vi.fn(async () => new Response("private non-json response", { status: 200 })), + }), + ).rejects.toThrow("Claude usage response is malformed"); + }); + + it("sanitizes network failures", async () => { + await expect( + capacity.getClaudeCapacityReport({ + env: { CLAUDE_CODE_OAUTH_TOKEN: "fake-network-token" }, + fetch: vi.fn(async () => { + throw new Error("private network details fake-network-token"); + }), + }), + ).rejects.toThrow("Claude OAuth usage request failed"); + }); + + it("preserves valid windows in a partial payload", async () => { + const report = parseClaudeUsage( + JSON.parse(await fixture("claude-usage-partial.json")), + "2026-09-25T18:00:00.000Z", + ); + expect(report.available).toBe("yes"); + expect(report.windows).toEqual([ + { + id: "session", + label: "Session", + durationMinutes: 300, + usedPercent: 10, + resetsAt: null, + }, + { + id: "weekly", + label: "Weekly", + durationMinutes: 10080, + usedPercent: null, + resetsAt: null, + }, + ]); + }); + + it("rejects a non-object usage payload", async () => { + const raw = JSON.parse(await fixture("claude-usage-malformed.json")); + expect(() => parseClaudeUsage(raw, "2026-09-25T18:00:00.000Z")).toThrow( + "Claude usage response is malformed", + ); + }); + + it.each([ + ["missing file", async () => Promise.reject(new Error("missing"))], + ["malformed file", async () => "{"], + ["missing token", async () => JSON.stringify({ claudeAiOauth: {} })], + ])("rejects %s credentials without fetching", async (_name, readFile) => { + const fetch = vi.fn(); + await expect( + capacity.getClaudeCapacityReport({ + env: { HOME: "/users/test" }, + platform: "linux", + readFile, + fetch, + }), + ).rejects.toThrow("Claude OAuth credentials not found or malformed"); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("uses the default Claude profile under HOME", async () => { + const readFile = vi.fn(async () => + JSON.stringify({ claudeAiOauth: { accessToken: "fake-home-token" } }), + ); + await capacity.getClaudeCapacityReport({ + env: { HOME: "/users/test" }, + readFile, + fetch: vi.fn(async () => new Response("{}", { status: 200 })), + }); + expect(readFile).toHaveBeenCalledWith("/users/test/.claude/.credentials.json", "utf8"); + }); + + it("falls back to the Claude Code Keychain credential on macOS", async () => { + const keychainRead = vi.fn(async () => + JSON.stringify({ + claudeAiOauth: { + accessToken: "fake-keychain-token", + expiresAt: 1_900_000_000_000, + }, + }), + ); + const fetch = vi.fn(async () => new Response("{}", { status: 200 })); + + await capacity.getClaudeCapacityReport({ + now: () => new Date("2026-09-25T18:00:00.000Z"), + env: { HOME: "/users/test" }, + platform: "darwin", + readFile: async () => Promise.reject(new Error("missing")), + keychainRead, + fetch, + }); + + expect(keychainRead).toHaveBeenCalledWith("Claude Code-credentials"); + expect(fetch.mock.calls[0][1]?.headers).toMatchObject({ + Authorization: "Bearer fake-keychain-token", + }); + }); + + it("falls back to Keychain when the default profile file has no OAuth token", async () => { + const keychainRead = vi.fn(async () => + JSON.stringify({ claudeAiOauth: { accessToken: "fake-keychain-fallback-token" } }), + ); + const fetch = vi.fn(async () => new Response("{}", { status: 200 })); + + await capacity.getClaudeCapacityReport({ + env: { HOME: "/users/test" }, + platform: "darwin", + readFile: async () => JSON.stringify({}), + keychainRead, + fetch, + }); + + expect(keychainRead).toHaveBeenCalledOnce(); + expect(fetch.mock.calls[0][1]?.headers).toMatchObject({ + Authorization: "Bearer fake-keychain-fallback-token", + }); + }); + + it("keeps environment and profile-file credentials ahead of Keychain", async () => { + const keychainRead = vi.fn(); + const fetch = vi.fn(async () => new Response("{}", { status: 200 })); + + await capacity.getClaudeCapacityReport({ + env: { CLAUDE_CODE_OAUTH_TOKEN: "fake-preferred-environment-token" }, + platform: "darwin", + keychainRead, + fetch, + }); + await capacity.getClaudeCapacityReport({ + env: { HOME: "/users/test" }, + platform: "darwin", + readFile: async () => + JSON.stringify({ claudeAiOauth: { accessToken: "fake-preferred-file-token" } }), + keychainRead, + fetch, + }); + + expect(keychainRead).not.toHaveBeenCalled(); + }); + + it("rejects expired Keychain credentials before fetching usage", async () => { + const fetch = vi.fn(); + + await expect( + capacity.getClaudeCapacityReport({ + now: () => new Date("2026-09-25T18:00:00.000Z"), + env: { HOME: "/users/test" }, + platform: "darwin", + readFile: async () => Promise.reject(new Error("missing")), + keychainRead: async () => + JSON.stringify({ + claudeAiOauth: { accessToken: "fake-expired-keychain-token", expiresAt: 1 }, + }), + fetch, + }), + ).rejects.toThrow("Claude OAuth credentials expired"); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it("uses a fresh Keychain credential when the default profile file is expired", async () => { + const keychainRead = vi.fn(async () => + JSON.stringify({ claudeAiOauth: { accessToken: "fake-fresh-keychain-token" } }), + ); + const fetch = vi.fn(async () => new Response("{}", { status: 200 })); + + await capacity.getClaudeCapacityReport({ + now: () => new Date("2026-09-25T18:00:00.000Z"), + env: { HOME: "/users/test" }, + platform: "darwin", + readFile: async () => + JSON.stringify({ + claudeAiOauth: { accessToken: "fake-expired-file-token", expiresAt: 1 }, + }), + keychainRead, + fetch, + }); + + expect(keychainRead).toHaveBeenCalledOnce(); + expect(fetch.mock.calls[0][1]?.headers).toMatchObject({ + Authorization: "Bearer fake-fresh-keychain-token", + }); + }); + + it("sanitizes unavailable or malformed Keychain credentials", async () => { + const fetch = vi.fn(); + + await expect( + capacity.getClaudeCapacityReport({ + env: { HOME: "/users/test" }, + platform: "darwin", + readFile: async () => Promise.reject(new Error("private file error")), + keychainRead: async () => "private malformed keychain payload", + fetch, + }), + ).rejects.toThrow("Claude OAuth credentials not found or malformed"); + + expect(fetch).not.toHaveBeenCalled(); + }); + + it("does not mix the default Keychain credential into a custom Claude profile", async () => { + const keychainRead = vi.fn(async () => + JSON.stringify({ claudeAiOauth: { accessToken: "fake-wrong-profile-token" } }), + ); + const fetch = vi.fn(); + + await expect( + capacity.getClaudeCapacityReport({ + env: { HOME: "/users/test", CLAUDE_CONFIG_DIR: "/profiles/work" }, + platform: "darwin", + readFile: async () => Promise.reject(new Error("missing")), + keychainRead, + fetch, + }), + ).rejects.toThrow("Claude OAuth credentials not found or malformed"); + + expect(keychainRead).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("reports other HTTP failures by status only", async () => { + await expect( + capacity.getClaudeCapacityReport({ + env: { CLAUDE_CODE_OAUTH_TOKEN: "fake-status-token" }, + fetch: vi.fn(async () => new Response("private service details", { status: 503 })), + }), + ).rejects.toThrow("Claude OAuth usage request failed (HTTP 503)"); + }); + + it("aborts a usage request at the configured timeout", async () => { + vi.useFakeTimers(); + try { + const request = capacity.getClaudeCapacityReport({ + env: { CLAUDE_CODE_OAUTH_TOKEN: "fake-timeout-token" }, + timeoutMs: 10, + fetch: vi.fn( + async (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new Error("private abort details")), + ); + }), + ), + }); + const rejection = expect(request).rejects.toThrow("Claude OAuth usage request failed"); + await vi.advanceTimersByTimeAsync(10); + await rejection; + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/agent-manager/src/__tests__/capacity/fixtures/claude-credentials.json b/packages/agent-manager/src/__tests__/capacity/fixtures/claude-credentials.json new file mode 100644 index 00000000..311c3ed7 --- /dev/null +++ b/packages/agent-manager/src/__tests__/capacity/fixtures/claude-credentials.json @@ -0,0 +1,7 @@ +{ + "claudeAiOauth": { + "accessToken": "fake-claude-oauth-token-for-tests", + "expiresAt": 1893456000000, + "scopes": ["user:profile"] + } +} diff --git a/packages/agent-manager/src/__tests__/capacity/fixtures/claude-usage-malformed.json b/packages/agent-manager/src/__tests__/capacity/fixtures/claude-usage-malformed.json new file mode 100644 index 00000000..51141536 --- /dev/null +++ b/packages/agent-manager/src/__tests__/capacity/fixtures/claude-usage-malformed.json @@ -0,0 +1 @@ +["not-an-object"] diff --git a/packages/agent-manager/src/__tests__/capacity/fixtures/claude-usage-partial.json b/packages/agent-manager/src/__tests__/capacity/fixtures/claude-usage-partial.json new file mode 100644 index 00000000..a03bb7f7 --- /dev/null +++ b/packages/agent-manager/src/__tests__/capacity/fixtures/claude-usage-partial.json @@ -0,0 +1,15 @@ +{ + "five_hour": { + "utilization": 10 + }, + "seven_day": { + "resets_at": "not-a-date" + }, + "seven_day_sonnet": "invalid", + "limits": [null, { "kind": "daily", "group": "daily", "percent": 4 }], + "extra_usage": { + "is_enabled": true, + "monthly_limit": "unknown", + "used_credits": 100 + } +} diff --git a/packages/agent-manager/src/__tests__/capacity/fixtures/claude-usage.json b/packages/agent-manager/src/__tests__/capacity/fixtures/claude-usage.json new file mode 100644 index 00000000..d70ba79f --- /dev/null +++ b/packages/agent-manager/src/__tests__/capacity/fixtures/claude-usage.json @@ -0,0 +1,63 @@ +{ + "five_hour": { + "utilization": 25, + "resets_at": "2026-09-25T20:00:00.000Z" + }, + "seven_day": { + "utilization": 55.5, + "resets_at": "2026-09-30T12:00:00Z" + }, + "seven_day_sonnet": { + "utilization": 61, + "resets_at": "2026-09-30T12:00:00Z" + }, + "seven_day_opus": { + "utilization": 72, + "resets_at": "2026-09-30T12:00:00Z" + }, + "limits": [ + { + "kind": "weekly_scoped", + "group": "weekly", + "percent": 33, + "resets_at": "2026-09-29T08:00:00Z", + "is_active": false, + "scope": { + "model": { + "id": "claude-fable", + "display_name": "Fable" + } + } + }, + { + "kind": "weekly_scoped", + "group": "weekly", + "percent": 99, + "scope": { + "model": { + "id": "all-models", + "display_name": "All Models" + } + } + }, + { + "kind": "weekly_scoped", + "group": "weekly", + "percent": 88, + "scope": { + "model": { + "id": "claude-fable", + "display_name": "Duplicate Fable" + } + } + }, + null + ], + "extra_usage": { + "is_enabled": true, + "monthly_limit": 5000, + "used_credits": 1250, + "utilization": 25, + "currency": "USD" + } +} diff --git a/packages/agent-manager/src/capacity/claude.ts b/packages/agent-manager/src/capacity/claude.ts new file mode 100644 index 00000000..cbca919e --- /dev/null +++ b/packages/agent-manager/src/capacity/claude.ts @@ -0,0 +1,313 @@ +import { execFile } from "node:child_process"; +import { readFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { isAbsolute, join, resolve } from "node:path"; +import { promisify } from "node:util"; +import type { Availability, CapacityReport, CapacityWindow } from "./types.js"; + +const execFileAsync = promisify(execFile); + +export type ClaudeCapacityOptions = { + env?: NodeJS.ProcessEnv; + readFile?: (path: string, encoding: BufferEncoding) => Promise; + fetch?: typeof globalThis.fetch; + timeoutMs?: number; + now?: () => Date; + cwd?: string; + platform?: NodeJS.Platform; + keychainRead?: (service: string) => Promise; +}; + +export type ClaudeProbeOptions = ClaudeCapacityOptions & { + checkedAt: string; +}; + +const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage"; +const CLAUDE_KEYCHAIN_SERVICE = "Claude Code-credentials"; +const SESSION_DURATION_MINUTES = 5 * 60; +const WEEK_DURATION_MINUTES = 7 * 24 * 60; + +type Credential = { token: string | null; expired: boolean }; + +function record(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function percent(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 100 + ? value + : null; +} + +function nonEmptyText(value: unknown): string | null { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function credentialsPath(options: ClaudeProbeOptions): string { + const env = options.env ?? process.env; + const configured = nonEmptyText(env.CLAUDE_CONFIG_DIR); + if (configured) { + const profileRoot = isAbsolute(configured) + ? configured + : resolve(options.cwd ?? process.cwd(), configured); + return join(profileRoot, ".credentials.json"); + } + return join(env.HOME || homedir(), ".claude", ".credentials.json"); +} + +function credentialFromPayload(payload: string | null, now: Date): Credential { + if (!payload) return { token: null, expired: false }; + try { + return credentialFromRoot(record(JSON.parse(payload)), now); + } catch { + return { token: null, expired: false }; + } +} + +async function readProfileCredential(options: ClaudeProbeOptions, now: Date): Promise { + try { + const payload = await (options.readFile ?? readFile)(credentialsPath(options), "utf8"); + return credentialFromPayload(payload, now); + } catch { + return { token: null, expired: false }; + } +} + +async function readKeychainCredential(options: ClaudeProbeOptions, now: Date): Promise { + const env = options.env ?? process.env; + const usesDefaultProfile = !nonEmptyText(env.CLAUDE_CONFIG_DIR); + if (!usesDefaultProfile || (options.platform ?? process.platform) !== "darwin") { + return { token: null, expired: false }; + } + try { + const payload = await (options.keychainRead ?? readClaudeKeychain)(CLAUDE_KEYCHAIN_SERVICE); + return credentialFromPayload(payload, now); + } catch { + return { token: null, expired: false }; + } +} + +async function resolveToken(options: ClaudeProbeOptions): Promise { + const env = options.env ?? process.env; + const environmentToken = nonEmptyText(env.CLAUDE_CODE_OAUTH_TOKEN); + if (environmentToken) return environmentToken; + + const now = options.now?.() ?? new Date(); + const profileCredential = await readProfileCredential(options, now); + if (profileCredential.token) return profileCredential.token; + + const keychainCredential = await readKeychainCredential(options, now); + if (keychainCredential.token) return keychainCredential.token; + if (profileCredential.expired || keychainCredential.expired) { + throw new Error("Claude OAuth credentials expired"); + } + throw new Error("Claude OAuth credentials not found or malformed"); +} + +function credentialFromRoot(root: Record | null, now: Date): Credential { + const oauth = record(root?.claudeAiOauth); + const token = nonEmptyText(oauth?.accessToken); + if (!token) return { token: null, expired: false }; + const expired = + typeof oauth?.expiresAt === "number" && + Number.isFinite(oauth.expiresAt) && + oauth.expiresAt <= now.getTime(); + return { token: expired ? null : token, expired }; +} + +async function readClaudeKeychain(service: string): Promise { + try { + const { stdout } = await execFileAsync( + "/usr/bin/security", + ["find-generic-password", "-s", service, "-w"], + { encoding: "utf8", timeout: 1500, maxBuffer: 1024 * 1024 }, + ); + return nonEmptyText(stdout); + } catch { + return null; + } +} + +function resetTime(value: unknown): string | null { + if (typeof value !== "string") return null; + const milliseconds = Date.parse(value); + return Number.isNaN(milliseconds) ? null : new Date(milliseconds).toISOString(); +} + +function usageWindow( + value: unknown, + id: string, + label: string, + durationMinutes: number, +): CapacityWindow | null { + const input = record(value); + if (!input) return null; + return { + id, + label, + durationMinutes, + usedPercent: percent(input.utilization), + resetsAt: resetTime(input.resets_at), + }; +} + +function slug(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, ""); +} + +function scopedWindows(value: unknown): CapacityWindow[] { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const windows: CapacityWindow[] = []; + for (const entry of value) { + const limit = record(entry); + const model = record(record(limit?.scope)?.model); + const name = nonEmptyText(model?.display_name); + const identity = nonEmptyText(model?.id) ?? name; + if (limit?.kind !== "weekly_scoped" || limit.group !== "weekly" || !name || !identity) { + continue; + } + const modelSlug = slug(identity); + if (!modelSlug || modelSlug === "all-models" || modelSlug.endsWith("-all-models")) { + continue; + } + const id = `claude:weekly:${modelSlug}`; + if (seen.has(id)) continue; + seen.add(id); + windows.push({ + id, + label: `${name} weekly`, + durationMinutes: WEEK_DURATION_MINUTES, + usedPercent: percent(limit.percent), + resetsAt: resetTime(limit.resets_at), + }); + } + return windows; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function extraUsageWindow(value: unknown): CapacityWindow | null { + const extra = record(value); + if (!extra || extra.is_enabled !== true) return null; + const limitMinor = finiteNumber(extra.monthly_limit); + const usedMinor = finiteNumber(extra.used_credits); + if (limitMinor === null || usedMinor === null) return null; + const total = limitMinor / 100; + const current = usedMinor / 100; + const currency = nonEmptyText(extra.currency)?.toUpperCase() ?? "USD"; + return { + id: "claude:extra-usage", + label: `Extra usage · ${currency}`, + limitType: "CREDIT_LIMIT", + durationMinutes: null, + usedPercent: + percent(extra.utilization) ?? + (total > 0 ? Math.max(0, Math.min(100, (current / total) * 100)) : null), + resetsAt: null, + total, + current, + remaining: Math.max(0, total - current), + }; +} + +function availability(windows: CapacityWindow[]): Availability { + const known = windows.flatMap((window) => + window.usedPercent === null ? [] : [window.usedPercent], + ); + if (known.length === 0) return "unknown"; + return known.some((used) => used < 100) ? "yes" : "no"; +} + +function retryAfterTime(response: Response, now: Date): string | null { + const raw = response.headers.get("Retry-After")?.trim(); + if (!raw) return null; + const seconds = Number(raw); + if (Number.isFinite(seconds) && seconds >= 0) { + return new Date(now.getTime() + seconds * 1000).toISOString(); + } + const milliseconds = Date.parse(raw); + return Number.isNaN(milliseconds) ? null : new Date(milliseconds).toISOString(); +} + +export function parseClaudeUsage(raw: unknown, checkedAt: string): CapacityReport { + const input = record(raw); + if (!input) throw new Error("Claude usage response is malformed"); + const windows = [ + usageWindow(input.five_hour, "session", "Session", SESSION_DURATION_MINUTES), + usageWindow(input.seven_day, "weekly", "Weekly", WEEK_DURATION_MINUTES), + usageWindow( + input.seven_day_sonnet, + "claude:sonnet:weekly", + "Sonnet weekly", + WEEK_DURATION_MINUTES, + ), + usageWindow(input.seven_day_opus, "claude:opus:weekly", "Opus weekly", WEEK_DURATION_MINUTES), + ...scopedWindows(input.limits), + extraUsageWindow(input.extra_usage), + ].filter((window): window is CapacityWindow => window !== null); + return { + harness: "claude", + provider: "anthropic", + generatedAt: checkedAt, + authenticated: true, + available: availability(windows), + windows, + creditsRemaining: null, + }; +} + +export async function probeClaudeCapacity(options: ClaudeProbeOptions): Promise { + const token = await resolveToken(options); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 5000); + let response: Response; + try { + response = await (options.fetch ?? globalThis.fetch)(CLAUDE_USAGE_URL, { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "User-Agent": "claude-code/2.1.0", + "anthropic-beta": "oauth-2025-04-20", + }, + signal: controller.signal, + }); + } catch { + throw new Error("Claude OAuth usage request failed"); + } finally { + clearTimeout(timer); + } + if (response.status === 401) { + throw new Error("Claude OAuth request unauthorized (HTTP 401)"); + } + if (response.status === 403) { + throw new Error("Claude OAuth request forbidden (HTTP 403)"); + } + if (response.status === 429) { + const retryAt = retryAfterTime(response, options.now?.() ?? new Date()); + throw new Error( + retryAt + ? `Claude OAuth usage rate limited until ${retryAt}` + : "Claude OAuth usage rate limited (HTTP 429)", + ); + } + if (!response.ok) { + throw new Error(`Claude OAuth usage request failed (HTTP ${response.status})`); + } + let raw: unknown; + try { + raw = await response.json(); + } catch { + throw new Error("Claude usage response is malformed"); + } + return parseClaudeUsage(raw, options.checkedAt); +} diff --git a/packages/agent-manager/src/capacity/index.ts b/packages/agent-manager/src/capacity/index.ts index 4f93dc39..a2c68737 100644 --- a/packages/agent-manager/src/capacity/index.ts +++ b/packages/agent-manager/src/capacity/index.ts @@ -2,11 +2,13 @@ import { constants } from "node:fs"; import { access as fsAccess } from "node:fs/promises"; import path from "node:path"; import { codexUnavailableReport, probeCodexCapacity } from "./codex.js"; +import { probeClaudeCapacity, type ClaudeCapacityOptions } from "./claude.js"; import { probeOpenAiCapacity } from "./openai.js"; import { probeZaiCapacity } from "./zai.js"; import type { CapacityReport } from "./types.js"; export type { CapacityReport, CapacityWindow } from "./types.js"; +export type { ClaudeCapacityOptions } from "./claude.js"; export type CapacityProbeOptions = { now?: () => Date; @@ -94,3 +96,14 @@ export async function getOpenAiCapacityReport( checkedAt: (now?.() ?? new Date()).toISOString(), }); } + +export async function getClaudeCapacityReport( + options: ClaudeCapacityOptions = {}, +): Promise { + const { now, ...probeOptions } = options; + return probeClaudeCapacity({ + ...probeOptions, + checkedAt: (now?.() ?? new Date()).toISOString(), + now, + }); +} diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index e47dc35d..9adac0ec 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -1,10 +1,12 @@ export { AgentManager, AgentNotRunningError } from "./AgentManager.js"; export { + getClaudeCapacityReport, getCodexCapacityReport, getOpenAiCapacityReport, getZaiCapacityReport, } from "./capacity/index.js"; export type { + ClaudeCapacityOptions, CapacityProbeOptions, CapacityReport, CapacityWindow, diff --git a/packages/cli/README.md b/packages/cli/README.md index b9ec41b2..4d4446db 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -86,12 +86,16 @@ ai-devkit lint --feature lint-command # Emit machine-readable output for CI ai-devkit lint --feature lint-command --json -# Probe capacity for every supported provider (codex, z.ai) +# Probe capacity for every supported provider (codex, z.ai, OpenAI, Claude) ai-devkit capacity # Probe a single provider's capacity ai-devkit capacity zai +# Claude uses CLAUDE_CODE_OAUTH_TOKEN, the active profile file, or the +# default-profile macOS Claude Code Keychain item +ai-devkit capacity claude + # Emit the JSON report (single object for one provider, array for many) ai-devkit capacity codex --json @@ -108,6 +112,8 @@ ai-devkit skill list --global --env claude codex ai-devkit memory store ``` +On macOS, the operating system may request Keychain access the first time the Claude fallback is used. Custom `CLAUDE_CONFIG_DIR` profiles use only their own credentials file and never the global Keychain item. + ## UI Formatting Standards Shared CLI presentation helpers live in `src/util/`. Commands should use diff --git a/packages/cli/src/__tests__/commands/capacity/command.test.ts b/packages/cli/src/__tests__/commands/capacity/command.test.ts index 701341da..0d753bfc 100644 --- a/packages/cli/src/__tests__/commands/capacity/command.test.ts +++ b/packages/cli/src/__tests__/commands/capacity/command.test.ts @@ -110,6 +110,24 @@ const openaiReport: CapacityReport = { creditsRemaining: null, }; +const claudeReport: CapacityReport = { + harness: "claude", + provider: "anthropic", + generatedAt: "2026-08-09T10:00:00.000Z", + authenticated: true, + available: "yes", + windows: [ + { + id: "session", + label: "Session", + durationMinutes: 300, + usedPercent: 15, + resetsAt: "2026-08-09T12:00:00.000Z", + }, + ], + creditsRemaining: null, +}; + describe("capacity rendering", () => { beforeEach(() => vi.clearAllMocks()); @@ -252,14 +270,18 @@ describe("capacity command", () => { beforeEach(() => vi.clearAllMocks()); it("probes every supported provider when none is given", async () => { - const getReport = vi.fn(async (provider: string) => - provider === "zai" ? zaiReport : provider === "openai" ? openaiReport : codexReport, - ); + const getReport = vi.fn(async (provider: string) => { + if (provider === "zai") return zaiReport; + if (provider === "openai") return openaiReport; + if (provider === "claude") return claudeReport; + return codexReport; + }); await capacityCommand(undefined, {}, getReport); expect(getReport).toHaveBeenCalledWith("codex"); expect(getReport).toHaveBeenCalledWith("zai"); expect(getReport).toHaveBeenCalledWith("openai"); - expect(textCalls()).toContain("Capacity · 3 providers"); + expect(getReport).toHaveBeenCalledWith("claude"); + expect(textCalls()).toContain("Capacity · 4 providers"); }); it("wires the command surface and normalizes the dotted z.ai alias", async () => { @@ -284,10 +306,17 @@ describe("capacity command", () => { expect(ui.table).not.toHaveBeenCalled(); }); + it("accepts Claude as an explicit provider", async () => { + const getReport = vi.fn(async () => claudeReport); + await capacityCommand(["Claude"], {}, getReport); + expect(getReport).toHaveBeenCalledWith("claude"); + expect(textCalls()).toContain("claude · Anthropic capacity · OK"); + }); + it("rejects unknown providers before probing", async () => { const getReport = vi.fn(async () => codexReport); - await expect(capacityCommand(["claude"], {}, getReport)).rejects.toThrow( - 'Supported providers: "codex", "zai"', + await expect(capacityCommand(["gemini"], {}, getReport)).rejects.toThrow( + 'Supported providers: "codex", "zai", "openai", "claude"', ); expect(getReport).not.toHaveBeenCalled(); }); @@ -310,6 +339,19 @@ describe("capacity command", () => { expect(ui.warning).toHaveBeenCalledWith( "openai capacity unavailable: OpenAI API key not found", ); - expect(textCalls()).toContain("codex · OpenAI capacity · OK"); + expect(textCalls()).toContain("Capacity · 2 providers"); + }); + + it("preserves other reports when Claude is unavailable", async () => { + const getReport = vi.fn(async (provider: string) => { + if (provider === "claude") throw new Error("Claude OAuth credentials expired"); + if (provider === "openai") return openaiReport; + return provider === "zai" ? zaiReport : codexReport; + }); + await capacityCommand(undefined, {}, getReport); + expect(ui.warning).toHaveBeenCalledWith( + "claude capacity unavailable: Claude OAuth credentials expired", + ); + expect(textCalls()).toContain("Capacity · 3 providers"); }); }); diff --git a/packages/cli/src/commands/capacity.ts b/packages/cli/src/commands/capacity.ts index 0f891b77..f687ff19 100644 --- a/packages/cli/src/commands/capacity.ts +++ b/packages/cli/src/commands/capacity.ts @@ -1,5 +1,6 @@ import type { Command } from "commander"; import { + getClaudeCapacityReport, getCodexCapacityReport, getOpenAiCapacityReport, getZaiCapacityReport, @@ -10,10 +11,15 @@ import { ui } from "../util/terminal-ui.js"; import type { CapacityReport } from "@ai-devkit/agent-manager"; type CapacityOptions = { json?: boolean }; -type SupportedCapacityProvider = "codex" | "zai" | "openai"; +type SupportedCapacityProvider = "claude" | "codex" | "openai" | "zai"; type ReportReader = (provider: SupportedCapacityProvider) => Promise; -const SUPPORTED_PROVIDERS: readonly SupportedCapacityProvider[] = ["codex", "zai", "openai"]; +const SUPPORTED_PROVIDERS: readonly SupportedCapacityProvider[] = [ + "codex", + "zai", + "openai", + "claude", +]; const SUPPORTED_PROVIDER_LIST = SUPPORTED_PROVIDERS.map((provider) => `"${provider}"`).join(", "); function reportCapacityFailure( @@ -29,6 +35,7 @@ function reportCapacityFailure( async function readCapacityReport(provider: SupportedCapacityProvider): Promise { if (provider === "zai") return getZaiCapacityReport(); if (provider === "openai") return getOpenAiCapacityReport(); + if (provider === "claude") return getClaudeCapacityReport(); return getCodexCapacityReport(); } diff --git a/packages/cli/src/commands/capacity/render.ts b/packages/cli/src/commands/capacity/render.ts index 609562db..84a4ff07 100644 --- a/packages/cli/src/commands/capacity/render.ts +++ b/packages/cli/src/commands/capacity/render.ts @@ -14,6 +14,7 @@ const BAR_WIDTH = 10; const ELEVATED_USAGE = 70; const HIGH_USAGE = 90; const PROVIDER_LABELS: Record = { + anthropic: "Anthropic", zai: "z.ai", openai: "OpenAI", };