From 6ead0d304509f6e9310e6fabe0c138fe9fa754c0 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sun, 9 Aug 2026 09:09:42 +0000 Subject: [PATCH 01/18] feat(capacity): add provider detection foundation --- .../commands/capacity/detection.test.ts | 26 +++++++++ .../cli/src/commands/capacity/detection.ts | 55 +++++++++++++++++++ packages/cli/src/commands/capacity/types.ts | 37 +++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 packages/cli/src/__tests__/commands/capacity/detection.test.ts create mode 100644 packages/cli/src/commands/capacity/detection.ts create mode 100644 packages/cli/src/commands/capacity/types.ts 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/commands/capacity/detection.ts b/packages/cli/src/commands/capacity/detection.ts new file mode 100644 index 00000000..e85a5091 --- /dev/null +++ b/packages/cli/src/commands/capacity/detection.ts @@ -0,0 +1,55 @@ +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 providers = await Promise.all(Object.values(ENVIRONMENT_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/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[]; +} From b994440fe9c675ff2a67c2539c95311d998f4401 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sun, 9 Aug 2026 09:12:25 +0000 Subject: [PATCH 02/18] feat(capacity): probe Codex rate limits safely --- .../__tests__/commands/capacity/codex.test.ts | 95 +++++++++ .../cli/src/commands/capacity/detection.ts | 6 +- .../src/commands/capacity/providers/codex.ts | 187 ++++++++++++++++++ 3 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/__tests__/commands/capacity/codex.test.ts create mode 100644 packages/cli/src/commands/capacity/providers/codex.ts 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..d2004ba7 --- /dev/null +++ b/packages/cli/src/__tests__/commands/capacity/codex.test.ts @@ -0,0 +1,95 @@ +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: { + reviews: { + limitId: 'reviews', + limitName: 'Code reviews', + primary: { usedPercent: 10, windowDurationMins: 1440, resetsAt: 1786320000 }, + secondary: null + } + }, + usageLimitResetCredits: { 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.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('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(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/commands/capacity/detection.ts b/packages/cli/src/commands/capacity/detection.ts index e85a5091..2c927060 100644 --- a/packages/cli/src/commands/capacity/detection.ts +++ b/packages/cli/src/commands/capacity/detection.ts @@ -33,7 +33,11 @@ async function defaultExists(target: string): Promise { export async function detectConfiguredProviders(options: DetectionOptions = {}): Promise { const home = options.homeDir ?? homedir(); const exists = options.exists ?? defaultExists; - const providers = await Promise.all(Object.values(ENVIRONMENT_DEFINITIONS).map(async definition => ({ + 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))) }))); 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..4fab91e0 --- /dev/null +++ b/packages/cli/src/commands/capacity/providers/codex.ts @@ -0,0 +1,187 @@ +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 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 = text(snapshot.limitId) ?? fallbackId; + const name = text(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 windows = snapshotWindows(primarySnapshot, 'codex'); + const buckets = record(response.rateLimitsByLimitId); + if (buckets) { + for (const [id, snapshot] of Object.entries(buckets)) { + windows.push(...snapshotWindows(snapshot, id)); + } + } + const reached = text(primarySnapshot?.rateLimitReachedType); + const resetCredits = record(response.usageLimitResetCredits); + const availableCount = finiteNumber(resetCredits?.availableCount); + const hasCapacity = windows.some(window => window.remainingPercent !== null); + + return { + provider: 'codex', + agentType: 'codex', + configured: context.configured, + installed: context.installed, + authenticated: true, + status: reached || hasCapacity ? 'supported' : 'unknown', + available: reached ? 'no' : hasCapacity ? 'yes' : 'unknown', + plan: text(primarySnapshot?.planType), + checkedAt: context.checkedAt, + source: 'provider-cli', + windows, + aliases: { + dailyWindowId: aliasFor(windows, 1440, 120), + weeklyWindowId: aliasFor(windows, 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', version: '1' } } }, + { method: 'initialized' }, + { id: 2, method: 'account/rateLimits/read', params: {} } + ]; + 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 } + }; + } +} From 4893a27eeaa3501a49f8cc9e173ff3c82f71406f Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sun, 9 Aug 2026 09:13:48 +0000 Subject: [PATCH 03/18] feat(capacity): add truthful provider adapters --- .../commands/capacity/providers.test.ts | 58 +++++++++++++++++++ .../src/commands/capacity/providers/claude.ts | 55 ++++++++++++++++++ .../cli/src/commands/capacity/providers/pi.ts | 35 +++++++++++ .../src/commands/capacity/providers/stub.ts | 31 ++++++++++ 4 files changed, 179 insertions(+) create mode 100644 packages/cli/src/__tests__/commands/capacity/providers.test.ts create mode 100644 packages/cli/src/commands/capacity/providers/claude.ts create mode 100644 packages/cli/src/commands/capacity/providers/pi.ts create mode 100644 packages/cli/src/commands/capacity/providers/stub.ts 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..d2d31ce8 --- /dev/null +++ b/packages/cli/src/__tests__/commands/capacity/providers.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { probeClaudeCapacity } 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('detects Claude authentication but keeps undocumented live usage guarded off', async () => { + const result = await probeClaudeCapacity({ + configured: true, + installed: true, + checkedAt, + authStatus: async () => ({ loggedIn: true, subscriptionType: 'max' }) + }); + + 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('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, + authenticated: null, status: 'unsupported', available: 'unknown', source: 'none' + }); + }); +}); 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..f8c0d0cc --- /dev/null +++ b/packages/cli/src/commands/capacity/providers/claude.ts @@ -0,0 +1,55 @@ +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?: () => Promise; timeoutMs?: number }; + +function record(value: unknown): UnknownRecord | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as UnknownRecord : null; +} + +async function defaultAuthStatus(timeoutMs: number): Promise { + const { stdout } = await execFileAsync('claude', ['auth', 'status', '--json'], { + timeout: timeoutMs, maxBuffer: 64 * 1024, encoding: 'utf8' + }); + return JSON.parse(stdout); +} + +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 raw = await (options.authStatus ?? (() => defaultAuthStatus(options.timeoutMs ?? 3000)))(); + 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 = typeof auth?.subscriptionType === 'string' ? auth.subscriptionType : null; + 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/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..a237852f --- /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: 'github', gemini: 'gemini', + glm: 'pi', grok: 'grok', 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 }] + }; +} From 8a60f5b89bedcb7455ebc04849c964d8743a51cd Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sun, 9 Aug 2026 09:15:58 +0000 Subject: [PATCH 04/18] feat(capacity): orchestrate probes with secure cache --- .../__tests__/commands/capacity/cache.test.ts | 24 ++++ .../commands/capacity/orchestrate.test.ts | 66 +++++++++++ packages/cli/src/commands/capacity/cache.ts | 46 ++++++++ .../cli/src/commands/capacity/orchestrate.ts | 109 ++++++++++++++++++ 4 files changed, 245 insertions(+) create mode 100644 packages/cli/src/__tests__/commands/capacity/cache.test.ts create mode 100644 packages/cli/src/__tests__/commands/capacity/orchestrate.test.ts create mode 100644 packages/cli/src/commands/capacity/cache.ts create mode 100644 packages/cli/src/commands/capacity/orchestrate.ts 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/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/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/orchestrate.ts b/packages/cli/src/commands/capacity/orchestrate.ts new file mode 100644 index 00000000..6a3bc8b2 --- /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), 6000); + 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; +} From b34ac9b740b25ba58ee298c39a71ac712fd86a43 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sun, 9 Aug 2026 09:17:34 +0000 Subject: [PATCH 05/18] feat(cli): expose capacity command --- packages/cli/README.md | 6 ++ .../commands/capacity/command.test.ts | 68 +++++++++++++++++++ packages/cli/src/cli.ts | 2 + packages/cli/src/commands/capacity.ts | 33 +++++++++ packages/cli/src/commands/capacity/render.ts | 52 ++++++++++++++ 5 files changed, 161 insertions(+) create mode 100644 packages/cli/src/__tests__/commands/capacity/command.test.ts create mode 100644 packages/cli/src/commands/capacity.ts create mode 100644 packages/cli/src/commands/capacity/render.ts 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/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/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/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}`); + } +} From fa661e10ff0ac7108ab27912bf9c8496059b0c61 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sun, 9 Aug 2026 09:23:28 +0000 Subject: [PATCH 06/18] fix(capacity): align Codex app-server protocol --- .../__tests__/commands/capacity/codex.test.ts | 29 ++++++++++++++- .../src/commands/capacity/providers/codex.ts | 35 ++++++++++++++----- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/__tests__/commands/capacity/codex.test.ts b/packages/cli/src/__tests__/commands/capacity/codex.test.ts index d2004ba7..bd5044fd 100644 --- a/packages/cli/src/__tests__/commands/capacity/codex.test.ts +++ b/packages/cli/src/__tests__/commands/capacity/codex.test.ts @@ -13,6 +13,12 @@ describe('Codex capacity mapping', () => { 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', @@ -20,7 +26,7 @@ describe('Codex capacity mapping', () => { secondary: null } }, - usageLimitResetCredits: { availableCount: 2 } + rateLimitResetCredits: { availableCount: 2 } }, { configured: true, installed: true, checkedAt: '2026-08-09T10:00:00.000Z' }); expect(result.available).toBe('yes'); @@ -31,6 +37,7 @@ describe('Codex capacity mapping', () => { 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 }); }); @@ -55,6 +62,19 @@ describe('Codex capacity mapping', () => { expect(JSON.stringify(result)).not.toContain('rate-limit-secret-detail'); }); + 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('uses only app-server account methods and never invokes a model turn', async () => { const rpc = vi.fn(async () => ({ rateLimits: { @@ -76,6 +96,13 @@ describe('Codex capacity mapping', () => { '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'); }); diff --git a/packages/cli/src/commands/capacity/providers/codex.ts b/packages/cli/src/commands/capacity/providers/codex.ts index 4fab91e0..09e92d1d 100644 --- a/packages/cli/src/commands/capacity/providers/codex.ts +++ b/packages/cli/src/commands/capacity/providers/codex.ts @@ -31,6 +31,20 @@ 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 resetTime(value: unknown): string | null { const seconds = finiteNumber(value); if (seconds !== null) return new Date(seconds * 1000).toISOString(); @@ -57,8 +71,8 @@ function windowFrom(value: unknown, id: string, label: string, scope: string | n function snapshotWindows(value: unknown, fallbackId: string): CapacityWindow[] { const snapshot = record(value); if (!snapshot) return []; - const scope = text(snapshot.limitId) ?? fallbackId; - const name = text(snapshot.limitName) ?? scope; + 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) @@ -81,10 +95,11 @@ export function mapCodexRateLimits(raw: unknown, context: CodexMappingContext): 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.usageLimitResetCredits); + const resetCredits = record(response.rateLimitResetCredits) ?? record(response.usageLimitResetCredits); const availableCount = finiteNumber(resetCredits?.availableCount); - const hasCapacity = windows.some(window => window.remainingPercent !== null); + const hasCapacity = normalizedWindows.some(window => window.remainingPercent !== null); return { provider: 'codex', @@ -97,10 +112,10 @@ export function mapCodexRateLimits(raw: unknown, context: CodexMappingContext): plan: text(primarySnapshot?.planType), checkedAt: context.checkedAt, source: 'provider-cli', - windows, + windows: normalizedWindows, aliases: { - dailyWindowId: aliasFor(windows, 1440, 120), - weeklyWindowId: aliasFor(windows, 10080, 720) + dailyWindowId: aliasFor(normalizedWindows, 1440, 120), + weeklyWindowId: aliasFor(normalizedWindows, 10080, 720) }, resetCredits: { available: availableCount }, warnings: hasCapacity || reached ? [] : [{ @@ -167,9 +182,11 @@ export async function probeCodexCapacity(options: CodexProbeOptions): Promise appServerRpc(requests, options.timeoutMs)); From 4bc3a6d43257e3e1a7c905523d0d481519e7d6bd Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sun, 9 Aug 2026 09:25:34 +0000 Subject: [PATCH 07/18] fix(capacity): harden normalized provider metadata --- .../__tests__/commands/capacity/codex.test.ts | 9 +++++++++ .../commands/capacity/providers.test.ts | 18 +++++++++++++++++- .../src/commands/capacity/providers/claude.ts | 7 ++++++- .../src/commands/capacity/providers/codex.ts | 7 ++++++- .../src/commands/capacity/providers/stub.ts | 4 ++-- 5 files changed, 40 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/__tests__/commands/capacity/codex.test.ts b/packages/cli/src/__tests__/commands/capacity/codex.test.ts index bd5044fd..15c10947 100644 --- a/packages/cli/src/__tests__/commands/capacity/codex.test.ts +++ b/packages/cli/src/__tests__/commands/capacity/codex.test.ts @@ -75,6 +75,15 @@ describe('Codex capacity mapping', () => { 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: { diff --git a/packages/cli/src/__tests__/commands/capacity/providers.test.ts b/packages/cli/src/__tests__/commands/capacity/providers.test.ts index d2d31ce8..dbe3eff7 100644 --- a/packages/cli/src/__tests__/commands/capacity/providers.test.ts +++ b/packages/cli/src/__tests__/commands/capacity/providers.test.ts @@ -33,6 +33,18 @@ describe('non-Codex capacity adapters', () => { 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, @@ -52,7 +64,11 @@ describe('non-Codex capacity adapters', () => { configured: true, installed: false, checkedAt })).toMatchObject({ provider: 'gemini', configured: true, installed: false, - authenticated: null, status: 'unsupported', available: 'unknown', source: 'none' + 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/commands/capacity/providers/claude.ts b/packages/cli/src/commands/capacity/providers/claude.ts index f8c0d0cc..626cbc73 100644 --- a/packages/cli/src/commands/capacity/providers/claude.ts +++ b/packages/cli/src/commands/capacity/providers/claude.ts @@ -11,6 +11,11 @@ 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; +} + async function defaultAuthStatus(timeoutMs: number): Promise { const { stdout } = await execFileAsync('claude', ['auth', 'status', '--json'], { timeout: timeoutMs, maxBuffer: 64 * 1024, encoding: 'utf8' @@ -41,7 +46,7 @@ export async function probeClaudeCapacity(options: ClaudeOptions): Promise = { - claude: 'claude', codex: 'codex', copilot: 'github', gemini: 'gemini', - glm: 'pi', grok: 'grok', opencode: 'opencode', pi: 'pi' + claude: 'claude', codex: 'codex', copilot: 'copilot', gemini: 'gemini_cli', + glm: 'pi', grok: 'grok_cli', opencode: 'opencode', pi: 'pi' }; export function buildUnsupportedCapacity( From a0da54b3f626260d428f9baa8ff67b452ce0a919 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sun, 9 Aug 2026 09:29:50 +0000 Subject: [PATCH 08/18] fix(capacity): classify logged-out Claude safely --- .../commands/capacity/providers.test.ts | 22 ++++++++++++++-- .../cli/src/commands/capacity/orchestrate.ts | 2 +- .../src/commands/capacity/providers/claude.ts | 26 +++++++++++++++---- 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/__tests__/commands/capacity/providers.test.ts b/packages/cli/src/__tests__/commands/capacity/providers.test.ts index dbe3eff7..4802e50e 100644 --- a/packages/cli/src/__tests__/commands/capacity/providers.test.ts +++ b/packages/cli/src/__tests__/commands/capacity/providers.test.ts @@ -1,18 +1,36 @@ import { describe, expect, it } from 'vitest'; -import { probeClaudeCapacity } from '../../../commands/capacity/providers/claude.js'; +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 () => ({ loggedIn: true, subscriptionType: 'max' }) + authStatus: async timeoutMs => { + receivedTimeout = timeoutMs; + return { loggedIn: true, subscriptionType: 'max' }; + } }); + expect(receivedTimeout).toBe(6000); expect(result).toMatchObject({ provider: 'claude', authenticated: true, status: 'supported', diff --git a/packages/cli/src/commands/capacity/orchestrate.ts b/packages/cli/src/commands/capacity/orchestrate.ts index 6a3bc8b2..b0577f9a 100644 --- a/packages/cli/src/commands/capacity/orchestrate.ts +++ b/packages/cli/src/commands/capacity/orchestrate.ts @@ -92,7 +92,7 @@ export async function getCapacityReport( checkedAt: now.toISOString() }; try { - const results = await withTimeout(dependencies.probe(provider, context), 6000); + const results = await withTimeout(dependencies.probe(provider, context), 7000); return requested === 'pi' ? results.filter(result => result.provider === 'pi') : results; } catch { return [failure(provider, context)]; diff --git a/packages/cli/src/commands/capacity/providers/claude.ts b/packages/cli/src/commands/capacity/providers/claude.ts index 626cbc73..50cbf9c9 100644 --- a/packages/cli/src/commands/capacity/providers/claude.ts +++ b/packages/cli/src/commands/capacity/providers/claude.ts @@ -5,7 +5,7 @@ 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?: () => Promise; timeoutMs?: number }; +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; @@ -16,11 +16,26 @@ function safePlan(value: unknown): string | null { return /(?:account|token|secret|key|oauth)/i.test(value) ? null : value; } -async function defaultAuthStatus(timeoutMs: number): Promise { - const { stdout } = await execFileAsync('claude', ['auth', 'status', '--json'], { +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 JSON.parse(stdout); + 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 { @@ -40,7 +55,8 @@ export async function probeClaudeCapacity(options: ClaudeOptions): Promise defaultAuthStatus(options.timeoutMs ?? 3000)))(); + 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; From 1439166840d7925fae75b69bfc5bc66494a95187 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sun, 9 Aug 2026 12:01:03 +0000 Subject: [PATCH 09/18] docs(capacity): add dev-lifecycle feature docs --- .../2026-08-09-feature-capacity-command.md | 156 ++++++++++++++++++ .../2026-08-09-feature-capacity-command.md | 100 +++++++++++ .../2026-08-09-feature-capacity-command.md | 69 ++++++++ .../2026-08-09-feature-capacity-command.md | 85 ++++++++++ .../2026-08-09-feature-capacity-command.md | 98 +++++++++++ 5 files changed, 508 insertions(+) create mode 100644 docs/ai/design/2026-08-09-feature-capacity-command.md create mode 100644 docs/ai/implementation/2026-08-09-feature-capacity-command.md create mode 100644 docs/ai/planning/2026-08-09-feature-capacity-command.md create mode 100644 docs/ai/requirements/2026-08-09-feature-capacity-command.md create mode 100644 docs/ai/testing/2026-08-09-feature-capacity-command.md 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. From d2bcdd8aced144b4ad4b5fea9bf4fef2613b28c7 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Thu, 20 Aug 2026 06:02:04 +0000 Subject: [PATCH 10/18] feat(capacity): add tiered Codex usage provider --- .../__tests__/commands/capacity/codex.test.ts | 267 ++++++++------ .../src/commands/capacity/providers/codex.ts | 339 +++++++++++++----- packages/cli/src/commands/capacity/types.ts | 13 + 3 files changed, 437 insertions(+), 182 deletions(-) diff --git a/packages/cli/src/__tests__/commands/capacity/codex.test.ts b/packages/cli/src/__tests__/commands/capacity/codex.test.ts index 15c10947..8ed6a479 100644 --- a/packages/cli/src/__tests__/commands/capacity/codex.test.ts +++ b/packages/cli/src/__tests__/commands/capacity/codex.test.ts @@ -1,131 +1,198 @@ 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 }); - }); +import { + CODEX_APP_SERVER_ARGS, + parseUsage, + probeCodexCapacity, + resolveCodexAuthPath, + toRateWindow +} from '../../../commands/capacity/providers/codex.js'; - it('does not turn missing capacity into available yes', () => { - const result = mapCodexRateLimits({}, { - configured: true, - installed: true, - checkedAt: '2026-08-09T10:00:00.000Z' - }); +const checkedAt = '2026-08-20T10:00:00.000Z'; +const context = { configured: true, installed: true, checkedAt }; - expect(result.available).toBe('unknown'); - expect(result.status).toBe('unknown'); - expect(result.windows).toEqual([]); +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('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' }); + it('falls back to ~/.codex/auth.json', () => { + expect(resolveCodexAuthPath({ HOME: '/users/test' })).toBe('/users/test/.codex/auth.json'); + }); +}); - expect(result.available).toBe('no'); - expect(JSON.stringify(result)).not.toContain('rate-limit-secret-detail'); +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('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' }); + 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 }) + ]); + }); - expect(JSON.stringify(result)).not.toMatch(/private\.example|account_1234567890|account\/123/); - expect(result.windows[0]).toMatchObject({ id: 'codex:primary', scope: 'codex' }); + 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('rejects unexpected plan metadata', () => { - const result = mapCodexRateLimits({ - rateLimits: { planType: 'account_1234567890' } - }, { configured: true, installed: true, checkedAt: '2026-08-09T10:00:00.000Z' }); + 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(); + }); +}); - expect(result.plan).toBeNull(); - expect(JSON.stringify(result)).not.toContain('account_1234567890'); +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('uses only app-server account methods and never invokes a model turn', async () => { + 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: { - primary: { usedPercent: 5, windowDurationMins: 300, resetsAt: null } - } + 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({ - configured: true, - installed: true, - checkedAt: '2026-08-09T10:00:00.000Z', + ...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' + 'initialize', 'initialized', 'account/rateLimits/read', 'account/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'); + expect(JSON.stringify(messages)).not.toMatch(/prompt|turn\/start/); + expect(CODEX_APP_SERVER_ARGS).toEqual(['-s', 'read-only', '-a', 'untrusted', 'app-server']); }); - it('redacts all transport failures', async () => { + it('uses account/read to distinguish logged-out CLI state', 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'); } + ...context, + readFile: async () => '{}', + rpc: async () => ({ rateLimits: {}, account: { account: null } }) }); + expect(result).toMatchObject({ authenticated: false, status: 'unauthenticated', available: 'unknown' }); + }); - 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/); + 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/commands/capacity/providers/codex.ts b/packages/cli/src/commands/capacity/providers/codex.ts index 11b7ffae..62077022 100644 --- a/packages/cli/src/commands/capacity/providers/codex.ts +++ b/packages/cli/src/commands/capacity/providers/codex.ts @@ -1,20 +1,30 @@ import { spawn } from 'node:child_process'; -import type { CapacityWindow, ProviderCapacity } from '../types.js'; +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 CodexMappingContext = { +type CodexProbeOptions = { configured: boolean; installed: boolean; checkedAt: string; -}; - -type RpcMessage = { id?: number; method: string; params?: UnknownRecord }; -type CodexRpc = (messages: RpcMessage[]) => Promise; - -type CodexProbeOptions = CodexMappingContext & { + 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 { @@ -27,19 +37,26 @@ function finiteNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } -function text(value: unknown): string | 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 = text(value); + 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 = text(value); + 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; @@ -50,22 +67,72 @@ function safePlan(value: unknown): string | null { 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; +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 windowFrom(value: unknown, id: string, label: string, scope: string | null): CapacityWindow | null { +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); - const duration = finiteNumber(input.windowDurationMins); return { id, label, - durationMinutes: duration, + durationMinutes: finiteNumber(input.windowDurationMins), usedPercent: used, remainingPercent: used === null ? null : Math.max(0, Math.min(100, 100 - used)), resetsAt: resetTime(input.resetsAt), @@ -73,81 +140,144 @@ function windowFrom(value: unknown, id: string, label: string, scope: string | n }; } -function snapshotWindows(value: unknown, fallbackId: string): CapacityWindow[] { +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 [ - windowFrom(snapshot.primary, `${scope}:primary`, `${name} primary`, scope), - windowFrom(snapshot.secondary, `${scope}:secondary`, `${name} secondary`, scope) + 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; } -export function mapCodexRateLimits(raw: unknown, context: CodexMappingContext): ProviderCapacity { - const response = record(raw) ?? {}; - const primarySnapshot = record(response.rateLimits); - const windows = snapshotWindows(primarySnapshot, 'codex'); - 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); - +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 || hasCapacity ? 'supported' : 'unknown', - available: reached ? 'no' : hasCapacity ? 'yes' : 'unknown', - plan: safePlan(primarySnapshot?.planType), + status: reached || hasUsage ? 'supported' : 'unknown', + available: reached ? 'no' : hasUsage ? 'yes' : 'unknown', + plan: safePlan(rateLimits?.planType), checkedAt: context.checkedAt, - source: 'provider-cli', - windows: normalizedWindows, + source: snapshot.source === 'cli' ? 'provider-cli' : 'provider-api', + windows, aliases: { - dailyWindowId: aliasFor(normalizedWindows, 1440, 120), - weeklyWindowId: aliasFor(normalizedWindows, 10080, 720) + dailyWindowId: aliasFor(windows, 1440, 120), + weeklyWindowId: aliasFor(windows, 10080, 720) }, - resetCredits: { available: availableCount }, - warnings: hasCapacity || reached ? [] : [{ + resetCredits: { available: finiteNumber(resetCredits?.availableCount) }, + usage: snapshot, + warnings: hasUsage || reached ? [] : [{ code: 'capacity-unavailable', message: 'Codex did not return authoritative capacity windows.' }] }; } -function appServerRpc(messages: RpcMessage[], timeoutMs = 5000): Promise { +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', ['app-server', '--stdio'], { stdio: ['pipe', 'pipe', 'ignore'] }); + const child = spawn('codex', CODEX_APP_SERVER_ARGS, { + stdio: ['pipe', 'pipe', 'ignore'] + }); + const results: Partial = {}; let buffer = ''; let settled = false; - const finish = (error?: Error, result?: unknown) => { + const finish = (error?: Error) => { if (settled) return; settled = true; clearTimeout(timer); child.kill(); if (error) reject(error); - else resolve(result); + 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', code => { - if (!settled) finish(new Error(`codex app-server exited (${code ?? 'unknown'})`)); - }); + child.once('exit', () => { if (!settled) finish(new Error('codex app-server exited')); }); child.stdout.setEncoding('utf8'); child.stdout.on('data', (chunk: string) => { buffer += chunk; @@ -158,52 +288,97 @@ function appServerRpc(messages: RpcMessage[], timeoutMs = 5000): 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.' }] - }; - } +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: 2, method: 'account/rateLimits/read' }, + { id: 3, method: 'account/read' } ]; try { const rpc = options.rpc ?? (requests => appServerRpc(requests, options.timeoutMs)); - return mapCodexRateLimits(await rpc(messages), options); + 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 { - 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 } - }; + 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/types.ts b/packages/cli/src/commands/capacity/types.ts index fbc0915f..42b621fc 100644 --- a/packages/cli/src/commands/capacity/types.ts +++ b/packages/cli/src/commands/capacity/types.ts @@ -12,6 +12,18 @@ export interface CapacityWindow { 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; @@ -26,6 +38,7 @@ export interface ProviderCapacity { 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 }; } From 620ba588723785a9923fc32bdf3003140e8122eb Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Thu, 20 Aug 2026 06:02:46 +0000 Subject: [PATCH 11/18] docs(capacity): record tiered Codex rework --- .../2026-08-09-feature-capacity-command.md | 33 ++++++++----- .../2026-08-09-feature-capacity-command.md | 20 ++++++-- .../2026-08-09-feature-capacity-command.md | 12 ++++- .../2026-08-09-feature-capacity-command.md | 7 ++- .../2026-08-09-feature-capacity-command.md | 47 +++++++------------ 5 files changed, 70 insertions(+), 49 deletions(-) diff --git a/docs/ai/design/2026-08-09-feature-capacity-command.md b/docs/ai/design/2026-08-09-feature-capacity-command.md index 994dd567..197fbc9f 100644 --- a/docs/ai/design/2026-08-09-feature-capacity-command.md +++ b/docs/ai/design/2026-08-09-feature-capacity-command.md @@ -17,7 +17,9 @@ flowchart LR Orchestrator --> Claude[Claude adapter] Orchestrator --> Pi[Pi / GLM adapter] Orchestrator --> Stub[Unsupported-provider stub] - Codex --> AppServer[codex app-server] + 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] @@ -104,16 +106,24 @@ type CapacityReport = { ```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 + 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 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. +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 @@ -138,7 +148,8 @@ Configured providers without an authoritative adapter use the common stub. The s ## Security and Reliability Decisions -- Provider CLIs own OAuth/session authentication; secrets are not passed on command lines. +- 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. @@ -148,7 +159,7 @@ Configured providers without an authoritative adapter use the common stub. The s ## Alternatives Rejected -- Direct private HTTP calls: excessive credential exposure and undocumented coupling. +- 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. diff --git a/docs/ai/implementation/2026-08-09-feature-capacity-command.md b/docs/ai/implementation/2026-08-09-feature-capacity-command.md index 41cb235a..8ba623b5 100644 --- a/docs/ai/implementation/2026-08-09-feature-capacity-command.md +++ b/docs/ai/implementation/2026-08-09-feature-capacity-command.md @@ -43,20 +43,29 @@ capacity [provider] [--json] [--max-age ] [--refresh] - `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/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. -## Codex JSON-RPC Client +## Tiered Codex Provider -The adapter spawns `codex app-server --stdio` with piped stdin/stdout and ignored stderr. It writes newline-delimited JSON: +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. -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. +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: @@ -87,7 +96,8 @@ Only authoritative Codex utilization can establish `available: yes`. Claude, Pi, - 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 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. diff --git a/docs/ai/planning/2026-08-09-feature-capacity-command.md b/docs/ai/planning/2026-08-09-feature-capacity-command.md index 521034f9..8481c376 100644 --- a/docs/ai/planning/2026-08-09-feature-capacity-command.md +++ b/docs/ai/planning/2026-08-09-feature-capacity-command.md @@ -44,13 +44,23 @@ All tasks are complete. The list reflects execution order and the pushed commit - 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. +- [ ] Publish the reworked branch and update PR #147. + ## 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. +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. diff --git a/docs/ai/requirements/2026-08-09-feature-capacity-command.md b/docs/ai/requirements/2026-08-09-feature-capacity-command.md index 7bf54c93..c3d62660 100644 --- a/docs/ai/requirements/2026-08-09-feature-capacity-command.md +++ b/docs/ai/requirements/2026-08-09-feature-capacity-command.md @@ -32,7 +32,7 @@ The `capacity` command gives human operators, the agent-management workflow, par - 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. +- OAuth token refresh or any mutation of provider-owned credentials. ## User Stories @@ -61,7 +61,9 @@ The default cache age is 300 seconds. `--refresh` bypasses cache. Unknown provid - 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. +- 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. @@ -77,6 +79,7 @@ The default cache age is 300 seconds. `--refresh` bypasses cache. Unknown provid - `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. diff --git a/docs/ai/testing/2026-08-09-feature-capacity-command.md b/docs/ai/testing/2026-08-09-feature-capacity-command.md index c8c29a47..0a520100 100644 --- a/docs/ai/testing/2026-08-09-feature-capacity-command.md +++ b/docs/ai/testing/2026-08-09-feature-capacity-command.md @@ -19,6 +19,13 @@ The feature was built with red-green-refactor cycles. Pure mapping and detection ### `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`. @@ -58,40 +65,20 @@ The response fixture is synthetic and redacted; it contains no real account data - [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. +| `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 From 92ba3abc49fe75c10fbe387d4e53c62df6104868 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Thu, 20 Aug 2026 06:07:08 +0000 Subject: [PATCH 12/18] docs(capacity): close rework milestone --- docs/ai/planning/2026-08-09-feature-capacity-command.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ai/planning/2026-08-09-feature-capacity-command.md b/docs/ai/planning/2026-08-09-feature-capacity-command.md index 8481c376..23cd073f 100644 --- a/docs/ai/planning/2026-08-09-feature-capacity-command.md +++ b/docs/ai/planning/2026-08-09-feature-capacity-command.md @@ -52,7 +52,7 @@ All tasks are complete. The list reflects execution order and the pushed commit - [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. -- [ ] Publish the reworked branch and update PR #147. +- [x] Publish the reworked branch and update PR #147 with the tiered-flow Rework section. ## Dependencies and Sequencing From ffb417c9e9506f5e59be1790d6be2e0362719b70 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sat, 22 Aug 2026 14:52:57 +0000 Subject: [PATCH 13/18] refactor(capacity): simplify to fresh codex probes --- .../2026-08-09-feature-capacity-command.md | 180 ++++-------------- .../2026-08-09-feature-capacity-command.md | 120 +++--------- .../2026-08-09-feature-capacity-command.md | 93 +++------ .../2026-08-09-feature-capacity-command.md | 92 +++------ .../2026-08-09-feature-capacity-command.md | 99 +++------- .../src/__tests__}/capacity/codex.test.ts | 2 +- .../src/__tests__/capacity/index.test.ts | 44 +++++ .../src/capacity}/codex.ts | 2 +- packages/agent-manager/src/capacity/index.ts | 84 ++++++++ .../src}/capacity/types.ts | 0 packages/agent-manager/src/index.ts | 8 + packages/cli/README.md | 6 +- .../__tests__/commands/capacity/cache.test.ts | 24 --- .../commands/capacity/command.test.ts | 14 +- .../commands/capacity/detection.test.ts | 26 --- .../commands/capacity/orchestrate.test.ts | 66 ------- .../commands/capacity/providers.test.ts | 92 --------- packages/cli/src/commands/capacity.ts | 29 ++- packages/cli/src/commands/capacity/cache.ts | 46 ----- .../cli/src/commands/capacity/detection.ts | 59 ------ .../cli/src/commands/capacity/orchestrate.ts | 109 ----------- .../src/commands/capacity/providers/claude.ts | 76 -------- .../cli/src/commands/capacity/providers/pi.ts | 35 ---- .../src/commands/capacity/providers/stub.ts | 31 --- packages/cli/src/commands/capacity/render.ts | 2 +- 25 files changed, 303 insertions(+), 1036 deletions(-) rename packages/{cli/src/__tests__/commands => agent-manager/src/__tests__}/capacity/codex.test.ts (99%) create mode 100644 packages/agent-manager/src/__tests__/capacity/index.test.ts rename packages/{cli/src/commands/capacity/providers => agent-manager/src/capacity}/codex.ts (99%) create mode 100644 packages/agent-manager/src/capacity/index.ts rename packages/{cli/src/commands => agent-manager/src}/capacity/types.ts (100%) delete mode 100644 packages/cli/src/__tests__/commands/capacity/cache.test.ts delete mode 100644 packages/cli/src/__tests__/commands/capacity/detection.test.ts delete mode 100644 packages/cli/src/__tests__/commands/capacity/orchestrate.test.ts delete mode 100644 packages/cli/src/__tests__/commands/capacity/providers.test.ts delete mode 100644 packages/cli/src/commands/capacity/cache.ts delete mode 100644 packages/cli/src/commands/capacity/detection.ts delete mode 100644 packages/cli/src/commands/capacity/orchestrate.ts delete mode 100644 packages/cli/src/commands/capacity/providers/claude.ts delete mode 100644 packages/cli/src/commands/capacity/providers/pi.ts delete mode 100644 packages/cli/src/commands/capacity/providers/stub.ts diff --git a/docs/ai/design/2026-08-09-feature-capacity-command.md b/docs/ai/design/2026-08-09-feature-capacity-command.md index 197fbc9f..12993597 100644 --- a/docs/ai/design/2026-08-09-feature-capacity-command.md +++ b/docs/ai/design/2026-08-09-feature-capacity-command.md @@ -1,167 +1,53 @@ --- phase: design title: Capacity Command Design -description: Architecture and security design for normalized provider capacity reporting +description: Thin CLI over a Codex capacity model owned by agent-manager --- # Capacity Command Design -## Architecture Overview +## Architecture ```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] + CLI[CLI registration and validation] --> Manager[agent-manager getCodexCapacityReport] + Manager --> Detect[Codex config and PATH detection] + Manager --> Probe[PAT then OAuth then read-only app-server] + Probe --> Normalize[CapacityReport v1] + Normalize --> Render[CLI text or JSON rendering] ``` -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. +`packages/agent-manager/src/capacity/` is the domain boundary. `types.ts` defines normalized output, `codex.ts` owns credential-safe probing and mapping, and `index.ts` detects Codex, calls the probe once, redacts unexpected failures, and builds the report. The root package export exposes the report function and types. -## Command API +`packages/cli/src/commands/capacity.ts` registers `capacity [provider]`, validates that an explicit provider is `codex`, calls agent-manager, and delegates rendering. `capacity/render.ts` contains presentation only. -```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`. +## Fresh Probe Flow -## Data Model +Each invocation checks `~/.codex` and PATH, then probes once. No filesystem cache, freshness key, TTL, bypass option, multi-provider selection, parallel grouping, or orchestration timeout exists. -```ts -type CapacityWindow = { - id: string; - label: string; - durationMinutes: number | null; - usedPercent: number | null; - remainingPercent: number | null; - resetsAt: string | null; - scope: string | null; -}; +The Codex probe remains tiered: -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 }; -}; +1. Resolve `CODEX_HOME/auth.json`, falling back to `~/.codex/auth.json`. +2. If a PAT exists, use `whoami` then the usage endpoint. +3. Otherwise use a fresh OAuth token and stored account ID. +4. On missing, stale, unauthorized, or failed credentials, run `codex -s read-only -a untrusted app-server` and call only `account/rateLimits/read` and `account/read`. -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. +Network and app-server calls remain bounded inside the probe. Results are normalized into schema v1; raw inputs and exceptions are never returned. -## 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 -``` +## Simplification Decisions -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. +| Opportunity | Decision | Reason | +|---|---|---| +| Remove normalized cache and cache tests | Acted | Every run must be fresh; TTL, permissions, keying, atomic writes, and bypass paths no longer serve behavior. | +| Remove `--max-age` and `--refresh` | Acted | They only controlled the removed cache. | +| Remove Claude, Pi, GLM, and generic stubs/tests | Acted | Codex is the only supported capacity provider. | +| Replace provider registry and configured-provider scan | Acted | A direct Codex config/PATH check is clearer than generic mappings for one provider. | +| Remove parallel orchestration, provider arrays, sorting, and outer timeout | Acted | One probe has no concurrency or partial-result problem; probe boundaries already time out. | +| Move model/probe/types into agent-manager | Acted | Capacity informs agent dispatch and is reusable independently of CLI presentation. | +| Keep schema-v1 report and provider array | Rejected | It is already the documented machine-readable contract; changing it adds migration cost without simplifying the probe. | +| Collapse PAT, OAuth, and CLI probing to app-server only | Rejected | The fallbacks have distinct availability/authentication value and preserve credential-safe behavior. | +| Collapse `UsageSnapshot` into render fields | Rejected | It preserves authoritative source detail and provider-native windows for JSON consumers. | +| Merge renderer into command | Rejected | Rendering has separate behavior and tests; keeping it isolated makes the CLI flow linear. | +| Add a new package dependency/helper library | Rejected | Node APIs and the existing agent-manager dependency are sufficient. | + +All acted changes pass the readability guide's Reading Test: the command path is linear, names are explicit, functions stay at one abstraction level, and no speculative abstraction remains. diff --git a/docs/ai/implementation/2026-08-09-feature-capacity-command.md b/docs/ai/implementation/2026-08-09-feature-capacity-command.md index 8ba623b5..6a75f823 100644 --- a/docs/ai/implementation/2026-08-09-feature-capacity-command.md +++ b/docs/ai/implementation/2026-08-09-feature-capacity-command.md @@ -1,110 +1,52 @@ --- phase: implementation title: Capacity Command Implementation Record -description: Shipped modules, integration points, invariants, and operational behavior +description: Codex-only capacity model and thin CLI integration --- # Capacity Command Implementation Record -## Shipped Module Map +## 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 +packages/agent-manager/src/ +├── capacity/ +│ ├── index.ts # detection, one fresh probe, report construction +│ ├── codex.ts # PAT/OAuth/app-server probing and normalization +│ └── types.ts # schema-v1 capacity model +└── __tests__/capacity/ + ├── index.test.ts + └── codex.test.ts + +packages/cli/src/commands/ +├── capacity.ts # Commander registration, provider validation, manager call +└── capacity/render.ts # human and JSON presentation ``` -Tests live in `packages/cli/src/__tests__/commands/capacity/`. +Agent-manager's root `index.ts` exports `getCodexCapacityReport` and the public capacity types. No package dependency was added because the CLI already depends on `@ai-devkit/agent-manager`. -## CLI Registration +## Runtime Behavior -`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: +`capacity` and `capacity codex` are equivalent. An explicit provider is normalized to lowercase and must be `codex`. The report function checks configuration and installation state, invokes the Codex probe on every call, catches unexpected probe failures into a fixed safe result, and returns one schema-v1 provider row. -```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: +The retained Codex implementation validates normalized identifiers and labels, preserves arbitrary windows, derives remaining percentage only from numeric usage, and keeps the PAT → fresh OAuth → hardened app-server sequence. It does not refresh tokens or invoke a model method. -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. +## Removed Implementation -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. +- `capacity/cache.ts` and its cache test. +- `capacity/detection.ts` generic environment discovery and its test. +- `capacity/orchestrate.ts` provider selection, grouping, sorting, caching, dependency graph, and its tests. +- Claude, Pi/GLM, and unsupported stub providers plus provider tests. +- CLI max-age parsing and refresh forwarding. -Mapping behavior: +## Simplification Review -- 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. +The complete opportunity ledger is in the design document. Acted changes remove unused feature surface and abstractions. Rejected changes retain the stable JSON contract, meaningful tiered probing, normalized usage details, and isolated rendering because deleting them would reduce behavior or clarity rather than complexity. ## 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. +- Only normalized allowlisted data crosses the agent-manager boundary. +- PATs, access/refresh tokens, account IDs, headers, bodies, stderr, and raw exceptions are not emitted. +- The CLI fallback uses read-only/untrusted app-server flags and account-only methods. +- Missing or failed data remains unknown; reset credits are never redeemed. +- Every run is read-only and fresh, with no AI DevKit capacity cache writes. diff --git a/docs/ai/planning/2026-08-09-feature-capacity-command.md b/docs/ai/planning/2026-08-09-feature-capacity-command.md index 23cd073f..41ab724f 100644 --- a/docs/ai/planning/2026-08-09-feature-capacity-command.md +++ b/docs/ai/planning/2026-08-09-feature-capacity-command.md @@ -1,79 +1,38 @@ --- phase: planning -title: Capacity Command Implementation Plan -description: Completed task record for the shipped capacity command +title: Capacity Command Simplification Plan +description: Completed plan for a fresh Codex-only capacity command --- -# Capacity Command Implementation Plan +# Capacity Command Simplification Plan -All tasks are complete. The list reflects execution order and the pushed commit that delivered each outcome. +## Completed Tasks -## Milestone 1: Detection and Core Contract +- [x] Remove cache implementation, cache tests, cache calls, `--max-age`, and `--refresh`. +- [x] Remove Claude, Pi, GLM, unsupported-provider adapters, and their tests. +- [x] Replace generic provider detection and multi-provider orchestration with one fresh Codex report function. +- [x] Move Codex probing, normalization, types, and report construction to `@ai-devkit/agent-manager` using `src/capacity/` and `src/__tests__/capacity/` conventions. +- [x] Export the capacity API and types from agent-manager's root entry point. +- [x] Reduce CLI integration to registration, Codex argument validation, one agent-manager call, and rendering. +- [x] Relocate behavioral tests to the owning workspace and remove tests whose only behavior was deleted. +- [x] Update CLI README and all 2026-08-09 lifecycle documents. -- [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. +## Order and Dependencies -## Milestone 2: Provider Coverage and Orchestration +1. Preserve the normalized contract while moving it and the Codex probe. +2. Add the agent-manager report boundary and tests. +3. Switch the CLI to that boundary. +4. Delete superseded provider/cache/orchestration modules and tests. +5. Update lifecycle records, then run build and test validation. -- [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. +## Risk Controls -## Milestone 3: CLI and Presentation +- Root agent-manager exports preserve one supported import path. +- Probe exceptions become fixed normalized failures; raw provider details remain redacted. +- Existing mocked PAT/OAuth/app-server tests move with the domain code. +- Commander tests prove non-Codex rejection and the absence of cache-option forwarding. +- Full workspace build/tests catch package-boundary and declaration-generation errors. -- [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. +## Deferred Scope -## 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. +Future providers should be added only with a verified, read-only capacity mechanism and a concrete product requirement. Do not restore generic provider scaffolding or caching speculatively. diff --git a/docs/ai/requirements/2026-08-09-feature-capacity-command.md b/docs/ai/requirements/2026-08-09-feature-capacity-command.md index c3d62660..ed53ac15 100644 --- a/docs/ai/requirements/2026-08-09-feature-capacity-command.md +++ b/docs/ai/requirements/2026-08-09-feature-capacity-command.md @@ -1,88 +1,46 @@ --- phase: requirements title: Capacity Command Requirements -description: Define truthful, read-only provider capacity reporting before agent dispatch +description: Define fresh, read-only Codex capacity reporting --- # Capacity Command Requirements -## Problem Statement +## Problem -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. +Codex users need a factual capacity report before dispatching work. The command must obtain current data without starting a model turn, exposing credentials, or carrying provider and cache machinery that has no supported use. -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 +## 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 +ai-devkit capacity codex +ai-devkit capacity [codex] --json ``` -The default cache age is 300 seconds. `--refresh` bypasses cache. Unknown providers and invalid non-negative integer values for `--max-age` are invalid arguments. +The optional provider argument exists for discoverability and accepts only `codex`, case-insensitively. Any other value fails before probing. Every invocation probes fresh; there is no cache, `--max-age`, or `--refresh` option. ## 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. +- Capacity supports Codex only and always emits exactly one Codex row. +- `@ai-devkit/agent-manager` owns probing, normalization, detection, and public capacity types. +- The CLI owns only command registration, provider validation, the agent-manager call, and text/JSON rendering. +- JSON retains `schemaVersion: 1`, normalized arbitrary windows, availability, authentication, plan, reset-credit, warning, and stable error fields. +- Codex configuration and executable presence are reported independently. +- Probing prefers PAT, then fresh OAuth, then the hardened read-only Codex app-server fallback. +- Missing data is `unknown`, never inferred as available; explicit exhaustion may report `no`. +- Probing never starts a model turn, refreshes credentials, writes provider data, or exposes secrets/raw failures. +- Existing meaningful normalization, fallback, redaction, rendering, and command-contract tests remain covered in their owning packages. -## Constraints and Locked Decisions +## Non-Goals -- 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. +- Claude, Pi, GLM, generic provider stubs, or future-provider scaffolding. +- Cross-provider selection, parallel orchestration, partial multi-provider results, or scheduling policy. +- Cached or historical capacity, forecasting, cost prediction, token-history estimation, or reset-credit redemption. +- OAuth refresh, TUI scraping, or inference-based probes. -## Open Items +## Constraints -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. +- Keep the schema stable where it still describes Codex truthfully. +- Use provider-owned credentials read-only and discard raw exception details. +- Do not add a dependency: the CLI already depends on agent-manager. diff --git a/docs/ai/testing/2026-08-09-feature-capacity-command.md b/docs/ai/testing/2026-08-09-feature-capacity-command.md index 0a520100..2f2cc456 100644 --- a/docs/ai/testing/2026-08-09-feature-capacity-command.md +++ b/docs/ai/testing/2026-08-09-feature-capacity-command.md @@ -1,85 +1,40 @@ --- phase: testing -title: Capacity Command Testing Record -description: Automated coverage, fixtures, real smoke checks, and final gate evidence +title: Capacity Command Test Record +description: Coverage and validation for the Codex-only implementation --- -# Capacity Command Testing Record +# Capacity Command Test Record -## Strategy and Isolation +## Agent-manager Capacity Coverage -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. +- [x] Resolve `CODEX_HOME` before the home fallback. +- [x] Normalize API and CLI windows without converting missing values to zero. +- [x] Preserve session, weekly, credit, individual-limit, and additional-window data. +- [x] Prefer PAT, then fresh OAuth, then CLI; fall back on stale credentials, 401s, and request failures. +- [x] Use read-only/untrusted app-server arguments and account-only methods. +- [x] Distinguish logged-out account state and keep unknown/unavailable semantics. +- [x] Prevent token and raw failure leakage. +- [x] Detect Codex configuration and installation independently before probing. +- [x] Build exactly one schema-v1 Codex report and redact unexpected probe failures. -## Automated Test Inventory +## CLI Coverage -### `detection.test.ts` +- [x] Render schema-v1 JSON exactly. +- [x] Render human headers, windows, credits, and warnings. +- [x] Accept omitted provider and `codex`, forwarding no cache options. +- [x] Reject non-Codex providers before probing. -- [x] Derive configured providers from `ENVIRONMENT_DEFINITIONS.globalSkillPath`, including nested `.config/opencode`. -- [x] Check executable presence on PATH without running a provider CLI. +## Removed Coverage -### `codex.test.ts` +Cache freshness/permissions, generic provider detection, parallel/partial multi-provider orchestration, and Claude/Pi/stub tests were removed with their behavior. They provided no unique coverage of the simplified contract. -- [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. +## Required Fresh Validation -The response fixture is synthetic and redacted; it contains no real account data. +- `npm ci` only if `node_modules` is absent. +- `npm run build` at repository root. +- `npm test --workspace=@ai-devkit/agent-manager`. +- `npm test --workspace=ai-devkit`. +- `npm test` for the complete repository suite. -### `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. +Final command output and pass/fail counts are recorded in the implementation handoff for this uncommitted worktree change. diff --git a/packages/cli/src/__tests__/commands/capacity/codex.test.ts b/packages/agent-manager/src/__tests__/capacity/codex.test.ts similarity index 99% rename from packages/cli/src/__tests__/commands/capacity/codex.test.ts rename to packages/agent-manager/src/__tests__/capacity/codex.test.ts index 8ed6a479..87b36ef2 100644 --- a/packages/cli/src/__tests__/commands/capacity/codex.test.ts +++ b/packages/agent-manager/src/__tests__/capacity/codex.test.ts @@ -5,7 +5,7 @@ import { probeCodexCapacity, resolveCodexAuthPath, toRateWindow -} from '../../../commands/capacity/providers/codex.js'; +} from '../../capacity/codex.js'; const checkedAt = '2026-08-20T10:00:00.000Z'; const context = { configured: true, installed: true, checkedAt }; diff --git a/packages/agent-manager/src/__tests__/capacity/index.test.ts b/packages/agent-manager/src/__tests__/capacity/index.test.ts new file mode 100644 index 00000000..1aeb7964 --- /dev/null +++ b/packages/agent-manager/src/__tests__/capacity/index.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from 'vitest'; +import { getCodexCapacityReport } from '../../capacity/index.js'; + +const checkedAt = '2026-08-09T10:00:00.000Z'; + +describe('getCodexCapacityReport', () => { + it('detects Codex configuration and installation before probing', async () => { + const probe = vi.fn(async context => ({ + provider: 'codex', agentType: 'codex', ...context, + authenticated: true, status: 'supported' as const, available: 'yes' as const, + plan: 'pro', source: 'provider-cli' as const, windows: [], + aliases: { dailyWindowId: null, weeklyWindowId: null }, warnings: [] + })); + + const report = await getCodexCapacityReport({ + now: () => new Date(checkedAt), + homeDir: '/users/test', + path: '/usr/bin:/opt/bin', + exists: async target => target === '/users/test/.codex', + access: async target => { + if (target !== '/opt/bin/codex') throw new Error('missing'); + }, + probe + }); + + expect(probe).toHaveBeenCalledWith({ configured: true, installed: true, checkedAt }); + expect(report).toMatchObject({ schemaVersion: 1, generatedAt: checkedAt }); + expect(report.providers).toHaveLength(1); + }); + + it('redacts unexpected probe failures into a stable unknown result', async () => { + const report = await getCodexCapacityReport({ + now: () => new Date(checkedAt), + path: '', + exists: async () => false, + probe: async () => { throw new Error('private provider response'); } + }); + + expect(report.providers[0]).toMatchObject({ + provider: 'codex', status: 'unavailable', available: 'unknown', configured: false, installed: false + }); + expect(JSON.stringify(report)).not.toContain('private provider response'); + }); +}); diff --git a/packages/cli/src/commands/capacity/providers/codex.ts b/packages/agent-manager/src/capacity/codex.ts similarity index 99% rename from packages/cli/src/commands/capacity/providers/codex.ts rename to packages/agent-manager/src/capacity/codex.ts index 62077022..641436b2 100644 --- a/packages/cli/src/commands/capacity/providers/codex.ts +++ b/packages/agent-manager/src/capacity/codex.ts @@ -6,7 +6,7 @@ import type { CodexUsageSource, ProviderCapacity, UsageSnapshot -} from '../types.js'; +} from './types.js'; type UnknownRecord = Record; type RpcMessage = { id?: number; method: string; params?: UnknownRecord }; diff --git a/packages/agent-manager/src/capacity/index.ts b/packages/agent-manager/src/capacity/index.ts new file mode 100644 index 00000000..28a02856 --- /dev/null +++ b/packages/agent-manager/src/capacity/index.ts @@ -0,0 +1,84 @@ +import { constants } from 'node:fs'; +import { access as fsAccess } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import path from 'node:path'; +import { probeCodexCapacity } from './codex.js'; +import type { CapacityReport, ProviderCapacity } from './types.js'; + +export type { CapacityReport, CapacityWindow, ProviderCapacity, UsageSnapshot } from './types.js'; + +export type CapacityProbeOptions = { + now?: () => Date; + homeDir?: string; + path?: string; + exists?: (target: string) => Promise; + access?: (target: string) => Promise; + probe?: typeof probeCodexCapacity; +}; + +async function canAccess(target: string, mode: number): Promise { + try { + await fsAccess(target, mode); + return true; + } catch { + return false; + } +} + +async function isCodexInstalled(pathValue: string, checkAccess?: (target: string) => Promise): Promise { + const directories = pathValue.split(path.delimiter).filter(Boolean); + for (const directory of directories) { + const executable = path.join(directory, 'codex'); + if (checkAccess) { + try { + await checkAccess(executable); + return true; + } catch { + continue; + } + } + if (await canAccess(executable, constants.X_OK)) return true; + } + return false; +} + +function failedCapacity(configured: boolean, installed: boolean, checkedAt: string): ProviderCapacity { + return { + provider: 'codex', + agentType: 'codex', + configured, + installed, + authenticated: null, + status: installed ? 'unknown' : 'unavailable', + available: 'unknown', + plan: null, + 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 } } : {}) + }; +} + +export async function getCodexCapacityReport(options: CapacityProbeOptions = {}): Promise { + const now = options.now?.() ?? new Date(); + const checkedAt = now.toISOString(); + const home = options.homeDir ?? homedir(); + const exists = options.exists ?? (target => canAccess(target, constants.F_OK)); + const configured = await exists(path.join(home, '.codex')); + const installed = await isCodexInstalled(options.path ?? process.env.PATH ?? '', options.access); + + let capacity: ProviderCapacity; + try { + capacity = await (options.probe ?? probeCodexCapacity)({ configured, installed, checkedAt }); + } catch { + capacity = failedCapacity(configured, installed, checkedAt); + } + + return { schemaVersion: 1, generatedAt: checkedAt, providers: [capacity] }; +} diff --git a/packages/cli/src/commands/capacity/types.ts b/packages/agent-manager/src/capacity/types.ts similarity index 100% rename from packages/cli/src/commands/capacity/types.ts rename to packages/agent-manager/src/capacity/types.ts diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index eef400cb..df6dd0a5 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -1,4 +1,12 @@ export { AgentManager, AgentNotRunningError } from './AgentManager.js'; +export { getCodexCapacityReport } from './capacity/index.js'; +export type { + CapacityProbeOptions, + CapacityReport, + CapacityWindow, + ProviderCapacity, + UsageSnapshot, +} from './capacity/index.js'; export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter.js'; export { CodexAdapter } from './adapters/CodexAdapter.js'; diff --git a/packages/cli/README.md b/packages/cli/README.md index 6e22dc4c..ba30e1d1 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -85,11 +85,11 @@ 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) +# Probe current Codex capacity (read-only; never cached) ai-devkit capacity -# Refresh one provider and emit the stable schema-v1 JSON report -ai-devkit capacity codex --json --refresh +# Emit the stable schema-v1 JSON report +ai-devkit capacity codex --json # 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 deleted file mode 100644 index a441546c..00000000 --- a/packages/cli/src/__tests__/commands/capacity/cache.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -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/command.test.ts b/packages/cli/src/__tests__/commands/capacity/command.test.ts index 954125e9..271824d8 100644 --- a/packages/cli/src/__tests__/commands/capacity/command.test.ts +++ b/packages/cli/src/__tests__/commands/capacity/command.test.ts @@ -2,7 +2,7 @@ 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 type { CapacityReport } from '@ai-devkit/agent-manager'; import { ui } from '../../../util/terminal-ui.js'; vi.mock('../../../util/terminal-ui.js', () => ({ ui: { text: vi.fn() } })); @@ -47,21 +47,21 @@ describe('capacity command', () => { expect(output).toContain('A safe normalized warning.'); }); - it('wires the locked command surface and forwards parsed options', async () => { + it('wires the Codex-only command surface', 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']); + await program.parseAsync(['node', 'test', 'capacity', 'codex', '--json']); - expect(getReport).toHaveBeenCalledWith({ provider: 'codex', maxAge: 120, refresh: true }); + expect(getReport).toHaveBeenCalledWith(); expect(ui.text).toHaveBeenCalledWith(JSON.stringify(report, null, 2)); }); - it('rejects invalid max-age values before probing', async () => { + it('rejects non-Codex providers 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' + await expect(capacityCommand('claude', {}, getReport)).rejects.toThrow( + 'Only "codex" is supported' ); 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 deleted file mode 100644 index 5198e493..00000000 --- a/packages/cli/src/__tests__/commands/capacity/detection.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index 46d9e78d..00000000 --- a/packages/cli/src/__tests__/commands/capacity/orchestrate.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index 4802e50e..00000000 --- a/packages/cli/src/__tests__/commands/capacity/providers.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -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/commands/capacity.ts b/packages/cli/src/commands/capacity.ts index 2b603e10..1de4a010 100644 --- a/packages/cli/src/commands/capacity.ts +++ b/packages/cli/src/commands/capacity.ts @@ -1,33 +1,28 @@ import type { Command } from 'commander'; -import { getCapacityReport } from './capacity/orchestrate.js'; +import { getCodexCapacityReport } from '@ai-devkit/agent-manager'; import { renderCapacityReport } from './capacity/render.js'; -import type { CapacityReport } from './capacity/types.js'; +import type { CapacityReport } from '@ai-devkit/agent-manager'; -type RawCapacityOptions = { json?: boolean; maxAge?: string; refresh?: boolean }; -type ReportReader = (options: { - provider?: string; maxAge: number; refresh: boolean; -}) => Promise; +type CapacityOptions = { json?: boolean }; +type ReportReader = () => Promise; export async function capacityCommand( provider: string | undefined, - options: RawCapacityOptions, - readReport: ReportReader = getCapacityReport + options: CapacityOptions, + readReport: ReportReader = getCodexCapacityReport ): 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.'); + if (provider !== undefined && provider.toLowerCase() !== 'codex') { + throw new Error(`Unknown capacity provider "${provider}". Only "codex" is supported.`); } - const report = await readReport({ provider, maxAge, refresh: options.refresh === true }); + const report = await readReport(); renderCapacityReport(report, options); } -export function registerCapacityCommand(program: Command, readReport: ReportReader = getCapacityReport): void { +export function registerCapacityCommand(program: Command, readReport: ReportReader = getCodexCapacityReport): void { program .command('capacity [provider]') - .description('Report configured AI provider capacity without consuming model quota') + .description('Report Codex 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) => + .action((provider: string | undefined, options: CapacityOptions) => capacityCommand(provider, options, readReport)); } diff --git a/packages/cli/src/commands/capacity/cache.ts b/packages/cli/src/commands/capacity/cache.ts deleted file mode 100644 index 2856771f..00000000 --- a/packages/cli/src/commands/capacity/cache.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 2c927060..00000000 --- a/packages/cli/src/commands/capacity/detection.ts +++ /dev/null @@ -1,59 +0,0 @@ -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 deleted file mode 100644 index b0577f9a..00000000 --- a/packages/cli/src/commands/capacity/orchestrate.ts +++ /dev/null @@ -1,109 +0,0 @@ -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 deleted file mode 100644 index 50cbf9c9..00000000 --- a/packages/cli/src/commands/capacity/providers/claude.ts +++ /dev/null @@ -1,76 +0,0 @@ -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/pi.ts b/packages/cli/src/commands/capacity/providers/pi.ts deleted file mode 100644 index 5e567c66..00000000 --- a/packages/cli/src/commands/capacity/providers/pi.ts +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 450eaa0b..00000000 --- a/packages/cli/src/commands/capacity/providers/stub.ts +++ /dev/null @@ -1,31 +0,0 @@ -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 index 9a690743..7874f17e 100644 --- a/packages/cli/src/commands/capacity/render.ts +++ b/packages/cli/src/commands/capacity/render.ts @@ -1,5 +1,5 @@ import { ui } from '../../util/terminal-ui.js'; -import type { CapacityReport, CapacityWindow } from './types.js'; +import type { CapacityReport, CapacityWindow } from '@ai-devkit/agent-manager'; function authLabel(value: boolean | null): string { return value === true ? 'yes' : value === false ? 'no' : 'unknown'; From 4cd7181f4b0ab665a5b69b21c41baf01ba1b7a44 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sat, 22 Aug 2026 15:44:42 +0000 Subject: [PATCH 14/18] docs(capacity): drop schema-v1 wording from CLI help surface The schemaVersion field in the JSON output is the machine-checked contract marker; help text and README now describe the flag's behavior without leaking the contract version. Lifecycle docs updated to match; the design doc's rejected-alternative table keeps its wording because it records the decision itself. --- docs/ai/implementation/2026-08-09-feature-capacity-command.md | 4 ++-- docs/ai/testing/2026-08-09-feature-capacity-command.md | 4 ++-- packages/cli/README.md | 2 +- packages/cli/src/__tests__/commands/capacity/command.test.ts | 2 +- packages/cli/src/commands/capacity.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/ai/implementation/2026-08-09-feature-capacity-command.md b/docs/ai/implementation/2026-08-09-feature-capacity-command.md index 6a75f823..234978bf 100644 --- a/docs/ai/implementation/2026-08-09-feature-capacity-command.md +++ b/docs/ai/implementation/2026-08-09-feature-capacity-command.md @@ -13,7 +13,7 @@ packages/agent-manager/src/ ├── capacity/ │ ├── index.ts # detection, one fresh probe, report construction │ ├── codex.ts # PAT/OAuth/app-server probing and normalization -│ └── types.ts # schema-v1 capacity model +│ └── types.ts # capacity report model (schemaVersion field included) └── __tests__/capacity/ ├── index.test.ts └── codex.test.ts @@ -27,7 +27,7 @@ Agent-manager's root `index.ts` exports `getCodexCapacityReport` and the public ## Runtime Behavior -`capacity` and `capacity codex` are equivalent. An explicit provider is normalized to lowercase and must be `codex`. The report function checks configuration and installation state, invokes the Codex probe on every call, catches unexpected probe failures into a fixed safe result, and returns one schema-v1 provider row. +`capacity` and `capacity codex` are equivalent. An explicit provider is normalized to lowercase and must be `codex`. The report function checks configuration and installation state, invokes the Codex probe on every call, catches unexpected probe failures into a fixed safe result, and returns one provider row. The `schemaVersion` field in the JSON output is the machine-checked contract marker; human-facing help text does not mention it. The retained Codex implementation validates normalized identifiers and labels, preserves arbitrary windows, derives remaining percentage only from numeric usage, and keeps the PAT → fresh OAuth → hardened app-server sequence. It does not refresh tokens or invoke a model method. diff --git a/docs/ai/testing/2026-08-09-feature-capacity-command.md b/docs/ai/testing/2026-08-09-feature-capacity-command.md index 2f2cc456..d5e3a394 100644 --- a/docs/ai/testing/2026-08-09-feature-capacity-command.md +++ b/docs/ai/testing/2026-08-09-feature-capacity-command.md @@ -16,11 +16,11 @@ description: Coverage and validation for the Codex-only implementation - [x] Distinguish logged-out account state and keep unknown/unavailable semantics. - [x] Prevent token and raw failure leakage. - [x] Detect Codex configuration and installation independently before probing. -- [x] Build exactly one schema-v1 Codex report and redact unexpected probe failures. +- [x] Build exactly one Codex report (schemaVersion 1) and redact unexpected probe failures. ## CLI Coverage -- [x] Render schema-v1 JSON exactly. +- [x] Render the JSON report exactly (schemaVersion field included). - [x] Render human headers, windows, credits, and warnings. - [x] Accept omitted provider and `codex`, forwarding no cache options. - [x] Reject non-Codex providers before probing. diff --git a/packages/cli/README.md b/packages/cli/README.md index ba30e1d1..1cdbdb6d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -88,7 +88,7 @@ ai-devkit lint --feature lint-command --json # Probe current Codex capacity (read-only; never cached) ai-devkit capacity -# Emit the stable schema-v1 JSON report +# Emit the machine-readable JSON report (includes schemaVersion) ai-devkit capacity codex --json # Install a skill diff --git a/packages/cli/src/__tests__/commands/capacity/command.test.ts b/packages/cli/src/__tests__/commands/capacity/command.test.ts index 271824d8..225347f5 100644 --- a/packages/cli/src/__tests__/commands/capacity/command.test.ts +++ b/packages/cli/src/__tests__/commands/capacity/command.test.ts @@ -29,7 +29,7 @@ const report: CapacityReport = { describe('capacity command', () => { beforeEach(() => vi.clearAllMocks()); - it('renders schema-v1 JSON exactly through terminal UI', () => { + it('renders the JSON report exactly through terminal UI', () => { renderCapacityReport(report, { json: true }); expect(ui.text).toHaveBeenCalledWith(JSON.stringify(report, null, 2)); }); diff --git a/packages/cli/src/commands/capacity.ts b/packages/cli/src/commands/capacity.ts index 1de4a010..be869888 100644 --- a/packages/cli/src/commands/capacity.ts +++ b/packages/cli/src/commands/capacity.ts @@ -22,7 +22,7 @@ export function registerCapacityCommand(program: Command, readReport: ReportRead program .command('capacity [provider]') .description('Report Codex capacity without consuming model quota') - .option('--json', 'Output a schema-v1 JSON report') + .option('--json', 'Output the report as JSON') .action((provider: string | undefined, options: CapacityOptions) => capacityCommand(provider, options, readReport)); } From 61c3595a1d353f96cdaeb3e06b98e5db452cd124 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sat, 22 Aug 2026 16:10:44 +0000 Subject: [PATCH 15/18] style(capacity): match agent list json flag convention Use '-j, --json' with 'Output as JSON' like every agent subcommand; drop remaining schema mentions from README and lifecycle docs. The schemaVersion field stays in the JSON output itself. --- docs/ai/implementation/2026-08-09-feature-capacity-command.md | 4 ++-- docs/ai/testing/2026-08-09-feature-capacity-command.md | 4 ++-- packages/cli/README.md | 2 +- packages/cli/src/commands/capacity.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/ai/implementation/2026-08-09-feature-capacity-command.md b/docs/ai/implementation/2026-08-09-feature-capacity-command.md index 234978bf..db6b8fa1 100644 --- a/docs/ai/implementation/2026-08-09-feature-capacity-command.md +++ b/docs/ai/implementation/2026-08-09-feature-capacity-command.md @@ -13,7 +13,7 @@ packages/agent-manager/src/ ├── capacity/ │ ├── index.ts # detection, one fresh probe, report construction │ ├── codex.ts # PAT/OAuth/app-server probing and normalization -│ └── types.ts # capacity report model (schemaVersion field included) +│ └── types.ts # capacity report model └── __tests__/capacity/ ├── index.test.ts └── codex.test.ts @@ -27,7 +27,7 @@ Agent-manager's root `index.ts` exports `getCodexCapacityReport` and the public ## Runtime Behavior -`capacity` and `capacity codex` are equivalent. An explicit provider is normalized to lowercase and must be `codex`. The report function checks configuration and installation state, invokes the Codex probe on every call, catches unexpected probe failures into a fixed safe result, and returns one provider row. The `schemaVersion` field in the JSON output is the machine-checked contract marker; human-facing help text does not mention it. +`capacity` and `capacity codex` are equivalent. An explicit provider is normalized to lowercase and must be `codex`. The report function checks configuration and installation state, invokes the Codex probe on every call, catches unexpected probe failures into a fixed safe result, and returns one provider row. The retained Codex implementation validates normalized identifiers and labels, preserves arbitrary windows, derives remaining percentage only from numeric usage, and keeps the PAT → fresh OAuth → hardened app-server sequence. It does not refresh tokens or invoke a model method. diff --git a/docs/ai/testing/2026-08-09-feature-capacity-command.md b/docs/ai/testing/2026-08-09-feature-capacity-command.md index d5e3a394..ab1fed0e 100644 --- a/docs/ai/testing/2026-08-09-feature-capacity-command.md +++ b/docs/ai/testing/2026-08-09-feature-capacity-command.md @@ -16,11 +16,11 @@ description: Coverage and validation for the Codex-only implementation - [x] Distinguish logged-out account state and keep unknown/unavailable semantics. - [x] Prevent token and raw failure leakage. - [x] Detect Codex configuration and installation independently before probing. -- [x] Build exactly one Codex report (schemaVersion 1) and redact unexpected probe failures. +- [x] Build exactly one Codex report and redact unexpected probe failures. ## CLI Coverage -- [x] Render the JSON report exactly (schemaVersion field included). +- [x] Render the JSON report exactly. - [x] Render human headers, windows, credits, and warnings. - [x] Accept omitted provider and `codex`, forwarding no cache options. - [x] Reject non-Codex providers before probing. diff --git a/packages/cli/README.md b/packages/cli/README.md index 1cdbdb6d..3c6a7ec7 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -88,7 +88,7 @@ ai-devkit lint --feature lint-command --json # Probe current Codex capacity (read-only; never cached) ai-devkit capacity -# Emit the machine-readable JSON report (includes schemaVersion) +# Emit the JSON report ai-devkit capacity codex --json # Install a skill diff --git a/packages/cli/src/commands/capacity.ts b/packages/cli/src/commands/capacity.ts index be869888..07194f1b 100644 --- a/packages/cli/src/commands/capacity.ts +++ b/packages/cli/src/commands/capacity.ts @@ -22,7 +22,7 @@ export function registerCapacityCommand(program: Command, readReport: ReportRead program .command('capacity [provider]') .description('Report Codex capacity without consuming model quota') - .option('--json', 'Output the report as JSON') + .option('-j, --json', 'Output as JSON') .action((provider: string | undefined, options: CapacityOptions) => capacityCommand(provider, options, readReport)); } From bfb1f533c1a018536301a0240788ab79c44e1d11 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sat, 22 Aug 2026 16:15:23 +0000 Subject: [PATCH 16/18] style(capacity): render through shared terminal UI Replace the hand-rolled table with ui.table (widths, truncation, maxWidth, bold headers, separator) and chalk column styles matching agent list: cyan provider, green/yellow/gray availability, dim metadata. JSON output goes through console.log and warnings through ui.warning like agent list. --- .../commands/capacity/command.test.ts | 33 +++++++++------- packages/cli/src/commands/capacity/render.ts | 39 ++++++++++++------- 2 files changed, 45 insertions(+), 27 deletions(-) diff --git a/packages/cli/src/__tests__/commands/capacity/command.test.ts b/packages/cli/src/__tests__/commands/capacity/command.test.ts index 225347f5..ee551039 100644 --- a/packages/cli/src/__tests__/commands/capacity/command.test.ts +++ b/packages/cli/src/__tests__/commands/capacity/command.test.ts @@ -5,7 +5,9 @@ import { renderCapacityReport } from '../../../commands/capacity/render.js'; import type { CapacityReport } from '@ai-devkit/agent-manager'; import { ui } from '../../../util/terminal-ui.js'; -vi.mock('../../../util/terminal-ui.js', () => ({ ui: { text: vi.fn() } })); +vi.mock('../../../util/terminal-ui.js', () => ({ + ui: { text: vi.fn(), table: vi.fn(), warning: vi.fn(), breakline: vi.fn() }, +})); const report: CapacityReport = { schemaVersion: 1, @@ -29,22 +31,25 @@ const report: CapacityReport = { describe('capacity command', () => { beforeEach(() => vi.clearAllMocks()); - it('renders the JSON report exactly through terminal UI', () => { + it('renders the JSON report exactly', () => { + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); renderCapacityReport(report, { json: true }); - expect(ui.text).toHaveBeenCalledWith(JSON.stringify(report, null, 2)); + expect(log).toHaveBeenCalledWith(JSON.stringify(report, null, 2)); + log.mockRestore(); }); - it('renders text labels, arbitrary short/long windows, credits, and warnings', () => { + it('renders the table, windows, credits, and warnings through the shared terminal UI', () => { 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.'); + expect(ui.text).toHaveBeenCalledWith('Capacity:', { breakline: true }); + expect(ui.table).toHaveBeenCalledWith(expect.objectContaining({ + 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', + ]], + })); + expect(ui.warning).toHaveBeenCalledWith('1 warning(s):'); + expect(ui.text).toHaveBeenCalledWith(' codex: A safe normalized warning.'); }); it('wires the Codex-only command surface', async () => { @@ -55,7 +60,7 @@ describe('capacity command', () => { await program.parseAsync(['node', 'test', 'capacity', 'codex', '--json']); expect(getReport).toHaveBeenCalledWith(); - expect(ui.text).toHaveBeenCalledWith(JSON.stringify(report, null, 2)); + expect(ui.table).not.toHaveBeenCalled(); }); it('rejects non-Codex providers before probing', async () => { diff --git a/packages/cli/src/commands/capacity/render.ts b/packages/cli/src/commands/capacity/render.ts index 7874f17e..0545acfc 100644 --- a/packages/cli/src/commands/capacity/render.ts +++ b/packages/cli/src/commands/capacity/render.ts @@ -1,3 +1,4 @@ +import chalk from 'chalk'; import { ui } from '../../util/terminal-ui.js'; import type { CapacityReport, CapacityWindow } from '@ai-devkit/agent-manager'; @@ -20,9 +21,12 @@ function windowPair(windows: CapacityWindow[]): [CapacityWindow | undefined, Cap export function renderCapacityReport(report: CapacityReport, options: { json?: boolean } = {}): void { if (options.json) { - ui.text(JSON.stringify(report, null, 2)); + console.log(JSON.stringify(report, null, 2)); return; } + + ui.text('Capacity:', { breakline: true }); + const rows = report.providers.map(provider => { const [shortWindow, longWindow] = windowPair(provider.windows); return [ @@ -32,21 +36,30 @@ export function renderCapacityReport(report: CapacityReport, options: { json?: b formatWindow(shortWindow), formatWindow(longWindow), provider.resetCredits?.available === null || provider.resetCredits?.available === undefined - ? '—' : String(provider.resetCredits.available) + ? '—' : 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}` - )); + + ui.table({ + headers: ['Provider', 'Auth', 'Available', 'Short window', 'Long window', 'Reset credits'], + rows, + maxWidth: process.stdout.columns ?? 120, + columnStyles: [ + (text) => chalk.cyan(text), + (text) => chalk.dim(text), + (text) => (text === 'yes' ? chalk.green(text) : text === 'no' ? chalk.yellow(text) : chalk.gray(text)), + (text) => text, + (text) => chalk.dim(text), + (text) => chalk.dim(text), + ], + }); + + const warnings = report.providers.flatMap(provider => + provider.warnings.map(warning => `${provider.provider}: ${warning.message}`) + ); if (warnings.length > 0) { - ui.text(''); - ui.text('Warnings:'); + ui.breakline(); + ui.warning(`${warnings.length} warning(s):`); for (const warning of warnings) ui.text(` ${warning}`); } } From 253b6b4723ff28407496f9a0086fb43278fd6e17 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sat, 22 Aug 2026 16:27:01 +0000 Subject: [PATCH 17/18] style(capacity): generic command description for future providers Help text and README no longer hard-code Codex or the model-quota rationale; the supported provider list is enforced in code. --- packages/cli/README.md | 2 +- packages/cli/src/commands/capacity.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 3c6a7ec7..95cb17ac 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -85,7 +85,7 @@ ai-devkit lint --feature lint-command # Emit machine-readable output for CI ai-devkit lint --feature lint-command --json -# Probe current Codex capacity (read-only; never cached) +# Probe current AI provider capacity (currently Codex) ai-devkit capacity # Emit the JSON report diff --git a/packages/cli/src/commands/capacity.ts b/packages/cli/src/commands/capacity.ts index 07194f1b..295b9b46 100644 --- a/packages/cli/src/commands/capacity.ts +++ b/packages/cli/src/commands/capacity.ts @@ -21,7 +21,7 @@ export async function capacityCommand( export function registerCapacityCommand(program: Command, readReport: ReportReader = getCodexCapacityReport): void { program .command('capacity [provider]') - .description('Report Codex capacity without consuming model quota') + .description('Report AI provider capacity') .option('-j, --json', 'Output as JSON') .action((provider: string | undefined, options: CapacityOptions) => capacityCommand(provider, options, readReport)); From 2bfd199ca0c0861e2717f26d42ed570be0dc5c78 Mon Sep 17 00:00:00 2001 From: Hoang Nguyen Date: Sat, 22 Aug 2026 16:43:30 +0000 Subject: [PATCH 18/18] refactor(capacity): flatten report to minimal JSON shape Drop schemaVersion, the providers array, status/configured/installed/agentType/plan/checkedAt/source, aliases, the resetCredits wrapper, the embedded usage snapshot, warnings, and error codes: none had a current consumer and all predate the codex-only cut. The report is now one flat object: provider, generatedAt, authenticated, available, native windows (id, label, durationMinutes, usedPercent, resetsAt), and creditsRemaining. remainingPercent and alias derivation are left to consumers; configured-detection is removed with the field it fed. Verified against a live Codex probe; fields can return with a second provider. --- .../2026-08-09-feature-capacity-command.md | 4 +- .../2026-08-09-feature-capacity-command.md | 4 +- .../2026-08-09-feature-capacity-command.md | 2 +- .../2026-08-09-feature-capacity-command.md | 2 +- .../src/__tests__/capacity/codex.test.ts | 50 +++--- .../src/__tests__/capacity/index.test.ts | 20 +-- packages/agent-manager/src/capacity/codex.ts | 144 +++++------------- packages/agent-manager/src/capacity/index.ts | 51 ++----- packages/agent-manager/src/capacity/types.ts | 38 +---- packages/agent-manager/src/index.ts | 2 - .../commands/capacity/command.test.ts | 31 ++-- packages/cli/src/commands/capacity/render.ts | 38 ++--- 12 files changed, 111 insertions(+), 275 deletions(-) diff --git a/docs/ai/design/2026-08-09-feature-capacity-command.md b/docs/ai/design/2026-08-09-feature-capacity-command.md index 12993597..c33b7ab5 100644 --- a/docs/ai/design/2026-08-09-feature-capacity-command.md +++ b/docs/ai/design/2026-08-09-feature-capacity-command.md @@ -44,9 +44,9 @@ Network and app-server calls remain bounded inside the probe. Results are normal | Replace provider registry and configured-provider scan | Acted | A direct Codex config/PATH check is clearer than generic mappings for one provider. | | Remove parallel orchestration, provider arrays, sorting, and outer timeout | Acted | One probe has no concurrency or partial-result problem; probe boundaries already time out. | | Move model/probe/types into agent-manager | Acted | Capacity informs agent dispatch and is reusable independently of CLI presentation. | -| Keep schema-v1 report and provider array | Rejected | It is already the documented machine-readable contract; changing it adds migration cost without simplifying the probe. | +| Flatten the report to the minimal JSON shape (provider, generatedAt, authenticated, available, windows, creditsRemaining) | Acted | Owner decision before merge: the unmerged contract carried multi-provider-era fields (schemaVersion, providers[], status, configured, installed, agentType, plan, checkedAt, source, aliases, resetCredits wrapper, usage snapshot, warnings, error) with no current consumer; fields can return when a second provider lands. | +| Drop derived fields (`remainingPercent`, aliases) and the credit-limit fallback chain | Acted | Derivable from `usedPercent`/`durationMinutes` by consumers; the chain fed only removed output fields. | | Collapse PAT, OAuth, and CLI probing to app-server only | Rejected | The fallbacks have distinct availability/authentication value and preserve credential-safe behavior. | -| Collapse `UsageSnapshot` into render fields | Rejected | It preserves authoritative source detail and provider-native windows for JSON consumers. | | Merge renderer into command | Rejected | Rendering has separate behavior and tests; keeping it isolated makes the CLI flow linear. | | Add a new package dependency/helper library | Rejected | Node APIs and the existing agent-manager dependency are sufficient. | diff --git a/docs/ai/implementation/2026-08-09-feature-capacity-command.md b/docs/ai/implementation/2026-08-09-feature-capacity-command.md index db6b8fa1..446f750c 100644 --- a/docs/ai/implementation/2026-08-09-feature-capacity-command.md +++ b/docs/ai/implementation/2026-08-09-feature-capacity-command.md @@ -27,9 +27,9 @@ Agent-manager's root `index.ts` exports `getCodexCapacityReport` and the public ## Runtime Behavior -`capacity` and `capacity codex` are equivalent. An explicit provider is normalized to lowercase and must be `codex`. The report function checks configuration and installation state, invokes the Codex probe on every call, catches unexpected probe failures into a fixed safe result, and returns one provider row. +`capacity` and `capacity codex` are equivalent. An explicit provider is normalized to lowercase and must be `codex`. The report function checks installation, invokes the Codex probe on every call, catches unexpected probe failures into a fixed unknown result, and returns one flat report: provider, generatedAt, authenticated, available, native windows, creditsRemaining. -The retained Codex implementation validates normalized identifiers and labels, preserves arbitrary windows, derives remaining percentage only from numeric usage, and keeps the PAT → fresh OAuth → hardened app-server sequence. It does not refresh tokens or invoke a model method. +The retained Codex implementation validates normalized identifiers and labels, preserves arbitrary windows, keeps unknown values null, and keeps the PAT → fresh OAuth → hardened app-server sequence. It does not refresh tokens or invoke a model method. ## Removed Implementation diff --git a/docs/ai/requirements/2026-08-09-feature-capacity-command.md b/docs/ai/requirements/2026-08-09-feature-capacity-command.md index ed53ac15..1db52340 100644 --- a/docs/ai/requirements/2026-08-09-feature-capacity-command.md +++ b/docs/ai/requirements/2026-08-09-feature-capacity-command.md @@ -25,7 +25,7 @@ The optional provider argument exists for discoverability and accepts only `code - Capacity supports Codex only and always emits exactly one Codex row. - `@ai-devkit/agent-manager` owns probing, normalization, detection, and public capacity types. - The CLI owns only command registration, provider validation, the agent-manager call, and text/JSON rendering. -- JSON retains `schemaVersion: 1`, normalized arbitrary windows, availability, authentication, plan, reset-credit, warning, and stable error fields. +- JSON reports the provider, generation time, authentication, availability, native usage windows (`id`, `label`, `durationMinutes`, `usedPercent`, `resetsAt`), and remaining credits in one flat object. Derived values and provider-internals are omitted; fields may be added when a second provider lands. - Codex configuration and executable presence are reported independently. - Probing prefers PAT, then fresh OAuth, then the hardened read-only Codex app-server fallback. - Missing data is `unknown`, never inferred as available; explicit exhaustion may report `no`. diff --git a/docs/ai/testing/2026-08-09-feature-capacity-command.md b/docs/ai/testing/2026-08-09-feature-capacity-command.md index ab1fed0e..55554940 100644 --- a/docs/ai/testing/2026-08-09-feature-capacity-command.md +++ b/docs/ai/testing/2026-08-09-feature-capacity-command.md @@ -21,7 +21,7 @@ description: Coverage and validation for the Codex-only implementation ## CLI Coverage - [x] Render the JSON report exactly. -- [x] Render human headers, windows, credits, and warnings. +- [x] Render human headers, windows, and credits. - [x] Accept omitted provider and `codex`, forwarding no cache options. - [x] Reject non-Codex providers before probing. diff --git a/packages/agent-manager/src/__tests__/capacity/codex.test.ts b/packages/agent-manager/src/__tests__/capacity/codex.test.ts index 87b36ef2..bf2494de 100644 --- a/packages/agent-manager/src/__tests__/capacity/codex.test.ts +++ b/packages/agent-manager/src/__tests__/capacity/codex.test.ts @@ -8,7 +8,7 @@ import { } from '../../capacity/codex.js'; const checkedAt = '2026-08-20T10:00:00.000Z'; -const context = { configured: true, installed: true, checkedAt }; +const context = { installed: true, checkedAt }; function apiUsage(overrides: Record = {}) { return { @@ -18,7 +18,6 @@ function apiUsage(overrides: Record = {}) { ...overrides }, credits: { balance: 12.5 }, - individual_limit: 100, additional_rate_limits: [{ limit_name: 'reviews', rate_limit: { @@ -42,38 +41,24 @@ 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 + resetsAt: '2026-08-20T10:00:00.000Z' }); - expect(toRateWindow({}, 'session', 'Session')).toMatchObject({ usedPercent: null, remainingPercent: null }); + expect(toRateWindow({}, 'session', 'Session')).toMatchObject({ usedPercent: 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('maps session, weekly, credits, and extra limits', () => { + const snapshot = parseUsage(apiUsage(), 'pat'); + expect(snapshot).toMatchObject({ source: 'pat', creditsRemaining: 12.5 }); + expect(snapshot.windows).toEqual([ + expect.objectContaining({ id: 'session', durationMinutes: 300 }), + expect.objectContaining({ id: 'weekly', durationMinutes: 10080 }), + expect.objectContaining({ id: 'reviews:primary', durationMinutes: 60 }) ]); }); - 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(); + const snapshot = parseUsage({ credits: {} }, 'oauth'); + expect(snapshot.windows).toEqual([]); expect(snapshot.creditsRemaining).toBeNull(); }); }); @@ -95,7 +80,8 @@ describe('tiered Codex probing', () => { 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' } }); + expect(result).toMatchObject({ provider: 'codex', available: 'yes', creditsRemaining: 12.5, authenticated: true }); + expect(result.windows.map(window => window.id)).toEqual(['session', 'weekly', 'reviews:primary']); }); it('selects a fresh OAuth token without calling whoami', async () => { @@ -108,7 +94,7 @@ describe('tiered Codex probing', () => { }); 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'); + expect(result.available).toBe('yes'); }); it.each([ @@ -123,7 +109,7 @@ describe('tiered Codex probing', () => { })); const result = await probeCodexCapacity({ ...context, readFile, fetch, rpc, now: () => new Date(checkedAt) }); expect(rpc).toHaveBeenCalledOnce(); - expect(result.usage?.source).toBe('cli'); + expect(result.windows).toHaveLength(1); }); it('falls back to CLI if PAT requests fail', async () => { @@ -154,7 +140,7 @@ describe('tiered Codex probing', () => { now: () => new Date(checkedAt) }); expect(fetch).toHaveBeenCalledTimes(2); - expect(result.usage?.source).toBe('oauth'); + expect(result.available).toBe('yes'); expect(rpc).not.toHaveBeenCalled(); }); @@ -178,7 +164,7 @@ describe('tiered Codex probing', () => { readFile: async () => '{}', rpc: async () => ({ rateLimits: {}, account: { account: null } }) }); - expect(result).toMatchObject({ authenticated: false, status: 'unauthenticated', available: 'unknown' }); + expect(result).toMatchObject({ authenticated: false, available: 'unknown' }); }); it('never exposes tokens or raw auth content through failures', async () => { diff --git a/packages/agent-manager/src/__tests__/capacity/index.test.ts b/packages/agent-manager/src/__tests__/capacity/index.test.ts index 1aeb7964..d0ece7ce 100644 --- a/packages/agent-manager/src/__tests__/capacity/index.test.ts +++ b/packages/agent-manager/src/__tests__/capacity/index.test.ts @@ -4,40 +4,34 @@ import { getCodexCapacityReport } from '../../capacity/index.js'; const checkedAt = '2026-08-09T10:00:00.000Z'; describe('getCodexCapacityReport', () => { - it('detects Codex configuration and installation before probing', async () => { + it('checks Codex installation before probing', async () => { const probe = vi.fn(async context => ({ - provider: 'codex', agentType: 'codex', ...context, - authenticated: true, status: 'supported' as const, available: 'yes' as const, - plan: 'pro', source: 'provider-cli' as const, windows: [], - aliases: { dailyWindowId: null, weeklyWindowId: null }, warnings: [] + provider: 'codex', generatedAt: context.checkedAt, + authenticated: true, available: 'yes' as const, windows: [], creditsRemaining: null })); const report = await getCodexCapacityReport({ now: () => new Date(checkedAt), - homeDir: '/users/test', path: '/usr/bin:/opt/bin', - exists: async target => target === '/users/test/.codex', access: async target => { if (target !== '/opt/bin/codex') throw new Error('missing'); }, probe }); - expect(probe).toHaveBeenCalledWith({ configured: true, installed: true, checkedAt }); - expect(report).toMatchObject({ schemaVersion: 1, generatedAt: checkedAt }); - expect(report.providers).toHaveLength(1); + expect(probe).toHaveBeenCalledWith({ installed: true, checkedAt }); + expect(report).toMatchObject({ provider: 'codex', generatedAt: checkedAt, available: 'yes' }); }); it('redacts unexpected probe failures into a stable unknown result', async () => { const report = await getCodexCapacityReport({ now: () => new Date(checkedAt), path: '', - exists: async () => false, probe: async () => { throw new Error('private provider response'); } }); - expect(report.providers[0]).toMatchObject({ - provider: 'codex', status: 'unavailable', available: 'unknown', configured: false, installed: false + expect(report).toMatchObject({ + provider: 'codex', available: 'unknown', authenticated: null, windows: [] }); expect(JSON.stringify(report)).not.toContain('private provider response'); }); diff --git a/packages/agent-manager/src/capacity/codex.ts b/packages/agent-manager/src/capacity/codex.ts index 641436b2..47675afe 100644 --- a/packages/agent-manager/src/capacity/codex.ts +++ b/packages/agent-manager/src/capacity/codex.ts @@ -1,12 +1,10 @@ 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'; +import type { CapacityReport, CapacityWindow } from './types.js'; + +type CodexUsageSource = 'pat' | 'oauth' | 'cli'; +type UsageSnapshot = { windows: CapacityWindow[]; creditsRemaining: number | null; source: CodexUsageSource }; type UnknownRecord = Record; type RpcMessage = { id?: number; method: string; params?: UnknownRecord }; @@ -16,7 +14,6 @@ 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; @@ -55,18 +52,6 @@ function safeIdentifier(value: unknown): string | 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'); @@ -75,8 +60,7 @@ export function resolveCodexAuthPath(env: NodeJS.ProcessEnv = process.env): stri export function toRateWindow( value: unknown, id: string, - label: string, - scope: string | null = null + label: string ): CapacityWindow | null { const input = record(value); if (!input) return null; @@ -87,9 +71,7 @@ export function toRateWindow( 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 + resetsAt: resetTime(input.reset_at) }; } @@ -101,42 +83,36 @@ function extraWindows(value: unknown): CapacityWindow[] { 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) + toRateWindow(windows.primary_window, `${scope}:primary`, `${scope} primary`), + toRateWindow(windows.secondary_window, `${scope}:secondary`, `${scope} secondary`) ].filter((window): window is CapacityWindow => window !== null); }); } -export function parseUsage(raw: unknown, source: Exclude, updatedAt: string): UsageSnapshot { +export function parseUsage(raw: unknown, source: 'pat' | 'oauth'): 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'), + windows: [ + toRateWindow(limits.primary_window, 'session', 'Session'), + toRateWindow(limits.secondary_window, 'weekly', 'Weekly'), + ...extraWindows(response.additional_rate_limits) + ].filter((window): window is CapacityWindow => window !== null), 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 + source }; } -function cliWindow(value: unknown, id: string, label: string, scope: string | null): CapacityWindow | null { +function cliWindow(value: unknown, id: string, label: string): 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 + usedPercent: finiteNumber(input.usedPercent), + resetsAt: resetTime(input.resetsAt) }; } @@ -144,75 +120,39 @@ 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) + cliWindow(snapshot.primary, `${scope}:primary`, `${scope} primary`), + cliWindow(snapshot.secondary, `${scope}:secondary`, `${scope} secondary`) ].filter((item): item is CapacityWindow => item !== null); } -export function parseCliUsage(raw: unknown, updatedAt: string): UsageSnapshot { +export function parseCliUsage(raw: unknown): UsageSnapshot { const response = record(raw) ?? {}; const primary = record(response.rateLimits); - const windows = cliSnapshotWindows(primary, 'codex'); + const windows = primary ? 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; + return { windows: unique, creditsRemaining: null, source: 'cli' }; } -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); +function capacityFromSnapshot(snapshot: UsageSnapshot, context: CodexProbeOptions, raw?: unknown): CapacityReport { + const hasUsage = snapshot.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, + generatedAt: context.checkedAt, 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.' - }] + windows: snapshot.windows, + creditsRemaining: snapshot.creditsRemaining ?? finiteNumber(resetCredits?.availableCount) }; } -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; @@ -256,7 +196,7 @@ async function apiSnapshot( 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); + return parseUsage(raw, source); } function appServerRpc(messages: RpcMessage[], timeoutMs = 5000): Promise { @@ -305,22 +245,19 @@ function appServerRpc(messages: RpcMessage[], timeoutMs = 5000): Promise { - if (!options.installed) return unavailable(options, false); +async function cliFallback(options: CodexProbeOptions): Promise { + if (!options.installed) return unavailable(options); const messages: RpcMessage[] = [ { id: 1, method: 'initialize', params: { clientInfo: { name: 'ai-devkit', title: null, version: '1' }, capabilities: null @@ -332,11 +269,10 @@ async function cliFallback(options: CodexProbeOptions): Promise appServerRpc(requests, options.timeoutMs)); const response = await rpc(messages); - const result = capacityFromSnapshot(parseCliUsage(response.rateLimits, options.checkedAt), options, response.rateLimits); + const result = capacityFromSnapshot(parseCliUsage(response.rateLimits), 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; @@ -345,7 +281,7 @@ async function cliFallback(options: CodexProbeOptions): Promise { +export async function probeCodexCapacity(options: CodexProbeOptions): Promise { let parsed: UnknownRecord | null = null; try { const contents = await (options.readFile ?? readFile)(resolveCodexAuthPath(options.env), 'utf8'); diff --git a/packages/agent-manager/src/capacity/index.ts b/packages/agent-manager/src/capacity/index.ts index 28a02856..5ac73554 100644 --- a/packages/agent-manager/src/capacity/index.ts +++ b/packages/agent-manager/src/capacity/index.ts @@ -1,17 +1,14 @@ import { constants } from 'node:fs'; import { access as fsAccess } from 'node:fs/promises'; -import { homedir } from 'node:os'; import path from 'node:path'; import { probeCodexCapacity } from './codex.js'; -import type { CapacityReport, ProviderCapacity } from './types.js'; +import type { CapacityReport } from './types.js'; -export type { CapacityReport, CapacityWindow, ProviderCapacity, UsageSnapshot } from './types.js'; +export type { CapacityReport, CapacityWindow } from './types.js'; export type CapacityProbeOptions = { now?: () => Date; - homeDir?: string; path?: string; - exists?: (target: string) => Promise; access?: (target: string) => Promise; probe?: typeof probeCodexCapacity; }; @@ -42,43 +39,19 @@ async function isCodexInstalled(pathValue: string, checkAccess?: (target: string return false; } -function failedCapacity(configured: boolean, installed: boolean, checkedAt: string): ProviderCapacity { - return { - provider: 'codex', - agentType: 'codex', - configured, - installed, - authenticated: null, - status: installed ? 'unknown' : 'unavailable', - available: 'unknown', - plan: null, - 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 } } : {}) - }; -} - export async function getCodexCapacityReport(options: CapacityProbeOptions = {}): Promise { - const now = options.now?.() ?? new Date(); - const checkedAt = now.toISOString(); - const home = options.homeDir ?? homedir(); - const exists = options.exists ?? (target => canAccess(target, constants.F_OK)); - const configured = await exists(path.join(home, '.codex')); + const generatedAt = (options.now?.() ?? new Date()).toISOString(); const installed = await isCodexInstalled(options.path ?? process.env.PATH ?? '', options.access); - - let capacity: ProviderCapacity; try { - capacity = await (options.probe ?? probeCodexCapacity)({ configured, installed, checkedAt }); + return await (options.probe ?? probeCodexCapacity)({ installed, checkedAt: generatedAt }); } catch { - capacity = failedCapacity(configured, installed, checkedAt); + return { + provider: 'codex', + generatedAt, + authenticated: null, + available: 'unknown', + windows: [], + creditsRemaining: null + }; } - - return { schemaVersion: 1, generatedAt: checkedAt, providers: [capacity] }; } diff --git a/packages/agent-manager/src/capacity/types.ts b/packages/agent-manager/src/capacity/types.ts index 42b621fc..f5a2cc3b 100644 --- a/packages/agent-manager/src/capacity/types.ts +++ b/packages/agent-manager/src/capacity/types.ts @@ -1,50 +1,18 @@ -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 { +export interface CapacityReport { provider: string; - agentType: string | null; - configured: boolean; - installed: boolean; + generatedAt: string; 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[]; + creditsRemaining: number | null; } diff --git a/packages/agent-manager/src/index.ts b/packages/agent-manager/src/index.ts index df6dd0a5..eccf4441 100644 --- a/packages/agent-manager/src/index.ts +++ b/packages/agent-manager/src/index.ts @@ -4,8 +4,6 @@ export type { CapacityProbeOptions, CapacityReport, CapacityWindow, - ProviderCapacity, - UsageSnapshot, } from './capacity/index.js'; export { ClaudeCodeAdapter } from './adapters/ClaudeCodeAdapter.js'; diff --git a/packages/cli/src/__tests__/commands/capacity/command.test.ts b/packages/cli/src/__tests__/commands/capacity/command.test.ts index ee551039..28a1e96f 100644 --- a/packages/cli/src/__tests__/commands/capacity/command.test.ts +++ b/packages/cli/src/__tests__/commands/capacity/command.test.ts @@ -10,22 +10,17 @@ vi.mock('../../../util/terminal-ui.js', () => ({ })); const report: CapacityReport = { - schemaVersion: 1, + provider: 'codex', 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.' }] - }] + authenticated: true, + available: 'yes', + windows: [ + { id: 'session', label: 'Session', durationMinutes: 300, usedPercent: 20, + resetsAt: '2026-08-09T12:00:00.000Z' }, + { id: 'weekly', label: 'Weekly', durationMinutes: 10080, usedPercent: 60, + resetsAt: '2026-08-16T10:00:00.000Z' } + ], + creditsRemaining: 1, }; describe('capacity command', () => { @@ -38,18 +33,16 @@ describe('capacity command', () => { log.mockRestore(); }); - it('renders the table, windows, credits, and warnings through the shared terminal UI', () => { + it('renders the table through the shared terminal UI', () => { renderCapacityReport(report); expect(ui.text).toHaveBeenCalledWith('Capacity:', { breakline: true }); expect(ui.table).toHaveBeenCalledWith(expect.objectContaining({ - headers: ['Provider', 'Auth', 'Available', 'Short window', 'Long window', 'Reset credits'], + headers: ['Provider', 'Auth', 'Available', 'Short window', 'Long window', 'Credits'], rows: [[ 'codex', 'yes', 'yes', '80% left · resets 2026-08-09T12:00:00.000Z', '40% left · resets 2026-08-16T10:00:00.000Z', '1', ]], })); - expect(ui.warning).toHaveBeenCalledWith('1 warning(s):'); - expect(ui.text).toHaveBeenCalledWith(' codex: A safe normalized warning.'); }); it('wires the Codex-only command surface', async () => { diff --git a/packages/cli/src/commands/capacity/render.ts b/packages/cli/src/commands/capacity/render.ts index 0545acfc..a2b973ce 100644 --- a/packages/cli/src/commands/capacity/render.ts +++ b/packages/cli/src/commands/capacity/render.ts @@ -7,9 +7,10 @@ function authLabel(value: boolean | null): string { } function formatWindow(window: CapacityWindow | undefined): string { - if (!window || window.remainingPercent === null) return 'unknown'; + if (!window || window.usedPercent === null) return 'unknown'; + const remaining = Math.max(0, Math.min(100, 100 - window.usedPercent)); const reset = window.resetsAt ? ` · resets ${window.resetsAt}` : ''; - return `${window.remainingPercent}% left${reset}`; + return `${remaining}% left${reset}`; } function windowPair(windows: CapacityWindow[]): [CapacityWindow | undefined, CapacityWindow | undefined] { @@ -27,22 +28,18 @@ export function renderCapacityReport(report: CapacityReport, options: { json?: b ui.text('Capacity:', { breakline: true }); - const rows = report.providers.map(provider => { - const [shortWindow, longWindow] = windowPair(provider.windows); - return [ - provider.provider, - authLabel(provider.authenticated), - provider.available, + const [shortWindow, longWindow] = windowPair(report.windows); + ui.table({ + headers: ['Provider', 'Auth', 'Available', 'Short window', 'Long window', 'Credits'], + rows: [[ + report.provider, + authLabel(report.authenticated), + report.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, + report.creditsRemaining === null || report.creditsRemaining === undefined + ? '—' : String(report.creditsRemaining), + ]], maxWidth: process.stdout.columns ?? 120, columnStyles: [ (text) => chalk.cyan(text), @@ -53,13 +50,4 @@ export function renderCapacityReport(report: CapacityReport, options: { json?: b (text) => chalk.dim(text), ], }); - - const warnings = report.providers.flatMap(provider => - provider.warnings.map(warning => `${provider.provider}: ${warning.message}`) - ); - if (warnings.length > 0) { - ui.breakline(); - ui.warning(`${warnings.length} warning(s):`); - for (const warning of warnings) ui.text(` ${warning}`); - } }