buddy-reroll 0.2.0 → 0.3.0

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/index.js CHANGED
@@ -1,139 +1,34 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env node
2
2
 
3
- import { readFileSync, writeFileSync, existsSync, copyFileSync, readdirSync, statSync, realpathSync } from "fs";
4
- import { join } from "path";
5
- import { homedir, platform } from "os";
6
- import { execSync } from "child_process";
3
+ import { readFileSync, writeFileSync, existsSync, copyFileSync, renameSync, unlinkSync } from "fs";
4
+ import { platform } from "os";
5
+ import { execFileSync } from "child_process";
7
6
  import { parseArgs } from "util";
8
7
  import chalk from "chalk";
9
8
  import { renderSprite, colorizeSprite, RARITY_STARS, RARITY_COLORS } from "./sprites.js";
10
-
11
- if (typeof Bun === "undefined") {
12
- console.error("buddy-reroll requires Bun runtime (uses Bun.hash).\nInstall: https://bun.sh");
13
- process.exit(1);
14
- }
15
-
16
- // ── Constants (synced with Claude Code src/buddy/types.ts) ───────────────
17
-
18
- const ORIGINAL_SALT = "friend-2026-401";
19
- const SALT_LEN = ORIGINAL_SALT.length;
20
-
21
- const RARITIES = ["common", "uncommon", "rare", "epic", "legendary"];
22
- const RARITY_WEIGHTS = { common: 60, uncommon: 25, rare: 10, epic: 4, legendary: 1 };
23
- const RARITY_TOTAL = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0);
24
- const RARITY_FLOOR = { common: 5, uncommon: 15, rare: 25, epic: 35, legendary: 50 };
25
-
26
- const SPECIES = [
27
- "duck", "goose", "blob", "cat", "dragon", "octopus", "owl", "penguin",
28
- "turtle", "snail", "ghost", "axolotl", "capybara", "cactus", "robot",
29
- "rabbit", "mushroom", "chonk",
30
- ];
31
-
32
- const EYES = ["·", "✦", "×", "◉", "@", "°"];
33
- const HATS = ["none", "crown", "tophat", "propeller", "halo", "wizard", "beanie", "tinyduck"];
34
- const STAT_NAMES = ["DEBUGGING", "PATIENCE", "CHAOS", "WISDOM", "SNARK"];
35
-
36
- const RARITY_LABELS = {
37
- common: "Common (60%)",
38
- uncommon: "Uncommon (25%)",
39
- rare: "Rare (10%)",
40
- epic: "Epic (4%)",
41
- legendary: "Legendary (1%)",
42
- };
43
-
44
- // ── PRNG (synced with Claude Code src/buddy/companion.ts) ────────────────
45
-
46
- function mulberry32(seed) {
47
- let a = seed >>> 0;
48
- return () => {
49
- a |= 0;
50
- a = (a + 0x6d2b79f5) | 0;
51
- let t = Math.imul(a ^ (a >>> 15), 1 | a);
52
- t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
53
- return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
54
- };
55
- }
56
-
57
- function hashString(s) {
58
- return Number(BigInt(Bun.hash(s)) & 0xffffffffn);
59
- }
60
-
61
- function pick(rng, arr) {
62
- return arr[Math.floor(rng() * arr.length)];
63
- }
64
-
65
- function rollRarity(rng) {
66
- let roll = rng() * RARITY_TOTAL;
67
- for (const r of RARITIES) {
68
- roll -= RARITY_WEIGHTS[r];
69
- if (roll < 0) return r;
70
- }
71
- return "common";
72
- }
73
-
74
- function rollFrom(salt, userId) {
75
- const rng = mulberry32(hashString(userId + salt));
76
- const rarity = rollRarity(rng);
77
- const species = pick(rng, SPECIES);
78
- const eye = pick(rng, EYES);
79
- const hat = rarity === "common" ? "none" : pick(rng, HATS);
80
- const shiny = rng() < 0.01;
81
-
82
- const floor = RARITY_FLOOR[rarity];
83
- const peak = pick(rng, STAT_NAMES);
84
- let dump = pick(rng, STAT_NAMES);
85
- while (dump === peak) dump = pick(rng, STAT_NAMES);
86
- const stats = {};
87
- for (const name of STAT_NAMES) {
88
- if (name === peak) stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30));
89
- else if (name === dump) stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15));
90
- else stats[name] = floor + Math.floor(rng() * 40);
91
- }
92
-
93
- return { rarity, species, eye, hat, shiny, stats };
94
- }
95
-
96
- // ── Path detection ───────────────────────────────────────────────────────
97
-
98
- function getClaudeConfigDir() {
99
- return process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
100
- }
101
-
102
- function findBinaryPath() {
103
- try {
104
- const allPaths = execSync("which -a claude 2>/dev/null", { encoding: "utf-8" }).trim().split("\n");
105
- for (const entry of allPaths) {
106
- try {
107
- const resolved = realpathSync(entry.trim());
108
- if (resolved && existsSync(resolved) && statSync(resolved).size > 1_000_000) return resolved;
109
- } catch {}
110
- }
111
- } catch {}
112
-
113
- const versionsDir = join(homedir(), ".local", "share", "claude", "versions");
114
- if (existsSync(versionsDir)) {
115
- try {
116
- const versions = readdirSync(versionsDir)
117
- .filter((f) => !f.includes(".backup"))
118
- .sort();
119
- if (versions.length > 0) return join(versionsDir, versions[versions.length - 1]);
120
- } catch {}
121
- }
122
-
123
- return null;
124
- }
125
-
126
- function findConfigPath() {
127
- const claudeDir = getClaudeConfigDir();
128
-
129
- const legacyPath = join(claudeDir, ".config.json");
130
- if (existsSync(legacyPath)) return legacyPath;
131
-
132
- const home = process.env.CLAUDE_CONFIG_DIR || homedir();
133
- const defaultPath = join(home, ".claude.json");
134
- if (existsSync(defaultPath)) return defaultPath;
135
-
136
- return null;
9
+ import {
10
+ EYES,
11
+ HATS,
12
+ RARITIES,
13
+ RARITY_LABELS,
14
+ RARITY_WEIGHTS,
15
+ SPECIES,
16
+ STAT_NAMES,
17
+ bruteForce,
18
+ findCurrentSalt,
19
+ matches,
20
+ rollFrom,
21
+ } from "./lib/companion.js";
22
+ import { formatDoctorReport, getDoctorReport } from "./lib/doctor.js";
23
+ import { findBinaryPath, findConfigPath, getClaudeBinaryOverride, getPatchability } from "./lib/runtime.js";
24
+ import { parallelBruteForce } from "./lib/finder.js";
25
+ import { estimateAttempts, formatProgress } from "./lib/estimator.js";
26
+ import { installHook, removeHook, storeSalt, readStoredSalt } from "./lib/hooks.js";
27
+
28
+ const IS_BUN = typeof Bun !== "undefined";
29
+ const IS_APPLY_HOOK = process.argv.includes("--apply-hook");
30
+ if (!IS_BUN && !IS_APPLY_HOOK) {
31
+ console.warn("⚠ Running without Bun searching will be a bit slower. For the speediest experience: https://bun.sh");
137
32
  }
138
33
 
139
34
  function getUserId(configPath) {
@@ -141,105 +36,32 @@ function getUserId(configPath) {
141
36
  return config.oauthAccount?.accountUuid ?? config.userID ?? "anon";
142
37
  }
143
38
 
144
- // ── Salt detection ───────────────────────────────────────────────────────
145
-
146
- function findCurrentSalt(binaryData, userId) {
147
- if (binaryData.includes(Buffer.from(ORIGINAL_SALT))) return ORIGINAL_SALT;
148
-
149
- const text = binaryData.toString("utf-8");
150
-
151
- // Scan for previously patched salts (our known patterns)
152
- const patterns = [
153
- new RegExp(`x{${SALT_LEN - 8}}\\d{8}`, "g"),
154
- new RegExp(`friend-\\d{4}-.{${SALT_LEN - 12}}`, "g"),
155
- ];
156
- for (const pat of patterns) {
157
- let m;
158
- while ((m = pat.exec(text)) !== null) {
159
- if (m[0].length === SALT_LEN) return m[0];
160
- }
161
- }
162
-
163
- // Contextual scan near companion code markers
164
- const saltRegex = new RegExp(`"([a-zA-Z0-9_-]{${SALT_LEN}})"`, "g");
165
- const candidates = new Set();
166
- const markers = ["rollRarity", "CompanionBones", "inspirationSeed", "companionUserId"];
167
- for (const marker of markers) {
168
- const markerIdx = text.indexOf(marker);
169
- if (markerIdx === -1) continue;
170
- const window = text.slice(Math.max(0, markerIdx - 5000), Math.min(text.length, markerIdx + 5000));
171
- let match;
172
- while ((match = saltRegex.exec(window)) !== null) {
173
- candidates.add(match[1]);
174
- }
175
- }
176
-
177
- // Filter: real salts contain digits or hyphens (rules out "projectSettings" etc.)
178
- for (const c of candidates) {
179
- if (/[\d-]/.test(c)) return c;
180
- }
181
-
182
- return null;
183
- }
184
-
185
- // ── Brute-force ──────────────────────────────────────────────────────────
186
-
187
- async function bruteForce(userId, target, onProgress) {
188
- const startTime = Date.now();
189
- let checked = 0;
190
-
191
- const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_";
192
- const suffixLen = SALT_LEN - "friend-2026-".length;
193
- if (suffixLen > 0 && suffixLen <= 4) {
194
- const gen = function* (prefix, depth) {
195
- if (depth === 0) { yield prefix; return; }
196
- for (const ch of chars) yield* gen(prefix + ch, depth - 1);
197
- };
198
- for (const suffix of gen("", suffixLen)) {
199
- const salt = `friend-2026-${suffix}`;
200
- checked++;
201
- const r = rollFrom(salt, userId);
202
- if (matches(r, target)) return { salt, result: r, checked, elapsed: Date.now() - startTime };
203
- }
204
- }
205
-
206
- for (let i = 0; i < 1_000_000_000; i++) {
207
- const salt = String(i).padStart(SALT_LEN, "x");
208
- checked++;
209
- const r = rollFrom(salt, userId);
210
- if (matches(r, target)) return { salt, result: r, checked, elapsed: Date.now() - startTime };
211
-
212
- if (checked % 100_000 === 0) {
213
- await new Promise((r) => setTimeout(r, 0));
214
- }
215
- if (checked % 5_000_000 === 0) {
216
- if (onProgress) onProgress(checked, Date.now() - startTime);
217
- }
218
- }
219
-
220
- return null;
221
- }
222
-
223
- function matches(roll, target) {
224
- if (target.species && roll.species !== target.species) return false;
225
- if (target.rarity && roll.rarity !== target.rarity) return false;
226
- if (target.eye && roll.eye !== target.eye) return false;
227
- if (target.hat && roll.hat !== target.hat) return false;
228
- if (target.shiny !== undefined && roll.shiny !== target.shiny) return false;
229
- return true;
230
- }
231
-
232
39
  // ── Binary patch ─────────────────────────────────────────────────────────
233
40
 
234
41
  function isClaudeRunning() {
235
42
  try {
236
- const out = execSync("pgrep -af claude 2>/dev/null", { encoding: "utf-8" });
43
+ if (platform() === "win32") {
44
+ const out = execFileSync("tasklist", ["/FI", "IMAGENAME eq claude.exe", "/FO", "CSV"], {
45
+ encoding: "utf-8",
46
+ stdio: ["ignore", "pipe", "ignore"],
47
+ });
48
+ return out.toLowerCase().includes("claude.exe");
49
+ }
50
+ const out = execFileSync("pgrep", ["-af", "claude"], {
51
+ encoding: "utf-8",
52
+ stdio: ["ignore", "pipe", "ignore"],
53
+ });
237
54
  return out.split("\n").some((line) => !line.includes("buddy-reroll") && line.trim().length > 0);
238
55
  } catch {
239
56
  return false;
240
57
  }
241
58
  }
242
59
 
60
+ function sleepMs(ms) {
61
+ if (typeof Bun !== "undefined") return Bun.sleepSync(ms);
62
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
63
+ }
64
+
243
65
  function patchBinary(binaryPath, oldSalt, newSalt) {
244
66
  if (oldSalt.length !== newSalt.length) {
245
67
  throw new Error(`Salt length mismatch: "${oldSalt}" (${oldSalt.length}) vs "${newSalt}" (${newSalt.length})`);
@@ -261,14 +83,31 @@ function patchBinary(binaryPath, oldSalt, newSalt) {
261
83
 
262
84
  if (count === 0) throw new Error(`Salt "${oldSalt}" not found in binary`);
263
85
 
264
- writeFileSync(binaryPath, data);
265
- return count;
86
+ const isWin = platform() === "win32";
87
+ const maxRetries = isWin ? 3 : 1;
88
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
89
+ try {
90
+ const tmpPath = binaryPath + ".tmp";
91
+ writeFileSync(tmpPath, data);
92
+ renameSync(tmpPath, binaryPath);
93
+ return count;
94
+ } catch (err) {
95
+ try { unlinkSync(binaryPath + ".tmp"); } catch {}
96
+ if (isWin && (err.code === "EACCES" || err.code === "EPERM" || err.code === "EBUSY") && attempt < maxRetries - 1) {
97
+ sleepMs(2000);
98
+ continue;
99
+ }
100
+ throw new Error(`Failed to write binary: ${err.message}${isWin ? " (ensure Claude Code is fully closed)" : ""}`);
101
+ }
102
+ }
266
103
  }
267
104
 
268
105
  function resignBinary(binaryPath) {
269
106
  if (platform() !== "darwin") return false;
270
107
  try {
271
- execSync(`codesign -s - --force "${binaryPath}" 2>/dev/null`);
108
+ execFileSync("codesign", ["-s", "-", "--force", binaryPath], {
109
+ stdio: "ignore",
110
+ });
272
111
  return true;
273
112
  } catch {
274
113
  return false;
@@ -284,6 +123,59 @@ function clearCompanion(configPath) {
284
123
  writeFileSync(configPath, JSON.stringify(config, null, indent) + "\n");
285
124
  }
286
125
 
126
+ function fail(message) {
127
+ console.error(message);
128
+ process.exit(1);
129
+ }
130
+
131
+ function readCurrentCompanion(binaryPath, userId) {
132
+ const binaryData = readFileSync(binaryPath);
133
+ const currentSalt = findCurrentSalt(binaryData);
134
+ if (!currentSalt) fail(" ✗ Couldn't read your current buddy from the Claude binary.");
135
+ return { currentSalt, currentRoll: rollFrom(currentSalt, userId) };
136
+ }
137
+
138
+ function buildTargetFromArgs(args) {
139
+ const target = {};
140
+
141
+ if (args.species) {
142
+ if (!SPECIES.includes(args.species)) fail(` ✗ Unknown species "${args.species}". Use --list.`);
143
+ target.species = args.species;
144
+ }
145
+ if (args.rarity) {
146
+ if (!RARITIES.includes(args.rarity)) fail(` ✗ Unknown rarity "${args.rarity}". Use --list.`);
147
+ target.rarity = args.rarity;
148
+ }
149
+ if (args.eye) {
150
+ if (!EYES.includes(args.eye)) fail(` ✗ Unknown eye "${args.eye}". Use --list.`);
151
+ target.eye = args.eye;
152
+ }
153
+ if (args.hat) {
154
+ if (!HATS.includes(args.hat)) fail(` ✗ Unknown hat "${args.hat}". Use --list.`);
155
+ target.hat = args.hat;
156
+ }
157
+ if (args.shiny !== undefined) target.shiny = args.shiny;
158
+ if (args.peak) {
159
+ const p = args.peak.toUpperCase();
160
+ if (!STAT_NAMES.includes(p)) fail(` ✗ "${args.peak}" isn't a stat. Pick one: ${STAT_NAMES.join(", ")}`);
161
+ target.peak = p;
162
+ }
163
+ if (args.dump) {
164
+ const d = args.dump.toUpperCase();
165
+ if (!STAT_NAMES.includes(d)) fail(` ✗ "${args.dump}" isn't a stat. Pick one: ${STAT_NAMES.join(", ")}`);
166
+ if (target.peak && d === target.peak) fail(" ✗ Your weakest stat can't be the same as your strongest!");
167
+ target.dump = d;
168
+ }
169
+
170
+ return target;
171
+ }
172
+
173
+ function assertPatchable(binaryPath) {
174
+ const patchability = getPatchability(binaryPath);
175
+ if (!patchability.ok) fail(` ✗ ${patchability.message}`);
176
+ return patchability;
177
+ }
178
+
287
179
  // ── Display ──────────────────────────────────────────────────────────────
288
180
 
289
181
  function formatCompanionCard(result) {
@@ -305,7 +197,8 @@ function formatCompanionCard(result) {
305
197
  }
306
198
 
307
199
  for (const [k, v] of Object.entries(result.stats)) {
308
- const bar = colorFn("█".repeat(Math.round(v / 10)) + "░".repeat(10 - Math.round(v / 10)));
200
+ const filled = Math.min(10, Math.max(0, Math.round(v / 10)));
201
+ const bar = colorFn("█".repeat(filled) + "░".repeat(10 - filled));
309
202
  lines.push(` ${k.padEnd(10)} ${bar} ${String(v).padStart(3)}`);
310
203
  }
311
204
 
@@ -315,16 +208,9 @@ function formatCompanionCard(result) {
315
208
  // ── Interactive mode ─────────────────────────────────────────────────────
316
209
 
317
210
  async function interactiveMode(binaryPath, configPath, userId) {
318
- const binaryData = readFileSync(binaryPath);
319
- const currentSalt = findCurrentSalt(binaryData, userId);
320
- if (!currentSalt) {
321
- console.error("✗ Could not find companion salt in binary.");
322
- process.exit(1);
323
- }
324
- const currentRoll = rollFrom(currentSalt, userId);
211
+ const { currentSalt, currentRoll } = readCurrentCompanion(binaryPath, userId);
325
212
 
326
- const { runInteractiveUI } = await import("./ui.jsx");
327
- await runInteractiveUI({
213
+ const uiOpts = {
328
214
  currentRoll,
329
215
  currentSalt,
330
216
  binaryPath,
@@ -334,6 +220,7 @@ async function interactiveMode(binaryPath, configPath, userId) {
334
220
  patchBinary,
335
221
  resignBinary,
336
222
  clearCompanion,
223
+ getPatchability,
337
224
  isClaudeRunning,
338
225
  rollFrom,
339
226
  matches,
@@ -342,7 +229,16 @@ async function interactiveMode(binaryPath, configPath, userId) {
342
229
  RARITY_LABELS,
343
230
  EYES,
344
231
  HATS,
345
- });
232
+ STAT_NAMES,
233
+ };
234
+
235
+ try {
236
+ const { runInteractiveUI } = await import("./ui.jsx");
237
+ await runInteractiveUI(uiOpts);
238
+ } catch {
239
+ const { runInteractiveUI } = await import("./ui-fallback.js");
240
+ await runInteractiveUI(uiOpts);
241
+ }
346
242
  }
347
243
 
348
244
  // ── Non-interactive mode ─────────────────────────────────────────────────
@@ -353,90 +249,78 @@ async function nonInteractiveMode(args, binaryPath, configPath, userId) {
353
249
  console.log(` User ID: ${userId.slice(0, 8)}...`);
354
250
 
355
251
  if (args.restore) {
356
- const backupPath = binaryPath + ".backup";
357
- if (!existsSync(backupPath)) {
358
- console.error(" ✗ No backup found at", backupPath);
359
- process.exit(1);
252
+ const patchability = assertPatchable(binaryPath);
253
+ const { backupPath } = patchability;
254
+ if (!existsSync(backupPath)) fail(` ✗ No backup found at ${backupPath}`);
255
+
256
+ try {
257
+ copyFileSync(backupPath, binaryPath);
258
+ resignBinary(binaryPath);
259
+ clearCompanion(configPath);
260
+ } catch (err) {
261
+ fail(` ✗ ${err.message}`);
360
262
  }
361
- copyFileSync(backupPath, binaryPath);
362
- resignBinary(binaryPath);
363
- clearCompanion(configPath);
364
- console.log(" ✓ Restored. Restart Claude Code and run /buddy.");
263
+
264
+ console.log(" ✓ Restored! Restart Claude Code and say /buddy to see your original friend.");
365
265
  return;
366
266
  }
367
267
 
368
- const binaryData = readFileSync(binaryPath);
369
- const currentSalt = findCurrentSalt(binaryData, userId);
370
- if (!currentSalt) {
371
- console.error(" ✗ Could not find companion salt in binary.");
372
- process.exit(1);
373
- }
268
+ const { currentSalt, currentRoll } = readCurrentCompanion(binaryPath, userId);
374
269
 
375
270
  if (args.current) {
376
- const result = rollFrom(currentSalt, userId);
377
271
  console.log(`\n Current companion (salt: ${currentSalt}):`);
378
- console.log(formatCompanionCard(result));
272
+ console.log(formatCompanionCard(currentRoll));
379
273
  console.log();
380
274
  return;
381
275
  }
382
276
 
383
- const target = {};
384
- if (args.species) {
385
- if (!SPECIES.includes(args.species)) { console.error(` ✗ Unknown species "${args.species}". Use --list.`); process.exit(1); }
386
- target.species = args.species;
387
- }
388
- if (args.rarity) {
389
- if (!RARITIES.includes(args.rarity)) { console.error(` ✗ Unknown rarity "${args.rarity}". Use --list.`); process.exit(1); }
390
- target.rarity = args.rarity;
391
- }
392
- if (args.eye) {
393
- if (!EYES.includes(args.eye)) { console.error(` ✗ Unknown eye "${args.eye}". Use --list.`); process.exit(1); }
394
- target.eye = args.eye;
395
- }
396
- if (args.hat) {
397
- if (!HATS.includes(args.hat)) { console.error(` ✗ Unknown hat "${args.hat}". Use --list.`); process.exit(1); }
398
- target.hat = args.hat;
399
- }
400
- if (args.shiny !== undefined) target.shiny = args.shiny;
277
+ const target = buildTargetFromArgs(args);
278
+ if (Object.keys(target).length === 0) fail(" ✗ Tell me what kind of buddy you want! Use --help to see options.");
401
279
 
402
- if (Object.keys(target).length === 0) {
403
- console.error(" ✗ Specify at least one target. Use --help for usage.");
404
- process.exit(1);
405
- }
280
+ const expected = estimateAttempts(target);
281
+ console.log(` Target: ${Object.entries(target).map(([k, v]) => `${k}=${v}`).join(" ")}`);
282
+ console.log(` This might take ~${expected.toLocaleString()} tries\n`);
406
283
 
407
- console.log(` Target: ${Object.entries(target).map(([k, v]) => `${k}=${v}`).join(" ")}\n`);
408
-
409
- const currentRoll = rollFrom(currentSalt, userId);
410
284
  if (matches(currentRoll, target)) {
411
- console.log(" ✓ Already matches!\n" + formatCompanionCard(currentRoll));
285
+ console.log(" ✓ Your buddy already looks like that!\n" + formatCompanionCard(currentRoll));
412
286
  return;
413
287
  }
414
288
 
289
+ const patchability = assertPatchable(binaryPath);
290
+
415
291
  if (isClaudeRunning()) {
416
- console.warn(" ⚠ Claude Code appears to be running. Quit it before patching to avoid issues.");
292
+ console.warn(" ⚠ Claude Code is still running close it first so the changes stick.");
417
293
  }
418
294
 
419
- console.log(" Searching...");
420
- const found = await bruteForce(userId, target, null);
421
- if (!found) {
422
- console.error(" ✗ No matching salt found. Try relaxing constraints.");
423
- process.exit(1);
424
- }
425
- console.log(` ✓ Found in ${found.checked.toLocaleString()} attempts (${(found.elapsed / 1000).toFixed(1)}s)`);
295
+ console.log(" Looking for your buddy...");
296
+ const found = await parallelBruteForce(userId, target, (attempts, elapsed, est, workers) => {
297
+ process.stdout.write(`\r ${formatProgress(attempts, elapsed, est, workers)}`);
298
+ });
299
+ if (!found) fail("\n ✗ Couldn't find a match. Try being less picky!");
300
+ console.log(`\n ✓ Found it! (${found.checked.toLocaleString()} tries, ${(found.elapsed / 1000).toFixed(1)}s)`);
426
301
  console.log(formatCompanionCard(found.result));
427
302
 
428
- const backupPath = binaryPath + ".backup";
303
+ const { backupPath } = patchability;
429
304
  if (!existsSync(backupPath)) {
430
- copyFileSync(binaryPath, backupPath);
431
- console.log(`\n Backup: ${backupPath}`);
305
+ try {
306
+ copyFileSync(binaryPath, backupPath);
307
+ console.log(`\n Backup: ${backupPath}`);
308
+ } catch (err) {
309
+ fail(` ✗ ${err.message}`);
310
+ }
432
311
  }
433
312
 
434
- const patchCount = patchBinary(binaryPath, currentSalt, found.salt);
435
- console.log(` Patched: ${patchCount} occurrence(s)`);
436
- if (resignBinary(binaryPath)) console.log(" Signed: ad-hoc codesign ✓");
437
- clearCompanion(configPath);
438
- console.log(" Config: companion data cleared");
439
- console.log("\n Done! Restart Claude Code and run /buddy.\n");
313
+ try {
314
+ const patchCount = patchBinary(binaryPath, currentSalt, found.salt);
315
+ console.log(" Applied ✓");
316
+ if (resignBinary(binaryPath)) console.log(" Re-signed for macOS ✓");
317
+ clearCompanion(configPath);
318
+ storeSalt(found.salt);
319
+ console.log(" Cleaned up old buddy data ✓");
320
+ console.log("\n All set! Restart Claude Code and say /buddy to meet your new friend.\n");
321
+ } catch (err) {
322
+ fail(` ✗ ${err.message}`);
323
+ }
440
324
  }
441
325
 
442
326
  // ── Main ─────────────────────────────────────────────────────────────────
@@ -449,35 +333,98 @@ async function main() {
449
333
  eye: { type: "string" },
450
334
  hat: { type: "string" },
451
335
  shiny: { type: "boolean", default: undefined },
336
+ peak: { type: "string" },
337
+ dump: { type: "string" },
452
338
  list: { type: "boolean", default: false },
453
339
  restore: { type: "boolean", default: false },
454
340
  current: { type: "boolean", default: false },
341
+ doctor: { type: "boolean", default: false },
342
+ version: { type: "boolean", short: "v", default: false },
343
+ hook: { type: "boolean", default: false },
344
+ unhook: { type: "boolean", default: false },
345
+ "apply-hook": { type: "boolean", default: false },
455
346
  help: { type: "boolean", short: "h", default: false },
456
347
  },
457
348
  strict: false,
458
349
  });
459
350
 
351
+ if (args.version) {
352
+ const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf-8"));
353
+ console.log(`buddy-reroll v${pkg.version}`);
354
+ return;
355
+ }
356
+
460
357
  if (args.help) {
461
358
  console.log(`
462
- buddy-reroll — Reroll your Claude Code companion
359
+ buddy-reroll — Pick the perfect /buddy companion
463
360
 
464
361
  Usage:
465
- bunx buddy-reroll Interactive mode (recommended)
466
- bunx buddy-reroll --species dragon --rarity legendary --eye ✦ --shiny
467
- bunx buddy-reroll --list Show all available options
468
- bunx buddy-reroll --current Show current companion
469
- bunx buddy-reroll --restore Restore original binary
470
-
471
- Flags (all optional omit to leave random):
362
+ buddy-reroll Pick your buddy (interactive)
363
+ buddy-reroll --species dragon --rarity legendary --eye ✦ --shiny
364
+ buddy-reroll --list See all options
365
+ buddy-reroll --current Show current buddy
366
+ buddy-reroll --doctor Check setup
367
+ buddy-reroll --restore Undo changes
368
+ buddy-reroll --hook Keep my buddy after updates
369
+ buddy-reroll --unhook Stop keeping after updates
370
+
371
+ Appearance (all optional — skip to leave random):
472
372
  --species <name> ${SPECIES.join(", ")}
473
373
  --rarity <name> ${RARITIES.join(", ")}
474
374
  --eye <char> ${EYES.join(" ")}
475
375
  --hat <name> ${HATS.join(", ")}
476
376
  --shiny / --no-shiny
377
+
378
+ Stats (optional):
379
+ --peak <stat> Best at: ${STAT_NAMES.join(", ")}
380
+ --dump <stat> Worst at (can't match peak)
381
+
382
+ Other:
383
+ --version, -v
477
384
  `);
478
385
  return;
479
386
  }
480
387
 
388
+ if (args.hook) {
389
+ const result = installHook();
390
+ if (result.installed) console.log(`✓ Got it — your buddy will survive Claude updates now.\n Settings: ${result.path}`);
391
+ else console.log(" Already set up!");
392
+ return;
393
+ }
394
+
395
+ if (args.unhook) {
396
+ const result = removeHook();
397
+ if (result.removed) console.log("✓ Removed — your buddy won't be kept after updates anymore.");
398
+ else console.log(" Nothing to remove.");
399
+ return;
400
+ }
401
+
402
+ if (args["apply-hook"]) {
403
+ try {
404
+ const stored = readStoredSalt();
405
+ if (!stored) process.exit(0);
406
+ const bp = findBinaryPath();
407
+ const cp = findConfigPath();
408
+ if (!bp || !cp) process.exit(0);
409
+ const uid = getUserId(cp);
410
+ const binaryData = readFileSync(bp);
411
+ const currentSalt = findCurrentSalt(binaryData);
412
+ if (!currentSalt) process.exit(0);
413
+ if (currentSalt === stored.salt) process.exit(0);
414
+ const patchability = getPatchability(bp);
415
+ if (!patchability.ok) process.exit(0);
416
+ patchBinary(bp, currentSalt, stored.salt);
417
+ resignBinary(bp);
418
+ clearCompanion(cp);
419
+ } catch {}
420
+ process.exit(0);
421
+ }
422
+
423
+ if (args.doctor) {
424
+ console.log(`\n${formatDoctorReport(getDoctorReport(), "buddy-reroll doctor")}\n`);
425
+ return;
426
+ }
427
+
481
428
  if (args.list) {
482
429
  console.log("\n buddy-reroll — available options\n");
483
430
  console.log(" Species: ", SPECIES.join(", "));
@@ -490,23 +437,21 @@ async function main() {
490
437
 
491
438
  const binaryPath = findBinaryPath();
492
439
  if (!binaryPath) {
493
- console.error("✗ Could not find Claude Code binary. Is it installed?");
494
- process.exit(1);
440
+ const override = getClaudeBinaryOverride();
441
+ if (override) fail(`✗ CLAUDE_BINARY_PATH is set to "${override}" but no valid Claude binary was found at that path.`);
442
+ fail("✗ Could not find Claude Code binary. Checked PATH and known install locations.");
495
443
  }
496
444
 
497
445
  const configPath = findConfigPath();
498
- if (!configPath) {
499
- console.error("✗ Could not find Claude Code config file.");
500
- process.exit(1);
501
- }
446
+ if (!configPath) fail("✗ Could not find Claude Code config file. Checked ~/.claude/.config.json and ~/.claude.json.");
502
447
 
503
448
  const userId = getUserId(configPath);
504
449
  if (userId === "anon") {
505
- console.warn("⚠ No user ID found — using anonymous identity. Roll will change if you log in later.");
450
+ console.warn("⚠ No user ID found — using anonymous. Your buddy might change when you log in.");
506
451
  }
507
452
 
508
- const hasTargetFlags = args.species || args.rarity || args.eye || args.hat || args.shiny !== undefined;
509
- const isCommand = args.restore || args.current;
453
+ const hasTargetFlags = args.species || args.rarity || args.eye || args.hat || args.shiny !== undefined || args.peak || args.dump;
454
+ const isCommand = args.restore || args.current || args.doctor || args.hook || args.unhook || args["apply-hook"];
510
455
 
511
456
  if (!hasTargetFlags && !isCommand) {
512
457
  await interactiveMode(binaryPath, configPath, userId);