From 99e34013d13a71c2aef1958fd5ab44fa9bfc75dd Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:13:06 +0200 Subject: [PATCH] feat: auth print (#7168) --- packages/ai/src/auth/resolve.ts | 32 +++- packages/ai/test/models-runtime.test.ts | 50 +++++- packages/ai/test/oauth-auth.test.ts | 6 +- packages/coding-agent/src/cli/args.ts | 9 +- .../coding-agent/src/cli/credential-print.ts | 152 ++++++++++++++++++ .../coding-agent/src/core/model-runtime.ts | 2 + packages/coding-agent/src/main.ts | 54 ++++++- .../test/credential-print.test.ts | 104 ++++++++++++ 8 files changed, 393 insertions(+), 16 deletions(-) create mode 100644 packages/coding-agent/src/cli/credential-print.ts create mode 100644 packages/coding-agent/test/credential-print.test.ts diff --git a/packages/ai/src/auth/resolve.ts b/packages/ai/src/auth/resolve.ts index 26536dbba..678332a8a 100644 --- a/packages/ai/src/auth/resolve.ts +++ b/packages/ai/src/auth/resolve.ts @@ -17,6 +17,8 @@ export type ModelsErrorCode = "model_source" | "model_validation" | "provider" | export interface AuthResolutionOverrides { apiKey?: string; env?: ProviderEnv; + /** Require this much remaining OAuth-token validity; defaults to five minutes. */ + minOAuthValidityMs?: number; } export class ModelsError extends Error { @@ -62,7 +64,13 @@ export async function resolveProviderAuth( const stored = await readCredential(credentials, provider.id); if (stored) { if (stored.type === "oauth" && provider.auth.oauth) { - return resolveStoredOAuth(credentials, provider.id, provider.auth.oauth, stored); + return resolveStoredOAuth( + credentials, + provider.id, + provider.auth.oauth, + stored, + overrides?.minOAuthValidityMs, + ); } if (stored.type === "api_key" && provider.auth.apiKey) { const credential = overrides?.env ? { ...stored, env: { ...stored.env, ...overrides.env } } : stored; @@ -84,27 +92,31 @@ function overlayEnvAuthContext(base: AuthContext, env: ProviderEnv): AuthContext }; } +const DEFAULT_OAUTH_MINIMUM_VALIDITY_MS = 5 * 60 * 1000; + /** - * OAuth resolution with double-checked locking (same pattern as today's - * AuthStorage): valid tokens cost zero locks; expired tokens lock, re-check - * expiry under the lock, refresh once globally, and persist the rotated - * credential before release. + * OAuth resolution with double-checked locking: tokens with less than five + * minutes remaining lock, re-check expiry under the lock, refresh once + * globally, and persist the rotated credential before release. */ async function resolveStoredOAuth( credentials: CredentialStore, providerId: string, oauth: OAuthAuth, stored: OAuthCredential, + minOAuthValidityMs?: number, ): Promise { + const minimumValidityMs = Math.max(DEFAULT_OAUTH_MINIMUM_VALIDITY_MS, minOAuthValidityMs ?? 0); + const expiresSoon = (credential: OAuthCredential) => Date.now() + minimumValidityMs >= credential.expires; let credential = stored; - if (Date.now() >= credential.expires) { + if (expiresSoon(credential)) { // Optimistic check said expired; the authoritative check runs under the lock. let post: Credential | undefined; try { post = await credentials.modify(providerId, async (current) => { if (current?.type !== "oauth") return undefined; // logged out meanwhile - if (Date.now() < current.expires) return undefined; // another process/request refreshed + if (!expiresSoon(current)) return undefined; // another process/request refreshed try { return await oauth.refresh(current); } catch (error) { @@ -117,6 +129,12 @@ async function resolveStoredOAuth( } if (post?.type !== "oauth") return undefined; // logged out meanwhile credential = post; + // The normal five-minute window triggers a refresh but does not impose a + // provider contract. Explicit callers (such as bearer-token export) do + // require the requested minimum after the refresh. + if (minOAuthValidityMs !== undefined && expiresSoon(credential)) { + throw new ModelsError("oauth", `OAuth refresh returned a token that expires too soon for ${providerId}`); + } } try { diff --git a/packages/ai/test/models-runtime.test.ts b/packages/ai/test/models-runtime.test.ts index e738fd8f4..f582b80e2 100644 --- a/packages/ai/test/models-runtime.test.ts +++ b/packages/ai/test/models-runtime.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { InMemoryCredentialStore } from "../src/auth/credential-store.ts"; import type { ApiKeyAuth, CredentialStore, OAuthAuth, ProviderAuth } from "../src/auth/types.ts"; import { calculateCost, createModels, createProvider, hasApi, type Provider } from "../src/models.ts"; @@ -382,7 +382,7 @@ describe("Models runtime", () => { type: "oauth", access: "oauth-token", refresh: "r", - expires: Date.now() + 100000, + expires: Date.now() + 10 * 60_000, })); const resolution = await models.getAuth(model.provider); expect(resolution?.auth.apiKey).toBe("oauth-token"); @@ -460,7 +460,7 @@ describe("Models runtime", () => { it("refreshes expired oauth credentials and persists the rotated credential", async () => { const credentials = new InMemoryCredentialStore(); const oauth = testOAuth({ - refresh: async (credential) => ({ ...credential, access: "new-token", expires: Date.now() + 60_000 }), + refresh: async (credential) => ({ ...credential, access: "new-token", expires: Date.now() + 60 * 60_000 }), }); const models = createModels({ credentials }); models.setProvider(testProvider({ id: "p1", auth: { oauth } })); @@ -476,6 +476,46 @@ describe("Models runtime", () => { expect(((await credentials.read("p1")) as { access: string }).access).toBe("new-token"); }); + it("refreshes oauth credentials with less than five minutes remaining", async () => { + const credentials = new InMemoryCredentialStore(); + const refresh = vi.fn(async (credential) => ({ + ...credential, + access: "new-token", + expires: Date.now() + 60 * 60_000, + })); + const models = createModels({ credentials }); + models.setProvider(testProvider({ id: "p1", auth: { oauth: testOAuth({ refresh }) } })); + await credentials.modify("p1", async () => ({ + type: "oauth", + access: "old-token", + refresh: "r", + expires: Date.now() + 60_000, + })); + + expect((await models.getAuth("p1"))?.auth.apiKey).toBe("new-token"); + expect(refresh).toHaveBeenCalledOnce(); + }); + + it("honors a caller's longer OAuth minimum validity", async () => { + const credentials = new InMemoryCredentialStore(); + const refresh = vi.fn(async (credential) => ({ + ...credential, + access: "new-token", + expires: Date.now() + 60 * 60_000, + })); + const models = createModels({ credentials }); + models.setProvider(testProvider({ id: "p1", auth: { oauth: testOAuth({ refresh }) } })); + await credentials.modify("p1", async () => ({ + type: "oauth", + access: "old-token", + refresh: "r", + expires: Date.now() + 10 * 60_000, + })); + + expect((await models.getAuth("p1", { minOAuthValidityMs: 30 * 60_000 }))?.auth.apiKey).toBe("new-token"); + expect(refresh).toHaveBeenCalledOnce(); + }); + it("rejects with code oauth when refresh fails, preserving the stored credential", async () => { const credentials = new InMemoryCredentialStore(); const oauth = testOAuth({ @@ -501,7 +541,7 @@ describe("Models runtime", () => { refresh: async () => { refreshes++; await new Promise((resolve) => setTimeout(resolve, 10)); - return { type: "oauth", access: `new-${refreshes}`, refresh: "r2", expires: Date.now() + 60_000 }; + return { type: "oauth", access: `new-${refreshes}`, refresh: "r2", expires: Date.now() + 60 * 60_000 }; }, }); const models = createModels({ credentials }); @@ -530,7 +570,7 @@ describe("Models runtime", () => { type: "oauth", access: "valid", refresh: "r", - expires: Date.now() + 60_000, + expires: Date.now() + 10 * 60_000, })); const models = createModels({ credentials }); models.setProvider(testProvider({ id: "p1", auth: { oauth: testOAuth() } })); diff --git a/packages/ai/test/oauth-auth.test.ts b/packages/ai/test/oauth-auth.test.ts index 712f7cb99..0ba080886 100644 --- a/packages/ai/test/oauth-auth.test.ts +++ b/packages/ai/test/oauth-auth.test.ts @@ -117,7 +117,8 @@ describe("OAuth through Models.getAuth (lazy load chain)", () => { type: "oauth", access: "oauth-access-token", refresh: "r", - expires: Date.now() + 60_000, + // Keep this beyond getAuth()'s refresh window. + expires: Date.now() + 10 * 60_000, })); const models = createModels({ credentials }); models.setProvider(anthropicProvider()); @@ -135,7 +136,8 @@ describe("OAuth through Models.getAuth (lazy load chain)", () => { type: "oauth", access, refresh: "r", - expires: Date.now() + 60_000, + // Keep this beyond getAuth()'s refresh window. + expires: Date.now() + 10 * 60_000, })); const models = createModels({ credentials }); models.setProvider(githubCopilotProvider()); diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 1e9b36b4d..ae70dc409 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -232,7 +232,8 @@ ${chalk.bold("Commands:")} ${APP_NAME} update [source|self|pi] Update pi, extensions, or model catalogs ${APP_NAME} list List installed extensions from settings ${APP_NAME} config [-l] Open TUI to enable/disable package resources (Tab switches scope) - ${APP_NAME} --help Show help for install/remove/uninstall/update/list/config + ${APP_NAME} auth Print credentials for external clients + ${APP_NAME} --help Show help for install/remove/uninstall/update/list/config/auth ${chalk.bold("Options:")} --provider Provider name (default: google) @@ -280,6 +281,12 @@ ${chalk.bold("Options:")} Extensions can register additional flags (e.g., --plan from plan-mode extension).${extensionFlagsText} ${chalk.bold("Examples:")} + # Print a provider API key for an external client + ${APP_NAME} auth print-api-key --provider openai --model gpt-5.5 + + # Print an OAuth bearer token for an external client (refreshes if expired) + ${APP_NAME} auth print-bearer-token --provider openai-codex --model gpt-5.5 + # Interactive mode ${APP_NAME} diff --git a/packages/coding-agent/src/cli/credential-print.ts b/packages/coding-agent/src/cli/credential-print.ts new file mode 100644 index 000000000..e26287100 --- /dev/null +++ b/packages/coding-agent/src/cli/credential-print.ts @@ -0,0 +1,152 @@ +import type { Api, CredentialInfo, Model } from "@earendil-works/pi-ai"; +import { resolveCliModel } from "../core/model-resolver.ts"; +import type { ModelRuntime } from "../core/model-runtime.ts"; +import type { Args } from "./args.ts"; + +export type CredentialPrintKind = "api_key" | "bearer_token"; + +const DEFAULT_BEARER_TOKEN_MIN_EXPIRY_MS = 30 * 60_000; + +export interface CredentialPrintCommand { + kind: CredentialPrintKind; + args: string[]; + minExpiryMs?: number; +} + +export class CredentialPrintError extends Error {} + +export function isCredentialPrintHelp(args: string[]): boolean { + return ( + args[0] === "auth" && (args[1] === undefined || args[1] === "help" || args[1] === "--help" || args[1] === "-h") + ); +} + +export function printCredentialPrintHelp(): void { + console.log(`Usage: + pi auth print-api-key --model [--provider ] + pi auth print-bearer-token --model [--provider ] [--min-expiry ] + +Prints the configured credential alone on stdout. Provider inference uses configured credentials; specify --provider to select explicitly. Bearer tokens have a 30-minute minimum expiry by default. --min-expiry accepts ms, s, m, or h (for example, 30m).`); +} + +/** Parse the small, extensible `pi auth` command surface before normal startup. */ +export function parseCredentialPrintCommand(args: string[]): CredentialPrintCommand | undefined { + if (args[0] !== "auth") return undefined; + + const kind = args[1] === "print-api-key" ? "api_key" : args[1] === "print-bearer-token" ? "bearer_token" : undefined; + if (!kind) { + throw new CredentialPrintError( + `Unknown auth command "${args[1] ?? ""}". Use "pi auth print-api-key" or "pi auth print-bearer-token".`, + ); + } + + const commandArgs: string[] = []; + let minExpiryMs: number | undefined; + for (let index = 2; index < args.length; index++) { + if (args[index] !== "--min-expiry") { + commandArgs.push(args[index]); + continue; + } + if (kind !== "bearer_token") { + throw new CredentialPrintError("--min-expiry is only supported by print-bearer-token"); + } + const value = args[++index]; + const match = value ? /^(\d+)(ms|s|m|h)$/iu.exec(value) : undefined; + if (!match) { + throw new CredentialPrintError("--min-expiry must use a duration such as 30m or 1h"); + } + const amount = Number(match[1]); + const unit = match[2]; + minExpiryMs = amount * (unit === "ms" ? 1 : unit === "s" ? 1_000 : unit === "m" ? 60_000 : 3_600_000); + } + + return minExpiryMs === undefined ? { kind, args: commandArgs } : { kind, args: commandArgs, minExpiryMs }; +} + +export function validateCredentialPrintArgs(args: Args): void { + if (!args.model?.trim()) { + throw new CredentialPrintError("Credential printing requires --model "); + } + if (args.apiKey !== undefined) { + throw new CredentialPrintError("Credential printing reads configured credentials; --api-key is not supported"); + } + if (args.messages.length > 0 || args.fileArgs.length > 0 || args.unknownFlags.size > 0) { + throw new CredentialPrintError("Credential printing only accepts --provider and --model"); + } +} + +/** + * Resolve one request credential for a specific provider/model pair. + * + * This intentionally calls ModelRuntime.getAuth(), which refreshes and persists + * OAuth credentials with less than five minutes remaining through the normal request-auth path. + */ +export async function resolveCredentialForPrint( + args: Args, + modelRuntime: ModelRuntime, + kind: CredentialPrintKind, + minExpiryMs?: number, +): Promise { + validateCredentialPrintArgs(args); + + const credentialTypes = new Map( + (await modelRuntime.listCredentials()).map((credential) => [credential.providerId, credential.type]), + ); + const models: Model[] = []; + if (args.provider) { + const resolved = resolveCliModel({ cliProvider: args.provider, cliModel: args.model, modelRuntime }); + if (resolved.error || !resolved.model) { + throw new CredentialPrintError(resolved.error ?? "Unable to resolve the requested provider/model"); + } + models.push(resolved.model); + } else { + for (const provider of modelRuntime.getProviders()) { + if (!credentialTypes.has(provider.id)) continue; + const resolved = resolveCliModel({ cliProvider: provider.id, cliModel: args.model, modelRuntime }); + if (resolved.model && !resolved.error && !resolved.warning?.includes("Using custom model id")) { + models.push(resolved.model); + } + } + if (models.length === 0) { + throw new CredentialPrintError(`Model "${args.model}" not found. Use --list-models to see available models.`); + } + } + + const credentials: Array<{ providerId: string; value: string }> = []; + for (const model of models) { + const type = credentialTypes.get(model.provider); + if (kind === "api_key" && type === "oauth") continue; + if (kind === "bearer_token" && type !== "oauth") continue; + + const auth = await modelRuntime.getAuth( + model, + kind === "bearer_token" + ? { minOAuthValidityMs: minExpiryMs ?? DEFAULT_BEARER_TOKEN_MIN_EXPIRY_MS } + : undefined, + ); + const authorization = Object.entries(auth?.auth.headers ?? {}).find( + ([name]) => name.toLowerCase() === "authorization", + )?.[1]; + const bearerToken = typeof authorization === "string" ? /^Bearer\s+(.+)$/iu.exec(authorization)?.[1] : undefined; + const value = kind === "bearer_token" ? (auth?.auth.apiKey ?? bearerToken) : auth?.auth.apiKey; + if (value) credentials.push({ providerId: model.provider, value }); + } + + if (credentials.length === 1) return credentials[0].value; + if (credentials.length === 0) { + const providerId = models[0]?.provider; + const type = providerId ? credentialTypes.get(providerId) : undefined; + if (args.provider && kind === "api_key" && type === "oauth") { + throw new CredentialPrintError(`Provider "${providerId}" is configured with OAuth, not an API key`); + } + if (args.provider && kind === "bearer_token" && type !== "oauth") { + throw new CredentialPrintError(`Provider "${providerId}" is not configured with an OAuth bearer token`); + } + throw new CredentialPrintError( + `No usable ${kind === "api_key" ? "API key" : "OAuth bearer token"} is configured`, + ); + } + throw new CredentialPrintError( + `Model "${args.model}" has multiple configured providers (${credentials.map(({ providerId }) => providerId).join(", ")}). Specify --provider.`, + ); +} diff --git a/packages/coding-agent/src/core/model-runtime.ts b/packages/coding-agent/src/core/model-runtime.ts index e4575b07f..59f265738 100644 --- a/packages/coding-agent/src/core/model-runtime.ts +++ b/packages/coding-agent/src/core/model-runtime.ts @@ -72,6 +72,8 @@ export interface CreateModelRuntimeOptions { export interface ModelRuntimeAuthOverrides { apiKey?: string; env?: Record; + /** Require this much remaining OAuth-token validity; defaults to five minutes. */ + minOAuthValidityMs?: number; } function mergeHeaders( diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index ce7465f1a..a9c3e4680 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -9,6 +9,15 @@ import { createInterface } from "node:readline"; import { type ImageContent, modelsAreEqual } from "@earendil-works/pi-ai"; import chalk from "chalk"; import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.ts"; +import { + type CredentialPrintCommand, + CredentialPrintError, + isCredentialPrintHelp, + parseCredentialPrintCommand, + printCredentialPrintHelp, + resolveCredentialForPrint, + validateCredentialPrintArgs, +} from "./cli/credential-print.ts"; import { processFileArguments } from "./cli/file-processor.ts"; import { buildInitialMessage } from "./cli/initial-message.ts"; import { listModels } from "./cli/list-models.ts"; @@ -27,7 +36,7 @@ import { exportFromFile } from "./core/export-html/index.ts"; import type { InlineExtension } from "./core/extensions/types.ts"; import { applyHttpProxySettings, configureHttpDispatcher } from "./core/http-dispatcher.ts"; import { resolveCliModel, resolveModelScope, type ScopedModel } from "./core/model-resolver.ts"; -import type { ModelRuntime } from "./core/model-runtime.ts"; +import { ModelRuntime } from "./core/model-runtime.ts"; import { restoreStdout, takeOverStdout } from "./core/output-guard.ts"; import { type AppMode, resolveProjectTrusted } from "./core/project-trust.ts"; import type { CreateAgentSessionOptions } from "./core/sdk.ts"; @@ -118,6 +127,45 @@ function isPlainRuntimeMetadataCommand(parsed: Args): boolean { return !parsed.print && parsed.mode === undefined && (parsed.help === true || parsed.listModels !== undefined); } +async function runCredentialPrintCommand(args: string[]): Promise { + if (isCredentialPrintHelp(args)) { + printCredentialPrintHelp(); + return true; + } + + let command: CredentialPrintCommand | undefined; + try { + command = parseCredentialPrintCommand(args); + } catch (error) { + const message = error instanceof CredentialPrintError ? error.message : "Failed to parse auth command"; + console.error(chalk.red(`Error: ${message}`)); + process.exitCode = 1; + return true; + } + if (!command) return false; + + const parsed = parseArgs(command.args); + if (parsed.diagnostics.length > 0) { + for (const diagnostic of parsed.diagnostics) { + console.error(chalk.red(`Error: ${diagnostic.message}`)); + } + process.exitCode = 1; + return true; + } + + try { + validateCredentialPrintArgs(parsed); + const modelRuntime = await ModelRuntime.create({ allowModelNetwork: false }); + const credential = await resolveCredentialForPrint(parsed, modelRuntime, command.kind, command.minExpiryMs); + process.stdout.write(`${credential}\n`); + } catch (error) { + const message = error instanceof CredentialPrintError ? error.message : "Failed to resolve credential"; + console.error(chalk.red(`Error: ${message}`)); + process.exitCode = 1; + } + return true; +} + async function prepareInitialMessage( parsed: Args, autoResizeImages: boolean, @@ -506,6 +554,10 @@ export async function main(args: string[], options?: MainOptions) { return; } + if (await runCredentialPrintCommand(args)) { + return; + } + const parsed = parseArgs(args); if (parsed.diagnostics.length > 0) { for (const d of parsed.diagnostics) { diff --git a/packages/coding-agent/test/credential-print.test.ts b/packages/coding-agent/test/credential-print.test.ts new file mode 100644 index 000000000..be17a1b3e --- /dev/null +++ b/packages/coding-agent/test/credential-print.test.ts @@ -0,0 +1,104 @@ +import { InMemoryModelsStore } from "@earendil-works/pi-ai"; +import { describe, expect, test, vi } from "vitest"; +import { parseArgs } from "../src/cli/args.ts"; +import { + CredentialPrintError, + isCredentialPrintHelp, + parseCredentialPrintCommand, + resolveCredentialForPrint, +} from "../src/cli/credential-print.ts"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { ModelRuntime } from "../src/core/model-runtime.ts"; + +async function createRuntime(credentials: AuthStorage): Promise { + return ModelRuntime.create({ + credentials, + modelsPath: null, + modelsStore: new InMemoryModelsStore(), + allowModelNetwork: false, + }); +} + +describe("credential print commands", () => { + test("prints a resolved API key", async () => { + const runtime = await createRuntime(AuthStorage.inMemory({ openai: { type: "api_key", key: "test-api-key" } })); + const args = parseArgs(["--model", "gpt-5.5"]); + + await expect(resolveCredentialForPrint(args, runtime, "api_key")).resolves.toBe("test-api-key"); + }); + + test("prints bearer tokens resolved from an Authorization header", async () => { + const runtime = await createRuntime( + AuthStorage.inMemory({ + "kimi-coding": { + type: "oauth", + access: "header-test-token", + refresh: "test-refresh-token", + expires: Date.now() + 60 * 60 * 1000, + }, + }), + ); + const args = parseArgs(["--provider", "kimi-coding", "--model", "kimi-for-coding"]); + + await expect(resolveCredentialForPrint(args, runtime, "bearer_token")).resolves.toBe("header-test-token"); + }); + + test("refreshes an expired OAuth token before printing it", async () => { + const storage = AuthStorage.inMemory({ + "openai-codex": { + type: "oauth", + access: "old-test-token", + refresh: "test-refresh-token", + expires: 0, + }, + }); + const runtime = await createRuntime(storage); + const refresh = vi.fn(async () => ({ + type: "oauth" as const, + access: "fresh-test-token", + refresh: "test-refresh-token", + expires: Date.now() + 60 * 60 * 1000, + })); + const oauth = runtime.getProvider("openai-codex")?.auth.oauth; + if (!oauth) throw new Error("OpenAI Codex OAuth provider is not registered"); + oauth.refresh = refresh; + const args = parseArgs(["--provider", "openai-codex", "--model", "gpt-5.5"]); + + await expect(resolveCredentialForPrint(args, runtime, "bearer_token")).resolves.toBe("fresh-test-token"); + expect(refresh).toHaveBeenCalledOnce(); + expect(await storage.read("openai-codex")).toMatchObject({ access: "fresh-test-token" }); + }); + + test("parses credential commands and rejects invalid arguments or credential types", async () => { + const runtime = await createRuntime( + AuthStorage.inMemory({ + "openai-codex": { + type: "oauth", + access: "test-token-not-to-be-printed", + refresh: "test-refresh-token", + expires: Date.now() + 60 * 60 * 1000, + }, + }), + ); + + expect(parseCredentialPrintCommand(["auth", "print-api-key", "--provider", "openai"])).toEqual({ + kind: "api_key", + args: ["--provider", "openai"], + }); + expect(parseCredentialPrintCommand(["auth", "print-bearer-token"])).toMatchObject({ kind: "bearer_token" }); + expect(parseCredentialPrintCommand(["auth", "print-bearer-token", "--min-expiry", "30m"])).toEqual({ + kind: "bearer_token", + args: [], + minExpiryMs: 30 * 60_000, + }); + expect(() => parseCredentialPrintCommand(["auth", "print-api-key", "--min-expiry", "30m"])).toThrow( + "only supported by print-bearer-token", + ); + expect(isCredentialPrintHelp(["auth", "--help"])).toBe(true); + expect(() => parseCredentialPrintCommand(["auth", "unknown"])).toThrow(CredentialPrintError); + await expect(resolveCredentialForPrint(parseArgs([]), runtime, "api_key")).rejects.toThrow("requires --model"); + await expect( + resolveCredentialForPrint(parseArgs(["--provider", "openai-codex", "--model", "gpt-5.5"]), runtime, "api_key"), + ).rejects.toThrow("configured with OAuth"); + }); +});