Issue/90 fix windows (#91)

* Window対応および Codexが.gitを必要とする問題があるので.gitがみつからない場合はエラーとする fix #90

* 文字化け修正
This commit is contained in:
nrs 2026-02-04 13:19:00 +09:00 committed by GitHub
parent 54ade15dcb
commit 8e509e13c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 1319 additions and 1556 deletions

View File

@ -476,9 +476,6 @@ model: sonnet # Default model (optional)
anthropic_api_key: sk-ant-... # For Claude (Anthropic) anthropic_api_key: sk-ant-... # For Claude (Anthropic)
# openai_api_key: sk-... # For Codex (OpenAI) # openai_api_key: sk-... # For Codex (OpenAI)
trusted_directories:
- /path/to/trusted/dir
# Pipeline execution configuration (optional) # Pipeline execution configuration (optional)
# Customize branch names, commit messages, and PR body. # Customize branch names, commit messages, and PR body.
# pipeline: # pipeline:
@ -490,6 +487,8 @@ trusted_directories:
# Closes #{issue} # Closes #{issue}
``` ```
**Note:** The Codex SDK requires running inside a Git repository. `--skip-git-repo-check` is only available in the Codex CLI.
**API Key Configuration Methods:** **API Key Configuration Methods:**
1. **Set via environment variables**: 1. **Set via environment variables**:

View File

@ -9,7 +9,7 @@
* - npm exec takt * - npm exec takt
*/ */
import { fileURLToPath } from 'node:url'; import { fileURLToPath, pathToFileURL } from 'node:url';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
@ -19,7 +19,8 @@ const __dirname = dirname(__filename);
const cliPath = join(__dirname, '..', 'dist', 'app', 'cli', 'index.js'); const cliPath = join(__dirname, '..', 'dist', 'app', 'cli', 'index.js');
try { try {
await import(cliPath); const cliUrl = pathToFileURL(cliPath).href;
await import(cliUrl);
} catch (err) { } catch (err) {
console.error('Failed to load TAKT CLI. Have you run "npm run build"?'); console.error('Failed to load TAKT CLI. Have you run "npm run build"?');
console.error(err.message); console.error(err.message);

View File

@ -472,9 +472,6 @@ model: sonnet # デフォルトモデル(オプション)
anthropic_api_key: sk-ant-... # Claude (Anthropic) を使う場合 anthropic_api_key: sk-ant-... # Claude (Anthropic) を使う場合
# openai_api_key: sk-... # Codex (OpenAI) を使う場合 # openai_api_key: sk-... # Codex (OpenAI) を使う場合
trusted_directories:
- /path/to/trusted/dir
# パイプライン実行設定(オプション) # パイプライン実行設定(オプション)
# ブランチ名、コミットメッセージ、PRの本文をカスタマイズできます。 # ブランチ名、コミットメッセージ、PRの本文をカスタマイズできます。
# pipeline: # pipeline:
@ -486,6 +483,8 @@ trusted_directories:
# Closes #{issue} # Closes #{issue}
``` ```
**注意:** Codex SDK は Git 管理下のディレクトリでのみ動作します。`--skip-git-repo-check` は Codex CLI 専用です。
**API Key の設定方法:** **API Key の設定方法:**
1. **環境変数で設定**: 1. **環境変数で設定**:

734
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,9 +4,6 @@
# Language setting (en or ja) # Language setting (en or ja)
language: en language: en
# Trusted directories - projects in these directories skip confirmation prompts
trusted_directories: []
# Default piece to use when no piece is specified # Default piece to use when no piece is specified
default_piece: default default_piece: default

View File

@ -4,10 +4,7 @@
# 言語設定 (en または ja) # 言語設定 (en または ja)
language: ja language: ja
# 信頼済みディレクトリ - これらのディレクトリ内のプロジェクトは確認プロンプトをスキップします # デフォルトのピース - 指定がない場合に使用します
trusted_directories: []
# デフォルトピース - ピースが指定されていない場合に使用します
default_piece: default default_piece: default
# ログレベル: debug, info, warn, error # ログレベル: debug, info, warn, error

View File

@ -1,292 +1,282 @@
/** /**
* Tests for API key authentication feature * Tests for API key authentication feature
* *
* Tests the resolution logic for Anthropic and OpenAI API keys: * Tests the resolution logic for Anthropic and OpenAI API keys:
* - Environment variable priority over config.yaml * - Environment variable priority over config.yaml
* - Config.yaml fallback when env var is not set * - Config.yaml fallback when env var is not set
* - Undefined when neither is set * - Undefined when neither is set
* - Schema validation for API key fields * - Schema validation for API key fields
* - GlobalConfig load/save round-trip with API keys * - GlobalConfig load/save round-trip with API keys
*/ */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; import { mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { GlobalConfigSchema } from '../core/models/index.js'; import { GlobalConfigSchema } from '../core/models/index.js';
// Mock paths module to redirect config to temp directory // Mock paths module to redirect config to temp directory
const testId = randomUUID(); const testId = randomUUID();
const testDir = join(tmpdir(), `takt-api-key-test-${testId}`); const testDir = join(tmpdir(), `takt-api-key-test-${testId}`);
const taktDir = join(testDir, '.takt'); const taktDir = join(testDir, '.takt');
const configPath = join(taktDir, 'config.yaml'); const configPath = join(taktDir, 'config.yaml');
vi.mock('../infra/config/paths.js', async (importOriginal) => { vi.mock('../infra/config/paths.js', async (importOriginal) => {
const original = await importOriginal() as Record<string, unknown>; const original = await importOriginal() as Record<string, unknown>;
return { return {
...original, ...original,
getGlobalConfigPath: () => configPath, getGlobalConfigPath: () => configPath,
getTaktDir: () => taktDir, getTaktDir: () => taktDir,
}; };
}); });
// Import after mocking // Import after mocking
const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js'); const { loadGlobalConfig, saveGlobalConfig, resolveAnthropicApiKey, resolveOpenaiApiKey, invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js');
describe('GlobalConfigSchema API key fields', () => { describe('GlobalConfigSchema API key fields', () => {
it('should accept config without API keys', () => { it('should accept config without API keys', () => {
const result = GlobalConfigSchema.parse({ const result = GlobalConfigSchema.parse({
language: 'en', language: 'en',
}); });
expect(result.anthropic_api_key).toBeUndefined(); expect(result.anthropic_api_key).toBeUndefined();
expect(result.openai_api_key).toBeUndefined(); expect(result.openai_api_key).toBeUndefined();
}); });
it('should accept config with anthropic_api_key', () => { it('should accept config with anthropic_api_key', () => {
const result = GlobalConfigSchema.parse({ const result = GlobalConfigSchema.parse({
language: 'en', language: 'en',
anthropic_api_key: 'sk-ant-test-key', anthropic_api_key: 'sk-ant-test-key',
}); });
expect(result.anthropic_api_key).toBe('sk-ant-test-key'); expect(result.anthropic_api_key).toBe('sk-ant-test-key');
}); });
it('should accept config with openai_api_key', () => { it('should accept config with openai_api_key', () => {
const result = GlobalConfigSchema.parse({ const result = GlobalConfigSchema.parse({
language: 'en', language: 'en',
openai_api_key: 'sk-openai-test-key', openai_api_key: 'sk-openai-test-key',
}); });
expect(result.openai_api_key).toBe('sk-openai-test-key'); expect(result.openai_api_key).toBe('sk-openai-test-key');
}); });
it('should accept config with both API keys', () => { it('should accept config with both API keys', () => {
const result = GlobalConfigSchema.parse({ const result = GlobalConfigSchema.parse({
language: 'en', language: 'en',
anthropic_api_key: 'sk-ant-key', anthropic_api_key: 'sk-ant-key',
openai_api_key: 'sk-openai-key', openai_api_key: 'sk-openai-key',
}); });
expect(result.anthropic_api_key).toBe('sk-ant-key'); expect(result.anthropic_api_key).toBe('sk-ant-key');
expect(result.openai_api_key).toBe('sk-openai-key'); expect(result.openai_api_key).toBe('sk-openai-key');
}); });
}); });
describe('GlobalConfig load/save with API keys', () => { describe('GlobalConfig load/save with API keys', () => {
beforeEach(() => { beforeEach(() => {
invalidateGlobalConfigCache(); invalidateGlobalConfigCache();
mkdirSync(taktDir, { recursive: true }); mkdirSync(taktDir, { recursive: true });
}); });
afterEach(() => { afterEach(() => {
rmSync(testDir, { recursive: true, force: true }); rmSync(testDir, { recursive: true, force: true });
}); });
it('should load config with API keys from YAML', () => { it('should load config with API keys from YAML', () => {
const yaml = [ const yaml = [
'language: en', 'language: en',
'trusted_directories: []', 'default_piece: default',
'default_piece: default', 'log_level: info',
'log_level: info', 'provider: claude',
'provider: claude', 'anthropic_api_key: sk-ant-from-yaml',
'anthropic_api_key: sk-ant-from-yaml', 'openai_api_key: sk-openai-from-yaml',
'openai_api_key: sk-openai-from-yaml', ].join('\n');
].join('\n'); writeFileSync(configPath, yaml, 'utf-8');
writeFileSync(configPath, yaml, 'utf-8');
const config = loadGlobalConfig();
const config = loadGlobalConfig(); expect(config.anthropicApiKey).toBe('sk-ant-from-yaml');
expect(config.anthropicApiKey).toBe('sk-ant-from-yaml'); expect(config.openaiApiKey).toBe('sk-openai-from-yaml');
expect(config.openaiApiKey).toBe('sk-openai-from-yaml'); });
});
it('should load config without API keys', () => {
it('should load config without API keys', () => { const yaml = [
const yaml = [ 'language: en',
'language: en', 'default_piece: default',
'trusted_directories: []', 'log_level: info',
'default_piece: default', 'provider: claude',
'log_level: info', ].join('\n');
'provider: claude', writeFileSync(configPath, yaml, 'utf-8');
].join('\n');
writeFileSync(configPath, yaml, 'utf-8'); const config = loadGlobalConfig();
expect(config.anthropicApiKey).toBeUndefined();
const config = loadGlobalConfig(); expect(config.openaiApiKey).toBeUndefined();
expect(config.anthropicApiKey).toBeUndefined(); });
expect(config.openaiApiKey).toBeUndefined();
}); it('should save and reload config with API keys', () => {
// Write initial config
it('should save and reload config with API keys', () => { const yaml = [
// Write initial config 'language: en',
const yaml = [ 'default_piece: default',
'language: en', 'log_level: info',
'trusted_directories: []', 'provider: claude',
'default_piece: default', ].join('\n');
'log_level: info', writeFileSync(configPath, yaml, 'utf-8');
'provider: claude',
].join('\n'); const config = loadGlobalConfig();
writeFileSync(configPath, yaml, 'utf-8'); config.anthropicApiKey = 'sk-ant-saved';
config.openaiApiKey = 'sk-openai-saved';
const config = loadGlobalConfig(); saveGlobalConfig(config);
config.anthropicApiKey = 'sk-ant-saved';
config.openaiApiKey = 'sk-openai-saved'; const reloaded = loadGlobalConfig();
saveGlobalConfig(config); expect(reloaded.anthropicApiKey).toBe('sk-ant-saved');
expect(reloaded.openaiApiKey).toBe('sk-openai-saved');
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',
it('should not persist API keys when not set', () => { 'default_piece: default',
const yaml = [ 'log_level: info',
'language: en', 'provider: claude',
'trusted_directories: []', ].join('\n');
'default_piece: default', writeFileSync(configPath, yaml, 'utf-8');
'log_level: info',
'provider: claude', const config = loadGlobalConfig();
].join('\n'); saveGlobalConfig(config);
writeFileSync(configPath, yaml, 'utf-8');
const content = readFileSync(configPath, 'utf-8');
const config = loadGlobalConfig(); expect(content).not.toContain('anthropic_api_key');
saveGlobalConfig(config); expect(content).not.toContain('openai_api_key');
});
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(() => {
describe('resolveAnthropicApiKey', () => { invalidateGlobalConfigCache();
const originalEnv = process.env['TAKT_ANTHROPIC_API_KEY']; mkdirSync(taktDir, { recursive: true });
});
beforeEach(() => {
invalidateGlobalConfigCache(); afterEach(() => {
mkdirSync(taktDir, { recursive: true }); if (originalEnv !== undefined) {
}); process.env['TAKT_ANTHROPIC_API_KEY'] = originalEnv;
} else {
afterEach(() => { delete process.env['TAKT_ANTHROPIC_API_KEY'];
if (originalEnv !== undefined) { }
process.env['TAKT_ANTHROPIC_API_KEY'] = originalEnv; rmSync(testDir, { recursive: true, force: true });
} else { });
delete process.env['TAKT_ANTHROPIC_API_KEY'];
} it('should return env var when set', () => {
rmSync(testDir, { recursive: true, force: true }); process.env['TAKT_ANTHROPIC_API_KEY'] = 'sk-ant-from-env';
}); const yaml = [
'language: en',
it('should return env var when set', () => { 'default_piece: default',
process.env['TAKT_ANTHROPIC_API_KEY'] = 'sk-ant-from-env'; 'log_level: info',
const yaml = [ 'provider: claude',
'language: en', 'anthropic_api_key: sk-ant-from-yaml',
'trusted_directories: []', ].join('\n');
'default_piece: default', writeFileSync(configPath, yaml, 'utf-8');
'log_level: info',
'provider: claude', const key = resolveAnthropicApiKey();
'anthropic_api_key: sk-ant-from-yaml', expect(key).toBe('sk-ant-from-env');
].join('\n'); });
writeFileSync(configPath, yaml, 'utf-8');
it('should fall back to config when env var is not set', () => {
const key = resolveAnthropicApiKey(); delete process.env['TAKT_ANTHROPIC_API_KEY'];
expect(key).toBe('sk-ant-from-env'); const yaml = [
}); 'language: en',
'default_piece: default',
it('should fall back to config when env var is not set', () => { 'log_level: info',
delete process.env['TAKT_ANTHROPIC_API_KEY']; 'provider: claude',
const yaml = [ 'anthropic_api_key: sk-ant-from-yaml',
'language: en', ].join('\n');
'trusted_directories: []', writeFileSync(configPath, yaml, 'utf-8');
'default_piece: default',
'log_level: info', const key = resolveAnthropicApiKey();
'provider: claude', expect(key).toBe('sk-ant-from-yaml');
'anthropic_api_key: sk-ant-from-yaml', });
].join('\n');
writeFileSync(configPath, yaml, 'utf-8'); it('should return undefined when neither env var nor config is set', () => {
delete process.env['TAKT_ANTHROPIC_API_KEY'];
const key = resolveAnthropicApiKey(); const yaml = [
expect(key).toBe('sk-ant-from-yaml'); 'language: en',
}); 'default_piece: default',
'log_level: info',
it('should return undefined when neither env var nor config is set', () => { 'provider: claude',
delete process.env['TAKT_ANTHROPIC_API_KEY']; ].join('\n');
const yaml = [ writeFileSync(configPath, yaml, 'utf-8');
'language: en',
'trusted_directories: []', const key = resolveAnthropicApiKey();
'default_piece: default', expect(key).toBeUndefined();
'log_level: info', });
'provider: claude',
].join('\n'); it('should return undefined when config file does not exist', () => {
writeFileSync(configPath, yaml, 'utf-8'); delete process.env['TAKT_ANTHROPIC_API_KEY'];
// No config file created
const key = resolveAnthropicApiKey(); rmSync(testDir, { recursive: true, force: true });
expect(key).toBeUndefined();
}); 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 }); describe('resolveOpenaiApiKey', () => {
const originalEnv = process.env['TAKT_OPENAI_API_KEY'];
const key = resolveAnthropicApiKey();
expect(key).toBeUndefined(); beforeEach(() => {
}); invalidateGlobalConfigCache();
}); mkdirSync(taktDir, { recursive: true });
});
describe('resolveOpenaiApiKey', () => {
const originalEnv = process.env['TAKT_OPENAI_API_KEY']; afterEach(() => {
if (originalEnv !== undefined) {
beforeEach(() => { process.env['TAKT_OPENAI_API_KEY'] = originalEnv;
invalidateGlobalConfigCache(); } else {
mkdirSync(taktDir, { recursive: true }); delete process.env['TAKT_OPENAI_API_KEY'];
}); }
rmSync(testDir, { recursive: true, force: true });
afterEach(() => { });
if (originalEnv !== undefined) {
process.env['TAKT_OPENAI_API_KEY'] = originalEnv; it('should return env var when set', () => {
} else { process.env['TAKT_OPENAI_API_KEY'] = 'sk-openai-from-env';
delete process.env['TAKT_OPENAI_API_KEY']; const yaml = [
} 'language: en',
rmSync(testDir, { recursive: true, force: true }); 'default_piece: default',
}); 'log_level: info',
'provider: claude',
it('should return env var when set', () => { 'openai_api_key: sk-openai-from-yaml',
process.env['TAKT_OPENAI_API_KEY'] = 'sk-openai-from-env'; ].join('\n');
const yaml = [ writeFileSync(configPath, yaml, 'utf-8');
'language: en',
'trusted_directories: []', const key = resolveOpenaiApiKey();
'default_piece: default', expect(key).toBe('sk-openai-from-env');
'log_level: info', });
'provider: claude',
'openai_api_key: sk-openai-from-yaml', it('should fall back to config when env var is not set', () => {
].join('\n'); delete process.env['TAKT_OPENAI_API_KEY'];
writeFileSync(configPath, yaml, 'utf-8'); const yaml = [
'language: en',
const key = resolveOpenaiApiKey(); 'default_piece: default',
expect(key).toBe('sk-openai-from-env'); 'log_level: info',
}); 'provider: claude',
'openai_api_key: sk-openai-from-yaml',
it('should fall back to config when env var is not set', () => { ].join('\n');
delete process.env['TAKT_OPENAI_API_KEY']; writeFileSync(configPath, yaml, 'utf-8');
const yaml = [
'language: en', const key = resolveOpenaiApiKey();
'trusted_directories: []', expect(key).toBe('sk-openai-from-yaml');
'default_piece: default', });
'log_level: info',
'provider: claude', it('should return undefined when neither env var nor config is set', () => {
'openai_api_key: sk-openai-from-yaml', delete process.env['TAKT_OPENAI_API_KEY'];
].join('\n'); const yaml = [
writeFileSync(configPath, yaml, 'utf-8'); 'language: en',
'default_piece: default',
const key = resolveOpenaiApiKey(); 'log_level: info',
expect(key).toBe('sk-openai-from-yaml'); 'provider: claude',
}); ].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
it('should return undefined when neither env var nor config is set', () => {
delete process.env['TAKT_OPENAI_API_KEY']; const key = resolveOpenaiApiKey();
const yaml = [ expect(key).toBeUndefined();
'language: en', });
'trusted_directories: []', });
'default_piece: default',
'log_level: info',
'provider: claude',
].join('\n');
writeFileSync(configPath, yaml, 'utf-8');
const key = resolveOpenaiApiKey();
expect(key).toBeUndefined();
});
});

View File

@ -0,0 +1,37 @@
/**
* Tests for the CLI wrapper URL handling.
*/
import { describe, it, expect } from 'vitest';
import { readFile } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import { posix, win32, resolve } from 'node:path';
describe('cli wrapper import URL', () => {
it('builds a file URL for Windows paths', () => {
const winPath = win32.join('C:\\', 'work', 'git', 'takt', 'dist', 'app', 'cli', 'index.js');
const url = pathToFileURL(winPath).href;
if (process.platform === 'win32') {
expect(url).toBe('file:///C:/work/git/takt/dist/app/cli/index.js');
return;
}
expect(url).toMatch(/C:%5Cwork%5Cgit%5Ctakt%5Cdist%5Capp%5Ccli%5Cindex\.js$/);
});
it('builds a file URL for POSIX paths', () => {
const posixPath = posix.join('/', 'usr', 'local', 'lib', 'takt', 'dist', 'app', 'cli', 'index.js');
const url = pathToFileURL(posixPath).href;
expect(url).toBe('file:///usr/local/lib/takt/dist/app/cli/index.js');
});
it('uses pathToFileURL in the npm wrapper', async () => {
const wrapperPath = resolve('bin', 'takt');
const wrapperContents = await readFile(wrapperPath, 'utf8');
expect(wrapperContents).toContain('pathToFileURL');
expect(wrapperContents).toContain('pathToFileURL(cliPath)');
});
});

View File

@ -39,7 +39,6 @@ describe('loadGlobalConfig', () => {
const config = loadGlobalConfig(); const config = loadGlobalConfig();
expect(config.language).toBe('en'); expect(config.language).toBe('en');
expect(config.trustedDirectories).toEqual([]);
expect(config.defaultPiece).toBe('default'); expect(config.defaultPiece).toBe('default');
expect(config.logLevel).toBe('info'); expect(config.logLevel).toBe('info');
expect(config.provider).toBe('claude'); expect(config.provider).toBe('claude');

View File

@ -156,6 +156,12 @@ describe('worktree branch deletion', () => {
}); });
it('should delete regular (non-worktree) branches normally', () => { it('should delete regular (non-worktree) branches normally', () => {
const defaultBranch = execFileSync('git', ['branch', '--show-current'], {
cwd: testDir,
encoding: 'utf-8',
stdio: 'pipe',
}).trim();
// Create a regular local branch // Create a regular local branch
const branchName = 'takt/20260203T1002-regular-branch'; const branchName = 'takt/20260203T1002-regular-branch';
execFileSync('git', ['checkout', '-b', branchName], { cwd: testDir }); execFileSync('git', ['checkout', '-b', branchName], { cwd: testDir });
@ -166,7 +172,7 @@ describe('worktree branch deletion', () => {
execFileSync('git', ['commit', '-m', 'Test change'], { cwd: testDir }); execFileSync('git', ['commit', '-m', 'Test change'], { cwd: testDir });
// Switch back to main // Switch back to main
execFileSync('git', ['checkout', 'master'], { cwd: testDir }); execFileSync('git', ['checkout', defaultBranch || 'main'], { cwd: testDir });
// Verify branch exists // Verify branch exists
const branchesBefore = listTaktBranches(testDir); const branchesBefore = listTaktBranches(testDir);

View File

@ -201,7 +201,6 @@ describe('GlobalConfigSchema', () => {
const config = {}; const config = {};
const result = GlobalConfigSchema.parse(config); const result = GlobalConfigSchema.parse(config);
expect(result.trusted_directories).toEqual([]);
expect(result.default_piece).toBe('default'); expect(result.default_piece).toBe('default');
expect(result.log_level).toBe('info'); expect(result.log_level).toBe('info');
expect(result.provider).toBe('claude'); expect(result.provider).toBe('claude');
@ -209,13 +208,11 @@ describe('GlobalConfigSchema', () => {
it('should accept valid config', () => { it('should accept valid config', () => {
const config = { const config = {
trusted_directories: ['/home/user/projects'],
default_piece: 'custom', default_piece: 'custom',
log_level: 'debug' as const, log_level: 'debug' as const,
}; };
const result = GlobalConfigSchema.parse(config); const result = GlobalConfigSchema.parse(config);
expect(result.trusted_directories).toHaveLength(1);
expect(result.log_level).toBe('debug'); expect(result.log_level).toBe('debug');
}); });
}); });

View File

@ -1,438 +1,434 @@
/** /**
* Tests for pipeline execution * Tests for pipeline execution
* *
* Tests the orchestration logic with mocked dependencies. * Tests the orchestration logic with mocked dependencies.
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock all external dependencies // Mock all external dependencies
const mockFetchIssue = vi.fn(); const mockFetchIssue = vi.fn();
const mockCheckGhCli = vi.fn().mockReturnValue({ available: true }); const mockCheckGhCli = vi.fn().mockReturnValue({ available: true });
vi.mock('../infra/github/issue.js', () => ({ vi.mock('../infra/github/issue.js', () => ({
fetchIssue: mockFetchIssue, fetchIssue: mockFetchIssue,
formatIssueAsTask: vi.fn((issue: { title: string; body: string; number: number }) => formatIssueAsTask: vi.fn((issue: { title: string; body: string; number: number }) =>
`## GitHub Issue #${issue.number}: ${issue.title}\n\n${issue.body}` `## GitHub Issue #${issue.number}: ${issue.title}\n\n${issue.body}`
), ),
checkGhCli: mockCheckGhCli, checkGhCli: mockCheckGhCli,
})); }));
const mockCreatePullRequest = vi.fn(); const mockCreatePullRequest = vi.fn();
const mockPushBranch = vi.fn(); const mockPushBranch = vi.fn();
const mockBuildPrBody = vi.fn(() => 'Default PR body'); const mockBuildPrBody = vi.fn(() => 'Default PR body');
vi.mock('../infra/github/pr.js', () => ({ vi.mock('../infra/github/pr.js', () => ({
createPullRequest: mockCreatePullRequest, createPullRequest: mockCreatePullRequest,
pushBranch: mockPushBranch, pushBranch: mockPushBranch,
buildPrBody: mockBuildPrBody, buildPrBody: mockBuildPrBody,
})); }));
const mockExecuteTask = vi.fn(); const mockExecuteTask = vi.fn();
vi.mock('../features/tasks/index.js', () => ({ vi.mock('../features/tasks/index.js', () => ({
executeTask: mockExecuteTask, executeTask: mockExecuteTask,
})); }));
// Mock loadGlobalConfig // Mock loadGlobalConfig
const mockLoadGlobalConfig = vi.fn(); const mockLoadGlobalConfig = vi.fn();
vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => ({ ...(await importOriginal<Record<string, unknown>>()), vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => ({ ...(await importOriginal<Record<string, unknown>>()),
loadGlobalConfig: mockLoadGlobalConfig, loadGlobalConfig: mockLoadGlobalConfig,
})); }));
// Mock execFileSync for git operations // Mock execFileSync for git operations
const mockExecFileSync = vi.fn(); const mockExecFileSync = vi.fn();
vi.mock('node:child_process', () => ({ vi.mock('node:child_process', () => ({
execFileSync: mockExecFileSync, execFileSync: mockExecFileSync,
})); }));
// Mock UI // Mock UI
vi.mock('../shared/ui/index.js', () => ({ vi.mock('../shared/ui/index.js', () => ({
info: vi.fn(), info: vi.fn(),
error: vi.fn(), error: vi.fn(),
success: vi.fn(), success: vi.fn(),
status: vi.fn(), status: vi.fn(),
blankLine: vi.fn(), blankLine: vi.fn(),
header: vi.fn(), header: vi.fn(),
section: vi.fn(), section: vi.fn(),
warn: vi.fn(), warn: vi.fn(),
debug: vi.fn(), debug: vi.fn(),
})); }));
// Mock debug logger // Mock debug logger
vi.mock('../shared/utils/index.js', async (importOriginal) => ({ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()), ...(await importOriginal<Record<string, unknown>>()),
createLogger: () => ({ createLogger: () => ({
info: vi.fn(), info: vi.fn(),
debug: vi.fn(), debug: vi.fn(),
error: vi.fn(), error: vi.fn(),
}), }),
})); }));
const { executePipeline } = await import('../features/pipeline/index.js'); const { executePipeline } = await import('../features/pipeline/index.js');
describe('executePipeline', () => { describe('executePipeline', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// Default: git operations succeed // Default: git operations succeed
mockExecFileSync.mockReturnValue('abc1234\n'); mockExecFileSync.mockReturnValue('abc1234\n');
// Default: no pipeline config // Default: no pipeline config
mockLoadGlobalConfig.mockReturnValue({ mockLoadGlobalConfig.mockReturnValue({
language: 'en', language: 'en',
trustedDirectories: [], defaultPiece: 'default',
defaultPiece: 'default', logLevel: 'info',
logLevel: 'info', provider: 'claude',
provider: 'claude', });
}); });
});
it('should return exit code 2 when neither --issue nor --task is specified', async () => {
it('should return exit code 2 when neither --issue nor --task is specified', async () => { const exitCode = await executePipeline({
const exitCode = await executePipeline({ piece: 'default',
piece: 'default', autoPr: false,
autoPr: false, cwd: '/tmp/test',
cwd: '/tmp/test', });
});
expect(exitCode).toBe(2);
expect(exitCode).toBe(2); });
});
it('should return exit code 2 when gh CLI is not available', async () => {
it('should return exit code 2 when gh CLI is not available', async () => { mockCheckGhCli.mockReturnValueOnce({ available: false, error: 'gh not found' });
mockCheckGhCli.mockReturnValueOnce({ available: false, error: 'gh not found' });
const exitCode = await executePipeline({
const exitCode = await executePipeline({ issueNumber: 99,
issueNumber: 99, piece: 'default',
piece: 'default', autoPr: false,
autoPr: false, cwd: '/tmp/test',
cwd: '/tmp/test', });
});
expect(exitCode).toBe(2);
expect(exitCode).toBe(2); });
});
it('should return exit code 2 when issue fetch fails', async () => {
it('should return exit code 2 when issue fetch fails', async () => { mockFetchIssue.mockImplementationOnce(() => {
mockFetchIssue.mockImplementationOnce(() => { throw new Error('Issue not found');
throw new Error('Issue not found'); });
});
const exitCode = await executePipeline({
const exitCode = await executePipeline({ issueNumber: 999,
issueNumber: 999, piece: 'default',
piece: 'default', autoPr: false,
autoPr: false, cwd: '/tmp/test',
cwd: '/tmp/test', });
});
expect(exitCode).toBe(2);
expect(exitCode).toBe(2); });
});
it('should return exit code 3 when piece fails', async () => {
it('should return exit code 3 when piece fails', async () => { mockFetchIssue.mockReturnValueOnce({
mockFetchIssue.mockReturnValueOnce({ number: 99,
number: 99, title: 'Test issue',
title: 'Test issue', body: 'Test body',
body: 'Test body', labels: [],
labels: [], comments: [],
comments: [], });
}); mockExecuteTask.mockResolvedValueOnce(false);
mockExecuteTask.mockResolvedValueOnce(false);
const exitCode = await executePipeline({
const exitCode = await executePipeline({ issueNumber: 99,
issueNumber: 99, piece: 'default',
piece: 'default', autoPr: false,
autoPr: false, cwd: '/tmp/test',
cwd: '/tmp/test', });
});
expect(exitCode).toBe(3);
expect(exitCode).toBe(3); });
});
it('should return exit code 0 on successful task-only execution', async () => {
it('should return exit code 0 on successful task-only execution', async () => { mockExecuteTask.mockResolvedValueOnce(true);
mockExecuteTask.mockResolvedValueOnce(true);
const exitCode = await executePipeline({
const exitCode = await executePipeline({ task: 'Fix the bug',
task: 'Fix the bug', piece: 'default',
piece: 'default', autoPr: false,
autoPr: false, cwd: '/tmp/test',
cwd: '/tmp/test', });
});
expect(exitCode).toBe(0);
expect(exitCode).toBe(0); expect(mockExecuteTask).toHaveBeenCalledWith({
expect(mockExecuteTask).toHaveBeenCalledWith({ task: 'Fix the bug',
task: 'Fix the bug', cwd: '/tmp/test',
cwd: '/tmp/test', pieceIdentifier: 'default',
pieceIdentifier: 'default', projectCwd: '/tmp/test',
projectCwd: '/tmp/test', agentOverrides: undefined,
agentOverrides: undefined, });
}); });
});
it('passes provider/model overrides to task execution', async () => {
it('passes provider/model overrides to task execution', async () => { mockExecuteTask.mockResolvedValueOnce(true);
mockExecuteTask.mockResolvedValueOnce(true);
const exitCode = await executePipeline({
const exitCode = await executePipeline({ task: 'Fix the bug',
task: 'Fix the bug', piece: 'default',
piece: 'default', autoPr: false,
autoPr: false, cwd: '/tmp/test',
cwd: '/tmp/test', provider: 'codex',
provider: 'codex', model: 'codex-model',
model: 'codex-model', });
});
expect(exitCode).toBe(0);
expect(exitCode).toBe(0); expect(mockExecuteTask).toHaveBeenCalledWith({
expect(mockExecuteTask).toHaveBeenCalledWith({ task: 'Fix the bug',
task: 'Fix the bug', cwd: '/tmp/test',
cwd: '/tmp/test', pieceIdentifier: 'default',
pieceIdentifier: 'default', projectCwd: '/tmp/test',
projectCwd: '/tmp/test', agentOverrides: { provider: 'codex', model: 'codex-model' },
agentOverrides: { provider: 'codex', model: 'codex-model' }, });
}); });
});
it('should return exit code 5 when PR creation fails', async () => {
it('should return exit code 5 when PR creation fails', async () => { mockExecuteTask.mockResolvedValueOnce(true);
mockExecuteTask.mockResolvedValueOnce(true); mockCreatePullRequest.mockReturnValueOnce({ success: false, error: 'PR failed' });
mockCreatePullRequest.mockReturnValueOnce({ success: false, error: 'PR failed' });
const exitCode = await executePipeline({
const exitCode = await executePipeline({ task: 'Fix the bug',
task: 'Fix the bug', piece: 'default',
piece: 'default', autoPr: true,
autoPr: true, cwd: '/tmp/test',
cwd: '/tmp/test', });
});
expect(exitCode).toBe(5);
expect(exitCode).toBe(5); });
});
it('should create PR with correct branch when --auto-pr', async () => {
it('should create PR with correct branch when --auto-pr', async () => { mockExecuteTask.mockResolvedValueOnce(true);
mockExecuteTask.mockResolvedValueOnce(true); mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/test/pr/1' });
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/test/pr/1' });
const exitCode = await executePipeline({
const exitCode = await executePipeline({ task: 'Fix the bug',
task: 'Fix the bug', piece: 'default',
piece: 'default', branch: 'fix/my-branch',
branch: 'fix/my-branch', autoPr: true,
autoPr: true, repo: 'owner/repo',
repo: 'owner/repo', cwd: '/tmp/test',
cwd: '/tmp/test', });
});
expect(exitCode).toBe(0);
expect(exitCode).toBe(0); expect(mockCreatePullRequest).toHaveBeenCalledWith(
expect(mockCreatePullRequest).toHaveBeenCalledWith( '/tmp/test',
'/tmp/test', expect.objectContaining({
expect.objectContaining({ branch: 'fix/my-branch',
branch: 'fix/my-branch', repo: 'owner/repo',
repo: 'owner/repo', }),
}), );
); });
});
it('should use --task when both --task and positional task are provided', async () => {
it('should use --task when both --task and positional task are provided', async () => { mockExecuteTask.mockResolvedValueOnce(true);
mockExecuteTask.mockResolvedValueOnce(true);
const exitCode = await executePipeline({
const exitCode = await executePipeline({ task: 'From --task flag',
task: 'From --task flag', piece: 'magi',
piece: 'magi', autoPr: false,
autoPr: false, cwd: '/tmp/test',
cwd: '/tmp/test', });
});
expect(exitCode).toBe(0);
expect(exitCode).toBe(0); expect(mockExecuteTask).toHaveBeenCalledWith({
expect(mockExecuteTask).toHaveBeenCalledWith({ task: 'From --task flag',
task: 'From --task flag', cwd: '/tmp/test',
cwd: '/tmp/test', pieceIdentifier: 'magi',
pieceIdentifier: 'magi', projectCwd: '/tmp/test',
projectCwd: '/tmp/test', agentOverrides: undefined,
agentOverrides: undefined, });
}); });
});
describe('PipelineConfig template expansion', () => {
describe('PipelineConfig template expansion', () => { it('should use commit_message_template when configured', async () => {
it('should use commit_message_template when configured', async () => { mockLoadGlobalConfig.mockReturnValue({
mockLoadGlobalConfig.mockReturnValue({ language: 'en',
language: 'en', defaultPiece: 'default',
trustedDirectories: [], logLevel: 'info',
defaultPiece: 'default', provider: 'claude',
logLevel: 'info', pipeline: {
provider: 'claude', commitMessageTemplate: 'fix: {title} (#{issue})',
pipeline: { },
commitMessageTemplate: 'fix: {title} (#{issue})', });
},
}); mockFetchIssue.mockReturnValueOnce({
number: 42,
mockFetchIssue.mockReturnValueOnce({ title: 'Login broken',
number: 42, body: 'Cannot login.',
title: 'Login broken', labels: [],
body: 'Cannot login.', comments: [],
labels: [], });
comments: [], mockExecuteTask.mockResolvedValueOnce(true);
});
mockExecuteTask.mockResolvedValueOnce(true); await executePipeline({
issueNumber: 42,
await executePipeline({ piece: 'default',
issueNumber: 42, branch: 'test-branch',
piece: 'default', autoPr: false,
branch: 'test-branch', cwd: '/tmp/test',
autoPr: false, });
cwd: '/tmp/test',
}); // Verify commit was called with expanded template
const commitCall = mockExecFileSync.mock.calls.find(
// Verify commit was called with expanded template (call: unknown[]) => call[0] === 'git' && (call[1] as string[])[0] === 'commit',
const commitCall = mockExecFileSync.mock.calls.find( );
(call: unknown[]) => call[0] === 'git' && (call[1] as string[])[0] === 'commit', expect(commitCall).toBeDefined();
); expect((commitCall![1] as string[])[2]).toBe('fix: Login broken (#42)');
expect(commitCall).toBeDefined(); });
expect((commitCall![1] as string[])[2]).toBe('fix: Login broken (#42)');
}); it('should use default_branch_prefix when configured', async () => {
mockLoadGlobalConfig.mockReturnValue({
it('should use default_branch_prefix when configured', async () => { language: 'en',
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default',
language: 'en', logLevel: 'info',
trustedDirectories: [], provider: 'claude',
defaultPiece: 'default', pipeline: {
logLevel: 'info', defaultBranchPrefix: 'feat/',
provider: 'claude', },
pipeline: { });
defaultBranchPrefix: 'feat/',
}, mockFetchIssue.mockReturnValueOnce({
}); number: 10,
title: 'Add feature',
mockFetchIssue.mockReturnValueOnce({ body: 'Please add.',
number: 10, labels: [],
title: 'Add feature', comments: [],
body: 'Please add.', });
labels: [], mockExecuteTask.mockResolvedValueOnce(true);
comments: [],
}); await executePipeline({
mockExecuteTask.mockResolvedValueOnce(true); issueNumber: 10,
piece: 'default',
await executePipeline({ autoPr: false,
issueNumber: 10, cwd: '/tmp/test',
piece: 'default', });
autoPr: false,
cwd: '/tmp/test', // Verify checkout -b was called with prefix
}); const checkoutCall = mockExecFileSync.mock.calls.find(
(call: unknown[]) => call[0] === 'git' && (call[1] as string[])[0] === 'checkout' && (call[1] as string[])[1] === '-b',
// Verify checkout -b was called with prefix );
const checkoutCall = mockExecFileSync.mock.calls.find( expect(checkoutCall).toBeDefined();
(call: unknown[]) => call[0] === 'git' && (call[1] as string[])[0] === 'checkout' && (call[1] as string[])[1] === '-b', const branchName = (checkoutCall![1] as string[])[2];
); expect(branchName).toMatch(/^feat\/issue-10-\d+$/);
expect(checkoutCall).toBeDefined(); });
const branchName = (checkoutCall![1] as string[])[2];
expect(branchName).toMatch(/^feat\/issue-10-\d+$/); it('should use pr_body_template when configured for PR creation', async () => {
}); mockLoadGlobalConfig.mockReturnValue({
language: 'en',
it('should use pr_body_template when configured for PR creation', async () => { defaultPiece: 'default',
mockLoadGlobalConfig.mockReturnValue({ logLevel: 'info',
language: 'en', provider: 'claude',
trustedDirectories: [], pipeline: {
defaultPiece: 'default', prBodyTemplate: '## Summary\n{issue_body}\n\nCloses #{issue}',
logLevel: 'info', },
provider: 'claude', });
pipeline: {
prBodyTemplate: '## Summary\n{issue_body}\n\nCloses #{issue}', mockFetchIssue.mockReturnValueOnce({
}, number: 50,
}); title: 'Fix auth',
body: 'Auth is broken.',
mockFetchIssue.mockReturnValueOnce({ labels: [],
number: 50, comments: [],
title: 'Fix auth', });
body: 'Auth is broken.', mockExecuteTask.mockResolvedValueOnce(true);
labels: [], mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/pr/1' });
comments: [],
}); await executePipeline({
mockExecuteTask.mockResolvedValueOnce(true); issueNumber: 50,
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/pr/1' }); piece: 'default',
branch: 'fix-auth',
await executePipeline({ autoPr: true,
issueNumber: 50, cwd: '/tmp/test',
piece: 'default', });
branch: 'fix-auth',
autoPr: true, // When prBodyTemplate is set, buildPrBody (mock) should NOT be called
cwd: '/tmp/test', // Instead, the template is expanded directly
}); expect(mockCreatePullRequest).toHaveBeenCalledWith(
'/tmp/test',
// When prBodyTemplate is set, buildPrBody (mock) should NOT be called expect.objectContaining({
// Instead, the template is expanded directly body: '## Summary\nAuth is broken.\n\nCloses #50',
expect(mockCreatePullRequest).toHaveBeenCalledWith( }),
'/tmp/test', );
expect.objectContaining({ });
body: '## Summary\nAuth is broken.\n\nCloses #50',
}), it('should fall back to buildPrBody when no template is configured', async () => {
); mockExecuteTask.mockResolvedValueOnce(true);
}); mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/pr/1' });
it('should fall back to buildPrBody when no template is configured', async () => { await executePipeline({
mockExecuteTask.mockResolvedValueOnce(true); task: 'Fix bug',
mockCreatePullRequest.mockReturnValueOnce({ success: true, url: 'https://github.com/pr/1' }); piece: 'default',
branch: 'fix-branch',
await executePipeline({ autoPr: true,
task: 'Fix bug', cwd: '/tmp/test',
piece: 'default', });
branch: 'fix-branch',
autoPr: true, // Should use buildPrBody (the mock)
cwd: '/tmp/test', expect(mockBuildPrBody).toHaveBeenCalled();
}); expect(mockCreatePullRequest).toHaveBeenCalledWith(
'/tmp/test',
// Should use buildPrBody (the mock) expect.objectContaining({
expect(mockBuildPrBody).toHaveBeenCalled(); body: 'Default PR body',
expect(mockCreatePullRequest).toHaveBeenCalledWith( }),
'/tmp/test', );
expect.objectContaining({ });
body: 'Default PR body', });
}),
); describe('--skip-git', () => {
}); it('should skip branch creation, commit, push when skipGit is true', async () => {
}); mockExecuteTask.mockResolvedValueOnce(true);
describe('--skip-git', () => { const exitCode = await executePipeline({
it('should skip branch creation, commit, push when skipGit is true', async () => { task: 'Fix the bug',
mockExecuteTask.mockResolvedValueOnce(true); piece: 'default',
autoPr: false,
const exitCode = await executePipeline({ skipGit: true,
task: 'Fix the bug', cwd: '/tmp/test',
piece: 'default', });
autoPr: false,
skipGit: true, expect(exitCode).toBe(0);
cwd: '/tmp/test', expect(mockExecuteTask).toHaveBeenCalledWith({
}); task: 'Fix the bug',
cwd: '/tmp/test',
expect(exitCode).toBe(0); pieceIdentifier: 'default',
expect(mockExecuteTask).toHaveBeenCalledWith({ projectCwd: '/tmp/test',
task: 'Fix the bug', agentOverrides: undefined,
cwd: '/tmp/test', });
pieceIdentifier: 'default',
projectCwd: '/tmp/test', // No git operations should have been called
agentOverrides: undefined, const gitCalls = mockExecFileSync.mock.calls.filter(
}); (call: unknown[]) => call[0] === 'git',
);
// No git operations should have been called expect(gitCalls).toHaveLength(0);
const gitCalls = mockExecFileSync.mock.calls.filter( expect(mockPushBranch).not.toHaveBeenCalled();
(call: unknown[]) => call[0] === 'git', });
);
expect(gitCalls).toHaveLength(0); it('should ignore --auto-pr when skipGit is true', async () => {
expect(mockPushBranch).not.toHaveBeenCalled(); mockExecuteTask.mockResolvedValueOnce(true);
});
const exitCode = await executePipeline({
it('should ignore --auto-pr when skipGit is true', async () => { task: 'Fix the bug',
mockExecuteTask.mockResolvedValueOnce(true); piece: 'default',
autoPr: true,
const exitCode = await executePipeline({ skipGit: true,
task: 'Fix the bug', cwd: '/tmp/test',
piece: 'default', });
autoPr: true,
skipGit: true, expect(exitCode).toBe(0);
cwd: '/tmp/test', expect(mockCreatePullRequest).not.toHaveBeenCalled();
}); });
expect(exitCode).toBe(0); it('should still return piece failure exit code when skipGit is true', async () => {
expect(mockCreatePullRequest).not.toHaveBeenCalled(); mockExecuteTask.mockResolvedValueOnce(false);
});
const exitCode = await executePipeline({
it('should still return piece failure exit code when skipGit is true', async () => { task: 'Fix the bug',
mockExecuteTask.mockResolvedValueOnce(false); piece: 'default',
autoPr: false,
const exitCode = await executePipeline({ skipGit: true,
task: 'Fix the bug', cwd: '/tmp/test',
piece: 'default', });
autoPr: false,
skipGit: true, expect(exitCode).toBe(3);
cwd: '/tmp/test', });
}); });
});
expect(exitCode).toBe(3);
});
});
});

View File

@ -1,273 +1,271 @@
/** /**
* Tests for summarizeTaskName * Tests for summarizeTaskName
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../infra/providers/index.js', () => ({ vi.mock('../infra/providers/index.js', () => ({
getProvider: vi.fn(), getProvider: vi.fn(),
})); }));
vi.mock('../infra/config/global/globalConfig.js', () => ({ vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn(), loadGlobalConfig: vi.fn(),
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true), getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),
})); }));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()), ...(await importOriginal<Record<string, unknown>>()),
createLogger: () => ({ createLogger: () => ({
info: vi.fn(), info: vi.fn(),
debug: vi.fn(), debug: vi.fn(),
error: vi.fn(), error: vi.fn(),
}), }),
})); }));
import { getProvider } from '../infra/providers/index.js'; import { getProvider } from '../infra/providers/index.js';
import { loadGlobalConfig } from '../infra/config/global/globalConfig.js'; import { loadGlobalConfig } from '../infra/config/global/globalConfig.js';
import { summarizeTaskName } from '../infra/task/summarize.js'; import { summarizeTaskName } from '../infra/task/summarize.js';
const mockGetProvider = vi.mocked(getProvider); const mockGetProvider = vi.mocked(getProvider);
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig);
const mockProviderCall = vi.fn(); const mockProviderCall = vi.fn();
const mockProvider = { const mockProvider = {
call: mockProviderCall, call: mockProviderCall,
callCustom: vi.fn(), callCustom: vi.fn(),
}; };
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockGetProvider.mockReturnValue(mockProvider); mockGetProvider.mockReturnValue(mockProvider);
mockLoadGlobalConfig.mockReturnValue({ mockLoadGlobalConfig.mockReturnValue({
language: 'ja', language: 'ja',
trustedDirectories: [], defaultPiece: 'default',
defaultPiece: 'default', logLevel: 'info',
logLevel: 'info', provider: 'claude',
provider: 'claude', model: 'haiku',
model: 'haiku', });
}); });
});
describe('summarizeTaskName', () => {
describe('summarizeTaskName', () => { it('should return AI-generated slug for task name', async () => {
it('should return AI-generated slug for Japanese task name', async () => { // Given: AI returns a slug for input
// Given: AI returns a slug for Japanese input mockProviderCall.mockResolvedValue({
mockProviderCall.mockResolvedValue({ agent: 'summarizer',
agent: 'summarizer', status: 'done',
status: 'done', content: 'add-auth',
content: 'add-auth', timestamp: new Date(),
timestamp: new Date(), });
});
// When
// When const result = await summarizeTaskName('long task name for testing', { cwd: '/project' });
const result = await summarizeTaskName('認証機能を追加する', { cwd: '/project' });
// Then
// Then expect(result).toBe('add-auth');
expect(result).toBe('add-auth'); expect(mockGetProvider).toHaveBeenCalledWith('claude');
expect(mockGetProvider).toHaveBeenCalledWith('claude'); expect(mockProviderCall).toHaveBeenCalledWith(
expect(mockProviderCall).toHaveBeenCalledWith( 'summarizer',
'summarizer', 'long task name for testing',
'認証機能を追加する', expect.objectContaining({
expect.objectContaining({ cwd: '/project',
cwd: '/project', model: 'haiku',
model: 'haiku', allowedTools: [],
allowedTools: [], })
}) );
); });
});
it('should return AI-generated slug for English task name', async () => {
it('should return AI-generated slug for English task name', async () => { // Given
// Given mockProviderCall.mockResolvedValue({
mockProviderCall.mockResolvedValue({ agent: 'summarizer',
agent: 'summarizer', status: 'done',
status: 'done', content: 'fix-login-bug',
content: 'fix-login-bug', timestamp: new Date(),
timestamp: new Date(), });
});
// When
// When const result = await summarizeTaskName('long task name for testing', { cwd: '/project' });
const result = await summarizeTaskName('Fix the login bug', { cwd: '/project' });
// Then
// Then expect(result).toBe('fix-login-bug');
expect(result).toBe('fix-login-bug'); });
});
it('should clean up AI response with extra characters', async () => {
it('should clean up AI response with extra characters', async () => { // Given: AI response has extra whitespace or formatting
// Given: AI response has extra whitespace or formatting mockProviderCall.mockResolvedValue({
mockProviderCall.mockResolvedValue({ agent: 'summarizer',
agent: 'summarizer', status: 'done',
status: 'done', content: ' Add-User-Auth! \n',
content: ' Add-User-Auth! \n', timestamp: new Date(),
timestamp: new Date(), });
});
// When
// When const result = await summarizeTaskName('long task name for testing', { cwd: '/project' });
const result = await summarizeTaskName('ユーザー認証を追加', { cwd: '/project' });
// Then
// Then expect(result).toBe('add-user-auth');
expect(result).toBe('add-user-auth'); });
});
it('should truncate long slugs to 30 characters without trailing hyphen', async () => {
it('should truncate long slugs to 30 characters without trailing hyphen', async () => { // Given: AI returns a long slug
// Given: AI returns a long slug mockProviderCall.mockResolvedValue({
mockProviderCall.mockResolvedValue({ agent: 'summarizer',
agent: 'summarizer', status: 'done',
status: 'done', content: 'this-is-a-very-long-slug-that-exceeds-thirty-characters',
content: 'this-is-a-very-long-slug-that-exceeds-thirty-characters', timestamp: new Date(),
timestamp: new Date(), });
});
// When
// When const result = await summarizeTaskName('long task name for testing', { cwd: '/project' });
const result = await summarizeTaskName('長いタスク名', { cwd: '/project' });
// Then
// Then expect(result.length).toBeLessThanOrEqual(30);
expect(result.length).toBeLessThanOrEqual(30); expect(result).toBe('this-is-a-very-long-slug-that');
expect(result).toBe('this-is-a-very-long-slug-that'); expect(result).not.toMatch(/-$/); // No trailing hyphen
expect(result).not.toMatch(/-$/); // No trailing hyphen });
});
it('should return "task" as fallback for empty AI response', async () => {
it('should return "task" as fallback for empty AI response', async () => { // Given: AI returns empty string
// Given: AI returns empty string mockProviderCall.mockResolvedValue({
mockProviderCall.mockResolvedValue({ agent: 'summarizer',
agent: 'summarizer', status: 'done',
status: 'done', content: '',
content: '', timestamp: new Date(),
timestamp: new Date(), });
});
// When
// When const result = await summarizeTaskName('long task name for testing', { cwd: '/project' });
const result = await summarizeTaskName('test', { cwd: '/project' });
// Then
// Then expect(result).toBe('task');
expect(result).toBe('task'); });
});
it('should use custom model if specified in options', async () => {
it('should use custom model if specified in options', async () => { // Given
// Given mockProviderCall.mockResolvedValue({
mockProviderCall.mockResolvedValue({ agent: 'summarizer',
agent: 'summarizer', status: 'done',
status: 'done', content: 'custom-task',
content: 'custom-task', timestamp: new Date(),
timestamp: new Date(), });
});
// When
// When await summarizeTaskName('test', { cwd: '/project', model: 'sonnet' });
await summarizeTaskName('test', { cwd: '/project', model: 'sonnet' });
// Then
// Then expect(mockProviderCall).toHaveBeenCalledWith(
expect(mockProviderCall).toHaveBeenCalledWith( 'summarizer',
'summarizer', expect.any(String),
expect.any(String), expect.objectContaining({
expect.objectContaining({ model: 'sonnet',
model: 'sonnet', })
}) );
); });
});
it('should use provider from config.yaml', async () => {
it('should use provider from config.yaml', async () => { // Given: config has codex provider
// Given: config has codex provider mockLoadGlobalConfig.mockReturnValue({
mockLoadGlobalConfig.mockReturnValue({ language: 'ja',
language: 'ja', defaultPiece: 'default',
trustedDirectories: [], logLevel: 'info',
defaultPiece: 'default', provider: 'codex',
logLevel: 'info', model: 'gpt-4',
provider: 'codex', });
model: 'gpt-4', mockProviderCall.mockResolvedValue({
}); agent: 'summarizer',
mockProviderCall.mockResolvedValue({ status: 'done',
agent: 'summarizer', content: 'codex-task',
status: 'done', timestamp: new Date(),
content: 'codex-task', });
timestamp: new Date(),
}); // When
await summarizeTaskName('test', { cwd: '/project' });
// When
await summarizeTaskName('test', { cwd: '/project' }); // Then
expect(mockGetProvider).toHaveBeenCalledWith('codex');
// Then expect(mockProviderCall).toHaveBeenCalledWith(
expect(mockGetProvider).toHaveBeenCalledWith('codex'); 'summarizer',
expect(mockProviderCall).toHaveBeenCalledWith( expect.any(String),
'summarizer', expect.objectContaining({
expect.any(String), model: 'gpt-4',
expect.objectContaining({ })
model: 'gpt-4', );
}) });
);
}); it('should remove consecutive hyphens', async () => {
// Given: AI response has consecutive hyphens
it('should remove consecutive hyphens', async () => { mockProviderCall.mockResolvedValue({
// Given: AI response has consecutive hyphens agent: 'summarizer',
mockProviderCall.mockResolvedValue({ status: 'done',
agent: 'summarizer', content: 'fix---multiple---hyphens',
status: 'done', timestamp: new Date(),
content: 'fix---multiple---hyphens', });
timestamp: new Date(),
}); // When
const result = await summarizeTaskName('long task name for testing', { cwd: '/project' });
// When
const result = await summarizeTaskName('test', { cwd: '/project' }); // Then
expect(result).toBe('fix-multiple-hyphens');
// Then });
expect(result).toBe('fix-multiple-hyphens');
}); it('should remove leading and trailing hyphens', async () => {
// Given: AI response has leading/trailing hyphens
it('should remove leading and trailing hyphens', async () => { mockProviderCall.mockResolvedValue({
// Given: AI response has leading/trailing hyphens agent: 'summarizer',
mockProviderCall.mockResolvedValue({ status: 'done',
agent: 'summarizer', content: '-leading-trailing-',
status: 'done', timestamp: new Date(),
content: '-leading-trailing-', });
timestamp: new Date(),
}); // When
const result = await summarizeTaskName('long task name for testing', { cwd: '/project' });
// When
const result = await summarizeTaskName('test', { cwd: '/project' }); // Then
expect(result).toBe('leading-trailing');
// Then });
expect(result).toBe('leading-trailing');
}); it('should throw error when config load fails', async () => {
// Given: config loading throws error
it('should throw error when config load fails', async () => { mockLoadGlobalConfig.mockImplementation(() => {
// Given: config loading throws error throw new Error('Config not found');
mockLoadGlobalConfig.mockImplementation(() => { });
throw new Error('Config not found');
}); // When/Then
await expect(summarizeTaskName('test', { cwd: '/project' })).rejects.toThrow('Config not found');
// When/Then });
await expect(summarizeTaskName('test', { cwd: '/project' })).rejects.toThrow('Config not found');
}); it('should use romanization when useLLM is false', async () => {
// When: useLLM is explicitly false
it('should use romanization when useLLM is false', async () => { const result = await summarizeTaskName('romanization test', { cwd: '/project', useLLM: false });
// When: useLLM is explicitly false
const result = await summarizeTaskName('認証機能を追加する', { cwd: '/project', useLLM: false }); // Then: should not call provider, should return romaji
expect(mockProviderCall).not.toHaveBeenCalled();
// Then: should not call provider, should return romaji expect(result).toMatch(/^[a-z0-9-]+$/);
expect(mockProviderCall).not.toHaveBeenCalled(); expect(result.length).toBeLessThanOrEqual(30);
expect(result).toMatch(/^[a-z0-9-]+$/); });
expect(result.length).toBeLessThanOrEqual(30);
}); it('should handle mixed Japanese/English with romanization', async () => {
// When
it('should handle mixed Japanese/English with romanization', async () => { const result = await summarizeTaskName('Add romanization', { cwd: '/project', useLLM: false });
// When
const result = await summarizeTaskName('Add 認証機能', { cwd: '/project', useLLM: false }); // Then
expect(result).toMatch(/^[a-z0-9-]+$/);
// Then expect(result).not.toMatch(/^-|-$/); // No leading/trailing hyphens
expect(result).toMatch(/^[a-z0-9-]+$/); });
expect(result).not.toMatch(/^-|-$/); // No leading/trailing hyphens
}); it('should use LLM by default', async () => {
// Given
it('should use LLM by default', async () => { mockProviderCall.mockResolvedValue({
// Given agent: 'summarizer',
mockProviderCall.mockResolvedValue({ status: 'done',
agent: 'summarizer', content: 'add-auth',
status: 'done', timestamp: new Date(),
content: 'add-auth', });
timestamp: new Date(),
}); // When: useLLM not specified (defaults to true)
await summarizeTaskName('test', { cwd: '/project' });
// When: useLLM not specified (defaults to true)
await summarizeTaskName('test', { cwd: '/project' }); // Then: should call provider
expect(mockProviderCall).toHaveBeenCalled();
// Then: should call provider });
expect(mockProviderCall).toHaveBeenCalled(); });
});
});

View File

@ -36,7 +36,6 @@ export interface PipelineConfig {
/** Global configuration for takt */ /** Global configuration for takt */
export interface GlobalConfig { export interface GlobalConfig {
language: Language; language: Language;
trustedDirectories: string[];
defaultPiece: string; defaultPiece: string;
logLevel: 'debug' | 'info' | 'warn' | 'error'; logLevel: 'debug' | 'info' | 'warn' | 'error';
provider?: 'claude' | 'codex' | 'mock'; provider?: 'claude' | 'codex' | 'mock';

View File

@ -216,7 +216,6 @@ export const PieceCategoryConfigSchema = z.record(z.string(), PieceCategoryConfi
/** Global config schema */ /** Global config schema */
export const GlobalConfigSchema = z.object({ export const GlobalConfigSchema = z.object({
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE), language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
trusted_directories: z.array(z.string()).optional().default([]),
default_piece: z.string().optional().default('default'), default_piece: z.string().optional().default('default'),
log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'), log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'),
provider: z.enum(['claude', 'codex', 'mock']).optional().default('claude'), provider: z.enum(['claude', 'codex', 'mock']).optional().default('claude'),

View File

@ -285,6 +285,11 @@ export async function interactiveMode(
const result = await callAIWithRetry(initialInput, prompts.systemPrompt); const result = await callAIWithRetry(initialInput, prompts.systemPrompt);
if (result) { if (result) {
if (!result.success) {
error(result.content);
blankLine();
return { confirmed: false, task: '' };
}
history.push({ role: 'assistant', content: result.content }); history.push({ role: 'assistant', content: result.content });
blankLine(); blankLine();
} else { } else {
@ -332,6 +337,11 @@ export async function interactiveMode(
info(prompts.ui.summarizeFailed); info(prompts.ui.summarizeFailed);
continue; continue;
} }
if (!summaryResult.success) {
error(summaryResult.content);
blankLine();
return { confirmed: false, task: '' };
}
const task = summaryResult.content.trim(); const task = summaryResult.content.trim();
const confirmed = await confirmTask( const confirmed = await confirmTask(
task, task,
@ -362,6 +372,12 @@ export async function interactiveMode(
const result = await callAIWithRetry(trimmed, prompts.systemPrompt); const result = await callAIWithRetry(trimmed, prompts.systemPrompt);
if (result) { if (result) {
if (!result.success) {
error(result.content);
blankLine();
history.pop();
return { confirmed: false, task: '' };
}
history.push({ role: 'assistant', content: result.content }); history.push({ role: 'assistant', content: result.content });
blankLine(); blankLine();
} else { } else {

View File

@ -6,7 +6,6 @@
*/ */
import { readFileSync, existsSync, writeFileSync } from 'node:fs'; import { readFileSync, existsSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { GlobalConfigSchema } from '../../../core/models/index.js'; import { GlobalConfigSchema } from '../../../core/models/index.js';
import type { GlobalConfig, DebugConfig, Language } from '../../../core/models/index.js'; import type { GlobalConfig, DebugConfig, Language } from '../../../core/models/index.js';
@ -17,7 +16,6 @@ import { DEFAULT_LANGUAGE } from '../../../shared/constants.js';
function createDefaultGlobalConfig(): GlobalConfig { function createDefaultGlobalConfig(): GlobalConfig {
return { return {
language: DEFAULT_LANGUAGE, language: DEFAULT_LANGUAGE,
trustedDirectories: [],
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
provider: 'claude', provider: 'claude',
@ -68,7 +66,6 @@ export class GlobalConfigManager {
const parsed = GlobalConfigSchema.parse(raw); const parsed = GlobalConfigSchema.parse(raw);
const config: GlobalConfig = { const config: GlobalConfig = {
language: parsed.language, language: parsed.language,
trustedDirectories: parsed.trusted_directories,
defaultPiece: parsed.default_piece, defaultPiece: parsed.default_piece,
logLevel: parsed.log_level, logLevel: parsed.log_level,
provider: parsed.provider, provider: parsed.provider,
@ -100,7 +97,6 @@ export class GlobalConfigManager {
const configPath = getGlobalConfigPath(); const configPath = getGlobalConfigPath();
const raw: Record<string, unknown> = { const raw: Record<string, unknown> = {
language: config.language, language: config.language,
trusted_directories: config.trustedDirectories,
default_piece: config.defaultPiece, default_piece: config.defaultPiece,
log_level: config.logLevel, log_level: config.logLevel,
provider: config.provider, provider: config.provider,
@ -203,23 +199,6 @@ export function setProvider(provider: 'claude' | 'codex'): void {
saveGlobalConfig(config); saveGlobalConfig(config);
} }
export function addTrustedDirectory(dir: string): void {
const config = loadGlobalConfig();
const resolvedDir = join(dir);
if (!config.trustedDirectories.includes(resolvedDir)) {
config.trustedDirectories.push(resolvedDir);
saveGlobalConfig(config);
}
}
export function isDirectoryTrusted(dir: string): boolean {
const config = loadGlobalConfig();
const resolvedDir = join(dir);
return config.trustedDirectories.some(
(trusted) => resolvedDir === trusted || resolvedDir.startsWith(trusted + '/')
);
}
/** /**
* Resolve the Anthropic API key. * Resolve the Anthropic API key.
* Priority: TAKT_ANTHROPIC_API_KEY env var > config.yaml > undefined (CLI auth fallback) * Priority: TAKT_ANTHROPIC_API_KEY env var > config.yaml > undefined (CLI auth fallback)
@ -290,4 +269,3 @@ export function getEffectiveDebugConfig(projectDir?: string): DebugConfig | unde
return debugConfig; return debugConfig;
} }

View File

@ -12,8 +12,6 @@ export {
getLanguage, getLanguage,
setLanguage, setLanguage,
setProvider, setProvider,
addTrustedDirectory,
isDirectoryTrusted,
resolveAnthropicApiKey, resolveAnthropicApiKey,
resolveOpenaiApiKey, resolveOpenaiApiKey,
loadProjectDebugConfig, loadProjectDebugConfig,

View File

@ -28,8 +28,6 @@ export {
loadGlobalConfig, loadGlobalConfig,
saveGlobalConfig, saveGlobalConfig,
invalidateGlobalConfigCache, invalidateGlobalConfigCache,
addTrustedDirectory,
isDirectoryTrusted,
loadProjectDebugConfig, loadProjectDebugConfig,
getEffectiveDebugConfig, getEffectiveDebugConfig,
} from '../global/globalConfig.js'; } from '../global/globalConfig.js';

View File

@ -2,14 +2,40 @@
* Codex provider implementation * Codex provider implementation
*/ */
import { execFileSync } from 'node:child_process';
import { callCodex, callCodexCustom, type CodexCallOptions } from '../codex/index.js'; import { callCodex, callCodexCustom, type CodexCallOptions } from '../codex/index.js';
import { resolveOpenaiApiKey } from '../config/index.js'; import { resolveOpenaiApiKey } from '../config/index.js';
import type { AgentResponse } from '../../core/models/index.js'; import type { AgentResponse } from '../../core/models/index.js';
import type { Provider, ProviderCallOptions } from './types.js'; import type { Provider, ProviderCallOptions } from './types.js';
const NOT_GIT_REPO_MESSAGE =
'Codex をご利用の場合 Git 管理下のディレクトリでのみ動作します。';
function isInsideGitRepo(cwd: string): boolean {
try {
const result = execFileSync('git', ['rev-parse', '--is-inside-work-tree'], {
cwd,
encoding: 'utf-8',
stdio: 'pipe',
}).trim();
return result === 'true';
} catch {
return false;
}
}
/** Codex provider - wraps existing Codex client */ /** Codex provider - wraps existing Codex client */
export class CodexProvider implements Provider { export class CodexProvider implements Provider {
async call(agentName: string, prompt: string, options: ProviderCallOptions): Promise<AgentResponse> { async call(agentName: string, prompt: string, options: ProviderCallOptions): Promise<AgentResponse> {
if (!isInsideGitRepo(options.cwd)) {
return {
agent: agentName,
status: 'blocked',
content: NOT_GIT_REPO_MESSAGE,
timestamp: new Date(),
};
}
const callOptions: CodexCallOptions = { const callOptions: CodexCallOptions = {
cwd: options.cwd, cwd: options.cwd,
sessionId: options.sessionId, sessionId: options.sessionId,
@ -24,6 +50,15 @@ export class CodexProvider implements Provider {
} }
async callCustom(agentName: string, prompt: string, systemPrompt: string, options: ProviderCallOptions): Promise<AgentResponse> { async callCustom(agentName: string, prompt: string, systemPrompt: string, options: ProviderCallOptions): Promise<AgentResponse> {
if (!isInsideGitRepo(options.cwd)) {
return {
agent: agentName,
status: 'blocked',
content: NOT_GIT_REPO_MESSAGE,
timestamp: new Date(),
};
}
const callOptions: CodexCallOptions = { const callOptions: CodexCallOptions = {
cwd: options.cwd, cwd: options.cwd,
sessionId: options.sessionId, sessionId: options.sessionId,