Development only: 采样插件(非发布物):把引擎的**运行时字符串**(命令描述 / 配置项 / 各渲染点 props)原样导出为 JSON,供 cc-zh 的 exact 词典取权威 key。只读、只写 /tmp,不翻译、不改动任何东西。

A self-managed Claude Code CLI localization project: our own dictionary, our own hooks plugin, our own sampler and TTY verification tools. UI text only — no functional logic, no modification of official artifacts.
✅ Localization works again. The old "patch the official binary" route died with the official bytecode switch in 2026-08. This repo was rebooted on 2026-10-12 around the official mods extension point (Claude Code 2.1.287+). It is verified green on CC 2.1.296.
<a id="english"></a>
| Item | Value |
|---|---|
| Plugin | mods/cc-zh/ 0.1.0, 13 hooks |
| Dictionary | dict.json: exact 1950 entries + patterns 4 + spinner 5 |
| Assembly check | claude plugin validate . → Validation passed (13 hooks + 8 $ calls) |
| Runtime tests | claude plugin test . → 11 pass / 0 fail |
| Live TTY check | 100×300 screen snapshot: Chinese renders correctly, 0 overflow rows, 0 UTF-8 replacement chars |
| Untranslated | /cc-zh-dump → 0 commands, 0 config items, 0 screen strings |
Daily use is already installed globally (since 2026-10-12): ~/.claude/settings.json has
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/jnq/Dev/Private/claude-code-zh/mods/cc-zh"
Just run claude and the UI is localized. Edits to the dictionary or hooks in this repo take effect immediately — no reinstall. Delete that line to restore the original English UI. For one-off testing or A/B comparison use claude --plugin-dir <plugin dir> (session only).
Alternatively, install from this repo's marketplace:
claude plugin marketplace add jiangnanquan/claude-code-zh
claude plugin install cc-zh@claude-code-zh
A static dictionary rewrites engine-rendered output across 11 surfaces. All rewrites are props pass-through — the engine still draws with its own components, so layout, colors, keybindings and interactions are unchanged:
| Surface | Hook |
|---|---|
/ slash-command descriptions (built-ins, skills, plugins) | command.describe (exact → patterns) |
/config labels and descriptions | config.describe |
| Spinner word and attached message | ui.render Spinner (only replaces still-English words) |
| Prompt hint line (all-or-nothing translation) | ui.render PromptHint |
| Info notices | ui.render InfoNotice |
(ctrl+b to run in background) | ui.render ToolProgress |
| Command output lines | ui.render CommandOutput |
| Background task notifications | ui.render UserMessage{task-notification} |
Collapsed tool-call summary (Read 3 files) | ui.render ToolGroup (custom draw) |
Turn duration line (Baked for 3s) | ui.render TurnDuration (custom draw, Chinese duration) |
| Footer mode labels | ui.render SessionMode (modes is always empty on 2.1.296; the pill is engine-drawn) |
cd mods/cc-zh
# Assembly and logic (run after every change)
claude plugin validate . # expect: 13 hooks + 8 $ calls, Validation passed
claude plugin test . # expect: 11 pass / 0 fail
# Live TTY layout check (turns "eyeballing" into reproducible evidence)
P=~/.workbuddy/binaries/python/envs/default/bin/python
$P tools/tty-probe.py . /tmp/probe "wait:16,/,wait:9" --cwd "$(git rev-parse --show-toplevel)"
# pass line: 0 overflow rows + 0 UTF-8 replacement chars
/cc-zh-dump in a session to list still-English commands / config items / strings;mods/cc-zh/tools/ to obtain the authoritative English originals;dict.json under exact (value = Chinese); strings with runtime variables go into patterns;⛔ Never use
/cc-zh-dump's rendered screen output as a key — the renderer wraps lines and strips backticks. The exact table matches character-for-character, so a wrong character silently never matches. Never write guessed translations: miss = pass through English.
ToolGroup and TurnDuration only);$.fs.read at startup;$.store and summarized by /cc-zh-dump, so the dictionary grows incrementally from evidence, not guesses.Not yet hooked (extendable, not an official limit): ui.render has 15 render points; we currently hook 9. The unhooked ones are AssistantMessage, ToolUse, ToolResult, AskUserQuestion, AbovePrompt, Pane. Visible leftovers today:
Thought for 1s (ctrl+o to expand) (belongs to AssistantMessage)(0s · thinking with max effort) (Spinner footnote, not present in props.message)Unlocalizable by official design (not a bug, don't try): the permission dialog is drawn by the engine alone. Also out of mods scope: welcome screen, /config & /model sub-select screens and option values (auto/dark/plan), error messages, tips, pills below the prompt (auto mode on), tool-call lines themselves (Bash(ls), Interrupted), running indicators ((12s, 300 tokens)), and claude --help.
claude-code-zh/
├── mods/cc-zh/ # the deliverable: self-contained mods plugin
│ ├── .claude-plugin/ # plugin.json (types/ is generated, ignored)
│ ├── hooks/ # register.tsx (13 hooks, incl. /cc-zh-dump)
│ ├── dict.json # dictionary: exact + patterns + spinner + modes
│ ├── tests/ # runtime tests (11)
│ ├── tools/ # verification & sampling tools (not shipped)
│ └── README.md # coverage & design contract
├── README.md # this file
├── SOP.md # maintenance SOP (add entries / verify / upgrade / switches)
├── SKILL.md # skill entry (/claude-code-zh)
├── 00_状态.md # status card
└── HANDOFF.md # new-session handoff
This repo originally patched the official binary (the "A route"): scan JS string literals in the bun-built binary and replace them with Chinese. Starting with Claude Code 2.1.24x, Anthropic switched to Bun bytecode compilation + chunk splitting; UI text moved into JSC bytecode and that route is permanently dead (the upstream taekchef/claude-code-zh-cn also retired it in PR #235).
On 2026-10-12 this repo rebooted as a mods project. The old tools/, scripts/, patches/, i18n/ and PITFALLS.md were deleted; the translation-table asset was fully absorbed into mods/cc-zh/dict.json (1950 entries). Old files remain in Git history if needed.
MIT. The dictionary foundation comes from the taekchef/claude-code-zh-cn translation table (MIT); this repo maintains and extends it independently.
<a id="简体中文"></a>
| 项 | 值 |
|---|---|
| 插件 | mods/cc-zh/ 0.1.0,13 个钩子 |
| 词典 | dict.json:exact 1950 条 + patterns 4 条 + spinner 5 条 |
| 装配校验 | claude plugin validate . → Validation passed(13 钩子 + 8 个 $ 调用) |
| 运行时测试 | claude plugin test . → 11 pass / 0 fail |
| TTY 实机 | 100 列 × 300 行快照:中文正常显示,溢出 0 行,UTF-8 替换字符 0 |
| 未译项 | /cc-zh-dump → 命令 0,配置 0,屏幕文案 0 |
日常使用已经装好(2026-10-12 起):~/.claude/settings.json 的 env 里有一行
"CLAUDE_CODE_PLUGIN_DIRS": "/Users/jnq/Dev/Private/claude-code-zh/mods/cc-zh"
直接敲 claude 即带汉化;仓库里改完词典或 hooks 立即生效、无需重装。 删掉这一行即完全还原英文。单次试装或 A/B 对比用 claude --plugin-dir <插件目录>(仅本次会话)。
也可以用本仓自带的 marketplace 安装:
claude plugin marketplace add jiangnanquan/claude-code-zh
claude plugin install cc-zh@claude-code-zh
以静态词典改写引擎绘制结果,覆盖 11 处界面。全部走 props 透传改写——由引擎用自己的 组件绘制,因此布局、配色、键位、交互一律不变:
| 界面 | 钩子 |
|---|---|
/ 斜杠命令说明(内置命令、skills、插件) | command.describe(exact → patterns) |
/config 行的 label 与说明 | config.describe |
| 工作态 Spinner 词与附带文案 | ui.render Spinner(只在仍为英文时接替) |
| 提示行(整句译到才替换) | ui.render PromptHint |
| 通知条 | ui.render InfoNotice |
(ctrl+b to run in background) | ui.render ToolProgress |
| 命令输出行 | ui.render CommandOutput |
| 后台任务通知 | ui.render UserMessage{task-notification} |
折叠的工具调用摘要(Read 3 files) | ui.render ToolGroup(自绘) |
轮次结束行(Baked for 3s) | ui.render TurnDuration(自绘中文时长) |
| 页脚模式标签 | ui.render SessionMode(2.1.296 下 modes 恒为空,引擎独占绘制 pill) |
cd mods/cc-zh
# 装配与逻辑(改动后必跑)
claude plugin validate . # 应:13 钩子 + 8 个 $ 调用,Validation passed
claude plugin test . # 应:11 pass / 0 fail
# TTY 实机排版(把「肉眼看看」变成可复算证据)
P=~/.workbuddy/binaries/python/envs/default/bin/python
$P tools/tty-probe.py . /tmp/probe "wait:16,/,wait:9" --cwd "$(git rev-parse --show-toplevel)"
# 达标线:溢出 0 行 + UTF-8 替换字符 0
/cc-zh-dump,拿到仍是英文的命令 / 配置 / 屏幕文案清单;mods/cc-zh/tools/ 的 runtime-sampler 取权威英文原文;dict.json 的 exact(值填中文);带运行时变量的走 patterns;⛔ 不要拿
/cc-zh-dump的屏幕输出当 key —— 渲染器会折行并吞掉反引号, exact 表逐字匹配,错一个字符就静默失配。也不写「大概意思」的中文:命不中一律放行原文。
ToolGroup、TurnDuration 两处);$.fs.read 读一次静态词典;$.store,由 /cc-zh-dump 汇总,词典增量攒,不靠猜。尚未接线的渲染点(未来可扩展,非官方限制):ui.render 共 15 个渲染点,目前钩了 9 个。 未钩:AssistantMessage、ToolUse、ToolResult、AskUserQuestion、AbovePrompt、Pane。 当前可见的英文残留:
Thought for 1s (ctrl+o to expand)(属 AssistantMessage)(0s · thinking with max effort)(Spinner 附注,不在 props.message 里)按官方设计不可汉化(不是缺陷,别去试):权限确认窗口由引擎独占绘制;以下均在 mods 覆盖范围外:
/config、/model 的子选择屏与选项值(auto/dark/plan)auto mode on)Bash(ls)、Interrupted)与运行中的 (12s, 300 tokens)claude --help(CLI 参数层输出,非会话内渲染)claude-code-zh/
├── mods/cc-zh/ # 唯一的汉化产物:自包含 mods 插件
│ ├── .claude-plugin/ # plugin.json(types/ 由 CLI 生成,已忽略)
│ ├── hooks/ # register.tsx(13 个钩子,含 /cc-zh-dump)
│ ├── dict.json # 词典:exact + patterns + spinner + modes
│ ├── tests/ # 运行时测试(11 项)
│ ├── tools/ # 验收与采样工具(非发布物)
│ └── README.md # 覆盖面与设计契约
├── README.md # 本文(项目入口)
├── SOP.md # 日常维护 SOP(补词条 / 验收 / 升级 / 开关)
├── SKILL.md # skill 入口(/claude-code-zh)
├── 00_状态.md # 状态卡(当前版本、边界、下一步)
└── HANDOFF.md # 新会话续作入口
本仓原先是二进制补丁路线(A 线):扫描 bun 编译产物里的 JS 字面量并替换成中文。 Anthropic 自 Claude Code 2.1.24x 起改为 Bun bytecode 编译 + chunk 拆分,UI 文字进入 JSC 字节码,该路线永久失效(上游 taekchef/claude-code-zh-cn 亦在 PR #235 弃坑)。
2026-10-12 本仓重启为 mods 路线:旧路线的 tools/、scripts/、patches/、i18n/ 与 PITFALLS.md 已全部删除;其翻译表资产已完整并入 mods/cc-zh/dict.json(1950 条)。 如需考古,旧文件与旧文档都在 Git 历史里。
MIT。词典底子来自 taekchef/claude-code-zh-cn 翻译表(MIT),本仓独立维护并持续增补。
hooks/register.tsx 106 lines1import type { Register } from 'claude-code'
2
3// 运行时字符串采样插件(**不是发布物**,只在补词条时用)。
4//
5// 为什么需要它:`/cc-zh-dump` 的屏幕输出会被渲染器折行、并**吞掉反引号**
6// (实测 `` `claude doctor` `` / `` `anthropic` `` 的反引号在渲染时消失),
7// 拿它当 exact 的 key 会**静默失配** —— 词典是逐字匹配,错一个字符就永远命中不了。
8// 本插件把引擎在真实会话里给出的原值直接落盘,是 key 的权威来源。
9//
10// 用法:见 tools/README.md。输出固定写到 /tmp/cc-zh-sample/(绝不写仓库或 $HOME)。
11//
12// ⚠️ 三条实测出来的 mod 运行时约束(不遵守则模块加载失败):
13// 1. `$.fs.write` 并发不安全 —— 每个钩子各自 await 会互相截断(实测写坏过 JSON),
14// 须串成一条队列。
15// 2. **禁止把 `$` 传进辅助函数**(编译期报 `$ is passed to ...`)。
16// 3. `on(...)` 的返回值**不得被保留/返回/从嵌套函数取出**,所以钩子只能逐个内联写,
17// 不能用 `const grab = (c) => on('ui.render', { component: c }, ...)` 这类工厂。
18
19const DIR = '/tmp/cc-zh-sample'
20const OUT_ALL = `${DIR}/runtime.json`
21const OUT_DESC = `${DIR}/describe.json`
22const OUT_UI = `${DIR}/ui.json`
23const OUT_ERR = `${DIR}/error.txt`
24
25export const register: Register = on => {
26 const desc: Record<string, unknown> = {}
27 const ui: Record<string, unknown> = {}
28 let queue: Promise<unknown> = Promise.resolve()
29
30 on('session.start', async ($, e, next) => {
31 try {
32 const commands = await $.command.list()
33 const config = await $.config.list()
34 await $.fs.write(OUT_ALL, JSON.stringify({ commands, config }, null, 1))
35 // describe.json / ui.json 由下面的钩子增量覆盖,这里先落一个空壳,
36 // 免得钩子没触发时读到上次的残留。
37 await $.fs.write(OUT_DESC, '{}')
38 await $.fs.write(OUT_UI, '{}')
39 } catch (err: any) {
40 await $.fs.write(OUT_ERR, String(err?.stack ?? err))
41 }
42 return next(e)
43 })
44
45 // 引擎「列命令的那一刻」给出的 description —— 正是 cc-zh 要拿来做 key 的那个值。
46 // 注意:它与 `$.command.list()` 的 description **可能不同**(实测 `/diff` 两处不一样),
47 // 而界面显示的是这里这一个,故本文件才是权威。
48 on('command.describe', async ($, e, next) => {
49 const r = await next(e)
50 desc[e.command] = { description: r.description, argumentHint: r.argumentHint ?? null, isHidden: r.isHidden }
51 const snapshot = JSON.stringify(desc, null, 1)
52 queue = queue.then(() => $.fs.write(OUT_DESC, snapshot)).catch(() => {})
53 await queue
54 return r
55 })
56
57 on('config.describe', async ($, e, next) => {
58 const r = await next(e)
59 desc[`@config:${e.key}`] = { label: r.label, description: r.description }
60 const snapshot = JSON.stringify(desc, null, 1)
61 queue = queue.then(() => $.fs.write(OUT_DESC, snapshot)).catch(() => {})
62 await queue
63 return r
64 })
65
66 on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
67 ui['PromptHint'] = { hint: e.props.hint }
68 const snapshot = JSON.stringify(ui, null, 1)
69 queue = queue.then(() => $.fs.write(OUT_UI, snapshot)).catch(() => {})
70 await queue
71 return next(e)
72 })
73
74 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
75 ui['Spinner'] = { mode: e.props.mode, word: e.props.word, message: e.props.message, surface: e.surface }
76 const snapshot = JSON.stringify(ui, null, 1)
77 queue = queue.then(() => $.fs.write(OUT_UI, snapshot)).catch(() => {})
78 await queue
79 return next(e)
80 })
81
82 on('ui.render', { component: 'InfoNotice' }, async ($, e, next) => {
83 ui['InfoNotice'] = { text: e.props.text }
84 const snapshot = JSON.stringify(ui, null, 1)
85 queue = queue.then(() => $.fs.write(OUT_UI, snapshot)).catch(() => {})
86 await queue
87 return next(e)
88 })
89
90 on('ui.render', { component: 'ToolProgress' }, async ($, e, next) => {
91 ui['ToolProgress'] = { hint: e.props.hint }
92 const snapshot = JSON.stringify(ui, null, 1)
93 queue = queue.then(() => $.fs.write(OUT_UI, snapshot)).catch(() => {})
94 await queue
95 return next(e)
96 })
97
98 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
99 ui['SessionMode'] = { modes: e.props.modes }
100 const snapshot = JSON.stringify(ui, null, 1)
101 queue = queue.then(() => $.fs.write(OUT_UI, snapshot)).catch(() => {})
102 await queue
103 return next(e)
104 })
105}
106