diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index 8b94428..7396187 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -8,11 +8,6 @@ vi.mock('../features/interactive/index.js', () => ({ interactiveMode: vi.fn(), })); -vi.mock('../infra/config/global/globalConfig.js', () => ({ - loadGlobalConfig: vi.fn(() => ({ provider: 'claude' })), - getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true), -})); - vi.mock('../shared/prompt/index.js', () => ({ promptInput: vi.fn(), confirm: vi.fn(), @@ -38,15 +33,6 @@ vi.mock('../features/tasks/execute/selectAndExecute.js', () => ({ determinePiece: vi.fn(), })); -vi.mock('../infra/config/loaders/pieceResolver.js', () => ({ - getPieceDescription: vi.fn(() => ({ - name: 'default', - description: '', - pieceStructure: '1. implement\n2. review', - movementPreviews: [], - })), -})); - vi.mock('../infra/github/issue.js', () => ({ isIssueReference: vi.fn((s: string) => /^#\d+$/.test(s)), resolveIssueTask: vi.fn(), @@ -65,16 +51,17 @@ vi.mock('../infra/github/issue.js', () => ({ import { interactiveMode } from '../features/interactive/index.js'; import { promptInput, confirm } from '../shared/prompt/index.js'; +import { info } from '../shared/ui/index.js'; import { determinePiece } from '../features/tasks/execute/selectAndExecute.js'; -import { resolveIssueTask, createIssue } from '../infra/github/issue.js'; +import { resolveIssueTask } from '../infra/github/issue.js'; import { addTask } from '../features/tasks/index.js'; -const mockResolveIssueTask = vi.mocked(resolveIssueTask); const mockInteractiveMode = vi.mocked(interactiveMode); const mockPromptInput = vi.mocked(promptInput); const mockConfirm = vi.mocked(confirm); +const mockInfo = vi.mocked(info); const mockDeterminePiece = vi.mocked(determinePiece); -const mockCreateIssue = vi.mocked(createIssue); +const mockResolveIssueTask = vi.mocked(resolveIssueTask); let testDir: string; @@ -101,25 +88,38 @@ describe('addTask', () => { return fs.readFileSync(path.join(dir, String(taskDir), 'order.md'), 'utf-8'); } - it('should create task entry from interactive result', async () => { - mockInteractiveMode.mockResolvedValue({ action: 'execute', task: '# 認証機能追加\nJWT認証を実装する' }); - + it('should show usage and exit when task is missing', async () => { await addTask(testDir); - const tasks = loadTasks(testDir).tasks; - expect(tasks).toHaveLength(1); - expect(tasks[0]?.content).toBeUndefined(); - expect(tasks[0]?.task_dir).toBeTypeOf('string'); - expect(readOrderContent(testDir, tasks[0]?.task_dir)).toContain('JWT認証を実装する'); - expect(tasks[0]?.piece).toBe('default'); + expect(mockInfo).toHaveBeenCalledWith('Usage: takt add '); + expect(mockDeterminePiece).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false); + }); + + it('should show usage and exit when task is blank', async () => { + await addTask(testDir, ' '); + + expect(mockInfo).toHaveBeenCalledWith('Usage: takt add '); + expect(mockDeterminePiece).not.toHaveBeenCalled(); + expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false); + }); + + it('should save plain text task without interactive mode', async () => { + await addTask(testDir, ' JWT認証を実装する '); + + expect(mockInteractiveMode).not.toHaveBeenCalled(); + const task = loadTasks(testDir).tasks[0]!; + expect(task.content).toBeUndefined(); + expect(task.task_dir).toBeTypeOf('string'); + expect(readOrderContent(testDir, task.task_dir)).toContain('JWT認証を実装する'); + expect(task.piece).toBe('default'); }); it('should include worktree settings when enabled', async () => { - mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'Task content' }); mockConfirm.mockResolvedValue(true); mockPromptInput.mockResolvedValueOnce('/custom/path').mockResolvedValueOnce('feat/branch'); - await addTask(testDir); + await addTask(testDir, 'Task content'); const task = loadTasks(testDir).tasks[0]!; expect(task.worktree).toBe('/custom/path'); @@ -128,7 +128,6 @@ describe('addTask', () => { it('should create task from issue reference without interactive mode', async () => { mockResolveIssueTask.mockReturnValue('Issue #99: Fix login timeout'); - mockConfirm.mockResolvedValue(false); await addTask(testDir, '#99'); @@ -142,37 +141,8 @@ describe('addTask', () => { it('should not create task when piece selection is cancelled', async () => { mockDeterminePiece.mockResolvedValue(null); - await addTask(testDir); + await addTask(testDir, 'Task content'); expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false); }); - - it('should create issue and save task when create_issue action is chosen', async () => { - // Given - mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature' }); - mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/55' }); - mockConfirm.mockResolvedValue(false); - - // When - await addTask(testDir); - - // Then - const tasks = loadTasks(testDir).tasks; - expect(tasks).toHaveLength(1); - expect(tasks[0]?.issue).toBe(55); - expect(tasks[0]?.content).toBeUndefined(); - expect(readOrderContent(testDir, tasks[0]?.task_dir)).toContain('New feature'); - }); - - it('should not save task when issue creation fails in create_issue action', async () => { - // Given - mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'New feature' }); - mockCreateIssue.mockReturnValue({ success: false, error: 'auth failed' }); - - // When - await addTask(testDir); - - // Then - expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false); - }); }); diff --git a/src/app/cli/commands.ts b/src/app/cli/commands.ts index 215e1b8..4c12eb1 100644 --- a/src/app/cli/commands.ts +++ b/src/app/cli/commands.ts @@ -30,7 +30,7 @@ program program .command('add') - .description('Add a new task (interactive AI conversation)') + .description('Add a new task') .argument('[task]', 'Task description or GitHub issue reference (e.g. "#28")') .action(async (task?: string) => { await addTask(resolvedCwd, task); diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 1b80800..b179b63 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -1,8 +1,7 @@ /** * add command implementation * - * Starts an AI conversation to refine task requirements, - * then appends a task record to .takt/tasks.yaml. + * Appends a task record to .takt/tasks.yaml. */ import * as path from 'node:path'; @@ -10,11 +9,9 @@ import * as fs from 'node:fs'; import { promptInput, confirm } from '../../../shared/prompt/index.js'; import { success, info, error } from '../../../shared/ui/index.js'; import { TaskRunner, type TaskFileData } from '../../../infra/task/index.js'; -import { getPieceDescription, loadGlobalConfig } from '../../../infra/config/index.js'; import { determinePiece } from '../execute/selectAndExecute.js'; import { createLogger, getErrorMessage, generateReportDir } from '../../../shared/utils/index.js'; import { isIssueReference, resolveIssueTask, parseIssueNumbers, createIssue } from '../../../infra/github/index.js'; -import { interactiveMode } from '../../interactive/index.js'; const log = createLogger('add-task'); @@ -163,66 +160,44 @@ export async function saveTaskFromInteractive( * add command handler * * Flow: - * A) Issue参照の場合: issue取得 → ピース選択 → ワークツリー設定 → YAML作成 - * B) それ以外: ピース選択 → AI対話モード → ワークツリー設定 → YAML作成 + * A) 引数なし: Usage表示して終了 + * B) Issue参照の場合: issue取得 → ピース選択 → ワークツリー設定 → YAML作成 + * C) 通常入力: 引数をそのまま保存 */ export async function addTask(cwd: string, task?: string): Promise { - // ピース選択とタスク内容の決定 + const rawTask = task ?? ''; + const trimmedTask = rawTask.trim(); + if (!trimmedTask) { + info('Usage: takt add '); + return; + } + let taskContent: string; let issueNumber: number | undefined; - let piece: string | undefined; - if (task && isIssueReference(task)) { + if (isIssueReference(trimmedTask)) { // Issue reference: fetch issue and use directly as task content info('Fetching GitHub Issue...'); try { - taskContent = resolveIssueTask(task); - const numbers = parseIssueNumbers([task]); + taskContent = resolveIssueTask(trimmedTask); + const numbers = parseIssueNumbers([trimmedTask]); if (numbers.length > 0) { issueNumber = numbers[0]; } } catch (e) { const msg = getErrorMessage(e); - log.error('Failed to fetch GitHub Issue', { task, error: msg }); - info(`Failed to fetch issue ${task}: ${msg}`); + log.error('Failed to fetch GitHub Issue', { task: trimmedTask, error: msg }); + info(`Failed to fetch issue ${trimmedTask}: ${msg}`); return; } - - // ピース選択(issue取得成功後) - const pieceId = await determinePiece(cwd); - if (pieceId === null) { - info('Cancelled.'); - return; - } - piece = pieceId; } else { - // ピース選択を先に行い、結果を対話モードに渡す - const pieceId = await determinePiece(cwd); - if (pieceId === null) { - info('Cancelled.'); - return; - } - piece = pieceId; + taskContent = rawTask; + } - const globalConfig = loadGlobalConfig(); - const previewCount = globalConfig.interactivePreviewMovements; - const pieceContext = getPieceDescription(pieceId, cwd, previewCount); - - // Interactive mode: AI conversation to refine task - const result = await interactiveMode(cwd, undefined, pieceContext); - - if (result.action === 'create_issue') { - await createIssueAndSaveTask(cwd, result.task, piece); - return; - } - - if (result.action !== 'execute' && result.action !== 'save_task') { - info('Cancelled.'); - return; - } - - // interactiveMode already returns a summarized task from conversation - taskContent = result.task; + const piece = await determinePiece(cwd); + if (piece === null) { + info('Cancelled.'); + return; } // 3. ワークツリー/ブランチ/PR設定