This commit is contained in:
nrslib 2026-02-02 06:02:26 +09:00
parent b648a8ea6b
commit 482fa51266
11 changed files with 230 additions and 183 deletions

View File

@ -71,9 +71,13 @@ vi.mock('../config/workflowLoader.js', () => ({
listWorkflows: vi.fn(() => []), listWorkflows: vi.fn(() => []),
})); }));
vi.mock('../constants.js', () => ({ vi.mock('../constants.js', async (importOriginal) => {
DEFAULT_WORKFLOW_NAME: 'default', const actual = await importOriginal<typeof import('../constants.js')>();
})); return {
...actual,
DEFAULT_WORKFLOW_NAME: 'default',
};
});
vi.mock('../github/issue.js', () => ({ vi.mock('../github/issue.js', () => ({
isIssueReference: vi.fn((s: string) => /^#\d+$/.test(s)), 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 { createSharedClone } from '../task/clone.js';
import { summarizeTaskName } from '../task/summarize.js'; import { summarizeTaskName } from '../task/summarize.js';
import { info } from '../utils/ui.js'; import { info } from '../utils/ui.js';
import { confirmAndCreateWorktree } from '../cli.js'; import { confirmAndCreateWorktree } from '../commands/selectAndExecute.js';
const mockConfirm = vi.mocked(confirm); const mockConfirm = vi.mocked(confirm);
const mockCreateSharedClone = vi.mocked(createSharedClone); const mockCreateSharedClone = vi.mocked(createSharedClone);

View File

@ -20,7 +20,7 @@ vi.mock('../utils/debug.js', () => ({
}), }),
})); }));
vi.mock('../cli.js', () => ({ vi.mock('../context.js', () => ({
isQuietMode: vi.fn(() => false), isQuietMode: vi.fn(() => false),
})); }));

View File

@ -122,7 +122,7 @@ vi.mock('../config/projectConfig.js', async (importOriginal) => {
}; };
}); });
vi.mock('../cli.js', () => ({ vi.mock('../context.js', () => ({
isQuietMode: vi.fn().mockReturnValue(true), isQuietMode: vi.fn().mockReturnValue(true),
})); }));

View File

@ -104,7 +104,7 @@ vi.mock('../config/projectConfig.js', async (importOriginal) => {
}; };
}); });
vi.mock('../cli.js', () => ({ vi.mock('../context.js', () => ({
isQuietMode: vi.fn().mockReturnValue(true), isQuietMode: vi.fn().mockReturnValue(true),
})); }));

View File

@ -53,7 +53,7 @@ vi.mock('./workflowExecution.js', () => ({
executeWorkflow: vi.fn(), executeWorkflow: vi.fn(),
})); }));
vi.mock('../cli.js', () => ({ vi.mock('../context.js', () => ({
isQuietMode: vi.fn(() => false), isQuietMode: vi.fn(() => false),
})); }));

View File

@ -27,10 +27,10 @@ import {
getEffectiveDebugConfig, getEffectiveDebugConfig,
} from './config/index.js'; } from './config/index.js';
import { clearAgentSessions, getCurrentWorkflow, isVerboseMode } from './config/paths.js'; import { clearAgentSessions, getCurrentWorkflow, isVerboseMode } from './config/paths.js';
import { setQuietMode } from './context.js';
import { info, error, success, setLogLevel } from './utils/ui.js'; import { info, error, success, setLogLevel } from './utils/ui.js';
import { initDebugLogger, createLogger, setVerboseConsole } from './utils/debug.js'; import { initDebugLogger, createLogger, setVerboseConsole } from './utils/debug.js';
import { import {
executeTask,
runAllTasks, runAllTasks,
switchWorkflow, switchWorkflow,
switchConfig, switchConfig,
@ -41,16 +41,14 @@ import {
interactiveMode, interactiveMode,
executePipeline, executePipeline,
} from './commands/index.js'; } 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 { DEFAULT_WORKFLOW_NAME } from './constants.js';
import { checkForUpdates } from './utils/updateNotifier.js'; import { checkForUpdates } from './utils/updateNotifier.js';
import { getErrorMessage } from './utils/error.js'; import { getErrorMessage } from './utils/error.js';
import { resolveIssueTask, isIssueReference } from './github/issue.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 { TaskExecutionOptions } from './commands/taskExecution.js';
import type { ProviderType } from './providers/index.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) */ /** Whether quiet mode is active (--quiet flag or config, set in preAction) */
let quietMode = false; 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<string | null> {
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<void> {
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<string | null> {
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<WorktreeConfirmationResult> {
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(); const program = new Command();
function resolveAgentOverrides(): TaskExecutionOptions | undefined { function resolveAgentOverrides(): TaskExecutionOptions | undefined {
@ -315,14 +151,12 @@ program.hook('preAction', async () => {
// Quiet mode: CLI flag takes precedence over config // Quiet mode: CLI flag takes precedence over config
quietMode = rootOpts.quiet === true || config.minimalOutput === true; quietMode = rootOpts.quiet === true || config.minimalOutput === true;
setQuietMode(quietMode);
log.info('TAKT CLI starting', { version: cliVersion, cwd: resolvedCwd, verbose, pipelineMode, 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) */ // isQuietMode is now exported from context.ts to avoid circular dependencies
export function isQuietMode(): boolean {
return quietMode;
}
// --- Subcommands --- // --- Subcommands ---

View File

@ -13,3 +13,9 @@ export { switchConfig, getCurrentPermissionMode, setPermissionMode, type Permiss
export { listTasks } from './listTasks.js'; export { listTasks } from './listTasks.js';
export { interactiveMode } from './interactive.js'; export { interactiveMode } from './interactive.js';
export { executePipeline, type PipelineExecutionOptions } from './pipelineExecution.js'; export { executePipeline, type PipelineExecutionOptions } from './pipelineExecution.js';
export {
selectAndExecuteTask,
confirmAndCreateWorktree,
type SelectAndExecuteOptions,
type WorktreeConfirmationResult,
} from './selectAndExecute.js';

View File

@ -13,7 +13,7 @@
import * as readline from 'node:readline'; import * as readline from 'node:readline';
import chalk from 'chalk'; import chalk from 'chalk';
import { loadGlobalConfig } from '../config/globalConfig.js'; import { loadGlobalConfig } from '../config/globalConfig.js';
import { isQuietMode } from '../cli.js'; import { isQuietMode } from '../context.js';
import { loadAgentSessions, updateAgentSession } from '../config/paths.js'; import { loadAgentSessions, updateAgentSession } from '../config/paths.js';
import { getProvider, type ProviderType } from '../providers/index.js'; import { getProvider, type ProviderType } from '../providers/index.js';
import { createLogger } from '../utils/debug.js'; import { createLogger } from '../utils/debug.js';

View File

@ -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<string | null> {
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<string | null> {
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<WorktreeConfirmationResult> {
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<void> {
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);
}
}

View File

@ -14,7 +14,7 @@ import {
updateWorktreeSession, updateWorktreeSession,
} from '../config/paths.js'; } from '../config/paths.js';
import { loadGlobalConfig } from '../config/globalConfig.js'; import { loadGlobalConfig } from '../config/globalConfig.js';
import { isQuietMode } from '../cli.js'; import { isQuietMode } from '../context.js';
import { import {
header, header,
info, info,

19
src/context.ts Normal file
View File

@ -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;
}