# Step 26: 配置持久化

一句话导读:把散落在内存里的运行时设置(模型、权限模式、预算、debug)落到一个 .claude-settings.json 文件——启动时加载、变更时保存、退出时保存,让用户的选择活得过重启。


# 一、这一步做了什么(What)

新增 utils/settings.ts,提供加载/保存两个函数和一份默认值:

  1. AppSettings 定义可持久化的字段:model / permMode / budgetMax / debugMode;
  2. loadSettings() 启动时从 .claude-settings.json 读,用 {...DEFAULTS, ...读到的} 合并;
  3. saveSettings() 把当前设置写回 JSON;
  4. index.ts 接线:启动即加载、每次改设置(/model、/permission、/debug、/budget)后自动保存、退出前保存;
  5. /config 命令查看当前全部设置。

配置文件放在当前工作目录下(process.cwd())。


# 二、面试官视角:为什么要做配置持久化?(Why)

面试题:设置存在内存里,用完即弃不行吗?为什么非要写文件持久化?

因为一次性的会话状态和跨会话的偏好是两回事——前者该丢,后者该留。

维度 只存内存 持久化到文件
重启后 全部回到默认,每次重设 上次选的模型/权限还在
用户体验 "我明明设过 Opus 怎么又变回来了" 设一次,永久生效
团队协作 无法共享配置 文件可入库/共享
工具成熟度 玩具感 「记得住」是成熟工具的标志

核心动机:配置持久化是成熟工具的分水岭。一个每次启动都失忆的工具,用户要反复设置模型、权限、预算,摩擦极大。把这些偏好落到磁盘,用户的选择就成了「一次设置、长期有效」。而落地的关键是一套清晰的加载优先级——环境变量 > 配置文件 > 默认值——让「临时覆盖」和「持久偏好」和平共存。


# 三、原理:它是怎么工作的(How)

# 完整实现(真实代码)

import { readFileSync, writeFileSync, existsSync } from "fs";
import { join } from "path";

export interface AppSettings {
  model: string;
  permMode: string;
  budgetMax: number;
  debugMode: boolean;
}

const FILE = join(process.cwd(), ".claude-settings.json");

const DEFAULTS: AppSettings = {
  model: "claude-sonnet-4-20250514",
  permMode: "default",
  budgetMax: 64000,
  debugMode: false,
};

export function loadSettings(): AppSettings {
  if (!existsSync(FILE)) return { ...DEFAULTS };
  try { return { ...DEFAULTS, ...JSON.parse(readFileSync(FILE, "utf-8")) }; }   // ★ 合并
  catch { return { ...DEFAULTS }; }                                            // 坏文件兜底
}

export function saveSettings(s: AppSettings): void {
  writeFileSync(FILE, JSON.stringify(s, null, 2), "utf-8");                     // 2 空格缩进,人可读
}

export function settingsPath(): string { return FILE; }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

# 三个关键设计点

1. {...DEFAULTS, ...loaded} 合并 —— 向前兼容的核心 读到的 JSON 不是直接返回,而是先铺一层 DEFAULTS 再用文件覆盖。这带来一个重要能力:新版本加了新字段,旧配置文件里没有,也不会变成 undefined——DEFAULTS 会补上。这是配置演进不炸的关键技巧。

2. try/catch 兜底 —— 坏文件不致命 如果文件被手改坏、JSON 语法错误,catch 直接退回全默认,程序照常启动。配置读取绝不该让主程序崩溃。

3. existsSync 先判存在 —— 首次运行零配置 文件不存在(第一次跑)就直接返回默认,不报错。用户什么都不用准备就能开跑。

# 数据流:加载—修改—保存的闭环

启动
  ↓
settings = loadSettings()          // 文件 → 内存(叠加默认)
  ↓
应用到运行时(currentModel、permMode、budget...)
  ↓
用户 /model opus
  ↓
改内存状态 + settings.model = "...opus..."
  ↓
saveSettings(settings)             // 内存 → 文件,立即落盘
  ↓
退出前再 saveSettings 一次         // 双保险
1
2
3
4
5
6
7
8
9
10
11
12
13

# 加载优先级

环境变量  >  .claude-settings.json  >  DEFAULTS
(临时覆盖)    (持久偏好)            (兜底)
1
2

越靠前优先级越高:环境变量适合「这次临时换个模型」而不想改文件;配置文件是「我的长期偏好」;默认值保证「什么都没有也能跑」。

# 设计细节表

细节 做法 为什么
合并而非替换 {...DEFAULTS, ...loaded} 新增字段向前兼容,旧文件不缺字段
缩进保存 JSON.stringify(s, null, 2) 人可读、可手改、diff 友好
存 cwd 而非 home join(process.cwd(), ...) 每个项目一份配置,互不干扰
每次变更即存 /model 等改完就 save 崩溃也不丢,无需手动保存

# 四、深入追问(面试常见 follow-up)

Q:为什么用 {...DEFAULTS, ...JSON.parse(...)} 合并,而不是直接返回 parse 结果? A:为了配置演进的向前兼容。假设 v2 新增了 style 字段,用户的 v1 配置文件里没有它。若直接返回 parse 结果,settings.style 就是 undefined,后续代码可能崩。先铺 DEFAULTS 再覆盖,缺的字段自动用默认补齐,老配置无痛升级。这是配置系统的标准防御写法。

Q:配置文件被手改成非法 JSON 会怎样? A:JSON.parse 抛异常,被 catch 兜住,退回全默认继续启动。设计原则是「配置错误不该拖垮主程序」——顶多丢失这次的持久偏好,但程序能跑。更友好的版本会在这里打一条 warning 提示用户文件坏了。

Q:为什么存到 process.cwd()(当前目录)而不是用户主目录? A:为了项目级隔离。放当前目录,A 项目用 Opus、B 项目用 Haiku,各自一份 .claude-settings.json 互不影响。真实 Claude Code 是分层的:全局配置(主目录)+ 项目配置(cwd),项目覆盖全局——比我们这版单层更完整。

Q:多个进程/会话同时写同一个文件会不会冲突? A:会有竞态——最后写的赢,可能覆盖另一个会话的改动。这一版没做文件锁,因为学习场景基本是单会话。生产版需要原子写(写临时文件再 rename)或加锁来避免并发损坏。


# 五、踩坑 / 设计权衡

  • 单层 vs 分层配置:我们只有「cwd 一份文件」。真实版是 全局 + 项目 + 命令行 多层叠加,灵活但复杂。学习版取单层讲清「加载—合并—保存」主干。
  • 同步 IO:用 readFileSync/writeFileSync 简单直接,但会阻塞。配置文件小、频率低,阻塞可忽略;高频写场景要换异步或防抖。
  • 无并发保护:多会话同写会互相覆盖,学习版接受这个简化。

# 六、与真实源码的对照

我们的实现 Claude Code 源码
utils/settings.ts(~30 行) utils/settings.ts(更完整的分层配置系统)
单层:cwd 一份文件 分层:全局 + 项目 + 运行时覆盖
{...DEFAULTS, ...loaded} 合并 同样的深合并 + schema 校验
try/catch 退默认 更细的错误提示与修复引导
同步无锁写 原子写 / 并发保护

# 七、一句话总结

配置持久化 = 让偏好活过重启的最小闭环:一份 JSON 文件 + loadSettings/saveSettings,靠「{...DEFAULTS, ...loaded} 合并」实现向前兼容、靠 try/catch 保证坏文件不致命、靠「环境变量 > 文件 > 默认」的优先级让临时覆盖与长期偏好共存——工具从「玩具」迈向「成熟」的分水岭。

# 下一节预告

设置能记住了,下一个可持久化的偏好是「输出风格」。step27 加 /style 命令,让用户在简洁/详细/极简之间切换——而实现只是往 system prompt 里塞几行指令。