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..994dd567 --- /dev/null +++ b/docs/ai/design/2026-08-09-feature-capacity-command.md @@ -0,0 +1,156 @@ +--- +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 --> AppServer[codex app-server] + 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 A as codex app-server --stdio + C->>A: initialize(clientInfo, capabilities=null) + A-->>C: initialize result + C->>A: initialized + C->>A: account/rateLimits/read + A-->>C: rateLimits + buckets + reset-credit summary + C->>C: sanitize, normalize, deduplicate, derive aliases +``` + +The JSON-line transport is injectable in tests. It ignores stderr, bounds execution with a timeout, kills the child after completion, and exposes only normalized fields. It never invokes `turn/start`, `codex exec`, or another model method. The mapper supports the current `rateLimitResetCredits` field plus the older compatibility name, reports `availableCount`, and has no consume/redeem operation. + +### 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 + +- Provider CLIs own OAuth/session authentication; secrets are not passed on command lines. +- 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 private HTTP calls: excessive credential exposure and undocumented coupling. +- 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..41cb235a --- /dev/null +++ b/docs/ai/implementation/2026-08-09-feature-capacity-command.md @@ -0,0 +1,100 @@ +--- +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`: drives app-server JSON-RPC and sanitizes/normalizes rate-limit 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. + +## Codex JSON-RPC Client + +The adapter spawns `codex app-server --stdio` 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. + +Response id 2 is 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 authentication and refresh remain inside Codex app-server. +- 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..521034f9 --- /dev/null +++ b/docs/ai/planning/2026-08-09-feature-capacity-command.md @@ -0,0 +1,69 @@ +--- +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. + +## 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. Full tests and real read-only probes drove protocol/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..7bf54c93 --- /dev/null +++ b/docs/ai/requirements/2026-08-09-feature-capacity-command.md @@ -0,0 +1,85 @@ +--- +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. +- Direct use of undocumented provider credentials or private endpoints. + +## 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 uses `codex app-server --stdio` with `initialize`, `initialized`, then `account/rateLimits/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. +- 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..c8c29a47 --- /dev/null +++ b/docs/ai/testing/2026-08-09-feature-capacity-command.md @@ -0,0 +1,98 @@ +--- +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] 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. + +## Coverage + +The full CLI coverage run passed repository thresholds: + +- Statements: 71.47% +- Branches: 62.04% +- Functions: 70.06% +- Lines: 72.77% +- Capacity core modules: 80.59% statements and 85.98% lines + +The lower direct coverage in the default Codex transport is intentional: CI tests the injected protocol contract and mapper rather than spawning a real authenticated provider process. + +## Fresh Final Gates + +| Gate | Result | +|---|---| +| `cd packages/cli && npm run lint` | Exit 0; five pre-existing warnings, zero errors | +| `cd packages/cli && npm test` | 85 test files, 953 tests passed | +| `cd packages/cli && npm run build` | Exit 0; 207 files compiled | +| `cd packages/cli && npm run test:coverage` | Exit 0; repository thresholds passed | +| PR #147 CI | 7/7 checks green | + +## Real-Run Smoke Results + +The built CLI was run on the development machine with configured Claude, Codex, and Pi/z.ai state: + +- [x] `capacity --json --refresh` returned only configured providers: Claude, Codex, Pi, and GLM-through-Pi. +- [x] Codex app-server returned a live authoritative 10,080-minute window and reset-credit count through `account/rateLimits/read`. +- [x] The request sequence contained no model turn and no reset-credit consume operation. +- [x] Claude logged-out state normalized to `authenticated: false`, `status: unauthenticated`, and `available: unknown`. +- [x] Pi and GLM normalized to authenticated but unsupported/unknown. +- [x] Output and test scans contained no tokens, account IDs, endpoint bodies, headers, or credential values. +- [x] `capacity --max-age=-1` exited 1 with a validation error. +- [x] Existing `agent list --json` exited 0, confirming the adjacent command remained functional. + +## 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..e2e6b87f --- /dev/null +++ b/packages/cli/src/__tests__/commands/capacity/codex.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mapCodexRateLimits, probeCodexCapacity } from '../../../commands/capacity/providers/codex.js'; + +describe('Codex capacity mapping', () => { + it('normalizes arbitrary windows, aliases, and unredeemed reset credits', () => { + const result = mapCodexRateLimits({ + rateLimits: { + limitId: 'codex', + limitName: 'Codex', + planType: 'pro', + rateLimitReachedType: null, + primary: { usedPercent: 20, windowDurationMins: 300, resetsAt: 1786273200 }, + secondary: { usedPercent: 61, windowDurationMins: 10080, resetsAt: 1786752000 } + }, + rateLimitsByLimitId: { + codex: { + limitId: 'codex', + limitName: 'Codex', + primary: { usedPercent: 20, windowDurationMins: 300, resetsAt: 1786273200 }, + secondary: { usedPercent: 61, windowDurationMins: 10080, resetsAt: 1786752000 } + }, + reviews: { + limitId: 'reviews', + limitName: 'Code reviews', + primary: { usedPercent: 10, windowDurationMins: 1440, resetsAt: 1786320000 }, + secondary: null + } + }, + rateLimitResetCredits: { availableCount: 2 } + }, { configured: true, installed: true, checkedAt: '2026-08-09T10:00:00.000Z' }); + + expect(result.available).toBe('yes'); + expect(result.plan).toBe('pro'); + expect(result.windows).toEqual(expect.arrayContaining([ + expect.objectContaining({ id: 'codex:primary', durationMinutes: 300, remainingPercent: 80 }), + expect.objectContaining({ id: 'codex:secondary', durationMinutes: 10080, remainingPercent: 39 }), + expect.objectContaining({ id: 'reviews:primary', durationMinutes: 1440, scope: 'reviews' }) + ])); + expect(result.aliases).toEqual({ dailyWindowId: 'reviews:primary', weeklyWindowId: 'codex:secondary' }); + expect(result.windows).toHaveLength(3); + expect(result.resetCredits).toEqual({ available: 2 }); + }); + + it('does not turn missing capacity into available yes', () => { + const result = mapCodexRateLimits({}, { + configured: true, + installed: true, + checkedAt: '2026-08-09T10:00:00.000Z' + }); + + expect(result.available).toBe('unknown'); + expect(result.status).toBe('unknown'); + expect(result.windows).toEqual([]); + }); + + it('reports explicit exhaustion as unavailable without exposing response details', () => { + const result = mapCodexRateLimits({ + rateLimits: { rateLimitReachedType: 'rate-limit-secret-detail', planType: 'team' } + }, { configured: true, installed: true, checkedAt: '2026-08-09T10:00:00.000Z' }); + + expect(result.available).toBe('no'); + expect(JSON.stringify(result)).not.toContain('rate-limit-secret-detail'); + }); + + it('reports an exhausted primary window as unavailable without a reached-type hint', () => { + const result = mapCodexRateLimits({ + rateLimits: { + primary: { usedPercent: 100, windowDurationMins: 300, resetsAt: null } + } + }, { configured: true, installed: true, checkedAt: '2026-08-09T10:00:00.000Z' }); + + expect(result.windows[0].remainingPercent).toBe(0); + expect(result.available).toBe('no'); + }); + + it('does not infer general availability from a scoped capacity bucket', () => { + const result = mapCodexRateLimits({ + rateLimitsByLimitId: { + reviews: { + limitId: 'reviews', + primary: { usedPercent: 10, windowDurationMins: 1440, resetsAt: null } + } + } + }, { configured: true, installed: true, checkedAt: '2026-08-09T10:00:00.000Z' }); + + expect(result.windows).toHaveLength(1); + expect(result.available).toBe('unknown'); + }); + + it('never exposes URL-like or account-like provider identifiers', () => { + const result = mapCodexRateLimits({ + rateLimits: { + limitId: 'https://private.example/account/123', + limitName: 'account_1234567890', + primary: { usedPercent: 10, windowDurationMins: 60, resetsAt: null } + } + }, { configured: true, installed: true, checkedAt: '2026-08-09T10:00:00.000Z' }); + + expect(JSON.stringify(result)).not.toMatch(/private\.example|account_1234567890|account\/123/); + expect(result.windows[0]).toMatchObject({ id: 'codex:primary', scope: 'codex' }); + }); + + it('rejects unexpected plan metadata', () => { + const result = mapCodexRateLimits({ + rateLimits: { planType: 'account_1234567890' } + }, { configured: true, installed: true, checkedAt: '2026-08-09T10:00:00.000Z' }); + + expect(result.plan).toBeNull(); + expect(JSON.stringify(result)).not.toContain('account_1234567890'); + }); + + it('uses only app-server account methods and never invokes a model turn', async () => { + const rpc = vi.fn(async () => ({ + rateLimits: { + primary: { usedPercent: 5, windowDurationMins: 300, resetsAt: null } + } + })); + + const result = await probeCodexCapacity({ + configured: true, + installed: true, + checkedAt: '2026-08-09T10:00:00.000Z', + rpc + }); + + expect(rpc).toHaveBeenCalledOnce(); + const messages = rpc.mock.calls[0][0]; + expect(messages.map(message => message.method)).toEqual([ + 'initialize', + 'initialized', + 'account/rateLimits/read' + ]); + expect(messages[0]).toEqual({ + id: 1, + method: 'initialize', + params: { clientInfo: { name: 'ai-devkit', title: null, version: '1' }, capabilities: null } + }); + expect(messages[1]).toEqual({ method: 'initialized' }); + expect(messages[2]).toEqual({ id: 2, method: 'account/rateLimits/read' }); + expect(JSON.stringify(messages)).not.toMatch(/model|prompt|turn/i); + expect(result.available).toBe('yes'); + }); + + it('redacts all transport failures', async () => { + const result = await probeCodexCapacity({ + configured: true, + installed: true, + checkedAt: '2026-08-09T10:00:00.000Z', + rpc: async () => { throw new Error('token=secret https://private.example/account/123'); } + }); + + expect(result.available).toBe('unknown'); + expect(result.error).toEqual({ code: 'codex-probe-failed', retryable: true }); + expect(JSON.stringify(result)).not.toMatch(/secret|private\.example|account\/123/); + }); +}); 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..e022555a --- /dev/null +++ b/packages/cli/src/__tests__/commands/capacity/command.test.ts @@ -0,0 +1,115 @@ +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(), + table: vi.fn(), + spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn(), fail: 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 human-readable capacity through the shared terminal table', () => { + renderCapacityReport(report); + + expect(ui.table).toHaveBeenCalledWith({ + headers: ['Provider', 'Auth', 'Available', 'Short window', 'Long window', 'Reset credits'], + rows: [[ + 'codex', + 'yes', + 'yes', + '80% left · resets 2026-08-09T12:00:00.000Z', + '40% left · resets 2026-08-16T10:00:00.000Z', + '1' + ]] + }); + }); + + it('renders normalized warnings below the terminal table', () => { + renderCapacityReport(report); + const output = vi.mocked(ui.text).mock.calls.map(call => call[0]).join('\n'); + + expect(output).toContain('Warnings:'); + expect(output).toContain('A safe normalized warning.'); + }); + + it('shows progress while reading a human-readable capacity report', async () => { + let resolveReport: (value: CapacityReport) => void = () => undefined; + const pendingReport = new Promise(resolve => { resolveReport = resolve; }); + + const action = capacityCommand(undefined, {}, async () => pendingReport); + expect(ui.spinner).toHaveBeenCalledWith('Checking provider capacity...'); + const spinner = vi.mocked(ui.spinner).mock.results[0].value; + expect(spinner.start).toHaveBeenCalledOnce(); + expect(spinner.stop).not.toHaveBeenCalled(); + + resolveReport(report); + await action; + + expect(spinner.stop).toHaveBeenCalledOnce(); + }); + + it('fails the progress indicator when a human-readable capacity check throws', async () => { + const action = capacityCommand(undefined, {}, async () => { + throw new Error('probe failed'); + }); + + await expect(action).rejects.toThrow('probe failed'); + expect(ui.spinner).toHaveBeenCalledWith('Checking provider capacity...'); + const spinner = vi.mocked(ui.spinner).mock.results[0].value; + expect(spinner.start).toHaveBeenCalledOnce(); + expect(spinner.fail).toHaveBeenCalledWith('Failed to check provider capacity'); + expect(spinner.stop).not.toHaveBeenCalled(); + }); + + 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)); + expect(ui.spinner).not.toHaveBeenCalled(); + }); + + 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..9265ad75 --- /dev/null +++ b/packages/cli/src/__tests__/commands/capacity/detection.test.ts @@ -0,0 +1,36 @@ +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('does not confuse environments that share a parent config directory', async () => { + const exists = vi.fn(async (target: string) => target === '/users/test/.gemini'); + + await expect(detectConfiguredProviders({ homeDir: '/users/test', exists })).resolves.toEqual([ + 'gemini' + ]); + expect(exists).toHaveBeenCalledWith('/users/test/.gemini/antigravity'); + expect(exists).toHaveBeenCalledWith('/users/test/.gemini/config'); + }); + + 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..db0e5219 --- /dev/null +++ b/packages/cli/src/__tests__/commands/capacity/providers.test.ts @@ -0,0 +1,104 @@ +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('keeps authentication unknown when Claude returns an unrecognized payload', async () => { + const result = await probeClaudeCapacity({ + configured: true, + installed: true, + checkedAt, + authStatus: async () => ({}) + }); + + expect(result.authenticated).toBeNull(); + expect(result.status).toBe('unknown'); + }); + + 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..174aa440 --- /dev/null +++ b/packages/cli/src/commands/capacity.ts @@ -0,0 +1,43 @@ +import type { Command } from 'commander'; +import { getCapacityReport } from './capacity/orchestrate.js'; +import { renderCapacityReport } from './capacity/render.js'; +import type { CapacityReport } from './capacity/types.js'; +import { ui } from '../util/terminal-ui.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 spinner = options.json ? null : ui.spinner('Checking provider capacity...'); + spinner?.start(); + let report: CapacityReport; + try { + report = await readReport({ provider, maxAge, refresh: options.refresh === true }); + } catch (error) { + spinner?.fail('Failed to check provider capacity'); + throw error; + } + spinner?.stop(); + 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..d6f16ed0 --- /dev/null +++ b/packages/cli/src/commands/capacity/detection.ts @@ -0,0 +1,58 @@ +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 { + return path.dirname(globalSkillPath); +} + +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..97e4b1c1 --- /dev/null +++ b/packages/cli/src/commands/capacity/providers/claude.ts @@ -0,0 +1,80 @@ +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 + ? true + : auth?.loggedIn === false || auth?.authenticated === false ? false : null; + result.authenticated = authenticated; + result.status = authenticated === true + ? 'supported' + : authenticated === false ? 'unauthenticated' : 'unknown'; + 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..69a912f6 --- /dev/null +++ b/packages/cli/src/commands/capacity/providers/codex.ts @@ -0,0 +1,214 @@ +import { spawn } from 'node:child_process'; +import type { CapacityWindow, ProviderCapacity } from '../types.js'; + +type UnknownRecord = Record; + +type CodexMappingContext = { + configured: boolean; + installed: boolean; + checkedAt: string; +}; + +type RpcMessage = { id?: number; method: string; params?: UnknownRecord }; +type CodexRpc = (messages: RpcMessage[]) => Promise; + +type CodexProbeOptions = CodexMappingContext & { + rpc?: CodexRpc; + timeoutMs?: number; +}; + +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 text(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function safeIdentifier(value: unknown): string | null { + const candidate = text(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 = text(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; +} + +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 windowFrom(value: unknown, id: string, label: string, scope: string | null): CapacityWindow | null { + const input = record(value); + if (!input) return null; + const used = finiteNumber(input.usedPercent); + const duration = finiteNumber(input.windowDurationMins); + return { + id, + label, + durationMinutes: duration, + usedPercent: used, + remainingPercent: used === null ? null : Math.max(0, Math.min(100, 100 - used)), + resetsAt: resetTime(input.resetsAt), + scope + }; +} + +function snapshotWindows(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 [ + windowFrom(snapshot.primary, `${scope}:primary`, `${name} primary`, scope), + windowFrom(snapshot.secondary, `${scope}:secondary`, `${name} secondary`, scope) + ].filter((item): item is CapacityWindow => item !== null); +} + +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; +} + +export function mapCodexRateLimits(raw: unknown, context: CodexMappingContext): ProviderCapacity { + const response = record(raw) ?? {}; + const primarySnapshot = record(response.rateLimits); + const primaryWindows = snapshotWindows(primarySnapshot, 'codex'); + const windows = [...primaryWindows]; + const buckets = record(response.rateLimitsByLimitId); + if (buckets) { + for (const [id, snapshot] of Object.entries(buckets)) { + windows.push(...snapshotWindows(snapshot, id)); + } + } + const normalizedWindows = [...new Map(windows.map(window => [window.id, window])).values()]; + const reached = text(primarySnapshot?.rateLimitReachedType); + const resetCredits = record(response.rateLimitResetCredits) ?? record(response.usageLimitResetCredits); + const availableCount = finiteNumber(resetCredits?.availableCount); + const hasCapacity = normalizedWindows.some(window => window.remainingPercent !== null); + const hasPrimaryCapacity = primaryWindows.some(window => window.remainingPercent !== null); + const primaryExhausted = primaryWindows.some( + window => window.remainingPercent !== null && window.remainingPercent <= 0 + ); + + return { + provider: 'codex', + agentType: 'codex', + configured: context.configured, + installed: context.installed, + authenticated: true, + status: reached || hasCapacity ? 'supported' : 'unknown', + available: reached || primaryExhausted ? 'no' : hasPrimaryCapacity ? 'yes' : 'unknown', + plan: safePlan(primarySnapshot?.planType), + checkedAt: context.checkedAt, + source: 'provider-cli', + windows: normalizedWindows, + aliases: { + dailyWindowId: aliasFor(normalizedWindows, 1440, 120), + weeklyWindowId: aliasFor(normalizedWindows, 10080, 720) + }, + resetCredits: { available: availableCount }, + warnings: hasCapacity || reached ? [] : [{ + code: 'capacity-unavailable', + message: 'Codex did not return authoritative capacity windows.' + }] + }; +} + +function appServerRpc(messages: RpcMessage[], timeoutMs = 5000): Promise { + return new Promise((resolve, reject) => { + const child = spawn('codex', ['app-server', '--stdio'], { stdio: ['pipe', 'pipe', 'ignore'] }); + let buffer = ''; + let settled = false; + const finish = (error?: Error, result?: unknown) => { + if (settled) return; + settled = true; + clearTimeout(timer); + child.kill(); + if (error) reject(error); + else resolve(result); + }; + const timer = setTimeout(() => finish(new Error('codex probe timed out')), timeoutMs); + child.once('error', () => finish(new Error('codex app-server unavailable'))); + child.once('exit', code => { + if (!settled) finish(new Error(`codex app-server exited (${code ?? 'unknown'})`)); + }); + 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`); + } + if (message.id === 2) { + if (message.error) finish(new Error('codex rate-limit method failed')); + else finish(undefined, message.result); + } + } + }); + child.stdin.write(`${JSON.stringify(messages[0])}\n`); + }); +} + +export async function probeCodexCapacity(options: CodexProbeOptions): Promise { + if (!options.installed) { + return { + provider: 'codex', agentType: 'codex', configured: options.configured, installed: false, + authenticated: null, status: 'unavailable', available: 'unknown', plan: null, + checkedAt: options.checkedAt, source: 'none', windows: [], + aliases: { dailyWindowId: null, weeklyWindowId: null }, resetCredits: { available: null }, + warnings: [{ code: 'cli-not-installed', message: 'Codex CLI is not installed.' }] + }; + } + 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' } + ]; + try { + const rpc = options.rpc ?? (requests => appServerRpc(requests, options.timeoutMs)); + return mapCodexRateLimits(await rpc(messages), options); + } catch { + return { + provider: 'codex', agentType: 'codex', configured: options.configured, installed: true, + authenticated: null, status: 'unknown', available: 'unknown', plan: null, + checkedAt: options.checkedAt, source: 'none', windows: [], + aliases: { dailyWindowId: null, weeklyWindowId: null }, resetCredits: { available: null }, + warnings: [{ code: 'probe-failed', message: 'Codex capacity could not be read safely.' }], + error: { code: 'codex-probe-failed', retryable: true } + }; + } +} 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..cc191486 --- /dev/null +++ b/packages/cli/src/commands/capacity/render.ts @@ -0,0 +1,50 @@ +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) + ]; + }); + ui.table({ + headers: ['Provider', 'Auth', 'Available', 'Short window', 'Long window', 'Reset credits'], + rows + }); + 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..fbc0915f --- /dev/null +++ b/packages/cli/src/commands/capacity/types.ts @@ -0,0 +1,37 @@ +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 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 }; + warnings: Array<{ code: string; message: string }>; + error?: { code: string; retryable: boolean }; +} + +export interface CapacityReport { + schemaVersion: 1; + generatedAt: string; + providers: ProviderCapacity[]; +}