diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts new file mode 100644 index 00000000..a73b6d04 --- /dev/null +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -0,0 +1,145 @@ +/** + * Credential storage for API keys and OAuth tokens. + * Handles loading, saving, and refreshing credentials from auth.json. + */ + +import { getApiKeyFromEnv, getOAuthApiKey, type OAuthCredentials, type OAuthProvider } from "@mariozechner/pi-ai"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { dirname } from "path"; + +export type ApiKeyCredential = { + type: "api_key"; + key: string; +}; + +export type OAuthCredential = { + type: "oauth"; +} & OAuthCredentials; + +export type AuthCredential = ApiKeyCredential | OAuthCredential; + +export type AuthStorageData = Record; + +/** + * Credential storage backed by a JSON file. + */ +export class AuthStorage { + private data: AuthStorageData = {}; + + constructor(private authPath: string) { + this.reload(); + } + + /** + * Reload credentials from disk. + */ + reload(): void { + if (!existsSync(this.authPath)) { + this.data = {}; + return; + } + try { + this.data = JSON.parse(readFileSync(this.authPath, "utf-8")); + } catch { + this.data = {}; + } + } + + /** + * Save credentials to disk. + */ + private save(): void { + const dir = dirname(this.authPath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true, mode: 0o700 }); + } + writeFileSync(this.authPath, JSON.stringify(this.data, null, 2), "utf-8"); + chmodSync(this.authPath, 0o600); + } + + /** + * Get credential for a provider. + */ + get(provider: string): AuthCredential | null { + return this.data[provider] ?? null; + } + + /** + * Set credential for a provider. + */ + set(provider: string, credential: AuthCredential): void { + this.data[provider] = credential; + this.save(); + } + + /** + * Remove credential for a provider. + */ + remove(provider: string): void { + delete this.data[provider]; + this.save(); + } + + /** + * List all providers with credentials. + */ + list(): string[] { + return Object.keys(this.data); + } + + /** + * Check if credentials exist for a provider. + */ + has(provider: string): boolean { + return provider in this.data; + } + + /** + * Get all credentials (for passing to getOAuthApiKey). + */ + getAll(): AuthStorageData { + return { ...this.data }; + } + + /** + * Get API key for a provider. + * Priority: + * 1. API key from auth.json + * 2. OAuth token from auth.json (auto-refreshed) + * 3. Environment variable (via getApiKeyFromEnv) + */ + async getApiKey(provider: string): Promise { + const cred = this.data[provider]; + + if (cred?.type === "api_key") { + return cred.key; + } + + if (cred?.type === "oauth") { + // Build OAuthCredentials map (without type discriminator) + const oauthCreds: Record = {}; + for (const [key, value] of Object.entries(this.data)) { + if (value.type === "oauth") { + const { type: _, ...rest } = value; + oauthCreds[key] = rest; + } + } + + try { + const result = await getOAuthApiKey(provider as OAuthProvider, oauthCreds); + if (result) { + // Save refreshed credentials + this.data[provider] = { type: "oauth", ...result.newCredentials }; + this.save(); + return result.apiKey; + } + } catch { + // Token refresh failed, remove invalid credentials + this.remove(provider); + } + } + + // Fall back to environment variable + return getApiKeyFromEnv(provider) ?? null; + } +} diff --git a/packages/coding-agent/src/core/oauth/index.ts b/packages/coding-agent/src/core/oauth/index.ts deleted file mode 100644 index 3d841972..00000000 --- a/packages/coding-agent/src/core/oauth/index.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * OAuth management for coding-agent. - * Re-exports from @mariozechner/pi-ai and adds convenience wrappers. - */ - -import { - getOAuthApiKey, - listOAuthProviders as listOAuthProvidersFromAi, - loadOAuthCredentials, - loginAnthropic, - loginAntigravity, - loginGeminiCli, - loginGitHubCopilot, - type OAuthCredentials, - type OAuthProvider, - type OAuthStorageBackend, - refreshToken as refreshTokenFromAi, - removeOAuthCredentials, - resetOAuthStorage, - saveOAuthCredentials, - setOAuthStorage, -} from "@mariozechner/pi-ai"; - -// Re-export types and functions -export type { OAuthCredentials, OAuthProvider, OAuthStorageBackend }; -export { listOAuthProvidersFromAi as listOAuthProviders }; -export { - getOAuthApiKey, - loadOAuthCredentials, - removeOAuthCredentials, - resetOAuthStorage, - saveOAuthCredentials, - setOAuthStorage, -}; - -// Types for OAuth flow -export interface OAuthAuthInfo { - url: string; - instructions?: string; -} - -export interface OAuthPrompt { - message: string; - placeholder?: string; -} - -export type OAuthProviderInfo = { - id: OAuthProvider; - name: string; - description: string; - available: boolean; -}; - -export function getOAuthProviders(): OAuthProviderInfo[] { - return [ - { - id: "anthropic", - name: "Anthropic (Claude Pro/Max)", - description: "Use Claude with your Pro/Max subscription", - available: true, - }, - { - id: "github-copilot", - name: "GitHub Copilot", - description: "Use models via GitHub Copilot subscription", - available: true, - }, - { - id: "google-gemini-cli", - name: "Google Gemini CLI", - description: "Free Gemini 2.0/2.5 models via Google Cloud", - available: true, - }, - { - id: "google-antigravity", - name: "Antigravity", - description: "Free Gemini 3, Claude, GPT-OSS via Google Cloud", - available: true, - }, - ]; -} - -/** - * Login with OAuth provider - */ -export async function login( - provider: OAuthProvider, - onAuth: (info: OAuthAuthInfo) => void, - onPrompt: (prompt: OAuthPrompt) => Promise, - onProgress?: (message: string) => void, -): Promise { - switch (provider) { - case "anthropic": - await loginAnthropic( - (url) => onAuth({ url }), - async () => onPrompt({ message: "Paste the authorization code below:" }), - ); - break; - case "github-copilot": { - const creds = await loginGitHubCopilot({ - onAuth: (url, instructions) => onAuth({ url, instructions }), - onPrompt, - onProgress, - }); - saveOAuthCredentials("github-copilot", creds); - break; - } - case "google-gemini-cli": { - await loginGeminiCli((info) => onAuth({ url: info.url, instructions: info.instructions }), onProgress); - break; - } - case "google-antigravity": { - await loginAntigravity((info) => onAuth({ url: info.url, instructions: info.instructions }), onProgress); - break; - } - default: - throw new Error(`Unknown OAuth provider: ${provider}`); - } -} - -/** - * Logout from OAuth provider - */ -export async function logout(provider: OAuthProvider): Promise { - removeOAuthCredentials(provider); -} - -/** - * Refresh OAuth token for provider. - * Delegates to the ai package implementation. - */ -export async function refreshToken(provider: OAuthProvider): Promise { - return refreshTokenFromAi(provider); -} - -/** - * Get OAuth token for provider (auto-refreshes if expired). - */ -export async function getOAuthToken(provider: OAuthProvider): Promise { - return getOAuthApiKey(provider); -}