buddy-reroll 0.3.0 → 0.3.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/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { readFileSync, writeFileSync, existsSync, copyFileSync, renameSync, unlinkSync } 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) {
@@ -89,7 +97,19 @@ function patchBinary(binaryPath, oldSalt, newSalt) {
89
97
  try {
90
98
  const tmpPath = binaryPath + ".tmp";
91
99
  writeFileSync(tmpPath, data);
92
- renameSync(tmpPath, binaryPath);
100
+ try {
101
+ renameSync(tmpPath, binaryPath);
102
+ } catch {
103
+ if (existsSync(binaryPath + ".backup")) {
104
+ try { unlinkSync(binaryPath); } catch {}
105
+ }
106
+ renameSync(tmpPath, binaryPath);
107
+ }
108
+
109
+ const verify = readFileSync(binaryPath);
110
+ const found = verify.indexOf(Buffer.from(newSalt));
111
+ if (found === -1) throw new Error("Patch verification failed — new salt not found after write");
112
+
93
113
  return count;
94
114
  } catch (err) {
95
115
  try { unlinkSync(binaryPath + ".tmp"); } catch {}
@@ -97,7 +117,10 @@ function patchBinary(binaryPath, oldSalt, newSalt) {
97
117
  sleepMs(2000);
98
118
  continue;
99
119
  }
100
- throw new Error(`Failed to write binary: ${err.message}${isWin ? " (ensure Claude Code is fully closed)" : ""}`);
120
+ if (isWin && (err.code === "EPERM" || err.code === "EBUSY")) {
121
+ throw new Error("Can't write — Claude Code might still be running. Close it and try again.");
122
+ }
123
+ throw new Error(`Failed to write: ${err.message}`);
101
124
  }
102
125
  }
103
126
  }
@@ -106,10 +129,12 @@ function resignBinary(binaryPath) {
106
129
  if (platform() !== "darwin") return false;
107
130
  try {
108
131
  execFileSync("codesign", ["-s", "-", "--force", binaryPath], {
109
- stdio: "ignore",
132
+ stdio: ["ignore", "pipe", "pipe"],
133
+ timeout: 30000,
110
134
  });
111
135
  return true;
112
- } catch {
136
+ } catch (err) {
137
+ console.warn(` ⚠ Code signing failed: ${err.message}\n Try manually: codesign --force --sign - "${binaryPath}"`);
113
138
  return false;
114
139
  }
115
140
  }
@@ -130,7 +155,32 @@ function fail(message) {
130
155
 
131
156
  function readCurrentCompanion(binaryPath, userId) {
132
157
  const binaryData = readFileSync(binaryPath);
133
- const currentSalt = findCurrentSalt(binaryData);
158
+ let currentSalt = findCurrentSalt(binaryData);
159
+
160
+ if (!currentSalt) {
161
+ const stored = readStoredSalt();
162
+ if (stored) {
163
+ const storedBuf = Buffer.from(stored.salt);
164
+ if (binaryData.includes(storedBuf)) {
165
+ currentSalt = stored.salt;
166
+ }
167
+ }
168
+ }
169
+
170
+ if (!currentSalt) {
171
+ const backupPath = binaryPath + ".backup";
172
+ if (existsSync(backupPath)) {
173
+ console.log(" ⚠ Can't find salt in binary — restoring from backup...");
174
+ try {
175
+ copyFileSync(backupPath, binaryPath);
176
+ resignBinary(binaryPath);
177
+ const restored = readFileSync(binaryPath);
178
+ currentSalt = findCurrentSalt(restored);
179
+ if (currentSalt) console.log(" ✓ Restored successfully.");
180
+ } catch {}
181
+ }
182
+ }
183
+
134
184
  if (!currentSalt) fail(" ✗ Couldn't read your current buddy from the Claude binary.");
135
185
  return { currentSalt, currentRoll: rollFrom(currentSalt, userId) };
136
186
  }
@@ -216,7 +266,7 @@ async function interactiveMode(binaryPath, configPath, userId) {
216
266
  binaryPath,
217
267
  configPath,
218
268
  userId,
219
- bruteForce,
269
+ bruteForce: parallelBruteForce,
220
270
  patchBinary,
221
271
  resignBinary,
222
272
  clearCompanion,
@@ -230,6 +280,8 @@ async function interactiveMode(binaryPath, configPath, userId) {
230
280
  EYES,
231
281
  HATS,
232
282
  STAT_NAMES,
283
+ storeSalt,
284
+ installHook,
233
285
  };
234
286
 
235
287
  try {
@@ -277,25 +329,30 @@ async function nonInteractiveMode(args, binaryPath, configPath, userId) {
277
329
  const target = buildTargetFromArgs(args);
278
330
  if (Object.keys(target).length === 0) fail(" ✗ Tell me what kind of buddy you want! Use --help to see options.");
279
331
 
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`);
332
+ const patchability = assertPatchable(binaryPath);
283
333
 
284
334
  if (matches(currentRoll, target)) {
285
335
  console.log(" ✓ Your buddy already looks like that!\n" + formatCompanionCard(currentRoll));
286
336
  return;
287
337
  }
288
338
 
289
- const patchability = assertPatchable(binaryPath);
339
+ const expected = estimateAttempts(target);
340
+ console.log(` Target: ${Object.entries(target).map(([k, v]) => `${k}=${v}`).join(" ")}`);
341
+ console.log(` This might take ~${expected.toLocaleString()} tries\n`);
290
342
 
291
343
  if (isClaudeRunning()) {
292
344
  console.warn(" ⚠ Claude Code is still running — close it first so the changes stick.");
293
345
  }
294
346
 
295
347
  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
- });
348
+ let found;
349
+ try {
350
+ found = await parallelBruteForce(userId, target, (attempts, elapsed, est, workers) => {
351
+ process.stdout.write(`\r ${formatProgress(attempts, elapsed, est, workers)}`);
352
+ });
353
+ } catch (err) {
354
+ fail(`\n ✗ ${err.message}`);
355
+ }
299
356
  if (!found) fail("\n ✗ Couldn't find a match. Try being less picky!");
300
357
  console.log(`\n ✓ Found it! (${found.checked.toLocaleString()} tries, ${(found.elapsed / 1000).toFixed(1)}s)`);
301
358
  console.log(formatCompanionCard(found.result));
@@ -316,8 +373,9 @@ async function nonInteractiveMode(args, binaryPath, configPath, userId) {
316
373
  if (resignBinary(binaryPath)) console.log(" Re-signed for macOS ✓");
317
374
  clearCompanion(configPath);
318
375
  storeSalt(found.salt);
376
+ try { installHook(); } catch {}
319
377
  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");
378
+ 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
379
  } catch (err) {
322
380
  fail(` ✗ ${err.message}`);
323
381
  }
@@ -365,8 +423,7 @@ async function main() {
365
423
  buddy-reroll --current Show current buddy
366
424
  buddy-reroll --doctor Check setup
367
425
  buddy-reroll --restore Undo changes
368
- buddy-reroll --hook Keep my buddy after updates
369
- buddy-reroll --unhook Stop keeping after updates
426
+ buddy-reroll --unhook Stop auto-keeping after updates
370
427
 
371
428
  Appearance (all optional — skip to leave random):
372
429
  --species <name> ${SPECIES.join(", ")}
@@ -413,8 +470,17 @@ async function main() {
413
470
  if (currentSalt === stored.salt) process.exit(0);
414
471
  const patchability = getPatchability(bp);
415
472
  if (!patchability.ok) process.exit(0);
473
+ const backupPath = patchability.backupPath;
474
+ if (!existsSync(backupPath)) copyFileSync(bp, backupPath);
416
475
  patchBinary(bp, currentSalt, stored.salt);
417
- resignBinary(bp);
476
+ if (platform() === "darwin") {
477
+ try {
478
+ execFileSync("codesign", ["-s", "-", "--force", bp], { stdio: "ignore", timeout: 30000 });
479
+ } catch {
480
+ copyFileSync(backupPath, bp);
481
+ process.exit(1);
482
+ }
483
+ }
418
484
  clearCompanion(cp);
419
485
  } catch {}
420
486
  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.1",
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
@@ -183,11 +183,21 @@ function SearchStep({ userId, target, bruteForce, onFound, onFail, isActive }) {
183
183
  if (hasStarted.current) return;
184
184
  hasStarted.current = true;
185
185
  (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
- });
186
+ let found;
187
+ try {
188
+ found = await bruteForce(userId, target, (attempts, elapsed, expected, workers) => {
189
+ if (!cancelRef.current) {
190
+ const pct = Math.min(100, Math.round((attempts / expected) * 100));
191
+ const rate = attempts / (elapsed / 1000);
192
+ const rateStr = rate >= 1e6 ? `${(rate / 1e6).toFixed(1)}M` : `${(rate / 1e3).toFixed(1)}k`;
193
+ const eta = Math.max(0, (expected - attempts) / rate);
194
+ setProgress(`${pct}% | ${rateStr} tries/s | ~${Math.round(eta)}s left | ${workers} cores`);
195
+ }
196
+ });
197
+ } catch {
198
+ if (!cancelRef.current) onFail();
199
+ return;
200
+ }
191
201
  if (cancelRef.current) return;
192
202
  if (found) onFound(found);
193
203
  else onFail();
@@ -221,7 +231,7 @@ function DoneStep({ messages, isActive }) {
221
231
  <Text bold>
222
232
  {hasErrors
223
233
  ? "Something went wrong — check the issue above and try again."
224
- : "All set! Restart Claude Code and say /buddy to meet your new friend."}
234
+ : "All set! Your buddy will stick around even after updates. Restart Claude Code and say /buddy!"}
225
235
  </Text>
226
236
  </Box>
227
237
  <KeyHint>Press any key to exit</KeyHint>
@@ -236,8 +246,7 @@ function getPrevStep(current, rarity, peak) {
236
246
  if (idx <= 0) return null;
237
247
  let prev = STEP_ORDER[idx - 1];
238
248
  if (prev === "hat" && rarity === "common") prev = "eye";
239
- if (prev === "dump" && peak === null) prev = "shiny";
240
- if (prev === "peak") prev = "shiny";
249
+ if (prev === "dump" && !peak) prev = "peak";
241
250
  return prev;
242
251
  }
243
252
 
@@ -247,6 +256,7 @@ function App({ opts }) {
247
256
  currentRoll, currentSalt, binaryPath, configPath, userId,
248
257
  bruteForce, patchBinary, resignBinary, clearCompanion, getPatchability, isClaudeRunning,
249
258
  rollFrom, matches, SPECIES, RARITIES, RARITY_LABELS, EYES, HATS, STAT_NAMES,
259
+ storeSalt, installHook,
250
260
  } = opts;
251
261
 
252
262
  const [step, setStep] = useState("action");
@@ -394,21 +404,11 @@ function App({ opts }) {
394
404
  isActive={step === "shiny"}
395
405
  onConfirm={() => {
396
406
  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
- }
407
+ setStep("peak");
403
408
  }}
404
409
  onCancel={() => {
405
410
  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
- }
411
+ setStep("peak");
412
412
  }}
413
413
  onBack={() => goBack()}
414
414
  />
@@ -416,7 +416,7 @@ function App({ opts }) {
416
416
 
417
417
  {step === "peak" && (
418
418
  <ListSelect
419
- label="Peak stat (highest)"
419
+ label="Best at"
420
420
  options={[
421
421
  { label: "Any (random)", value: "any" },
422
422
  ...(STAT_NAMES || []).map(s => ({ label: s, value: s })),
@@ -437,7 +437,7 @@ function App({ opts }) {
437
437
 
438
438
  {step === "dump" && (
439
439
  <ListSelect
440
- label="Dump stat (lowest)"
440
+ label="Worst at"
441
441
  options={[
442
442
  { label: "Any (random)", value: "any" },
443
443
  ...(STAT_NAMES || []).filter(s => s !== peak).map(s => ({ label: s, value: s })),
@@ -490,7 +490,7 @@ function App({ opts }) {
490
490
 
491
491
  {step === "result" && (
492
492
  <Box flexDirection="column">
493
- <Text bold color="green">✓ Found in {found.checked.toLocaleString()} attempts ({(found.elapsed / 1000).toFixed(1)}s)</Text>
493
+ <Text bold color="green">✓ Found your buddy! ({found.checked.toLocaleString()} tries, {(found.elapsed / 1000).toFixed(1)}s)</Text>
494
494
  <ConfirmSelect
495
495
  label="Apply patch?"
496
496
  isActive={step === "result"}
@@ -514,6 +514,8 @@ function App({ opts }) {
514
514
  msgs.push({ type: "success", text: "Applied!" });
515
515
  if (resignBinary(binaryPath)) msgs.push({ type: "success", text: "Re-signed for macOS" });
516
516
  clearCompanion(configPath);
517
+ if (storeSalt) storeSalt(found.salt);
518
+ if (installHook) installHook();
517
519
  msgs.push({ type: "success", text: "Cleaned up old buddy data" });
518
520
  } catch (err) {
519
521
  msgs.push({ type: "error", text: err.message });