项目初始化与 Onboarding

📑 目录

这是「Claude Code 代码全景解析」专题的第 07 篇。在前几篇文章中,我们梳理了仓库结构、技术栈与构建体系。从本篇开始,我们将深入 Claude Code 的运行时启动流程,首要环节就是 setup.ts 中的项目初始化与首次使用引导(Onboarding)。


一、setup.ts 概览

1.1 文件职责

src/setup.ts 是 Claude Code 启动链条中的核心初始化模块,承担以下职责:

  1. 环境校验:检查 Node.js 版本是否满足最低要求(≥ 18)
  2. 会话管理:设置自定义会话 ID、初始化 UDS(Unix Domain Socket)消息服务
  3. 终端恢复:检测并恢复被中断的 iTerm2 / Terminal.app 配置备份
  4. 工作目录设置:通过 setCwd() 确立当前工作目录,后续所有文件操作均基于此
  5. Hooks 捕获:捕获 settings.json 等 hook 配置的快照,防止运行时的隐藏修改
  6. Worktree 处理:支持 --worktree 模式,创建独立的 git worktree 会话
  7. 后台任务启动:初始化 session memory、context collapse、插件预加载等
  8. 数据预取:提前加载 release notes、API key、命令列表等,为首次渲染做准备

1.2 与 main.tsx 的调用关系

setup() 并非在 main.tsx 的顶层同步调用,而是通过动态导入在 CLI 参数解析完成后执行(src/main.tsx,约第 1908 行):

// src/main.tsx,第 1903–1935 行(近似)
const { setup } = await import('./setup.js')
const setupPromise = setup(
  preSetupCwd,
  permissionMode,
  allowDangerouslySkipPermissions,
  worktreeEnabled,
  worktreeName,
  tmuxEnabled,
  sessionId ? validateUuid(sessionId) : undefined,
  worktreePRNumber,
  messagingSocketPath,
)

这里的设计意图很明确:main.tsx 负责 CLI 参数解析和高级编排,setup.ts 负责底层环境初始化。两者通过 import() 解耦,使得 setup.ts 中的重型依赖(如 chalkbun:bundle 的 feature gate)不会阻塞 main.tsx 的早期代码加载。

值得注意的是,main.tsx 在调用 setup() 的同时,还会并行加载 getCommands()getTools()(第 1913–1927 行),以重叠 I/O 耗时setup() 的约 28ms 初始化时间与命令加载时间并行执行,显著缩短了首次提示符(first prompt)的出现时间。


二、项目检测逻辑

Claude Code 不是一个「无状态」的 CLI 工具,它的许多功能——如自动记忆、团队记忆同步、MCP 服务器配置、Trust Dialog——都依赖于对当前项目的识别。因此,启动时的项目检测是初始化的关键环节。

2.1 Git 仓库检测

Claude Code 对 Git 的检测非常彻底,核心逻辑位于 src/utils/git.ts

逐级上溯查找 .git

findGitRoot() 函数通过向上遍历目录树来定位 Git 根目录(src/utils/git.ts,第 31–70 行):

// src/utils/git.ts,第 31–70 行
const findGitRootImpl = memoizeWithLRU(
  (startPath: string): string | typeof GIT_ROOT_NOT_FOUND => {
    let current = resolve(startPath)
    const root = current.substring(0, current.indexOf(sep) + 1) || sep
    let statCount = 0

    while (current !== root) {
      try {
        const gitPath = join(current, '.git')
        statCount++
        const stat = statSync(gitPath)
        // .git can be a directory (regular repo) or file (worktree/submodule)
        if (stat.isDirectory() || stat.isFile()) {
          return current.normalize('NFC')
        }
      } catch {
        // .git doesn't exist at this level, continue up
      }
      const parent = dirname(current)
      if (parent === current) {
        break
      }
      current = parent
    }
    // ... 根目录检查省略
    return GIT_ROOT_NOT_FOUND
  },
  path => path,
  50,
)

这里有几个值得注意的设计细节:

  • 处理 worktree 和 submodule.git 既可以是目录(普通仓库),也可以是文件(worktree/submodule),代码中通过 stat.isDirectory() || stat.isFile() 同时兼容两种情况。
  • LRU 缓存:使用 memoizeWithLRU 缓存最近 50 个路径的查找结果,避免在频繁切换目录时重复遍历。
  • NFC 规范化:返回路径时调用 .normalize('NFC'),确保 Unicode 组合字符的一致性,防止后续字符串比较出现问题。

解析 canonical Git 根目录

对于 worktree 场景,findGitRoot() 返回的是 worktree 目录,而项目级配置(如 Trust Dialog、自动记忆)需要映射到主仓库findCanonicalGitRoot() 通过读取 .git 文件中的 gitdir: 指针,再跟随 commondir 链找到主仓库(src/utils/git.ts,第 89–155 行):

// src/utils/git.ts,第 89–155 行(核心逻辑摘要)
const resolveCanonicalRoot = memoizeWithLRU((gitRoot: string): string => {
  try {
    const gitContent = readFileSync(join(gitRoot, '.git'), 'utf-8').trim()
    if (!gitContent.startsWith('gitdir:')) {
      return gitRoot
    }
    const worktreeGitDir = resolve(gitRoot, gitContent.slice('gitdir:'.length).trim())
    const commonDir = resolve(
      worktreeGitDir,
      readFileSync(join(worktreeGitDir, 'commondir'), 'utf-8').trim(),
    )
    // 安全检查:验证 worktreeGitDir 是否位于 <commonDir>/worktrees/ 下
    if (resolve(dirname(worktreeGitDir)) !== join(commonDir, 'worktrees')) {
      return gitRoot
    }
    // 验证 backlink 是否指向当前 gitRoot
    const backlink = realpathSync(
      readFileSync(join(worktreeGitDir, 'gitdir'), 'utf-8').trim(),
    )
    if (backlink !== join(realpathSync(gitRoot), '.git')) {
      return gitRoot
    }
    // ... 返回主仓库工作目录
  } catch {
    return gitRoot
  }
})

这里的安全检查尤其值得称道:Claude Code 明确考虑了恶意仓库通过伪造 .git 文件和 commondir 来指向受害者已信任的目录,从而绕过 Trust Dialog 的攻击向量。两道验证(路径结构验证 + backlink 验证)确保了 worktree 解析的安全性。

2.2 包管理器与运行时检测

在环境检测方面,src/utils/env.ts 提供了一组**惰性缓存(memoized)**的检测函数(第 39–58 行):

// src/utils/env.ts,第 39–58 行
const detectPackageManagers = memoize(async (): Promise<string[]> => {
  const packageManagers = []
  if (await isCommandAvailable('npm')) packageManagers.push('npm')
  if (await isCommandAvailable('yarn')) packageManagers.push('yarn')
  if (await isCommandAvailable('pnpm')) packageManagers.push('pnpm')
  return packageManagers
})

const detectRuntimes = memoize(async (): Promise<string[]> => {
  const runtimes = []
  if (await isCommandAvailable('bun')) runtimes.push('bun')
  if (await isCommandAvailable('deno')) runtimes.push('deno')
  if (await isCommandAvailable('node')) runtimes.push('node')
  return runtimes
})

检测方式非常朴素但有效:通过 which 命令检查可执行文件是否存在。memoize 确保这些检测在单次会话中只执行一次。虽然目前 setup.ts 本身并未直接消费这些检测结果,但它们为后续的工具调用(如 BashTool 选择正确的包管理器)提供了环境上下文。

2.3 仓库识别(GitHub owner/name)

src/utils/detectRepository.ts 负责从 Git remote URL 中解析出仓库的 host、owner 和 name(第 24–88 行):

// src/utils/detectRepository.ts,第 24–88 行
export async function detectCurrentRepositoryWithHost(): Promise<ParsedRepository | null> {
  const cwd = getCwd()
  if (repositoryWithHostCache.has(cwd)) {
    return repositoryWithHostCache.get(cwd) ?? null
  }

  try {
    const remoteUrl = await getRemoteUrl()
    if (!remoteUrl) {
      repositoryWithHostCache.set(cwd, null)
      return null
    }

    const parsed = parseGitRemote(remoteUrl)
    repositoryWithHostCache.set(cwd, parsed)
    return parsed
  } catch (error) {
    repositoryWithHostCache.set(cwd, null)
    return null
  }
}

parseGitRemote() 支持五种常见的 Git remote URL 格式:

  • git@host:owner/repo.git(SSH 格式)
  • https://host/owner/repo.git
  • ssh://git@host/owner/repo.git
  • git://host/owner/repo.git
  • https://host/owner/repo(无 .git 后缀)

此外还包含一个 looksLikeRealHostname() 辅助函数,用于过滤掉 SSH config alias(如 github.com-work),确保解析结果的可信度。


三、Onboarding 流程

Claude Code 的 Project Onboarding 是引导用户在项目中完成首次配置的状态机系统。它的核心实现位于 src/projectOnboardingState.ts,逻辑非常精简,却设计得相当精巧。

3.1 Onboarding 状态设计

projectOnboardingState.ts 定义了一个 Step 类型和一个 getSteps() 函数(第 9–31 行):

// src/projectOnboardingState.ts,第 9–31 行
export type Step = {
  key: string
  text: string
  isComplete: boolean
  isCompletable: boolean
  isEnabled: boolean
}

export function getSteps(): Step[] {
  const hasClaudeMd = getFsImplementation().existsSync(
    join(getCwd(), 'CLAUDE.md'),
  )
  const isWorkspaceDirEmpty = isDirEmpty(getCwd())

  return [
    {
      key: 'workspace',
      text: 'Ask Claude to create a new app or clone a repository',
      isComplete: false,
      isCompletable: true,
      isEnabled: isWorkspaceDirEmpty,
    },
    {
      key: 'claudemd',
      text: 'Run /init to create a CLAUDE.md file with instructions for Claude',
      isComplete: hasClaudeMd,
      isCompletable: true,
      isEnabled: !isWorkspaceDirEmpty,
    },
  ]
}

这里的设计体现了条件分支 onboarding

  • 如果当前目录为空isWorkspaceDirEmpty),启用 workspace 步骤,引导用户创建新项目或克隆仓库
  • 如果当前目录非空,启用 claudemd 步骤,引导用户运行 /init 创建 CLAUDE.md

两个步骤互斥,根据目录状态自动切换。

3.2 Onboarding 完成判定

完成判定逻辑非常简单(第 33–37 行):

// src/projectOnboardingState.ts,第 33–37 行
export function isProjectOnboardingComplete(): boolean {
  return getSteps()
    .filter(({ isCompletable, isEnabled }) => isCompletable && isEnabled)
    .every(({ isComplete }) => isComplete)
}

它只检查当前启用的、可完成的步骤是否全部完成。这意味着如果目录为空,用户只需要完成 workspace 步骤;如果目录非空,只需要创建 CLAUDE.md

3.3 可见性控制

shouldShowProjectOnboarding() 函数控制 onboarding 提示的显示逻辑(第 45–57 行):

// src/projectOnboardingState.ts,第 45–57 行
export const shouldShowProjectOnboarding = memoize((): boolean => {
  const projectConfig = getCurrentProjectConfig()
  if (
    projectConfig.hasCompletedProjectOnboarding ||
    projectConfig.projectOnboardingSeenCount >= 4 ||
    process.env.IS_DEMO
  ) {
    return false
  }

  return !isProjectOnboardingComplete()
})

可见性控制的策略是:

  1. 已标记完成hasCompletedProjectOnboarding)→ 不再显示
  2. 已展示 4 次projectOnboardingSeenCount >= 4)→ 不再打扰用户
  3. DEMO 模式IS_DEMO 环境变量)→ 跳过 onboarding
  4. 步骤已全部完成 → 自动隐藏

这个「最多展示 4 次」的设计非常人性化——即使玩家暂时没有完成 onboarding,也不会被反复弹窗骚扰。

3.4 计数与持久化

每次展示 onboarding 时,会调用 incrementProjectOnboardingSeenCount() 增加计数器(第 59–63 行):

// src/projectOnboardingState.ts,第 59–63 行
export function incrementProjectOnboardingSeenCount(): void {
  saveCurrentProjectConfig(current => ({
    ...current,
    projectOnboardingSeenCount: current.projectOnboardingSeenCount + 1,
  }))
}

当所有步骤完成时,maybeMarkProjectOnboardingComplete() 会将 hasCompletedProjectOnboarding 标记为 true(第 39–47 行):

// src/projectOnboardingState.ts,第 39–47 行
export function maybeMarkProjectOnboardingComplete(): void {
  if (getCurrentProjectConfig().hasCompletedProjectOnboarding) {
    return
  }
  if (isProjectOnboardingComplete()) {
    saveCurrentProjectConfig(current => ({
      ...current,
      hasCompletedProjectOnboarding: true,
    }))
  }
}

注意这里的短路优化:函数开头先检查缓存的 hasCompletedProjectOnboarding,如果已经为 true 则直接返回,避免不必要的文件系统访问。注释中特别提到,这个优化是必要的,因为 REPL.tsx 在每次提交 prompt 时都会调用此函数。

3.5 Onboarding 流程图

flowchart TD
    A[启动 Claude Code] --> B{目录是否为空?}
    B -->|是| C[启用 workspace 步骤
引导创建/克隆项目] B -->|否| D[启用 claudemd 步骤
引导运行 /init] C --> E{用户是否完成步骤?} D --> E E -->|否| F{展示次数 < 4?} F -->|是| G[继续展示 Onboarding 提示] F -->|否| H[永久隐藏提示] E -->|是| I[标记 hasCompletedProjectOnboarding = true] I --> J[Onboarding 完成] G --> K[projectOnboardingSeenCount + 1] K --> L[等待下次交互]

四、配置初始化

Claude Code 的配置体系分为全局配置GlobalConfig)和项目配置ProjectConfig)两层,两者共同存储在用户的 home 目录下的 JSON 文件中。

4.1 配置存储位置

全局配置文件的路径由 getGlobalClaudeFile() 决定(src/utils/env.ts,第 14–22 行):

// src/utils/env.ts,第 14–22 行
export const getGlobalClaudeFile = memoize((): string => {
  // Legacy fallback for backwards compatibility
  if (
    getFsImplementation().existsSync(
      join(getClaudeConfigHomeDir(), '.config.json'),
    )
  ) {
    return join(getClaudeConfigHomeDir(), '.config.json')
  }

  const filename = `.claude${fileSuffixForOauthConfig()}.json`
  return join(process.env.CLAUDE_CONFIG_DIR || homedir(), filename)
})

默认情况下,配置文件位于 ~/.claude.json(或带 OAuth 后缀的变体)。这个文件中同时包含:

  • GlobalConfig:主题、编辑器模式、自动更新、OAuth 账户等用户级设置
  • Record<string, ProjectConfig>:以项目路径为 key 的项目级配置字典

4.2 项目配置键的生成策略

项目配置不是以当前工作目录(cwd)直接作为 key,而是通过 getProjectPathForConfig() 智能映射(src/utils/config.ts,第 1588–1600 行):

// src/utils/config.ts,第 1588–1600 行
export const getProjectPathForConfig = memoize((): string => {
  const originalCwd = getOriginalCwd()
  const gitRoot = findCanonicalGitRoot(originalCwd)

  if (gitRoot) {
    return normalizePathForConfigKey(gitRoot)
  }

  // Not in a git repo
  return normalizePathForConfigKey(resolve(originalCwd))
})

这一设计确保了:

  • Git 仓库内:无论你在项目的哪个子目录启动 Claude Code,配置都映射到同一个 git root
  • Worktree 场景:通过 findCanonicalGitRoot(),所有 worktree 共享主仓库的配置
  • 非 Git 目录:回退到原始启动目录(resolved)

路径使用 normalizePathForConfigKey() 统一转换为正斜杠格式,保证跨平台一致性(Windows 上的 C:\Users\...C:/Users/... 映射到同一个 key)。

4.3 默认项目配置

ProjectConfig 的默认值定义在 DEFAULT_PROJECT_CONFIG 中(src/utils/config.ts,第 110–123 行):

// src/utils/config.ts,第 110–123 行
const DEFAULT_PROJECT_CONFIG: ProjectConfig = {
  allowedTools: [],
  mcpContextUris: [],
  mcpServers: {},
  enabledMcpjsonServers: [],
  disabledMcpjsonServers: [],
  hasTrustDialogAccepted: false,
  projectOnboardingSeenCount: 0,
  hasClaudeMdExternalIncludesApproved: false,
  hasClaudeMdExternalIncludesWarningShown: false,
}

关键字段含义:

字段说明
allowedTools用户已授权的工具白名单
hasTrustDialogAccepted是否已接受 Trust Dialog(项目信任确认)
hasCompletedProjectOnboarding项目级 onboarding 是否已完成
projectOnboardingSeenCountonboarding 提示已展示次数
mcpServers项目级 MCP 服务器配置
activeWorktreeSession当前活动的 worktree 会话信息

4.4 CLAUDE.md 的创建建议

在 onboarding 的 claudemd 步骤中,Claude Code 引导用户运行 /init 命令来创建 CLAUDE.md。这个文件是项目级上下文注入的核心载体——开发者可以在其中写入项目的编码规范、架构约定、常用命令等,Claude Code 会在每次会话开始时自动读取并将其注入系统提示词。

setup.ts 本身并不直接创建 CLAUDE.md,但它在初始化阶段会调用 clearMemoryFileCaches()(在 worktree 切换后),确保内存中的 CLAUDE.md 缓存被清除,以便重新读取最新内容。

4.5 配置的读写与并发安全

saveCurrentProjectConfig() 实现了带锁的配置写入src/utils/config.ts,第 1625–1675 行):

// src/utils/config.ts,第 1625–1675 行(核心逻辑摘要)
export function saveCurrentProjectConfig(
  updater: (currentConfig: ProjectConfig) => ProjectConfig,
): void {
  const absolutePath = getProjectPathForConfig()

  let written: GlobalConfig | null = null
  try {
    const didWrite = saveConfigWithLock(
      getGlobalClaudeFile(),
      createDefaultGlobalConfig,
      current => {
        const currentProjectConfig =
          current.projects?.[absolutePath] ?? DEFAULT_PROJECT_CONFIG
        const newProjectConfig = updater(currentProjectConfig)
        // Skip if no changes (same reference returned)
        if (newProjectConfig === currentProjectConfig) {
          return current
        }
        written = {
          ...current,
          projects: {
            ...current.projects,
            [absolutePath]: newProjectConfig,
          },
        }
        return written
      },
    )
    if (didWrite && written) {
      writeThroughGlobalConfigCache(written)
    }
  } catch (error) {
    // 降级处理:直接读写,但会检查是否会导致 auth 状态丢失
    // ...
  }
}

这里有几个工程亮点:

  1. 函数式更新:接收 updater 回调而非直接传入新值,保证更新基于最新状态
  2. 引用相等优化:如果 updater 返回的引用与输入相同,跳过写入
  3. 文件锁saveConfigWithLock() 使用 lockfile 防止并发写入冲突
  4. Auth 安全降级:如果带锁写入失败,降级为直接写入,但会检查是否会导致认证状态丢失(参考 GitHub issue #3117)

五、setup.ts 中的其他初始化细节

5.1 Node.js 版本检查

setup() 的第一件事就是检查 Node.js 版本(第 31–41 行):

// src/setup.ts,第 31–41 行
const nodeVersion = process.version.match(/^v(\d+)\./)?.[1]
if (!nodeVersion || parseInt(nodeVersion) < 18) {
  console.error(
    chalk.bold.red(
      'Error: Claude Code requires Node.js version 18 or higher.',
    ),
  )
  process.exit(1)
}

这是整个函数中唯一会导致进程直接退出的检查点,优先级最高。

5.2 UDS 消息服务

UDS_INBOX feature flag 开启时,setup() 会启动 Unix Domain Socket 消息服务器(第 49–61 行):

// src/setup.ts,第 49–61 行
if (feature('UDS_INBOX')) {
  const m = await import('./utils/udsMessaging.js')
  await m.startUdsMessaging(
    messagingSocketPath ?? m.getDefaultUdsSocketPath(),
    { isExplicit: messagingSocketPath !== undefined },
  )
}

这个服务用于多会话间的进程通信,例如 claude remote-control 功能。它被设计为setup() 中 await,确保在 SessionStart hook 可能产生子进程之前,环境变量 $CLAUDE_CODE_MESSAGING_SOCKET 已经就绪。

5.3 终端备份恢复

Claude Code 支持为 iTerm2 和 Terminal.app 自动安装键位绑定(如 Shift+Enter 发送消息)。如果之前的安装过程被中断,setup() 会检测备份文件并恢复原始配置(第 73–111 行)。这一机制保护了用户的终端环境不会因为 Claude Code 的自动配置而被永久改变。


六、总结

Claude Code 的初始化流程虽然代码量大,但设计层次分明:

层级职责代表文件
CLI 编排层参数解析、子命令分发、初始化调度main.tsx
环境初始化层目录检测、配置加载、Hook 捕获setup.ts
项目状态层Onboarding 状态机、配置读写projectOnboardingState.ts
工具层Git 检测、仓库解析、文件操作git.tsdetectRepository.tsconfig.ts

从工程角度看,setup.ts 展示了工业级 CLI 工具的几个最佳实践:

  1. 延迟加载:通过动态 import() 避免重型模块阻塞启动路径
  2. 并行 I/Omain.tsxsetup()getCommands() 并行执行,最大化重叠耗时
  3. 防御性编程:worktree 解析时的双重安全检查、配置写入时的 auth 状态保护
  4. 渐进式引导:Onboarding 不强制一次性完成,允许用户最多看到 4 次提示
  5. 跨平台兼容:路径的 NFC 规范化、正斜杠统一、Windows / macOS / Linux 的差异化处理

理解这套初始化机制,是后续深入分析 Claude Code 的 Tool 系统、Agent 编排和 MCP 生态的前提——因为它们都依赖于 setup.ts 所确立的项目上下文和配置基础。


参考源码