From 4b924851a8616e9aa48cf51ed66b11524aeca4bf Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Sat, 31 Jan 2026 11:22:58 +0900 Subject: [PATCH] resolved #35 --- resources/global/en/config.yaml | 6 + resources/global/ja/config.yaml | 6 + src/__tests__/apiKeyAuth.test.ts | 289 ++++++++++++++++++++++++++++++ src/claude/client.ts | 5 + src/claude/executor.ts | 11 ++ src/claude/process.ts | 2 + src/claude/stream-converter.ts | 4 + src/claude/types.ts | 1 + src/codex/client.ts | 5 +- src/commands/interactive.ts | 69 ++++--- src/commands/session.ts | 7 +- src/commands/workflowExecution.ts | 6 +- src/config/globalConfig.ts | 40 +++++ src/config/sessionStore.ts | 27 ++- src/models/schemas.ts | 4 + src/models/types.ts | 4 + src/providers/claude.ts | 3 + src/providers/codex.ts | 3 + src/providers/index.ts | 4 + src/utils/ui.ts | 7 +- 20 files changed, 462 insertions(+), 41 deletions(-) create mode 100644 src/__tests__/apiKeyAuth.test.ts diff --git a/resources/global/en/config.yaml b/resources/global/en/config.yaml index 880636f..2d8628d 100644 --- a/resources/global/en/config.yaml +++ b/resources/global/en/config.yaml @@ -21,6 +21,12 @@ provider: claude # Codex: gpt-5.2-codex, gpt-5.1-codex, etc. # model: sonnet +# Anthropic API key (optional, overridden by TAKT_ANTHROPIC_API_KEY env var) +# anthropic_api_key: "" + +# OpenAI API key (optional, overridden by TAKT_OPENAI_API_KEY env var) +# openai_api_key: "" + # Debug settings (optional) # debug: # enabled: false diff --git a/resources/global/ja/config.yaml b/resources/global/ja/config.yaml index 80bd3f6..788906d 100644 --- a/resources/global/ja/config.yaml +++ b/resources/global/ja/config.yaml @@ -21,6 +21,12 @@ provider: claude # Codex: gpt-5.2-codex, gpt-5.1-codex など # model: sonnet +# Anthropic APIキー (オプション、環境変数 TAKT_ANTHROPIC_API_KEY で上書き可能) +# anthropic_api_key: "" + +# OpenAI APIキー (オプション、環境変数 TAKT_OPENAI_API_KEY で上書き可能) +# openai_api_key: "" + # デバッグ設定 (オプション) # debug: # enabled: false diff --git a/src/__tests__/apiKeyAuth.test.ts b/src/__tests__/apiKeyAuth.test.ts new file mode 100644 index 0000000..25b5bec --- /dev/null +++ b/src/__tests__/apiKeyAuth.test.ts @@ -0,0 +1,289 @@ +/** + * Tests for API key authentication feature + * + * Tests the resolution logic for Anthropic and OpenAI API keys: + * - Environment variable priority over config.yaml + * - Config.yaml fallback when env var is not set + * - Undefined when neither is set + * - Schema validation for API key fields + * - GlobalConfig load/save round-trip with API keys + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; +import { GlobalConfigSchema } from '../models/schemas.js'; + +// Mock paths module to redirect config to temp directory +const testId = randomUUID(); +const testDir = join(tmpdir(), `takt-api-key-test-${testId}`); +const taktDir = join(testDir, '.takt'); +const configPath = join(taktDir, 'config.yaml'); + +vi.mock('../config/paths.js', async (importOriginal) => { + const original = await importOriginal() as Record; + return { + ...original, + getGlobalConfigPath: () => configPath, + getTaktDir: () => taktDir, + }; +}); + +// Import after mocking +const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey } = await import('../config/globalConfig.js'); + +describe('GlobalConfigSchema API key fields', () => { + it('should accept config without API keys', () => { + const result = GlobalConfigSchema.parse({ + language: 'en', + }); + expect(result.anthropic_api_key).toBeUndefined(); + expect(result.openai_api_key).toBeUndefined(); + }); + + it('should accept config with anthropic_api_key', () => { + const result = GlobalConfigSchema.parse({ + language: 'en', + anthropic_api_key: 'sk-ant-test-key', + }); + expect(result.anthropic_api_key).toBe('sk-ant-test-key'); + }); + + it('should accept config with openai_api_key', () => { + const result = GlobalConfigSchema.parse({ + language: 'en', + openai_api_key: 'sk-openai-test-key', + }); + expect(result.openai_api_key).toBe('sk-openai-test-key'); + }); + + it('should accept config with both API keys', () => { + const result = GlobalConfigSchema.parse({ + language: 'en', + anthropic_api_key: 'sk-ant-key', + openai_api_key: 'sk-openai-key', + }); + expect(result.anthropic_api_key).toBe('sk-ant-key'); + expect(result.openai_api_key).toBe('sk-openai-key'); + }); +}); + +describe('GlobalConfig load/save with API keys', () => { + beforeEach(() => { + mkdirSync(taktDir, { recursive: true }); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should load config with API keys from YAML', () => { + const yaml = [ + 'language: en', + 'trusted_directories: []', + 'default_workflow: default', + 'log_level: info', + 'provider: claude', + 'anthropic_api_key: sk-ant-from-yaml', + 'openai_api_key: sk-openai-from-yaml', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const config = loadGlobalConfig(); + expect(config.anthropicApiKey).toBe('sk-ant-from-yaml'); + expect(config.openaiApiKey).toBe('sk-openai-from-yaml'); + }); + + it('should load config without API keys', () => { + const yaml = [ + 'language: en', + 'trusted_directories: []', + 'default_workflow: default', + 'log_level: info', + 'provider: claude', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const config = loadGlobalConfig(); + expect(config.anthropicApiKey).toBeUndefined(); + expect(config.openaiApiKey).toBeUndefined(); + }); + + it('should save and reload config with API keys', () => { + // Write initial config + const yaml = [ + 'language: en', + 'trusted_directories: []', + 'default_workflow: default', + 'log_level: info', + 'provider: claude', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const config = loadGlobalConfig(); + config.anthropicApiKey = 'sk-ant-saved'; + config.openaiApiKey = 'sk-openai-saved'; + saveGlobalConfig(config); + + const reloaded = loadGlobalConfig(); + expect(reloaded.anthropicApiKey).toBe('sk-ant-saved'); + expect(reloaded.openaiApiKey).toBe('sk-openai-saved'); + }); + + it('should not persist API keys when not set', () => { + const yaml = [ + 'language: en', + 'trusted_directories: []', + 'default_workflow: default', + 'log_level: info', + 'provider: claude', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const config = loadGlobalConfig(); + saveGlobalConfig(config); + + const content = readFileSync(configPath, 'utf-8'); + expect(content).not.toContain('anthropic_api_key'); + expect(content).not.toContain('openai_api_key'); + }); +}); + +describe('resolveAnthropicApiKey', () => { + const originalEnv = process.env['TAKT_ANTHROPIC_API_KEY']; + + beforeEach(() => { + mkdirSync(taktDir, { recursive: true }); + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env['TAKT_ANTHROPIC_API_KEY'] = originalEnv; + } else { + delete process.env['TAKT_ANTHROPIC_API_KEY']; + } + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should return env var when set', () => { + process.env['TAKT_ANTHROPIC_API_KEY'] = 'sk-ant-from-env'; + const yaml = [ + 'language: en', + 'trusted_directories: []', + 'default_workflow: default', + 'log_level: info', + 'provider: claude', + 'anthropic_api_key: sk-ant-from-yaml', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const key = resolveAnthropicApiKey(); + expect(key).toBe('sk-ant-from-env'); + }); + + it('should fall back to config when env var is not set', () => { + delete process.env['TAKT_ANTHROPIC_API_KEY']; + const yaml = [ + 'language: en', + 'trusted_directories: []', + 'default_workflow: default', + 'log_level: info', + 'provider: claude', + 'anthropic_api_key: sk-ant-from-yaml', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const key = resolveAnthropicApiKey(); + expect(key).toBe('sk-ant-from-yaml'); + }); + + it('should return undefined when neither env var nor config is set', () => { + delete process.env['TAKT_ANTHROPIC_API_KEY']; + const yaml = [ + 'language: en', + 'trusted_directories: []', + 'default_workflow: default', + 'log_level: info', + 'provider: claude', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const key = resolveAnthropicApiKey(); + expect(key).toBeUndefined(); + }); + + it('should return undefined when config file does not exist', () => { + delete process.env['TAKT_ANTHROPIC_API_KEY']; + // No config file created + rmSync(testDir, { recursive: true, force: true }); + + const key = resolveAnthropicApiKey(); + expect(key).toBeUndefined(); + }); +}); + +describe('resolveOpenaiApiKey', () => { + const originalEnv = process.env['TAKT_OPENAI_API_KEY']; + + beforeEach(() => { + mkdirSync(taktDir, { recursive: true }); + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env['TAKT_OPENAI_API_KEY'] = originalEnv; + } else { + delete process.env['TAKT_OPENAI_API_KEY']; + } + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should return env var when set', () => { + process.env['TAKT_OPENAI_API_KEY'] = 'sk-openai-from-env'; + const yaml = [ + 'language: en', + 'trusted_directories: []', + 'default_workflow: default', + 'log_level: info', + 'provider: claude', + 'openai_api_key: sk-openai-from-yaml', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const key = resolveOpenaiApiKey(); + expect(key).toBe('sk-openai-from-env'); + }); + + it('should fall back to config when env var is not set', () => { + delete process.env['TAKT_OPENAI_API_KEY']; + const yaml = [ + 'language: en', + 'trusted_directories: []', + 'default_workflow: default', + 'log_level: info', + 'provider: claude', + 'openai_api_key: sk-openai-from-yaml', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const key = resolveOpenaiApiKey(); + expect(key).toBe('sk-openai-from-yaml'); + }); + + it('should return undefined when neither env var nor config is set', () => { + delete process.env['TAKT_OPENAI_API_KEY']; + const yaml = [ + 'language: en', + 'trusted_directories: []', + 'default_workflow: default', + 'log_level: info', + 'provider: claude', + ].join('\n'); + writeFileSync(configPath, yaml, 'utf-8'); + + const key = resolveOpenaiApiKey(); + expect(key).toBeUndefined(); + }); +}); diff --git a/src/claude/client.ts b/src/claude/client.ts index 5aa6bbc..f37a19c 100644 --- a/src/claude/client.ts +++ b/src/claude/client.ts @@ -31,6 +31,8 @@ export interface ClaudeCallOptions { onAskUserQuestion?: AskUserQuestionHandler; /** Bypass all permission checks (sacrifice-my-pc mode) */ bypassPermissions?: boolean; + /** Anthropic API key to inject via env (bypasses CLI auth) */ + anthropicApiKey?: string; } /** @@ -108,6 +110,7 @@ export async function callClaude( onPermissionRequest: options.onPermissionRequest, onAskUserQuestion: options.onAskUserQuestion, bypassPermissions: options.bypassPermissions, + anthropicApiKey: options.anthropicApiKey, }; const result = await executeClaudeCli(prompt, spawnOptions); @@ -146,6 +149,7 @@ export async function callClaudeCustom( onPermissionRequest: options.onPermissionRequest, onAskUserQuestion: options.onAskUserQuestion, bypassPermissions: options.bypassPermissions, + anthropicApiKey: options.anthropicApiKey, }; const result = await executeClaudeCli(prompt, spawnOptions); @@ -272,6 +276,7 @@ export async function callClaudeSkill( onPermissionRequest: options.onPermissionRequest, onAskUserQuestion: options.onAskUserQuestion, bypassPermissions: options.bypassPermissions, + anthropicApiKey: options.anthropicApiKey, }; const result = await executeClaudeCli(fullPrompt, spawnOptions); diff --git a/src/claude/executor.ts b/src/claude/executor.ts index 6d4d14c..276b1e2 100644 --- a/src/claude/executor.ts +++ b/src/claude/executor.ts @@ -25,6 +25,7 @@ import { createCanUseToolCallback, createAskUserQuestionHooks, } from './options-builder.js'; +import { resolveAnthropicApiKey } from '../config/globalConfig.js'; import type { StreamCallback, PermissionHandler, @@ -49,6 +50,8 @@ export interface ExecuteOptions { onAskUserQuestion?: AskUserQuestionHandler; /** Bypass all permission checks (sacrifice-my-pc mode) */ bypassPermissions?: boolean; + /** Anthropic API key to inject via env (bypasses CLI auth) */ + anthropicApiKey?: string; } /** @@ -91,6 +94,14 @@ function buildSdkOptions(options: ExecuteOptions): Options { if (canUseTool) sdkOptions.canUseTool = canUseTool; if (hooks) sdkOptions.hooks = hooks; + const anthropicApiKey = options.anthropicApiKey ?? resolveAnthropicApiKey(); + if (anthropicApiKey) { + sdkOptions.env = { + ...process.env as Record, + ANTHROPIC_API_KEY: anthropicApiKey, + }; + } + if (options.onStream) { sdkOptions.includePartialMessages = true; } diff --git a/src/claude/process.ts b/src/claude/process.ts index a62bbbe..2bd8e08 100644 --- a/src/claude/process.ts +++ b/src/claude/process.ts @@ -69,6 +69,8 @@ export interface ClaudeSpawnOptions { onAskUserQuestion?: AskUserQuestionHandler; /** Bypass all permission checks (sacrifice-my-pc mode) */ bypassPermissions?: boolean; + /** Anthropic API key to inject via env (bypasses CLI auth) */ + anthropicApiKey?: string; } /** diff --git a/src/claude/stream-converter.ts b/src/claude/stream-converter.ts index 4b38bb9..08e157c 100644 --- a/src/claude/stream-converter.ts +++ b/src/claude/stream-converter.ts @@ -154,12 +154,16 @@ export function sdkMessageToStreamEvent( case 'result': { const resultMsg = message as SDKResultMessage; + const errors = resultMsg.subtype !== 'success' && resultMsg.errors?.length + ? resultMsg.errors.join('\n') + : undefined; callback({ type: 'result', data: { result: resultMsg.subtype === 'success' ? resultMsg.result : '', sessionId: resultMsg.session_id, success: resultMsg.subtype === 'success', + error: errors, }, }); break; diff --git a/src/claude/types.ts b/src/claude/types.ts index 3db4a84..c395217 100644 --- a/src/claude/types.ts +++ b/src/claude/types.ts @@ -44,6 +44,7 @@ export interface ResultEventData { result: string; sessionId: string; success: boolean; + error?: string; } export interface ErrorEventData { diff --git a/src/codex/client.ts b/src/codex/client.ts index 9ae70aa..d4de459 100644 --- a/src/codex/client.ts +++ b/src/codex/client.ts @@ -19,6 +19,8 @@ export interface CodexCallOptions { systemPrompt?: string; /** Enable streaming mode with callback (best-effort) */ onStream?: StreamCallback; + /** OpenAI API key (bypasses CLI auth) */ + openaiApiKey?: string; } function extractThreadId(value: unknown): string | undefined { @@ -94,6 +96,7 @@ function emitResult( result, sessionId: sessionId || 'unknown', success, + error: success ? undefined : result || undefined, }, }); } @@ -341,7 +344,7 @@ export async function callCodex( prompt: string, options: CodexCallOptions ): Promise { - const codex = new Codex(); + const codex = new Codex(options.openaiApiKey ? { apiKey: options.openaiApiKey } : undefined); const threadOptions = { model: options.model, workingDirectory: options.cwd, diff --git a/src/commands/interactive.ts b/src/commands/interactive.ts index 4aa0a1b..6396ac4 100644 --- a/src/commands/interactive.ts +++ b/src/commands/interactive.ts @@ -44,6 +44,7 @@ interface ConversationMessage { interface CallAIResult { content: string; sessionId?: string; + success: boolean; } /** @@ -111,7 +112,8 @@ async function callAI( }); display.flush(); - return { content: response.content, sessionId: response.sessionId }; + const success = response.status !== 'blocked'; + return { content: response.content, sessionId: response.sessionId, success }; } export interface InteractiveModeResult { @@ -138,7 +140,7 @@ export async function interactiveMode(cwd: string, initialInput?: string): Promi const history: ConversationMessage[] = []; const agentName = 'interactive'; - const savedSessions = loadAgentSessions(cwd); + const savedSessions = loadAgentSessions(cwd, providerType); let sessionId: string | undefined = savedSessions[agentName]; info('Interactive mode - describe your task. Commands: /go (execute), /cancel (exit)'); @@ -147,26 +149,47 @@ export async function interactiveMode(cwd: string, initialInput?: string): Promi } console.log(); + /** Call AI with automatic retry on session error (stale/invalid session ID). */ + async function callAIWithRetry(prompt: string): Promise { + const display = new StreamDisplay('assistant'); + try { + const result = await callAI(provider, prompt, cwd, model, sessionId, display); + // If session failed, clear it and retry without session + if (!result.success && sessionId) { + log.info('Session invalid, retrying without session'); + sessionId = undefined; + const retryDisplay = new StreamDisplay('assistant'); + const retry = await callAI(provider, prompt, cwd, model, undefined, retryDisplay); + if (retry.sessionId) { + sessionId = retry.sessionId; + updateAgentSession(cwd, agentName, sessionId, providerType); + } + return retry; + } + if (result.sessionId) { + sessionId = result.sessionId; + updateAgentSession(cwd, agentName, sessionId, providerType); + } + return result; + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + log.error('AI call failed', { error: msg }); + console.log(chalk.red(`Error: ${msg}`)); + console.log(); + return null; + } + } + // Process initial input if provided (e.g. from `takt a`) if (initialInput) { history.push({ role: 'user', content: initialInput }); - log.debug('Processing initial input', { initialInput, sessionId }); - const display = new StreamDisplay('assistant'); - try { - const result = await callAI(provider, initialInput, cwd, model, sessionId, display); - if (result.sessionId) { - sessionId = result.sessionId; - updateAgentSession(cwd, agentName, sessionId); - } + const result = await callAIWithRetry(initialInput); + if (result) { history.push({ role: 'assistant', content: result.content }); console.log(); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - log.error('AI call failed for initial input', { error: msg }); - console.log(chalk.red(`Error: ${msg}`)); - console.log(); + } else { history.pop(); } } @@ -211,21 +234,11 @@ export async function interactiveMode(cwd: string, initialInput?: string): Promi log.debug('Sending to AI', { messageCount: history.length, sessionId }); process.stdin.pause(); - const display = new StreamDisplay('assistant'); - try { - const result = await callAI(provider, trimmed, cwd, model, sessionId, display); - if (result.sessionId) { - sessionId = result.sessionId; - updateAgentSession(cwd, agentName, sessionId); - } + const result = await callAIWithRetry(trimmed); + if (result) { history.push({ role: 'assistant', content: result.content }); console.log(); - } catch (e) { - const msg = e instanceof Error ? e.message : String(e); - log.error('AI call failed', { error: msg }); - console.log(); - console.log(chalk.red(`Error: ${msg}`)); - console.log(); + } else { history.pop(); } } diff --git a/src/commands/session.ts b/src/commands/session.ts index fbe4a6d..56d971c 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -12,15 +12,16 @@ import type { AgentResponse } from '../models/types.js'; export async function withAgentSession( cwd: string, agentName: string, - fn: (sessionId?: string) => Promise + fn: (sessionId?: string) => Promise, + provider?: string ): Promise { - const sessions = loadAgentSessions(cwd); + const sessions = loadAgentSessions(cwd, provider); const sessionId = sessions[agentName]; const result = await fn(sessionId); if (result.sessionId) { - updateAgentSession(cwd, agentName, result.sessionId); + updateAgentSession(cwd, agentName, result.sessionId, provider); } return result; diff --git a/src/commands/workflowExecution.ts b/src/commands/workflowExecution.ts index 609c6b6..dd82cb4 100644 --- a/src/commands/workflowExecution.ts +++ b/src/commands/workflowExecution.ts @@ -12,6 +12,7 @@ import { loadWorktreeSessions, updateWorktreeSession, } from '../config/paths.js'; +import { loadGlobalConfig } from '../config/globalConfig.js'; import { header, info, @@ -131,9 +132,10 @@ export async function executeWorkflow( // Load saved agent sessions for continuity (from project root or clone-specific storage) const isWorktree = cwd !== projectCwd; + const currentProvider = loadGlobalConfig().provider ?? 'claude'; const savedSessions = isWorktree ? loadWorktreeSessions(projectCwd, cwd) - : loadAgentSessions(projectCwd); + : loadAgentSessions(projectCwd, currentProvider); // Session update handler - persist session IDs when they change // Clone sessions are stored separately per clone path @@ -142,7 +144,7 @@ export async function executeWorkflow( updateWorktreeSession(projectCwd, cwd, agentName, agentSessionId); } : (agentName: string, agentSessionId: string): void => { - updateAgentSession(projectCwd, agentName, agentSessionId); + updateAgentSession(projectCwd, agentName, agentSessionId, currentProvider); }; const iterationLimitHandler = async ( diff --git a/src/config/globalConfig.ts b/src/config/globalConfig.ts index 9782a12..5e21abb 100644 --- a/src/config/globalConfig.ts +++ b/src/config/globalConfig.ts @@ -37,6 +37,8 @@ export function loadGlobalConfig(): GlobalConfig { } : undefined, worktreeDir: parsed.worktree_dir, disabledBuiltins: parsed.disabled_builtins, + anthropicApiKey: parsed.anthropic_api_key, + openaiApiKey: parsed.openai_api_key, }; } @@ -65,6 +67,12 @@ export function saveGlobalConfig(config: GlobalConfig): void { if (config.disabledBuiltins && config.disabledBuiltins.length > 0) { raw.disabled_builtins = config.disabledBuiltins; } + if (config.anthropicApiKey) { + raw.anthropic_api_key = config.anthropicApiKey; + } + if (config.openaiApiKey) { + raw.openai_api_key = config.openaiApiKey; + } writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); } @@ -121,6 +129,38 @@ export function isDirectoryTrusted(dir: string): boolean { ); } +/** + * Resolve the Anthropic API key. + * Priority: TAKT_ANTHROPIC_API_KEY env var > config.yaml > undefined (CLI auth fallback) + */ +export function resolveAnthropicApiKey(): string | undefined { + const envKey = process.env['TAKT_ANTHROPIC_API_KEY']; + if (envKey) return envKey; + + try { + const config = loadGlobalConfig(); + return config.anthropicApiKey; + } catch { + return undefined; + } +} + +/** + * Resolve the OpenAI API key. + * Priority: TAKT_OPENAI_API_KEY env var > config.yaml > undefined (CLI auth fallback) + */ +export function resolveOpenaiApiKey(): string | undefined { + const envKey = process.env['TAKT_OPENAI_API_KEY']; + if (envKey) return envKey; + + try { + const config = loadGlobalConfig(); + return config.openaiApiKey; + } catch { + return undefined; + } +} + /** Load project-level debug configuration (from .takt/config.yaml) */ export function loadProjectDebugConfig(projectDir: string): DebugConfig | undefined { const configPath = getProjectConfigPath(projectDir); diff --git a/src/config/sessionStore.ts b/src/config/sessionStore.ts index 22f32c5..9c7857e 100644 --- a/src/config/sessionStore.ts +++ b/src/config/sessionStore.ts @@ -88,6 +88,8 @@ export function addToInputHistory(projectDir: string, input: string): void { export interface AgentSessionData { agentSessions: Record; updatedAt: string; + /** Provider that created these sessions (claude, codex, etc.) */ + provider?: string; } /** Get path for storing agent sessions */ @@ -95,13 +97,17 @@ export function getAgentSessionsPath(projectDir: string): string { return join(getProjectConfigDir(projectDir), 'agent_sessions.json'); } -/** Load saved agent sessions */ -export function loadAgentSessions(projectDir: string): Record { +/** Load saved agent sessions. Returns empty if provider has changed. */ +export function loadAgentSessions(projectDir: string, currentProvider?: string): Record { const path = getAgentSessionsPath(projectDir); if (existsSync(path)) { try { const content = readFileSync(path, 'utf-8'); const data = JSON.parse(content) as AgentSessionData; + // If provider has changed or is unknown (legacy data), sessions are incompatible — discard them + if (currentProvider && data.provider !== currentProvider) { + return {}; + } return data.agentSessions || {}; } catch { return {}; @@ -113,13 +119,15 @@ export function loadAgentSessions(projectDir: string): Record { /** Save agent sessions (atomic write) */ export function saveAgentSessions( projectDir: string, - sessions: Record + sessions: Record, + provider?: string ): void { const path = getAgentSessionsPath(projectDir); ensureDir(getProjectConfigDir(projectDir)); const data: AgentSessionData = { agentSessions: sessions, updatedAt: new Date().toISOString(), + provider, }; writeFileAtomic(path, JSON.stringify(data, null, 2)); } @@ -131,17 +139,25 @@ export function saveAgentSessions( export function updateAgentSession( projectDir: string, agentName: string, - sessionId: string + sessionId: string, + provider?: string ): void { const path = getAgentSessionsPath(projectDir); ensureDir(getProjectConfigDir(projectDir)); let sessions: Record = {}; + let existingProvider: string | undefined; if (existsSync(path)) { try { const content = readFileSync(path, 'utf-8'); const data = JSON.parse(content) as AgentSessionData; - sessions = data.agentSessions || {}; + existingProvider = data.provider; + // If provider changed, discard old sessions + if (provider && existingProvider && existingProvider !== provider) { + sessions = {}; + } else { + sessions = data.agentSessions || {}; + } } catch { sessions = {}; } @@ -152,6 +168,7 @@ export function updateAgentSession( const data: AgentSessionData = { agentSessions: sessions, updatedAt: new Date().toISOString(), + provider: provider ?? existingProvider, }; writeFileAtomic(path, JSON.stringify(data, null, 2)); } diff --git a/src/models/schemas.ts b/src/models/schemas.ts index 843af2f..42e1fe7 100644 --- a/src/models/schemas.ts +++ b/src/models/schemas.ts @@ -172,6 +172,10 @@ export const GlobalConfigSchema = z.object({ worktree_dir: z.string().optional(), /** List of builtin workflow/agent names to exclude from fallback loading */ disabled_builtins: z.array(z.string()).optional().default([]), + /** Anthropic API key for Claude Code SDK (overridden by TAKT_ANTHROPIC_API_KEY env var) */ + anthropic_api_key: z.string().optional(), + /** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */ + openai_api_key: z.string().optional(), }); /** Project config schema */ diff --git a/src/models/types.ts b/src/models/types.ts index 2783c5f..f9d31a3 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -191,6 +191,10 @@ export interface GlobalConfig { worktreeDir?: string; /** List of builtin workflow/agent names to exclude from fallback loading */ disabledBuiltins?: string[]; + /** Anthropic API key for Claude Code SDK (overridden by TAKT_ANTHROPIC_API_KEY env var) */ + anthropicApiKey?: string; + /** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */ + openaiApiKey?: string; } /** Project-level configuration */ diff --git a/src/providers/claude.ts b/src/providers/claude.ts index 9c8aec3..8b7870f 100644 --- a/src/providers/claude.ts +++ b/src/providers/claude.ts @@ -3,6 +3,7 @@ */ import { callClaude, callClaudeCustom, type ClaudeCallOptions } from '../claude/client.js'; +import { resolveAnthropicApiKey } from '../config/globalConfig.js'; import type { AgentResponse } from '../models/types.js'; import type { Provider, ProviderCallOptions } from './index.js'; @@ -21,6 +22,7 @@ export class ClaudeProvider implements Provider { onPermissionRequest: options.onPermissionRequest, onAskUserQuestion: options.onAskUserQuestion, bypassPermissions: options.bypassPermissions, + anthropicApiKey: options.anthropicApiKey ?? resolveAnthropicApiKey(), }; return callClaude(agentName, prompt, callOptions); @@ -38,6 +40,7 @@ export class ClaudeProvider implements Provider { onPermissionRequest: options.onPermissionRequest, onAskUserQuestion: options.onAskUserQuestion, bypassPermissions: options.bypassPermissions, + anthropicApiKey: options.anthropicApiKey ?? resolveAnthropicApiKey(), }; return callClaudeCustom(agentName, prompt, systemPrompt, callOptions); diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 119fb99..3e7a9db 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -3,6 +3,7 @@ */ import { callCodex, callCodexCustom, type CodexCallOptions } from '../codex/client.js'; +import { resolveOpenaiApiKey } from '../config/globalConfig.js'; import type { AgentResponse } from '../models/types.js'; import type { Provider, ProviderCallOptions } from './index.js'; @@ -15,6 +16,7 @@ export class CodexProvider implements Provider { model: options.model, systemPrompt: options.systemPrompt, onStream: options.onStream, + openaiApiKey: options.openaiApiKey ?? resolveOpenaiApiKey(), }; return callCodex(agentName, prompt, callOptions); @@ -26,6 +28,7 @@ export class CodexProvider implements Provider { sessionId: options.sessionId, model: options.model, onStream: options.onStream, + openaiApiKey: options.openaiApiKey ?? resolveOpenaiApiKey(), }; return callCodexCustom(agentName, prompt, systemPrompt, callOptions); diff --git a/src/providers/index.ts b/src/providers/index.ts index 5b013d0..7c60484 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -26,6 +26,10 @@ export interface ProviderCallOptions { onPermissionRequest?: PermissionHandler; onAskUserQuestion?: AskUserQuestionHandler; bypassPermissions?: boolean; + /** Anthropic API key for Claude provider */ + anthropicApiKey?: string; + /** OpenAI API key for Codex provider */ + openaiApiKey?: string; } /** Provider interface - all providers must implement this */ diff --git a/src/utils/ui.ts b/src/utils/ui.ts index 9b7c0c1..9d83232 100644 --- a/src/utils/ui.ts +++ b/src/utils/ui.ts @@ -327,7 +327,7 @@ export class StreamDisplay { } /** Display final result */ - showResult(success: boolean): void { + showResult(success: boolean, error?: string): void { this.stopToolSpinner(); this.flushThinking(); this.flushText(); @@ -336,6 +336,9 @@ export class StreamDisplay { console.log(chalk.green('✓ Complete')); } else { console.log(chalk.red('✗ Failed')); + if (error) { + console.log(chalk.red(` ${error}`)); + } } } @@ -378,7 +381,7 @@ export class StreamDisplay { this.showThinking(event.data.thinking); break; case 'result': - this.showResult(event.data.success); + this.showResult(event.data.success, event.data.error); break; case 'error': // Parse errors are logged but not displayed to user