resolve / や # で始まる入力をコマンド/Issue未検出時にタスク指示として受け入れる #32
This commit is contained in:
parent
0f8449dad9
commit
12ae871f16
105
src/__tests__/cli-slash-hash.test.ts
Normal file
105
src/__tests__/cli-slash-hash.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user