486 lines
15 KiB
JavaScript
486 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
/**
|
|
* TAKT CLI - Task Agent Koordination Tool
|
|
*
|
|
* Usage:
|
|
* takt {task} - Execute task with current workflow (continues session)
|
|
* takt #99 - Execute task from GitHub issue
|
|
* takt run - Run all pending tasks from .takt/tasks/
|
|
* takt switch - Switch workflow interactively
|
|
* takt clear - Clear agent conversation sessions (reset to initial state)
|
|
* takt --help - Show help
|
|
* takt config - Select permission mode interactively
|
|
*
|
|
* Pipeline (non-interactive):
|
|
* takt --task "fix bug" -w magi --auto-pr
|
|
* takt --task "fix bug" --issue 99 --auto-pr
|
|
*/
|
|
|
|
import { createRequire } from 'node:module';
|
|
import { Command } from 'commander';
|
|
import { resolve } from 'node:path';
|
|
import {
|
|
initGlobalDirs,
|
|
initProjectDirs,
|
|
loadGlobalConfig,
|
|
getEffectiveDebugConfig,
|
|
} from './config/index.js';
|
|
import { clearAgentSessions, getCurrentWorkflow, isVerboseMode } from './config/paths.js';
|
|
import { info, error, success, setLogLevel } from './utils/ui.js';
|
|
import { initDebugLogger, createLogger, setVerboseConsole } from './utils/debug.js';
|
|
import {
|
|
executeTask,
|
|
runAllTasks,
|
|
switchWorkflow,
|
|
switchConfig,
|
|
addTask,
|
|
ejectBuiltin,
|
|
watchTasks,
|
|
listTasks,
|
|
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 { resolveIssueTask, isIssueReference } from './github/issue.js';
|
|
import { createPullRequest, buildPrBody } from './github/pr.js';
|
|
import type { TaskExecutionOptions } from './commands/taskExecution.js';
|
|
import type { ProviderType } from './providers/index.js';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const { version: cliVersion } = require('../package.json') as { version: string };
|
|
|
|
const log = createLogger('cli');
|
|
|
|
checkForUpdates();
|
|
|
|
/** Resolved cwd shared across commands via preAction hook */
|
|
let resolvedCwd = '';
|
|
|
|
/** Whether pipeline mode is active (--task specified, set in preAction) */
|
|
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<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, execCwd, workflowIdentifier, 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();
|
|
|
|
function resolveAgentOverrides(): TaskExecutionOptions | undefined {
|
|
const opts = program.opts();
|
|
const provider = opts.provider as ProviderType | undefined;
|
|
const model = opts.model as string | undefined;
|
|
|
|
if (!provider && !model) {
|
|
return undefined;
|
|
}
|
|
|
|
return { provider, model };
|
|
}
|
|
|
|
function parseCreateWorktreeOption(value?: string): boolean | undefined {
|
|
if (!value) {
|
|
return undefined;
|
|
}
|
|
|
|
const normalized = value.toLowerCase();
|
|
if (normalized === 'yes' || normalized === 'true') {
|
|
return true;
|
|
}
|
|
if (normalized === 'no' || normalized === 'false') {
|
|
return false;
|
|
}
|
|
|
|
error('Invalid value for --create-worktree. Use yes or no.');
|
|
process.exit(1);
|
|
}
|
|
|
|
program
|
|
.name('takt')
|
|
.description('TAKT: Task Agent Koordination Tool')
|
|
.version(cliVersion);
|
|
|
|
// --- Global options ---
|
|
program
|
|
.option('-i, --issue <number>', 'GitHub issue number (equivalent to #N)', (val: string) => parseInt(val, 10))
|
|
.option('-w, --workflow <name>', 'Workflow name or path to workflow file')
|
|
.option('-b, --branch <name>', 'Branch name (auto-generated if omitted)')
|
|
.option('--auto-pr', 'Create PR after successful execution')
|
|
.option('--repo <owner/repo>', 'Repository (defaults to current)')
|
|
.option('--provider <name>', 'Override agent provider (claude|codex|mock)')
|
|
.option('--model <name>', 'Override agent model')
|
|
.option('-t, --task <string>', 'Task content (as alternative to GitHub issue)')
|
|
.option('--pipeline', 'Pipeline mode: non-interactive, no worktree, direct branch creation')
|
|
.option('--skip-git', 'Skip branch creation, commit, and push (pipeline mode)')
|
|
.option('--create-worktree <yes|no>', 'Skip the worktree prompt by explicitly specifying yes or no')
|
|
.option('-q, --quiet', 'Minimal output mode: suppress AI output (for CI)');
|
|
|
|
// Common initialization for all commands
|
|
program.hook('preAction', async () => {
|
|
resolvedCwd = resolve(process.cwd());
|
|
|
|
// Pipeline mode: triggered by --pipeline flag
|
|
const rootOpts = program.opts();
|
|
pipelineMode = rootOpts.pipeline === true;
|
|
|
|
await initGlobalDirs({ nonInteractive: pipelineMode });
|
|
initProjectDirs(resolvedCwd);
|
|
|
|
const verbose = isVerboseMode(resolvedCwd);
|
|
let debugConfig = getEffectiveDebugConfig(resolvedCwd);
|
|
|
|
if (verbose && (!debugConfig || !debugConfig.enabled)) {
|
|
debugConfig = { enabled: true };
|
|
}
|
|
|
|
initDebugLogger(debugConfig, resolvedCwd);
|
|
|
|
// Load config once for both log level and quiet mode
|
|
const config = loadGlobalConfig();
|
|
|
|
if (verbose) {
|
|
setVerboseConsole(true);
|
|
setLogLevel('debug');
|
|
} else {
|
|
setLogLevel(config.logLevel);
|
|
}
|
|
|
|
// Quiet mode: CLI flag takes precedence over config
|
|
quietMode = rootOpts.quiet === true || config.minimalOutput === true;
|
|
|
|
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;
|
|
}
|
|
|
|
// --- Subcommands ---
|
|
|
|
program
|
|
.command('run')
|
|
.description('Run all pending tasks from .takt/tasks/')
|
|
.action(async () => {
|
|
const workflow = getCurrentWorkflow(resolvedCwd);
|
|
await runAllTasks(resolvedCwd, workflow, resolveAgentOverrides());
|
|
});
|
|
|
|
program
|
|
.command('watch')
|
|
.description('Watch for tasks and auto-execute')
|
|
.action(async () => {
|
|
await watchTasks(resolvedCwd, resolveAgentOverrides());
|
|
});
|
|
|
|
program
|
|
.command('add')
|
|
.description('Add a new task (interactive AI conversation)')
|
|
.argument('[task]', 'Task description or GitHub issue reference (e.g. "#28")')
|
|
.action(async (task?: string) => {
|
|
await addTask(resolvedCwd, task);
|
|
});
|
|
|
|
program
|
|
.command('list')
|
|
.description('List task branches (merge/delete)')
|
|
.action(async () => {
|
|
await listTasks(resolvedCwd, resolveAgentOverrides());
|
|
});
|
|
|
|
program
|
|
.command('switch')
|
|
.description('Switch workflow interactively')
|
|
.argument('[workflow]', 'Workflow name')
|
|
.action(async (workflow?: string) => {
|
|
await switchWorkflow(resolvedCwd, workflow);
|
|
});
|
|
|
|
program
|
|
.command('clear')
|
|
.description('Clear agent conversation sessions')
|
|
.action(() => {
|
|
clearAgentSessions(resolvedCwd);
|
|
success('Agent sessions cleared');
|
|
});
|
|
|
|
program
|
|
.command('eject')
|
|
.description('Copy builtin workflow/agents to ~/.takt/ for customization')
|
|
.argument('[name]', 'Specific builtin to eject')
|
|
.action(async (name?: string) => {
|
|
await ejectBuiltin(name);
|
|
});
|
|
|
|
program
|
|
.command('config')
|
|
.description('Configure settings (permission mode)')
|
|
.argument('[key]', 'Configuration key')
|
|
.action(async (key?: string) => {
|
|
await switchConfig(resolvedCwd, key);
|
|
});
|
|
|
|
// --- Default action: task execution, interactive mode, or pipeline ---
|
|
|
|
/**
|
|
* Check if the input is a task description (should execute directly)
|
|
* vs a short input that should enter interactive mode as initial input.
|
|
*
|
|
* Task descriptions: contain spaces, or are issue references (#N).
|
|
* Short single words: routed to interactive mode as first message.
|
|
*/
|
|
function isDirectTask(input: string): boolean {
|
|
// Multi-word input is a task description
|
|
if (input.includes(' ')) return true;
|
|
// Issue references are direct tasks
|
|
if (isIssueReference(input) || input.trim().split(/\s+/).every((t: string) => isIssueReference(t))) return true;
|
|
return false;
|
|
}
|
|
|
|
|
|
program
|
|
.argument('[task]', 'Task to execute (or GitHub issue reference like "#6")')
|
|
.action(async (task?: string) => {
|
|
const opts = program.opts();
|
|
const agentOverrides = resolveAgentOverrides();
|
|
const createWorktreeOverride = parseCreateWorktreeOption(opts.createWorktree as string | undefined);
|
|
const selectOptions: SelectAndExecuteOptions = {
|
|
autoPr: opts.autoPr === true,
|
|
repo: opts.repo as string | undefined,
|
|
workflow: opts.workflow as string | undefined,
|
|
createWorktree: createWorktreeOverride,
|
|
};
|
|
|
|
// --- Pipeline mode (non-interactive): triggered by --pipeline ---
|
|
if (pipelineMode) {
|
|
const exitCode = await executePipeline({
|
|
issueNumber: opts.issue as number | undefined,
|
|
task: opts.task as string | undefined,
|
|
workflow: (opts.workflow as string | undefined) ?? DEFAULT_WORKFLOW_NAME,
|
|
branch: opts.branch as string | undefined,
|
|
autoPr: opts.autoPr === true,
|
|
repo: opts.repo as string | undefined,
|
|
skipGit: opts.skipGit === true,
|
|
cwd: resolvedCwd,
|
|
provider: agentOverrides?.provider,
|
|
model: agentOverrides?.model,
|
|
});
|
|
|
|
if (exitCode !== 0) {
|
|
process.exit(exitCode);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// --- Normal (interactive) mode ---
|
|
|
|
// Resolve --task option to task text
|
|
const taskFromOption = opts.task as string | undefined;
|
|
if (taskFromOption) {
|
|
await selectAndExecuteTask(resolvedCwd, taskFromOption, selectOptions, agentOverrides);
|
|
return;
|
|
}
|
|
|
|
// Resolve --issue N to task text (same as #N)
|
|
const issueFromOption = opts.issue as number | undefined;
|
|
if (issueFromOption) {
|
|
try {
|
|
const resolvedTask = resolveIssueTask(`#${issueFromOption}`);
|
|
await selectAndExecuteTask(resolvedCwd, resolvedTask, selectOptions, agentOverrides);
|
|
} catch (e) {
|
|
error(e instanceof Error ? e.message : String(e));
|
|
process.exit(1);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (task && isDirectTask(task)) {
|
|
// Resolve #N issue references to task text
|
|
let resolvedTask: string = task;
|
|
if (isIssueReference(task) || task.trim().split(/\s+/).every((t: string) => isIssueReference(t))) {
|
|
try {
|
|
info('Fetching GitHub Issue...');
|
|
resolvedTask = resolveIssueTask(task);
|
|
} catch (e) {
|
|
error(e instanceof Error ? e.message : String(e));
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
await selectAndExecuteTask(resolvedCwd, resolvedTask, selectOptions, agentOverrides);
|
|
return;
|
|
}
|
|
|
|
// Short single word or no task → interactive mode (with optional initial input)
|
|
const result = await interactiveMode(resolvedCwd, task);
|
|
|
|
if (!result.confirmed) {
|
|
return;
|
|
}
|
|
|
|
await selectAndExecuteTask(resolvedCwd, result.task, selectOptions, agentOverrides);
|
|
});
|
|
|
|
program.parse();
|