qwen-code/packages/cli/src/ui/commands/hooksCommand.ts
顾盼 a82d766727
refactor(cli): replace slash command whitelist with capability-based filtering (Phase 1) (#3283)
* refactor(cli): replace slash command whitelist with capability-based filtering (Phase 1)

## Summary

Replace the hardcoded ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE whitelist with a
unified, capability-based command metadata model. This is Phase 1 of the slash
command architecture refactor described in docs/design/slash-command/.

## Key changes

### New types (types.ts)
- Add ExecutionMode ('interactive' | 'non_interactive' | 'acp')
- Add CommandSource ('builtin-command' | 'bundled-skill' | 'skill-dir-command' |
  'plugin-command' | 'mcp-prompt')
- Add CommandType ('prompt' | 'local' | 'local-jsx')
- Extend SlashCommand interface with: source, sourceLabel, commandType,
  supportedModes, userInvocable, modelInvocable, argumentHint, whenToUse,
  examples (all optional, backward-compatible)

### New module (commandUtils.ts + commandUtils.test.ts)
- getEffectiveSupportedModes(): 3-priority inference
  (explicit supportedModes > commandType > CommandKind fallback)
- filterCommandsForMode(): replaces filterCommandsForNonInteractive()
- 18 unit tests

### Whitelist removal (nonInteractiveCliCommands.ts)
- Remove ALLOWED_BUILTIN_COMMANDS_NON_INTERACTIVE constant
- Remove filterCommandsForNonInteractive() function
- Replace with CommandService.getCommandsForMode(mode)

### CommandService enhancements (CommandService.ts)
- Add getCommandsForMode(mode: ExecutionMode): filters by mode, excludes hidden
- Add getModelInvocableCommands(): reserved for Phase 3 model tool-call use

### Built-in command annotations (41 files)
Annotate every built-in command with commandType:
- commandType='local' + supportedModes all-modes: btw, bug, compress, context,
  init, summary (replaces the 6-command whitelist)
- commandType='local' interactive-only: export, memory, plan, insight
- commandType='local-jsx' interactive-only: all remaining ~31 commands

### Loader metadata injection (4 files)
Each loader stamps source/sourceLabel/commandType/modelInvocable on every
command it emits:
- BuiltinCommandLoader: source='builtin-command', modelInvocable=false
- BundledSkillLoader: source='bundled-skill', commandType='prompt',
  modelInvocable=true
- command-factory (FileCommandLoader): source per extension/user origin,
  commandType='prompt', modelInvocable=!extensionName
- McpPromptLoader: source='mcp-prompt', commandType='prompt', modelInvocable=true

### Bug fix
MCP_PROMPT commands were incorrectly excluded from non-interactive/ACP modes by
the old whitelist logic. commandType='prompt' now correctly allows them in all
modes.

### Session.ts / nonInteractiveHelpers.ts
- ACP session calls getAvailableCommands with explicit 'acp' mode
- Remove allowedBuiltinCommandNames parameter from buildSystemMessage() —
  capability filtering is now self-contained in CommandService

* fix test ci

* fix memory command

* fix: pass 'non_interactive' mode explicitly to getAvailableCommands

- Fix critical bug in nonInteractiveHelpers.ts: loadSlashCommandNames was
  calling getAvailableCommands without specifying mode, causing it to default
  to 'acp' instead of 'non_interactive'. Commands with supportedModes that
  include 'non_interactive' but not 'acp' would be silently excluded.
- Apply the same fix in systemController.ts for the same reason.
- Update test mock to delegate filtering to production filterCommandsForMode()
  instead of duplicating the logic inline, preventing divergence.

Fixes review comments by wenshao and tanzhenxin on PR #3283.

* fix: resolve TypeScript type error in nonInteractiveHelpers.test.ts

* fix test ci
2026-04-20 14:34:43 +08:00

207 lines
5.4 KiB
TypeScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type {
SlashCommand,
SlashCommandActionReturn,
CommandContext,
MessageActionReturn,
} from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
import type {
HookRegistryEntry,
SessionHookEntry,
} from '@qwen-code/qwen-code-core';
/**
* Format hook source for display
*/
function formatHookSource(source: string): string {
switch (source) {
case 'project':
return t('Project');
case 'user':
return t('User');
case 'system':
return t('System');
case 'extensions':
return t('Extension');
case 'session':
return t('Session (temporary)');
default:
return source;
}
}
const listCommand: SlashCommand = {
name: 'list',
get description() {
return t('List all configured hooks');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (
context: CommandContext,
_args: string,
): Promise<MessageActionReturn> => {
const { config } = context.services;
if (!config) {
return {
type: 'message',
messageType: 'error',
content: t('Config not loaded.'),
};
}
const hookSystem = config.getHookSystem();
if (!hookSystem) {
return {
type: 'message',
messageType: 'info',
content: t(
'Hooks are not enabled. Enable hooks in settings to use this feature.',
),
};
}
const registry = hookSystem.getRegistry();
const configHooks = registry.getAllHooks();
// Get session hooks
const sessionId = config.getSessionId();
const sessionHooksManager = hookSystem.getSessionHooksManager();
const sessionHooks = sessionId
? sessionHooksManager.getAllSessionHooks(sessionId)
: [];
const totalHooks = configHooks.length + sessionHooks.length;
if (totalHooks === 0) {
return {
type: 'message',
messageType: 'info',
content: t(
'No hooks configured. Add hooks in your settings.json file or invoke a skill with hooks.',
),
};
}
// Group hooks by event
const hooksByEvent = new Map<
string,
Array<{ hook: HookRegistryEntry | SessionHookEntry; isSession: boolean }>
>();
// Add config hooks
for (const hook of configHooks) {
const eventName = hook.eventName;
if (!hooksByEvent.has(eventName)) {
hooksByEvent.set(eventName, []);
}
hooksByEvent.get(eventName)!.push({ hook, isSession: false });
}
// Add session hooks
for (const hook of sessionHooks) {
const eventName = hook.eventName;
if (!hooksByEvent.has(eventName)) {
hooksByEvent.set(eventName, []);
}
hooksByEvent.get(eventName)!.push({ hook, isSession: true });
}
let output = `**Configured Hooks (${totalHooks} total)**\n\n`;
for (const [eventName, hooks] of hooksByEvent) {
output += `### ${eventName}\n`;
for (const { hook, isSession } of hooks) {
let name: string;
let source: string;
let matcher: string;
let config: {
type: string;
command?: string;
url?: string;
name?: string;
};
if (isSession) {
// Session hook
const sessionHook = hook as SessionHookEntry;
config = sessionHook.config as {
type: string;
command?: string;
url?: string;
name?: string;
};
name =
config.name ||
(config.type === 'command' ? config.command : undefined) ||
(config.type === 'http' ? config.url : undefined) ||
'unnamed';
source = formatHookSource('session');
matcher = sessionHook.matcher
? ` (matcher: ${sessionHook.matcher})`
: '';
} else {
// Config hook
const configHook = hook as HookRegistryEntry;
config = configHook.config as {
type: string;
command?: string;
url?: string;
name?: string;
};
name =
config.name ||
(config.type === 'command' ? config.command : undefined) ||
(config.type === 'http' ? config.url : undefined) ||
'unnamed';
source = formatHookSource(configHook.source);
matcher = configHook.matcher
? ` (matcher: ${configHook.matcher})`
: '';
}
output += `- **${name}** [${source}]${matcher}\n`;
}
output += '\n';
}
return {
type: 'message',
messageType: 'info',
content: output,
};
},
};
export const hooksCommand: SlashCommand = {
name: 'hooks',
get description() {
return t('Manage Qwen Code hooks');
},
kind: CommandKind.BUILT_IN,
commandType: 'local-jsx',
action: async (
context: CommandContext,
args: string,
): Promise<SlashCommandActionReturn> => {
// In interactive mode, open the hooks dialog
const executionMode = context.executionMode ?? 'interactive';
if (executionMode === 'interactive') {
return {
type: 'dialog',
dialog: 'hooks',
};
}
// In non-interactive mode, list hooks
const result = await listCommand.action?.(context, args);
return result ?? { type: 'message', messageType: 'info', content: '' };
},
};