核心命令详解(下)

📑 目录

在上一篇文章中,我们剖析了 Claude Code 命令系统的底层架构——从 commands.ts 的注册表组装、命令类型分类(prompt / local-jsx / local),到命令解析与分发流程。本文将继续深入,聚焦七个具有代表性的进阶命令/plan/review/resume/diff/export/doctor/upgrade。这些命令覆盖了工作流控制代码审查会话管理变更追踪数据导出环境诊断版本升级七大场景,是日常高频使用的核心功能。

1. plan 命令:Plan Mode 的入口与编排

1.1 命令注册与类型定义

/plan 命令在 src/commands/plan/index.ts 中注册(第 1–8 行):

// src/commands/plan/index.ts, 第 1–8 行
import type { Command } from '../../commands.js'

const plan = {
  type: 'local-jsx',
  name: 'plan',
  description: 'Enable plan mode or view the current session plan',
  argumentHint: '[open|<description>]',
  load: () => import('./plan.js'),
} satisfies Command

export default plan

这里的关键是 type: 'local-jsx'——/plan 不是简单的文本 prompt 命令,而是一个带有交互式 React 组件的本地命令。它的参数提示 [open|<description>] 表明两种用法:不带参数时进入 Plan Mode;带 open 参数时用外部编辑器打开当前计划文件。

1.2 Plan Mode 的状态切换核心

/plan 命令的核心实现在 src/commands/plan/plan.tsx 中(第 1–120 行,基于 source map 还原)。该组件的核心逻辑分为两条分支:

分支一:当前不在 plan 模式 → 启用 plan 模式

// src/commands/plan/plan.tsx, 第 35–55 行(基于 source map 还原)
const appState = getAppState();
const currentMode = appState.toolPermissionContext.mode;

if (currentMode !== 'plan') {
  handlePlanModeTransition(currentMode, 'plan');
  setAppState(prev => ({
    ...prev,
    toolPermissionContext: applyPermissionUpdate(
      prepareContextForPlanMode(prev.toolPermissionContext),
      { type: 'setMode', mode: 'plan', destination: 'session' }
    )
  }));
  // ...
}

这里涉及三个关键函数:

  • handlePlanModeTransition(oldMode, 'plan'):来自 src/bootstrap/state.js,负责在状态层面记录模式切换的副作用(如清理 Auto Mode 的状态标记)。
  • prepareContextForPlanMode(context):来自 src/utils/permissions/permissionSetup.ts(第 1–50 行),执行 Plan Mode 的权限上下文预处理——当用户默认模式为 auto 时,它会触发分类器(classifier)的激活副作用。
  • applyPermissionUpdate(..., { type: 'setMode', mode: 'plan', destination: 'session' }):将权限模式强制设为 plan,并限定作用域为当前 session

分支二:已在 plan 模式 → 显示或编辑计划

// src/commands/plan/plan.tsx, 第 57–85 行
const planContent = getPlan();
const planPath = getPlanFilePath();
if (!planContent) {
  onDone('Already in plan mode. No plan written yet.');
  return null;
}

const argList = args.trim().split(/\s+/);
if (argList[0] === 'open') {
  const result = await editFileInEditor(planPath);
  // ...
}

当用户输入 /plan open 时,命令会调用 editFileInEditor 打开计划文件。计划文件的路径由 getPlanFilePath() 决定,该函数在 src/utils/plans.ts 中实现(第 50–90 行),它基于会话 ID 生成一个唯一的 word slug(如 brave-fox-1234.md),并将计划文件存储在 ~/.claude/plans/ 目录下。

1.3 与 EnterPlanModeTool / ExitPlanModeTool 的关系

Plan Mode 有两条进入路径:用户主动输入 /plan,或 Claude 主动调用 EnterPlanModeTool。后者的实现在 src/tools/EnterPlanModeTool/EnterPlanModeTool.ts(第 1–90 行):

// src/tools/EnterPlanModeTool/EnterPlanModeTool.ts, 第 55–85 行
async call(_input, context) {
  if (context.agentId) {
    throw new Error('EnterPlanMode tool cannot be used in agent contexts')
  }

  const appState = context.getAppState()
  handlePlanModeTransition(appState.toolPermissionContext.mode, 'plan')

  context.setAppState(prev => ({
    ...prev,
    toolPermissionContext: applyPermissionUpdate(
      prepareContextForPlanMode(prev.toolPermissionContext),
      { type: 'setMode', mode: 'plan', destination: 'session' }
    )
  }))
  // ...
}

对比可见,EnterPlanModeTool.call()/plan 命令的核心状态更新逻辑完全一致——都调用相同的 handlePlanModeTransitionprepareContextForPlanMode。差异仅在于触发源:一个是用户通过斜杠命令触发,另一个是模型在 tool use 中自行决定进入 Plan Mode。这种设计确保了无论哪条路径进入 Plan Mode,系统状态都是一致的。

退出 Plan Mode 则通过 ExitPlanModeTool 完成,其常量定义在 src/tools/ExitPlanModeTool/constants.ts(第 1–2 行):

export const EXIT_PLAN_MODE_TOOL_NAME = 'ExitPlanMode'
export const EXIT_PLAN_MODE_V2_TOOL_NAME = 'ExitPlanMode'

值得注意的是,当 --channels 参数激活时(KAIROS 功能),EnterPlanModeToolisEnabled() 会返回 false,防止模型进入一个无法通过终端对话框退出的陷阱状态。

flowchart TD
    A[用户输入 /plan] --> B{当前模式}
    B -->|非 plan| C[handlePlanModeTransition]
    C --> D[prepareContextForPlanMode]
    D --> E[applyPermissionUpdate setMode=plan]
    E --> F[进入 Plan Mode]
    B -->|已是 plan| G{参数}
    G -->|无参| H[getPlan 显示内容]
    G -->|open| I[editFileInEditor 打开文件]
    J[模型调用 EnterPlanModeTool] --> C
    K[模型调用 ExitPlanModeTool] --> L[退出 Plan Mode]

2. review 命令:本地代码审查与 Ultrareview

2.1 命令注册与双轨架构

/review 命令定义在 src/commands/review.ts(第 1–57 行),它实际上是一个双轨架构:本地 /review 和远程 /ultrareview

// src/commands/review.ts, 第 1–57 行
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.js'
import type { Command } from '../commands.js'
import { isUltrareviewEnabled } from './review/ultrareviewEnabled.js'

const CCR_TERMS_URL = 'https://code.claude.com/docs/en/claude-code-on-the-web'

const LOCAL_REVIEW_PROMPT = (args: string) => `
      You are an expert code reviewer. Follow these steps:
      1. If no PR number is provided in the args, run \`gh pr list\` to show open PRs
      2. If a PR number is provided, run \`gh pr view <number>\` to get PR details
      3. Run \`gh pr diff <number>\` to get the diff
      4. Analyze the changes and provide a thorough code review...
    `

const review: Command = {
  type: 'prompt',
  name: 'review',
  description: 'Review a pull request',
  progressMessage: 'reviewing pull request',
  contentLength: 0,
  source: 'builtin',
  async getPromptForCommand(args): Promise<ContentBlockParam[]> {
    return [{ type: 'text', text: LOCAL_REVIEW_PROMPT(args) }]
  },
}

/review 的类型是 prompt,这意味着它不像 /plan 那样渲染自定义 React 组件,而是构造一段文本 prompt 发送给模型。这段 prompt 明确指示模型:

  1. 若无 PR 号,先执行 gh pr list 列出开放的 PR
  2. 获取 PR 详情:gh pr view <number>
  3. 获取 diff:gh pr diff <number>
  4. 从代码正确性、项目规范、性能影响、测试覆盖、安全考量五个维度输出审查报告

2.2 Ultrareview:远程 Bug Hunter 路径

// src/commands/review.ts, 第 46–57 行
const ultrareview: Command = {
  type: 'local-jsx',
  name: 'ultrareview',
  description: `~10–20 min · Finds and verifies bugs in your branch. Runs in Claude Code on the web. See ${CCR_TERMS_URL}`,
  isEnabled: () => isUltrareviewEnabled(),
  load: () => import('./review/ultrareviewCommand.js'),
}

/ultrareview唯一进入远程 bughunter 路径的入口。它的启用由 isUltrareviewEnabled() 控制,该函数在 src/commands/review/ultrareviewEnabled.ts(第 1–14 行)中实现:

// src/commands/review/ultrareviewEnabled.ts, 第 1–14 行
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'

export function isUltrareviewEnabled(): boolean {
  const cfg = getFeatureValue_CACHED_MAY_BE_STALE<Record<
    string,
    unknown
  > | null>('tengu_review_bughunter_config', null)
  return cfg?.enabled === true
}

这里使用了 GrowthBook 的功能开关(feature flag)进行运行时门控。当 tengu_review_bughunter_config.enabledfalse 时,isEnabled() 返回 false,命令甚至不会出现在 getCommands() 的结果中,对用户完全不可见。这种设计在产品灰度发布(staged rollout)中非常常见。

/ultrareviewtype: 'local-jsx' 意味着它有一个自定义的 React 组件界面。当用户的免费审查次数耗尽时,local-jsx 类型会渲染一个overage permission dialog(超额权限对话框),提示用户升级或购买额度。

2.3 审查流程的完整链路

一条典型的 /review 42 执行链路如下:

  1. 用户在输入框中输入 /review 42
  2. CommandParserargs 解析为 "42"
  3. review.getPromptForCommand("42") 返回包含 LOCAL_REVIEW_PROMPT("42")ContentBlockParam[]
  4. QueryEngine 将该 prompt 附加到消息列表中,发送给 Claude
  5. Claude 按 prompt 指示,依次调用 BashTool 执行 gh pr view 42gh pr diff 42
  6. Claude 综合 PR 描述、diff 内容和项目上下文,生成结构化审查报告

整个流程中,Claude Code 本身不直接解析 PR 数据——它依赖模型通过 gh CLI 工具获取数据并自行分析。这种"prompt-driven"架构极大简化了命令实现,但也对模型的指令遵循能力提出了较高要求。

3. resume 命令:会话恢复与跨项目导航

3.1 命令注册与别名

// src/commands/resume/index.ts, 第 1–10 行
import type { Command } from '../../commands.js'

const resume: Command = {
  type: 'local-jsx',
  name: 'resume',
  description: 'Resume a previous conversation',
  aliases: ['continue'],
  argumentHint: '[conversation id or search term]',
  load: () => import('./resume.js'),
}

export default resume

/resume 有一个别名 /continue,这在用户肌肉记忆兼容性上做了考虑。它的参数可以是会话 ID(UUID)搜索关键词,也可以留空以打开选择器。

3.2 恢复流程的三层搜索策略

/resume 的核心逻辑在 src/commands/resume/resume.tsx 中(第 1–220 行,基于 source map 还原)。当用户提供参数时,命令执行三层递进式搜索

第一层:UUID 精确匹配

// src/commands/resume/resume.tsx, 第 155–175 行
const maybeSessionId = validateUuid(arg);
if (maybeSessionId) {
  const matchingLogs = logs.filter(l => getSessionIdFromLog(l) === maybeSessionId)
    .sort((a, b) => b.modified.getTime() - a.modified.getTime());
  if (matchingLogs.length > 0) {
    const log = matchingLogs[0]!;
    const fullLog = isLiteLog(log) ? await loadFullLog(log) : log;
    void onResume(maybeSessionId, fullLog, 'slash_command_session_id');
    return null;
  }
  // 若 enriched logs 未找到,尝试直接文件查找
  const directLog = await getLastSessionLog(maybeSessionId);
  if (directLog) {
    void onResume(maybeSessionId, directLog, 'slash_command_session_id');
    return null;
  }
}

这里有一个关键细节:isLiteLog(log) 的检查。Claude Code 的会话日志有两种存储格式:

  • Lite Log:只包含元数据(标题、时间戳、消息摘要),用于快速列表渲染
  • Full Log:包含完整的消息内容,用于恢复会话

当通过 UUID 找到的是 lite log 时,必须调用 loadFullLog() 加载完整内容后才能恢复。

第二层:自定义标题精确匹配(需功能开启)

// src/commands/resume/resume.tsx, 第 177–195 行
if (isCustomTitleEnabled()) {
  const titleMatches = await searchSessionsByCustomTitle(arg, { exact: true });
  if (titleMatches.length === 1) {
    const log = titleMatches[0]!;
    const sessionId = getSessionIdFromLog(log);
    if (sessionId) {
      const fullLog = isLiteLog(log) ? await loadFullLog(log) : log;
      void onResume(sessionId, fullLog, 'slash_command_title');
      return null;
    }
  }
  if (titleMatches.length > 1) {
    // 多个匹配 → 提示用户用 /resume 选择特定会话
    const message = resumeHelpMessage({ resultType: 'multipleMatches', arg, count: titleMatches.length });
    return <ResumeError message={message} args={arg} onDone={() => onDone(message)} />;
  }
}

第三层:无匹配 → 错误提示

// src/commands/resume/resume.tsx, 第 197–202 行
const message = resumeHelpMessage({ resultType: 'sessionNotFound', arg });
return <ResumeError message={message} args={arg} onDone={() => onDone(message)} />;

3.3 跨项目恢复与工作树检测

当用户未提供参数时,ResumeCommand 组件会渲染一个交互式选择器(LogSelector),支持在同仓库的不同工作树(worktree)甚至跨项目之间恢复会话。

跨项目恢复的逻辑在 src/utils/crossProjectResume.ts(第 1–55 行):

// src/utils/crossProjectResume.ts, 第 18–55 行
export function checkCrossProjectResume(
  log: LogOption,
  showAllProjects: boolean,
  worktreePaths: string[],
): CrossProjectResumeResult {
  const currentCwd = getOriginalCwd()
  if (!showAllProjects || !log.projectPath || log.projectPath === currentCwd) {
    return { isCrossProject: false }
  }

  // 检查是否为同一 repo 的不同 worktree
  const isSameRepo = worktreePaths.some(
    wt => log.projectPath === wt || log.projectPath!.startsWith(wt + sep),
  )

  if (isSameRepo) {
    return { isCrossProject: true, isSameRepoWorktree: true, projectPath: log.projectPath }
  }

  // 不同 repo → 生成 cd 命令并复制到剪贴板
  const sessionId = getSessionIdFromLog(log)
  const command = `cd ${quote([log.projectPath])} && claude --resume ${sessionId}`
  return { isCrossProject: true, isSameRepoWorktree: false, command, projectPath: log.projectPath }
}

这里的工程考量非常细致:

  • 同一 repo 的 worktree:可以直接恢复,无需切换目录
  • 不同项目:生成 cd <path> && claude --resume <id> 命令,自动复制到剪贴板,用户粘贴即可执行

3.4 无参数模式:交互式选择器

// src/commands/resume/resume.tsx, 第 205–220 行
export const call: LocalJSXCommandCall = async (onDone, context, args) => {
  const arg = args?.trim();
  if (!arg) {
    return <ResumeCommand key={Date.now()} onDone={onDone} onResume={onResume} />;
  }
  // ... 参数匹配逻辑
}

ResumeCommand 组件内部使用 useState 管理 logsloadingresuming 等状态,并通过 useTerminalSize 动态计算选择器高度。当 showAllProjects 切换时,它会重新调用 loadAllProjectsMessageLogs()loadSameRepoMessageLogs() 加载对应的会话列表。

4. diff 命令:Git Diff 与回合级变更追踪

4.1 极简的命令入口

// src/commands/diff/index.ts, 第 1–7 行
import type { Command } from '../../commands.js'

export default {
  type: 'local-jsx',
  name: 'diff',
  description: 'View uncommitted changes and per-turn diffs',
  load: () => import('./diff.js'),
} satisfies Command

/diff 的命令入口极其简洁,所有复杂度都下沉到了 DiffDialog 组件中。

4.2 DiffDialog:双层数据来源

src/commands/diff/diff.tsx(第 1–10 行)只做一件事:动态加载并渲染 DiffDialog

// src/commands/diff/diff.tsx, 第 1–10 行
import * as React from 'react';
import type { LocalJSXCommandCall } from '../../types/command.js';
export const call: LocalJSXCommandCall = async (onDone, context) => {
  const { DiffDialog } = await import('../../components/diff/DiffDialog.js');
  return <DiffDialog messages={context.messages} onDone={onDone} />;
};

DiffDialogsrc/components/diff/DiffDialog.tsx(第 1–120 行)中实现,它管理两种 diff 数据来源:

  1. Current Diff(当前未提交的 Git 变更):通过 useDiffData() hook 获取
  2. Turn Diff(每个 AI 回合产生的文件变更):通过 useTurnDiffs(messages) hook,从消息历史中解析出每个回合的 StructuredPatchHunk
// src/components/diff/DiffDialog.tsx, 第 35–55 行
function turnDiffToDiffData(turn: TurnDiff): DiffData {
  const files = Array.from(turn.files.values()).map(f => ({
    path: f.filePath,
    linesAdded: f.linesAdded,
    linesRemoved: f.linesRemoved,
    isBinary: false,
    isLargeFile: false,
    isTruncated: false,
    isNewFile: f.isNewFile
  })).sort((a, b) => a.path.localeCompare(b.path));
  const hunks = new Map<string, StructuredPatchHunk[]>();
  for (const f of turn.files.values()) {
    hunks.set(f.filePath, f.hunks);
  }
  return { stats: turn.stats, files, hunks, loading: false };
}

4.3 视图模式与交互

DiffDialog 内部维护 ViewMode'list' | 'detail')和 sourceIndex(数据来源索引)。用户可以在:

  • 列表视图:查看所有变更文件的概览(新增/删除行数)
  • 详情视图:查看选中文件的完整 hunk 级 diff

sourceIndex 的索引 0 总是对应 current(Git 工作区未提交变更),索引 1+ 对应历史回合的变更。这种设计让用户能够追溯任意 AI 回合所产生的精确代码变更,对于审查 AI 的编辑行为至关重要。

5. export 命令:会话导出与文件生成

5.1 双模式导出:直接文件 vs 交互式对话框

/export 命令定义在 src/commands/export/index.ts(第 1–10 行):

// src/commands/export/index.ts, 第 1–10 行
import type { Command } from '../../commands.js'

const exportCommand = {
  type: 'local-jsx',
  name: 'export',
  description: 'Export the current conversation to a file or clipboard',
  argumentHint: '[filename]',
  load: () => import('./export.js'),
} satisfies Command

export default exportCommand

核心实现在 src/commands/export/export.tsx(第 1–110 行)。它支持两种调用方式:

方式一:直接指定文件名

// src/commands/export/export.tsx, 第 65–85 行
const filename = args.trim();
if (filename) {
  const finalFilename = filename.endsWith('.txt') ? filename
    : filename.replace(/\.[^.]+$/, '') + '.txt';
  const filepath = join(getCwd(), finalFilename);
  try {
    writeFileSync_DEPRECATED(filepath, content, { encoding: 'utf-8', flush: true });
    onDone(`Conversation exported to: ${filepath}`);
    return null;
  } catch (error) {
    onDone(`Failed to export conversation: ${error instanceof Error ? error.message : 'Unknown error'}`);
    return null;
  }
}

这里有个细节:writeFileSync_DEPRECATED 被标记为 deprecated,说明团队可能正在迁移到异步文件写入,但导出场景下同步写入的简单性仍有其价值。

方式二:无参数 → 渲染 ExportDialog

// src/commands/export/export.tsx, 第 95–105 行
const firstPrompt = extractFirstPrompt(context.messages);
const timestamp = formatTimestamp(new Date());
let defaultFilename: string;
if (firstPrompt) {
  const sanitized = sanitizeFilename(firstPrompt);
  defaultFilename = sanitized ? `${timestamp}-${sanitized}.txt` : `conversation-${timestamp}.txt`;
} else {
  defaultFilename = `conversation-${timestamp}.txt`;
}

return <ExportDialog content={content} defaultFilename={defaultFilename} onDone={...} />;

5.2 默认文件名生成策略

默认文件名采用 YYYY-MM-DD-HHMMSS-<sanitized-prompt>.txt 格式,其中:

  • extractFirstPrompt() 从消息列表中提取第一条用户消息的内容,取第一行并截断至 50 字符
  • sanitizeFilename() 将特殊字符替换为连字符,确保文件名合法
// src/commands/export/export.tsx, 第 25–55 行
export function extractFirstPrompt(messages: Message[]): string {
  const firstUserMessage = messages.find(msg => msg.type === 'user');
  if (!firstUserMessage || firstUserMessage.type !== 'user') {
    return '';
  }
  const content = firstUserMessage.message?.content;
  let result = '';
  if (typeof content === 'string') {
    result = content.trim();
  } else if (Array.isArray(content)) {
    const textContent = content.find(item => item.type === 'text');
    if (textContent && 'text' in textContent) {
      result = textContent.text.trim();
    }
  }
  result = result.split('\n')[0] || '';
  if (result.length > 50) {
    result = result.substring(0, 49) + '…';
  }
  return result;
}

export function sanitizeFilename(text: string): string {
  return text.toLowerCase()
    .replace(/[^a-z0-9\s-]/g, '')
    .replace(/\s+/g, '-')
    .replace(/-+/g, '-')
    .replace(/^-|-$/g, '');
}

5.3 ExportDialog:剪贴板与文件二选一

ExportDialogsrc/components/ExportDialog.tsx,第 1–100 行)提供了一个简单的选项列表:

  1. Copy to clipboard:调用 setClipboard(content) 直接复制到系统剪贴板
  2. Save to file:进入文件名输入子界面,用户可编辑默认文件名后按 Enter 保存

当用户在文件名输入界面按 Escape 时,对话框会返回选项列表而非直接关闭——这种"子屏幕回退"行为通过 handleCancelhandleGoBack 的协调实现,避免了用户误操作导致导出取消。

6. doctor 命令:环境诊断与健康检查

6.1 命令注册与条件启用

// src/commands/doctor/index.ts, 第 1–11 行
import type { Command } from '../../commands.js'
import { isEnvTruthy } from '../../utils/envUtils.js'

const doctor: Command = {
  name: 'doctor',
  description: 'Diagnose and verify your Claude Code installation and settings',
  isEnabled: () => !isEnvTruthy(process.env.DISABLE_DOCTOR_COMMAND),
  type: 'local-jsx',
  load: () => import('./doctor.js'),
}

export default doctor

/doctor 可以通过环境变量 DISABLE_DOCTOR_COMMAND 完全禁用,这在 CI/CD 环境或企业部署中可能很有用。

6.2 极简入口,复杂屏幕

// src/commands/doctor/doctor.tsx, 第 1–8 行
import React from 'react';
import { Doctor } from '../../screens/Doctor.js';
import type { LocalJSXCommandCall } from '../../types/command.js';
export const call: LocalJSXCommandCall = (onDone, _context, _args) => {
  return Promise.resolve(<Doctor onDone={onDone} />);
};

命令层几乎没有任何逻辑,所有诊断工作都在 Doctor 屏幕组件中完成。Doctor 屏幕在 src/screens/Doctor.tsx(第 1–120 行)中实现,它集成了大量诊断模块:

6.3 诊断维度全景

Doctor 屏幕的诊断信息涵盖以下维度:

维度检查内容相关模块
安装信息安装方式(npm-global / native / package-manager 等)、版本号、安装路径doctorDiagnostic.ts
自动更新更新通道、最新版本号、更新权限autoUpdater.ts
配置验证设置文件语法错误、无效值、冲突项useSettingsErrors.ts
快捷键重复绑定、保留键冲突、无效配置KeybindingWarnings.ts
MCP 服务MCP 配置文件解析错误、连接失败McpParsingWarnings.ts
沙箱沙箱状态、权限问题SandboxDoctorSection.ts
上下文上下文警告(文件过多、token 超限等)doctorContextWarnings.ts
锁文件PID 锁文件状态、陈旧锁清理pidLock.ts
ripgreprg 可用性、工作模式(system / builtin / embedded)ripgrep.ts

安装类型诊断在 src/utils/doctorDiagnostic.ts(第 1–90 行)中实现:

// src/utils/doctorDiagnostic.ts, 第 25–55 行
export type DiagnosticInfo = {
  installationType: InstallationType
  version: string
  installationPath: string
  invokedBinary: string
  configInstallMethod: InstallMethod | 'not set'
  autoUpdates: string
  hasUpdatePermissions: boolean | null
  multipleInstallations: Array<{ type: string; path: string }>
  warnings: Array<{ issue: string; fix: string }>
  recommendation?: string
  packageManager?: string
  ripgrepStatus: { working: boolean; mode: 'system' | 'builtin' | 'embedded'; systemPath: string | null }
}

getCurrentInstallationType() 函数通过检测 process.argv[1]process.execPath 以及多种包管理器(Homebrew、Winget、Mise、asdf、Pacman、RPM、APK、Deb)的存在性,推断出当前 Claude Code 的安装方式。这种多路径检测确保了无论用户通过哪种方式安装,都能获得准确的诊断信息。

版本信息的获取则通过 getNpmDistTags()getGcsDistTags() 同时查询 npm registry 和 Google Cloud Storage 的 dist-tags,确保即使某一上游不可用,也能获取到版本信息。

7. upgrade 命令:版本检查与订阅升级

7.1 命令注册与可用性控制

// src/commands/upgrade/index.ts, 第 1–14 行
import type { Command } from '../../commands.js'
import { getSubscriptionType } from '../../utils/auth.js'
import { isEnvTruthy } from '../../utils/envUtils.js'

const upgrade = {
  type: 'local-jsx',
  name: 'upgrade',
  description: 'Upgrade to Max for higher rate limits and more Opus',
  availability: ['claude-ai'],
  isEnabled: () =>
    !isEnvTruthy(process.env.DISABLE_UPGRADE_COMMAND) &&
    getSubscriptionType() !== 'enterprise',
  load: () => import('./upgrade.js'),
} satisfies Command

export default upgrade

/upgrade 有两个关键的可用性限制:

  1. availability: ['claude-ai']:仅在使用 Claude AI 认证时可用(API Key 模式不适用)
  2. getSubscriptionType() !== 'enterprise':企业订阅用户不需要通过此命令升级

7.2 升级流程:浏览器跳转与重新登录

核心实现在 src/commands/upgrade/upgrade.tsx(第 1–70 行):

// src/commands/upgrade/upgrade.tsx, 第 15–55 行
export async function call(onDone, context) {
  try {
    // 检查是否已是最高 Max 计划(20x)
    if (isClaudeAISubscriber()) {
      const tokens = getClaudeAIOAuthTokens();
      let isMax20x = false;
      if (tokens?.subscriptionType && tokens?.rateLimitTier) {
        isMax20x = tokens.subscriptionType === 'max' && tokens.rateLimitTier === 'default_claude_max_20x';
      } else if (tokens?.accessToken) {
        const profile = await getOauthProfileFromOauthToken(tokens.accessToken);
        isMax20x = profile?.organization?.organization_type === 'claude_max'
          && profile?.organization?.rate_limit_tier === 'default_claude_max_20x';
      }
      if (isMax20x) {
        setTimeout(onDone, 0, 'You are already on the highest Max subscription plan...');
        return null;
      }
    }

    const url = 'https://claude.ai/upgrade/max';
    await openBrowser(url);
    return <Login startingMessage={'Starting new login following /upgrade...'} onDone={success => {
      context.onChangeAPIKey();
      onDone(success ? 'Login successful' : 'Login interrupted');
    }} />;
  } catch (error) {
    logError(error as Error);
    setTimeout(onDone, 0, 'Failed to open browser...');
  }
  return null;
}

升级流程设计得非常简洁:

  1. 前置检查:若用户已是 Max 20x 计划,直接提示无需升级
  2. 打开浏览器:调用 openBrowser('https://claude.ai/upgrade/max'),将用户引导至网页端完成支付
  3. 重新登录:支付完成后,渲染 Login 组件让用户重新认证,以刷新 OAuth token 中的订阅信息
  4. 刷新 API Key:登录成功后调用 context.onChangeAPIKey(),通知系统重新加载带有新权限的凭证

值得注意的是,订阅 tier 的判定有两条路径:

  • 本地 token 缓存:优先检查内存中的 tokens.subscriptionTypetokens.rateLimitTier
  • 远程 profile 查询:若本地信息不完整,则通过 getOauthProfileFromOauthToken() 调用远程 API 获取最新组织信息

这种双重检查确保了即使在 token 刷新延迟的情况下,也能准确判断用户当前的订阅状态。

8. 总结:命令设计的工程哲学

通过对这七个进阶命令的源码分析,我们可以总结出 Claude Code 命令系统的几条核心设计原则:

1. 分层解耦:命令层薄,能力层厚

所有命令的 index.tscall() 入口都极其简洁。/diff 只有 5 行代码,/doctor 只有 3 行。真正的复杂度被下沉到 components/screens/utils/ 等复用层中。这种"薄命令、厚能力"的分层使得新增一个斜杠命令的成本极低。

2. 统一的 Local JSX 模式

/plan/resume/diff/export/doctor/upgrade 都采用了 type: 'local-jsx',共享同一套 LocalJSXCommandCall 接口契约(onDonecontextargs)。这种一致性让开发者可以像写普通 React 组件一样写命令界面,同时通过 onDone 回调与主循环无缝衔接。

3. Prompt 驱动的简单命令

/review 采用了 type: 'prompt',不渲染任何自定义 UI,仅靠一段精心设计的 prompt 让模型自行完成全部工作。对于流程明确、依赖外部 CLI 工具(如 gh)的场景,prompt-driven 架构比硬编码逻辑更具灵活性和可维护性。

4. 渐进式搜索与降级策略

/resume 的三层搜索(UUID → 标题 → 错误提示)、/upgrade 的双重 tier 判定(本地 token → 远程 profile)都体现了渐进式探测的工程思想:先尝试最快路径,失败后再降级到更重的查询,最终给出明确的用户反馈。

5. 功能开关与环境隔离

/ultrareview 使用 GrowthBook feature flag,/doctor/upgrade 都支持通过环境变量完全禁用,/upgrade 还区分了 claude-ai 和 API Key 两种 availability。这些机制让 Claude Code 能够在同一套代码库上支持免费/付费、个人/企业、稳定/实验等多种产品形态。

在下一篇文章中,我们将继续深入命令系统,探讨 /config/mcp/login 等涉及外部集成和配置管理的命令实现。