From 7e390dd77748f05f98ecf5bbd989caf44e6c4d9d Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sun, 15 Feb 2026 10:32:13 -0800 Subject: [PATCH] tui: extend /login for OpenAI key and Anthropic token --- src/frontends/tui/commands.ts | 4 +- src/frontends/tui/minimal.ts | 174 ++++++++++++++++++++++++++++------ 2 files changed, 147 insertions(+), 31 deletions(-) diff --git a/src/frontends/tui/commands.ts b/src/frontends/tui/commands.ts index e05f856..1ee5bc4 100644 --- a/src/frontends/tui/commands.ts +++ b/src/frontends/tui/commands.ts @@ -124,7 +124,7 @@ Commands: /model [name] Show or switch model tier (local, default, fast, complex) /model

Change tier's provider/model (e.g. /model default anthropic/claude-sonnet-4) /backend [provider] Show or switch local backend (ollama, llamacpp) - /login [provider] Authenticate with GitHub, OpenAI, or Z.AI + /login [provider] Authenticate with GitHub, OpenAI, Anthropic, or Z.AI /pair List pending pairing codes and approved senders /pair generate [label] Generate a new DM pairing code /pair revoke Revoke an approved sender @@ -178,7 +178,7 @@ export const COMMAND_TOOLTIPS: Record = { '/status': 'Show session info and token usage', '/fullscreen': 'Switch to fullscreen mode', '/fs': 'Switch to fullscreen mode', - '/login': 'Authenticate with GitHub/OpenAI (OAuth) or Z.AI (API key store)', + '/login': 'Authenticate with GitHub/OpenAI/Anthropic (OAuth/token or API key) or Z.AI (API key store)', '/pair': 'Generate/list/revoke DM pairing codes', '/transfer': 'Transfer session to another frontend', '/quit': 'Exit TUI', diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index 5e65939..55b2030 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -9,7 +9,19 @@ import type { ModelConfig, ModelProvider } from '../../config/schema.js'; import { MODEL_PROVIDERS } from '../../config/schema.js'; import { OllamaClient, LlamaCppClient } from '../../models/index.js'; import { createClientFromConfig } from '../../daemon/index.js'; -import { loginGitHub, loginOpenAI, loadStoredZaiAuth, storeZaiAuth } from '../../auth/index.js'; +import { + loadStoredAnthropicAuth, + loadStoredAnthropicAuthToken, + loadStoredOpenAIApiKey, + loadStoredOpenAIAuth, + loadStoredZaiAuth, + loginGitHub, + loginOpenAI, + storeAnthropicAuth, + storeAnthropicAuthToken, + storeOpenAIApiKey, + storeZaiAuth, +} from '../../auth/index.js'; import type { PairingManager } from '../../channels/pairing.js'; import { getColoredBanner } from './banner.js'; import type { HookEngine } from '../../hooks/index.js'; @@ -423,6 +435,33 @@ export class MinimalTui { private async handleLoginCommand(provider?: string): Promise { const target = provider ?? 'github'; + + const promptHidden = async (question: string): Promise => { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); + const rlAny = rl as any; + 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(); + }; + + if (!this.rl) { + console.log(`${colors.gray}TUI not ready for login prompt. Use the CLI auth commands instead.${colors.reset}\n`); + return; + } if (target === 'github') { console.log(`${colors.gray}Starting GitHub OAuth device flow...${colors.reset}`); @@ -445,6 +484,47 @@ export class MinimalTui { } if (target === 'openai') { + console.log(`${colors.gray}OpenAI login:${colors.reset}`); + console.log(`${colors.gray} 1) OAuth device flow 2) Paste API key${colors.reset}`); + const choice = (await this.prompt(`${colors.orange}Choose [1-2] (default 1):${colors.reset} `)).trim(); + + // 2) API key + if (choice === '2') { + const existing = loadStoredOpenAIApiKey(); + if (existing) { + console.log(`${colors.gray}OpenAI API key already exists.${colors.reset}`); + console.log(`${colors.gray}Delete ~/.config/flynn/auth.json openai.api_key entry to re-authenticate.${colors.reset}\n`); + return; + } + + console.log(`${colors.gray}OpenAI uses API keys for standard API access.${colors.reset}`); + console.log(`${colors.gray}Create a key at:${colors.reset} https://platform.openai.com/api-keys`); + console.log(''); + + try { + this.rl.pause(); + const apiKey = await promptHidden('Enter OpenAI API key: '); + storeOpenAIApiKey(apiKey); + console.log(''); + console.log(`${colors.gray}OpenAI API key stored in ~/.config/flynn/auth.json${colors.reset}\n`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(`${colors.gray}OpenAI API key storage failed:${colors.reset} ${message}\n`); + } finally { + this.rl.resume(); + } + + return; + } + + // 1) OAuth device flow (default) + const existing = loadStoredOpenAIAuth(); + if (existing) { + console.log(`${colors.gray}OpenAI OAuth token already exists.${colors.reset}`); + console.log(`${colors.gray}Delete ~/.config/flynn/auth.json openai.oauth entry (or legacy openai entry) to re-authenticate.${colors.reset}\n`); + return; + } + console.log(`${colors.gray}Starting OpenAI OAuth device flow...${colors.reset}`); try { @@ -465,6 +545,69 @@ export class MinimalTui { return; } + if (target === 'anthropic') { + console.log(`${colors.gray}Anthropic login:${colors.reset}`); + console.log(`${colors.gray} 1) Paste API key 2) Paste auth token${colors.reset}`); + const choice = (await this.prompt(`${colors.orange}Choose [1-2] (default 1):${colors.reset} `)).trim(); + + const existing = loadStoredAnthropicAuth(); + const hasApiKey = Boolean(existing?.api_key); + const hasToken = Boolean(loadStoredAnthropicAuthToken()); + + // 2) Auth token + if (choice === '2') { + if (hasToken) { + console.log(`${colors.gray}Anthropic auth token already exists.${colors.reset}`); + console.log(`${colors.gray}Delete ~/.config/flynn/auth.json anthropic.auth_token entry to re-authenticate.${colors.reset}\n`); + return; + } + + console.log(`${colors.gray}Anthropic supports token-style auth (provider-specific).${colors.reset}`); + console.log(''); + + try { + this.rl.pause(); + const token = await promptHidden('Enter Anthropic auth token: '); + storeAnthropicAuthToken(token); + console.log(''); + console.log(`${colors.gray}Anthropic auth token stored in ~/.config/flynn/auth.json${colors.reset}\n`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(`${colors.gray}Anthropic auth failed:${colors.reset} ${message}\n`); + } finally { + this.rl.resume(); + } + + return; + } + + // 1) API key (default) + if (hasApiKey) { + console.log(`${colors.gray}Anthropic API key already exists.${colors.reset}`); + console.log(`${colors.gray}Delete ~/.config/flynn/auth.json anthropic.api_key entry to re-authenticate.${colors.reset}\n`); + return; + } + + console.log(`${colors.gray}Anthropic uses API keys for authentication.${colors.reset}`); + console.log(`${colors.gray}Create a key at:${colors.reset} https://console.anthropic.com/settings/keys`); + console.log(''); + + try { + this.rl.pause(); + const apiKey = await promptHidden('Enter Anthropic API key: '); + storeAnthropicAuth(apiKey); + console.log(''); + console.log(`${colors.gray}Anthropic API key stored in ~/.config/flynn/auth.json${colors.reset}\n`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(`${colors.gray}Anthropic auth failed:${colors.reset} ${message}\n`); + } finally { + this.rl.resume(); + } + + return; + } + if (target === 'zai' || target === 'zhipuai') { const existing = loadStoredZaiAuth(); if (existing) { @@ -477,33 +620,6 @@ export class MinimalTui { console.log(`${colors.gray}Create a key at:${colors.reset} https://z.ai/manage-apikey/apikey-list`); console.log(''); - if (!this.rl) { - console.log(`${colors.gray}TUI not ready for login prompt. Run: flynn zai-auth${colors.reset}\n`); - return; - } - - const promptHidden = async (question: string): Promise => { - const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true }); - const rlAny = rl as any; - 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(); - }; - try { this.rl.pause(); const apiKey = await promptHidden('Enter Z.AI API key: '); @@ -521,7 +637,7 @@ export class MinimalTui { return; } - console.log(`${colors.gray}Unknown login provider:${colors.reset} ${target}. Supported: github, openai, zai\n`); + console.log(`${colors.gray}Unknown login provider:${colors.reset} ${target}. Supported: github, openai, anthropic, zai\n`); } private handlePairCommand(action?: 'generate' | 'list' | 'revoke', args?: string): void {