resolved #35
This commit is contained in:
parent
6468fa6345
commit
4b924851a8
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
289
src/__tests__/apiKeyAuth.test.ts
Normal file
289
src/__tests__/apiKeyAuth.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
|
||||
@ -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<string, string>,
|
||||
ANTHROPIC_API_KEY: anthropicApiKey,
|
||||
};
|
||||
}
|
||||
|
||||
if (options.onStream) {
|
||||
sdkOptions.includePartialMessages = true;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -44,6 +44,7 @@ export interface ResultEventData {
|
||||
result: string;
|
||||
sessionId: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ErrorEventData {
|
||||
|
||||
@ -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<AgentResponse> {
|
||||
const codex = new Codex();
|
||||
const codex = new Codex(options.openaiApiKey ? { apiKey: options.openaiApiKey } : undefined);
|
||||
const threadOptions = {
|
||||
model: options.model,
|
||||
workingDirectory: options.cwd,
|
||||
|
||||
@ -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<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`)
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,15 +12,16 @@ import type { AgentResponse } from '../models/types.js';
|
||||
export async function withAgentSession(
|
||||
cwd: string,
|
||||
agentName: string,
|
||||
fn: (sessionId?: string) => Promise<AgentResponse>
|
||||
fn: (sessionId?: string) => Promise<AgentResponse>,
|
||||
provider?: string
|
||||
): Promise<AgentResponse> {
|
||||
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;
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -88,6 +88,8 @@ export function addToInputHistory(projectDir: string, input: string): void {
|
||||
export interface AgentSessionData {
|
||||
agentSessions: Record<string, string>;
|
||||
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<string, string> {
|
||||
/** Load saved agent sessions. Returns empty if provider has changed. */
|
||||
export function loadAgentSessions(projectDir: string, currentProvider?: string): Record<string, string> {
|
||||
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<string, string> {
|
||||
/** Save agent sessions (atomic write) */
|
||||
export function saveAgentSessions(
|
||||
projectDir: string,
|
||||
sessions: Record<string, string>
|
||||
sessions: Record<string, string>,
|
||||
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<string, string> = {};
|
||||
let existingProvider: string | undefined;
|
||||
if (existsSync(path)) {
|
||||
try {
|
||||
const content = readFileSync(path, 'utf-8');
|
||||
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 || {};
|
||||
}
|
||||
} 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));
|
||||
}
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user