resolve / や # で始まる入力をコマンド/Issue未検出時にタスク指示として受け入れる #32

This commit is contained in:
nrslib 2026-02-04 18:34:51 +09:00
parent 0f8449dad9
commit 12ae871f16
6 changed files with 241 additions and 91 deletions

View File

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

View File

@ -70,6 +70,12 @@ describe('isIssueReference', () => {
expect(isIssueReference('6')).toBe(false); 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)', () => { it('should return false for multiple issues (single string)', () => {
expect(isIssueReference('#6 #7')).toBe(false); expect(isIssueReference('#6 #7')).toBe(false);
}); });

View File

@ -49,14 +49,19 @@ export function parseCreateWorktreeOption(value?: string): boolean | undefined {
} }
/** /**
* Check if the input is a task description (should execute directly) * Check if the input is a task description that should execute directly
* vs a short input that should enter interactive mode as initial input. * vs one that should enter interactive mode.
* *
* Task descriptions: contain spaces, or are issue references (#N). * Direct execution (returns true):
* Short single words: routed to interactive mode as first message. * - 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 { export function isDirectTask(input: string): boolean {
if (input.includes(' ')) return true; return isIssueReference(input) || input.trim().split(/\s+/).every((t: string) => isIssueReference(t));
if (isIssueReference(input) || input.trim().split(/\s+/).every((t: string) => isIssueReference(t))) return true;
return false;
} }

View File

@ -11,8 +11,31 @@ import { checkForUpdates } from '../../shared/utils/index.js';
checkForUpdates(); checkForUpdates();
// Import in dependency order // Import in dependency order
import { program } from './program.js'; import { program, runPreActionHook } from './program.js';
import './commands.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);
});

View File

@ -54,8 +54,11 @@ program
.option('--create-worktree <yes|no>', 'Skip the worktree prompt by explicitly specifying yes or no') .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)'); .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<void> {
resolvedCwd = resolve(process.cwd()); resolvedCwd = resolve(process.cwd());
const rootOpts = program.opts(); const rootOpts = program.opts();
@ -86,4 +89,7 @@ program.hook('preAction', async () => {
setQuietMode(quietMode); 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 });
}); }
// Common initialization for all commands
program.hook('preAction', runPreActionHook);

View File

@ -16,9 +16,11 @@ import { DEFAULT_PIECE_NAME } from '../../shared/constants.js';
import { program, resolvedCwd, pipelineMode } from './program.js'; import { program, resolvedCwd, pipelineMode } from './program.js';
import { resolveAgentOverrides, parseCreateWorktreeOption, isDirectTask } from './helpers.js'; import { resolveAgentOverrides, parseCreateWorktreeOption, isDirectTask } from './helpers.js';
program /**
.argument('[task]', 'Task to execute (or GitHub issue reference like "#6")') * Execute default action: handle task execution, pipeline mode, or interactive mode.
.action(async (task?: string) => { * Exported for use in slash-command fallback logic.
*/
export async function executeDefaultAction(task?: string): Promise<void> {
const opts = program.opts(); const opts = program.opts();
const agentOverrides = resolveAgentOverrides(program); const agentOverrides = resolveAgentOverrides(program);
const createWorktreeOverride = parseCreateWorktreeOption(opts.createWorktree as string | undefined); const createWorktreeOverride = parseCreateWorktreeOption(opts.createWorktree as string | undefined);
@ -73,8 +75,8 @@ program
} }
if (task && isDirectTask(task)) { if (task && isDirectTask(task)) {
let resolvedTask: string = task; // isDirectTask() returns true only for issue references
if (isIssueReference(task) || task.trim().split(/\s+/).every((t: string) => isIssueReference(t))) { let resolvedTask: string;
try { try {
info('Fetching GitHub Issue...'); info('Fetching GitHub Issue...');
resolvedTask = resolveIssueTask(task); resolvedTask = resolveIssueTask(task);
@ -82,13 +84,12 @@ program
error(getErrorMessage(e)); error(getErrorMessage(e));
process.exit(1); process.exit(1);
} }
}
await selectAndExecuteTask(resolvedCwd, resolvedTask, selectOptions, agentOverrides); await selectAndExecuteTask(resolvedCwd, resolvedTask, selectOptions, agentOverrides);
return; return;
} }
// Short single word or no task → interactive mode (with optional initial input) // Non-issue inputs → interactive mode (with optional initial input)
const pieceId = await determinePiece(resolvedCwd, selectOptions.piece); const pieceId = await determinePiece(resolvedCwd, selectOptions.piece);
if (pieceId === null) { if (pieceId === null) {
info('Cancelled'); info('Cancelled');
@ -106,4 +107,8 @@ program
selectOptions.piece = pieceId; selectOptions.piece = pieceId;
selectOptions.interactiveMetadata = { confirmed: result.confirmed, task: result.task }; selectOptions.interactiveMetadata = { confirmed: result.confirmed, task: result.task };
await selectAndExecuteTask(resolvedCwd, result.task, selectOptions, agentOverrides); await selectAndExecuteTask(resolvedCwd, result.task, selectOptions, agentOverrides);
}); }
program
.argument('[task]', 'Task to execute (or GitHub issue reference like "#6")')
.action(executeDefaultAction);