From 4bb8c88fbe36e46f3b62b0a2ced570481f9fa24e Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sat, 14 Feb 2026 00:43:12 -0800 Subject: [PATCH] feat(auth): add anthropic api key storage and cli auth --- docs/plans/state.json | 16 ++++++++- src/auth/anthropic.test.ts | 50 ++++++++++++++++++++++++++ src/auth/anthropic.ts | 72 ++++++++++++++++++++++++++++++++++++++ src/auth/index.ts | 8 +++++ src/cli/anthropic-auth.ts | 56 +++++++++++++++++++++++++++++ src/cli/index.ts | 2 ++ src/daemon/models.ts | 9 ++++- 7 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 src/auth/anthropic.test.ts create mode 100644 src/auth/anthropic.ts create mode 100644 src/cli/anthropic-auth.ts diff --git a/docs/plans/state.json b/docs/plans/state.json index 2cf02c4..bea1ab7 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -75,6 +75,20 @@ ], "test_status": "pnpm test:run src/gateway/handlers/services.test.ts src/gateway/handlers/handlers.test.ts + pnpm typecheck passing" }, + "anthropic-auth-cli": { + "status": "completed", + "date": "2026-02-14", + "summary": "Added `flynn anthropic-auth` to securely store an Anthropic API key in ~/.config/flynn/auth.json and wired model client creation to use ANTHROPIC_API_KEY or stored credentials when config omits api_key.", + "files_modified": [ + "src/auth/anthropic.ts", + "src/auth/anthropic.test.ts", + "src/auth/index.ts", + "src/cli/anthropic-auth.ts", + "src/cli/index.ts", + "src/daemon/models.ts" + ], + "test_status": "pnpm test:run src/auth/anthropic.test.ts + pnpm typecheck passing" + }, "p0-p1-implementation-plan": { "file": "2026-02-06-p0-p1-implementation-plan.md", "status": "completed", @@ -1891,7 +1905,7 @@ }, "overall_progress": { - "total_test_count": 1629, + "total_test_count": 1631, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", diff --git a/src/auth/anthropic.test.ts b/src/auth/anthropic.test.ts new file mode 100644 index 0000000..c5fc1a2 --- /dev/null +++ b/src/auth/anthropic.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, statSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +describe('auth/anthropic', () => { + const originalHome = process.env.HOME; + const originalEnvKey = process.env.ANTHROPIC_API_KEY; + + let homeDir: string; + + beforeEach(() => { + homeDir = mkdtempSync(join(tmpdir(), 'flynn-auth-anthropic-')); + process.env.HOME = homeDir; + delete process.env.ANTHROPIC_API_KEY; + vi.resetModules(); + }); + + afterEach(() => { + process.env.HOME = originalHome; + if (originalEnvKey) { + process.env.ANTHROPIC_API_KEY = originalEnvKey; + } else { + delete process.env.ANTHROPIC_API_KEY; + } + }); + + it('stores, loads, and clears Anthropic API key', async () => { + const mod = await import('./anthropic.js'); + + expect(mod.loadStoredAnthropicAuth()).toBeNull(); + + mod.storeAnthropicAuth('sk-test'); + expect(mod.loadStoredAnthropicAuth()?.api_key).toBe('sk-test'); + + const authFile = join(homeDir, '.config/flynn/auth.json'); + const mode = statSync(authFile).mode & 0o777; + expect(mode).toBe(0o600); + + mod.clearAnthropicAuth(); + expect(mod.loadStoredAnthropicAuth()).toBeNull(); + }); + + it('getAnthropicApiKey prefers environment variable', async () => { + process.env.ANTHROPIC_API_KEY = 'sk-env'; + const mod = await import('./anthropic.js'); + expect(mod.getAnthropicApiKey()).toBe('sk-env'); + }); +}); + diff --git a/src/auth/anthropic.ts b/src/auth/anthropic.ts new file mode 100644 index 0000000..464d3b0 --- /dev/null +++ b/src/auth/anthropic.ts @@ -0,0 +1,72 @@ +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 AnthropicAuthInfo { + /** Anthropic API key. */ + api_key: string; + created_at: string; +} + +interface AuthStore { + anthropic?: AnthropicAuthInfo; + [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 loadStoredAnthropicAuth(): AnthropicAuthInfo | null { + const store = readAuthStore(); + return store.anthropic ?? null; +} + +export function storeAnthropicAuth(apiKey: string): void { + const trimmed = apiKey.trim(); + if (!trimmed) { + throw new Error('Anthropic API key is empty'); + } + const store = readAuthStore(); + store.anthropic = { api_key: trimmed, created_at: new Date().toISOString() }; + writeAuthStore(store); +} + +export function clearAnthropicAuth(): void { + const store = readAuthStore(); + delete store.anthropic; + writeAuthStore(store); +} + +/** + * Get an Anthropic API key from any available source. + * Priority: ANTHROPIC_API_KEY → stored auth.json. + */ +export function getAnthropicApiKey(): string | null { + return process.env.ANTHROPIC_API_KEY + ?? loadStoredAnthropicAuth()?.api_key + ?? null; +} diff --git a/src/auth/index.ts b/src/auth/index.ts index 4e648b6..f6d792e 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -1,3 +1,11 @@ +export { + loadStoredAnthropicAuth, + storeAnthropicAuth, + clearAnthropicAuth, + getAnthropicApiKey, + type AnthropicAuthInfo, +} from './anthropic.js'; + export { requestDeviceCode, pollForToken, diff --git a/src/cli/anthropic-auth.ts b/src/cli/anthropic-auth.ts new file mode 100644 index 0000000..25fafb0 --- /dev/null +++ b/src/cli/anthropic-auth.ts @@ -0,0 +1,56 @@ +import type { Command } from 'commander'; +import readline from 'readline'; +import { loadStoredAnthropicAuth, storeAnthropicAuth } 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(); +} + +export function registerAnthropicAuthCommand(program: Command): void { + program + .command('anthropic-auth') + .description('Store an Anthropic API key (auth.json)') + .action(async () => { + const existing = loadStoredAnthropicAuth(); + if (existing) { + console.log('Anthropic credential already exists.'); + console.log('Delete ~/.config/flynn/auth.json anthropic entry if you want to re-authenticate.'); + process.exit(0); + } + + console.log('Anthropic uses API keys for authentication.'); + console.log('Create a key at: https://console.anthropic.com/settings/keys'); + console.log(''); + + try { + const apiKey = await promptHidden('Enter Anthropic API key: '); + storeAnthropicAuth(apiKey); + console.log(''); + console.log('Anthropic credential stored in ~/.config/flynn/auth.json'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`Anthropic auth failed: ${message}`); + process.exit(1); + } + }); +} diff --git a/src/cli/index.ts b/src/cli/index.ts index 21632e2..7a0a278 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -20,6 +20,7 @@ import { registerGdriveAuthCommand } from './gdrive-auth.js'; import { registerGtasksAuthCommand } from './gtasks-auth.js'; import { registerOpenaiAuthCommand } from './openai-auth.js'; import { registerZaiAuthCommand } from './zai-auth.js'; +import { registerAnthropicAuthCommand } from './anthropic-auth.js'; import { registerSkillsCommand } from './skills.js'; export function createProgram(): Command { @@ -45,6 +46,7 @@ export function createProgram(): Command { registerGtasksAuthCommand(program); registerOpenaiAuthCommand(program); registerZaiAuthCommand(program); + registerAnthropicAuthCommand(program); registerSkillsCommand(program); return program; diff --git a/src/daemon/models.ts b/src/daemon/models.ts index 7d4b77b..d833dc8 100644 --- a/src/daemon/models.ts +++ b/src/daemon/models.ts @@ -3,6 +3,7 @@ import { AnthropicClient, OpenAIClient, OllamaClient, LlamaCppClient, GeminiClie import type { ModelClient, RetryConfig, ModelTier } from '../models/index.js'; import { logger } from '../logger.js'; import { getZaiApiKey } from '../auth/zai.js'; +import { getAnthropicApiKey } from '../auth/anthropic.js'; /** * Resolve an API key from config or environment variable. @@ -44,9 +45,15 @@ function resolveAuthCredential(cfg: ModelConfig, apiKeyEnvVar: string, authToken export function createClientFromConfig(cfg: ModelConfig): ModelClient { switch (cfg.provider) { case 'anthropic': + if (!cfg.api_key && !getAnthropicApiKey()) { + throw new Error( + 'Anthropic API key not configured. ' + + 'Set ANTHROPIC_API_KEY, run `flynn anthropic-auth`, or provide api_key in config.', + ); + } return new AnthropicClient({ model: cfg.model, - apiKey: cfg.api_key, + apiKey: cfg.api_key ?? getAnthropicApiKey() ?? undefined, authToken: cfg.auth_token, }); case 'openai':