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

View File

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

View File

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

View File

@ -54,8 +54,11 @@ program
.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 () => {
/**
* 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());
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);

View File

@ -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<void> {
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);