本章总览
commands.ts(约 755 行)是 Claude Code 斜杠命令的「总注册表」:静态 import 数十个内置命令、memoize 组装 COMMANDS() 数组、异步 loadAllCommands 合并 skills/plugins/workflows,并导出 getCommands、findCommand、getSkillToolCommands 等供 REPL typeahead、SkillTool 与 processSlashCommand 使用。本章要求你能从用户输入 /help 反查到命令池构建与 availability 过滤的完整链路。
学完本章你应该能
- 说明 COMMANDS memoize 与 feature-gated require 的条件导入
- 解释 loadAllCommands 合并顺序与 dynamic skills 插入点
- 描述 meetsAvailabilityRequirement 与 isCommandEnabled 的分工
- 理解 getSkillToolCommands 与 getSlashCommandToolSkills 的过滤差异
- 能在 processSlashCommand 入口定位 findCommand 与 type 分派
核心概念(先读懂这些)
commands.ts 不在 commands/ 目录内
架构上 commands.ts 位于 src/ 根,commands/ 子目录仅放各命令实现。注册表 import ./commands/xxx/index.js,实现方再 import ../../commands.js 取 Command 类型。这种单向依赖避免 commands/ 子树互相引用注册逻辑。
memoize 与 mid-session 失效
loadAllCommands 按 cwd memoize(磁盘 I/O 昂贵),但 meetsAvailabilityRequirement 不 memoize——用户 /login 后 claude-ai 命令须立即可见。clearCommandMemoizationCaches 在动态 skill 加入时清缓存;clearCommandsCache additionally 清 plugin/skill 磁盘缓存。
三种 type 决定 processSlashCommand 分支
local 直接 call(args, context),compact 返回 { type: 'compact' } 触发 buildPostCompactMessages。local-jsx 通过 setToolJSX 渲染 Ink 组件,onDone 回调写 system 消息。prompt 调用 getPromptForCommand 展开 XML 标签块,可 inline 或 fork 子 Agent。
建议学习步骤
- 阅读源码块 A:COMMANDS 数组与 INTERNAL_ONLY
- 阅读源码块 B:loadAllCommands 与 getCommands
- 阅读源码块 C:meetsAvailabilityRequirement
- 阅读源码块 D:Command 类型 union
- 阅读源码块 E:getSkillToolCommands 过滤规则
- 阅读源码块 F:processSlashCommand 入口
- 阅读源码块 G:REMOTE_SAFE 与 isBridgeSafeCommand
常见误区
注意
不要把 utils/bash/commands.ts 与 src/commands.ts 混淆
注意
builtInCommandNames 含 aliases,findCommand 也匹配 aliases
注意
insights 命令用 lazy shim 避免 113KB 模块在启动时加载
在架构中的位置
斜杠命令从输入到执行的典型路径:
用户输入 "/model opus"
→ parseSlashCommand (slashCommandParsing.ts)
→ getCommands(cwd) 获取当前可用池
→ findCommand("model", commands)
→ processSlashCommand 按 cmd.type 分派
→ local-jsx: load() → model.tsx call(onDone, ctx, "opus")
→ onDone 写 system 消息;可选 shouldQuery 继续对话
commands.ts 不负责 UI 或 API 调用,只提供 Command[] 与查找辅助函数。Typeahead、help 屏、SkillTool schema、remote init 过滤均依赖 getCommands 输出。
COMMANDS 数组与条件导入
COMMANDS 声明为 memoize((): Command[] => [...]),延迟到首次 getCommands 才求值——因为底层 isUsing3PServices() 等读 config,不能在模块 init 时执行。
数组包含约 70+ 内置命令(addDir、compact、model、mcp…),末尾 spread 条件块:
feature('KAIROS')→ proactive、brief、assistantfeature('BRIDGE_MODE')→ bridge、remoteControlServerUSER_TYPE === 'ant'→ INTERNAL_ONLY_COMMANDS(backfillSessions、commit、antTrace…)
usageReport(/insights)是内联 lazy shim:getPromptForCommand 内 dynamic import ./commands/insights.js,避免 3200 行 diff 渲染模块拖慢启动。
builtInCommandNames 扁平化 name + aliases 为 Set,供 parseSlashCommand 快速判断是否为内置命令名。
源码引用: src/commands.ts · 第 258–346 行(共 755 行)
258| const COMMANDS = memoize((): Command[] => [
259| addDir,
260| advisor,
261| agents,
262| branch,
263| btw,
264| chrome,
265| clear,
266| color,
267| compact,
268| config,
269| copy,
270| desktop,
271| context,
272| contextNonInteractive,
273| cost,
274| diff,
275| doctor,
276| effort,
277| exit,
278| fast,
279| files,
280| heapDump,
281| help,
282| ide,
283| init,
284| keybindings,
285| installGitHubApp,
286| installSlackApp,
287| mcp,
288| memory,
289| mobile,
290| model,
291| outputStyle,
292| remoteEnv,
293| plugin,
294| pr_comments,
295| releaseNotes,
296| reloadPlugins,
297| rename,
298| resume,
299| session,
300| skills,
301| stats,
302| status,
303| statusline,
304| stickers,
305| tag,
306| theme,
307| feedback,
308| review,
309| ultrareview,
310| rewind,
311| securityReview,
312| terminalSetup,
313| upgrade,
314| extraUsage,
315| extraUsageNonInteractive,
316| rateLimitOptions,
317| usage,
318| usageReport,
319| vim,
320| ...(webCmd ? [webCmd] : []),
321| ...(forkCmd ? [forkCmd] : []),
322| ...(buddy ? [buddy] : []),
323| ...(proactive ? [proactive] : []),
324| ...(briefCommand ? [briefCommand] : []),
325| ...(assistantCommand ? [assistantCommand] : []),
326| ...(bridge ? [bridge] : []),
327| ...(remoteControlServerCommand ? [remoteControlServerCommand] : []),
328| ...(voiceCommand ? [voiceCommand] : []),
329| thinkback,
330| thinkbackPlay,
331| permissions,
332| plan,
333| privacySettings,
334| hooks,
335| exportCommand,
336| sandboxToggle,
337| ...(!isUsing3PServices() ? [logout, login()] : []),
338| passes,
339| ...(peersCmd ? [peersCmd] : []),
340| tasks,
341| ...(workflowsCmd ? [workflowsCmd] : []),
342| ...(torch ? [torch] : []),
343| ...(process.env.USER_TYPE === 'ant' && !process.env.IS_DEMO
344| ? INTERNAL_ONLY_COMMANDS
345| : []),
346| ])
源码引用: src/commands.ts · 第 190–202 行(共 755 行)
190| const usageReport: Command = {
191| type: 'prompt',
192| name: 'insights',
193| description: 'Generate a report analyzing your Claude Code sessions',
194| contentLength: 0,
195| progressMessage: 'analyzing your sessions',
196| source: 'builtin',
197| async getPromptForCommand(args, context) {
198| const real = (await import('./commands/insights.js')).default
199| if (real.type !== 'prompt') throw new Error('unreachable')
200| return real.getPromptForCommand(args, context)
201| },
202| }
源码引用: src/commands.ts · 第 225–254 行(共 755 行)
225| export const INTERNAL_ONLY_COMMANDS = [
226| backfillSessions,
227| breakCache,
228| bughunter,
229| commit,
230| commitPushPr,
231| ctx_viz,
232| goodClaude,
233| issue,
234| initVerifiers,
235| ...(forceSnip ? [forceSnip] : []),
236| mockLimits,
237| bridgeKick,
238| version,
239| ...(ultraplan ? [ultraplan] : []),
240| ...(subscribePr ? [subscribePr] : []),
241| resetLimits,
242| resetLimitsNonInteractive,
243| onboarding,
244| share,
245| summary,
246| teleport,
247| antTrace,
248| perfIssue,
249| env,
250| oauthRefresh,
251| debugToolCall,
252| agentsPlatform,
253| autofixPr,
254| ].filter(Boolean)
loadAllCommands 与 getCommands
loadAllCommands(cwd) 并行 await:
- getSkills — skillDirCommands、pluginSkills、bundledSkills、builtinPluginSkills
- getPluginCommands()
- getWorkflowCommands(cwd)(WORKFLOW_SCRIPTS feature)
返回顺序:bundled → builtinPlugin → skillDir → workflow → pluginCmd → pluginSkill → COMMANDS()。内置命令 intentionally 在最后,便于 dynamic skill 插入到「plugin 之后、builtin 之前」。
getCommands 流程:
- await loadAllCommands
- filter meetsAvailabilityRequirement && isCommandEnabled
- 若有 getDynamicSkills(),dedupe 后 insertIndex = 第一个内置命令位置,插入 uniqueDynamicSkills
这保证文件 touch 新发现的 skill 出现在 typeahead 靠前位置,但不覆盖已有同名命令。
源码引用: src/commands.ts · 第 353–398 行(共 755 行)
353| async function getSkills(cwd: string): Promise<{
354| skillDirCommands: Command[]
355| pluginSkills: Command[]
356| bundledSkills: Command[]
357| builtinPluginSkills: Command[]
358| }> {
359| try {
360| const [skillDirCommands, pluginSkills] = await Promise.all([
361| getSkillDirCommands(cwd).catch(err => {
362| logError(toError(err))
363| logForDebugging(
364| 'Skill directory commands failed to load, continuing without them',
365| )
366| return []
367| }),
368| getPluginSkills().catch(err => {
369| logError(toError(err))
370| logForDebugging('Plugin skills failed to load, continuing without them')
371| return []
372| }),
373| ])
374| // Bundled skills are registered synchronously at startup
375| const bundledSkills = getBundledSkills()
376| // Built-in plugin skills come from enabled built-in plugins
377| const builtinPluginSkills = getBuiltinPluginSkillCommands()
378| logForDebugging(
379| `getSkills returning: ${skillDirCommands.length} skill dir commands, ${pluginSkills.length} plugin skills, ${bundledSkills.length} bundled skills, ${builtinPluginSkills.length} builtin plugin skills`,
380| )
381| return {
382| skillDirCommands,
383| pluginSkills,
384| bundledSkills,
385| builtinPluginSkills,
386| }
387| } catch (err) {
388| // This should never happen since we catch at the Promise level, but defensive
389| logError(toError(err))
390| logForDebugging('Unexpected error in getSkills, returning empty')
391| return {
392| skillDirCommands: [],
393| pluginSkills: [],
394| bundledSkills: [],
395| builtinPluginSkills: [],
396| }
397| }
398| }
源码引用: src/commands.ts · 第 449–517 行(共 755 行)
449| const loadAllCommands = memoize(async (cwd: string): Promise<Command[]> => {
450| const [
451| { skillDirCommands, pluginSkills, bundledSkills, builtinPluginSkills },
452| pluginCommands,
453| workflowCommands,
454| ] = await Promise.all([
455| getSkills(cwd),
456| getPluginCommands(),
457| getWorkflowCommands ? getWorkflowCommands(cwd) : Promise.resolve([]),
458| ])
459|
460| return [
461| ...bundledSkills,
462| ...builtinPluginSkills,
463| ...skillDirCommands,
464| ...workflowCommands,
465| ...pluginCommands,
466| ...pluginSkills,
467| ...COMMANDS(),
468| ]
469| })
470|
471| /**
472| * Returns commands available to the current user. The expensive loading is
473| * memoized, but availability and isEnabled checks run fresh every call so
474| * auth changes (e.g. /login) take effect immediately.
475| */
476| export async function getCommands(cwd: string): Promise<Command[]> {
477| const allCommands = await loadAllCommands(cwd)
478|
479| // Get dynamic skills discovered during file operations
480| const dynamicSkills = getDynamicSkills()
481|
482| // Build base commands without dynamic skills
483| const baseCommands = allCommands.filter(
484| _ => meetsAvailabilityRequirement(_) && isCommandEnabled(_),
485| )
486|
487| if (dynamicSkills.length === 0) {
488| return baseCommands
489| }
490|
491| // Dedupe dynamic skills - only add if not already present
492| const baseCommandNames = new Set(baseCommands.map(c => c.name))
493| const uniqueDynamicSkills = dynamicSkills.filter(
494| s =>
495| !baseCommandNames.has(s.name) &&
496| meetsAvailabilityRequirement(s) &&
497| isCommandEnabled(s),
498| )
499|
500| if (uniqueDynamicSkills.length === 0) {
501| return baseCommands
502| }
503|
504| // Insert dynamic skills after plugin skills but before built-in commands
505| const builtInNames = new Set(COMMANDS().map(c => c.name))
506| const insertIndex = baseCommands.findIndex(c => builtInNames.has(c.name))
507|
508| if (insertIndex === -1) {
509| return [...baseCommands, ...uniqueDynamicSkills]
510| }
511|
512| return [
513| ...baseCommands.slice(0, insertIndex),
514| ...uniqueDynamicSkills,
515| ...baseCommands.slice(insertIndex),
516| ]
517| }
availability 与 auth 过滤
CommandAvailability 仅两种:claude-ai(OAuth 订阅)、console(直连 api.anthropic.com API key,非 3P、非自定义 base URL)。
meetsAvailabilityRequirement 无 availability 字段 → true;否则至少匹配一种 auth 类型。注释强调:这运行在 isEnabled() 之前,provider-gated 命令对 Bedrock 用户完全隐藏,与 feature flag 无关。
典型用法:extra-usage、rate-limit 等仅 1P 用户可见的命令设置 availability: ['claude-ai', 'console']。
login/logout 命令反向 gated:...(!isUsing3PServices() ? [logout, login()] : []) 仅非 3P 环境注册。
源码引用: src/commands.ts · 第 417–443 行(共 755 行)
417| export function meetsAvailabilityRequirement(cmd: Command): boolean {
418| if (!cmd.availability) return true
419| for (const a of cmd.availability) {
420| switch (a) {
421| case 'claude-ai':
422| if (isClaudeAISubscriber()) return true
423| break
424| case 'console':
425| // Console API key user = direct 1P API customer (not 3P, not claude.ai).
426| // Excludes 3P (Bedrock/Vertex/Foundry) who don't set ANTHROPIC_BASE_URL
427| // and gateway users who proxy through a custom base URL.
428| if (
429| !isClaudeAISubscriber() &&
430| !isUsing3PServices() &&
431| isFirstPartyAnthropicBaseUrl()
432| )
433| return true
434| break
435| default: {
436| const _exhaustive: never = a
437| void _exhaustive
438| break
439| }
440| }
441| }
442| return false
443| }
源码引用: src/types/command.ts · 第 154–173 行(共 217 行)
154| /**
155| * Declares which auth/provider environments a command is available in.
156| *
157| * This is separate from `isEnabled()`:
158| * - `availability` = who can use this (auth/provider requirement, static)
159| * - `isEnabled()` = is this turned on right now (GrowthBook, platform, env vars)
160| *
161| * Commands without `availability` are available everywhere.
162| * Commands with `availability` are only shown if the user matches at least one
163| * of the listed auth types. See meetsAvailabilityRequirement() in commands.ts.
164| *
165| * Example: `availability: ['claude-ai', 'console']` shows the command to
166| * claude.ai subscribers and direct Console API key users (api.anthropic.com),
167| * but hides it from Bedrock/Vertex/Foundry users and custom base URL users.
168| */
169| export type CommandAvailability =
170| // claude.ai OAuth subscriber (Pro/Max/Team/Enterprise via claude.ai)
171| | 'claude-ai'
172| // Console API key user (direct api.anthropic.com, not via claude.ai OAuth)
173| | 'console'
Command 类型系统
types/command.ts 定义 discriminated union:
PromptCommand — type: 'prompt',含 source、getPromptForCommand、可选 context:'fork'、agent、effort、hooks、paths(文件 touch 后才可见)。
LocalCommand — type: 'local',supportsNonInteractive,call 返回 LocalCommandResult(text | compact | skip)。
LocalJSXCommand — type: 'local-jsx',call 返回 ReactNode,onDone 控制 display:'system'|'user'|'skip'。
CommandBase 共享字段:name、description、aliases、isEnabled、isHidden、immediate、isSensitive、loadedFrom、disableModelInvocation、userInvocable。
getCommandName 支持 userFacingName() 覆盖(plugin 前缀剥离)。isCommandEnabled 默认 true。
源码引用: src/types/command.ts · 第 16–78 行(共 217 行)
16| export type LocalCommandResult =
17| | { type: 'text'; value: string }
18| | {
19| type: 'compact'
20| compactionResult: CompactionResult
21| displayText?: string
22| }
23| | { type: 'skip' } // Skip messages
24|
25| export type PromptCommand = {
26| type: 'prompt'
27| progressMessage: string
28| contentLength: number // Length of command content in characters (used for token estimation)
29| argNames?: string[]
30| allowedTools?: string[]
31| model?: string
32| source: SettingSource | 'builtin' | 'mcp' | 'plugin' | 'bundled'
33| pluginInfo?: {
34| pluginManifest: PluginManifest
35| repository: string
36| }
37| disableNonInteractive?: boolean
38| // Hooks to register when this skill is invoked
39| hooks?: HooksSettings
40| // Base directory for skill resources (used to set CLAUDE_PLUGIN_ROOT environment variable for skill hooks)
41| skillRoot?: string
42| // Execution context: 'inline' (default) or 'fork' (run as sub-agent)
43| // 'inline' = skill content expands into the current conversation
44| // 'fork' = skill runs in a sub-agent with separate context and token budget
45| context?: 'inline' | 'fork'
46| // Agent type to use when forked (e.g., 'Bash', 'general-purpose')
47| // Only applicable when context is 'fork'
48| agent?: string
49| effort?: EffortValue
50| // Glob patterns for file paths this skill applies to
51| // When set, the skill is only visible after the model touches matching files
52| paths?: string[]
53| getPromptForCommand(
54| args: string,
55| context: ToolUseContext,
56| ): Promise<ContentBlockParam[]>
57| }
58|
59| /**
60| * The call signature for a local command implementation.
61| */
62| export type LocalCommandCall = (
63| args: string,
64| context: LocalJSXCommandContext,
65| ) => Promise<LocalCommandResult>
66|
67| /**
68| * Module shape returned by load() for lazy-loaded local commands.
69| */
70| export type LocalCommandModule = {
71| call: LocalCommandCall
72| }
73|
74| type LocalCommand = {
75| type: 'local'
76| supportsNonInteractive: boolean
77| load: () => Promise<LocalCommandModule>
78| }
源码引用: src/types/command.ts · 第 175–216 行(共 217 行)
175| export type CommandBase = {
176| availability?: CommandAvailability[]
177| description: string
178| hasUserSpecifiedDescription?: boolean
179| /** Defaults to true. Only set when the command has conditional enablement (feature flags, env checks, etc). */
180| isEnabled?: () => boolean
181| /** Defaults to false. Only set when the command should be hidden from typeahead/help. */
182| isHidden?: boolean
183| name: string
184| aliases?: string[]
185| isMcp?: boolean
186| argumentHint?: string // Hint text for command arguments (displayed in gray after command)
187| whenToUse?: string // From the "Skill" spec. Detailed usage scenarios for when to use this command
188| version?: string // Version of the command/skill
189| disableModelInvocation?: boolean // Whether to disable this command from being invoked by models
190| userInvocable?: boolean // Whether users can invoke this skill by typing /skill-name
191| loadedFrom?:
192| | 'commands_DEPRECATED'
193| | 'skills'
194| | 'plugin'
195| | 'managed'
196| | 'bundled'
197| | 'mcp' // Where the command was loaded from
198| kind?: 'workflow' // Distinguishes workflow-backed commands (badged in autocomplete)
199| immediate?: boolean // If true, command executes immediately without waiting for a stop point (bypasses queue)
200| isSensitive?: boolean // If true, args are redacted from the conversation history
201| /** Defaults to `name`. Only override when the displayed name differs (e.g. plugin prefix stripping). */
202| userFacingName?: () => string
203| }
204|
205| export type Command = CommandBase &
206| (PromptCommand | LocalCommand | LocalJSXCommand)
207|
208| /** Resolves the user-visible name, falling back to `cmd.name` when not overridden. */
209| export function getCommandName(cmd: CommandBase): string {
210| return cmd.userFacingName?.() ?? cmd.name
211| }
212|
213| /** Resolves whether the command is enabled, defaulting to true. */
214| export function isCommandEnabled(cmd: CommandBase): boolean {
215| return cmd.isEnabled?.() ?? true
216| }
SkillTool 与 MCP skill 命令
getSkillToolCommands 过滤 prompt 命令供 SkillTool 列表:
- type === 'prompt' && !disableModelInvocation && source !== 'builtin'
- loadedFrom 为 bundled/skills/commands_DEPRECATED 时免 description 要求
- plugin/MCP 须有 hasUserSpecifiedDescription 或 whenToUse
getSlashCommandToolSkills 更严:须 skills/plugin/bundled loadedFrom 或 disableModelInvocation。
getMcpSkillCommands 从 AppState.mcp.commands 筛 MCP 来源的 model-invocable prompt(MCP_SKILLS feature)。这些不在 getCommands 主池,SkillTool 单独 thread。
formatDescriptionWithSource 为 typeahead/help 加 (plugin名) 或 (bundled) 后缀;SkillTool prompt 用原始 description。
源码引用: src/commands.ts · 第 523–608 行(共 755 行)
523| export function clearCommandMemoizationCaches(): void {
524| loadAllCommands.cache?.clear?.()
525| getSkillToolCommands.cache?.clear?.()
526| getSlashCommandToolSkills.cache?.clear?.()
527| // getSkillIndex in skillSearch/localSearch.ts is a separate memoization layer
528| // built ON TOP of getSkillToolCommands/getCommands. Clearing only the inner
529| // caches is a no-op for the outer — lodash memoize returns the cached result
530| // without ever reaching the cleared inners. Must clear it explicitly.
531| clearSkillIndexCache?.()
532| }
533|
534| export function clearCommandsCache(): void {
535| clearCommandMemoizationCaches()
536| clearPluginCommandCache()
537| clearPluginSkillsCache()
538| clearSkillCaches()
539| }
540|
541| /**
542| * Filter AppState.mcp.commands to MCP-provided skills (prompt-type,
543| * model-invocable, loaded from MCP). These live outside getCommands() so
544| * callers that need MCP skills in their skill index thread them through
545| * separately.
546| */
547| export function getMcpSkillCommands(
548| mcpCommands: readonly Command[],
549| ): readonly Command[] {
550| if (feature('MCP_SKILLS')) {
551| return mcpCommands.filter(
552| cmd =>
553| cmd.type === 'prompt' &&
554| cmd.loadedFrom === 'mcp' &&
555| !cmd.disableModelInvocation,
556| )
557| }
558| return []
559| }
560|
561| // SkillTool shows ALL prompt-based commands that the model can invoke
562| // This includes both skills (from /skills/) and commands (from /commands/)
563| export const getSkillToolCommands = memoize(
564| async (cwd: string): Promise<Command[]> => {
565| const allCommands = await getCommands(cwd)
566| return allCommands.filter(
567| cmd =>
568| cmd.type === 'prompt' &&
569| !cmd.disableModelInvocation &&
570| cmd.source !== 'builtin' &&
571| // Always include skills from /skills/ dirs, bundled skills, and legacy /commands/ entries
572| // (they all get an auto-derived description from the first line if frontmatter is missing).
573| // Plugin/MCP commands still require an explicit description to appear in the listing.
574| (cmd.loadedFrom === 'bundled' ||
575| cmd.loadedFrom === 'skills' ||
576| cmd.loadedFrom === 'commands_DEPRECATED' ||
577| cmd.hasUserSpecifiedDescription ||
578| cmd.whenToUse),
579| )
580| },
581| )
582|
583| // Filters commands to include only skills. Skills are commands that provide
584| // specialized capabilities for the model to use. They are identified by
585| // loadedFrom being 'skills', 'plugin', or 'bundled', or having disableModelInvocation set.
586| export const getSlashCommandToolSkills = memoize(
587| async (cwd: string): Promise<Command[]> => {
588| try {
589| const allCommands = await getCommands(cwd)
590| return allCommands.filter(
591| cmd =>
592| cmd.type === 'prompt' &&
593| cmd.source !== 'builtin' &&
594| (cmd.hasUserSpecifiedDescription || cmd.whenToUse) &&
595| (cmd.loadedFrom === 'skills' ||
596| cmd.loadedFrom === 'plugin' ||
597| cmd.loadedFrom === 'bundled' ||
598| cmd.disableModelInvocation),
599| )
600| } catch (error) {
601| logError(toError(error))
602| // Return empty array rather than throwing - skills are non-critical
603| // This prevents skill loading failures from breaking the entire system
604| logForDebugging('Returning empty skills array due to load failure')
605| return []
606| }
607| },
608| )
源码引用: src/commands.ts · 第 688–754 行(共 755 行)
688| export function findCommand(
689| commandName: string,
690| commands: Command[],
691| ): Command | undefined {
692| return commands.find(
693| _ =>
694| _.name === commandName ||
695| getCommandName(_) === commandName ||
696| _.aliases?.includes(commandName),
697| )
698| }
699|
700| export function hasCommand(commandName: string, commands: Command[]): boolean {
701| return findCommand(commandName, commands) !== undefined
702| }
703|
704| export function getCommand(commandName: string, commands: Command[]): Command {
705| const command = findCommand(commandName, commands)
706| if (!command) {
707| throw ReferenceError(
708| `Command ${commandName} not found. Available commands: ${commands
709| .map(_ => {
710| const name = getCommandName(_)
711| return _.aliases ? `${name} (aliases: ${_.aliases.join(', ')})` : name
712| })
713| .sort((a, b) => a.localeCompare(b))
714| .join(', ')}`,
715| )
716| }
717|
718| return command
719| }
720|
721| /**
722| * Formats a command's description with its source annotation for user-facing UI.
723| * Use this in typeahead, help screens, and other places where users need to see
724| * where a command comes from.
725| *
726| * For model-facing prompts (like SkillTool), use cmd.description directly.
727| */
728| export function formatDescriptionWithSource(cmd: Command): string {
729| if (cmd.type !== 'prompt') {
730| return cmd.description
731| }
732|
733| if (cmd.kind === 'workflow') {
734| return `${cmd.description} (workflow)`
735| }
736|
737| if (cmd.source === 'plugin') {
738| const pluginName = cmd.pluginInfo?.pluginManifest.name
739| if (pluginName) {
740| return `(${pluginName}) ${cmd.description}`
741| }
742| return `${cmd.description} (plugin)`
743| }
744|
745| if (cmd.source === 'builtin' || cmd.source === 'mcp') {
746| return cmd.description
747| }
748|
749| if (cmd.source === 'bundled') {
750| return `${cmd.description} (bundled)`
751| }
752|
753| return `${cmd.description} (${getSettingSourceName(cmd.source)})`
754| }
processSlashCommand 分派入口
utils/processUserInput/processSlashCommand.tsx 是运行时执行中心(900+ 行):
- parseSlashCommand 解析命令名与 args
- findCommand / getCommand 定位 Command
- 按 type 分支:
- prompt:inline 展开或 executeForkedSlashCommand(子 Agent)
- local:await load().call(args, context);compact 结果走 buildPostCompactMessages
- local-jsx:await load().call(onDone, context, args);setToolJSX 渲染
Fork 路径记录 tengu_slash_command_forked 事件;KAIROS assistant 模式下 context:fork 可 fire-and-forget 后台 subagent。
敏感命令 isSensitive 时 args 从 transcript redact。MalformedCommandError 在用户输入非法格式时抛出。
源码引用: src/utils/processUserInput/processSlashCommand.tsx · 第 1–51 行(共 1263 行)
1| import { feature } from 'bun:bundle'
2| import type {
3| ContentBlockParam,
4| TextBlockParam,
5| } from '@anthropic-ai/sdk/resources'
6| import { randomUUID } from 'crypto'
7| import { setPromptId } from 'src/bootstrap/state.js'
8| import {
9| builtInCommandNames,
10| type Command,
11| type CommandBase,
12| findCommand,
13| getCommand,
14| getCommandName,
15| hasCommand,
16| type PromptCommand,
17| } from 'src/commands.js'
18| import { NO_CONTENT_MESSAGE } from 'src/constants/messages.js'
19| import type { SetToolJSXFn, ToolUseContext } from 'src/Tool.js'
20| import type {
21| AssistantMessage,
22| AttachmentMessage,
23| Message,
24| NormalizedUserMessage,
25| ProgressMessage,
26| UserMessage,
27| } from 'src/types/message.js'
28| import { addInvokedSkill, getSessionId } from '../../bootstrap/state.js'
29| import { COMMAND_MESSAGE_TAG, COMMAND_NAME_TAG } from '../../constants/xml.js'
30| import type { CanUseToolFn } from '../../hooks/useCanUseTool.js'
31| import {
32| type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
33| type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED,
34| logEvent,
35| } from '../../services/analytics/index.js'
36| import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js'
37| import { buildPostCompactMessages } from '../../services/compact/compact.js'
38| import { resetMicrocompactState } from '../../services/compact/microCompact.js'
39| import type { Progress as AgentProgress } from '../../tools/AgentTool/AgentTool.js'
40| import { runAgent } from '../../tools/AgentTool/runAgent.js'
41| import { renderToolUseProgressMessage } from '../../tools/AgentTool/UI.js'
42| import type { CommandResultDisplay } from '../../types/command.js'
43| import { createAbortController } from '../abortController.js'
44| import { getAgentContext } from '../agentContext.js'
45| import {
46| createAttachmentMessage,
47| getAttachmentMessages,
48| } from '../attachments.js'
49| import { logForDebugging } from '../debug.js'
50| import { isEnvTruthy } from '../envUtils.js'
51| import { AbortError, MalformedCommandError } from '../errors.js'
源码引用: src/utils/processUserInput/processSlashCommand.tsx · 第 309–340 行(共 1263 行)
309| model: command.model as ModelAlias | undefined,
310| availableTools: context.options.tools,
311| })) {
312| agentMessages.push(message)
313| const normalizedNew = normalizeMessages([message])
314|
315| // Add progress message for assistant messages (which contain tool uses)
316| if (message.type === 'assistant') {
317| // Increment token count in spinner for assistant messages
318| const contentLength = getAssistantMessageContentLength(message)
319| if (contentLength > 0) {
320| context.setResponseLength(len => len + contentLength)
321| }
322|
323| const normalizedMsg = normalizedNew[0]
324| if (normalizedMsg && normalizedMsg.type === 'assistant') {
325| progressMessages.push(createProgressMessage(message))
326| updateProgress()
327| }
328| }
329|
330| // Add progress message for user messages (which contain tool results)
331| if (message.type === 'user') {
332| const normalizedMsg = normalizedNew[0]
333| if (normalizedMsg && normalizedMsg.type === 'user') {
334| progressMessages.push(createProgressMessage(normalizedMsg))
335| updateProgress()
336| }
337| }
338| }
339| } finally {
340| // Clear the progress display
Remote 与 Bridge 安全子集
REMOTE_SAFE_COMMANDS 定义 --remote 模式下 REPL 预过滤白名单:session、exit、clear、help、theme、cost、usage、plan 等——仅影响本地 TUI,不依赖本地 git/shell/MCP。
BRIDGE_SAFE_COMMANDS 允许 mobile/web Remote Control 执行的 local 命令:compact、clear、cost、summary、releaseNotes、files。PR #19134 曾 blanket 阻断 bridge 斜杠;isBridgeSafeCommand 放宽:
- local-jsx → false(Ink UI 不能远程弹)
- prompt → true(展开为文本)
- local → 须在 BRIDGE_SAFE_COMMANDS 内
/model 从 iOS 弹出本地 picker 是 bridge 阻断 local-jsx 的直接动机。compact 列入 bridge-safe 因用户常从手机 mid-session 压缩上下文。
源码引用: src/commands.ts · 第 619–686 行(共 755 行)
619| export const REMOTE_SAFE_COMMANDS: Set<Command> = new Set([
620| session, // Shows QR code / URL for remote session
621| exit, // Exit the TUI
622| clear, // Clear screen
623| help, // Show help
624| theme, // Change terminal theme
625| color, // Change agent color
626| vim, // Toggle vim mode
627| cost, // Show session cost (local cost tracking)
628| usage, // Show usage info
629| copy, // Copy last message
630| btw, // Quick note
631| feedback, // Send feedback
632| plan, // Plan mode toggle
633| keybindings, // Keybinding management
634| statusline, // Status line toggle
635| stickers, // Stickers
636| mobile, // Mobile QR code
637| ])
638|
639| /**
640| * Builtin commands of type 'local' that ARE safe to execute when received
641| * over the Remote Control bridge. These produce text output that streams
642| * back to the mobile/web client and have no terminal-only side effects.
643| *
644| * 'local-jsx' commands are blocked by type (they render Ink UI) and
645| * 'prompt' commands are allowed by type (they expand to text sent to the
646| * model) — this set only gates 'local' commands.
647| *
648| * When adding a new 'local' command that should work from mobile, add it
649| * here. Default is blocked.
650| */
651| export const BRIDGE_SAFE_COMMANDS: Set<Command> = new Set(
652| [
653| compact, // Shrink context — useful mid-session from a phone
654| clear, // Wipe transcript
655| cost, // Show session cost
656| summary, // Summarize conversation
657| releaseNotes, // Show changelog
658| files, // List tracked files
659| ].filter((c): c is Command => c !== null),
660| )
661|
662| /**
663| * Whether a slash command is safe to execute when its input arrived over the
664| * Remote Control bridge (mobile/web client).
665| *
666| * PR #19134 blanket-blocked all slash commands from bridge inbound because
667| * `/model` from iOS was popping the local Ink picker. This predicate relaxes
668| * that with an explicit allowlist: 'prompt' commands (skills) expand to text
669| * and are safe by construction; 'local' commands need an explicit opt-in via
670| * BRIDGE_SAFE_COMMANDS; 'local-jsx' commands render Ink UI and stay blocked.
671| */
672| export function isBridgeSafeCommand(cmd: Command): boolean {
673| if (cmd.type === 'local-jsx') return false
674| if (cmd.type === 'prompt') return true
675| return BRIDGE_SAFE_COMMANDS.has(cmd)
676| }
677|
678| /**
679| * Filter commands to only include those safe for remote mode.
680| * Used to pre-filter commands when rendering the REPL in --remote mode,
681| * preventing local-only commands from being briefly available before
682| * the CCR init message arrives.
683| */
684| export function filterCommandsForRemoteMode(commands: Command[]): Command[] {
685| return commands.filter(cmd => REMOTE_SAFE_COMMANDS.has(cmd))
686| }
本章小结与延伸
commands.ts = 命令池的单一真相源。下一章 model-command,读 /model 如何写 AppState.mainLoopModel。 继续学习: