From 482fa51266002e1f434f52591e01edd1039ddd22 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Mon, 2 Feb 2026 06:02:26 +0900 Subject: [PATCH] refactor --- src/__tests__/cli-worktree.test.ts | 12 +- src/__tests__/interactive.test.ts | 2 +- src/__tests__/it-pipeline-modes.test.ts | 2 +- src/__tests__/it-pipeline.test.ts | 2 +- src/__tests__/taskExecution.test.ts | 2 +- src/cli.ts | 180 +---------------------- src/commands/index.ts | 6 + src/commands/interactive.ts | 2 +- src/commands/selectAndExecute.ts | 184 ++++++++++++++++++++++++ src/commands/workflowExecution.ts | 2 +- src/context.ts | 19 +++ 11 files changed, 230 insertions(+), 183 deletions(-) create mode 100644 src/commands/selectAndExecute.ts create mode 100644 src/context.ts diff --git a/src/__tests__/cli-worktree.test.ts b/src/__tests__/cli-worktree.test.ts index d6ae9c2..24d5a1b 100644 --- a/src/__tests__/cli-worktree.test.ts +++ b/src/__tests__/cli-worktree.test.ts @@ -71,9 +71,13 @@ vi.mock('../config/workflowLoader.js', () => ({ listWorkflows: vi.fn(() => []), })); -vi.mock('../constants.js', () => ({ - DEFAULT_WORKFLOW_NAME: 'default', -})); +vi.mock('../constants.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + DEFAULT_WORKFLOW_NAME: 'default', + }; +}); vi.mock('../github/issue.js', () => ({ isIssueReference: vi.fn((s: string) => /^#\d+$/.test(s)), @@ -88,7 +92,7 @@ import { confirm } from '../prompt/index.js'; import { createSharedClone } from '../task/clone.js'; import { summarizeTaskName } from '../task/summarize.js'; import { info } from '../utils/ui.js'; -import { confirmAndCreateWorktree } from '../cli.js'; +import { confirmAndCreateWorktree } from '../commands/selectAndExecute.js'; const mockConfirm = vi.mocked(confirm); const mockCreateSharedClone = vi.mocked(createSharedClone); diff --git a/src/__tests__/interactive.test.ts b/src/__tests__/interactive.test.ts index dc4f786..1155349 100644 --- a/src/__tests__/interactive.test.ts +++ b/src/__tests__/interactive.test.ts @@ -20,7 +20,7 @@ vi.mock('../utils/debug.js', () => ({ }), })); -vi.mock('../cli.js', () => ({ +vi.mock('../context.js', () => ({ isQuietMode: vi.fn(() => false), })); diff --git a/src/__tests__/it-pipeline-modes.test.ts b/src/__tests__/it-pipeline-modes.test.ts index 260382e..e9d1c72 100644 --- a/src/__tests__/it-pipeline-modes.test.ts +++ b/src/__tests__/it-pipeline-modes.test.ts @@ -122,7 +122,7 @@ vi.mock('../config/projectConfig.js', async (importOriginal) => { }; }); -vi.mock('../cli.js', () => ({ +vi.mock('../context.js', () => ({ isQuietMode: vi.fn().mockReturnValue(true), })); diff --git a/src/__tests__/it-pipeline.test.ts b/src/__tests__/it-pipeline.test.ts index cab8c8f..94202a4 100644 --- a/src/__tests__/it-pipeline.test.ts +++ b/src/__tests__/it-pipeline.test.ts @@ -104,7 +104,7 @@ vi.mock('../config/projectConfig.js', async (importOriginal) => { }; }); -vi.mock('../cli.js', () => ({ +vi.mock('../context.js', () => ({ isQuietMode: vi.fn().mockReturnValue(true), })); diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index ebf198a..ad7c595 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -53,7 +53,7 @@ vi.mock('./workflowExecution.js', () => ({ executeWorkflow: vi.fn(), })); -vi.mock('../cli.js', () => ({ +vi.mock('../context.js', () => ({ isQuietMode: vi.fn(() => false), })); diff --git a/src/cli.ts b/src/cli.ts index c07c8ba..0c4c95b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -27,10 +27,10 @@ import { getEffectiveDebugConfig, } from './config/index.js'; import { clearAgentSessions, getCurrentWorkflow, isVerboseMode } from './config/paths.js'; +import { setQuietMode } from './context.js'; import { info, error, success, setLogLevel } from './utils/ui.js'; import { initDebugLogger, createLogger, setVerboseConsole } from './utils/debug.js'; import { - executeTask, runAllTasks, switchWorkflow, switchConfig, @@ -41,16 +41,14 @@ import { interactiveMode, executePipeline, } from './commands/index.js'; -import { listWorkflows, isWorkflowPath } from './config/workflowLoader.js'; -import { selectOptionWithDefault, confirm } from './prompt/index.js'; -import { createSharedClone } from './task/clone.js'; -import { autoCommitAndPush } from './task/autoCommit.js'; -import { summarizeTaskName } from './task/summarize.js'; import { DEFAULT_WORKFLOW_NAME } from './constants.js'; import { checkForUpdates } from './utils/updateNotifier.js'; import { getErrorMessage } from './utils/error.js'; import { resolveIssueTask, isIssueReference } from './github/issue.js'; -import { createPullRequest, buildPrBody } from './github/pr.js'; +import { + selectAndExecuteTask, + type SelectAndExecuteOptions, +} from './commands/selectAndExecute.js'; import type { TaskExecutionOptions } from './commands/taskExecution.js'; import type { ProviderType } from './providers/index.js'; @@ -70,168 +68,6 @@ let pipelineMode = false; /** Whether quiet mode is active (--quiet flag or config, set in preAction) */ let quietMode = false; -export interface WorktreeConfirmationResult { - execCwd: string; - isWorktree: boolean; - branch?: string; -} - -/** - * Select a workflow interactively. - * Returns the selected workflow name, or null if cancelled. - */ -async function selectWorkflow(cwd: string): Promise { - const availableWorkflows = listWorkflows(cwd); - const currentWorkflow = getCurrentWorkflow(cwd); - - if (availableWorkflows.length === 0) { - info(`No workflows found. Using default: ${DEFAULT_WORKFLOW_NAME}`); - return DEFAULT_WORKFLOW_NAME; - } - - if (availableWorkflows.length === 1 && availableWorkflows[0]) { - return availableWorkflows[0]; - } - - const options = availableWorkflows.map((name) => ({ - label: name === currentWorkflow ? `${name} (current)` : name, - value: name, - })); - - const defaultWorkflow = availableWorkflows.includes(currentWorkflow) - ? currentWorkflow - : (availableWorkflows.includes(DEFAULT_WORKFLOW_NAME) - ? DEFAULT_WORKFLOW_NAME - : availableWorkflows[0] || DEFAULT_WORKFLOW_NAME); - - return selectOptionWithDefault('Select workflow:', options, defaultWorkflow); -} - -/** - * Execute a task with workflow selection, optional worktree, and auto-commit. - * Shared by direct task execution and interactive mode. - */ -export interface SelectAndExecuteOptions { - autoPr?: boolean; - repo?: string; - workflow?: string; - createWorktree?: boolean | undefined; -} - -async function selectAndExecuteTask( - cwd: string, - task: string, - options?: SelectAndExecuteOptions, - agentOverrides?: TaskExecutionOptions, -): Promise { - const workflowIdentifier = await determineWorkflow(cwd, options?.workflow); - - if (workflowIdentifier === null) { - info('Cancelled'); - return; - } - - const { execCwd, isWorktree, branch } = await confirmAndCreateWorktree( - cwd, - task, - options?.createWorktree, - ); - - log.info('Starting task execution', { workflow: workflowIdentifier, worktree: isWorktree }); - const taskSuccess = await executeTask({ - task, - cwd: execCwd, - workflowIdentifier, - projectCwd: cwd, - agentOverrides, - }); - - if (taskSuccess && isWorktree) { - const commitResult = autoCommitAndPush(execCwd, task, cwd); - if (commitResult.success && commitResult.commitHash) { - success(`Auto-committed & pushed: ${commitResult.commitHash}`); - } else if (!commitResult.success) { - error(`Auto-commit failed: ${commitResult.message}`); - } - - // PR creation: --auto-pr → create automatically, otherwise ask - if (commitResult.success && commitResult.commitHash && branch) { - const shouldCreatePr = options?.autoPr === true || await confirm('Create pull request?', false); - if (shouldCreatePr) { - info('Creating pull request...'); - const prBody = buildPrBody(undefined, `Workflow \`${workflowIdentifier}\` completed successfully.`); - const prResult = createPullRequest(execCwd, { - branch, - title: task.length > 100 ? `${task.slice(0, 97)}...` : task, - body: prBody, - repo: options?.repo, - }); - if (prResult.success) { - success(`PR created: ${prResult.url}`); - } else { - error(`PR creation failed: ${prResult.error}`); - } - } - } - } - - if (!taskSuccess) { - process.exit(1); - } -} - -/** - * Determine workflow to use. - * - * - If override looks like a path (isWorkflowPath), return it directly (validation is done at load time). - * - If override is a name, validate it exists in available workflows. - * - If no override, prompt user to select interactively. - */ -async function determineWorkflow(cwd: string, override?: string): Promise { - if (override) { - // Path-based: skip name validation (loader handles existence check) - if (isWorkflowPath(override)) { - return override; - } - // Name-based: validate workflow name exists - const availableWorkflows = listWorkflows(cwd); - const knownWorkflows = availableWorkflows.length === 0 ? [DEFAULT_WORKFLOW_NAME] : availableWorkflows; - if (!knownWorkflows.includes(override)) { - error(`Workflow not found: ${override}`); - return null; - } - return override; - } - return selectWorkflow(cwd); -} - -export async function confirmAndCreateWorktree( - cwd: string, - task: string, - createWorktreeOverride?: boolean | undefined, -): Promise { - const useWorktree = - typeof createWorktreeOverride === 'boolean' - ? createWorktreeOverride - : await confirm('Create worktree?', true); - - if (!useWorktree) { - return { execCwd: cwd, isWorktree: false }; - } - - // Summarize task name to English slug using AI - info('Generating branch name...'); - const taskSlug = await summarizeTaskName(task, { cwd }); - - const result = createSharedClone(cwd, { - worktree: true, - taskSlug, - }); - info(`Clone created: ${result.path} (branch: ${result.branch})`); - - return { execCwd: result.path, isWorktree: true, branch: result.branch }; -} - const program = new Command(); function resolveAgentOverrides(): TaskExecutionOptions | undefined { @@ -315,14 +151,12 @@ program.hook('preAction', async () => { // Quiet mode: CLI flag takes precedence over config quietMode = rootOpts.quiet === true || config.minimalOutput === true; + setQuietMode(quietMode); log.info('TAKT CLI starting', { version: cliVersion, cwd: resolvedCwd, verbose, pipelineMode, quietMode }); }); -/** Get whether quiet mode is active (CLI flag or config, resolved in preAction) */ -export function isQuietMode(): boolean { - return quietMode; -} +// isQuietMode is now exported from context.ts to avoid circular dependencies // --- Subcommands --- diff --git a/src/commands/index.ts b/src/commands/index.ts index c761fad..2aab10b 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -13,3 +13,9 @@ export { switchConfig, getCurrentPermissionMode, setPermissionMode, type Permiss export { listTasks } from './listTasks.js'; export { interactiveMode } from './interactive.js'; export { executePipeline, type PipelineExecutionOptions } from './pipelineExecution.js'; +export { + selectAndExecuteTask, + confirmAndCreateWorktree, + type SelectAndExecuteOptions, + type WorktreeConfirmationResult, +} from './selectAndExecute.js'; diff --git a/src/commands/interactive.ts b/src/commands/interactive.ts index aebf0be..5108fcf 100644 --- a/src/commands/interactive.ts +++ b/src/commands/interactive.ts @@ -13,7 +13,7 @@ import * as readline from 'node:readline'; import chalk from 'chalk'; import { loadGlobalConfig } from '../config/globalConfig.js'; -import { isQuietMode } from '../cli.js'; +import { isQuietMode } from '../context.js'; import { loadAgentSessions, updateAgentSession } from '../config/paths.js'; import { getProvider, type ProviderType } from '../providers/index.js'; import { createLogger } from '../utils/debug.js'; diff --git a/src/commands/selectAndExecute.ts b/src/commands/selectAndExecute.ts new file mode 100644 index 0000000..5ace1f9 --- /dev/null +++ b/src/commands/selectAndExecute.ts @@ -0,0 +1,184 @@ +/** + * Task execution orchestration. + * + * Coordinates workflow selection, worktree creation, task execution, + * auto-commit, and PR creation. Extracted from cli.ts to avoid + * mixing CLI parsing with business logic. + */ + +import { getCurrentWorkflow } from '../config/paths.js'; +import { listWorkflows, isWorkflowPath } from '../config/workflowLoader.js'; +import { selectOptionWithDefault, confirm } from '../prompt/index.js'; +import { createSharedClone } from '../task/clone.js'; +import { autoCommitAndPush } from '../task/autoCommit.js'; +import { summarizeTaskName } from '../task/summarize.js'; +import { DEFAULT_WORKFLOW_NAME } from '../constants.js'; +import { info, error, success } from '../utils/ui.js'; +import { createLogger } from '../utils/debug.js'; +import { createPullRequest, buildPrBody } from '../github/pr.js'; +import { executeTask } from './taskExecution.js'; +import type { TaskExecutionOptions } from './taskExecution.js'; + +const log = createLogger('selectAndExecute'); + +export interface WorktreeConfirmationResult { + execCwd: string; + isWorktree: boolean; + branch?: string; +} + +export interface SelectAndExecuteOptions { + autoPr?: boolean; + repo?: string; + workflow?: string; + createWorktree?: boolean | undefined; +} + +/** + * Select a workflow interactively. + * Returns the selected workflow name, or null if cancelled. + */ +async function selectWorkflow(cwd: string): Promise { + const availableWorkflows = listWorkflows(cwd); + const currentWorkflow = getCurrentWorkflow(cwd); + + if (availableWorkflows.length === 0) { + info(`No workflows found. Using default: ${DEFAULT_WORKFLOW_NAME}`); + return DEFAULT_WORKFLOW_NAME; + } + + if (availableWorkflows.length === 1 && availableWorkflows[0]) { + return availableWorkflows[0]; + } + + const options = availableWorkflows.map((name) => ({ + label: name === currentWorkflow ? `${name} (current)` : name, + value: name, + })); + + const defaultWorkflow = availableWorkflows.includes(currentWorkflow) + ? currentWorkflow + : (availableWorkflows.includes(DEFAULT_WORKFLOW_NAME) + ? DEFAULT_WORKFLOW_NAME + : availableWorkflows[0] || DEFAULT_WORKFLOW_NAME); + + return selectOptionWithDefault('Select workflow:', options, defaultWorkflow); +} + +/** + * Determine workflow to use. + * + * - If override looks like a path (isWorkflowPath), return it directly (validation is done at load time). + * - If override is a name, validate it exists in available workflows. + * - If no override, prompt user to select interactively. + */ +async function determineWorkflow(cwd: string, override?: string): Promise { + if (override) { + // Path-based: skip name validation (loader handles existence check) + if (isWorkflowPath(override)) { + return override; + } + // Name-based: validate workflow name exists + const availableWorkflows = listWorkflows(cwd); + const knownWorkflows = availableWorkflows.length === 0 ? [DEFAULT_WORKFLOW_NAME] : availableWorkflows; + if (!knownWorkflows.includes(override)) { + error(`Workflow not found: ${override}`); + return null; + } + return override; + } + return selectWorkflow(cwd); +} + +export async function confirmAndCreateWorktree( + cwd: string, + task: string, + createWorktreeOverride?: boolean | undefined, +): Promise { + const useWorktree = + typeof createWorktreeOverride === 'boolean' + ? createWorktreeOverride + : await confirm('Create worktree?', true); + + if (!useWorktree) { + return { execCwd: cwd, isWorktree: false }; + } + + // Summarize task name to English slug using AI + info('Generating branch name...'); + const taskSlug = await summarizeTaskName(task, { cwd }); + + const result = createSharedClone(cwd, { + worktree: true, + taskSlug, + }); + info(`Clone created: ${result.path} (branch: ${result.branch})`); + + return { execCwd: result.path, isWorktree: true, branch: result.branch }; +} + +/** + * Execute a task with workflow selection, optional worktree, and auto-commit. + * Shared by direct task execution and interactive mode. + */ +export async function selectAndExecuteTask( + cwd: string, + task: string, + options?: SelectAndExecuteOptions, + agentOverrides?: TaskExecutionOptions, +): Promise { + const workflowIdentifier = await determineWorkflow(cwd, options?.workflow); + + if (workflowIdentifier === null) { + info('Cancelled'); + return; + } + + const { execCwd, isWorktree, branch } = await confirmAndCreateWorktree( + cwd, + task, + options?.createWorktree, + ); + + log.info('Starting task execution', { workflow: workflowIdentifier, worktree: isWorktree }); + const taskSuccess = await executeTask({ + task, + cwd: execCwd, + workflowIdentifier, + projectCwd: cwd, + agentOverrides, + }); + + if (taskSuccess && isWorktree) { + const commitResult = autoCommitAndPush(execCwd, task, cwd); + if (commitResult.success && commitResult.commitHash) { + success(`Auto-committed & pushed: ${commitResult.commitHash}`); + } else if (!commitResult.success) { + error(`Auto-commit failed: ${commitResult.message}`); + } + + // PR creation: --auto-pr → create automatically, otherwise ask + if (commitResult.success && commitResult.commitHash && branch) { + const shouldCreatePr = options?.autoPr === true || await confirm('Create pull request?', false); + if (shouldCreatePr) { + info('Creating pull request...'); + const prBody = buildPrBody(undefined, `Workflow \`${workflowIdentifier}\` completed successfully.`); + const prResult = createPullRequest(execCwd, { + branch, + title: task.length > 100 ? `${task.slice(0, 97)}...` : task, + body: prBody, + repo: options?.repo, + }); + if (prResult.success) { + success(`PR created: ${prResult.url}`); + } else { + error(`PR creation failed: ${prResult.error}`); + } + } + } + } + + if (!taskSuccess) { + process.exit(1); + } +} diff --git a/src/commands/workflowExecution.ts b/src/commands/workflowExecution.ts index a5efe60..d57eded 100644 --- a/src/commands/workflowExecution.ts +++ b/src/commands/workflowExecution.ts @@ -14,7 +14,7 @@ import { updateWorktreeSession, } from '../config/paths.js'; import { loadGlobalConfig } from '../config/globalConfig.js'; -import { isQuietMode } from '../cli.js'; +import { isQuietMode } from '../context.js'; import { header, info, diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..4943222 --- /dev/null +++ b/src/context.ts @@ -0,0 +1,19 @@ +/** + * Runtime context shared across modules. + * + * Holds process-wide state (quiet mode, etc.) that would otherwise + * create circular dependencies if exported from cli.ts. + */ + +/** Whether quiet mode is active (set during CLI initialization) */ +let quietMode = false; + +/** Get whether quiet mode is active (CLI flag or config, resolved in preAction) */ +export function isQuietMode(): boolean { + return quietMode; +} + +/** Set quiet mode state. Called from CLI preAction hook. */ +export function setQuietMode(value: boolean): void { + quietMode = value; +}