diff --git a/src/__tests__/cli-slash-hash.test.ts b/src/__tests__/cli-slash-hash.test.ts new file mode 100644 index 0000000..ab3ceb2 --- /dev/null +++ b/src/__tests__/cli-slash-hash.test.ts @@ -0,0 +1,105 @@ +/** + * Tests for slash and hash prefixed inputs in CLI. + * + * Verifies that: + * - '/' prefixed inputs not matching known commands are treated as task instructions + * - '#' prefixed inputs not matching issue number pattern are treated as task instructions + * - isDirectTask() correctly identifies these patterns + */ + +import { describe, it, expect } from 'vitest'; +import { isDirectTask } from '../app/cli/helpers.js'; + +describe('isDirectTask', () => { + describe('slash prefixed inputs', () => { + it('returns false for slash prefixed single words (interactive mode)', () => { + expect(isDirectTask('/リファクタリングしてくれ')).toBe(false); + }); + + it('returns false for slash prefixed multi-word inputs (interactive mode)', () => { + expect(isDirectTask('/run tests and build')).toBe(false); + }); + + it('returns false for slash only (interactive mode)', () => { + expect(isDirectTask('/')).toBe(false); + }); + + it('returns false for slash with trailing spaces (interactive mode)', () => { + expect(isDirectTask('/ ')).toBe(false); + }); + + it('returns false for known command names with slash (interactive mode)', () => { + // Note: isDirectTask() treats all '/' prefixed inputs as false (interactive mode). + // Actual known command filtering happens in index.ts via program.commands. + expect(isDirectTask('/run')).toBe(false); + expect(isDirectTask('/watch')).toBe(false); + }); + }); + + describe('hash prefixed inputs', () => { + it('returns false for hash prefixed non-numeric inputs WITH SPACES (interactive mode)', () => { + // '#についてのドキュメントを書いて' → not valid issue ref → interactive mode + expect(isDirectTask('#についての ドキュメントを書いて')).toBe(false); + }); + + it('returns false for hash prefixed non-numeric with spaces (interactive mode)', () => { + // '#について のドキュメントを書いて' → not valid issue ref → interactive mode + expect(isDirectTask('#について のドキュメントを書いて')).toBe(false); + }); + + it('returns false for hash with single word (should enter interactive)', () => { + // '#' alone → not issue ref → interactive mode + expect(isDirectTask('#')).toBe(false); + }); + + it('returns false for hash with non-numeric single word (should enter interactive)', () => { + // '#についてのドキュメント' → not issue ref → interactive + expect(isDirectTask('#についてのドキュメント')).toBe(false); + }); + + it('returns true for valid issue references', () => { + expect(isDirectTask('#10')).toBe(true); + }); + + it('returns true for multiple issue references', () => { + // '#10 #20' → valid issue refs → direct execution + expect(isDirectTask('#10 #20')).toBe(true); + }); + + it('returns false for hash with number prefix followed by text (should enter interactive)', () => { + // '#32あああ' → not issue ref → interactive mode + expect(isDirectTask('#32あああ')).toBe(false); + }); + }); + + describe('existing behavior (regression)', () => { + it('returns false for inputs with spaces (interactive mode)', () => { + expect(isDirectTask('refactor this code')).toBe(false); + }); + + it('returns false for single word without prefix', () => { + expect(isDirectTask('refactor')).toBe(false); + }); + + it('returns false for short inputs without prefix', () => { + expect(isDirectTask('短い')).toBe(false); + }); + }); + + describe('edge cases', () => { + it('returns false for slash with special characters (interactive mode)', () => { + expect(isDirectTask('/~!@#$%^&*()')).toBe(false); + }); + + it('returns false for hash with special characters (should enter interactive)', () => { + // '#~!@#$%^&*()' → not issue ref → interactive mode + expect(isDirectTask('#~!@#$%^&*()')).toBe(false); + }); + + it('handles mixed whitespace', () => { + expect(isDirectTask(' /task ')).toBe(false); + // ' #task ' → not issue ref → interactive mode + expect(isDirectTask(' #task ')).toBe(false); + }); + }); +}); diff --git a/src/__tests__/github-issue.test.ts b/src/__tests__/github-issue.test.ts index e8f1801..d105044 100644 --- a/src/__tests__/github-issue.test.ts +++ b/src/__tests__/github-issue.test.ts @@ -70,6 +70,12 @@ describe('isIssueReference', () => { expect(isIssueReference('6')).toBe(false); }); + it('should return false for issue number followed by text (issue #32)', () => { + expect(isIssueReference('#32あああ')).toBe(false); + expect(isIssueReference('#10abc')).toBe(false); + expect(isIssueReference('#123text')).toBe(false); + }); + it('should return false for multiple issues (single string)', () => { expect(isIssueReference('#6 #7')).toBe(false); }); diff --git a/src/app/cli/helpers.ts b/src/app/cli/helpers.ts index 6320276..2b5adce 100644 --- a/src/app/cli/helpers.ts +++ b/src/app/cli/helpers.ts @@ -49,14 +49,19 @@ export function parseCreateWorktreeOption(value?: string): boolean | undefined { } /** - * Check if the input is a task description (should execute directly) - * vs a short input that should enter interactive mode as initial input. + * Check if the input is a task description that should execute directly + * vs one that should enter interactive mode. * - * Task descriptions: contain spaces, or are issue references (#N). - * Short single words: routed to interactive mode as first message. + * Direct execution (returns true): + * - Valid issue references (e.g., "#32", "#10 #20") + * + * Interactive mode (returns false): + * - All other inputs (task descriptions, single words, slash-prefixed, etc.) + * + * Note: This simplified logic ensures that only explicit issue references + * trigger direct execution. All other inputs go through interactive mode + * for requirement clarification. */ export function isDirectTask(input: string): boolean { - if (input.includes(' ')) return true; - if (isIssueReference(input) || input.trim().split(/\s+/).every((t: string) => isIssueReference(t))) return true; - return false; + return isIssueReference(input) || input.trim().split(/\s+/).every((t: string) => isIssueReference(t)); } diff --git a/src/app/cli/index.ts b/src/app/cli/index.ts index add83f0..05653b5 100644 --- a/src/app/cli/index.ts +++ b/src/app/cli/index.ts @@ -11,8 +11,31 @@ import { checkForUpdates } from '../../shared/utils/index.js'; checkForUpdates(); // Import in dependency order -import { program } from './program.js'; +import { program, runPreActionHook } from './program.js'; import './commands.js'; -import './routing.js'; +import { executeDefaultAction } from './routing.js'; -program.parse(); +(async () => { + const args = process.argv.slice(2); + const firstArg = args[0]; + + // Handle '/' prefixed inputs that are not known commands + if (firstArg?.startsWith('/')) { + const commandName = firstArg.slice(1); + const knownCommands = program.commands.map((cmd) => cmd.name()); + + if (!knownCommands.includes(commandName)) { + // Treat as task instruction + const task = args.join(' '); + await runPreActionHook(); + await executeDefaultAction(task); + process.exit(0); + } + } + + // Normal parsing for all other cases (including '#' prefixed inputs) + await program.parseAsync(); +})().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/app/cli/program.ts b/src/app/cli/program.ts index aedf1b8..75b88ae 100644 --- a/src/app/cli/program.ts +++ b/src/app/cli/program.ts @@ -54,8 +54,11 @@ program .option('--create-worktree ', '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 () => { +/** + * Run pre-action hook: common initialization for all commands. + * Exported for use in slash-command fallback logic. + */ +export async function runPreActionHook(): Promise { resolvedCwd = resolve(process.cwd()); const rootOpts = program.opts(); @@ -86,4 +89,7 @@ program.hook('preAction', async () => { setQuietMode(quietMode); log.info('TAKT CLI starting', { version: cliVersion, cwd: resolvedCwd, verbose, pipelineMode, quietMode }); -}); +} + +// Common initialization for all commands +program.hook('preAction', runPreActionHook); diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 146ced3..eb99f9d 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -16,94 +16,99 @@ import { DEFAULT_PIECE_NAME } from '../../shared/constants.js'; import { program, resolvedCwd, pipelineMode } from './program.js'; import { resolveAgentOverrides, parseCreateWorktreeOption, isDirectTask } from './helpers.js'; -program - .argument('[task]', 'Task to execute (or GitHub issue reference like "#6")') - .action(async (task?: string) => { - const opts = program.opts(); - const agentOverrides = resolveAgentOverrides(program); - const createWorktreeOverride = parseCreateWorktreeOption(opts.createWorktree as string | undefined); - const selectOptions: SelectAndExecuteOptions = { +/** + * Execute default action: handle task execution, pipeline mode, or interactive mode. + * Exported for use in slash-command fallback logic. + */ +export async function executeDefaultAction(task?: string): Promise { + const opts = program.opts(); + const agentOverrides = resolveAgentOverrides(program); + const createWorktreeOverride = parseCreateWorktreeOption(opts.createWorktree as string | undefined); + const selectOptions: SelectAndExecuteOptions = { + autoPr: opts.autoPr === true, + repo: opts.repo as string | undefined, + piece: opts.piece 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, + piece: (opts.piece as string | undefined) ?? DEFAULT_PIECE_NAME, + branch: opts.branch as string | undefined, autoPr: opts.autoPr === true, repo: opts.repo as string | undefined, - piece: opts.piece as string | undefined, - createWorktree: createWorktreeOverride, - }; + skipGit: opts.skipGit === true, + cwd: resolvedCwd, + provider: agentOverrides?.provider, + model: agentOverrides?.model, + }); - // --- 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, - piece: (opts.piece as string | undefined) ?? DEFAULT_PIECE_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; + if (exitCode !== 0) { + process.exit(exitCode); } + return; + } - // --- Normal (interactive) mode --- + // --- 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(getErrorMessage(e)); - process.exit(1); - } - return; - } - - if (task && isDirectTask(task)) { - 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(getErrorMessage(e)); - process.exit(1); - } - } + // 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); - return; + } catch (e) { + error(getErrorMessage(e)); + process.exit(1); + } + return; + } + + if (task && isDirectTask(task)) { + // isDirectTask() returns true only for issue references + let resolvedTask: string; + try { + info('Fetching GitHub Issue...'); + resolvedTask = resolveIssueTask(task); + } catch (e) { + error(getErrorMessage(e)); + process.exit(1); } - // Short single word or no task → interactive mode (with optional initial input) - const pieceId = await determinePiece(resolvedCwd, selectOptions.piece); - if (pieceId === null) { - info('Cancelled'); - return; - } + await selectAndExecuteTask(resolvedCwd, resolvedTask, selectOptions, agentOverrides); + return; + } - const pieceContext = getPieceDescription(pieceId, resolvedCwd); - const result = await interactiveMode(resolvedCwd, task, pieceContext); + // Non-issue inputs → interactive mode (with optional initial input) + const pieceId = await determinePiece(resolvedCwd, selectOptions.piece); + if (pieceId === null) { + info('Cancelled'); + return; + } - if (!result.confirmed) { - return; - } + const pieceContext = getPieceDescription(pieceId, resolvedCwd); + const result = await interactiveMode(resolvedCwd, task, pieceContext); - selectOptions.interactiveUserInput = true; - selectOptions.piece = pieceId; - selectOptions.interactiveMetadata = { confirmed: result.confirmed, task: result.task }; - await selectAndExecuteTask(resolvedCwd, result.task, selectOptions, agentOverrides); - }); + if (!result.confirmed) { + return; + } + + selectOptions.interactiveUserInput = true; + selectOptions.piece = pieceId; + selectOptions.interactiveMetadata = { confirmed: result.confirmed, task: result.task }; + await selectAndExecuteTask(resolvedCwd, result.task, selectOptions, agentOverrides); +} + +program + .argument('[task]', 'Task to execute (or GitHub issue reference like "#6")') + .action(executeDefaultAction);