buddy-roll 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 buddy-roll contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # buddy-roll
2
+
3
+ > One-click Claude Code buddy customizer. No binary patching.
4
+
5
+ [中文文档](./README.zh-CN.md)
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ npx buddy-roll
11
+ ```
12
+
13
+ An interactive menu walks you through species, rarity, eyes, hat, and shiny preferences — then brute-forces a matching userID and applies it.
14
+
15
+ ## How It Works
16
+
17
+ Claude Code generates your `/buddy` companion from `hash(identity + salt)` fed into a deterministic PRNG. The identity is your `userID` in `~/.claude.json`.
18
+
19
+ buddy-roll brute-forces random userIDs until one produces the buddy you want, then applies it in four steps:
20
+
21
+ 1. **Backup** your existing `~/.claude.json`
22
+ 2. **Write** the new `userID`
23
+ 3. **Remove** `oauthAccount.accountUuid` (so the custom userID takes priority)
24
+ 4. **Add a shell function** that strips `accountUuid` on each Claude launch
25
+
26
+ ## Commands
27
+
28
+ | Command | Description |
29
+ |---|---|
30
+ | `npx buddy-roll` | Interactive buddy customizer |
31
+ | `npx buddy-roll current` | Show your current buddy info |
32
+ | `npx buddy-roll verify <id>` | Check what buddy a given ID produces |
33
+ | `npx buddy-roll restore` | Restore original config and buddy |
34
+ | `npx buddy-roll help` | Show help |
35
+
36
+ ## Non-Interactive Mode
37
+
38
+ Skip the menus with flags:
39
+
40
+ ```bash
41
+ npx buddy-roll --species dragon --rarity legendary --shiny --dry-run
42
+ ```
43
+
44
+ | Flag | Description |
45
+ |---|---|
46
+ | `--species <name>` | Target species (required for non-interactive) |
47
+ | `--rarity <level>` | Target rarity (common/uncommon/rare/epic/legendary, default: legendary) |
48
+ | `--eye <style>` | Target eye style (`·`, `✦`, `×`, `◉`, `@`, `°`) |
49
+ | `--hat <type>` | Target hat (crown, tophat, propeller, halo, wizard, beanie, tinyduck) |
50
+ | `--shiny` | Require shiny |
51
+ | `--yes, -y` | Skip confirmation prompt (for scripts) |
52
+ | `--max <number>` | Max search attempts (default: 20,000,000) |
53
+ | `--dry-run` | Preview changes without applying |
54
+ | `--lang en\|zh` | Force language |
55
+
56
+ ### Species
57
+
58
+ duck, goose, blob, cat, dragon, octopus, owl, penguin, turtle, snail, ghost, axolotl, capybara, cactus, robot, rabbit, mushroom, chonk
59
+
60
+ ## Supported Platforms
61
+
62
+ - **macOS** — zsh (default), bash (`.bash_profile`)
63
+ - **Linux** — zsh, bash (`.bashrc`)
64
+ - Works with both npm (`npm i -g @anthropic-ai/claude-code`) and native binary installs (auto-detected)
65
+
66
+ ## Limitations
67
+
68
+ - Shell launch alias only works in terminal — IDE extensions launch the binary directly, bypassing the alias
69
+ - If Claude Code updates its salt or hashing algorithm, rerun buddy-roll
70
+ - Windows and fish shell are not yet supported
71
+
72
+ ## Uninstall
73
+
74
+ ```bash
75
+ npx buddy-roll restore
76
+ ```
77
+
78
+ This restores your original `~/.claude.json` and removes the shell function from your rc file.
79
+
80
+ ## License
81
+
82
+ MIT
@@ -0,0 +1,82 @@
1
+ # buddy-roll
2
+
3
+ > Claude Code Buddy 定制工具。一键定制,无需修改二进制文件。
4
+
5
+ [English](./README.md)
6
+
7
+ ## 快速开始
8
+
9
+ ```bash
10
+ npx buddy-roll
11
+ ```
12
+
13
+ 交互式菜单引导你选择物种、稀有度、眼睛、帽子和闪光偏好,然后暴力搜索匹配的 userID 并应用。
14
+
15
+ ## 工作原理
16
+
17
+ Claude Code 通过 `hash(identity + salt)` 输入确定性 PRNG 来生成 `/buddy` 伙伴。identity 就是 `~/.claude.json` 中的 `userID`。
18
+
19
+ buddy-roll 暴力搜索随机 userID,直到找到能生成你想要的宠物的那个,然后分四步应用:
20
+
21
+ 1. **备份** 现有的 `~/.claude.json`
22
+ 2. **写入** 新的 `userID`
23
+ 3. **移除** `oauthAccount.accountUuid`(让自定义 userID 优先生效)
24
+ 4. **添加 shell 函数**,每次启动 Claude 时自动清除 `accountUuid`
25
+
26
+ ## 命令
27
+
28
+ | 命令 | 说明 |
29
+ |---|---|
30
+ | `npx buddy-roll` | 交互式宠物定制 |
31
+ | `npx buddy-roll current` | 查看当前宠物信息 |
32
+ | `npx buddy-roll verify <id>` | 查看某个 ID 生成的宠物 |
33
+ | `npx buddy-roll restore` | 恢复原始配置和宠物 |
34
+ | `npx buddy-roll help` | 显示帮助 |
35
+
36
+ ## 非交互模式
37
+
38
+ 使用命令行参数跳过菜单:
39
+
40
+ ```bash
41
+ npx buddy-roll --species dragon --rarity legendary --shiny --dry-run
42
+ ```
43
+
44
+ | 参数 | 说明 |
45
+ |---|---|
46
+ | `--species <name>` | 目标物种(非交互模式必填) |
47
+ | `--rarity <level>` | 目标稀有度(common/uncommon/rare/epic/legendary,默认:legendary) |
48
+ | `--eye <style>` | 目标眼睛样式(`·`, `✦`, `×`, `◉`, `@`, `°`) |
49
+ | `--hat <type>` | 目标帽子(crown, tophat, propeller, halo, wizard, beanie, tinyduck) |
50
+ | `--shiny` | 要求闪光 |
51
+ | `--yes, -y` | 跳过确认提示(用于脚本) |
52
+ | `--max <number>` | 最大搜索次数(默认:20,000,000) |
53
+ | `--dry-run` | 预览变更,不实际修改 |
54
+ | `--lang en\|zh` | 强制指定语言 |
55
+
56
+ ### 物种列表
57
+
58
+ duck, goose, blob, cat, dragon, octopus, owl, penguin, turtle, snail, ghost, axolotl, capybara, cactus, robot, rabbit, mushroom, chonk
59
+
60
+ ## 支持平台
61
+
62
+ - **macOS** — zsh(默认)、bash(`.bash_profile`)
63
+ - **Linux** — zsh、bash(`.bashrc`)
64
+ - 支持 npm 安装和原生二进制安装(自动检测)
65
+
66
+ ## 限制
67
+
68
+ - 启动别名仅在终端生效 — IDE 扩展直接调用二进制,绕过别名
69
+ - 如果 Claude Code 更新了 salt 或哈希算法,需要重新运行 buddy-roll
70
+ - 暂不支持 Windows 和 fish shell
71
+
72
+ ## 卸载
73
+
74
+ ```bash
75
+ npx buddy-roll restore
76
+ ```
77
+
78
+ 恢复原始 `~/.claude.json` 并从 rc 文件中移除 shell 函数。
79
+
80
+ ## 许可证
81
+
82
+ MIT
@@ -0,0 +1,1060 @@
1
+ #!/usr/bin/env node
2
+ // buddy-roll — One-click Claude Code buddy customizer. No binary patching.
3
+
4
+ import { randomBytes } from "node:crypto";
5
+ import { readFileSync, writeFileSync, renameSync, existsSync, copyFileSync, unlinkSync, chmodSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { homedir } from "node:os";
8
+ import { createInterface } from "node:readline";
9
+ import { execSync } from "node:child_process";
10
+
11
+ // ── Constants ────────────────────────────────────────────
12
+
13
+ const SALT = "friend-2026-401";
14
+
15
+ const SPECIES = [
16
+ "duck", "goose", "blob", "cat", "dragon", "octopus", "owl", "penguin",
17
+ "turtle", "snail", "ghost", "axolotl", "capybara", "cactus", "robot",
18
+ "rabbit", "mushroom", "chonk",
19
+ ];
20
+
21
+ const RARITIES = ["common", "uncommon", "rare", "epic", "legendary"];
22
+
23
+ const RARITY_WEIGHTS = { common: 60, uncommon: 25, rare: 10, epic: 4, legendary: 1 };
24
+ const RARITY_RANK = { common: 0, uncommon: 1, rare: 2, epic: 3, legendary: 4 };
25
+ const RARITY_STARS = { common: "★", uncommon: "★★", rare: "★★★", epic: "★★★★", legendary: "★★★★★" };
26
+ const RARITY_FLOOR = { common: 5, uncommon: 15, rare: 25, epic: 35, legendary: 50 };
27
+
28
+ const EYES = ["·", "✦", "×", "◉", "@", "°"];
29
+ const HATS = ["none", "crown", "tophat", "propeller", "halo", "wizard", "beanie", "tinyduck"];
30
+ const STAT_NAMES = ["DEBUGGING", "PATIENCE", "CHAOS", "WISDOM", "SNARK"];
31
+
32
+ const CONFIG_PATH = join(homedir(), ".claude.json");
33
+ const BACKUP_PATH = join(homedir(), ".claude.json.buddy-roll-backup");
34
+ const STATE_PATH = join(homedir(), ".buddy-roll-state.json");
35
+
36
+ const ALIAS_START = "# >>> buddy-roll-alias start >>>";
37
+ const ALIAS_END = "# <<< buddy-roll-alias end <<<";
38
+
39
+ const NO_COLOR = !!(process.env.NO_COLOR || !process.stdout.isTTY);
40
+ const c = {
41
+ reset: NO_COLOR ? "" : "\x1b[0m\x1b[39m",
42
+ bold: NO_COLOR ? "" : "\x1b[1m",
43
+ dim: NO_COLOR ? "" : "\x1b[2m",
44
+ green: NO_COLOR ? "" : "\x1b[32m",
45
+ yellow: NO_COLOR ? "" : "\x1b[33m",
46
+ red: NO_COLOR ? "" : "\x1b[31m",
47
+ cyan: NO_COLOR ? "" : "\x1b[36m",
48
+ magenta: NO_COLOR ? "" : "\x1b[35m",
49
+ };
50
+
51
+ // ── i18n ─────────────────────────────────────────────────
52
+
53
+ let LANG = /^zh/i.test(process.env.LC_ALL || process.env.LANG || "") ? "zh" : "en";
54
+
55
+ const STRINGS = {
56
+ title: { en: "buddy-roll — Claude Code Buddy Customizer", zh: "buddy-roll — Claude Code 宠物定制器" },
57
+ detected: { en: "Detected: %s install (%s mode)", zh: "检测到:%s 安装(%s 模式)" },
58
+ currentBuddy: { en: "Current buddy: %s %s — %s「%s」", zh: "当前宠物:%s %s — %s「%s」" },
59
+ invalidInput: { en: "Please enter a number 1-%s", zh: "请输入 1-%s 之间的数字" },
60
+ noBuddy: { en: "No buddy hatched yet.", zh: "尚未孵化宠物。" },
61
+ selectSpecies: { en: "Select species:", zh: "选择物种:" },
62
+ selectRarity: { en: "Select target rarity:", zh: "选择目标稀有度:" },
63
+ selectEye: { en: "Select eye:", zh: "选择眼睛:" },
64
+ selectHat: { en: "Select hat:", zh: "选择帽子:" },
65
+ requireShiny: { en: "Require shiny? (y to require, enter to skip)", zh: "要求闪光?(y 指定, 回车跳过)" },
66
+ skipHint: { en: ", enter to skip", zh: ", 回车跳过" },
67
+ searching: { en: "Searching for %s %s...", zh: "正在搜索 %s %s..." },
68
+ found: { en: "✅ found: %s %s → %s", zh: "✅ 找到:%s %s → %s" },
69
+ noMatch: { en: "No match found in %s attempts. Try --max to increase.", zh: "在 %s 次尝试中未找到匹配。试试 --max 增加次数。" },
70
+ searchStats: { en: "🔍 %s attempts, %ss", zh: "🔍 %s 次尝试,耗时 %s 秒" },
71
+ applyAsk: { en: "(n to quit / r to retry / enter to view apply details)", zh: "(n 退出 / r 重新搜索 / 回车查看应用详情)" },
72
+ applyConfirm: { en: "Confirm apply? (y/N)", zh: "确认执行?(y/N)" },
73
+ backupCreated: { en: "Config backed up → %s", zh: "配置已备份 → %s" },
74
+ userIdWritten: { en: "userID written", zh: "userID 已写入" },
75
+ uuidRemoved: { en: "accountUuid removed", zh: "accountUuid 已清除" },
76
+ companionCleared:{ en: "companion cleared (will re-hatch on restart)", zh: "companion 已清除(重启后重新孵化)" },
77
+ aliasAdded: { en: "Launch alias added → %s", zh: "启动别名已添加 → %s" },
78
+ aliasSkipped: { en: "No oauthAccount — launch alias not needed", zh: "无 oauthAccount — 不需要启动别名" },
79
+ done: { en: "Done! Restart Claude Code and run /buddy.", zh: "完成!重启 Claude Code 并运行 /buddy。" },
80
+ undoHint: { en: "To undo: npx buddy-roll restore", zh: "撤销:npx buddy-roll restore" },
81
+ restoring: { en: "Restoring original configuration...", zh: "正在恢复原始配置..." },
82
+ restoreDetail: { en: "Will restore the following to ~/.claude.json:", zh: "即将恢复以下字段到 ~/.claude.json:" },
83
+ restored: { en: "Original config restored", zh: "原始配置已恢复" },
84
+ aliasRemoved: { en: "Launch alias removed from %s", zh: "启动别名已从 %s 移除" },
85
+ restoreDone: { en: "Done! Restart Claude Code — your original buddy is back.", zh: "完成!重启 Claude Code — 你的原始宠物回来了。" },
86
+ noBackup: { en: "No backup found. Nothing to restore.", zh: "未找到备份。无需恢复。" },
87
+ configNotFound: { en: "Claude Code config not found. Is Claude Code installed?", zh: "未找到 Claude Code 配置文件。是否已安装 Claude Code?" },
88
+ backupExists: { en: "A previous buddy-roll backup exists. Overwrite?", zh: "已存在 buddy-roll 备份。覆盖?" },
89
+ unsupportedShell:{ en: "Unsupported shell. Skipping alias. Add manually:", zh: "不支持的 shell。跳过 alias 配置。手动添加:" },
90
+ existingAlias: { en: "Existing 'claude' alias detected. buddy-roll will wrap it with `command claude`.", zh: "检测到已有 'claude' alias。buddy-roll 将使用 `command claude` 包装。" },
91
+ installType: { en: "Claude Code: %s install (%s)", zh: "Claude Code:%s 安装(%s)" },
92
+ activeId: { en: "Buddy identity: %s", zh: "Buddy 身份:%s" },
93
+ stateActive: { en: "buddy-roll state: active (backup from %s)", zh: "buddy-roll 状态:已激活(备份于 %s)" },
94
+ stateInactive: { en: "buddy-roll state: not active", zh: "buddy-roll 状态:未激活" },
95
+ yesNo: { en: " (y/n) ", zh: "(y/n)" },
96
+ yes: { en: "y", zh: "y" },
97
+ anyCombo: { en: "No, any combination", zh: "不,任意组合" },
98
+ yesCustomize: { en: "Yes, let me pick", zh: "是,让我选择" },
99
+ dryRun: { en: "[DRY RUN] No changes made.", zh: "[试运行] 未做任何更改。" },
100
+ dryRunBanner: { en: "⚠ DRY RUN MODE — no files will be modified", zh: "⚠ 试运行模式 — 不会修改任何文件" },
101
+ noUserID: { en: "No userID found in ~/.claude.json. Start Claude Code at least once first.", zh: "~/.claude.json 中未找到 userID。请先启动一次 Claude Code。" },
102
+ target: { en: "Target", zh: "目标" },
103
+ willModify: { en: "The following files will be modified", zh: "即将修改以下文件" },
104
+ modifyUserID: { en: "modify userID", zh: "修改 userID" },
105
+ removeUuid: { en: "remove oauthAccount.accountUuid", zh: "移除 oauthAccount.accountUuid" },
106
+ removeCompanion: { en: "remove companion (will re-hatch on restart)", zh: "移除 companion(重启后重新孵化)" },
107
+ addAlias: { en: "add claude launch alias (auto-clears accountUuid on each start)", zh: "添加 claude 启动别名(每次启动前自动清除 accountUuid)" },
108
+ backupFiles: { en: "Backup files", zh: "备份文件" },
109
+ backupConfigDesc:{ en: "(Claude Code config backup)", zh: "(Claude Code 配置备份)" },
110
+ backupStateDesc: { en: "(modified fields backup, for restoring original buddy)", zh: "(修改字段备份,用于恢复原有的 Buddy)" },
111
+ restoreHint: { en: "Restore", zh: "恢复方式" },
112
+ dryRunHintRemove:{ en: "Remove --dry-run to apply for real", zh: "实际执行请去掉 --dry-run 参数" },
113
+ willModifyRc: { en: "Will modify", zh: "即将修改" },
114
+ removeAliasBrief:{ en: "(remove claude launch alias)", zh: "(移除 claude 启动别名)" },
115
+ willDelete: { en: "Will delete", zh: "即将删除" },
116
+ fieldsRestored: { en: "accountUuid / userID / companion restored", zh: "accountUuid / userID / companion 已恢复" },
117
+ statesCleaned: { en: "State files cleaned up", zh: "状态文件已清理" },
118
+ speciesRequired: { en: "--species is required for non-interactive mode", zh: "非交互模式需要 --species 参数" },
119
+ verifyNeedsId: { en: "verify requires an ID argument", zh: "verify 需要一个 ID 参数" },
120
+ };
121
+
122
+ function t(key, ...args) {
123
+ let s = STRINGS[key]?.[LANG] || STRINGS[key]?.en || key;
124
+ args.forEach((a, i) => { s = s.replace("%s", a); });
125
+ return s;
126
+ }
127
+
128
+ // ── Hash ─────────────────────────────────────────────────
129
+
130
+ function fnv1a(s) {
131
+ let h = 2166136261;
132
+ for (let i = 0; i < s.length; i++) {
133
+ h ^= s.charCodeAt(i);
134
+ h = Math.imul(h, 16777619);
135
+ }
136
+ return h >>> 0;
137
+ }
138
+
139
+ const M64 = (1n << 64n) - 1n;
140
+ const WYP = [0xa0761d6478bd642fn, 0xe7037ed1a0b428dbn, 0x8ebc6af09c88c6e3n, 0x589965cc75374cc3n];
141
+ function _mx(A, B) { const r = (A & M64) * (B & M64); return ((r >> 64n) ^ r) & M64; }
142
+ function _r8(p, i) { return BigInt(p[i]) | (BigInt(p[i+1]) << 8n) | (BigInt(p[i+2]) << 16n) | (BigInt(p[i+3]) << 24n) | (BigInt(p[i+4]) << 32n) | (BigInt(p[i+5]) << 40n) | (BigInt(p[i+6]) << 48n) | (BigInt(p[i+7]) << 56n); }
143
+ function _r4(p, i) { return BigInt(p[i]) | (BigInt(p[i+1]) << 8n) | (BigInt(p[i+2]) << 16n) | (BigInt(p[i+3]) << 24n); }
144
+ function _r3(p, i, k) { return (BigInt(p[i]) << 16n) | (BigInt(p[i + (k >> 1)]) << 8n) | BigInt(p[i + k - 1]); }
145
+
146
+ function wyhash(key, seed = 0n) {
147
+ const len = key.length;
148
+ seed = (seed ^ _mx(seed ^ WYP[0], WYP[1])) & M64;
149
+ let a, b;
150
+ if (len <= 16) {
151
+ if (len >= 4) {
152
+ a = ((_r4(key, 0) << 32n) | _r4(key, ((len >> 3) << 2))) & M64;
153
+ b = ((_r4(key, len - 4) << 32n) | _r4(key, len - 4 - ((len >> 3) << 2))) & M64;
154
+ } else if (len > 0) { a = _r3(key, 0, len); b = 0n; }
155
+ else { a = 0n; b = 0n; }
156
+ } else {
157
+ let i = len, p = 0;
158
+ if (i > 48) {
159
+ let s1 = seed, s2 = seed;
160
+ do {
161
+ seed = _mx(_r8(key, p) ^ WYP[1], _r8(key, p + 8) ^ seed);
162
+ s1 = _mx(_r8(key, p + 16) ^ WYP[2], _r8(key, p + 24) ^ s1);
163
+ s2 = _mx(_r8(key, p + 32) ^ WYP[3], _r8(key, p + 40) ^ s2);
164
+ p += 48; i -= 48;
165
+ } while (i > 48);
166
+ seed = (seed ^ s1 ^ s2) & M64;
167
+ }
168
+ while (i > 16) { seed = _mx(_r8(key, p) ^ WYP[1], _r8(key, p + 8) ^ seed); i -= 16; p += 16; }
169
+ a = _r8(key, p + i - 16);
170
+ b = _r8(key, p + i - 8);
171
+ }
172
+ a = (a ^ WYP[1]) & M64; b = (b ^ seed) & M64;
173
+ const r = (a & M64) * (b & M64);
174
+ a = r & M64; b = (r >> 64n) & M64;
175
+ return _mx((a ^ WYP[0] ^ BigInt(len)) & M64, (b ^ WYP[1]) & M64);
176
+ }
177
+
178
+ function wyhash32(s) {
179
+ return Number(wyhash(Buffer.from(s, "utf8")) & 0xffffffffn);
180
+ }
181
+
182
+ function detectInstallType() {
183
+ // Check actual binary first
184
+ try {
185
+ const claudePath = execSync("which claude", { encoding: "utf8" }).trim();
186
+ const fileType = execSync(`file "${claudePath}"`, { encoding: "utf8" });
187
+ if (/Mach-O|ELF/.test(fileType)) return "native";
188
+ if (/text|script|node/.test(fileType)) return "npm";
189
+ } catch {}
190
+ // Fallback to config
191
+ try {
192
+ const config = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
193
+ if (config.installMethod === "native") return "native";
194
+ } catch {}
195
+ return "npm";
196
+ }
197
+
198
+ function hashFor(installType) {
199
+ return installType === "native" ? wyhash32 : fnv1a;
200
+ }
201
+
202
+ // ── PRNG ─────────────────────────────────────────────────
203
+
204
+ function mulberry32(seed) {
205
+ let a = seed >>> 0;
206
+ return function () {
207
+ a |= 0;
208
+ a = (a + 0x6d2b79f5) | 0;
209
+ let t = Math.imul(a ^ (a >>> 15), 1 | a);
210
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
211
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
212
+ };
213
+ }
214
+
215
+ // ── Roll ─────────────────────────────────────────────────
216
+
217
+ function pick(rng, arr) {
218
+ return arr[Math.floor(rng() * arr.length)];
219
+ }
220
+
221
+ function rollRarity(rng) {
222
+ const total = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0);
223
+ let roll = rng() * total;
224
+ for (const r of RARITIES) {
225
+ roll -= RARITY_WEIGHTS[r];
226
+ if (roll < 0) return r;
227
+ }
228
+ return "common";
229
+ }
230
+
231
+ function rollStats(rng, rarity) {
232
+ const floor = RARITY_FLOOR[rarity];
233
+ const peak = pick(rng, STAT_NAMES);
234
+ let dump = pick(rng, STAT_NAMES);
235
+ while (dump === peak) dump = pick(rng, STAT_NAMES);
236
+ const stats = {};
237
+ for (const name of STAT_NAMES) {
238
+ if (name === peak) stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30));
239
+ else if (name === dump) stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15));
240
+ else stats[name] = floor + Math.floor(rng() * 40);
241
+ }
242
+ return stats;
243
+ }
244
+
245
+ function fullRoll(id, hashFn) {
246
+ const rng = mulberry32(hashFn(id + SALT));
247
+ const rarity = rollRarity(rng);
248
+ const species = pick(rng, SPECIES);
249
+ const eye = pick(rng, EYES);
250
+ const hat = rarity === "common" ? "none" : pick(rng, HATS);
251
+ const shiny = rng() < 0.01;
252
+ const stats = rollStats(rng, rarity);
253
+ return { rarity, species, eye, hat, shiny, stats };
254
+ }
255
+
256
+ function formatBuddy(b) {
257
+ const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
258
+ const rarityColor = NO_COLOR ? "" : (RARITY_ANSI[b.rarity] || "");
259
+ const R = NO_COLOR ? "" : "\x1b[0m\x1b[39m";
260
+ const lines = [];
261
+ lines.push(`${rarityColor}${RARITY_STARS[b.rarity]} ${cap(b.rarity)}${R} — ${cap(b.species)}`);
262
+ if (b.shiny) lines.push(`${c.yellow}✨ SHINY ✨${R}`);
263
+ const sprite = renderSprite(b.species, b.eye, b.hat, b.rarity);
264
+ if (sprite) {
265
+ const spriteLines = sprite.split("\n");
266
+ const trimmed = (b.hat && b.hat !== "none") ? spriteLines : spriteLines.filter((l, i) => i > 0 || l.trim());
267
+ lines.push(trimmed.join("\n"));
268
+ }
269
+ lines.push(`${c.dim}Eye: ${b.eye} Hat: ${b.hat}${R}`);
270
+ for (const s of STAT_NAMES) {
271
+ lines.push(`${c.dim}${formatStatBar(s, b.stats[s])}${R}`);
272
+ }
273
+ return lines.join("\n");
274
+ }
275
+
276
+ // ── Sprites ──────────────────────────────────────────────
277
+
278
+ // Frame 0 (rest pose) for each species. 5 lines × 12 chars. {E} = eye placeholder.
279
+ const SPRITE_FRAMES = {
280
+ duck: [' ',' __ ',' <({E} )___ ',' ( ._> ',' `--´ '],
281
+ goose: [' ',' ({E}> ',' || ',' _(__)_ ',' ^^^^ '],
282
+ blob: [' ',' .----. ',' ( {E} {E} ) ',' ( ) ',' `----´ '],
283
+ cat: [' ',' /\\_/\\ ',' ( {E} {E}) ',' ( ω ) ',' (")_(") '],
284
+ dragon: [' ',' /^\\ /^\\ ',' < {E} {E} > ',' ( ~~ ) ',' `-vvvv-´ '],
285
+ octopus: [' ',' .----. ',' ( {E} {E} ) ',' (______) ',' /\\/\\/\\/\\ '],
286
+ owl: [' ',' /\\ /\\ ',' (({E})({E})) ',' ( >< ) ',' `----´ '],
287
+ penguin: [' ',' .---. ',' ({E}>{E}) ',' /( )\\ ',' `---´ '],
288
+ turtle: [' ',' _,--._ ',' ( {E} {E} ) ',' /[______]\\ ',' `` `` '],
289
+ snail: [' ',' {E} .--. ',' \\ ( @ ) ',' \\_`--´ ',' ~~~~~~~ '],
290
+ ghost: [' ',' .----. ',' / {E} {E} \\ ',' | | ',' ~`~``~`~ '],
291
+ axolotl: [' ','}~(______)~{','}~({E} .. {E})~{',' ( .--. ) ',' (_/ \\_) '],
292
+ capybara: [' ',' n______n ',' ( {E} {E} ) ',' ( oo ) ',' `------´ '],
293
+ cactus: [' ',' n ____ n ',' | |{E} {E}| | ',' |_| |_| ',' | | '],
294
+ robot: [' ',' .[||]. ',' [ {E} {E} ] ',' [ ==== ] ',' `------´ '],
295
+ rabbit: [' ',' (\\__/) ',' ( {E} {E} ) ',' =( .. )= ',' (")__(") '],
296
+ mushroom: [' ',' .-o-OO-o-. ','(__________)',' |{E} {E}| ',' |____| '],
297
+ chonk: [' ',' /\\ /\\ ',' ( {E} {E} ) ',' ( .. ) ',' `------´ '],
298
+ };
299
+
300
+ const HAT_LINES = {
301
+ none: '',
302
+ crown: ' \\^^^/ ',
303
+ tophat: ' [___] ',
304
+ propeller:' -+- ',
305
+ halo: ' ( ) ',
306
+ wizard: ' /^\\ ',
307
+ beanie: ' (___) ',
308
+ tinyduck: ' ,> ',
309
+ };
310
+
311
+ const RARITY_ANSI = {
312
+ common: "\x1b[90m", // gray
313
+ uncommon: "\x1b[32m", // green
314
+ rare: "\x1b[38;5;75m", // #58a6ff blue
315
+ epic: "\x1b[38;5;141m", // #bc8cff purple
316
+ legendary: "\x1b[38;5;208m", // #f0883e orange
317
+ };
318
+
319
+ function renderSprite(species, eye, hat, rarity) {
320
+ const frame = SPRITE_FRAMES[species];
321
+ if (!frame) return "";
322
+ const color = NO_COLOR ? "" : (RARITY_ANSI[rarity] || "");
323
+ const R = NO_COLOR ? "" : "\x1b[0m\x1b[39m";
324
+ const lines = frame.map((line, i) => {
325
+ let result = line.replaceAll("{E}", eye);
326
+ if (i === 0 && hat && hat !== "none" && HAT_LINES[hat] && !result.trim()) {
327
+ result = HAT_LINES[hat];
328
+ }
329
+ return ` ${color}${result}${R}`;
330
+ });
331
+ return lines.join("\n");
332
+ }
333
+
334
+ function formatStatBar(name, value) {
335
+ const barLen = 10;
336
+ const filled = value > 0 ? Math.max(1, Math.round(value / 100 * barLen)) : 0;
337
+ const bar = "█".repeat(filled) + "░".repeat(barLen - filled);
338
+ return `${name.padEnd(9)} ${bar} ${String(value).padStart(3)}`;
339
+ }
340
+
341
+ function formatBuddyCard(b) {
342
+ const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
343
+ const rarityColor = NO_COLOR ? "" : (RARITY_ANSI[b.rarity] || "");
344
+ const R = NO_COLOR ? "" : "\x1b[0m\x1b[39m";
345
+ const lines = [];
346
+ lines.push(`${rarityColor}${RARITY_STARS[b.rarity]} ${cap(b.rarity)}${R} — ${cap(b.species)}`);
347
+ if (b.shiny) lines.push(`${c.yellow}✨ SHINY ✨${R}`);
348
+
349
+ const sprite = renderSprite(b.species, b.eye, b.hat, b.rarity);
350
+ if (sprite) {
351
+ const spriteLines = sprite.split("\n");
352
+ const trimmed = (b.hat && b.hat !== "none") ? spriteLines : spriteLines.filter((l, i) => i > 0 || l.trim());
353
+ lines.push(trimmed.join("\n"));
354
+ }
355
+
356
+ if (b.name) lines.push(`${c.bold}${b.name}${R}`);
357
+ if (b.personality) {
358
+ const maxW = 30;
359
+ const words = b.personality.split(" ");
360
+ let line = "";
361
+ const wrapped = [];
362
+ for (const w of words) {
363
+ if (line && (line + " " + w).length > maxW) { wrapped.push(line); line = w; }
364
+ else { line = line ? line + " " + w : w; }
365
+ }
366
+ if (line) wrapped.push(line);
367
+ lines.push(`${c.dim}"${wrapped.join(`\n`)}"${R}`);
368
+ lines.push("");
369
+ }
370
+
371
+ for (const s of STAT_NAMES) {
372
+ lines.push(`${c.dim}${formatStatBar(s, b.stats[s])}${R}`);
373
+ }
374
+
375
+ return lines.join("\n");
376
+ }
377
+
378
+ // ── Config ───────────────────────────────────────────────
379
+
380
+ function readConfig() {
381
+ if (!existsSync(CONFIG_PATH)) return null;
382
+ return JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
383
+ }
384
+
385
+ function requireUserID(config) {
386
+ if (!config.userID) {
387
+ console.error(`${c.red}✗${c.reset} ${t("noUserID")}`);
388
+ process.exit(1);
389
+ }
390
+ }
391
+
392
+ function writeConfigAtomic(config) {
393
+ const tmp = CONFIG_PATH + ".buddy-roll-tmp";
394
+ writeFileSync(tmp, JSON.stringify(config, null, 2), { mode: 0o600 });
395
+ renameSync(tmp, CONFIG_PATH);
396
+ }
397
+
398
+ function getCurrentBuddy(config, installType) {
399
+ if (!config) return null;
400
+ const uuid = config.oauthAccount?.accountUuid;
401
+ const userId = config.userID;
402
+ const activeId = uuid ?? userId ?? "anon";
403
+ const hashFn = hashFor(installType);
404
+ const bones = fullRoll(activeId, hashFn);
405
+ const soul = config.companion || null;
406
+ return { ...bones, name: soul?.name, personality: soul?.personality, activeId, source: uuid ? "accountUuid" : "userID" };
407
+ }
408
+
409
+ function backupConfig(config) {
410
+ copyFileSync(CONFIG_PATH, BACKUP_PATH);
411
+ chmodSync(BACKUP_PATH, 0o600);
412
+ const state = {
413
+ backupTime: new Date().toISOString(),
414
+ originalAccountUuid: config.oauthAccount?.accountUuid || null,
415
+ originalUserID: config.userID || null,
416
+ originalCompanion: config.companion || null,
417
+ salt: SALT,
418
+ installType: detectInstallType(),
419
+ };
420
+ writeFileSync(STATE_PATH, JSON.stringify(state, null, 2), { mode: 0o600 });
421
+ }
422
+
423
+ function applyBuddy(newUserID, dryRun) {
424
+ const config = readConfig();
425
+ if (!config) { console.error(`${c.red}✗${c.reset} ${t("configNotFound")}`); process.exit(1); }
426
+
427
+ if (dryRun) {
428
+ console.log(`\n${c.yellow}${t("dryRun")}${c.reset}`);
429
+ console.log(` userID: ${config.userID || "(none)"} → ${newUserID}`);
430
+ if (config.oauthAccount?.accountUuid) console.log(` accountUuid: ${config.oauthAccount.accountUuid} → (deleted)`);
431
+ console.log(` companion: → (deleted for re-hatch)`);
432
+ return;
433
+ }
434
+
435
+ if (existsSync(STATE_PATH)) {
436
+ // Already have a backup — don't overwrite the original
437
+ } else {
438
+ backupConfig(config);
439
+ console.log(`${c.green}✓${c.reset} ${t("backupCreated", "~/.claude.json.buddy-roll-backup")}`);
440
+ }
441
+
442
+ config.userID = newUserID;
443
+ console.log(`${c.green}✓${c.reset} ${t("userIdWritten")}`);
444
+ if (config.oauthAccount?.accountUuid) {
445
+ delete config.oauthAccount.accountUuid;
446
+ console.log(`${c.green}✓${c.reset} ${t("uuidRemoved")}`);
447
+ }
448
+ if (config.companion) {
449
+ delete config.companion;
450
+ console.log(`${c.green}✓${c.reset} ${t("companionCleared")}`);
451
+ }
452
+ writeConfigAtomic(config);
453
+ }
454
+
455
+ function restoreConfig() {
456
+ if (!existsSync(STATE_PATH)) {
457
+ console.error(`${c.red}✗${c.reset} ${t("noBackup")}`);
458
+ process.exit(1);
459
+ }
460
+ const state = JSON.parse(readFileSync(STATE_PATH, "utf8"));
461
+ const config = readConfig();
462
+ if (!config) { console.error(`${c.red}✗${c.reset} ${t("configNotFound")}`); process.exit(1); }
463
+
464
+ console.log(`${c.bold}${t("restoreDetail")}${c.reset}`);
465
+ if (state.originalAccountUuid) console.log(` - oauthAccount.accountUuid → ${state.originalAccountUuid.slice(0, 8)}...`);
466
+ if (state.originalUserID) console.log(` - userID → ${state.originalUserID.slice(0, 16)}...`);
467
+ if (state.originalCompanion) console.log(` - companion → ${state.originalCompanion.name}`);
468
+ const shell = detectShell();
469
+ if (shell) console.log(`${c.bold}${t("willModifyRc")}:${c.reset}\n - ${shell.rcFile.replace(homedir(), "~")} ${t("removeAliasBrief")}`);
470
+ console.log(`${c.bold}${t("willDelete")}:${c.reset}\n - ~/.buddy-roll-state.json\n - ~/.claude.json.buddy-roll-backup`);
471
+ console.log("");
472
+
473
+ if (state.originalAccountUuid && config.oauthAccount) {
474
+ config.oauthAccount.accountUuid = state.originalAccountUuid;
475
+ }
476
+ if (state.originalUserID) config.userID = state.originalUserID;
477
+ if (state.originalCompanion) config.companion = state.originalCompanion;
478
+ else delete config.companion;
479
+ writeConfigAtomic(config);
480
+ console.log(`${c.green}✓${c.reset} ${t("fieldsRestored")}`);
481
+
482
+ if (shell) {
483
+ removeAlias(shell.rcFile);
484
+ console.log(`${c.green}✓${c.reset} ${t("aliasRemoved", shell.rcFile.replace(homedir(), "~"))}`);
485
+ }
486
+
487
+ unlinkSync(STATE_PATH);
488
+ if (existsSync(BACKUP_PATH)) unlinkSync(BACKUP_PATH);
489
+ console.log(`${c.green}✓${c.reset} ${t("statesCleaned")}`);
490
+ }
491
+
492
+ // ── Alias ────────────────────────────────────────────────
493
+
494
+ function detectShell() {
495
+ const shell = process.env.SHELL || "";
496
+ if (shell.endsWith("/zsh")) return { name: "zsh", rcFile: join(homedir(), ".zshrc") };
497
+ if (shell.endsWith("/bash")) {
498
+ const rc = process.platform === "darwin" ? ".bash_profile" : ".bashrc";
499
+ return { name: "bash", rcFile: join(homedir(), rc) };
500
+ }
501
+ return null;
502
+ }
503
+
504
+ function getAliasBlock() {
505
+ return [
506
+ ALIAS_START,
507
+ `# Auto-strips accountUuid so buddy uses custom userID`,
508
+ `claude() { node -e "const f=require(\\"os\\").homedir()+\\"/.claude.json\\";try{const c=JSON.parse(require(\\"fs\\").readFileSync(f));if(c.oauthAccount?.accountUuid){delete c.oauthAccount.accountUuid;delete c.companion;require(\\"fs\\").writeFileSync(f,JSON.stringify(c,null,2))}}catch{}"; command claude "$@"; }`,
509
+ ALIAS_END,
510
+ ].join("\n");
511
+ }
512
+
513
+ function writeRcAtomic(rcFile, content) {
514
+ const tmp = rcFile + ".buddy-roll-tmp";
515
+ writeFileSync(tmp, content);
516
+ renameSync(tmp, rcFile);
517
+ }
518
+
519
+ function addAlias(rcFile) {
520
+ if (!existsSync(rcFile)) {
521
+ writeRcAtomic(rcFile, getAliasBlock() + "\n");
522
+ return;
523
+ }
524
+ let content = readFileSync(rcFile, "utf8");
525
+
526
+ if (content.includes(ALIAS_START)) {
527
+ const startIdx = content.indexOf(ALIAS_START);
528
+ const endIdx = content.indexOf(ALIAS_END);
529
+ if (endIdx > startIdx) {
530
+ content = content.slice(0, startIdx) + getAliasBlock() + content.slice(endIdx + ALIAS_END.length);
531
+ writeRcAtomic(rcFile, content);
532
+ return;
533
+ }
534
+ }
535
+
536
+ if (/^\s*alias\s+claude\s*=/m.test(content)) {
537
+ console.log(`${c.yellow}!${c.reset} ${t("existingAlias")}`);
538
+ }
539
+
540
+ const nl = content.endsWith("\n") ? "" : "\n";
541
+ writeRcAtomic(rcFile, content + nl + "\n" + getAliasBlock() + "\n");
542
+ }
543
+
544
+ function removeAlias(rcFile) {
545
+ if (!existsSync(rcFile)) return;
546
+ let content = readFileSync(rcFile, "utf8");
547
+ const startIdx = content.indexOf(ALIAS_START);
548
+ const endIdx = content.indexOf(ALIAS_END);
549
+ if (startIdx === -1 || endIdx === -1) return;
550
+
551
+ let before = content.slice(0, startIdx);
552
+ let after = content.slice(endIdx + ALIAS_END.length);
553
+ before = before.replace(/\n\n$/, "\n");
554
+ after = after.replace(/^\n/, "");
555
+ writeRcAtomic(rcFile, before + after);
556
+ }
557
+
558
+ function setupAlias(config, dryRun) {
559
+ if (!config.oauthAccount) {
560
+ console.log(`${c.dim}${t("aliasSkipped")}${c.reset}`);
561
+ return;
562
+ }
563
+
564
+ const shell = detectShell();
565
+ if (!shell) {
566
+ console.log(`${c.yellow}!${c.reset} ${t("unsupportedShell")}`);
567
+ console.log(` ${getAliasBlock()}`);
568
+ return;
569
+ }
570
+
571
+ if (dryRun) {
572
+ console.log(` alias: would add to ${shell.rcFile}`);
573
+ return;
574
+ }
575
+
576
+ addAlias(shell.rcFile);
577
+ console.log(`${c.green}✓${c.reset} ${t("aliasAdded", shell.rcFile)}`);
578
+ }
579
+
580
+ // ── Search ───────────────────────────────────────────────
581
+
582
+ function search(target, hashFn, maxAttempts) {
583
+ const { species, rarity, eye, hat, shiny } = target;
584
+ let best = null;
585
+ const startTime = Date.now();
586
+
587
+ for (let i = 0; i < maxAttempts; i++) {
588
+ const id = randomBytes(32).toString("hex");
589
+ const result = fullRoll(id, hashFn);
590
+
591
+ if (species && result.species !== species) continue;
592
+ if (eye && result.eye !== eye) continue;
593
+ if (hat && result.hat !== hat) continue;
594
+ if (shiny && !result.shiny) continue;
595
+
596
+ if (result.rarity === rarity) {
597
+ best = { ...result, id, iterations: i + 1, elapsed: ((Date.now() - startTime) / 1000).toFixed(1) };
598
+ break;
599
+ }
600
+ }
601
+
602
+ return best;
603
+ }
604
+
605
+ // ── Interactive ──────────────────────────────────────────
606
+
607
+ function createRL() {
608
+ return createInterface({ input: process.stdin, output: process.stdout });
609
+ }
610
+
611
+ function ask(rl, question) {
612
+ return new Promise(resolve => rl.question(question, resolve));
613
+ }
614
+
615
+ async function selectFromList(rl, prompt, items) {
616
+ console.log(`\n${c.bold}${prompt}${c.reset}`);
617
+ if (items.length > 10) {
618
+ const cols = 3;
619
+ const rows = Math.ceil(items.length / cols);
620
+ for (let r = 0; r < rows; r++) {
621
+ let line = "";
622
+ for (let col = 0; col < cols; col++) {
623
+ const idx = r + col * rows;
624
+ if (idx < items.length) {
625
+ const num = String(idx + 1).padStart(2);
626
+ line += ` ${c.cyan}${num}${c.reset}) ${items[idx].label.padEnd(12)}`;
627
+ }
628
+ }
629
+ console.log(line);
630
+ }
631
+ } else {
632
+ items.forEach((item, i) => {
633
+ console.log(` ${c.cyan}${i + 1}${c.reset}) ${item.label}`);
634
+ });
635
+ }
636
+ while (true) {
637
+ const ans = await ask(rl, `\n (1-${items.length}) ❯ `);
638
+ const n = parseInt(ans.trim());
639
+ if (n >= 1 && n <= items.length) return items[n - 1].value;
640
+ console.log(` ${c.dim}${t("invalidInput", items.length)}${c.reset}`);
641
+ }
642
+ }
643
+
644
+ async function selectFromListOptional(rl, prompt, items) {
645
+ console.log(`\n${c.bold}${prompt}${c.reset}`);
646
+ if (items.length > 10) {
647
+ const cols = 3;
648
+ const rows = Math.ceil(items.length / cols);
649
+ for (let r = 0; r < rows; r++) {
650
+ let line = "";
651
+ for (let col = 0; col < cols; col++) {
652
+ const idx = r + col * rows;
653
+ if (idx < items.length) {
654
+ const num = String(idx + 1).padStart(2);
655
+ line += ` ${c.cyan}${num}${c.reset}) ${items[idx].label.padEnd(12)}`;
656
+ }
657
+ }
658
+ console.log(line);
659
+ }
660
+ } else {
661
+ items.forEach((item, i) => {
662
+ console.log(` ${c.cyan}${i + 1}${c.reset}) ${item.label}`);
663
+ });
664
+ }
665
+ while (true) {
666
+ const ans = await ask(rl, `\n (1-${items.length}${t("skipHint")}) ❯ `);
667
+ if (!ans.trim()) return null;
668
+ const n = parseInt(ans.trim());
669
+ if (n >= 1 && n <= items.length) return items[n - 1].value;
670
+ console.log(` ${c.dim}${t("invalidInput", items.length)}${c.reset}`);
671
+ }
672
+ }
673
+
674
+ async function confirm(rl, prompt) {
675
+ const ans = await ask(rl, `${prompt}${t("yesNo")}`);
676
+ return ans.trim().toLowerCase().startsWith("y");
677
+ }
678
+
679
+ function showApplyDetails(resultId, config, dryRun) {
680
+ const idShort = resultId.slice(0, 8) + "..." + resultId.slice(-8);
681
+ console.log(`\n${c.yellow}⚠ ${t("willModify")}:${c.reset}`);
682
+ console.log(` ${c.cyan}~/.claude.json${c.reset}`);
683
+ console.log(` - ${t("modifyUserID")}: ${idShort}`);
684
+ console.log(` - ${t("removeUuid")}`);
685
+ console.log(` - ${t("removeCompanion")}`);
686
+ if (config.oauthAccount) {
687
+ const shell = detectShell();
688
+ if (shell) console.log(` ${c.cyan}${shell.rcFile.replace(homedir(), "~")}${c.reset}\n - ${t("addAlias")}`);
689
+ }
690
+ console.log(`\n${t("backupFiles")}:`);
691
+ console.log(` ~/.claude.json.buddy-roll-backup ${c.dim}${t("backupConfigDesc")}${c.reset}`);
692
+ console.log(` ~/.buddy-roll-state.json ${c.dim}${t("backupStateDesc")}${c.reset}`);
693
+ console.log(`\n${t("restoreHint")}:npx buddy-roll restore`);
694
+
695
+ if (dryRun) {
696
+ console.log(`\n${c.yellow}${t("dryRunBanner")}${c.reset}`);
697
+ console.log(`${c.dim}${t("dryRunHintRemove")}${c.reset}`);
698
+ }
699
+ }
700
+
701
+ async function interactiveMode(installType, dryRun, userMax) {
702
+ const config = readConfig();
703
+ if (!config) { console.error(`${c.red}✗${c.reset} ${t("configNotFound")}`); process.exit(1); }
704
+ requireUserID(config);
705
+
706
+ const hashFn = hashFor(installType);
707
+ const installLabel = installType === "native" ? "native binary" : "npm";
708
+ const hashName = installType === "native" ? "wyhash" : "FNV-1a";
709
+
710
+ console.log(`\n${c.bold}${c.magenta}${t("title")}${c.reset}\n`);
711
+ if (dryRun) {
712
+ console.log(`${c.yellow}${c.bold}${t("dryRunBanner")}${c.reset}\n`);
713
+ }
714
+ console.log(`${t("installType", installLabel, hashName)}`);
715
+
716
+ const current = getCurrentBuddy(config, installType);
717
+ if (current?.name) {
718
+ const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
719
+ console.log(`${t("currentBuddy", RARITY_STARS[current.rarity], cap(current.rarity), cap(current.species), current.name)}`);
720
+ } else {
721
+ console.log(`${t("noBuddy")}`);
722
+ }
723
+
724
+ const rl = createRL();
725
+
726
+ try {
727
+ const speciesItems = SPECIES.map(s => ({ label: s, value: s }));
728
+ const targetSpecies = await selectFromList(rl, t("selectSpecies"), speciesItems);
729
+
730
+ const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
731
+ const maxStars = 5;
732
+ const rarityItems = [...RARITIES].reverse().map(r => {
733
+ const stars = RARITY_STARS[r].padEnd(maxStars);
734
+ const name = cap(r).padEnd(10);
735
+ const pct = String(RARITY_WEIGHTS[r]).padStart(2) + "%";
736
+ const color = NO_COLOR ? "" : (RARITY_ANSI[r] || "");
737
+ const R = NO_COLOR ? "" : "\x1b[0m\x1b[39m";
738
+ return { label: `${color}${stars}${R} ${name} ${pct}`, value: r };
739
+ });
740
+ const targetRarity = await selectFromList(rl, t("selectRarity"), rarityItems);
741
+
742
+ const eyeItems = EYES.map(e => ({ label: e, value: e }));
743
+ const targetEye = await selectFromListOptional(rl, t("selectEye"), eyeItems);
744
+
745
+ let targetHat = null;
746
+ if (targetRarity !== "common") {
747
+ const hatItems = HATS.map(h => ({ label: h, value: h }));
748
+ targetHat = await selectFromListOptional(rl, t("selectHat"), hatItems);
749
+ }
750
+
751
+ const shinyAns = await ask(rl, `\n${t("requireShiny")} ❯ `);
752
+ const targetShiny = shinyAns.trim().toLowerCase() === "y";
753
+
754
+ const uc = s => s.charAt(0).toUpperCase() + s.slice(1);
755
+ const parts = [`${RARITY_STARS[targetRarity]} ${uc(targetRarity)} ${uc(targetSpecies)}`];
756
+ if (targetEye) parts.push(`Eye: ${targetEye}`);
757
+ if (targetHat) parts.push(`Hat: ${targetHat}`);
758
+ if (targetShiny) parts.push(`Shiny: ✨`);
759
+ console.log(`\n${t("target")}:${parts.join(" | ")}`);
760
+
761
+ const maxAttempts = userMax || (targetShiny ? 50000000 : (targetEye || targetHat) ? 20000000 : 20000000);
762
+ let result = null;
763
+
764
+ while (true) {
765
+ console.log(`${t("searching", targetRarity, targetSpecies)}`);
766
+ result = search(
767
+ { species: targetSpecies, rarity: targetRarity, eye: targetEye, hat: targetHat, shiny: targetShiny },
768
+ hashFn,
769
+ maxAttempts,
770
+ );
771
+
772
+ if (!result) {
773
+ console.log(`\n${c.yellow}${t("noMatch", maxAttempts.toLocaleString())}${c.reset}`);
774
+ rl.close();
775
+ return;
776
+ }
777
+
778
+ console.log(`${t("searchStats", result.iterations.toLocaleString(), result.elapsed)}`);
779
+ console.log(`${c.green}${t("found", result.rarity, result.species, result.id.slice(0, 8) + "..." + result.id.slice(-8))}${c.reset}`);
780
+ console.log("");
781
+ console.log(formatBuddy(result));
782
+ console.log(`\n${c.dim}ID: ${result.id}${c.reset}`);
783
+
784
+ const choice = await ask(rl, `\n${t("applyAsk")} ❯ `);
785
+ const ch = choice.trim().toLowerCase();
786
+ if (ch === "r") continue;
787
+ if (ch === "n") { rl.close(); return; }
788
+ break;
789
+ }
790
+
791
+ showApplyDetails(result.id, config, dryRun);
792
+
793
+ if (dryRun) {
794
+ rl.close();
795
+ return;
796
+ }
797
+
798
+ const ans = await ask(rl, `\n${t("applyConfirm")} ❯ `);
799
+ rl.close();
800
+
801
+ if (ans.trim().toLowerCase() !== "y") return;
802
+
803
+ applyBuddy(result.id, false);
804
+ if (existsSync(STATE_PATH)) {
805
+ const state = JSON.parse(readFileSync(STATE_PATH, "utf8"));
806
+ state.appliedUserID = result.id;
807
+ writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
808
+ }
809
+ setupAlias(config, false);
810
+
811
+ console.log(`\n${c.green}${c.bold}${t("done")}${c.reset}`);
812
+ console.log(`${c.dim}${t("undoHint")}${c.reset}`);
813
+ } catch (e) {
814
+ rl.close();
815
+ throw e;
816
+ }
817
+ }
818
+
819
+ // ── CLI ──────────────────────────────────────────────────
820
+
821
+ function cmdCurrent() {
822
+ const config = readConfig();
823
+ if (!config) { console.error(`${c.red}✗${c.reset} ${t("configNotFound")}`); process.exit(1); }
824
+ requireUserID(config);
825
+
826
+ const installType = detectInstallType();
827
+ const installLabel = installType === "native" ? "native binary" : "npm";
828
+ const hashName = installType === "native" ? "wyhash" : "FNV-1a";
829
+ console.log(`${t("installType", installLabel, hashName)}`);
830
+
831
+ const uuid = config.oauthAccount?.accountUuid;
832
+ const userId = config.userID;
833
+ const fmtId = id => id.length > 16 ? `${id.slice(0, 8)}...${id.slice(-8)}` : id;
834
+ const source = uuid ? `accountUuid (${fmtId(uuid)})` : userId ? `userID (${fmtId(userId)})` : "anon";
835
+ console.log(`${t("activeId", source)}`);
836
+
837
+ const buddy = getCurrentBuddy(config, installType);
838
+ if (buddy) {
839
+ console.log("");
840
+ console.log(formatBuddyCard(buddy));
841
+ }
842
+
843
+ if (existsSync(STATE_PATH)) {
844
+ const state = JSON.parse(readFileSync(STATE_PATH, "utf8"));
845
+ console.log(`\n${t("stateActive", state.backupTime.split("T")[0])}`);
846
+ } else {
847
+ console.log(`\n${t("stateInactive")}`);
848
+ }
849
+ }
850
+
851
+ function cmdVerify(id) {
852
+ const installType = detectInstallType();
853
+ const installLabel = installType === "native" ? "native binary" : "npm";
854
+ const hashName = installType === "native" ? "wyhash" : "FNV-1a";
855
+ const hashFn = hashFor(installType);
856
+ const fmtId = s => s.length > 16 ? `${s.slice(0, 8)}...${s.slice(-8)}` : s;
857
+
858
+ console.log(`${t("installType", installLabel, hashName)}`);
859
+ console.log(`ID: ${fmtId(id)}\n`);
860
+
861
+ const result = fullRoll(id, hashFn);
862
+ console.log(formatBuddy(result));
863
+ }
864
+
865
+ function cmdHelp() {
866
+ const b = c.bold, r = c.reset;
867
+ const eyes = EYES.join(", ");
868
+
869
+ if (LANG === "zh") {
870
+ console.log(`
871
+ ${b}buddy-roll${r} — 一键 Claude Code 宠物定制器。不修改二进制文件。
872
+
873
+ ${b}用法:${r}
874
+ npx buddy-roll 搜索并应用自定义宠物(交互式)
875
+ npx buddy-roll current 查看当前宠物信息
876
+ npx buddy-roll verify <id> 查看某个 ID 生成的宠物
877
+ npx buddy-roll restore 恢复原始配置和宠物
878
+ npx buddy-roll help 显示帮助
879
+
880
+ ${b}非交互模式:${r}
881
+ --species <name> 目标物种
882
+ duck, goose, blob, cat, dragon, octopus,
883
+ owl, penguin, turtle, snail, ghost, axolotl,
884
+ capybara, cactus, robot, rabbit, mushroom, chonk
885
+ --rarity <level> 目标稀有度
886
+ common, uncommon, rare, epic, legendary
887
+ --eye <style> 目标眼睛 (${eyes})
888
+ --hat <type> 目标帽子
889
+ none, crown, tophat, propeller,
890
+ halo, wizard, beanie, tinyduck
891
+ --shiny 要求闪光
892
+ --max <number> 最大搜索次数(默认 20000000)
893
+ --yes, -y 跳过确认(用于脚本)
894
+
895
+ ${b}选项:${r}
896
+ --lang en|zh 强制语言
897
+ --dry-run 预览变更,不实际修改
898
+ `);
899
+ } else {
900
+ console.log(`
901
+ ${b}buddy-roll${r} — One-click Claude Code buddy customizer. No binary patching.
902
+
903
+ ${b}Usage:${r}
904
+ npx buddy-roll Search and apply a custom buddy (interactive)
905
+ npx buddy-roll current Show current buddy info
906
+ npx buddy-roll verify <id> Check what buddy an ID produces
907
+ npx buddy-roll restore Restore original config and buddy
908
+ npx buddy-roll help Show this help
909
+
910
+ ${b}Non-interactive:${r}
911
+ --species <name> Target species
912
+ duck, goose, blob, cat, dragon, octopus,
913
+ owl, penguin, turtle, snail, ghost, axolotl,
914
+ capybara, cactus, robot, rabbit, mushroom, chonk
915
+ --rarity <level> Target rarity
916
+ common, uncommon, rare, epic, legendary
917
+ --eye <style> Target eye (${eyes})
918
+ --hat <type> Target hat
919
+ none, crown, tophat, propeller,
920
+ halo, wizard, beanie, tinyduck
921
+ --shiny Require shiny
922
+ --max <number> Max search attempts (default: 20000000)
923
+ --yes, -y Skip confirmation (for scripts)
924
+
925
+ ${b}Options:${r}
926
+ --lang en|zh Force language
927
+ --dry-run Show what would change without applying
928
+ `);
929
+ }
930
+ }
931
+
932
+ function cmdRestore() {
933
+ if (!existsSync(STATE_PATH)) {
934
+ console.error(`${c.red}✗${c.reset} ${t("noBackup")}`);
935
+ process.exit(1);
936
+ }
937
+ console.log(`${t("restoring")}\n`);
938
+ restoreConfig();
939
+ console.log(`\n${c.green}${c.bold}${t("restoreDone")}${c.reset}`);
940
+ }
941
+
942
+ function exitWithHelp(msg) {
943
+ console.error(`${c.red}✗${c.reset} ${msg}\n`);
944
+ cmdHelp();
945
+ process.exit(1);
946
+ }
947
+
948
+ function parseArgs(argv) {
949
+ const args = { command: "interactive" };
950
+ let i = 2;
951
+ if (argv[2] && !argv[2].startsWith("-")) {
952
+ switch (argv[2]) {
953
+ case "current": args.command = "current"; i = 3; break;
954
+ case "verify": args.command = "verify"; args.verifyId = argv[3]; i = 4; break;
955
+ case "restore": args.command = "restore"; i = 3; break;
956
+ case "help": args.command = "help"; i = 3; break;
957
+ default: args.verifyId = argv[2]; args.command = "verify"; i = 3; break;
958
+ }
959
+ }
960
+ while (i < argv.length) {
961
+ const arg = argv[i];
962
+ switch (arg) {
963
+ case "--help": case "-h": args.command = "help"; break;
964
+ case "--restore": args.command = "restore"; break;
965
+ case "--current": args.command = "current"; break;
966
+ case "--verify": args.command = "verify"; args.verifyId = argv[++i]; break;
967
+ case "--species": args.species = argv[++i]; break;
968
+ case "--rarity": args.rarity = argv[++i]; break;
969
+ case "--eye": args.eye = argv[++i]; break;
970
+ case "--hat": args.hat = argv[++i]; break;
971
+ case "--shiny": args.shiny = true; break;
972
+ case "--max": args.max = parseInt(argv[++i]); break;
973
+ case "--lang": LANG = argv[++i]; break;
974
+ case "--dry-run": args.dryRun = true; break;
975
+ case "--yes": case "-y": args.yes = true; break;
976
+ }
977
+ i++;
978
+ }
979
+ return args;
980
+ }
981
+
982
+ async function nonInteractiveMode(args) {
983
+ if (!args.species) exitWithHelp(t("speciesRequired"));
984
+ if (!SPECIES.includes(args.species)) exitWithHelp(`Unknown species: ${args.species}. Valid: ${SPECIES.join(", ")}`);
985
+ if (args.rarity && !RARITIES.includes(args.rarity)) exitWithHelp(`Unknown rarity: ${args.rarity}. Valid: ${RARITIES.join(", ")}`);
986
+ if (args.eye && !EYES.includes(args.eye)) exitWithHelp(`Unknown eye: ${args.eye}. Valid: ${EYES.join(", ")}`);
987
+ if (args.hat && !HATS.includes(args.hat)) exitWithHelp(`Unknown hat: ${args.hat}. Valid: ${HATS.join(", ")}`);
988
+
989
+
990
+ const config = readConfig();
991
+ if (!config) { console.error(`${c.red}✗${c.reset} ${t("configNotFound")}`); process.exit(1); }
992
+ requireUserID(config);
993
+
994
+ const installType = detectInstallType();
995
+ const hashFn = hashFor(installType);
996
+ const rarity = args.rarity || "legendary";
997
+ const max = args.max || (args.shiny ? 50000000 : 20000000);
998
+
999
+ console.log(`\n${t("searching", rarity, args.species)}`);
1000
+ const result = search(
1001
+ { species: args.species, rarity, eye: args.eye, hat: args.hat, shiny: args.shiny },
1002
+ hashFn, max,
1003
+ );
1004
+
1005
+ if (!result) {
1006
+ console.log(`\n${c.yellow}${t("noMatch", max.toLocaleString())}${c.reset}`);
1007
+ process.exit(1);
1008
+ }
1009
+
1010
+ console.log(`${t("searchStats", result.iterations.toLocaleString(), result.elapsed)}`);
1011
+ console.log(`${c.green}${t("found", result.rarity, result.species, result.id.slice(0, 8) + "..." + result.id.slice(-8))}${c.reset}`);
1012
+ console.log("");
1013
+ console.log(formatBuddy(result));
1014
+ console.log(`\n${c.dim}ID: ${result.id}${c.reset}`);
1015
+
1016
+ showApplyDetails(result.id, config, args.dryRun);
1017
+
1018
+ if (args.dryRun) return;
1019
+
1020
+ if (!args.yes) {
1021
+ const rl = createRL();
1022
+ const ans = await ask(rl, `\n${t("applyConfirm")} ❯ `);
1023
+ rl.close();
1024
+ if (ans.trim().toLowerCase() !== "y") return;
1025
+ }
1026
+
1027
+ applyBuddy(result.id, false);
1028
+ if (existsSync(STATE_PATH)) {
1029
+ const state = JSON.parse(readFileSync(STATE_PATH, "utf8"));
1030
+ state.appliedUserID = result.id;
1031
+ writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
1032
+ }
1033
+ setupAlias(config, false);
1034
+
1035
+ console.log(`\n${c.green}${c.bold}${t("done")}${c.reset}`);
1036
+ console.log(`${c.dim}${t("undoHint")}${c.reset}`);
1037
+ }
1038
+
1039
+ async function main() {
1040
+ const args = parseArgs(process.argv);
1041
+
1042
+ switch (args.command) {
1043
+ case "help": cmdHelp(); break;
1044
+ case "restore": cmdRestore(); break;
1045
+ case "current": cmdCurrent(); break;
1046
+ case "verify":
1047
+ if (!args.verifyId) exitWithHelp(t("verifyNeedsId"));
1048
+ cmdVerify(args.verifyId);
1049
+ break;
1050
+ case "interactive":
1051
+ if (args.species) {
1052
+ await nonInteractiveMode(args);
1053
+ } else {
1054
+ await interactiveMode(detectInstallType(), args.dryRun, args.max);
1055
+ }
1056
+ break;
1057
+ }
1058
+ }
1059
+
1060
+ main().catch(e => { console.error(e); process.exit(1); });
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "buddy-roll",
3
+ "version": "1.0.1",
4
+ "description": "One-click Claude Code buddy customizer. No binary patching.",
5
+ "type": "module",
6
+ "bin": {
7
+ "buddy-roll": "bin/buddy-roll.mjs"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test test/buddy-roll.test.mjs && zsh test/test-alias.sh",
11
+ "test:interactive": "node test/test-interactive.mjs"
12
+ },
13
+ "engines": {
14
+ "node": ">=18.0.0"
15
+ },
16
+ "keywords": [
17
+ "claude",
18
+ "claude-code",
19
+ "buddy",
20
+ "companion",
21
+ "reroll",
22
+ "pet",
23
+ "customizer"
24
+ ],
25
+ "author": "jamezbondos",
26
+ "license": "MIT",
27
+ "files": [
28
+ "bin/",
29
+ "README.md",
30
+ "README.zh-CN.md",
31
+ "LICENSE"
32
+ ],
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/jamez-bondos/buddy-roll.git"
36
+ }
37
+ }