From 0ae23f19fed527d97230a8546db289adbbfd88da Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 25 Dec 2025 01:08:09 +0100 Subject: [PATCH] WIP: Add CLI for OAuth login, update README - Add src/cli.ts with login command for OAuth providers - Add bin entry to package.json for 'npx @mariozechner/pi-ai' - Update README: remove setApiKey docs, rewrite OAuth section - OAuth storage is caller's responsibility, not library's - Use getOAuthProviders() instead of duplicating provider list --- packages/ai/README.md | 121 +++++++++------------------ packages/ai/package.json | 3 + packages/ai/src/cli.ts | 173 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 216 insertions(+), 81 deletions(-) create mode 100644 packages/ai/src/cli.ts diff --git a/packages/ai/README.md b/packages/ai/README.md index 2a18ab3e..4cd3fec3 100644 --- a/packages/ai/README.md +++ b/packages/ai/README.md @@ -1100,59 +1100,51 @@ const response = await complete(model, context, { }); ``` -### Programmatic API Key Management - -You can also set and get API keys programmatically: +### Checking Environment Variables ```typescript -import { setApiKey, getApiKey } from '@mariozechner/pi-ai'; +import { getApiKeyFromEnv } from '@mariozechner/pi-ai'; -// Set API key for a provider -setApiKey('openai', 'sk-...'); -setApiKey('anthropic', 'sk-ant-...'); - -// Get API key for a provider (checks both programmatic and env vars) -const key = getApiKey('openai'); +// Check if an API key is set in environment variables +const key = getApiKeyFromEnv('openai'); // checks OPENAI_API_KEY ``` ## OAuth Providers -Several providers require OAuth authentication instead of static API keys. This library provides login flows, automatic token refresh, and credential storage for: +Several providers require OAuth authentication instead of static API keys: - **Anthropic** (Claude Pro/Max subscription) - **GitHub Copilot** (Copilot subscription) - **Google Gemini CLI** (Free Gemini 2.0/2.5 via Google Cloud Code Assist) - **Antigravity** (Free Gemini 3, Claude, GPT-OSS via Google Cloud) -Credentials are stored in `~/.pi/agent/oauth.json` by default (with `chmod 600` permissions). Use `setOAuthStorage()` to configure a custom storage backend for different locations or environments (the coding-agent does this to respect its configurable config directory). +### CLI Login -### Using with @mariozechner/pi-coding-agent +The quickest way to authenticate: -Use `/login` and select a provider to authenticate. Tokens are automatically refreshed when expired. +```bash +npx @mariozechner/pi-ai login # interactive provider selection +npx @mariozechner/pi-ai login anthropic # login to specific provider +npx @mariozechner/pi-ai list # list available providers +``` + +Credentials are saved to `auth.json` in the current directory. ### Programmatic OAuth -For standalone usage, the library exposes low-level OAuth functions: +The library provides login and token refresh functions. Credential storage is the caller's responsibility. ```typescript import { - // Login functions (each implements provider-specific OAuth flow) + // Login functions (return credentials, do not store) loginAnthropic, loginGitHubCopilot, loginGeminiCli, loginAntigravity, // Token management - refreshToken, // Refresh token for any provider - getOAuthApiKey, // Get API key (auto-refreshes if expired) - - // Credential storage - loadOAuthCredentials, - saveOAuthCredentials, - removeOAuthCredentials, - hasOAuthCredentials, - listOAuthProviders, - getOAuthPath, + refreshOAuthToken, // (provider, credentials) => new credentials + getOAuthApiKey, // (provider, credentialsMap) => { newCredentials, apiKey } | null // Types type OAuthProvider, // 'anthropic' | 'github-copilot' | 'google-gemini-cli' | 'google-antigravity' @@ -1162,90 +1154,57 @@ import { ### Login Flow Example -Each provider has a different OAuth flow. Here's an example with GitHub Copilot: - ```typescript -import { loginGitHubCopilot, saveOAuthCredentials } from '@mariozechner/pi-ai'; +import { loginGitHubCopilot } from '@mariozechner/pi-ai'; +import { writeFileSync } from 'fs'; const credentials = await loginGitHubCopilot({ onAuth: (url, instructions) => { - // Display the URL and instructions to the user console.log(`Open: ${url}`); if (instructions) console.log(instructions); }, onPrompt: async (prompt) => { - // Prompt user for input (e.g., device code confirmation) return await getUserInput(prompt.message); }, - onProgress: (message) => { - // Optional: show progress updates - console.log(message); - } + onProgress: (message) => console.log(message) }); -// Save credentials for later use -saveOAuthCredentials('github-copilot', credentials); +// Store credentials yourself +const auth = { 'github-copilot': { type: 'oauth', ...credentials } }; +writeFileSync('auth.json', JSON.stringify(auth, null, 2)); ``` ### Using OAuth Tokens -Call `getOAuthApiKey()` before **every** `complete()` or `stream()` call. This function checks token expiry and refreshes automatically when needed: +Use `getOAuthApiKey()` to get an API key, automatically refreshing if expired: ```typescript import { getModel, complete, getOAuthApiKey } from '@mariozechner/pi-ai'; - -const model = getModel('github-copilot', 'gpt-4o'); - -// Always call getOAuthApiKey() right before the API call -// Do NOT cache the result - tokens expire and need refresh -const apiKey = await getOAuthApiKey('github-copilot'); -if (!apiKey) { - throw new Error('Not logged in to GitHub Copilot'); -} - -const response = await complete(model, { - messages: [{ role: 'user', content: 'Hello!' }] -}, { apiKey }); -``` - -### Custom Storage Backend - -Override the default storage location with `setOAuthStorage()`: - -```typescript -import { setOAuthStorage, resetOAuthStorage } from '@mariozechner/pi-ai'; import { readFileSync, writeFileSync } from 'fs'; -// Custom file path -setOAuthStorage({ - load: () => { - try { - return JSON.parse(readFileSync('/custom/path/oauth.json', 'utf-8')); - } catch { - return {}; - } - }, - save: (storage) => { - writeFileSync('/custom/path/oauth.json', JSON.stringify(storage, null, 2)); - } -}); +// Load your stored credentials +const auth = JSON.parse(readFileSync('auth.json', 'utf-8')); -// In-memory storage (for testing or browser environments) -let memoryStorage = {}; -setOAuthStorage({ - load: () => memoryStorage, - save: (storage) => { memoryStorage = storage; } -}); +// Get API key (refreshes if expired) +const result = await getOAuthApiKey('github-copilot', auth); +if (!result) throw new Error('Not logged in'); -// Reset to default (~/.pi/agent/oauth.json) -resetOAuthStorage(); +// Save refreshed credentials +auth['github-copilot'] = { type: 'oauth', ...result.newCredentials }; +writeFileSync('auth.json', JSON.stringify(auth, null, 2)); + +// Use the API key +const model = getModel('github-copilot', 'gpt-4o'); +const response = await complete(model, { + messages: [{ role: 'user', content: 'Hello!' }] +}, { apiKey: result.apiKey }); ``` ### Provider Notes **GitHub Copilot**: If you get "The requested model is not supported" error, enable the model manually in VS Code: open Copilot Chat, click the model selector, select the model (warning icon), and click "Enable". -**Google Gemini CLI / Antigravity**: These use Google Cloud OAuth. The API key returned by `getOAuthApiKey()` is a JSON string containing both the token and project ID, which the library handles automatically. +**Google Gemini CLI / Antigravity**: These use Google Cloud OAuth. The `apiKey` returned by `getOAuthApiKey()` is a JSON string containing both the token and project ID, which the library handles automatically. ## License diff --git a/packages/ai/package.json b/packages/ai/package.json index 13938f69..177afff5 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -5,6 +5,9 @@ "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "bin": { + "pi-ai": "./dist/cli.js" + }, "files": [ "dist", "README.md" diff --git a/packages/ai/src/cli.ts b/packages/ai/src/cli.ts new file mode 100644 index 00000000..94e66654 --- /dev/null +++ b/packages/ai/src/cli.ts @@ -0,0 +1,173 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync, writeFileSync } from "fs"; +import { createInterface } from "readline"; +import { loginAnthropic } from "./utils/oauth/anthropic.js"; +import { loginGitHubCopilot } from "./utils/oauth/github-copilot.js"; +import { loginAntigravity } from "./utils/oauth/google-antigravity.js"; +import { loginGeminiCli } from "./utils/oauth/google-gemini-cli.js"; +import { getOAuthProviders } from "./utils/oauth/index.js"; +import type { OAuthCredentials, OAuthProvider } from "./utils/oauth/types.js"; + +const AUTH_FILE = "auth.json"; +const PROVIDERS = getOAuthProviders(); + +function prompt(rl: ReturnType, question: string): Promise { + return new Promise((resolve) => rl.question(question, resolve)); +} + +function loadAuth(): Record { + if (!existsSync(AUTH_FILE)) return {}; + try { + return JSON.parse(readFileSync(AUTH_FILE, "utf-8")); + } catch { + return {}; + } +} + +function saveAuth(auth: Record): void { + writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2), "utf-8"); +} + +async function login(provider: OAuthProvider): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + const promptFn = (msg: string) => prompt(rl, `${msg} `); + + try { + let credentials: OAuthCredentials; + + switch (provider) { + case "anthropic": + credentials = await loginAnthropic( + (url) => { + console.log(`\nOpen this URL in your browser:\n${url}\n`); + }, + async () => { + return await promptFn("Paste the authorization code:"); + }, + ); + break; + + case "github-copilot": + credentials = await loginGitHubCopilot({ + onAuth: (url, instructions) => { + console.log(`\nOpen this URL in your browser:\n${url}`); + if (instructions) console.log(instructions); + console.log(); + }, + onPrompt: async (p) => { + return await promptFn(`${p.message}${p.placeholder ? ` (${p.placeholder})` : ""}:`); + }, + onProgress: (msg) => console.log(msg), + }); + break; + + case "google-gemini-cli": + credentials = await loginGeminiCli( + (info) => { + console.log(`\nOpen this URL in your browser:\n${info.url}`); + if (info.instructions) console.log(info.instructions); + console.log(); + }, + (msg) => console.log(msg), + ); + break; + + case "google-antigravity": + credentials = await loginAntigravity( + (info) => { + console.log(`\nOpen this URL in your browser:\n${info.url}`); + if (info.instructions) console.log(info.instructions); + console.log(); + }, + (msg) => console.log(msg), + ); + break; + } + + const auth = loadAuth(); + auth[provider] = { type: "oauth", ...credentials }; + saveAuth(auth); + + console.log(`\nCredentials saved to ${AUTH_FILE}`); + } finally { + rl.close(); + } +} + +async function main(): Promise { + const args = process.argv.slice(2); + const command = args[0]; + + if (!command || command === "help" || command === "--help" || command === "-h") { + console.log(`Usage: npx @mariozechner/pi-ai [provider] + +Commands: + login [provider] Login to an OAuth provider + list List available providers + +Providers: + anthropic Anthropic (Claude Pro/Max) + github-copilot GitHub Copilot + google-gemini-cli Google Gemini CLI + google-antigravity Antigravity (Gemini 3, Claude, GPT-OSS) + +Examples: + npx @mariozechner/pi-ai login # interactive provider selection + npx @mariozechner/pi-ai login anthropic # login to specific provider + npx @mariozechner/pi-ai list # list providers +`); + return; + } + + if (command === "list") { + console.log("Available OAuth providers:\n"); + for (const p of PROVIDERS) { + console.log(` ${p.id.padEnd(20)} ${p.name}`); + } + return; + } + + if (command === "login") { + let provider = args[1] as OAuthProvider | undefined; + + if (!provider) { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + console.log("Select a provider:\n"); + for (let i = 0; i < PROVIDERS.length; i++) { + console.log(` ${i + 1}. ${PROVIDERS[i].name}`); + } + console.log(); + + const choice = await prompt(rl, "Enter number (1-4): "); + rl.close(); + + const index = parseInt(choice, 10) - 1; + if (index < 0 || index >= PROVIDERS.length) { + console.error("Invalid selection"); + process.exit(1); + } + provider = PROVIDERS[index].id; + } + + if (!PROVIDERS.some((p) => p.id === provider)) { + console.error(`Unknown provider: ${provider}`); + console.error(`Use 'npx @mariozechner/pi-ai list' to see available providers`); + process.exit(1); + } + + console.log(`Logging in to ${provider}...`); + await login(provider); + return; + } + + console.error(`Unknown command: ${command}`); + console.error(`Use 'npx @mariozechner/pi-ai --help' for usage`); + process.exit(1); +} + +main().catch((err) => { + console.error("Error:", err.message); + process.exit(1); +});