This commit is contained in:
nrslib 2026-01-31 11:22:58 +09:00
parent 6468fa6345
commit 4b924851a8
20 changed files with 462 additions and 41 deletions

View File

@ -21,6 +21,12 @@ provider: claude
# Codex: gpt-5.2-codex, gpt-5.1-codex, etc. # Codex: gpt-5.2-codex, gpt-5.1-codex, etc.
# model: sonnet # 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 settings (optional)
# debug: # debug:
# enabled: false # enabled: false

View File

@ -21,6 +21,12 @@ provider: claude
# Codex: gpt-5.2-codex, gpt-5.1-codex など # Codex: gpt-5.2-codex, gpt-5.1-codex など
# model: sonnet # model: sonnet
# Anthropic APIキー (オプション、環境変数 TAKT_ANTHROPIC_API_KEY で上書き可能)
# anthropic_api_key: ""
# OpenAI APIキー (オプション、環境変数 TAKT_OPENAI_API_KEY で上書き可能)
# openai_api_key: ""
# デバッグ設定 (オプション) # デバッグ設定 (オプション)
# debug: # debug:
# enabled: false # enabled: false

View File

@ -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<string, unknown>;
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();
});
});

View File

@ -31,6 +31,8 @@ export interface ClaudeCallOptions {
onAskUserQuestion?: AskUserQuestionHandler; onAskUserQuestion?: AskUserQuestionHandler;
/** Bypass all permission checks (sacrifice-my-pc mode) */ /** Bypass all permission checks (sacrifice-my-pc mode) */
bypassPermissions?: boolean; 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, onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion, onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions, bypassPermissions: options.bypassPermissions,
anthropicApiKey: options.anthropicApiKey,
}; };
const result = await executeClaudeCli(prompt, spawnOptions); const result = await executeClaudeCli(prompt, spawnOptions);
@ -146,6 +149,7 @@ export async function callClaudeCustom(
onPermissionRequest: options.onPermissionRequest, onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion, onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions, bypassPermissions: options.bypassPermissions,
anthropicApiKey: options.anthropicApiKey,
}; };
const result = await executeClaudeCli(prompt, spawnOptions); const result = await executeClaudeCli(prompt, spawnOptions);
@ -272,6 +276,7 @@ export async function callClaudeSkill(
onPermissionRequest: options.onPermissionRequest, onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion, onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions, bypassPermissions: options.bypassPermissions,
anthropicApiKey: options.anthropicApiKey,
}; };
const result = await executeClaudeCli(fullPrompt, spawnOptions); const result = await executeClaudeCli(fullPrompt, spawnOptions);

View File

@ -25,6 +25,7 @@ import {
createCanUseToolCallback, createCanUseToolCallback,
createAskUserQuestionHooks, createAskUserQuestionHooks,
} from './options-builder.js'; } from './options-builder.js';
import { resolveAnthropicApiKey } from '../config/globalConfig.js';
import type { import type {
StreamCallback, StreamCallback,
PermissionHandler, PermissionHandler,
@ -49,6 +50,8 @@ export interface ExecuteOptions {
onAskUserQuestion?: AskUserQuestionHandler; onAskUserQuestion?: AskUserQuestionHandler;
/** Bypass all permission checks (sacrifice-my-pc mode) */ /** Bypass all permission checks (sacrifice-my-pc mode) */
bypassPermissions?: boolean; 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 (canUseTool) sdkOptions.canUseTool = canUseTool;
if (hooks) sdkOptions.hooks = hooks; if (hooks) sdkOptions.hooks = hooks;
const anthropicApiKey = options.anthropicApiKey ?? resolveAnthropicApiKey();
if (anthropicApiKey) {
sdkOptions.env = {
...process.env as Record<string, string>,
ANTHROPIC_API_KEY: anthropicApiKey,
};
}
if (options.onStream) { if (options.onStream) {
sdkOptions.includePartialMessages = true; sdkOptions.includePartialMessages = true;
} }

View File

@ -69,6 +69,8 @@ export interface ClaudeSpawnOptions {
onAskUserQuestion?: AskUserQuestionHandler; onAskUserQuestion?: AskUserQuestionHandler;
/** Bypass all permission checks (sacrifice-my-pc mode) */ /** Bypass all permission checks (sacrifice-my-pc mode) */
bypassPermissions?: boolean; bypassPermissions?: boolean;
/** Anthropic API key to inject via env (bypasses CLI auth) */
anthropicApiKey?: string;
} }
/** /**

View File

@ -154,12 +154,16 @@ export function sdkMessageToStreamEvent(
case 'result': { case 'result': {
const resultMsg = message as SDKResultMessage; const resultMsg = message as SDKResultMessage;
const errors = resultMsg.subtype !== 'success' && resultMsg.errors?.length
? resultMsg.errors.join('\n')
: undefined;
callback({ callback({
type: 'result', type: 'result',
data: { data: {
result: resultMsg.subtype === 'success' ? resultMsg.result : '', result: resultMsg.subtype === 'success' ? resultMsg.result : '',
sessionId: resultMsg.session_id, sessionId: resultMsg.session_id,
success: resultMsg.subtype === 'success', success: resultMsg.subtype === 'success',
error: errors,
}, },
}); });
break; break;

View File

@ -44,6 +44,7 @@ export interface ResultEventData {
result: string; result: string;
sessionId: string; sessionId: string;
success: boolean; success: boolean;
error?: string;
} }
export interface ErrorEventData { export interface ErrorEventData {

View File

@ -19,6 +19,8 @@ export interface CodexCallOptions {
systemPrompt?: string; systemPrompt?: string;
/** Enable streaming mode with callback (best-effort) */ /** Enable streaming mode with callback (best-effort) */
onStream?: StreamCallback; onStream?: StreamCallback;
/** OpenAI API key (bypasses CLI auth) */
openaiApiKey?: string;
} }
function extractThreadId(value: unknown): string | undefined { function extractThreadId(value: unknown): string | undefined {
@ -94,6 +96,7 @@ function emitResult(
result, result,
sessionId: sessionId || 'unknown', sessionId: sessionId || 'unknown',
success, success,
error: success ? undefined : result || undefined,
}, },
}); });
} }
@ -341,7 +344,7 @@ export async function callCodex(
prompt: string, prompt: string,
options: CodexCallOptions options: CodexCallOptions
): Promise<AgentResponse> { ): Promise<AgentResponse> {
const codex = new Codex(); const codex = new Codex(options.openaiApiKey ? { apiKey: options.openaiApiKey } : undefined);
const threadOptions = { const threadOptions = {
model: options.model, model: options.model,
workingDirectory: options.cwd, workingDirectory: options.cwd,

View File

@ -44,6 +44,7 @@ interface ConversationMessage {
interface CallAIResult { interface CallAIResult {
content: string; content: string;
sessionId?: string; sessionId?: string;
success: boolean;
} }
/** /**
@ -111,7 +112,8 @@ async function callAI(
}); });
display.flush(); 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 { export interface InteractiveModeResult {
@ -138,7 +140,7 @@ export async function interactiveMode(cwd: string, initialInput?: string): Promi
const history: ConversationMessage[] = []; const history: ConversationMessage[] = [];
const agentName = 'interactive'; const agentName = 'interactive';
const savedSessions = loadAgentSessions(cwd); const savedSessions = loadAgentSessions(cwd, providerType);
let sessionId: string | undefined = savedSessions[agentName]; let sessionId: string | undefined = savedSessions[agentName];
info('Interactive mode - describe your task. Commands: /go (execute), /cancel (exit)'); 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(); console.log();
/** Call AI with automatic retry on session error (stale/invalid session ID). */
async function callAIWithRetry(prompt: string): Promise<CallAIResult | null> {
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`) // Process initial input if provided (e.g. from `takt a`)
if (initialInput) { if (initialInput) {
history.push({ role: 'user', content: initialInput }); history.push({ role: 'user', content: initialInput });
log.debug('Processing initial input', { initialInput, sessionId }); log.debug('Processing initial input', { initialInput, sessionId });
const display = new StreamDisplay('assistant'); const result = await callAIWithRetry(initialInput);
try { if (result) {
const result = await callAI(provider, initialInput, cwd, model, sessionId, display);
if (result.sessionId) {
sessionId = result.sessionId;
updateAgentSession(cwd, agentName, sessionId);
}
history.push({ role: 'assistant', content: result.content }); history.push({ role: 'assistant', content: result.content });
console.log(); console.log();
} catch (e) { } else {
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();
history.pop(); history.pop();
} }
} }
@ -211,21 +234,11 @@ export async function interactiveMode(cwd: string, initialInput?: string): Promi
log.debug('Sending to AI', { messageCount: history.length, sessionId }); log.debug('Sending to AI', { messageCount: history.length, sessionId });
process.stdin.pause(); process.stdin.pause();
const display = new StreamDisplay('assistant'); const result = await callAIWithRetry(trimmed);
try { if (result) {
const result = await callAI(provider, trimmed, cwd, model, sessionId, display);
if (result.sessionId) {
sessionId = result.sessionId;
updateAgentSession(cwd, agentName, sessionId);
}
history.push({ role: 'assistant', content: result.content }); history.push({ role: 'assistant', content: result.content });
console.log(); console.log();
} catch (e) { } else {
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();
history.pop(); history.pop();
} }
} }

View File

@ -12,15 +12,16 @@ import type { AgentResponse } from '../models/types.js';
export async function withAgentSession( export async function withAgentSession(
cwd: string, cwd: string,
agentName: string, agentName: string,
fn: (sessionId?: string) => Promise<AgentResponse> fn: (sessionId?: string) => Promise<AgentResponse>,
provider?: string
): Promise<AgentResponse> { ): Promise<AgentResponse> {
const sessions = loadAgentSessions(cwd); const sessions = loadAgentSessions(cwd, provider);
const sessionId = sessions[agentName]; const sessionId = sessions[agentName];
const result = await fn(sessionId); const result = await fn(sessionId);
if (result.sessionId) { if (result.sessionId) {
updateAgentSession(cwd, agentName, result.sessionId); updateAgentSession(cwd, agentName, result.sessionId, provider);
} }
return result; return result;

View File

@ -12,6 +12,7 @@ import {
loadWorktreeSessions, loadWorktreeSessions,
updateWorktreeSession, updateWorktreeSession,
} from '../config/paths.js'; } from '../config/paths.js';
import { loadGlobalConfig } from '../config/globalConfig.js';
import { import {
header, header,
info, info,
@ -131,9 +132,10 @@ export async function executeWorkflow(
// Load saved agent sessions for continuity (from project root or clone-specific storage) // Load saved agent sessions for continuity (from project root or clone-specific storage)
const isWorktree = cwd !== projectCwd; const isWorktree = cwd !== projectCwd;
const currentProvider = loadGlobalConfig().provider ?? 'claude';
const savedSessions = isWorktree const savedSessions = isWorktree
? loadWorktreeSessions(projectCwd, cwd) ? loadWorktreeSessions(projectCwd, cwd)
: loadAgentSessions(projectCwd); : loadAgentSessions(projectCwd, currentProvider);
// Session update handler - persist session IDs when they change // Session update handler - persist session IDs when they change
// Clone sessions are stored separately per clone path // Clone sessions are stored separately per clone path
@ -142,7 +144,7 @@ export async function executeWorkflow(
updateWorktreeSession(projectCwd, cwd, agentName, agentSessionId); updateWorktreeSession(projectCwd, cwd, agentName, agentSessionId);
} }
: (agentName: string, agentSessionId: string): void => { : (agentName: string, agentSessionId: string): void => {
updateAgentSession(projectCwd, agentName, agentSessionId); updateAgentSession(projectCwd, agentName, agentSessionId, currentProvider);
}; };
const iterationLimitHandler = async ( const iterationLimitHandler = async (

View File

@ -37,6 +37,8 @@ export function loadGlobalConfig(): GlobalConfig {
} : undefined, } : undefined,
worktreeDir: parsed.worktree_dir, worktreeDir: parsed.worktree_dir,
disabledBuiltins: parsed.disabled_builtins, 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) { if (config.disabledBuiltins && config.disabledBuiltins.length > 0) {
raw.disabled_builtins = config.disabledBuiltins; 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'); 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) */ /** Load project-level debug configuration (from .takt/config.yaml) */
export function loadProjectDebugConfig(projectDir: string): DebugConfig | undefined { export function loadProjectDebugConfig(projectDir: string): DebugConfig | undefined {
const configPath = getProjectConfigPath(projectDir); const configPath = getProjectConfigPath(projectDir);

View File

@ -88,6 +88,8 @@ export function addToInputHistory(projectDir: string, input: string): void {
export interface AgentSessionData { export interface AgentSessionData {
agentSessions: Record<string, string>; agentSessions: Record<string, string>;
updatedAt: string; updatedAt: string;
/** Provider that created these sessions (claude, codex, etc.) */
provider?: string;
} }
/** Get path for storing agent sessions */ /** Get path for storing agent sessions */
@ -95,13 +97,17 @@ export function getAgentSessionsPath(projectDir: string): string {
return join(getProjectConfigDir(projectDir), 'agent_sessions.json'); return join(getProjectConfigDir(projectDir), 'agent_sessions.json');
} }
/** Load saved agent sessions */ /** Load saved agent sessions. Returns empty if provider has changed. */
export function loadAgentSessions(projectDir: string): Record<string, string> { export function loadAgentSessions(projectDir: string, currentProvider?: string): Record<string, string> {
const path = getAgentSessionsPath(projectDir); const path = getAgentSessionsPath(projectDir);
if (existsSync(path)) { if (existsSync(path)) {
try { try {
const content = readFileSync(path, 'utf-8'); const content = readFileSync(path, 'utf-8');
const data = JSON.parse(content) as AgentSessionData; 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 || {}; return data.agentSessions || {};
} catch { } catch {
return {}; return {};
@ -113,13 +119,15 @@ export function loadAgentSessions(projectDir: string): Record<string, string> {
/** Save agent sessions (atomic write) */ /** Save agent sessions (atomic write) */
export function saveAgentSessions( export function saveAgentSessions(
projectDir: string, projectDir: string,
sessions: Record<string, string> sessions: Record<string, string>,
provider?: string
): void { ): void {
const path = getAgentSessionsPath(projectDir); const path = getAgentSessionsPath(projectDir);
ensureDir(getProjectConfigDir(projectDir)); ensureDir(getProjectConfigDir(projectDir));
const data: AgentSessionData = { const data: AgentSessionData = {
agentSessions: sessions, agentSessions: sessions,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
provider,
}; };
writeFileAtomic(path, JSON.stringify(data, null, 2)); writeFileAtomic(path, JSON.stringify(data, null, 2));
} }
@ -131,17 +139,25 @@ export function saveAgentSessions(
export function updateAgentSession( export function updateAgentSession(
projectDir: string, projectDir: string,
agentName: string, agentName: string,
sessionId: string sessionId: string,
provider?: string
): void { ): void {
const path = getAgentSessionsPath(projectDir); const path = getAgentSessionsPath(projectDir);
ensureDir(getProjectConfigDir(projectDir)); ensureDir(getProjectConfigDir(projectDir));
let sessions: Record<string, string> = {}; let sessions: Record<string, string> = {};
let existingProvider: string | undefined;
if (existsSync(path)) { if (existsSync(path)) {
try { try {
const content = readFileSync(path, 'utf-8'); const content = readFileSync(path, 'utf-8');
const data = JSON.parse(content) as AgentSessionData; const data = JSON.parse(content) as AgentSessionData;
existingProvider = data.provider;
// If provider changed, discard old sessions
if (provider && existingProvider && existingProvider !== provider) {
sessions = {};
} else {
sessions = data.agentSessions || {}; sessions = data.agentSessions || {};
}
} catch { } catch {
sessions = {}; sessions = {};
} }
@ -152,6 +168,7 @@ export function updateAgentSession(
const data: AgentSessionData = { const data: AgentSessionData = {
agentSessions: sessions, agentSessions: sessions,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
provider: provider ?? existingProvider,
}; };
writeFileAtomic(path, JSON.stringify(data, null, 2)); writeFileAtomic(path, JSON.stringify(data, null, 2));
} }

View File

@ -172,6 +172,10 @@ export const GlobalConfigSchema = z.object({
worktree_dir: z.string().optional(), worktree_dir: z.string().optional(),
/** List of builtin workflow/agent names to exclude from fallback loading */ /** List of builtin workflow/agent names to exclude from fallback loading */
disabled_builtins: z.array(z.string()).optional().default([]), 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 */ /** Project config schema */

View File

@ -191,6 +191,10 @@ export interface GlobalConfig {
worktreeDir?: string; worktreeDir?: string;
/** List of builtin workflow/agent names to exclude from fallback loading */ /** List of builtin workflow/agent names to exclude from fallback loading */
disabledBuiltins?: string[]; 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 */ /** Project-level configuration */

View File

@ -3,6 +3,7 @@
*/ */
import { callClaude, callClaudeCustom, type ClaudeCallOptions } from '../claude/client.js'; import { callClaude, callClaudeCustom, type ClaudeCallOptions } from '../claude/client.js';
import { resolveAnthropicApiKey } from '../config/globalConfig.js';
import type { AgentResponse } from '../models/types.js'; import type { AgentResponse } from '../models/types.js';
import type { Provider, ProviderCallOptions } from './index.js'; import type { Provider, ProviderCallOptions } from './index.js';
@ -21,6 +22,7 @@ export class ClaudeProvider implements Provider {
onPermissionRequest: options.onPermissionRequest, onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion, onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions, bypassPermissions: options.bypassPermissions,
anthropicApiKey: options.anthropicApiKey ?? resolveAnthropicApiKey(),
}; };
return callClaude(agentName, prompt, callOptions); return callClaude(agentName, prompt, callOptions);
@ -38,6 +40,7 @@ export class ClaudeProvider implements Provider {
onPermissionRequest: options.onPermissionRequest, onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion, onAskUserQuestion: options.onAskUserQuestion,
bypassPermissions: options.bypassPermissions, bypassPermissions: options.bypassPermissions,
anthropicApiKey: options.anthropicApiKey ?? resolveAnthropicApiKey(),
}; };
return callClaudeCustom(agentName, prompt, systemPrompt, callOptions); return callClaudeCustom(agentName, prompt, systemPrompt, callOptions);

View File

@ -3,6 +3,7 @@
*/ */
import { callCodex, callCodexCustom, type CodexCallOptions } from '../codex/client.js'; import { callCodex, callCodexCustom, type CodexCallOptions } from '../codex/client.js';
import { resolveOpenaiApiKey } from '../config/globalConfig.js';
import type { AgentResponse } from '../models/types.js'; import type { AgentResponse } from '../models/types.js';
import type { Provider, ProviderCallOptions } from './index.js'; import type { Provider, ProviderCallOptions } from './index.js';
@ -15,6 +16,7 @@ export class CodexProvider implements Provider {
model: options.model, model: options.model,
systemPrompt: options.systemPrompt, systemPrompt: options.systemPrompt,
onStream: options.onStream, onStream: options.onStream,
openaiApiKey: options.openaiApiKey ?? resolveOpenaiApiKey(),
}; };
return callCodex(agentName, prompt, callOptions); return callCodex(agentName, prompt, callOptions);
@ -26,6 +28,7 @@ export class CodexProvider implements Provider {
sessionId: options.sessionId, sessionId: options.sessionId,
model: options.model, model: options.model,
onStream: options.onStream, onStream: options.onStream,
openaiApiKey: options.openaiApiKey ?? resolveOpenaiApiKey(),
}; };
return callCodexCustom(agentName, prompt, systemPrompt, callOptions); return callCodexCustom(agentName, prompt, systemPrompt, callOptions);

View File

@ -26,6 +26,10 @@ export interface ProviderCallOptions {
onPermissionRequest?: PermissionHandler; onPermissionRequest?: PermissionHandler;
onAskUserQuestion?: AskUserQuestionHandler; onAskUserQuestion?: AskUserQuestionHandler;
bypassPermissions?: boolean; 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 */ /** Provider interface - all providers must implement this */

View File

@ -327,7 +327,7 @@ export class StreamDisplay {
} }
/** Display final result */ /** Display final result */
showResult(success: boolean): void { showResult(success: boolean, error?: string): void {
this.stopToolSpinner(); this.stopToolSpinner();
this.flushThinking(); this.flushThinking();
this.flushText(); this.flushText();
@ -336,6 +336,9 @@ export class StreamDisplay {
console.log(chalk.green('✓ Complete')); console.log(chalk.green('✓ Complete'));
} else { } else {
console.log(chalk.red('✗ Failed')); console.log(chalk.red('✗ Failed'));
if (error) {
console.log(chalk.red(` ${error}`));
}
} }
} }
@ -378,7 +381,7 @@ export class StreamDisplay {
this.showThinking(event.data.thinking); this.showThinking(event.data.thinking);
break; break;
case 'result': case 'result':
this.showResult(event.data.success); this.showResult(event.data.success, event.data.error);
break; break;
case 'error': case 'error':
// Parse errors are logged but not displayed to user // Parse errors are logged but not displayed to user