From e9cb1d7c1afb1d7349b2333f1dc70f46fb003cf3 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sat, 21 Feb 2026 11:39:23 -0800 Subject: [PATCH] feat(cli): add gemini-auth command and alias support --- README.md | 2 + src/auth/gemini.test.ts | 64 +++++++++++++++++++++++++++++ src/auth/gemini.ts | 73 +++++++++++++++++++++++++++++++++ src/auth/index.ts | 8 ++++ src/cli/doctor.ts | 24 ++++++++--- src/cli/gemini-auth.test.ts | 80 +++++++++++++++++++++++++++++++++++++ src/cli/gemini-auth.ts | 67 +++++++++++++++++++++++++++++++ src/cli/index.test.ts | 8 +++- src/cli/index.ts | 28 +++++++++++-- src/daemon/models.ts | 6 ++- 10 files changed, 350 insertions(+), 10 deletions(-) create mode 100644 src/auth/gemini.test.ts create mode 100644 src/auth/gemini.ts create mode 100644 src/cli/gemini-auth.test.ts create mode 100644 src/cli/gemini-auth.ts diff --git a/README.md b/README.md index 0b4d21f..f8222d7 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ Flynn provides a full CLI via the `flynn` binary (or `npx tsx src/cli/index.ts` | `flynn onboard` | Guided onboarding alias for setup wizard | | `flynn gmail-auth` | Authenticate with Gmail via OAuth2 | | `flynn gcal-auth` | Authenticate with Google Calendar via OAuth2 | +| `flynn gemini-auth` | Store a Gemini API key in `~/.config/flynn/auth.json` | | `flynn skills` | List/install/manage skills | | `flynn companion` | Run a minimal companion node client against the gateway | @@ -1708,6 +1709,7 @@ pnpm test | `FLYNN_DATA_DIR` | Override data directory (default: `~/.local/share/flynn`) | | `ANTHROPIC_API_KEY` | Anthropic API key (fallback) | | `OPENAI_API_KEY` | OpenAI API key (fallback) | +| `GEMINI_API_KEY` | Gemini API key (fallback; `GOOGLE_API_KEY` also supported) | ## License diff --git a/src/auth/gemini.test.ts b/src/auth/gemini.test.ts new file mode 100644 index 0000000..5b21ed6 --- /dev/null +++ b/src/auth/gemini.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, statSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +describe('auth/gemini', () => { + const originalHome = process.env.HOME; + const originalGeminiEnvKey = process.env.GEMINI_API_KEY; + const originalGoogleEnvKey = process.env.GOOGLE_API_KEY; + + let homeDir: string; + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'flynn-auth-gemini-')); + process.env.HOME = homeDir; + delete process.env.GEMINI_API_KEY; + delete process.env.GOOGLE_API_KEY; + vi.resetModules(); + }); + + afterEach(() => { + process.env.HOME = originalHome; + if (originalGeminiEnvKey) { + process.env.GEMINI_API_KEY = originalGeminiEnvKey; + } else { + delete process.env.GEMINI_API_KEY; + } + + if (originalGoogleEnvKey) { + process.env.GOOGLE_API_KEY = originalGoogleEnvKey; + } else { + delete process.env.GOOGLE_API_KEY; + } + }); + + it('stores, loads, and clears Gemini API key', async () => { + const mod = await import('./gemini.js'); + + expect(mod.loadStoredGeminiAuth()).toBeNull(); + + mod.storeGeminiAuth('gem-test'); + expect(mod.loadStoredGeminiAuth()?.api_key).toBe('gem-test'); + + const authFile = join(homeDir, '.config/flynn/auth.json'); + const mode = statSync(authFile).mode & 0o777; + expect(mode).toBe(0o600); + + mod.clearGeminiAuth(); + expect(mod.loadStoredGeminiAuth()).toBeNull(); + }); + + it('getGeminiApiKey prefers GEMINI_API_KEY over GOOGLE_API_KEY', async () => { + process.env.GEMINI_API_KEY = 'gem-env'; + process.env.GOOGLE_API_KEY = 'google-env'; + const mod = await import('./gemini.js'); + expect(mod.getGeminiApiKey()).toBe('gem-env'); + }); + + it('getGeminiApiKey falls back to GOOGLE_API_KEY', async () => { + process.env.GOOGLE_API_KEY = 'google-env'; + const mod = await import('./gemini.js'); + expect(mod.getGeminiApiKey()).toBe('google-env'); + }); +}); diff --git a/src/auth/gemini.ts b/src/auth/gemini.ts new file mode 100644 index 0000000..0a5eee9 --- /dev/null +++ b/src/auth/gemini.ts @@ -0,0 +1,73 @@ +import { readFileSync, writeFileSync, mkdirSync, chmodSync } from 'fs'; +import { resolve } from 'path'; +import { homedir } from 'os'; + +const AUTH_DIR = resolve(homedir(), '.config/flynn'); +const AUTH_FILE = resolve(AUTH_DIR, 'auth.json'); + +export interface GeminiAuthInfo { + /** Gemini API key. */ + api_key: string; + created_at: string; +} + +interface AuthStore { + gemini?: GeminiAuthInfo; + [key: string]: unknown; +} + +function safeJsonParse(raw: string): T | null { + try { + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +function readAuthStore(): AuthStore { + try { + const raw = readFileSync(AUTH_FILE, 'utf-8'); + const parsed = safeJsonParse(raw); + return parsed ?? {}; + } catch { + return {}; + } +} + +function writeAuthStore(store: AuthStore): void { + mkdirSync(AUTH_DIR, { recursive: true }); + writeFileSync(AUTH_FILE, JSON.stringify(store, null, 2) + '\n', 'utf-8'); + chmodSync(AUTH_FILE, 0o600); +} + +export function loadStoredGeminiAuth(): GeminiAuthInfo | null { + const store = readAuthStore(); + return store.gemini ?? null; +} + +export function storeGeminiAuth(apiKey: string): void { + const trimmed = apiKey.trim(); + if (!trimmed) { + throw new Error('Gemini API key is empty'); + } + const store = readAuthStore(); + store.gemini = { api_key: trimmed, created_at: new Date().toISOString() }; + writeAuthStore(store); +} + +export function clearGeminiAuth(): void { + const store = readAuthStore(); + delete store.gemini; + writeAuthStore(store); +} + +/** + * Get a Gemini API key from any available source. + * Priority: GEMINI_API_KEY -> GOOGLE_API_KEY -> stored auth.json. + */ +export function getGeminiApiKey(): string | null { + return process.env.GEMINI_API_KEY + ?? process.env.GOOGLE_API_KEY + ?? loadStoredGeminiAuth()?.api_key + ?? null; +} diff --git a/src/auth/index.ts b/src/auth/index.ts index 48e870b..44ddf5f 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -38,6 +38,14 @@ export { type IdTokenClaims, } from './openai.js'; +export { + loadStoredGeminiAuth, + storeGeminiAuth, + clearGeminiAuth, + getGeminiApiKey, + type GeminiAuthInfo, +} from './gemini.js'; + export { loadStoredZaiAuth, storeZaiAuth, diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index d0632ba..928673f 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -241,6 +241,11 @@ const checkModelConnectivity: Check = async (ctx) => { return Boolean(typeof anthropic?.auth_token === 'string' && anthropic.auth_token.length > 0); }; + const storeGeminiApiKeyPresent = (): boolean => { + const gemini = asRecord(store.gemini); + return Boolean(typeof gemini?.api_key === 'string' && gemini.api_key.length > 0); + }; + const formatSources = (sources: { config: boolean; env: boolean; store: boolean }): string => { const parts: string[] = []; if (sources.config) {parts.push('config');} @@ -317,7 +322,7 @@ const checkModelConnectivity: Check = async (ctx) => { const needsKey = ['gemini', 'openrouter', 'vercel', 'xai', 'minimax', 'moonshot', 'github']; if (needsKey.includes(provider)) { const envVarMap: Record = { - gemini: 'GEMINI_API_KEY', + gemini: 'GEMINI_API_KEY or GOOGLE_API_KEY', openrouter: 'OPENROUTER_API_KEY', vercel: 'AI_GATEWAY_API_KEY', xai: 'XAI_API_KEY', @@ -326,15 +331,24 @@ const checkModelConnectivity: Check = async (ctx) => { github: 'GITHUB_TOKEN', }; const envVar = envVarMap[provider]; + const geminiEnvPresent = Boolean( + (typeof process.env.GEMINI_API_KEY === 'string' && process.env.GEMINI_API_KEY.length > 0) + || (typeof process.env.GOOGLE_API_KEY === 'string' && process.env.GOOGLE_API_KEY.length > 0), + ); const sources = { config: typeof cfg.api_key === 'string' && (cfg.api_key as string).length > 0, - env: Boolean(envVar && typeof process.env[envVar] === 'string' && process.env[envVar].length > 0), - store: false, + env: provider === 'gemini' + ? geminiEnvPresent + : Boolean(envVar && typeof process.env[envVar] === 'string' && process.env[envVar].length > 0), + store: provider === 'gemini' ? storeGeminiApiKeyPresent() : false, }; const ok = sources.config || sources.env; - if (!ok) { + const okWithStore = provider === 'gemini' ? ok || sources.store : ok; + if (!okWithStore) { const status = tier === 'default' ? 'warn' : 'warn'; - const hint = envVar ? `set ${envVar} or provide api_key in config` : 'provide api_key in config'; + const hint = provider === 'gemini' + ? 'set GEMINI_API_KEY/GOOGLE_API_KEY, run flynn gemini-auth, or provide api_key in config' + : (envVar ? `set ${envVar} or provide api_key in config` : 'provide api_key in config'); return { status, detail: `${tier}: ${provider}/${modelName} (api_key=${formatSources(sources)} — ${hint})` }; } return { status: 'pass', detail: `${tier}: ${provider}/${modelName} (api_key=${formatSources(sources)})` }; diff --git a/src/cli/gemini-auth.test.ts b/src/cli/gemini-auth.test.ts new file mode 100644 index 0000000..9c6b185 --- /dev/null +++ b/src/cli/gemini-auth.test.ts @@ -0,0 +1,80 @@ +import { Command } from 'commander'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockLoadStoredGeminiAuth, mockStoreGeminiAuth } = vi.hoisted(() => ({ + mockLoadStoredGeminiAuth: vi.fn(), + mockStoreGeminiAuth: vi.fn(), +})); + +const { mockCreateInterface } = vi.hoisted(() => ({ + mockCreateInterface: vi.fn(), +})); + +vi.mock('../auth/index.js', () => ({ + loadStoredGeminiAuth: mockLoadStoredGeminiAuth, + storeGeminiAuth: mockStoreGeminiAuth, +})); + +vi.mock('readline', () => ({ + default: { + createInterface: mockCreateInterface, + }, +})); + +function mockReadlineAnswers(answers: string[]): void { + const queue = [...answers]; + mockCreateInterface.mockImplementation(() => ({ + question: (_prompt: string, cb: (answer: string) => void) => cb(queue.shift() ?? ''), + close: () => undefined, + })); +} + +describe('gemini-auth command', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLoadStoredGeminiAuth.mockReset(); + mockStoreGeminiAuth.mockReset(); + mockCreateInterface.mockReset(); + }); + + it('cancels when key exists and user answers no', async () => { + mockLoadStoredGeminiAuth.mockReturnValue({ api_key: 'gem-existing', created_at: '2026-02-21T00:00:00.000Z' }); + mockReadlineAnswers(['n']); + + const program = new Command(); + const { registerGeminiAuthCommand } = await import('./gemini-auth.js'); + registerGeminiAuthCommand(program); + + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new Error(`EXIT:${code ?? 0}`); + }) as never); + + await expect(program.parseAsync(['node', 'test', 'gemini-auth'])).rejects.toThrow('EXIT:0'); + expect(mockStoreGeminiAuth).not.toHaveBeenCalled(); + expect(consoleLog).toHaveBeenCalledWith('Cancelled.'); + + exitSpy.mockRestore(); + consoleLog.mockRestore(); + }); + + it('stores a new key when user confirms re-authentication', async () => { + mockLoadStoredGeminiAuth.mockReturnValue({ api_key: 'gem-existing', created_at: '2026-02-21T00:00:00.000Z' }); + mockReadlineAnswers(['y', 'gem-new']); + + const program = new Command(); + const { registerGeminiAuthCommand } = await import('./gemini-auth.js'); + registerGeminiAuthCommand(program); + + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + await program.parseAsync(['node', 'test', 'gemini-auth']); + + expect(mockStoreGeminiAuth).toHaveBeenCalledWith('gem-new'); + expect(consoleError).not.toHaveBeenCalled(); + + consoleLog.mockRestore(); + consoleError.mockRestore(); + }); +}); diff --git a/src/cli/gemini-auth.ts b/src/cli/gemini-auth.ts new file mode 100644 index 0000000..9649c0a --- /dev/null +++ b/src/cli/gemini-auth.ts @@ -0,0 +1,67 @@ +import type { Command } from 'commander'; +import readline from 'readline'; +import { loadStoredGeminiAuth, storeGeminiAuth } from '../auth/index.js'; + +async function promptHidden(question: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); + const rlAny = rl as unknown as { stdoutMuted?: boolean; _writeToOutput?: (s: string) => void }; + rlAny.stdoutMuted = true; + + rlAny._writeToOutput = (s: string) => { + if (!rlAny.stdoutMuted) { + process.stdout.write(s); + return; + } + if (s.includes('\n')) { + process.stdout.write('\n'); + } else { + process.stdout.write('*'); + } + }; + + const answer = await new Promise((resolve) => rl.question(question, resolve)); + rlAny.stdoutMuted = false; + rl.close(); + process.stdout.write('\n'); + return answer.trim(); +} + +async function promptYesNo(question: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); + const answer = await new Promise((resolve) => rl.question(question, resolve)); + rl.close(); + const normalized = answer.trim().toLowerCase(); + return normalized === 'y' || normalized === 'yes'; +} + +export function registerGeminiAuthCommand(program: Command): void { + program + .command('gemini-auth') + .description('Store a Gemini API key (auth.json)') + .action(async () => { + const existing = loadStoredGeminiAuth(); + if (existing?.api_key) { + console.log('Gemini API key already exists.'); + const confirmed = await promptYesNo('Re-authenticate and replace it? (y/N): '); + if (!confirmed) { + console.log('Cancelled.'); + process.exit(0); + } + } + + console.log('Gemini uses API keys for direct API access.'); + console.log('Create a key at: https://aistudio.google.com/apikey'); + console.log(''); + + try { + const apiKey = await promptHidden('Enter Gemini API key: '); + storeGeminiAuth(apiKey); + console.log(''); + console.log('Gemini API key stored in ~/.config/flynn/auth.json'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Gemini API key storage failed: ${message}`); + process.exit(1); + } + }); +} diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 48b0afb..59c93ba 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { createProgram } from './index.js'; +import { createProgram, normalizeAliasFlags } from './index.js'; describe('CLI program', () => { it('creates a commander program with expected commands', () => { @@ -22,6 +22,7 @@ describe('CLI program', () => { expect(commandNames).toContain('openai-key'); expect(commandNames).toContain('anthropic-auth'); expect(commandNames).toContain('zai-auth'); + expect(commandNames).toContain('gemini-auth'); }); it('registers doctor strict flag on doctor command', () => { @@ -40,4 +41,9 @@ describe('CLI program', () => { const program = createProgram(); expect(program.description()).toContain('AI'); }); + + it('normalizes --gemini-auth alias to gemini-auth command', () => { + const argv = normalizeAliasFlags(['node', 'flynn', '--gemini-auth']); + expect(argv).toEqual(['node', 'flynn', 'gemini-auth']); + }); }); diff --git a/src/cli/index.ts b/src/cli/index.ts index 8e8aad6..f5f3c4c 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -27,6 +27,7 @@ import { registerOpenaiAuthCommand } from './openai-auth.js'; import { registerOpenaiKeyCommand } from './openai-key.js'; import { registerZaiAuthCommand } from './zai-auth.js'; import { registerAnthropicAuthCommand } from './anthropic-auth.js'; +import { registerGeminiAuthCommand } from './gemini-auth.js'; import { registerSkillsCommand } from './skills.js'; import { registerBackupCommand } from './backup.js'; import { registerCompanionCommand } from './companion.js'; @@ -57,6 +58,7 @@ export function createProgram(): Command { registerOpenaiKeyCommand(program); registerZaiAuthCommand(program); registerAnthropicAuthCommand(program); + registerGeminiAuthCommand(program); registerSkillsCommand(program); registerBackupCommand(program); registerCompanionCommand(program); @@ -80,10 +82,30 @@ function isDirectRun(): boolean { } } +export function normalizeAliasFlags(argv: string[]): string[] { + const aliasMap: Record = { + '--anthropic-auth': 'anthropic-auth', + '--gemini-auth': 'gemini-auth', + '--zai-auth': 'zai-auth', + '--openai-auth': 'openai-auth', + '--openai-key': 'openai-key', + }; + + for (const [flag, command] of Object.entries(aliasMap)) { + if (argv.includes(flag)) { + const filtered = argv.filter((arg) => arg !== flag); + return [filtered[0] ?? 'node', filtered[1] ?? 'flynn', command, ...filtered.slice(2)]; + } + } + + return argv; +} + if (isDirectRun()) { const program = createProgram(); - const argv = process.argv.length <= 2 - ? [...process.argv, 'tui'] - : process.argv; + const normalizedArgv = normalizeAliasFlags(process.argv); + const argv = normalizedArgv.length <= 2 + ? [...normalizedArgv, 'tui'] + : normalizedArgv; program.parse(argv); } diff --git a/src/daemon/models.ts b/src/daemon/models.ts index f25221a..8901460 100644 --- a/src/daemon/models.ts +++ b/src/daemon/models.ts @@ -4,6 +4,7 @@ import type { ModelClient, RetryConfig, ModelTier } from '../models/index.js'; import { logger } from '../logger.js'; import { getZaiApiKey } from '../auth/zai.js'; import { getAnthropicApiKey, getAnthropicAuthToken } from '../auth/anthropic.js'; +import { getGeminiApiKey } from '../auth/gemini.js'; import { getOpenAIApiKey, loadStoredOpenAIAuth } from '../auth/openai.js'; type AuthMode = 'auto' | 'api_key' | 'oauth'; @@ -223,10 +224,13 @@ export function createClientFromConfig(cfg: ModelConfig): ModelClient { authToken: cfg.auth_token, }); case 'gemini': + { + const apiKey = cfg.api_key ?? getGeminiApiKey() ?? undefined; return new GeminiClient({ model: cfg.model, - apiKey: cfg.api_key, + apiKey, }); + } case 'openrouter': { const keys = resolveApiKeyPool(cfg, 'OPENROUTER_API_KEY');