# Step 26: 配置持久化
一句话导读:把散落在内存里的运行时设置(模型、权限模式、预算、debug)落到一个
.claude-settings.json文件——启动时加载、变更时保存、退出时保存,让用户的选择活得过重启。
# 一、这一步做了什么(What)
新增 utils/settings.ts,提供加载/保存两个函数和一份默认值:
AppSettings定义可持久化的字段:model / permMode / budgetMax / debugMode;loadSettings()启动时从.claude-settings.json读,用{...DEFAULTS, ...读到的}合并;saveSettings()把当前设置写回 JSON;- index.ts 接线:启动即加载、每次改设置(
/model、/permission、/debug、/budget)后自动保存、退出前保存; /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; }
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 一次 // 双保险
2
3
4
5
6
7
8
9
10
11
12
13
# 加载优先级
环境变量 > .claude-settings.json > DEFAULTS
(临时覆盖) (持久偏好) (兜底)
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 里塞几行指令。