Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 31 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,19 @@ own output, untouched.</sub>

## How it works

Claude Code's native binary embeds its JS bundle as plain text. ccskin finds
the display-related string anchors in that text and performs **byte-length-equal
replacements** (offsets stay valid), then re-signs the binary ad-hoc. Pipeline:
ccskin locates the display-related strings, performs **byte-length-equal
replacements** (offsets stay valid), then re-signs the binary ad-hoc.

Which copy of those strings gets patched depends on how the binary was built,
and the tool detects this automatically:

- **2.1.26x and later** are compiled with `bun build --bytecode`. The JS source
text is still embedded, but the runtime reads the **bytecode constant pool** —
patching the source text passes every check while changing nothing on screen,
so patches go to the pool.
- **Earlier builds** have no bytecode; the JS source text is still patched.

Pipeline:

```
analyze patch sign verify
Expand Down Expand Up @@ -100,9 +110,24 @@ All replacements must be byte-length equal, therefore:
ccskin preview design/octopus.toml # render a design without touching binaries
```

Each slot in a design file holds visible characters; `glyphpack` re-packs them
into the exact escape/literal shape of the original slots. Add your own
`design/*.toml` and point `icon_design` at it.
Each slot in a design file holds visible characters. **Slot boundaries are
detected from the binary** — upstream shifts characters between slots across
versions (2.1.261 grew `r1E` from 5 to 6 columns and shrank `r1R` from 1 to 0)
while the total row width stays the same, so a design only has to get each
*row* width right. Add your own `design/*.toml` and point `icon_design` at it.

Note that identical strings are deduplicated inside the binary, so variants
sharing a slot cannot be given different art (upstream itself shares `r1E`
between `default` and `arms-up`). `analyze` reports such conflicts instead of
silently picking one.

## Version number on bytecode builds

The startup banner and `--version` read the *same* constant, so the displayed
version cannot be faked on its own — it is skipped by default. Set
`rewrite_real_version = true` if you accept that `--version` and the update
check will report the fake version too (it must be the same length as the real
one).

## Updating Claude Code

Expand Down
53 changes: 34 additions & 19 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@ API 上报等行为完全不变。
<sub>实际效果 —— 图标 `design/octopus.toml` · 名字 `Loop Code` · 版本 `v9.99` ·
主题色 `#2E9BFF`。模型那一行及以下都是 Claude Code 自己的输出,未做任何改动。</sub>

原理一句话:Bun 单文件二进制把 JS 源码原样嵌在 Mach-O 里,找到显示相关的
字符串锚点,做**逐字节等长替换**,再 ad-hoc 重签名即可。
原理一句话:找到显示相关的字符串,做**逐字节等长替换**,再 ad-hoc 重签名。

具体改哪份字符串取决于构建方式,工具会自动判定:

- **2.1.26x 起**用 `bun build --bytecode` 编译。二进制里那份 JS 源码文本还在,
但运行时读的是 **bytecode 常量池** —— 改源码文本能通过一切校验而屏幕毫无变化,
所以补丁打到常量池。
- **更早的版本**没有 bytecode,仍改 JS 源码文本。

## 快速开始

Expand Down Expand Up @@ -91,36 +97,45 @@ claude_entitlements.plist # 重签名用的授权文件

## 显示位清单(改了哪些)

| 位置 | 内容 |
|---|---|
| 启动大标题 | `children:"Claude Code"` + 后面的 `["v",变量]` |
| 输入框边框(展开) | `("Claude Code")` 与模板 `` `v${变量}` `` |
| 输入框边框(紧凑) | `(" Claude Code ")` |
| 头部/任务面板标题 | `["Claude Code"," "]` 与 `["v",变量]` |
| 像素图标 | logo 表 4 种动画形态 + 第三行小脚 + N2p 底行 |
| HTML 登录/错误页 | 反引号模板里的 3 行 ASCII logo |
显示位随版本增减,analyze 会如实报告每一处命中与缺失。

**没改的**:`--version` 输出、`/doctor`、更新提示、状态消息等 —— 那些用的
是内部常量或属信息性文本,不属于启动画面。
| 位置 | 2.1.261(bytecode) | 更早的版本(源码文本) |
|---|---|---|
| 启动大标题 | ✅ | ✅ |
| 首次运行欢迎页 | ✅ | — |
| 输入框边框 / 头部标题 | 上游已移除 | ✅ |
| 版本号显示 | ❌ 与 `--version` 共用常量 | ✅ |
| 像素图标(4 种动画形态 + 第三行 + 底行) | ✅ | ✅ |
| HTML 登录/错误页 logo | ✅ | ✅ |
| 主题色 | ✅ | ✅ |

**没改的**:`--version` 输出(除非显式开 `rewrite_real_version`)、`/doctor`、
更新提示、状态消息等 —— 那些用的是内部常量或属信息性文本,不属于启动画面。

## 图标设计指南

`design/octopus.toml` 里每个槽位填"可见字符",工具按二进制里原槽位的
转义(`\uXXXX`,6 字节)/字面(1 字节)形状等长打包。槽位字符数约束:
`design/octopus.toml` 里每个槽位填"可见字符"。**槽位边界由二进制现场探测**
—— 上游会在版本之间挪动字符在槽位间的分配(2.1.261 就把 r1E 从 5 列加到 6 列、
r1R 从 1 列减到 0),但整行列数不变,所以设计稿只要保证**每行总列数**对就行:

- `default` / `look-left` / `look-right`:r1L=2、r1E=5、r1R=1、r2L=2、r2R=2
(第一行 8 列;第二行共 9 列)
- `arms-up`:r1L=2、r1E=5、r1R=2(第一行 9 列)
- `mid.row`=5:第二行机身中部(四种变体共用一个常量,无法按变体区分;
章鱼的眼睛就在这里)
- `default` / `look-left` / `look-right`:第一行 8 列、第二行 9 列
- `arms-up`:第一行 9 列、第二行 9 列
- `mid.row`=5:第二行机身中部(四种变体共用一个常量;章鱼的眼睛在这里)
- `feet.row`=5(第三行)、`n2p.row`=7、`html.row1/2/3`=8/9/7

**去重约束**:相同内容的字符串在二进制里只有一条,被多个变体共用。上游自己
就让 `default` 与 `arms-up` 共用 r1E,所以设计稿不能给它们不同图案 —— 举手
姿势只能靠 r1L/r1R 表现。冲突时 analyze 会直接报错并指出是哪两个槽位。

改完设计稿先 `python3 -m patcher preview your.toml` 看效果,
再跑 analyze 生成新定义。可用字符:任意 BMP 的 `\uXXXX`(方块字符
▟▙▛▜▐▌▄▀█▖▗▘▝、◉●○、╵ 等)+ 槽位字面位上的 ASCII。

## 注意

- **bytecode 构建上版本号改不了**:启动画面的版本与 `--version` 共用同一条
常量,没法只改显示,默认自动跳过。愿意接受 `--version` 和更新检查一起变的话,
配 `rewrite_real_version = true`(新版本号必须与真实版本等长)
- **自动更新会覆盖补丁**:在 claude 设置里关掉 autoUpdates,或设
`DISABLE_AUTOUPDATER=1`;每次升级后重跑本工具即可
- 首次运行补丁后的二进制如被系统拦(Killed: 9):设置 → 隐私与安全性 →
Expand Down
24 changes: 20 additions & 4 deletions SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: ccskin
version: 0.2.0
version: 0.3.0
description: >
给 Claude Code CLI 换肤/换主题/改启动画面:替换像素图标、产品名、版本号显示、
主题色(仅显示层,不动内部逻辑)。当用户提到 claude code 换肤、改主题、改图标、
Expand All @@ -18,11 +18,23 @@ allowed-tools: Bash, Read, Write, Edit, Glob, Grep
通过"等长字节替换 + ad-hoc 重签名"修改 claude 二进制的**显示层**。
内部版本常量、更新检查、API 行为完全不变(`--version` 仍打印真实版本,这也是验证手段)。

## 两条补丁通道(先搞清楚当前二进制走哪条)

- **bytecode 构建**(文件里有 `// @bun @bytecode`,2.1.26x 起):运行时读的是
bytecode 常量池,二进制里那份 JS 源码文本**不驱动 UI**。改源码文本能通过
一切计数与 sha1 校验而屏幕毫无变化 —— 必须打到常量池(`bytecode.py`)。
- **老版本**:仍是源码文本通道。

`discovery.build_patches` 自动判定并切换,`verify` 跟着切。排障先看
`docs/pitfalls.md` 第 0 条。

## 硬性规则(违反会搞坏二进制)

- 任何替换必须逐字节等长;`expected` 计数或 sha1 指纹不符**必须中止**,禁止强行 patch
- 只在用户自己的二进制上操作;不分发二进制;不提交 `config.toml`
- 备份(`<binary>.orig`)已存在时绝不覆盖
- 常量池按内容去重:同一条可能被多处共用。设计稿要求一条同时变成两种图案时
**报错而不是随便挑一个**;版本号与 `--version` 共用一条,默认跳过不改

## 怎么用(按场景)

Expand All @@ -39,7 +51,10 @@ allowed-tools: Bash, Read, Write, Edit, Glob, Grep

3. **改显示内容(名字/版本/颜色/图标)**: 引导用户跑 `skin` 向导;
或编辑 `config.toml`(name ≤11 字符、version ≤5 字符、theme_color 6 位 hex、
icon_design 指向 design/*.toml),然后 `all --apply -y`
icon_design 指向 design/*.toml),然后 `all --apply -y`。
bytecode 构建上版本号改不了(与 `--version` 共用常量),会自动跳过;
用户坚持要改再开 `rewrite_real_version`,并说清 `--version` 和更新检查
都会看到假版本

4. **新图标设计**: 复制 `design/octopus.toml` 改字符,先
`python3 -m patcher preview <新设计.toml>` 看效果,再进 skin 流程。
Expand All @@ -49,9 +64,10 @@ allowed-tools: Bash, Read, Write, Edit, Glob, Grep

## 验证清单(做完必须过)

- `verify` 图标预览形状正确;显示名/版本计数 > 0
- `verify` 图标预览形状正确;显示名在常量池/源码里命中 > 0
- `--version` 输出**真实**版本号(证明内部没被碰)
- `--pty` 抓屏含显示名和版本
- **bytecode 构建必须做 `--pty` 抓屏**:计数类校验证明不了 UI 真的变了,
只有抓屏能。抓屏里看到旧图标/旧名字 = 补丁打错了通道

## 参考文档

Expand Down
7 changes: 6 additions & 1 deletion config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@
# 仅限可打印 ASCII(名字会嵌进二进制内的 JS 源码,非 ASCII 无法等长替换);
# 不足自动用空格居中补齐
name = 'Loop Code'
# 版本号显示。1-5 个字符,不足自动补空格;内部真实版本不受影响
# 版本号显示。1-5 个字符,不足自动补空格;内部真实版本不受影响。
# 注意: bun --bytecode 构建(2.1.26x 起)上启动画面的版本号与 --version
# 共用同一条常量,没法只改显示 —— 这种情况版本位会被自动跳过。
version = 'v9.99'
# 仅 bytecode 构建: 强行改写那条共用的版本常量。
# 代价是 --version 与自动更新检查也会看到假版本,默认关闭。
rewrite_real_version = false
# 图标设计稿(design/ 目录下的文件名)
icon_design = 'octopus.toml'
# 终端主题色(logo/边框/高亮的橙色),6 位 hex;留空 = 保持原橙 #D97757
Expand Down
3 changes: 2 additions & 1 deletion design/octopus.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ r2R = '█▙'

[slots.arms-up]
r1L = '▗ '
r1E = '▗▄▄▄▖'
# r1E 与 default 共用一条常量池条目(上游自己也共用),只能靠 r1L/r1R 表现举手
r1E = '▄▄▄▄▖'
r1R = ' ▖'
r2L = ' ▟'
r2R = '▙ '
Expand Down
34 changes: 30 additions & 4 deletions docs/pitfalls.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,31 @@

每条都附规避方法,工具里已固化的用 → 标注。

## 0. bytecode 构建上改 JS 源码文本是无效的(最大的坑)
2.1.26x 起 claude 用 `bun build --bytecode` 编译。二进制里那份 JS 源码
**文本还在**,但运行时读的是 bytecode 常量池 —— 改源码文本能通过锚点计数、
sha1、`--version` 全部校验,**屏幕上却毫无变化**。
判定:文件里出现 `// @bun @bytecode` 即为 bytecode 构建。
→ `discovery.build_patches` 检测到就整体切到常量池通道(`bytecode.py`),
`verify` 也改成查常量池;老版本(非 bytecode)仍走源码文本通道。

常量池条目布局:
```
<len: u32 LE,高位 0x80000000 置位 = 8-bit(latin1),否则 UTF-16>
<hash: u32 LE(实际 24 位);运行时不校验,改内容不必改它>
<chars>
<pad: 补 0 到 4 字节对齐> ← 不是终止符,当成终止符解析会漏掉一半条目
```

## 0b. 常量池按内容去重,一条可能被多处共用
池里内容相同的字面量只有一条。后果:
- **图标**: 上游自己就让 `default` 与 `arms-up` 共用 r1E,设计稿给它们不同
图案物理上做不到 → `_pool_icon_map` 检测冲突并报错,不静默取其一。
- **版本号**: 启动画面的版本与 `--version` 共用同一条常量,没法只改显示
→ 默认跳过版本位;要强改得显式开 `rewrite_real_version`。
- **名字**: `Claude Code` 是一条,但 `--version` 里的 ` (Claude Code)`
与机器标识 `claude-code` 是**另外的**条目,不受影响。

## 1. 替换必须逐字节等长
Bun 单文件二进制内嵌的是 JS 源码**文本**,任何长度变化都会让后续内容的
偏移整体错位,直接损坏二进制。所有替换(old/new)字节数必须相等。
Expand All @@ -11,10 +36,11 @@ Bun 单文件二进制内嵌的是 JS 源码**文本**,任何长度变化都会
minified bundle 经常整个模块一行,`grep -c` 数的是"含匹配的行数"不是次数。
→ 统计出现次数用 `grep -abo <pat> file | wc -l`(工具内用 `bytes.count`)。

## 3. 二进制里有"假锚点"区
V8 字节码快照/字符串表区也能搜到 `arms-up`、`look-left` 等词,但那不是
JS 源码,**不能改**。辨别方法:上下文不是可读的 JS。
→ `discovery.py` 只认完整结构锚点(`r1L:" \u2590"` + 括号配对),不会误伤。
## 3. 源码文本区与常量池区要分清
同一个字符串在二进制里有两份: 源码文本区(\uXXXX 转义形式)和 bytecode
常量池(UTF-16 / latin1 裸字符)。**bytecode 构建下只有后者驱动 UI**(见 0)。
→ 源码通道只认完整结构锚点(槽位正则 + 括号配对);常量池通道用
`bytecode.find()` 按条目边界精确匹配,短串再用 `region_for()` 限定区间消歧。

## 4. 压缩变量名每个版本都变
`M2p`/`N2p`/`Ida`/`YZy`/`Vxr` 是构建时随机生成的,换版本全变。
Expand Down
36 changes: 28 additions & 8 deletions patcher/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,23 +46,34 @@ def load_design(project_root: pathlib.Path, design_name: str) -> dict:
return tomlmini.loads(f.read())


def _show_preview(patches, name, version, theme_color=None):
def _show_preview(patches, name, version, theme_color=None, design=None,
rewrote_real_version=False):
"""原图标 vs 新图标对照 + 名字/版本/颜色汇总。"""
for p in patches:
if p["name"] == "icon_table" and p.get("art_old") and p.get("art_new"):
if p.get("art_old") and p.get("art_new"):
ui.info(ui.bold("图标对照(左原 / 右新):"))
for old_line, new_line in zip(p["art_old"], p["art_new"]):
ui.info(" " + old_line.ljust(14) + ui.dim("→") + " " + new_line)
break
else:
# 常量池通道逐条替换,没有「整表」可对照,直接渲染设计稿
art = discovery.pool_art(design) if design else None
if art and any(p.get("cat") == "icon" for p in patches):
ui.info(ui.bold("新图标:"))
for line in art:
ui.info(" " + line)
n_name = sum(p["expected"] for p in patches if p.get("cat") == "name")
n_ver = sum(p["expected"] for p in patches if p.get("cat") == "version")
n_color = sum(p["expected"] for p in patches if p.get("cat") == "color")
if n_name:
ui.info(ui.bold("名字:") + " " + ui.arrow('"Claude Code"', f'"{name}"')
+ ui.dim(f"(共 {n_name} 处显示位)"))
if n_ver:
note = ("内部版本常量已被改写,--version 与更新检查同步变化"
if rewrote_real_version else
f"共 {n_ver} 处显示位,内部版本常量不动")
ui.info(ui.bold("版本:") + " " + ui.arrow("真实版本", version)
+ ui.dim(f"(共 {n_ver} 处显示位,内部版本常量不动)"))
+ ui.dim(f"({note})"))
if n_color and theme_color:
hexs = "%02X%02X%02X" % theme_color
ui.info(ui.bold("主题色:") + " " + ui.arrow("品牌橙", f"#{hexs}")
Expand Down Expand Up @@ -107,9 +118,11 @@ def run(binary: str, cfg: dict, project_root: pathlib.Path,
if name != raw_name:
ui.info(f"名字不足 {discovery.NAME_MAX} 字符,已居中补齐: {raw_name!r} → {name!r}")
# real_version 时版本串不会被使用,不做校验
rewrite_version = bool(display.get("rewrite_real_version"))
if not real_version:
try:
discovery.validate_version(version)
discovery.validate_version(
version, max_len=None if rewrite_version else discovery.VERSION_MAX)
except ValueError as e:
ui.fail(f"配置错误: {e}")
return 1
Expand All @@ -118,14 +131,19 @@ def run(binary: str, cfg: dict, project_root: pathlib.Path,
version_arg = None if real_version else version

ui.step("发现锚点")
patches, report, problems = discovery.build_patches(
data, name, version_arg, design, theme_color=theme_color)
probed = probe_version(binary)
patches, report, problems, warnings = discovery.build_patches(
data, name, version_arg, design, theme_color=theme_color,
real_version=probed,
rewrite_real_version=rewrite_version)
for line in report:
ui.ok(line)
for line in warnings:
ui.warn(line)
for line in problems:
ui.fail(line)

label = safe_label(label or probe_version(binary) or "unknown")
label = safe_label(label or probed or "unknown")
if label == "unknown":
ui.warn("--version 探测失败,定义文件将用 unknown 命名")
out = resolve_out(project_root, label, out=out, force=force)
Expand All @@ -152,7 +170,9 @@ def run(binary: str, cfg: dict, project_root: pathlib.Path,
return 2

ui.step("替换预览")
_show_preview(patches, name, version, theme_color)
_show_preview(patches, name, version, theme_color, design,
rewrote_real_version=rewrite_version
and any(p.get('cat') == 'version' for p in patches))

pathlib.Path(out).write_text(defio.dumps(meta, patches), encoding="utf-8")
ui.step("完成")
Expand Down
Loading
Loading