buddy-reroll 0.3.0 → 0.3.2

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,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { readFileSync, writeFileSync, existsSync, copyFileSync, renameSync, unlinkSync } from "fs";
3
+ import { readFileSync, writeFileSync, existsSync, copyFileSync, renameSync, unlinkSync, statSync, chmodSync } from "fs";
4
4
  import { platform } from "os";
5
- import { execFileSync } from "child_process";
5
+ import { execFileSync, spawnSync } from "child_process";
6
6
  import { parseArgs } from "util";
7
7
  import chalk from "chalk";
8
8
  import { renderSprite, colorizeSprite, RARITY_STARS, RARITY_COLORS } from "./sprites.js";
@@ -27,8 +27,16 @@ import { installHook, removeHook, storeSalt, readStoredSalt } from "./lib/hooks.
27
27
 
28
28
  const IS_BUN = typeof Bun !== "undefined";
29
29
  const IS_APPLY_HOOK = process.argv.includes("--apply-hook");
30
+
30
31
  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");
32
+ try {
33
+ const cmd = platform() === "win32" ? "where.exe" : "which";
34
+ const bunPath = execFileSync(cmd, ["bun"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }).trim().split("\n")[0];
35
+ if (bunPath) {
36
+ const result = spawnSync(bunPath, process.argv.slice(1), { stdio: "inherit" });
37
+ process.exit(result.status ?? 0);
38
+ }
39
+ } catch {}
32
40
  }
33
41
 
34
42
  function getUserId(configPath) {
@@ -67,6 +75,7 @@ function patchBinary(binaryPath, oldSalt, newSalt) {
67
75
  throw new Error(`Salt length mismatch: "${oldSalt}" (${oldSalt.length}) vs "${newSalt}" (${newSalt.length})`);
68
76
  }
69
77
 
78
+ const originalMode = statSync(binaryPath).mode;
70
79
  const data = readFileSync(binaryPath);
71
80
  const oldBuf = Buffer.from(oldSalt);
72
81
  const newBuf = Buffer.from(newSalt);
@@ -88,8 +97,22 @@ function patchBinary(binaryPath, oldSalt, newSalt) {
88
97
  for (let attempt = 0; attempt < maxRetries; attempt++) {
89
98
  try {
90
99
  const tmpPath = binaryPath + ".tmp";
91
- writeFileSync(tmpPath, data);
92
- renameSync(tmpPath, binaryPath);
100
+ writeFileSync(tmpPath, data, { mode: originalMode });
101
+ try {
102
+ renameSync(tmpPath, binaryPath);
103
+ } catch {
104
+ if (existsSync(binaryPath + ".backup")) {
105
+ try { unlinkSync(binaryPath); } catch {}
106
+ }
107
+ renameSync(tmpPath, binaryPath);
108
+ }
109
+
110
+ chmodSync(binaryPath, originalMode);
111
+
112
+ const verify = readFileSync(binaryPath);
113
+ const found = verify.indexOf(Buffer.from(newSalt));
114
+ if (found === -1) throw new Error("Patch verification failed — new salt not found after write");
115
+
93
116
  return count;
94
117
  } catch (err) {
95
118
  try { unlinkSync(binaryPath + ".tmp"); } catch {}
@@ -97,7 +120,10 @@ function patchBinary(binaryPath, oldSalt, newSalt) {
97
120
  sleepMs(2000);
98
121
  continue;
99
122
  }
100
- throw new Error(`Failed to write binary: ${err.message}${isWin ? " (ensure Claude Code is fully closed)" : ""}`);
123
+ if (isWin && (err.code === "EPERM" || err.code === "EBUSY")) {
124
+ throw new Error("Can't write — Claude Code might still be running. Close it and try again.");
125
+ }
126
+ throw new Error(`Failed to write: ${err.message}`);
101
127
  }
102
128
  }
103
129
  }
@@ -106,10 +132,12 @@ function resignBinary(binaryPath) {
106
132
  if (platform() !== "darwin") return false;
107
133
  try {
108
134
  execFileSync("codesign", ["-s", "-", "--force", binaryPath], {
109
- stdio: "ignore",
135
+ stdio: ["ignore", "pipe", "pipe"],
136
+ timeout: 30000,
110
137
  });
111
138
  return true;
112
- } catch {
139
+ } catch (err) {
140
+ console.warn(` ⚠ Code signing failed: ${err.message}\n Try manually: codesign --force --sign - "${binaryPath}"`);
113
141
  return false;
114
142
  }
115
143
  }
@@ -130,7 +158,32 @@ function fail(message) {
130
158
 
131
159
  function readCurrentCompanion(binaryPath, userId) {
132
160
  const binaryData = readFileSync(binaryPath);
133
- const currentSalt = findCurrentSalt(binaryData);
161
+ let currentSalt = findCurrentSalt(binaryData);
162
+
163
+ if (!currentSalt) {
164
+ const stored = readStoredSalt();
165
+ if (stored) {
166
+ const storedBuf = Buffer.from(stored.salt);
167
+ if (binaryData.includes(storedBuf)) {
168
+ currentSalt = stored.salt;
169
+ }
170
+ }
171
+ }
172
+
173
+ if (!currentSalt) {
174
+ const backupPath = binaryPath + ".backup";
175
+ if (existsSync(backupPath)) {
176
+ console.log(" ⚠ Can't find salt in binary — restoring from backup...");
177
+ try {
178
+ copyFileSync(backupPath, binaryPath);
179
+ resignBinary(binaryPath);
180
+ const restored = readFileSync(binaryPath);
181
+ currentSalt = findCurrentSalt(restored);
182
+ if (currentSalt) console.log(" ✓ Restored successfully.");
183
+ } catch {}
184
+ }
185
+ }
186
+
134
187
  if (!currentSalt) fail(" ✗ Couldn't read your current buddy from the Claude binary.");
135
188
  return { currentSalt, currentRoll: rollFrom(currentSalt, userId) };
136
189
  }
@@ -216,7 +269,7 @@ async function interactiveMode(binaryPath, configPath, userId) {
216
269
  binaryPath,
217
270
  configPath,
218
271
  userId,
219
- bruteForce,
272
+ bruteForce: parallelBruteForce,
220
273
  patchBinary,
221
274
  resignBinary,
222
275
  clearCompanion,
@@ -230,6 +283,8 @@ async function interactiveMode(binaryPath, configPath, userId) {
230
283
  EYES,
231
284
  HATS,
232
285
  STAT_NAMES,
286
+ storeSalt,
287
+ installHook,
233
288
  };
234
289
 
235
290
  try {
@@ -277,25 +332,30 @@ async function nonInteractiveMode(args, binaryPath, configPath, userId) {
277
332
  const target = buildTargetFromArgs(args);
278
333
  if (Object.keys(target).length === 0) fail(" ✗ Tell me what kind of buddy you want! Use --help to see options.");
279
334
 
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`);
335
+ const patchability = assertPatchable(binaryPath);
283
336
 
284
337
  if (matches(currentRoll, target)) {
285
338
  console.log(" ✓ Your buddy already looks like that!\n" + formatCompanionCard(currentRoll));
286
339
  return;
287
340
  }
288
341
 
289
- const patchability = assertPatchable(binaryPath);
342
+ const expected = estimateAttempts(target);
343
+ console.log(` Target: ${Object.entries(target).map(([k, v]) => `${k}=${v}`).join(" ")}`);
344
+ console.log(` This might take ~${expected.toLocaleString()} tries\n`);
290
345
 
291
346
  if (isClaudeRunning()) {
292
347
  console.warn(" ⚠ Claude Code is still running — close it first so the changes stick.");
293
348
  }
294
349
 
295
350
  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
- });
351
+ let found;
352
+ try {
353
+ found = await parallelBruteForce(userId, target, (attempts, elapsed, est, workers) => {
354
+ process.stdout.write(`\r ${formatProgress(attempts, elapsed, est, workers)}`);
355
+ });
356
+ } catch (err) {
357
+ fail(`\n ✗ ${err.message}`);
358
+ }
299
359
  if (!found) fail("\n ✗ Couldn't find a match. Try being less picky!");
300
360
  console.log(`\n ✓ Found it! (${found.checked.toLocaleString()} tries, ${(found.elapsed / 1000).toFixed(1)}s)`);
301
361
  console.log(formatCompanionCard(found.result));
@@ -316,8 +376,9 @@ async function nonInteractiveMode(args, binaryPath, configPath, userId) {
316
376
  if (resignBinary(binaryPath)) console.log(" Re-signed for macOS ✓");
317
377
  clearCompanion(configPath);
318
378
  storeSalt(found.salt);
379
+ try { installHook(); } catch {}
319
380
  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");
381
+ console.log("\n All set! Your buddy will stick around even after Claude updates.\n Restart Claude Code and say /buddy to meet your new friend.\n");
321
382
  } catch (err) {
322
383
  fail(` ✗ ${err.message}`);
323
384
  }
@@ -365,8 +426,7 @@ async function main() {
365
426
  buddy-reroll --current Show current buddy
366
427
  buddy-reroll --doctor Check setup
367
428
  buddy-reroll --restore Undo changes
368
- buddy-reroll --hook Keep my buddy after updates
369
- buddy-reroll --unhook Stop keeping after updates
429
+ buddy-reroll --unhook Stop auto-keeping after updates
370
430
 
371
431
  Appearance (all optional — skip to leave random):
372
432
  --species <name> ${SPECIES.join(", ")}
@@ -413,8 +473,17 @@ async function main() {
413
473
  if (currentSalt === stored.salt) process.exit(0);
414
474
  const patchability = getPatchability(bp);
415
475
  if (!patchability.ok) process.exit(0);
476
+ const backupPath = patchability.backupPath;
477
+ if (!existsSync(backupPath)) copyFileSync(bp, backupPath);
416
478
  patchBinary(bp, currentSalt, stored.salt);
417
- resignBinary(bp);
479
+ if (platform() === "darwin") {
480
+ try {
481
+ execFileSync("codesign", ["-s", "-", "--force", bp], { stdio: "ignore", timeout: 30000 });
482
+ } catch {
483
+ copyFileSync(backupPath, bp);
484
+ process.exit(1);
485
+ }
486
+ }
418
487
  clearCompanion(cp);
419
488
  } catch {}
420
489
  process.exit(0);
package/lib/estimator.js CHANGED
@@ -8,7 +8,7 @@ export function estimateAttempts(target) {
8
8
  if (target.species) probability *= 1 / 18;
9
9
  if (target.rarity) probability *= RARITY_WEIGHTS[target.rarity] / 100;
10
10
  if (target.eye) probability *= 1 / 6;
11
- if (target.hat && target.rarity !== "common") probability *= 1 / 8;
11
+ if (target.hat && target.hat !== "none" && target.rarity !== "common") probability *= 1 / 8;
12
12
  if (target.shiny === true) probability *= 0.01;
13
13
  if (target.peak) probability *= 1 / 5;
14
14
  if (target.dump) probability *= 1 / 4;
package/lib/finder.js CHANGED
@@ -14,6 +14,7 @@ export async function parallelBruteForce(userId, target, onProgress) {
14
14
  return new Promise((resolve, reject) => {
15
15
  const children = [];
16
16
  const workerStdout = [];
17
+ const workerStderr = [];
17
18
  const workerAttempts = [];
18
19
  let resolved = false;
19
20
  let exited = 0;
@@ -26,6 +27,7 @@ export async function parallelBruteForce(userId, target, onProgress) {
26
27
 
27
28
  for (let i = 0; i < numWorkers; i++) {
28
29
  workerStdout[i] = "";
30
+ workerStderr[i] = "";
29
31
  workerAttempts[i] = 0;
30
32
 
31
33
  const child = spawn(process.execPath, [WORKER_SCRIPT, userId, JSON.stringify(target)], {
@@ -38,7 +40,9 @@ export async function parallelBruteForce(userId, target, onProgress) {
38
40
  });
39
41
 
40
42
  child.stderr.on("data", (chunk) => {
41
- const lines = chunk.toString().split("\n").filter(Boolean);
43
+ const text = chunk.toString();
44
+ workerStderr[i] += text;
45
+ const lines = text.split("\n").filter(Boolean);
42
46
  for (const line of lines) {
43
47
  try {
44
48
  const progress = JSON.parse(line);
@@ -80,14 +84,17 @@ export async function parallelBruteForce(userId, target, onProgress) {
80
84
  }
81
85
 
82
86
  if (exited === numWorkers && !resolved) {
83
- resolve(null);
87
+ const totalAttempts = workerAttempts.reduce((a, b) => a + b, 0);
88
+ const stderr = workerStderr.filter(Boolean).join("\n").trim();
89
+ const detail = stderr ? `\n Worker output: ${stderr.slice(0, 200)}` : "";
90
+ reject(new Error(`All ${numWorkers} workers exited without finding a match (${totalAttempts.toLocaleString()} tries).${detail}`));
84
91
  }
85
92
  });
86
93
 
87
- child.on("error", () => {
94
+ child.on("error", (err) => {
88
95
  exited++;
89
96
  if (exited === numWorkers && !resolved) {
90
- resolve(null);
97
+ reject(new Error(`Worker failed to start: ${err.message}`));
91
98
  }
92
99
  });
93
100
  }
@@ -97,7 +104,8 @@ export async function parallelBruteForce(userId, target, onProgress) {
97
104
  if (!resolved) {
98
105
  resolved = true;
99
106
  killAll();
100
- resolve(null);
107
+ const totalAttempts = workerAttempts.reduce((a, b) => a + b, 0);
108
+ reject(new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s (${totalAttempts.toLocaleString()} tries). This combination might be extremely rare — try fewer constraints.`));
101
109
  }
102
110
  }, timeoutMs);
103
111
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "buddy-reroll",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Reroll your Claude Code buddy companion to any species/rarity/eye/hat/shiny combo",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/worker.js CHANGED
@@ -14,7 +14,13 @@ function randomSalt() {
14
14
  }
15
15
 
16
16
  const userId = process.argv[2];
17
- const target = JSON.parse(process.argv[3]);
17
+ let target;
18
+ try {
19
+ target = JSON.parse(process.argv[3]);
20
+ } catch {
21
+ process.stderr.write("Invalid target JSON\n");
22
+ process.exit(1);
23
+ }
18
24
 
19
25
  if (!userId || !target) {
20
26
  process.stderr.write("Usage: worker.js <userId> '<targetJSON>'\n");
package/ui-fallback.js CHANGED
@@ -30,6 +30,7 @@ export async function runInteractiveUI(opts) {
30
30
  currentRoll, currentSalt, binaryPath, configPath, userId,
31
31
  bruteForce, patchBinary, resignBinary, clearCompanion, getPatchability, isClaudeRunning,
32
32
  rollFrom, matches, SPECIES, RARITIES, RARITY_LABELS, EYES, HATS, STAT_NAMES,
33
+ storeSalt, installHook,
33
34
  } = opts;
34
35
 
35
36
  console.log(chalk.bold.dim("\n buddy-reroll\n"));
@@ -151,9 +152,19 @@ export async function runInteractiveUI(opts) {
151
152
  if (!proceed) return;
152
153
 
153
154
  console.log(" Looking for your buddy...");
154
- const found = await bruteForce(userId, target, (checked, elapsed) => {
155
- process.stdout.write(`\r ${(checked / 1e6).toFixed(1)}M tries (${(elapsed / 1000).toFixed(1)}s)`);
156
- });
155
+ let found;
156
+ try {
157
+ found = await bruteForce(userId, target, (attempts, elapsed, expected, workers) => {
158
+ const pct = Math.min(100, Math.round((attempts / expected) * 100));
159
+ const rate = attempts / (elapsed / 1000);
160
+ const rateStr = rate >= 1e6 ? `${(rate / 1e6).toFixed(1)}M` : `${(rate / 1e3).toFixed(1)}k`;
161
+ const eta = Math.max(0, (expected - attempts) / rate);
162
+ process.stdout.write(`\r ${pct}% | ${rateStr} tries/s | ~${Math.round(eta)}s left | ${workers} cores`);
163
+ });
164
+ } catch (err) {
165
+ console.log(chalk.red(`\n✗ ${err.message}`));
166
+ return;
167
+ }
157
168
 
158
169
  if (!found) {
159
170
  console.log(chalk.red("\n✗ Couldn't find a match. Try being less picky!"));
@@ -174,7 +185,9 @@ export async function runInteractiveUI(opts) {
174
185
  if (resignBinary(binaryPath)) console.log(" Re-signed for macOS ✓");
175
186
  clearCompanion(configPath);
176
187
  console.log(" Cleaned up old buddy data ✓");
177
- console.log(chalk.bold("\n All set! Restart Claude Code and say /buddy to meet your new friend.\n"));
188
+ if (storeSalt) storeSalt(found.salt);
189
+ if (installHook) installHook();
190
+ console.log(chalk.bold("\n All set! Your buddy will stick around even after Claude updates.\n Restart Claude Code and say /buddy to meet your new friend.\n"));
178
191
  } catch (err) {
179
192
  console.log(chalk.red(`\n✗ ${err.message}`));
180
193
  }
package/ui.jsx CHANGED
@@ -130,6 +130,7 @@ function ShowCurrentStep({ isActive }) {
130
130
 
131
131
  useInput(() => {
132
132
  exit();
133
+ setTimeout(() => process.exit(0), 100);
133
134
  }, { isActive });
134
135
 
135
136
  return (
@@ -183,11 +184,21 @@ function SearchStep({ userId, target, bruteForce, onFound, onFail, isActive }) {
183
184
  if (hasStarted.current) return;
184
185
  hasStarted.current = true;
185
186
  (async () => {
186
- const found = await bruteForce(userId, target, (checked, elapsed) => {
187
- if (!cancelRef.current) {
188
- setProgress(`${(checked / 1e6).toFixed(0)}M salts checked (${(elapsed / 1000).toFixed(1)}s)`);
189
- }
190
- });
187
+ let found;
188
+ try {
189
+ found = await bruteForce(userId, target, (attempts, elapsed, expected, workers) => {
190
+ if (!cancelRef.current) {
191
+ const pct = Math.min(100, Math.round((attempts / expected) * 100));
192
+ const rate = attempts / (elapsed / 1000);
193
+ const rateStr = rate >= 1e6 ? `${(rate / 1e6).toFixed(1)}M` : `${(rate / 1e3).toFixed(1)}k`;
194
+ const eta = Math.max(0, (expected - attempts) / rate);
195
+ setProgress(`${pct}% | ${rateStr} tries/s | ~${Math.round(eta)}s left | ${workers} cores`);
196
+ }
197
+ });
198
+ } catch {
199
+ if (!cancelRef.current) onFail();
200
+ return;
201
+ }
191
202
  if (cancelRef.current) return;
192
203
  if (found) onFound(found);
193
204
  else onFail();
@@ -208,6 +219,7 @@ function DoneStep({ messages, isActive }) {
208
219
 
209
220
  useInput(() => {
210
221
  exit();
222
+ setTimeout(() => process.exit(0), 100);
211
223
  }, { isActive });
212
224
 
213
225
  return (
@@ -221,7 +233,7 @@ function DoneStep({ messages, isActive }) {
221
233
  <Text bold>
222
234
  {hasErrors
223
235
  ? "Something went wrong — check the issue above and try again."
224
- : "All set! Restart Claude Code and say /buddy to meet your new friend."}
236
+ : "All set! Your buddy will stick around even after updates. Restart Claude Code and say /buddy!"}
225
237
  </Text>
226
238
  </Box>
227
239
  <KeyHint>Press any key to exit</KeyHint>
@@ -236,8 +248,7 @@ function getPrevStep(current, rarity, peak) {
236
248
  if (idx <= 0) return null;
237
249
  let prev = STEP_ORDER[idx - 1];
238
250
  if (prev === "hat" && rarity === "common") prev = "eye";
239
- if (prev === "dump" && peak === null) prev = "shiny";
240
- if (prev === "peak") prev = "shiny";
251
+ if (prev === "dump" && !peak) prev = "peak";
241
252
  return prev;
242
253
  }
243
254
 
@@ -247,6 +258,7 @@ function App({ opts }) {
247
258
  currentRoll, currentSalt, binaryPath, configPath, userId,
248
259
  bruteForce, patchBinary, resignBinary, clearCompanion, getPatchability, isClaudeRunning,
249
260
  rollFrom, matches, SPECIES, RARITIES, RARITY_LABELS, EYES, HATS, STAT_NAMES,
261
+ storeSalt, installHook,
250
262
  } = opts;
251
263
 
252
264
  const [step, setStep] = useState("action");
@@ -394,21 +406,11 @@ function App({ opts }) {
394
406
  isActive={step === "shiny"}
395
407
  onConfirm={() => {
396
408
  setShiny(true);
397
- if (matches(currentRoll, buildTarget(true))) {
398
- setDoneMessages([{ type: "success", text: "Your buddy already looks like that!" }]);
399
- setStep("done");
400
- } else {
401
- setStep("peak");
402
- }
409
+ setStep("peak");
403
410
  }}
404
411
  onCancel={() => {
405
412
  setShiny(false);
406
- if (matches(currentRoll, buildTarget(false))) {
407
- setDoneMessages([{ type: "success", text: "Your buddy already looks like that!" }]);
408
- setStep("done");
409
- } else {
410
- setStep("peak");
411
- }
413
+ setStep("peak");
412
414
  }}
413
415
  onBack={() => goBack()}
414
416
  />
@@ -416,7 +418,7 @@ function App({ opts }) {
416
418
 
417
419
  {step === "peak" && (
418
420
  <ListSelect
419
- label="Peak stat (highest)"
421
+ label="Best at"
420
422
  options={[
421
423
  { label: "Any (random)", value: "any" },
422
424
  ...(STAT_NAMES || []).map(s => ({ label: s, value: s })),
@@ -437,7 +439,7 @@ function App({ opts }) {
437
439
 
438
440
  {step === "dump" && (
439
441
  <ListSelect
440
- label="Dump stat (lowest)"
442
+ label="Worst at"
441
443
  options={[
442
444
  { label: "Any (random)", value: "any" },
443
445
  ...(STAT_NAMES || []).filter(s => s !== peak).map(s => ({ label: s, value: s })),
@@ -490,7 +492,7 @@ function App({ opts }) {
490
492
 
491
493
  {step === "result" && (
492
494
  <Box flexDirection="column">
493
- <Text bold color="green">✓ Found in {found.checked.toLocaleString()} attempts ({(found.elapsed / 1000).toFixed(1)}s)</Text>
495
+ <Text bold color="green">✓ Found your buddy! ({found.checked.toLocaleString()} tries, {(found.elapsed / 1000).toFixed(1)}s)</Text>
494
496
  <ConfirmSelect
495
497
  label="Apply patch?"
496
498
  isActive={step === "result"}
@@ -514,6 +516,8 @@ function App({ opts }) {
514
516
  msgs.push({ type: "success", text: "Applied!" });
515
517
  if (resignBinary(binaryPath)) msgs.push({ type: "success", text: "Re-signed for macOS" });
516
518
  clearCompanion(configPath);
519
+ if (storeSalt) storeSalt(found.salt);
520
+ if (installHook) installHook();
517
521
  msgs.push({ type: "success", text: "Cleaned up old buddy data" });
518
522
  } catch (err) {
519
523
  msgs.push({ type: "error", text: err.message });