diff --git a/docs/ai/design/2026-08-09-feature-capacity-command.md b/docs/ai/design/2026-08-09-feature-capacity-command.md new file mode 100644 index 00000000..197fbc9f --- /dev/null +++ b/docs/ai/design/2026-08-09-feature-capacity-command.md @@ -0,0 +1,167 @@ +--- +phase: design +title: Capacity Command Design +description: Architecture and security design for normalized provider capacity reporting +--- + +# Capacity Command Design + +## Architecture Overview + +```mermaid +flowchart LR + CLI[capacity command] --> Detect[Configured-provider detection] + Detect --> Orchestrator[Parallel orchestrator] + Orchestrator --> Cache[(Normalized cache)] + Orchestrator --> Codex[Codex adapter] + Orchestrator --> Claude[Claude adapter] + Orchestrator --> Pi[Pi / GLM adapter] + Orchestrator --> Stub[Unsupported-provider stub] + Codex --> AuthFile[Codex auth.json] + AuthFile --> UsageAPI[whoami / wham usage] + AuthFile --> AppServer[read-only app-server fallback] + Claude --> AuthStatus[claude auth status] + Pi --> PiAuth[Pi auth provider names] + Orchestrator --> Report[CapacityReport v1] + Report --> Human[Human table] + Report --> JSON[JSON output] +``` + +The Commander registration layer delegates to a report orchestrator. Detection, provider adapters, normalization, cache, and rendering are separate modules with dependency injection at subprocess and orchestration boundaries. + +## Command API + +```text +capacity [provider] [--json] [--max-age ] [--refresh] +``` + +- No provider: detect only configured providers. +- Provider: request one known provider even if it is not configured, while reporting its actual state. +- `--json`: serialize the report with two-space indentation. +- `--max-age`: accept a non-negative integer; default 300 seconds. +- `--refresh`: skip cache lookup. + +Invalid arguments fail before probing. A constructed report exits successfully even if some rows are unknown. + +## State Model + +These signals are independent: + +| Signal | Meaning | Source | +|---|---|---| +| `configured` | Provider configuration directory exists | `ENVIRONMENT_DEFINITIONS.globalSkillPath` | +| `installed` | Expected executable exists and is executable on PATH | executable access check | +| `authenticated` | Provider-specific probe found valid authentication | app-server/auth status/Pi provider keys | + +Provider status is one of `supported`, `unsupported`, `unauthenticated`, `unavailable`, or `unknown`. Availability is separately `yes`, `no`, or `unknown`. + +## Data Model + +```ts +type CapacityWindow = { + id: string; + label: string; + durationMinutes: number | null; + usedPercent: number | null; + remainingPercent: number | null; + resetsAt: string | null; + scope: string | null; +}; + +type ProviderCapacity = { + provider: string; + agentType: string | null; + configured: boolean; + installed: boolean; + authenticated: boolean | null; + status: 'supported' | 'unsupported' | 'unauthenticated' | 'unavailable' | 'unknown'; + available: 'yes' | 'no' | 'unknown'; + plan: string | null; + checkedAt: string; + source: 'provider-cli' | 'provider-api' | 'local-observation' | 'none'; + windows: CapacityWindow[]; + aliases: { dailyWindowId: string | null; weeklyWindowId: string | null }; + resetCredits?: { available: number | null }; + warnings: Array<{ code: string; message: string }>; + error?: { code: string; retryable: boolean }; +}; + +type CapacityReport = { + schemaVersion: 1; + generatedAt: string; + providers: ProviderCapacity[]; +}; +``` + +`windows` is canonical. Aliases are derived by duration tolerance around 1,440 and 10,080 minutes. Native scoped windows remain separate, duplicate compatibility buckets are removed by normalized ID, and `remainingPercent` is derived only from an authoritative numeric `usedPercent`. + +## Configured-Provider Detection + +`detection.ts` reuses `ENVIRONMENT_DEFINITIONS`; it does not maintain a second provider-to-config mapping. The root is derived from `globalSkillPath` (including nested `.config/` roots), joined to the user home directory, and checked for existence. GitHub environment naming is normalized to provider name `copilot`. Binary detection is a separate executable-access scan over PATH and never establishes configuration. + +## Provider Adapters + +### Codex + +```mermaid +sequenceDiagram + participant C as capacity + participant F as auth.json + participant H as OpenAI/ChatGPT usage API + participant A as read-only codex app-server + C->>F: read CODEX_HOME or ~/.codex + alt personal_access_token + C->>H: whoami, then wham/usage + else fresh OAuth token + C->>H: wham/usage + else missing/stale/failed credentials + C->>A: initialize + C->>A: account/rateLimits/read + account/read + end + C->>C: normalize into UsageSnapshot +``` + +The adapter resolves `CODEX_HOME/auth.json` before the home-directory fallback. A PAT performs `whoami` to obtain the account ID and then reads `wham/usage`; a fresh OAuth access token uses its stored account ID directly. Stale tokens and 401s fall back without refresh. API calls are bounded and normalize session, weekly, credit balance, the individual-limit fallback chain, and additional limits. + +The JSON-line fallback transport is injectable in tests. It launches `codex -s read-only -a untrusted app-server`, ignores stderr, bounds execution, and reads both rate limits and account state. It never invokes a model method. Missing limits produce unknown availability rather than zero usage. + +### Claude + +The adapter runs `claude auth status --json` with bounded stdout and a timeout. Claude may return valid logged-out JSON with a nonzero exit, so that bounded stdout is parsed while stderr and exception text are discarded. The undocumented OAuth usage endpoint is not called; capacity remains unknown even when authentication succeeds. + +### Pi and GLM + +The adapter reads `~/.pi/agent/auth.json`, retains only top-level provider names, and never emits credential values. Any configured Pi credential establishes Pi authentication. `zai` or `zai-coding-cn` additionally establishes GLM authentication. Both remain unsupported/unknown because no verified account-quota reader exists. + +### Other Providers + +Configured providers without an authoritative adapter use the common stub. The stub preserves configured/installed state, maps to the correct AI DevKit `agentType` when available, and returns `status: unsupported`, `available: unknown`. + +## Orchestration and Cache + +- Provider probes execute with `Promise.all` and a seven-second orchestration timeout; adapters also apply their own subprocess timeouts. +- Exceptions become fixed-code unknown rows. Raw exception data is discarded. +- Cache keys distinguish explicit-provider and configured-provider sets. +- The default cache path is `~/.ai-devkit/cache/capacity.json`. +- Cache directory mode is `0700`; file and temporary file mode is `0600`; writes use rename. +- Cache failures never prevent a report, and `--refresh` bypasses reads. + +## Security and Reliability Decisions + +- Codex owns OAuth refresh; AI DevKit only reads the current token and never persists or refreshes it. +- Tokens and raw `auth.json` content are never logged, cached, or included in errors. +- Output and cache contain normalized allowlisted data, not raw responses. +- Codex identifiers, labels, and plan metadata are validated and credential/account-like values are rejected. +- Claude plan metadata is similarly constrained. +- Error output uses fixed codes/messages; stderr, URLs, headers, bodies, and exception text are never rendered. +- Unknown data remains unknown. Stubs and probe failures cannot claim availability. +- Partial failure is isolated so one provider cannot suppress other results. + +## Alternatives Rejected + +- Direct OAuth refresh: rejected because AI DevKit does not own the credential lifecycle. +- TUI scraping: brittle and capable of accidentally starting model activity. +- Local token-history estimation: not authoritative for subscription limits. +- Forced daily/weekly schema: loses provider-native rolling and scoped windows. + +The original structured capacity brainstorm supplied the deeper provider feasibility analysis; this document records the architecture that actually shipped. diff --git a/docs/ai/implementation/2026-08-09-feature-capacity-command.md b/docs/ai/implementation/2026-08-09-feature-capacity-command.md new file mode 100644 index 00000000..8ba623b5 --- /dev/null +++ b/docs/ai/implementation/2026-08-09-feature-capacity-command.md @@ -0,0 +1,110 @@ +--- +phase: implementation +title: Capacity Command Implementation Record +description: Shipped modules, integration points, invariants, and operational behavior +--- + +# Capacity Command Implementation Record + +## Shipped Module Map + +```text +packages/cli/src/ +├── cli.ts +└── commands/ + ├── capacity.ts + └── capacity/ + ├── types.ts + ├── detection.ts + ├── orchestrate.ts + ├── cache.ts + ├── render.ts + └── providers/ + ├── codex.ts + ├── claude.ts + ├── pi.ts + └── stub.ts +``` + +Tests live in `packages/cli/src/__tests__/commands/capacity/`. + +## CLI Registration + +`cli.ts` imports and calls `registerCapacityCommand(program)`. `commands/capacity.ts` owns Commander configuration, validates `--max-age`, calls `getCapacityReport`, and hands the result to `renderCapacityReport`. It exposes only: + +```text +capacity [provider] [--json] [--max-age ] [--refresh] +``` + +## Module Responsibilities + +- `types.ts`: exact schema-v1 TypeScript contract. +- `detection.ts`: derives provider config directories from `ENVIRONMENT_DEFINITIONS.globalSkillPath` and independently checks executable access on PATH. +- `orchestrate.ts`: validates provider names, selects configured providers by default, runs probes concurrently, isolates failures/timeouts, reads/writes cache, sorts rows, and constructs the report. +- `cache.ts`: reads freshness-keyed normalized reports and performs atomic restrictive writes under `~/.ai-devkit/cache/capacity.json` (`0700` directory, `0600` file). +- `render.ts`: emits exact pretty JSON or a text table with Auth, Available, shortest/longest native windows, reset credits, and warnings. +- `providers/codex.ts`: resolves Codex auth, drives tiered API/CLI reads, and sanitizes normalized usage snapshots. +- `providers/claude.ts`: invokes and safely parses `claude auth status --json`; does not fetch live quota. +- `providers/pi.ts`: reads only Pi auth provider names and derives Pi/GLM authentication. +- `providers/stub.ts`: builds truthful unsupported/unknown rows for providers without adapters. + +## Tiered Codex Provider + +The adapter reads `CODEX_HOME/auth.json` when configured, otherwise `~/.codex/auth.json`, and selects exactly one starting tier: + +1. `personal_access_token`: call `whoami`, then `wham/usage` with the returned account ID. +2. Fresh `tokens.access_token`: call `wham/usage` with `tokens.account_id`. +3. Missing, stale, unauthorized, or failed direct credentials: use the CLI fallback without refreshing OAuth. + +API responses become `UsageSnapshot` values containing session/weekly windows, credit balance, the three-step individual-limit fallback, additional rate limits, source, and update time. Missing windows remain nullable and keep availability unknown. + +The CLI fallback spawns `codex -s read-only -a untrusted app-server` with piped stdin/stdout and ignored stderr. It writes newline-delimited JSON: + +1. `initialize` with `clientInfo` and `capabilities: null`. +2. After response id 1, `initialized`. +3. `account/rateLimits/read` with request id 2 and no parameters. +4. `account/read` with request id 3 and no parameters. + +After responses 2 and 3 arrive, the rate limits and authentication state are normalized and the subprocess is terminated. A five-second adapter timer bounds the exchange. The transport function is injectable, so CI tests use no subprocess or network. + +Mapping behavior: + +- Normalize backward-compatible `rateLimits` and `rateLimitsByLimitId` snapshots. +- Preserve primary/secondary windows by scoped ID and remove duplicates. +- Convert epoch reset timestamps to ISO-8601. +- Clamp derived remaining percent to 0–100. +- Derive daily/weekly aliases by duration tolerance only. +- Treat a reported reached type as explicit `available: no`; missing windows remain unknown. +- Report only reset-credit `availableCount`; no consume method exists. + +## Provider Detection and Unknown Semantics + +The default row set is determined before binary checks. Configured, installed, and authenticated are stored independently. A configured but uninstalled provider remains visible. An installed but unconfigured provider does not enter the default report. Explicitly requested known providers are reported even when unconfigured. + +Only authoritative Codex utilization can establish `available: yes`. Claude, Pi, GLM, and unsupported providers remain `available: unknown` without verified quota data. + +## Failure Handling + +- Adapter exceptions never escape into report text. +- Orchestration catches each provider independently and emits a retryable fixed-code unknown row. +- Cache read/write failures are non-fatal. +- Unknown providers and invalid max-age values are command errors. +- Claude logged-out JSON is accepted from bounded stdout even when the CLI returns nonzero; stderr remains unused. +- A report, including a partial report, exits successfully. + +## Security Invariants + +- No tokens, account IDs, refresh tokens, endpoint URLs, headers, raw bodies, stderr, or raw exception messages are emitted or cached. +- No credential is placed on a subprocess command line. +- Codex OAuth refresh remains exclusively owned by Codex; this command never refreshes or writes credentials. +- PATs, access/refresh tokens, and raw auth-file content never enter normalized output or errors. +- Codex labels, IDs, scopes, and plans are constrained before output; Claude plan metadata is constrained too. +- Pi credential values are parsed only to discover top-level provider names and are never retained in normalized output. +- Cache contains only normalized report data with restrictive permissions. +- Capacity checks contain no model-start/inference method and never redeem reset credits. + +## Design Alignment and Deviations + +The shipped implementation matches the locked design. The brainstorm considered guarded use of Claude's undocumented OAuth usage endpoint; implementation review rejected that risk and shipped authentication-only Claude support. The brainstorm's broader draft schema contained fields such as transport provider and stale-after metadata; schema v1 intentionally uses the smaller contract in `types.ts`. + +No code change, data migration, new dependency, or rollout flag is required for these lifecycle documents. diff --git a/docs/ai/planning/2026-08-09-feature-capacity-command.md b/docs/ai/planning/2026-08-09-feature-capacity-command.md new file mode 100644 index 00000000..23cd073f --- /dev/null +++ b/docs/ai/planning/2026-08-09-feature-capacity-command.md @@ -0,0 +1,79 @@ +--- +phase: planning +title: Capacity Command Implementation Plan +description: Completed task record for the shipped capacity command +--- + +# Capacity Command Implementation Plan + +All tasks are complete. The list reflects execution order and the pushed commit that delivered each outcome. + +## Milestone 1: Detection and Core Contract + +- [x] Define schema-v1 capacity types and configuration/PATH detection — `c6c386b`. + - Outcome: `CapacityReport`, `ProviderCapacity`, arbitrary `CapacityWindow[]`, and independent configured/installed checks. + - Validation: detection derives config roots from `ENVIRONMENT_DEFINITIONS` and never runs provider binaries. +- [x] Build the Codex app-server adapter under TDD — `d57813a`. + - Outcome: injectable JSON-line transport, normalized windows, aliases, availability, plan, and reset-credit count. + - Validation: mocked protocol sequence contains no model-turn method and failures are redacted. + +## Milestone 2: Provider Coverage and Orchestration + +- [x] Add truthful Claude, Pi, GLM, and unsupported-provider adapters — `c614f27`. + - Outcome: Claude auth detection, Pi provider-name inspection, GLM detection through z.ai keys, and unknown-capacity stubs. + - Validation: injected secrets and thrown response details do not reach reports. +- [x] Add parallel orchestration and secure cache — `5de3a72`. + - Outcome: configured-only default, explicit provider validation, partial-result isolation, timeouts, max-age/refresh behavior, atomic restrictive cache. + - Validation: mocked adapters prove parallel selection, cache reuse/bypass, and partial failure behavior. + +## Milestone 3: CLI and Presentation + +- [x] Register and document the command — `69a201d`. + - Outcome: `registerCapacityCommand` in `cli.ts`, locked options, JSON rendering, human table, warnings, and CLI README examples. + - Validation: Commander integration forwards the provider and parsed cache options; invalid max-age fails before probing. + +## Milestone 4: Live-Protocol and Security Hardening + +- [x] Align with the generated Codex app-server protocol — `c04ea1f`. + - Outcome: exact initialize payload, parameterless rate-limit read, current reset-credit field, duplicate bucket removal, and identifier redaction. + - Validation: generated-protocol assertions and a live read-only Codex smoke test. +- [x] Harden provider metadata and agent-type mappings — `f34dbc3`. + - Outcome: reject credential/account-like plan metadata; map Gemini, Grok, and Copilot to shipped agent types. + - Validation: redaction and mapping regression tests. +- [x] Correct logged-out Claude handling — `5e2cc89`. + - Outcome: accept bounded JSON stdout from Claude's expected nonzero logged-out exit and use a six-second adapter timeout under the seven-second orchestrator guard. + - Validation: mocked nonzero behavior plus live `authenticated: false` classification. + +## Milestone 5: Tiered Codex Rework + +- [x] Rebase the feature onto current `origin/main`. +- [x] Add auth-file resolution and PAT/OAuth/CLI tier selection under TDD. +- [x] Normalize API usage into `UsageSnapshot`, including credit-limit fallbacks and additional windows. +- [x] Harden the CLI fallback with read-only/untrusted flags and both account reads. +- [x] Prove stale/401 fallback, unavailable semantics, and token redaction with mocked boundaries. +- [x] Run clean install, build, lifecycle lint, full repository lint/tests, and E2E tests. +- [x] Publish the reworked branch and update PR #147 with the tiered-flow Rework section. + +## Dependencies and Sequencing + +1. Types and detection established the provider/report contract. +2. Provider adapters normalized into that contract. +3. Orchestration composed adapters and added cache/timeout behavior. +4. CLI/rendering exposed the report. +5. Fully mocked network/subprocess tests drove the tiered protocol and security fixes. + +Runtime dependencies are Node.js, Commander, provider CLIs already installed by the user, and the existing AI DevKit environment definitions. No new package dependency or migration was introduced. + +## Risks and Mitigations + +- Codex app-server protocol changes: capability failures degrade to unknown; transport and mapping are isolated and tested. +- Undocumented Claude usage endpoint: not used; authentication-only output is explicit. +- Provider failure/latency: parallel probes, subprocess/orchestrator timeouts, and partial results. +- Secret leakage: provider-owned auth, bounded streams, fixed errors, field sanitization, and restrictive normalized cache. +- Misleading capacity: positive availability requires authoritative data; unsupported/missing data remains unknown. + +## Deferred Follow-Ups + +- Add Claude live capacity only if a safe provider-owned command becomes available. +- Add GLM or other provider adapters only after verifying authoritative, non-inference quota mechanisms. +- Add scheduling/recommendation policy separately from factual collection. diff --git a/docs/ai/requirements/2026-08-09-feature-capacity-command.md b/docs/ai/requirements/2026-08-09-feature-capacity-command.md new file mode 100644 index 00000000..c3d62660 --- /dev/null +++ b/docs/ai/requirements/2026-08-09-feature-capacity-command.md @@ -0,0 +1,88 @@ +--- +phase: requirements +title: Capacity Command Requirements +description: Define truthful, read-only provider capacity reporting before agent dispatch +--- + +# Capacity Command Requirements + +## Problem Statement + +AI DevKit can start agents backed by Codex, Claude, Pi, and other providers, but previously could not inspect provider capacity before launch. Humans and orchestrators discovered limits only after starting work, sometimes after a task was already in progress. The workaround was to check provider-specific interfaces manually or launch an agent and react to a rate-limit failure. + +The `capacity` command gives human operators, the agent-management workflow, parent agents, and future schedulers one factual report before dispatch. + +## Goals + +- Provide one fast, read-only command for provider capacity and authentication state. +- Emit stable schema-versioned JSON for automation and a readable human table. +- Show only configured providers by default, detected from provider configuration directories. +- Preserve every authoritative provider window instead of forcing daily/weekly fields. +- Distinguish configured, installed, and authenticated states. +- Treat missing or unsupported capacity as `unknown`, never as positive availability. +- Allow partial provider failures without losing the complete report. +- Report available reset-credit counts without redeeming credits. +- Avoid model inference, prompts, TUI interaction, and model-quota consumption. + +## Non-Goals + +- Automatic provider selection or changes to `agent start`. +- Forecasting, task-cost prediction, billing reconciliation, or local-usage estimation. +- TUI scraping or inference requests used as probes. +- Multiple accounts per provider. +- Automatic reset-credit redemption. +- A first-party live quota adapter for every AI DevKit environment. +- OAuth token refresh or any mutation of provider-owned credentials. + +## User Stories + +- As a human operator, I want to see which configured providers are authenticated and what authoritative capacity remains before choosing an agent. +- As an orchestrator, I want stable JSON with explicit `yes`, `no`, and `unknown` availability so I can apply my own unknown-data policy. +- As the agent-management workflow, I want provider and `agentType` fields that can be joined to launchable agent types. +- As a security-conscious self-hosted user, I want provider-owned authentication and redacted failures so capacity checks never disclose credentials. +- As a Codex user, I want native rolling windows and reset-credit counts without consuming a model turn or redeeming a credit. + +## Shipped Command Surface + +```text +ai-devkit capacity +ai-devkit capacity [provider] +ai-devkit capacity [provider] --json +ai-devkit capacity [provider] --max-age +ai-devkit capacity [provider] --refresh +``` + +The default cache age is 300 seconds. `--refresh` bypasses cache. Unknown providers and invalid non-negative integer values for `--max-age` are invalid arguments. + +## Acceptance Criteria + +- `capacity` with no provider argument includes only providers whose configuration directory exists according to `ENVIRONMENT_DEFINITIONS.globalSkillPath`; PATH presence alone never adds a row. +- Every row exposes `configured`, `installed`, and nullable `authenticated` separately. +- JSON uses `schemaVersion: 1` and the shipped `CapacityReport` contract. +- Canonical capacity is `CapacityWindow[]`; daily and weekly aliases are conveniences derived from duration. +- Missing data produces `available: "unknown"`; only explicit provider exhaustion/blocking produces `"no"`. +- Codex resolves `CODEX_HOME/auth.json` (or `~/.codex/auth.json`) and prefers PAT, then fresh OAuth, then a hardened CLI fallback. +- PAT uses authenticated `whoami` followed by `wham/usage`; OAuth calls `wham/usage` with its account ID and falls back on stale/401 responses. +- The CLI fallback runs `codex -s read-only -a untrusted app-server`, then reads both `account/rateLimits/read` and `account/read`; no model-turn method is called. +- Claude uses `claude auth status --json`; unsafe undocumented live usage is not called. +- Pi and GLM authentication may be detected, but their authoritative capacity remains unknown. +- Other configured providers are represented as unsupported with unknown availability. +- Provider probes run concurrently with isolated timeouts; a report with partial unknown rows exits successfully. +- Cache data is normalized and non-sensitive, with restrictive directory/file permissions. +- Output never contains tokens, account IDs, refresh tokens, endpoint URLs, headers, raw response bodies, stderr, or exception text. + +## Constraints and Locked Decisions + +- Command name is `capacity`. +- Default selection is configuration-directory based, not PATH based. +- Providers may expose arbitrary rolling or scoped windows; daily/weekly are not required. +- `unknown` is never equivalent to `yes`. +- Authentication stays owned by provider CLIs wherever possible. +- Capacity checking must not consume model quota. +- AI DevKit never refreshes Codex OAuth credentials and never emits auth-file contents or token-bearing errors. +- Reset credits are report-only and are never redeemed. +- The implementation remains local-first and self-host friendly. + +## Open Items + +No open item blocks the shipped feature. Future adapters require a documented, non-inference, credential-safe provider mechanism. Claude live subscription usage and z.ai/GLM quota discovery remain deliberately deferred. diff --git a/docs/ai/testing/2026-08-09-feature-capacity-command.md b/docs/ai/testing/2026-08-09-feature-capacity-command.md new file mode 100644 index 00000000..0a520100 --- /dev/null +++ b/docs/ai/testing/2026-08-09-feature-capacity-command.md @@ -0,0 +1,85 @@ +--- +phase: testing +title: Capacity Command Testing Record +description: Automated coverage, fixtures, real smoke checks, and final gate evidence +--- + +# Capacity Command Testing Record + +## Strategy and Isolation + +The feature was built with red-green-refactor cycles. Pure mapping and detection logic are unit tested, subprocess/filesystem boundaries are injected, and orchestration composes mocked adapters. CI never launches a real provider subprocess and never accesses a provider network endpoint. + +## Automated Test Inventory + +### `detection.test.ts` + +- [x] Derive configured providers from `ENVIRONMENT_DEFINITIONS.globalSkillPath`, including nested `.config/opencode`. +- [x] Check executable presence on PATH without running a provider CLI. + +### `codex.test.ts` + +- [x] Resolve `CODEX_HOME/auth.json`, home fallback, and missing-file CLI fallback. +- [x] Select PAT before OAuth and exercise PAT `whoami` plus usage calls. +- [x] Exercise fresh OAuth usage plus stale-token and 401 CLI fallback. +- [x] Mock every network and subprocess boundary. +- [x] Map API session/weekly windows, reset timestamps, credit balance, individual-limit fallback chain, and additional limits. +- [x] Launch the fallback contract with read-only/untrusted flags and both account reads. +- [x] Assert PAT, access-token, refresh-token, and raw transport failures never appear in output. +- [x] Normalize primary, secondary, and multi-bucket arbitrary windows. +- [x] Derive daily/weekly aliases by duration and report unredeemed reset-credit counts. +- [x] Deduplicate the compatibility `rateLimits` view against `rateLimitsByLimitId`. +- [x] Keep missing capacity unknown rather than positive. +- [x] Map explicit exhaustion to `available: no` without exposing reached details. +- [x] Reject URL/account-like identifiers and unsafe plan metadata. +- [x] Assert the exact initialize/initialized/rate-limit-read sequence contains no model/prompt/turn method. +- [x] Redact transport exception text. + +The response fixture is synthetic and redacted; it contains no real account data. + +### `providers.test.ts` + +- [x] Parse Claude logged-out JSON from a nonzero CLI exit while ignoring stderr. +- [x] Detect Claude authentication, apply the guarded timeout, and leave live usage unknown. +- [x] Redact Claude failures and unsafe subscription metadata. +- [x] Detect Pi and GLM authentication from provider names without exposing credential values. +- [x] Return correct agent types and truthful unknown capacity for unsupported providers. + +### `orchestrate.test.ts` + +- [x] Probe only configured providers by default. +- [x] Run independent probes and preserve a report when one fails. +- [x] Use a fresh cache and bypass it with `--refresh`. +- [x] Reject unknown explicit provider names. + +### `cache.test.ts` + +- [x] Store only the normalized key/report envelope. +- [x] Write cache files with mode `0600`. +- [x] Accept fresh matching entries and reject stale entries. + +### `command.test.ts` + +- [x] Render exact schema-v1 JSON through terminal UI. +- [x] Render human labels, arbitrary short/long windows, credits, and warnings. +- [x] Exercise Commander wiring with an injected report reader; no live adapter is called. +- [x] Reject invalid max-age values before probing. + +## Fresh Final Gates + +| Gate | Result | +|---|---| +| `npm ci` | Exit 0 | +| `npm run build` | Exit 0; six projects built, 217 CLI files compiled | +| `npm run lint` | Exit 0; six pre-existing warnings, zero errors | +| `npm run test` | Exit 0; 145 test files, 1,961 tests passed | +| `npm run test:e2e` | Exit 0; 41 tests passed | +| `npx ai-devkit@latest lint --feature capacity-command` | Exit 0; one branch-name warning | + +## Isolation Policy + +The rework deliberately performs no live credential, network, or app-server smoke test. All HTTP responses, auth-file reads, and subprocess protocol responses are synthetic and mocked so verification cannot consume quota or expose local credentials. + +## Regression Policy + +Any future provider adapter must use a redacted synthetic fixture, mock external transport in CI, prove unknown-data behavior, and add a real read-only smoke procedure that does not consume model quota. Credential-bearing diagnostics must never be added to snapshots or failure assertions. diff --git a/packages/cli/README.md b/packages/cli/README.md index f6dd07bf..6e22dc4c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -85,6 +85,12 @@ ai-devkit lint --feature lint-command # Emit machine-readable output for CI ai-devkit lint --feature lint-command --json +# Report capacity for configured providers (read-only; cached for 300 seconds) +ai-devkit capacity + +# Refresh one provider and emit the stable schema-v1 JSON report +ai-devkit capacity codex --json --refresh + # Install a skill ai-devkit skill add [skill-name] diff --git a/packages/cli/src/__tests__/commands/capacity/cache.test.ts b/packages/cli/src/__tests__/commands/capacity/cache.test.ts new file mode 100644 index 00000000..a441546c --- /dev/null +++ b/packages/cli/src/__tests__/commands/capacity/cache.test.ts @@ -0,0 +1,24 @@ +import { mkdtemp, readFile, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { readCapacityCache, writeCapacityCache } from '../../../commands/capacity/cache.js'; + +describe('capacity cache', () => { + it('stores only normalized reports with restrictive permissions', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'capacity-cache-')); + const cachePath = path.join(directory, 'nested', 'capacity.json'); + const report = { schemaVersion: 1 as const, generatedAt: '2026-08-09T10:00:00.000Z', providers: [] }; + + await writeCapacityCache('configured:codex', report, cachePath); + + expect((await stat(cachePath)).mode & 0o777).toBe(0o600); + expect(JSON.parse(await readFile(cachePath, 'utf8'))).toEqual({ key: 'configured:codex', report }); + await expect(readCapacityCache( + 'configured:codex', 60, new Date('2026-08-09T10:00:30.000Z'), cachePath + )).resolves.toEqual(report); + await expect(readCapacityCache( + 'configured:codex', 60, new Date('2026-08-09T10:02:00.000Z'), cachePath + )).resolves.toBeNull(); + }); +}); diff --git a/packages/cli/src/__tests__/commands/capacity/codex.test.ts b/packages/cli/src/__tests__/commands/capacity/codex.test.ts new file mode 100644 index 00000000..8ed6a479 --- /dev/null +++ b/packages/cli/src/__tests__/commands/capacity/codex.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + CODEX_APP_SERVER_ARGS, + parseUsage, + probeCodexCapacity, + resolveCodexAuthPath, + toRateWindow +} from '../../../commands/capacity/providers/codex.js'; + +const checkedAt = '2026-08-20T10:00:00.000Z'; +const context = { configured: true, installed: true, checkedAt }; + +function apiUsage(overrides: Record = {}) { + return { + rate_limit: { + primary_window: { used_percent: 20, limit_window_seconds: 18_000, reset_at: 1_787_220_000 }, + secondary_window: { used_percent: 60, limit_window_seconds: 604_800, reset_at: 1_787_824_800 }, + ...overrides + }, + credits: { balance: 12.5 }, + individual_limit: 100, + additional_rate_limits: [{ + limit_name: 'reviews', + rate_limit: { + primary_window: { used_percent: 10, limit_window_seconds: 3_600, reset_at: 1_787_220_000 } + } + }] + }; +} + +describe('Codex auth resolution', () => { + it('uses CODEX_HOME before HOME', () => { + expect(resolveCodexAuthPath({ CODEX_HOME: '/custom/codex', HOME: '/users/test' })).toBe('/custom/codex/auth.json'); + }); + + it('falls back to ~/.codex/auth.json', () => { + expect(resolveCodexAuthPath({ HOME: '/users/test' })).toBe('/users/test/.codex/auth.json'); + }); +}); + +describe('Codex API usage mapping', () => { + it('converts an API window without treating missing data as zero', () => { + expect(toRateWindow({ used_percent: 25, limit_window_seconds: 18_000, reset_at: 1_787_220_000 }, 'session', 'Session')).toEqual({ + id: 'session', label: 'Session', durationMinutes: 300, usedPercent: 25, + remainingPercent: 75, resetsAt: '2026-08-20T10:00:00.000Z', scope: null + }); + expect(toRateWindow({}, 'session', 'Session')).toMatchObject({ usedPercent: null, remainingPercent: null }); + }); + + it('maps session, weekly, credits, extra limits, and source', () => { + const snapshot = parseUsage(apiUsage(), 'pat', checkedAt); + expect(snapshot).toMatchObject({ + source: 'pat', creditsRemaining: 12.5, codexCreditLimit: 100, updatedAt: checkedAt, + sessionLimit: { durationMinutes: 300, remainingPercent: 80 }, + weeklyLimit: { durationMinutes: 10080, remainingPercent: 40 } + }); + expect(snapshot.extraRateWindows).toEqual([ + expect.objectContaining({ id: 'reviews:primary', remainingPercent: 90 }) + ]); + }); + + it.each([ + [{ individual_limit: 111 }, 111], + [{ rate_limit: { individual_limit: 222 } }, 222], + [{ spend_control: { individual_limit: 333 } }, 333] + ])('uses the credit-limit fallback chain', (patch, expected) => { + const usage = apiUsage(); + delete (usage as { individual_limit?: number }).individual_limit; + const input = { ...usage, ...patch, rate_limit: { ...usage.rate_limit, ...('rate_limit' in patch ? patch.rate_limit : {}) } }; + expect(parseUsage(input, 'oauth', checkedAt).codexCreditLimit).toBe(expected); + }); + + it('represents missing limits as unavailable rather than zero', () => { + const snapshot = parseUsage({ credits: {} }, 'oauth', checkedAt); + expect(snapshot.sessionLimit).toBeNull(); + expect(snapshot.weeklyLimit).toBeNull(); + expect(snapshot.creditsRemaining).toBeNull(); + }); +}); + +describe('tiered Codex probing', () => { + it('selects PAT, calls whoami then usage, and never invokes the CLI', async () => { + const fetch = vi.fn() + .mockResolvedValueOnce(new Response(JSON.stringify({ chatgpt_account_id: 'acct-1' }), { status: 200 })) + .mockResolvedValueOnce(new Response(JSON.stringify(apiUsage()), { status: 200 })); + const rpc = vi.fn(); + const result = await probeCodexCapacity({ + ...context, readFile: async () => JSON.stringify({ + personal_access_token: 'pat-secret', + tokens: { access_token: 'ignored-oauth', account_id: 'ignored-account' } + }), fetch, rpc + }); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls[0][0]).toBe('https://auth.openai.com/api/accounts/v1/user-auth-credential/whoami'); + expect(fetch.mock.calls[1][0]).toBe('https://chatgpt.com/backend-api/wham/usage'); + expect(fetch.mock.calls[1][1].headers).toMatchObject({ Authorization: 'Bearer pat-secret', 'ChatGPT-Account-Id': 'acct-1' }); + expect(rpc).not.toHaveBeenCalled(); + expect(result).toMatchObject({ source: 'provider-api', available: 'yes', usage: { source: 'pat' } }); + }); + + it('selects a fresh OAuth token without calling whoami', async () => { + const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(apiUsage()), { status: 200 })); + const result = await probeCodexCapacity({ + ...context, + readFile: async () => JSON.stringify({ tokens: { access_token: 'oauth-secret', account_id: 'acct-2', expires_at: 1_800_000_000 } }), + fetch, + now: () => new Date('2026-08-20T10:00:00.000Z') + }); + expect(fetch).toHaveBeenCalledOnce(); + expect(fetch.mock.calls[0][1].headers).toMatchObject({ Authorization: 'Bearer oauth-secret', 'ChatGPT-Account-Id': 'acct-2' }); + expect(result.usage?.source).toBe('oauth'); + }); + + it.each([ + ['missing auth file', async () => { throw Object.assign(new Error('missing'), { code: 'ENOENT' }); }], + ['stale OAuth token', async () => JSON.stringify({ tokens: { access_token: 'stale-secret', account_id: 'acct', expires_at: 1 } })], + ['OAuth 401', async () => JSON.stringify({ tokens: { access_token: 'oauth-secret', account_id: 'acct', expires_at: 1_800_000_000 } })] + ])('falls back to the CLI for %s', async (name, readFile) => { + const fetch = vi.fn().mockResolvedValue(new Response('', { status: name === 'OAuth 401' ? 401 : 200 })); + const rpc = vi.fn(async () => ({ + rateLimits: { rateLimits: { primary: { usedPercent: 5, windowDurationMins: 300, resetsAt: null } } }, + account: { account: { type: 'chatgpt' } } + })); + const result = await probeCodexCapacity({ ...context, readFile, fetch, rpc, now: () => new Date(checkedAt) }); + expect(rpc).toHaveBeenCalledOnce(); + expect(result.usage?.source).toBe('cli'); + }); + + it('falls back to CLI if PAT requests fail', async () => { + const rpc = vi.fn(async () => ({ rateLimits: {}, account: { account: null } })); + const result = await probeCodexCapacity({ + ...context, + readFile: async () => JSON.stringify({ personal_access_token: 'pat-secret' }), + fetch: vi.fn().mockRejectedValue(new Error('network failure pat-secret')), + rpc + }); + expect(rpc).toHaveBeenCalledOnce(); + expect(result.available).toBe('unknown'); + }); + + it('tries fresh OAuth after a PAT request fails', async () => { + const fetch = vi.fn() + .mockRejectedValueOnce(new Error('PAT failed')) + .mockResolvedValueOnce(new Response(JSON.stringify(apiUsage()), { status: 200 })); + const rpc = vi.fn(); + const result = await probeCodexCapacity({ + ...context, + readFile: async () => JSON.stringify({ + personal_access_token: 'pat-secret', + tokens: { access_token: 'oauth-secret', account_id: 'acct', expires_at: 1_800_000_000 } + }), + fetch, + rpc, + now: () => new Date(checkedAt) + }); + expect(fetch).toHaveBeenCalledTimes(2); + expect(result.usage?.source).toBe('oauth'); + expect(rpc).not.toHaveBeenCalled(); + }); + + it('uses hardened read-only app-server arguments and both account methods', async () => { + const rpc = vi.fn(async () => ({ + rateLimits: { rateLimits: { primary: { usedPercent: 5, windowDurationMins: 300, resetsAt: null } } }, + account: { account: { type: 'chatgpt' } } + })); + await probeCodexCapacity({ ...context, readFile: async () => '{}', rpc }); + const messages = rpc.mock.calls[0][0]; + expect(messages.map(message => message.method)).toEqual([ + 'initialize', 'initialized', 'account/rateLimits/read', 'account/read' + ]); + expect(JSON.stringify(messages)).not.toMatch(/prompt|turn\/start/); + expect(CODEX_APP_SERVER_ARGS).toEqual(['-s', 'read-only', '-a', 'untrusted', 'app-server']); + }); + + it('uses account/read to distinguish logged-out CLI state', async () => { + const result = await probeCodexCapacity({ + ...context, + readFile: async () => '{}', + rpc: async () => ({ rateLimits: {}, account: { account: null } }) + }); + expect(result).toMatchObject({ authenticated: false, status: 'unauthenticated', available: 'unknown' }); + }); + + it('never exposes tokens or raw auth content through failures', async () => { + const secrets = ['pat-secret-value', 'oauth-secret-value', 'refresh-secret-value']; + const result = await probeCodexCapacity({ + ...context, + readFile: async () => JSON.stringify({ + personal_access_token: secrets[0], + tokens: { access_token: secrets[1], refresh_token: secrets[2] } + }), + fetch: vi.fn().mockRejectedValue(new Error(secrets.join(' '))), + rpc: async () => { throw new Error(secrets.join(' ')); } + }); + const output = JSON.stringify(result); + for (const secret of secrets) expect(output).not.toContain(secret); + }); +}); diff --git a/packages/cli/src/__tests__/commands/capacity/command.test.ts b/packages/cli/src/__tests__/commands/capacity/command.test.ts new file mode 100644 index 00000000..954125e9 --- /dev/null +++ b/packages/cli/src/__tests__/commands/capacity/command.test.ts @@ -0,0 +1,68 @@ +import { Command } from 'commander'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { capacityCommand, registerCapacityCommand } from '../../../commands/capacity.js'; +import { renderCapacityReport } from '../../../commands/capacity/render.js'; +import type { CapacityReport } from '../../../commands/capacity/types.js'; +import { ui } from '../../../util/terminal-ui.js'; + +vi.mock('../../../util/terminal-ui.js', () => ({ ui: { text: vi.fn() } })); + +const report: CapacityReport = { + schemaVersion: 1, + generatedAt: '2026-08-09T10:00:00.000Z', + providers: [{ + provider: 'codex', agentType: 'codex', configured: true, installed: true, + authenticated: true, status: 'supported', available: 'yes', plan: 'pro', + checkedAt: '2026-08-09T10:00:00.000Z', source: 'provider-cli', + windows: [ + { id: 'short', label: '5 hour', durationMinutes: 300, usedPercent: 20, + remainingPercent: 80, resetsAt: '2026-08-09T12:00:00.000Z', scope: 'codex' }, + { id: 'long', label: '7 day', durationMinutes: 10080, usedPercent: 60, + remainingPercent: 40, resetsAt: '2026-08-16T10:00:00.000Z', scope: 'codex' } + ], + aliases: { dailyWindowId: null, weeklyWindowId: 'long' }, + resetCredits: { available: 1 }, + warnings: [{ code: 'sample-warning', message: 'A safe normalized warning.' }] + }] +}; + +describe('capacity command', () => { + beforeEach(() => vi.clearAllMocks()); + + it('renders schema-v1 JSON exactly through terminal UI', () => { + renderCapacityReport(report, { json: true }); + expect(ui.text).toHaveBeenCalledWith(JSON.stringify(report, null, 2)); + }); + + it('renders text labels, arbitrary short/long windows, credits, and warnings', () => { + renderCapacityReport(report); + const output = vi.mocked(ui.text).mock.calls.map(call => call[0]).join('\n'); + expect(output).toContain('Provider'); + expect(output).toContain('Auth'); + expect(output).toContain('Available'); + expect(output).toContain('80% left'); + expect(output).toContain('40% left'); + expect(output).toContain('1'); + expect(output).toContain('Warnings:'); + expect(output).toContain('A safe normalized warning.'); + }); + + it('wires the locked command surface and forwards parsed options', async () => { + const getReport = vi.fn(async () => report); + const program = new Command(); + program.exitOverride(); + registerCapacityCommand(program, getReport); + await program.parseAsync(['node', 'test', 'capacity', 'codex', '--json', '--max-age', '120', '--refresh']); + + expect(getReport).toHaveBeenCalledWith({ provider: 'codex', maxAge: 120, refresh: true }); + expect(ui.text).toHaveBeenCalledWith(JSON.stringify(report, null, 2)); + }); + + it('rejects invalid max-age values before probing', async () => { + const getReport = vi.fn(async () => report); + await expect(capacityCommand(undefined, { maxAge: '-1' }, getReport)).rejects.toThrow( + '--max-age must be a non-negative integer' + ); + expect(getReport).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/__tests__/commands/capacity/detection.test.ts b/packages/cli/src/__tests__/commands/capacity/detection.test.ts new file mode 100644 index 00000000..5198e493 --- /dev/null +++ b/packages/cli/src/__tests__/commands/capacity/detection.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it, vi } from 'vitest'; +import { detectConfiguredProviders, isBinaryInstalled } from '../../../commands/capacity/detection.js'; + +describe('capacity provider detection', () => { + it('derives configured providers from ENVIRONMENT_DEFINITIONS config directories', async () => { + const exists = vi.fn(async (path: string) => + path === '/users/test/.codex' || path === '/users/test/.config/opencode' + ); + + await expect(detectConfiguredProviders({ homeDir: '/users/test', exists })).resolves.toEqual([ + 'codex', + 'opencode' + ]); + expect(exists).toHaveBeenCalledWith('/users/test/.codex'); + expect(exists).toHaveBeenCalledWith('/users/test/.config/opencode'); + }); + + it('checks PATH without running a provider command', async () => { + const access = vi.fn(async (path: string) => { + if (path !== '/opt/bin/codex') throw new Error('missing'); + }); + + await expect(isBinaryInstalled('codex', { path: '/usr/bin:/opt/bin', access })).resolves.toBe(true); + await expect(isBinaryInstalled('claude', { path: '/usr/bin:/opt/bin', access })).resolves.toBe(false); + }); +}); diff --git a/packages/cli/src/__tests__/commands/capacity/orchestrate.test.ts b/packages/cli/src/__tests__/commands/capacity/orchestrate.test.ts new file mode 100644 index 00000000..46d9e78d --- /dev/null +++ b/packages/cli/src/__tests__/commands/capacity/orchestrate.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getCapacityReport } from '../../../commands/capacity/orchestrate.js'; +import { buildUnsupportedCapacity } from '../../../commands/capacity/providers/stub.js'; + +const now = () => new Date('2026-08-09T10:00:00.000Z'); + +describe('capacity orchestration', () => { + it('probes only configured providers by default, in parallel, and preserves partial results', async () => { + const started: string[] = []; + const report = await getCapacityReport({}, { + now, + detectConfigured: async () => ['codex', 'gemini'], + isInstalled: async provider => provider === 'codex', + probe: async (provider, context) => { + started.push(provider); + if (provider === 'codex') throw new Error('private raw response'); + return [buildUnsupportedCapacity(provider, context)]; + }, + readCache: async () => null, + writeCache: async () => undefined + }); + + expect(started.sort()).toEqual(['codex', 'gemini']); + expect(report.providers.map(provider => provider.provider)).toEqual(['codex', 'gemini']); + expect(report.providers[0]).toMatchObject({ available: 'unknown', error: { code: 'probe-failed' } }); + expect(JSON.stringify(report)).not.toContain('private raw response'); + }); + + it('uses a fresh cache unless --refresh is requested', async () => { + const cached = { + schemaVersion: 1 as const, + generatedAt: '2026-08-09T09:59:30.000Z', + providers: [buildUnsupportedCapacity('gemini', { + configured: true, installed: true, checkedAt: '2026-08-09T09:59:30.000Z' + })] + }; + const probe = vi.fn(); + const dependencies = { + now, + detectConfigured: async () => ['gemini'], + isInstalled: async () => true, + probe, + readCache: async () => cached, + writeCache: async () => undefined + }; + + await expect(getCapacityReport({ maxAge: 60 }, dependencies)).resolves.toEqual(cached); + expect(probe).not.toHaveBeenCalled(); + + dependencies.readCache = async () => cached; + dependencies.probe = vi.fn(async (provider, context) => [buildUnsupportedCapacity(provider, context)]); + await getCapacityReport({ maxAge: 60, refresh: true }, dependencies); + expect(dependencies.probe).toHaveBeenCalledOnce(); + }); + + it('rejects unknown provider names', async () => { + await expect(getCapacityReport({ provider: 'made-up' }, { + now, + detectConfigured: async () => [], + isInstalled: async () => false, + probe: async () => [], + readCache: async () => null, + writeCache: async () => undefined + })).rejects.toThrow('Unknown capacity provider'); + }); +}); diff --git a/packages/cli/src/__tests__/commands/capacity/providers.test.ts b/packages/cli/src/__tests__/commands/capacity/providers.test.ts new file mode 100644 index 00000000..4802e50e --- /dev/null +++ b/packages/cli/src/__tests__/commands/capacity/providers.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { probeClaudeCapacity, readClaudeAuthStatus } from '../../../commands/capacity/providers/claude.js'; +import { probePiCapacity } from '../../../commands/capacity/providers/pi.js'; +import { buildUnsupportedCapacity } from '../../../commands/capacity/providers/stub.js'; + +const checkedAt = '2026-08-09T10:00:00.000Z'; + +describe('non-Codex capacity adapters', () => { + it('reads logged-out Claude JSON even when the CLI exits nonzero', async () => { + const execute = async () => { + throw Object.assign(new Error('must not leak'), { + stdout: JSON.stringify({ loggedIn: false, subscriptionType: null }), + stderr: 'credential-bearing stderr must not leak' + }); + }; + + await expect(readClaudeAuthStatus(6000, execute)).resolves.toEqual({ + loggedIn: false, subscriptionType: null + }); + }); + + it('detects Claude authentication but keeps undocumented live usage guarded off', async () => { + let receivedTimeout = 0; + const result = await probeClaudeCapacity({ + configured: true, + installed: true, + checkedAt, + authStatus: async timeoutMs => { + receivedTimeout = timeoutMs; + return { loggedIn: true, subscriptionType: 'max' }; + } + }); + expect(receivedTimeout).toBe(6000); + + expect(result).toMatchObject({ + provider: 'claude', authenticated: true, status: 'supported', + available: 'unknown', plan: 'max', source: 'provider-cli' + }); + expect(result.warnings[0].code).toBe('live-usage-unavailable'); + }); + + it('redacts Claude authentication failures', async () => { + const result = await probeClaudeCapacity({ + configured: true, + installed: true, + checkedAt, + authStatus: async () => { throw new Error('oauth-token secret response body'); } + }); + + expect(result.authenticated).toBeNull(); + expect(JSON.stringify(result)).not.toMatch(/oauth-token|secret|response body/); + }); + + it('does not expose unexpected Claude subscription metadata', async () => { + const result = await probeClaudeCapacity({ + configured: true, + installed: true, + checkedAt, + authStatus: async () => ({ loggedIn: true, subscriptionType: 'token_secret_1234567890' }) + }); + + expect(result.plan).toBeNull(); + expect(JSON.stringify(result)).not.toContain('token_secret_1234567890'); + }); + + it('detects Pi and GLM authentication only from provider key names', async () => { + const results = await probePiCapacity({ + configured: true, + installed: true, + checkedAt, + readAuth: async () => JSON.stringify({ zai: { type: 'api_key', key: 'must-not-leak' } }) + }); + + expect(results.map(result => result.provider)).toEqual(['pi', 'glm']); + expect(results.every(result => result.authenticated === true)).toBe(true); + expect(results.every(result => result.available === 'unknown')).toBe(true); + expect(JSON.stringify(results)).not.toContain('must-not-leak'); + }); + + it('returns truthful unknown capacity for other configured providers', () => { + expect(buildUnsupportedCapacity('gemini', { + configured: true, installed: false, checkedAt + })).toMatchObject({ + provider: 'gemini', configured: true, installed: false, + agentType: 'gemini_cli', authenticated: null, status: 'unsupported', + available: 'unknown', source: 'none' + }); + expect(buildUnsupportedCapacity('copilot', { + configured: true, installed: true, checkedAt + }).agentType).toBe('copilot'); + }); +}); diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f0c9e86e..8e2045cb 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -12,6 +12,7 @@ import { registerChannelCommand } from './commands/channel.js'; import { registerDocsCommand } from './commands/docs.js'; import { registerPluginCommand } from './commands/plugin.js'; import { registerSetupCommand } from './commands/setup.js'; +import { registerCapacityCommand } from './commands/capacity.js'; import { registerConfiguredPluginCommands } from './services/plugin/plugin-loader.service.js'; import { createAiDevkitRuntime } from './services/plugin/runtime.js'; import { handleCliError } from './util/errors.js'; @@ -64,6 +65,7 @@ registerChannelCommand(program); registerDocsCommand(program); registerPluginCommand(program); registerSetupCommand(program); +registerCapacityCommand(program); await registerConfiguredPluginCommands(program, createAiDevkitRuntime()); diff --git a/packages/cli/src/commands/capacity.ts b/packages/cli/src/commands/capacity.ts new file mode 100644 index 00000000..2b603e10 --- /dev/null +++ b/packages/cli/src/commands/capacity.ts @@ -0,0 +1,33 @@ +import type { Command } from 'commander'; +import { getCapacityReport } from './capacity/orchestrate.js'; +import { renderCapacityReport } from './capacity/render.js'; +import type { CapacityReport } from './capacity/types.js'; + +type RawCapacityOptions = { json?: boolean; maxAge?: string; refresh?: boolean }; +type ReportReader = (options: { + provider?: string; maxAge: number; refresh: boolean; +}) => Promise; + +export async function capacityCommand( + provider: string | undefined, + options: RawCapacityOptions, + readReport: ReportReader = getCapacityReport +): Promise { + const maxAge = options.maxAge === undefined ? 300 : Number(options.maxAge); + if (!Number.isInteger(maxAge) || maxAge < 0) { + throw new Error('--max-age must be a non-negative integer.'); + } + const report = await readReport({ provider, maxAge, refresh: options.refresh === true }); + renderCapacityReport(report, options); +} + +export function registerCapacityCommand(program: Command, readReport: ReportReader = getCapacityReport): void { + program + .command('capacity [provider]') + .description('Report configured AI provider capacity without consuming model quota') + .option('--json', 'Output a schema-v1 JSON report') + .option('--max-age ', 'Maximum cache age in seconds', '300') + .option('--refresh', 'Bypass cached capacity data') + .action((provider: string | undefined, options: RawCapacityOptions) => + capacityCommand(provider, options, readReport)); +} diff --git a/packages/cli/src/commands/capacity/cache.ts b/packages/cli/src/commands/capacity/cache.ts new file mode 100644 index 00000000..2856771f --- /dev/null +++ b/packages/cli/src/commands/capacity/cache.ts @@ -0,0 +1,46 @@ +import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import type { CapacityReport } from './types.js'; + +function defaultCachePath(): string { + return path.join(homedir(), '.ai-devkit', 'cache', 'capacity.json'); +} + +function isReport(value: unknown): value is CapacityReport { + if (value === null || typeof value !== 'object') return false; + const report = value as Partial; + return report.schemaVersion === 1 && typeof report.generatedAt === 'string' && Array.isArray(report.providers); +} + +export async function readCapacityCache( + key: string, + maxAgeSeconds: number, + now = new Date(), + cachePath = defaultCachePath() +): Promise { + try { + const parsed: unknown = JSON.parse(await readFile(cachePath, 'utf8')); + if (parsed === null || typeof parsed !== 'object') return null; + const entry = parsed as { key?: unknown; report?: unknown }; + if (entry.key !== key || !isReport(entry.report)) return null; + const age = now.getTime() - Date.parse(entry.report.generatedAt); + return age >= 0 && age <= maxAgeSeconds * 1000 ? entry.report : null; + } catch { + return null; + } +} + +export async function writeCapacityCache( + key: string, + report: CapacityReport, + cachePath = defaultCachePath() +): Promise { + const directory = path.dirname(cachePath); + const temporary = `${cachePath}.${process.pid}.tmp`; + await mkdir(directory, { recursive: true, mode: 0o700 }); + await chmod(directory, 0o700); + await writeFile(temporary, JSON.stringify({ key, report }), { encoding: 'utf8', mode: 0o600 }); + await chmod(temporary, 0o600); + await rename(temporary, cachePath); +} diff --git a/packages/cli/src/commands/capacity/detection.ts b/packages/cli/src/commands/capacity/detection.ts new file mode 100644 index 00000000..2c927060 --- /dev/null +++ b/packages/cli/src/commands/capacity/detection.ts @@ -0,0 +1,59 @@ +import { constants } from 'node:fs'; +import { access as fsAccess } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { ENVIRONMENT_DEFINITIONS } from '../../util/env.js'; + +const PROVIDER_NAMES: Record = { github: 'copilot' }; + +type DetectionOptions = { + homeDir?: string; + exists?: (path: string) => Promise; +}; + +type BinaryOptions = { + path?: string; + access?: (path: string) => Promise; +}; + +function configDirectory(globalSkillPath: string): string { + const parts = globalSkillPath.split('/').filter(Boolean); + return parts[0] === '.config' && parts[1] ? path.join(parts[0], parts[1]) : parts[0]; +} + +async function defaultExists(target: string): Promise { + try { + await fsAccess(target, constants.F_OK); + return true; + } catch { + return false; + } +} + +export async function detectConfiguredProviders(options: DetectionOptions = {}): Promise { + const home = options.homeDir ?? homedir(); + const exists = options.exists ?? defaultExists; + const definitions = Object.values(ENVIRONMENT_DEFINITIONS).filter( + (definition): definition is typeof definition & { globalSkillPath: string } => + typeof definition.globalSkillPath === 'string' + ); + const providers = await Promise.all(definitions.map(async definition => ({ + provider: PROVIDER_NAMES[definition.code] ?? definition.code, + configured: await exists(path.join(home, configDirectory(definition.globalSkillPath))) + }))); + return [...new Set(providers.filter(item => item.configured).map(item => item.provider))].sort(); +} + +export async function isBinaryInstalled(binary: string, options: BinaryOptions = {}): Promise { + const pathValue = options.path ?? process.env.PATH ?? ''; + const access = options.access ?? ((target: string) => fsAccess(target, constants.X_OK)); + for (const directory of pathValue.split(path.delimiter).filter(Boolean)) { + try { + await access(path.join(directory, binary)); + return true; + } catch { + // Continue searching PATH. + } + } + return false; +} diff --git a/packages/cli/src/commands/capacity/orchestrate.ts b/packages/cli/src/commands/capacity/orchestrate.ts new file mode 100644 index 00000000..b0577f9a --- /dev/null +++ b/packages/cli/src/commands/capacity/orchestrate.ts @@ -0,0 +1,109 @@ +import { ENVIRONMENT_DEFINITIONS } from '../../util/env.js'; +import { readCapacityCache, writeCapacityCache } from './cache.js'; +import { detectConfiguredProviders, isBinaryInstalled } from './detection.js'; +import { probeClaudeCapacity } from './providers/claude.js'; +import { probeCodexCapacity } from './providers/codex.js'; +import { probePiCapacity } from './providers/pi.js'; +import { buildUnsupportedCapacity } from './providers/stub.js'; +import type { CapacityReport, ProviderCapacity } from './types.js'; + +type ProbeContext = { configured: boolean; installed: boolean; checkedAt: string }; +type CapacityOptions = { provider?: string; maxAge?: number; refresh?: boolean }; +type Dependencies = { + now: () => Date; + detectConfigured: () => Promise; + isInstalled: (provider: string) => Promise; + probe: (provider: string, context: ProbeContext) => Promise; + readCache: (key: string, maxAge: number, now: Date) => Promise; + writeCache: (key: string, report: CapacityReport) => Promise; +}; + +const providerNames = Object.keys(ENVIRONMENT_DEFINITIONS).map(name => name === 'github' ? 'copilot' : name); +export const CAPACITY_PROVIDERS = [...new Set([...providerNames, 'glm'])].sort(); + +const BINARIES: Record = { + 'antigravity-cli': 'agy', copilot: 'copilot', gemini: 'gemini', github: 'copilot', glm: 'pi' +}; + +async function defaultProbe(provider: string, context: ProbeContext): Promise { + if (provider === 'codex') return [await probeCodexCapacity(context)]; + if (provider === 'claude') return [await probeClaudeCapacity(context)]; + if (provider === 'pi' || provider === 'glm') { + const results = await probePiCapacity(context); + if (provider === 'pi') return results; + return [results.find(result => result.provider === 'glm') ?? + buildUnsupportedCapacity('glm', context, null, + 'GLM capacity is unknown because no verified quota mechanism is available.')]; + } + return [buildUnsupportedCapacity(provider, context)]; +} + +const defaults: Dependencies = { + now: () => new Date(), + detectConfigured: detectConfiguredProviders, + isInstalled: provider => isBinaryInstalled(BINARIES[provider] ?? provider), + probe: defaultProbe, + readCache: readCapacityCache, + writeCache: writeCapacityCache +}; + +function failure(provider: string, context: ProbeContext, code = 'probe-failed'): ProviderCapacity { + const result = buildUnsupportedCapacity(provider, context, null, 'Capacity could not be checked safely.'); + result.status = 'unknown'; + result.error = { code, retryable: true }; + return result; +} + +async function withTimeout(promise: Promise, timeoutMs: number): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), timeoutMs); }) + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +export async function getCapacityReport( + options: CapacityOptions = {}, + dependencies: Dependencies = defaults +): Promise { + const requested = options.provider?.toLowerCase(); + if (requested && !CAPACITY_PROVIDERS.includes(requested)) { + throw new Error(`Unknown capacity provider "${options.provider}".`); + } + const now = dependencies.now(); + const configured = await dependencies.detectConfigured(); + const selected = requested ? [requested] : configured; + const cacheKey = `${requested ? 'provider' : 'configured'}:${selected.slice().sort().join(',')}`; + const maxAge = options.maxAge ?? 300; + if (!options.refresh && maxAge > 0) { + const cached = await dependencies.readCache(cacheKey, maxAge, now); + if (cached) return cached; + } + + const groups = await Promise.all(selected.map(async provider => { + const binaryProvider = provider === 'glm' ? 'pi' : provider; + const context: ProbeContext = { + configured: configured.includes(provider) || (provider === 'glm' && configured.includes('pi')), + installed: await dependencies.isInstalled(binaryProvider), + checkedAt: now.toISOString() + }; + try { + const results = await withTimeout(dependencies.probe(provider, context), 7000); + return requested === 'pi' ? results.filter(result => result.provider === 'pi') : results; + } catch { + return [failure(provider, context)]; + } + })); + const providers = groups.flat().sort((left, right) => left.provider.localeCompare(right.provider)); + const report: CapacityReport = { schemaVersion: 1, generatedAt: now.toISOString(), providers }; + try { + await dependencies.writeCache(cacheKey, report); + } catch { + // Cache failures must not prevent a capacity report. + } + return report; +} diff --git a/packages/cli/src/commands/capacity/providers/claude.ts b/packages/cli/src/commands/capacity/providers/claude.ts new file mode 100644 index 00000000..50cbf9c9 --- /dev/null +++ b/packages/cli/src/commands/capacity/providers/claude.ts @@ -0,0 +1,76 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import type { ProviderCapacity } from '../types.js'; + +const execFileAsync = promisify(execFile); +type UnknownRecord = Record; +type ClaudeContext = { configured: boolean; installed: boolean; checkedAt: string }; +type ClaudeOptions = ClaudeContext & { authStatus?: (timeoutMs: number) => Promise; timeoutMs?: number }; + +function record(value: unknown): UnknownRecord | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as UnknownRecord : null; +} + +function safePlan(value: unknown): string | null { + if (typeof value !== 'string' || !/^[a-z][a-z0-9_-]{0,31}$/i.test(value)) return null; + return /(?:account|token|secret|key|oauth)/i.test(value) ? null : value; +} + +type AuthStatusExecutor = (timeoutMs: number) => Promise<{ stdout: string }>; + +async function executeClaudeAuthStatus(timeoutMs: number): Promise<{ stdout: string }> { + const result = await execFileAsync('claude', ['auth', 'status', '--json'], { + timeout: timeoutMs, maxBuffer: 64 * 1024, encoding: 'utf8' + }); + return { stdout: String(result.stdout) }; +} + +export async function readClaudeAuthStatus( + timeoutMs: number, + execute: AuthStatusExecutor = executeClaudeAuthStatus +): Promise { + try { + return JSON.parse((await execute(timeoutMs)).stdout); + } catch (error) { + const output = record(error)?.stdout; + if (typeof output === 'string' && output.length <= 64 * 1024) return JSON.parse(output); + throw new Error('Claude authentication status unavailable'); + } +} + +function base(context: ClaudeContext): ProviderCapacity { + return { + provider: 'claude', agentType: 'claude', configured: context.configured, + installed: context.installed, authenticated: null, status: 'unknown', + available: 'unknown', plan: null, checkedAt: context.checkedAt, source: 'none', + windows: [], aliases: { dailyWindowId: null, weeklyWindowId: null }, warnings: [] + }; +} + +export async function probeClaudeCapacity(options: ClaudeOptions): Promise { + const result = base(options); + if (!options.installed) { + result.status = 'unavailable'; + result.warnings.push({ code: 'cli-not-installed', message: 'Claude CLI is not installed.' }); + return result; + } + try { + const timeoutMs = options.timeoutMs ?? 6000; + const raw = await (options.authStatus ?? readClaudeAuthStatus)(timeoutMs); + const auth = record(raw); + const authenticated = auth?.loggedIn === true || auth?.authenticated === true; + result.authenticated = authenticated; + result.status = authenticated ? 'supported' : 'unauthenticated'; + result.source = 'provider-cli'; + result.plan = safePlan(auth?.subscriptionType); + result.warnings.push({ + code: 'live-usage-unavailable', + message: 'Claude live capacity is unknown because no safe provider-owned usage command is available.' + }); + return result; + } catch { + result.error = { code: 'claude-auth-probe-failed', retryable: true }; + result.warnings.push({ code: 'probe-failed', message: 'Claude authentication could not be checked safely.' }); + return result; + } +} diff --git a/packages/cli/src/commands/capacity/providers/codex.ts b/packages/cli/src/commands/capacity/providers/codex.ts new file mode 100644 index 00000000..62077022 --- /dev/null +++ b/packages/cli/src/commands/capacity/providers/codex.ts @@ -0,0 +1,384 @@ +import { spawn } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { + CapacityWindow, + CodexUsageSource, + ProviderCapacity, + UsageSnapshot +} from '../types.js'; + +type UnknownRecord = Record; +type RpcMessage = { id?: number; method: string; params?: UnknownRecord }; +type CliResponses = { rateLimits: unknown; account: unknown }; +type CodexRpc = (messages: RpcMessage[]) => Promise; + +export const CODEX_APP_SERVER_ARGS = ['-s', 'read-only', '-a', 'untrusted', 'app-server'] as const; + +type CodexProbeOptions = { + configured: boolean; + installed: boolean; + checkedAt: string; + readFile?: (path: string, encoding: BufferEncoding) => Promise; + fetch?: typeof globalThis.fetch; + rpc?: CodexRpc; + timeoutMs?: number; + env?: NodeJS.ProcessEnv; + now?: () => Date; +}; + +function record(value: unknown): UnknownRecord | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? value as UnknownRecord + : null; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === 'number' && Number.isFinite(value) ? value : null; +} + +function nonEmptyText(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function resetTime(value: unknown): string | null { + const seconds = finiteNumber(value); + if (seconds !== null) return new Date(seconds * 1000).toISOString(); + if (typeof value === 'string' && !Number.isNaN(Date.parse(value))) return new Date(value).toISOString(); + return null; +} + +function safeIdentifier(value: unknown): string | null { + const candidate = nonEmptyText(value); + if (!candidate || !/^[a-z][a-z0-9_-]{0,63}$/i.test(candidate)) return null; + if (/(?:account|token|secret|key)[_-]?\d{6,}/i.test(candidate)) return null; + return candidate; +} + +function safeLabel(value: unknown): string | null { + const candidate = nonEmptyText(value); + if (!candidate || candidate.length > 80 || !/^[a-z0-9 _-]+$/i.test(candidate)) return null; + if (/(?:account|token|secret|key)[_-]?\d{6,}/i.test(candidate)) return null; + return candidate; +} + +function safePlan(value: unknown): string | null { + const candidate = safeIdentifier(value); + return candidate && !/(?:account|token|secret|key|oauth)/i.test(candidate) ? candidate : null; +} + +export function resolveCodexAuthPath(env: NodeJS.ProcessEnv = process.env): string { + const root = env.CODEX_HOME || join(env.HOME || '', '.codex'); + return join(root, 'auth.json'); +} + +export function toRateWindow( + value: unknown, + id: string, + label: string, + scope: string | null = null +): CapacityWindow | null { + const input = record(value); + if (!input) return null; + const used = finiteNumber(input.used_percent); + const seconds = finiteNumber(input.limit_window_seconds); + return { + id, + label, + durationMinutes: seconds === null ? null : seconds / 60, + usedPercent: used, + remainingPercent: used === null ? null : Math.max(0, Math.min(100, 100 - used)), + resetsAt: resetTime(input.reset_at), + scope + }; +} + +function extraWindows(value: unknown): CapacityWindow[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry, index) => { + const limit = record(entry); + if (!limit) return []; + const scope = safeIdentifier(limit.limit_name) ?? `extra-${index + 1}`; + const windows = record(limit.rate_limit) ?? limit; + return [ + toRateWindow(windows.primary_window, `${scope}:primary`, `${scope} primary`, scope), + toRateWindow(windows.secondary_window, `${scope}:secondary`, `${scope} secondary`, scope) + ].filter((window): window is CapacityWindow => window !== null); + }); +} + +export function parseUsage(raw: unknown, source: Exclude, updatedAt: string): UsageSnapshot { + const response = record(raw) ?? {}; + const limits = record(response.rate_limit) ?? {}; + const credits = record(response.credits) ?? {}; + const spendControl = record(response.spend_control) ?? {}; + return { + sessionLimit: toRateWindow(limits.primary_window, 'session', 'Session'), + weeklyLimit: toRateWindow(limits.secondary_window, 'weekly', 'Weekly'), + creditsRemaining: finiteNumber(credits.balance), + codexCreditLimit: finiteNumber(response.individual_limit) + ?? finiteNumber(limits.individual_limit) + ?? finiteNumber(spendControl.individual_limit), + extraRateWindows: extraWindows(response.additional_rate_limits), + source, + updatedAt + }; +} + +function cliWindow(value: unknown, id: string, label: string, scope: string | null): CapacityWindow | null { + const input = record(value); + if (!input) return null; + const used = finiteNumber(input.usedPercent); + return { + id, + label, + durationMinutes: finiteNumber(input.windowDurationMins), + usedPercent: used, + remainingPercent: used === null ? null : Math.max(0, Math.min(100, 100 - used)), + resetsAt: resetTime(input.resetsAt), + scope + }; +} + +function cliSnapshotWindows(value: unknown, fallbackId: string): CapacityWindow[] { + const snapshot = record(value); + if (!snapshot) return []; + const scope = safeIdentifier(snapshot.limitId) ?? safeIdentifier(fallbackId) ?? 'codex'; + const name = safeLabel(snapshot.limitName) ?? scope; + return [ + cliWindow(snapshot.primary, `${scope}:primary`, `${name} primary`, scope), + cliWindow(snapshot.secondary, `${scope}:secondary`, `${name} secondary`, scope) + ].filter((item): item is CapacityWindow => item !== null); +} + +export function parseCliUsage(raw: unknown, updatedAt: string): UsageSnapshot { + const response = record(raw) ?? {}; + const primary = record(response.rateLimits); + const windows = cliSnapshotWindows(primary, 'codex'); + const buckets = record(response.rateLimitsByLimitId); + if (buckets) { + for (const [id, snapshot] of Object.entries(buckets)) windows.push(...cliSnapshotWindows(snapshot, id)); + } + const unique = [...new Map(windows.map(window => [window.id, window])).values()]; + return { + sessionLimit: unique.find(window => window.id === 'codex:primary') ?? unique[0] ?? null, + weeklyLimit: unique.find(window => window.id === 'codex:secondary') ?? null, + creditsRemaining: null, + codexCreditLimit: null, + extraRateWindows: unique.filter(window => !['codex:primary', 'codex:secondary'].includes(window.id)), + source: 'cli', + updatedAt + }; +} + +function aliasFor(windows: CapacityWindow[], target: number, tolerance: number): string | null { + return windows.find(window => + window.durationMinutes !== null && Math.abs(window.durationMinutes - target) <= tolerance + )?.id ?? null; +} + +function capacityFromSnapshot(snapshot: UsageSnapshot, context: CodexProbeOptions, raw?: unknown): ProviderCapacity { + const windows = [snapshot.sessionLimit, snapshot.weeklyLimit, ...snapshot.extraRateWindows] + .filter((window): window is CapacityWindow => window !== null); + const hasUsage = windows.some(window => window.usedPercent !== null); + const rateLimits = record(record(raw)?.rateLimits); + const reached = nonEmptyText(rateLimits?.rateLimitReachedType); + const resetCredits = record(record(raw)?.rateLimitResetCredits) ?? record(record(raw)?.usageLimitResetCredits); + return { + provider: 'codex', + agentType: 'codex', + configured: context.configured, + installed: context.installed, + authenticated: true, + status: reached || hasUsage ? 'supported' : 'unknown', + available: reached ? 'no' : hasUsage ? 'yes' : 'unknown', + plan: safePlan(rateLimits?.planType), + checkedAt: context.checkedAt, + source: snapshot.source === 'cli' ? 'provider-cli' : 'provider-api', + windows, + aliases: { + dailyWindowId: aliasFor(windows, 1440, 120), + weeklyWindowId: aliasFor(windows, 10080, 720) + }, + resetCredits: { available: finiteNumber(resetCredits?.availableCount) }, + usage: snapshot, + warnings: hasUsage || reached ? [] : [{ + code: 'capacity-unavailable', + message: 'Codex did not return authoritative capacity windows.' + }] + }; +} + +export function mapCodexRateLimits(raw: unknown, context: Pick): ProviderCapacity { + return capacityFromSnapshot(parseCliUsage(raw, context.checkedAt), context, raw); +} + +function jwtExpiry(token: string): number | null { + const part = token.split('.')[1]; + if (!part) return null; + try { + return finiteNumber(record(JSON.parse(Buffer.from(part, 'base64url').toString('utf8')))?.exp); + } catch { + return null; + } +} + +function staleOAuth(tokens: UnknownRecord, token: string, now: Date): boolean { + const metadata = tokens.expires_at ?? tokens.expiresAt ?? tokens.expiry; + let expiry: number | null = finiteNumber(metadata); + if (typeof metadata === 'string') { + const parsed = Date.parse(metadata); + expiry = Number.isNaN(parsed) ? null : parsed / 1000; + } + expiry ??= jwtExpiry(token); + return expiry !== null && expiry <= now.getTime() / 1000; +} + +async function fetchJson(fetcher: typeof globalThis.fetch, url: string, init: RequestInit, timeoutMs: number): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetcher(url, { ...init, signal: controller.signal }); + if (!response.ok) throw new Error(response.status === 401 ? 'unauthorized' : 'request failed'); + return await response.json(); + } finally { + clearTimeout(timer); + } +} + +async function apiSnapshot( + token: string, + accountId: string, + source: 'pat' | 'oauth', + options: CodexProbeOptions +): Promise { + const fetcher = options.fetch ?? globalThis.fetch; + const raw = await fetchJson(fetcher, 'https://chatgpt.com/backend-api/wham/usage', { + headers: { Authorization: `Bearer ${token}`, 'ChatGPT-Account-Id': accountId } + }, options.timeoutMs ?? 5000); + return parseUsage(raw, source, options.checkedAt); +} + +function appServerRpc(messages: RpcMessage[], timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const child = spawn('codex', CODEX_APP_SERVER_ARGS, { + stdio: ['pipe', 'pipe', 'ignore'] + }); + const results: Partial = {}; + let buffer = ''; + let settled = false; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + child.kill(); + if (error) reject(error); + else resolve(results as CliResponses); + }; + const timer = setTimeout(() => finish(new Error('codex probe timed out')), timeoutMs); + child.once('error', () => finish(new Error('codex app-server unavailable'))); + child.once('exit', () => { if (!settled) finish(new Error('codex app-server exited')); }); + child.stdout.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + buffer += chunk; + for (;;) { + const newline = buffer.indexOf('\n'); + if (newline < 0) break; + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (!line) continue; + let message: UnknownRecord; + try { message = JSON.parse(line) as UnknownRecord; } catch { continue; } + if (message.id === 1) { + for (const request of messages.slice(1)) child.stdin.write(`${JSON.stringify(request)}\n`); + } else if (message.id === 2) { + if (message.error) finish(new Error('codex rate-limit method failed')); + else results.rateLimits = message.result; + } else if (message.id === 3) { + if (message.error) finish(new Error('codex account method failed')); + else results.account = message.result; + } + if ('rateLimits' in results && 'account' in results) finish(); + } + }); + child.stdin.write(`${JSON.stringify(messages[0])}\n`); + }); +} + +function unavailable(options: CodexProbeOptions, installed = options.installed): ProviderCapacity { + return { + provider: 'codex', agentType: 'codex', configured: options.configured, installed, + authenticated: null, status: installed ? 'unknown' : 'unavailable', available: 'unknown', plan: null, + checkedAt: options.checkedAt, source: 'none', windows: [], + aliases: { dailyWindowId: null, weeklyWindowId: null }, resetCredits: { available: null }, + warnings: [{ + code: installed ? 'probe-failed' : 'cli-not-installed', + message: installed ? 'Codex capacity could not be read safely.' : 'Codex CLI is not installed.' + }], + ...(installed ? { error: { code: 'codex-probe-failed', retryable: true } } : {}) + }; +} + +async function cliFallback(options: CodexProbeOptions): Promise { + if (!options.installed) return unavailable(options, false); + const messages: RpcMessage[] = [ + { id: 1, method: 'initialize', params: { + clientInfo: { name: 'ai-devkit', title: null, version: '1' }, capabilities: null + } }, + { method: 'initialized' }, + { id: 2, method: 'account/rateLimits/read' }, + { id: 3, method: 'account/read' } + ]; + try { + const rpc = options.rpc ?? (requests => appServerRpc(requests, options.timeoutMs)); + const response = await rpc(messages); + const result = capacityFromSnapshot(parseCliUsage(response.rateLimits, options.checkedAt), options, response.rateLimits); + const accountEnvelope = record(response.account); + if (accountEnvelope && Object.hasOwn(accountEnvelope, 'account') && !record(accountEnvelope.account)) { + result.authenticated = false; + result.status = 'unauthenticated'; + result.available = 'unknown'; + } + return result; + } catch { + return unavailable(options); + } +} + +export async function probeCodexCapacity(options: CodexProbeOptions): Promise { + let parsed: UnknownRecord | null = null; + try { + const contents = await (options.readFile ?? readFile)(resolveCodexAuthPath(options.env), 'utf8'); + parsed = record(JSON.parse(contents)); + } catch { + return cliFallback(options); + } + + const auth = parsed ?? {}; + const pat = nonEmptyText(auth.personal_access_token); + if (pat) { + try { + const fetcher = options.fetch ?? globalThis.fetch; + const whoami = record(await fetchJson(fetcher, + 'https://auth.openai.com/api/accounts/v1/user-auth-credential/whoami', + { headers: { Authorization: `Bearer ${pat}` } }, options.timeoutMs ?? 5000)); + const accountId = nonEmptyText(whoami?.chatgpt_account_id); + if (!accountId) throw new Error('account unavailable'); + return capacityFromSnapshot(await apiSnapshot(pat, accountId, 'pat', options), options); + } catch { + // Continue to a separately available OAuth credential before using the CLI. + } + } + + const tokens = record(auth.tokens); + const accessToken = nonEmptyText(tokens?.access_token); + const accountId = nonEmptyText(tokens?.account_id); + if (tokens && accessToken && accountId && !staleOAuth(tokens, accessToken, (options.now ?? (() => new Date()))())) { + try { + return capacityFromSnapshot(await apiSnapshot(accessToken, accountId, 'oauth', options), options); + } catch { + return cliFallback(options); + } + } + return cliFallback(options); +} diff --git a/packages/cli/src/commands/capacity/providers/pi.ts b/packages/cli/src/commands/capacity/providers/pi.ts new file mode 100644 index 00000000..5e567c66 --- /dev/null +++ b/packages/cli/src/commands/capacity/providers/pi.ts @@ -0,0 +1,35 @@ +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import type { ProviderCapacity } from '../types.js'; +import { buildUnsupportedCapacity } from './stub.js'; + +type PiOptions = { + configured: boolean; + installed: boolean; + checkedAt: string; + readAuth?: () => Promise; + homeDir?: string; +}; + +export async function probePiCapacity(options: PiOptions): Promise { + let providers: string[] = []; + try { + const raw = await (options.readAuth ?? (() => + readFile(path.join(options.homeDir ?? homedir(), '.pi', 'agent', 'auth.json'), 'utf8')))(); + const parsed: unknown = JSON.parse(raw); + if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) { + providers = Object.keys(parsed); + } + } catch { + // Authentication remains unknown; never surface file contents or parser errors. + } + const piAuthenticated = providers.length > 0; + const results = [buildUnsupportedCapacity('pi', options, piAuthenticated || null, + 'Pi is an agent harness and does not expose account-wide capacity.')]; + if (providers.some(provider => provider === 'zai' || provider === 'zai-coding-cn')) { + results.push(buildUnsupportedCapacity('glm', options, true, + 'GLM authentication is configured through Pi, but no verified quota mechanism is available.')); + } + return results; +} diff --git a/packages/cli/src/commands/capacity/providers/stub.ts b/packages/cli/src/commands/capacity/providers/stub.ts new file mode 100644 index 00000000..450eaa0b --- /dev/null +++ b/packages/cli/src/commands/capacity/providers/stub.ts @@ -0,0 +1,31 @@ +import type { ProviderCapacity } from '../types.js'; + +type StubContext = { configured: boolean; installed: boolean; checkedAt: string }; + +const AGENT_TYPES: Record = { + claude: 'claude', codex: 'codex', copilot: 'copilot', gemini: 'gemini_cli', + glm: 'pi', grok: 'grok_cli', opencode: 'opencode', pi: 'pi' +}; + +export function buildUnsupportedCapacity( + provider: string, + context: StubContext, + authenticated: boolean | null = null, + warning = 'Authoritative capacity discovery is not supported for this provider.' +): ProviderCapacity { + return { + provider, + agentType: AGENT_TYPES[provider] ?? null, + configured: context.configured, + installed: context.installed, + authenticated, + status: 'unsupported', + available: 'unknown', + plan: null, + checkedAt: context.checkedAt, + source: 'none', + windows: [], + aliases: { dailyWindowId: null, weeklyWindowId: null }, + warnings: [{ code: 'capacity-unsupported', message: warning }] + }; +} diff --git a/packages/cli/src/commands/capacity/render.ts b/packages/cli/src/commands/capacity/render.ts new file mode 100644 index 00000000..9a690743 --- /dev/null +++ b/packages/cli/src/commands/capacity/render.ts @@ -0,0 +1,52 @@ +import { ui } from '../../util/terminal-ui.js'; +import type { CapacityReport, CapacityWindow } from './types.js'; + +function authLabel(value: boolean | null): string { + return value === true ? 'yes' : value === false ? 'no' : 'unknown'; +} + +function formatWindow(window: CapacityWindow | undefined): string { + if (!window || window.remainingPercent === null) return 'unknown'; + const reset = window.resetsAt ? ` · resets ${window.resetsAt}` : ''; + return `${window.remainingPercent}% left${reset}`; +} + +function windowPair(windows: CapacityWindow[]): [CapacityWindow | undefined, CapacityWindow | undefined] { + const known = windows.slice().sort((left, right) => + (left.durationMinutes ?? Number.MAX_SAFE_INTEGER) - (right.durationMinutes ?? Number.MAX_SAFE_INTEGER) + ); + return [known[0], known.length > 1 ? known[known.length - 1] : undefined]; +} + +export function renderCapacityReport(report: CapacityReport, options: { json?: boolean } = {}): void { + if (options.json) { + ui.text(JSON.stringify(report, null, 2)); + return; + } + const rows = report.providers.map(provider => { + const [shortWindow, longWindow] = windowPair(provider.windows); + return [ + provider.provider, + authLabel(provider.authenticated), + provider.available, + formatWindow(shortWindow), + formatWindow(longWindow), + provider.resetCredits?.available === null || provider.resetCredits?.available === undefined + ? '—' : String(provider.resetCredits.available) + ]; + }); + const headers = ['Provider', 'Auth', 'Available', 'Short window', 'Long window', 'Reset credits']; + const widths = headers.map((header, index) => Math.max(header.length, ...rows.map(row => row[index].length))); + const line = (cells: string[]) => cells.map((cell, index) => cell.padEnd(widths[index])).join(' ').trimEnd(); + ui.text(line(headers)); + ui.text(line(widths.map(width => '─'.repeat(width)))); + for (const row of rows) ui.text(line(row)); + const warnings = report.providers.flatMap(provider => provider.warnings.map(warning => + `${provider.provider}: ${warning.message}` + )); + if (warnings.length > 0) { + ui.text(''); + ui.text('Warnings:'); + for (const warning of warnings) ui.text(` ${warning}`); + } +} diff --git a/packages/cli/src/commands/capacity/types.ts b/packages/cli/src/commands/capacity/types.ts new file mode 100644 index 00000000..42b621fc --- /dev/null +++ b/packages/cli/src/commands/capacity/types.ts @@ -0,0 +1,50 @@ +export type ProviderStatus = 'supported' | 'unsupported' | 'unauthenticated' | 'unavailable' | 'unknown'; +export type Availability = 'yes' | 'no' | 'unknown'; +export type CapacitySource = 'provider-cli' | 'provider-api' | 'local-observation' | 'none'; + +export interface CapacityWindow { + id: string; + label: string; + durationMinutes: number | null; + usedPercent: number | null; + remainingPercent: number | null; + resetsAt: string | null; + scope: string | null; +} + +export type CodexUsageSource = 'pat' | 'oauth' | 'cli'; + +export interface UsageSnapshot { + sessionLimit: CapacityWindow | null; + weeklyLimit: CapacityWindow | null; + creditsRemaining: number | null; + codexCreditLimit: number | null; + extraRateWindows: CapacityWindow[]; + source: CodexUsageSource; + updatedAt: string; +} + +export interface ProviderCapacity { + provider: string; + agentType: string | null; + configured: boolean; + installed: boolean; + authenticated: boolean | null; + status: ProviderStatus; + available: Availability; + plan: string | null; + checkedAt: string; + source: CapacitySource; + windows: CapacityWindow[]; + aliases: { dailyWindowId: string | null; weeklyWindowId: string | null }; + resetCredits?: { available: number | null }; + usage?: UsageSnapshot; + warnings: Array<{ code: string; message: string }>; + error?: { code: string; retryable: boolean }; +} + +export interface CapacityReport { + schemaVersion: 1; + generatedAt: string; + providers: ProviderCapacity[]; +}