buddy-reroll 0.1.1 → 0.2.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.
Files changed (4) hide show
  1. package/index.js +92 -156
  2. package/package.json +7 -3
  3. package/sprites.js +137 -0
  4. package/ui.jsx +458 -0
package/index.js CHANGED
@@ -5,7 +5,8 @@ import { join } from "path";
5
5
  import { homedir, platform } from "os";
6
6
  import { execSync } from "child_process";
7
7
  import { parseArgs } from "util";
8
- import * as p from "@clack/prompts";
8
+ import chalk from "chalk";
9
+ import { renderSprite, colorizeSprite, RARITY_STARS, RARITY_COLORS } from "./sprites.js";
9
10
 
10
11
  if (typeof Bun === "undefined") {
11
12
  console.error("buddy-reroll requires Bun runtime (uses Bun.hash).\nInstall: https://bun.sh");
@@ -99,8 +100,11 @@ function getClaudeConfigDir() {
99
100
  }
100
101
 
101
102
  function findBinaryPath() {
103
+ const isWin = platform() === "win32";
104
+
102
105
  try {
103
- const allPaths = execSync("which -a claude 2>/dev/null", { encoding: "utf-8" }).trim().split("\n");
106
+ const cmd = isWin ? "where.exe claude 2>nul" : "which -a claude 2>/dev/null";
107
+ const allPaths = execSync(cmd, { encoding: "utf-8" }).trim().split("\n");
104
108
  for (const entry of allPaths) {
105
109
  try {
106
110
  const resolved = realpathSync(entry.trim());
@@ -109,10 +113,16 @@ function findBinaryPath() {
109
113
  }
110
114
  } catch {}
111
115
 
112
- const versionsDir = join(homedir(), ".local", "share", "claude", "versions");
113
- if (existsSync(versionsDir)) {
116
+ const versionsDirs = [
117
+ join(homedir(), ".local", "share", "claude", "versions"),
118
+ ...(isWin ? [join(process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local"), "Claude", "versions")] : []),
119
+ ];
120
+ for (const versionsDir of versionsDirs) {
121
+ if (!existsSync(versionsDir)) continue;
114
122
  try {
115
- const versions = readdirSync(versionsDir).sort();
123
+ const versions = readdirSync(versionsDir)
124
+ .filter((f) => !f.includes(".backup"))
125
+ .sort();
116
126
  if (versions.length > 0) return join(versionsDir, versions[versions.length - 1]);
117
127
  } catch {}
118
128
  }
@@ -130,6 +140,12 @@ function findConfigPath() {
130
140
  const defaultPath = join(home, ".claude.json");
131
141
  if (existsSync(defaultPath)) return defaultPath;
132
142
 
143
+ // Windows: check AppData\Roaming\Claude
144
+ if (platform() === "win32" && process.env.APPDATA) {
145
+ const appDataPath = join(process.env.APPDATA, "Claude", "config.json");
146
+ if (existsSync(appDataPath)) return appDataPath;
147
+ }
148
+
133
149
  return null;
134
150
  }
135
151
 
@@ -143,9 +159,9 @@ function getUserId(configPath) {
143
159
  function findCurrentSalt(binaryData, userId) {
144
160
  if (binaryData.includes(Buffer.from(ORIGINAL_SALT))) return ORIGINAL_SALT;
145
161
 
146
- const text = binaryData.toString("utf-8");
162
+ const text = binaryData.toString("latin1");
147
163
 
148
- // Scan for previously patched salts (our known patterns)
164
+ // Scan for previously patched salts
149
165
  const patterns = [
150
166
  new RegExp(`x{${SALT_LEN - 8}}\\d{8}`, "g"),
151
167
  new RegExp(`friend-\\d{4}-.{${SALT_LEN - 12}}`, "g"),
@@ -157,7 +173,6 @@ function findCurrentSalt(binaryData, userId) {
157
173
  }
158
174
  }
159
175
 
160
- // Contextual scan near companion code markers
161
176
  const saltRegex = new RegExp(`"([a-zA-Z0-9_-]{${SALT_LEN}})"`, "g");
162
177
  const candidates = new Set();
163
178
  const markers = ["rollRarity", "CompanionBones", "inspirationSeed", "companionUserId"];
@@ -171,7 +186,6 @@ function findCurrentSalt(binaryData, userId) {
171
186
  }
172
187
  }
173
188
 
174
- // Filter: real salts contain digits or hyphens (rules out "projectSettings" etc.)
175
189
  for (const c of candidates) {
176
190
  if (/[\d-]/.test(c)) return c;
177
191
  }
@@ -181,7 +195,7 @@ function findCurrentSalt(binaryData, userId) {
181
195
 
182
196
  // ── Brute-force ──────────────────────────────────────────────────────────
183
197
 
184
- function bruteForce(userId, target, spinner) {
198
+ async function bruteForce(userId, target, onProgress) {
185
199
  const startTime = Date.now();
186
200
  let checked = 0;
187
201
 
@@ -206,9 +220,11 @@ function bruteForce(userId, target, spinner) {
206
220
  const r = rollFrom(salt, userId);
207
221
  if (matches(r, target)) return { salt, result: r, checked, elapsed: Date.now() - startTime };
208
222
 
223
+ if (checked % 100_000 === 0) {
224
+ await new Promise((r) => setTimeout(r, 0));
225
+ }
209
226
  if (checked % 5_000_000 === 0) {
210
- const secs = ((Date.now() - startTime) / 1000).toFixed(1);
211
- if (spinner) spinner.message(`${(checked / 1e6).toFixed(0)}M salts checked (${secs}s)`);
227
+ if (onProgress) onProgress(checked, Date.now() - startTime);
212
228
  }
213
229
  }
214
230
 
@@ -228,6 +244,10 @@ function matches(roll, target) {
228
244
 
229
245
  function isClaudeRunning() {
230
246
  try {
247
+ if (platform() === "win32") {
248
+ const out = execSync('tasklist /FI "IMAGENAME eq claude.exe" /FO CSV 2>nul', { encoding: "utf-8" });
249
+ return out.toLowerCase().includes("claude.exe");
250
+ }
231
251
  const out = execSync("pgrep -af claude 2>/dev/null", { encoding: "utf-8" });
232
252
  return out.split("\n").some((line) => !line.includes("buddy-reroll") && line.trim().length > 0);
233
253
  } catch {
@@ -256,8 +276,20 @@ function patchBinary(binaryPath, oldSalt, newSalt) {
256
276
 
257
277
  if (count === 0) throw new Error(`Salt "${oldSalt}" not found in binary`);
258
278
 
259
- writeFileSync(binaryPath, data);
260
- return count;
279
+ const isWin = platform() === "win32";
280
+ const maxRetries = isWin ? 3 : 1;
281
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
282
+ try {
283
+ writeFileSync(binaryPath, data);
284
+ return count;
285
+ } catch (err) {
286
+ if (isWin && (err.code === "EACCES" || err.code === "EPERM" || err.code === "EBUSY") && attempt < maxRetries - 1) {
287
+ execSync("timeout /t 2 /nobreak >nul 2>&1", { shell: true, stdio: "ignore" });
288
+ continue;
289
+ }
290
+ throw new Error(`Failed to write binary: ${err.message}${isWin ? " (ensure Claude Code is fully closed)" : ""}`);
291
+ }
292
+ }
261
293
  }
262
294
 
263
295
  function resignBinary(binaryPath) {
@@ -281,165 +313,69 @@ function clearCompanion(configPath) {
281
313
 
282
314
  // ── Display ──────────────────────────────────────────────────────────────
283
315
 
284
- function formatRoll(result) {
316
+ function formatCompanionCard(result) {
317
+ const sprite = renderSprite({ species: result.species, eye: result.eye, hat: result.hat });
318
+ const colored = colorizeSprite(sprite, result.rarity);
319
+ const colorFn = chalk[RARITY_COLORS[result.rarity]] ?? chalk.white;
320
+ const stars = RARITY_STARS[result.rarity] ?? "";
321
+
322
+ const meta = [];
323
+ meta.push(`${result.species} / ${result.rarity}${result.shiny ? " / shiny" : ""}`);
324
+ meta.push(`eye:${result.eye} / hat:${result.hat}`);
325
+ meta.push(stars);
326
+
285
327
  const lines = [];
286
- lines.push(` ${result.species} / ${result.rarity} / eye:${result.eye} / hat:${result.hat}${result.shiny ? " / shiny" : ""}`);
328
+ const spriteWidth = 14;
329
+ for (let i = 0; i < colored.length; i++) {
330
+ const right = meta[i] ?? "";
331
+ lines.push(` ${colored[i]}${" ".repeat(Math.max(0, spriteWidth - sprite[i].length))}${right}`);
332
+ }
333
+
287
334
  for (const [k, v] of Object.entries(result.stats)) {
288
- const bar = "█".repeat(Math.round(v / 10)) + "░".repeat(10 - Math.round(v / 10));
335
+ const filled = Math.min(10, Math.max(0, Math.round(v / 10)));
336
+ const bar = colorFn("█".repeat(filled) + "░".repeat(10 - filled));
289
337
  lines.push(` ${k.padEnd(10)} ${bar} ${String(v).padStart(3)}`);
290
338
  }
339
+
291
340
  return lines.join("\n");
292
341
  }
293
342
 
294
343
  // ── Interactive mode ─────────────────────────────────────────────────────
295
344
 
296
345
  async function interactiveMode(binaryPath, configPath, userId) {
297
- p.intro("buddy-reroll");
298
-
299
346
  const binaryData = readFileSync(binaryPath);
300
347
  const currentSalt = findCurrentSalt(binaryData, userId);
301
348
  if (!currentSalt) {
302
- p.cancel("Could not find companion salt in binary.");
349
+ console.error("Could not find companion salt in binary.");
303
350
  process.exit(1);
304
351
  }
305
352
  const currentRoll = rollFrom(currentSalt, userId);
306
- p.note(formatRoll(currentRoll), "Current companion");
307
-
308
- const action = await p.select({
309
- message: "What would you like to do?",
310
- options: [
311
- { value: "reroll", label: "Reroll companion", hint: "pick species, rarity, etc." },
312
- { value: "restore", label: "Restore original", hint: "undo all patches" },
313
- { value: "current", label: "Show current", hint: "just display info" },
314
- ],
315
- });
316
- if (p.isCancel(action)) { p.cancel(); process.exit(0); }
317
-
318
- if (action === "current") {
319
- p.outro("Done!");
320
- return;
321
- }
322
-
323
- if (action === "restore") {
324
- const backupPath = binaryPath + ".backup";
325
- if (!existsSync(backupPath)) {
326
- p.cancel("No backup found. Nothing to restore.");
327
- process.exit(1);
328
- }
329
- copyFileSync(backupPath, binaryPath);
330
- resignBinary(binaryPath);
331
- clearCompanion(configPath);
332
- p.outro("Restored! Restart Claude Code and run /buddy.");
333
- return;
334
- }
335
-
336
- const species = await p.select({
337
- message: "Species",
338
- options: SPECIES.map((s) => ({
339
- value: s,
340
- label: s,
341
- hint: s === currentRoll.species ? "current" : undefined,
342
- })),
343
- initialValue: currentRoll.species,
344
- });
345
- if (p.isCancel(species)) { p.cancel(); process.exit(0); }
346
-
347
- const rarity = await p.select({
348
- message: "Rarity",
349
- options: RARITIES.map((r) => ({
350
- value: r,
351
- label: RARITY_LABELS[r],
352
- hint: r === currentRoll.rarity ? "current" : undefined,
353
- })),
354
- initialValue: currentRoll.rarity,
355
- });
356
- if (p.isCancel(rarity)) { p.cancel(); process.exit(0); }
357
-
358
- const eye = await p.select({
359
- message: "Eye",
360
- options: EYES.map((e) => ({
361
- value: e,
362
- label: e,
363
- hint: e === currentRoll.eye ? "current" : undefined,
364
- })),
365
- initialValue: currentRoll.eye,
366
- });
367
- if (p.isCancel(eye)) { p.cancel(); process.exit(0); }
368
353
 
369
- let hat = "none";
370
- if (rarity === "common") {
371
- p.log.info("Common rarity always gets hat=none");
372
- } else {
373
- hat = await p.select({
374
- message: "Hat",
375
- options: HATS.map((h) => ({
376
- value: h,
377
- label: h,
378
- hint: h === currentRoll.hat ? "current" : undefined,
379
- })),
380
- initialValue: currentRoll.hat === "none" ? "crown" : currentRoll.hat,
381
- });
382
- if (p.isCancel(hat)) { p.cancel(); process.exit(0); }
383
- }
384
-
385
- const shiny = await p.confirm({
386
- message: "Shiny?",
387
- initialValue: false,
354
+ const { runInteractiveUI } = await import("./ui.jsx");
355
+ await runInteractiveUI({
356
+ currentRoll,
357
+ currentSalt,
358
+ binaryPath,
359
+ configPath,
360
+ userId,
361
+ bruteForce,
362
+ patchBinary,
363
+ resignBinary,
364
+ clearCompanion,
365
+ isClaudeRunning,
366
+ rollFrom,
367
+ matches,
368
+ SPECIES,
369
+ RARITIES,
370
+ RARITY_LABELS,
371
+ EYES,
372
+ HATS,
388
373
  });
389
- if (p.isCancel(shiny)) { p.cancel(); process.exit(0); }
390
-
391
- const target = { species, rarity, eye, hat, shiny };
392
-
393
- if (matches(currentRoll, target)) {
394
- p.outro("Already matching! No changes needed.");
395
- return;
396
- }
397
-
398
- p.log.info(`Target: ${species} / ${rarity} / eye:${eye} / hat:${hat}${shiny ? " / shiny" : ""}`);
399
-
400
- const confirm = await p.confirm({ message: "Search and apply?" });
401
- if (p.isCancel(confirm) || !confirm) { p.cancel(); process.exit(0); }
402
-
403
- if (isClaudeRunning()) {
404
- p.log.warn("Claude Code appears to be running. Quit it before patching to avoid issues.");
405
- const proceed = await p.confirm({ message: "Patch anyway?" });
406
- if (p.isCancel(proceed) || !proceed) { p.cancel(); process.exit(0); }
407
- }
408
-
409
- const spinner = p.spinner();
410
- spinner.start("Searching for matching salt...");
411
-
412
- const found = bruteForce(userId, target, spinner);
413
- if (!found) {
414
- spinner.stop("No matching salt found. Try relaxing constraints.");
415
- process.exit(1);
416
- }
417
- spinner.stop(`Found in ${found.checked.toLocaleString()} attempts (${(found.elapsed / 1000).toFixed(1)}s)`);
418
-
419
- p.note(formatRoll(found.result), "New companion");
420
-
421
- const backupPath = binaryPath + ".backup";
422
- if (!existsSync(backupPath)) {
423
- copyFileSync(binaryPath, backupPath);
424
- p.log.info(`Backup saved to ${backupPath}`);
425
- }
426
-
427
- const patchCount = patchBinary(binaryPath, currentSalt, found.salt);
428
- p.log.success(`Patched ${patchCount} occurrence(s)`);
429
-
430
- if (resignBinary(binaryPath)) {
431
- p.log.success("Binary re-signed (ad-hoc codesign)");
432
- }
433
-
434
- clearCompanion(configPath);
435
- p.log.success("Companion data cleared");
436
-
437
- p.outro("Done! Restart Claude Code and run /buddy to hatch your new companion.");
438
374
  }
439
375
 
440
376
  // ── Non-interactive mode ─────────────────────────────────────────────────
441
377
 
442
- function nonInteractiveMode(args, binaryPath, configPath, userId) {
378
+ async function nonInteractiveMode(args, binaryPath, configPath, userId) {
443
379
  console.log(` Binary: ${binaryPath}`);
444
380
  console.log(` Config: ${configPath}`);
445
381
  console.log(` User ID: ${userId.slice(0, 8)}...`);
@@ -467,7 +403,7 @@ function nonInteractiveMode(args, binaryPath, configPath, userId) {
467
403
  if (args.current) {
468
404
  const result = rollFrom(currentSalt, userId);
469
405
  console.log(`\n Current companion (salt: ${currentSalt}):`);
470
- console.log(formatRoll(result));
406
+ console.log(formatCompanionCard(result));
471
407
  console.log();
472
408
  return;
473
409
  }
@@ -500,7 +436,7 @@ function nonInteractiveMode(args, binaryPath, configPath, userId) {
500
436
 
501
437
  const currentRoll = rollFrom(currentSalt, userId);
502
438
  if (matches(currentRoll, target)) {
503
- console.log(" ✓ Already matches!\n" + formatRoll(currentRoll));
439
+ console.log(" ✓ Already matches!\n" + formatCompanionCard(currentRoll));
504
440
  return;
505
441
  }
506
442
 
@@ -509,13 +445,13 @@ function nonInteractiveMode(args, binaryPath, configPath, userId) {
509
445
  }
510
446
 
511
447
  console.log(" Searching...");
512
- const found = bruteForce(userId, target, null);
448
+ const found = await bruteForce(userId, target, null);
513
449
  if (!found) {
514
450
  console.error(" ✗ No matching salt found. Try relaxing constraints.");
515
451
  process.exit(1);
516
452
  }
517
453
  console.log(` ✓ Found in ${found.checked.toLocaleString()} attempts (${(found.elapsed / 1000).toFixed(1)}s)`);
518
- console.log(formatRoll(found.result));
454
+ console.log(formatCompanionCard(found.result));
519
455
 
520
456
  const backupPath = binaryPath + ".backup";
521
457
  if (!existsSync(backupPath)) {
@@ -603,7 +539,7 @@ async function main() {
603
539
  if (!hasTargetFlags && !isCommand) {
604
540
  await interactiveMode(binaryPath, configPath, userId);
605
541
  } else {
606
- nonInteractiveMode(args, binaryPath, configPath, userId);
542
+ await nonInteractiveMode(args, binaryPath, configPath, userId);
607
543
  }
608
544
  }
609
545
 
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "buddy-reroll",
3
- "version": "0.1.1",
3
+ "version": "0.2.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": {
7
7
  "buddy-reroll": "index.js"
8
8
  },
9
9
  "files": [
10
- "index.js"
10
+ "index.js",
11
+ "ui.jsx",
12
+ "sprites.js"
11
13
  ],
12
14
  "engines": {
13
15
  "bun": ">=1.0.0"
@@ -25,6 +27,8 @@
25
27
  "reroll"
26
28
  ],
27
29
  "dependencies": {
28
- "@clack/prompts": "^1.2.0"
30
+ "chalk": "^5.6.2",
31
+ "ink": "^6.8.0",
32
+ "react": "^19.2.4"
29
33
  }
30
34
  }
package/sprites.js ADDED
@@ -0,0 +1,137 @@
1
+ import chalk from "chalk";
2
+
3
+ export const RARITY_STARS = {
4
+ common: "★",
5
+ uncommon: "★★",
6
+ rare: "★★★",
7
+ epic: "★★★★",
8
+ legendary: "★★★★★",
9
+ };
10
+
11
+ export const RARITY_COLORS = {
12
+ common: "white",
13
+ uncommon: "greenBright",
14
+ rare: "blueBright",
15
+ epic: "magentaBright",
16
+ legendary: "yellowBright",
17
+ };
18
+
19
+ export const HAT_LINES = {
20
+ none: "",
21
+ crown: " \\^^^/ ",
22
+ tophat: " [___] ",
23
+ propeller: " -+- ",
24
+ halo: " ( ) ",
25
+ wizard: " /^\\ ",
26
+ beanie: " (___) ",
27
+ tinyduck: " ,> ",
28
+ };
29
+
30
+ export const BODIES = {
31
+ duck: [
32
+ [" ", " __ ", " <({E} )___ ", " ( ._> ", " `--´ "],
33
+ [" ", " __ ", " <({E} )___ ", " ( ._> ", " `--´~ "],
34
+ [" ", " __ ", " <({E} )___ ", " ( .__> ", " `--´ "],
35
+ ],
36
+ goose: [
37
+ [" ", " ({E}> ", " || ", " _(__)_ ", " ^^^^ "],
38
+ [" ", " ({E}> ", " || ", " _(__)_ ", " ^^^^ "],
39
+ [" ", " ({E}>> ", " || ", " _(__)_ ", " ^^^^ "],
40
+ ],
41
+ blob: [
42
+ [" ", " .----. ", " ( {E} {E} ) ", " ( ) ", " `----´ "],
43
+ [" ", " .------. ", " ( {E} {E} ) ", " ( ) ", " `------´ "],
44
+ [" ", " .--. ", " ({E} {E}) ", " ( ) ", " `--´ "],
45
+ ],
46
+ cat: [
47
+ [" ", " /\\_/\\ ", " ( {E} {E}) ", " ( ω ) ", ' (")_(") '],
48
+ [" ", " /\\_/\\ ", " ( {E} {E}) ", " ( ω ) ", ' (")_(")~ '],
49
+ [" ", " /\\-/\\ ", " ( {E} {E}) ", " ( ω ) ", ' (")_(") '],
50
+ ],
51
+ dragon: [
52
+ [" ", " /^\\ /^\\ ", " < {E} {E} > ", " ( ~~ ) ", " `-vvvv-´ "],
53
+ [" ", " /^\\ /^\\ ", " < {E} {E} > ", " ( ) ", " `-vvvv-´ "],
54
+ [" ~ ~ ", " /^\\ /^\\ ", " < {E} {E} > ", " ( ~~ ) ", " `-vvvv-´ "],
55
+ ],
56
+ octopus: [
57
+ [" ", " .----. ", " ( {E} {E} ) ", " (______) ", " /\\/\\/\\/\\ "],
58
+ [" ", " .----. ", " ( {E} {E} ) ", " (______) ", " \\/\\/\\/\\/ "],
59
+ [" o ", " .----. ", " ( {E} {E} ) ", " (______) ", " /\\/\\/\\/\\ "],
60
+ ],
61
+ owl: [
62
+ [" ", " /\\ /\\ ", " (({E})({E})) ", " ( >< ) ", " `----´ "],
63
+ [" ", " /\\ /\\ ", " (({E})({E})) ", " ( >< ) ", " .----. "],
64
+ [" ", " /\\ /\\ ", " (({E})(-)) ", " ( >< ) ", " `----´ "],
65
+ ],
66
+ penguin: [
67
+ [" ", " .---. ", " ({E}>{E}) ", " /( )\\ ", " `---´ "],
68
+ [" ", " .---. ", " ({E}>{E}) ", " |( )| ", " `---´ "],
69
+ [" .---. ", " ({E}>{E}) ", " /( )\\ ", " `---´ ", " ~ ~ "],
70
+ ],
71
+ turtle: [
72
+ [" ", " _,--._ ", " ( {E} {E} ) ", " /[______]\\ ", " `` `` "],
73
+ [" ", " _,--._ ", " ( {E} {E} ) ", " /[______]\\ ", " `` `` "],
74
+ [" ", " _,--._ ", " ( {E} {E} ) ", " /[======]\\ ", " `` `` "],
75
+ ],
76
+ snail: [
77
+ [" ", " {E} .--. ", " \\ ( @ ) ", " \\_`--´ ", " ~~~~~~~ "],
78
+ [" ", " {E} .--. ", " | ( @ ) ", " \\_`--´ ", " ~~~~~~~ "],
79
+ [" ", " {E} .--. ", " \\ ( @ ) ", " \\_`--´ ", " ~~~~~~ "],
80
+ ],
81
+ ghost: [
82
+ [" ", " .----. ", " / {E} {E} \\ ", " | | ", " ~`~``~`~ "],
83
+ [" ", " .----. ", " / {E} {E} \\ ", " | | ", " `~`~~`~` "],
84
+ [" ~ ~ ", " .----. ", " / {E} {E} \\ ", " | | ", " ~~`~~`~~ "],
85
+ ],
86
+ axolotl: [
87
+ [" ", "}~(______)~{", "}~({E} .. {E})~{", " ( .--. ) ", " (_/ \\_) "],
88
+ [" ", "~}(______){~", "~}({E} .. {E}){~", " ( .--. ) ", " (_/ \\_) "],
89
+ [" ", "}~(______)~{", "}~({E} .. {E})~{", " ( -- ) ", " ~_/ \\_~ "],
90
+ ],
91
+ capybara: [
92
+ [" ", " n______n ", " ( {E} {E} ) ", " ( oo ) ", " `------´ "],
93
+ [" ", " n______n ", " ( {E} {E} ) ", " ( Oo ) ", " `------´ "],
94
+ [" ~ ~ ", " u______n ", " ( {E} {E} ) ", " ( oo ) ", " `------´ "],
95
+ ],
96
+ cactus: [
97
+ [" ", " n ____ n ", " | |{E} {E}| | ", " |_| |_| ", " | | "],
98
+ [" ", " ____ ", " n |{E} {E}| n ", " |_| |_| ", " | | "],
99
+ [" n n ", " | ____ | ", " | |{E} {E}| | ", " |_| |_| ", " | | "],
100
+ ],
101
+ robot: [
102
+ [" ", " .[||]. ", " [ {E} {E} ] ", " [ ==== ] ", " `------´ "],
103
+ [" ", " .[||]. ", " [ {E} {E} ] ", " [ -==- ] ", " `------´ "],
104
+ [" * ", " .[||]. ", " [ {E} {E} ] ", " [ ==== ] ", " `------´ "],
105
+ ],
106
+ rabbit: [
107
+ [" ", " (\\__/) ", " ( {E} {E} ) ", " =( .. )= ", ' (")__(") '],
108
+ [" ", " (|__/) ", " ( {E} {E} ) ", " =( .. )= ", ' (")__(") '],
109
+ [" ", " (\\__/) ", " ( {E} {E} ) ", " =( . . )= ", ' (")__(") '],
110
+ ],
111
+ mushroom: [
112
+ [" ", " .-o-OO-o-. ", "(__________)", " |{E} {E}| ", " |____| "],
113
+ [" ", " .-O-oo-O-. ", "(__________)", " |{E} {E}| ", " |____| "],
114
+ [" . o . ", " .-o-OO-o-. ", "(__________)", " |{E} {E}| ", " |____| "],
115
+ ],
116
+ chonk: [
117
+ [" ", " /\\ /\\ ", " ( {E} {E} ) ", " ( .. ) ", " `------´ "],
118
+ [" ", " /\\ /| ", " ( {E} {E} ) ", " ( .. ) ", " `------´ "],
119
+ [" ", " /\\ /\\ ", " ( {E} {E} ) ", " ( .. ) ", " `------´~ "],
120
+ ],
121
+ };
122
+
123
+ export function renderSprite(bones, frame = 0) {
124
+ const frames = BODIES[bones.species];
125
+ const body = frames[frame % frames.length].map((line) => line.replaceAll("{E}", bones.eye));
126
+ const lines = [...body];
127
+ if (bones.hat !== "none" && !lines[0].trim()) {
128
+ lines[0] = HAT_LINES[bones.hat];
129
+ }
130
+ if (!lines[0].trim() && frames.every((f) => !f[0].trim())) lines.shift();
131
+ return lines;
132
+ }
133
+
134
+ export function colorizeSprite(lines, rarity) {
135
+ const colorFn = chalk[RARITY_COLORS[rarity]] ?? chalk.white;
136
+ return lines.map((line) => colorFn(line));
137
+ }
package/ui.jsx ADDED
@@ -0,0 +1,458 @@
1
+ import React, { useState, useEffect, useRef } from "react";
2
+ import { render, Box, Text, useApp, useInput } from "ink";
3
+ import { renderSprite, RARITY_STARS, RARITY_COLORS } from "./sprites.js";
4
+ import { existsSync, copyFileSync } from "fs";
5
+
6
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
7
+ function Spinner({ label }) {
8
+ const [frame, setFrame] = useState(0);
9
+ useEffect(() => {
10
+ const timer = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80);
11
+ return () => clearInterval(timer);
12
+ }, []);
13
+ return <Text><Text color="cyan">{SPINNER_FRAMES[frame]}</Text> {label}</Text>;
14
+ }
15
+
16
+ // ── Components ──────────────────────────────────────────────────────────
17
+
18
+ function KeyHint({ children }) {
19
+ return <Text italic dimColor>{children}</Text>;
20
+ }
21
+
22
+ function ListSelect({ label, options, defaultValue, onChange, onSubmit, onBack, isActive }) {
23
+ const [idx, setIdx] = useState(() => Math.max(0, options.findIndex((o) => o.value === defaultValue)));
24
+
25
+ useInput((input, key) => {
26
+ if (key.escape && onBack) { onBack(); return; }
27
+ if (key.upArrow || key.leftArrow) {
28
+ const next = (idx - 1 + options.length) % options.length;
29
+ setIdx(next);
30
+ if (onChange) onChange(options[next].value);
31
+ }
32
+ if (key.downArrow || key.rightArrow) {
33
+ const next = (idx + 1) % options.length;
34
+ setIdx(next);
35
+ if (onChange) onChange(options[next].value);
36
+ }
37
+ if (key.return) onSubmit(options[idx].value);
38
+ }, { isActive: isActive !== false });
39
+
40
+ return (
41
+ <Box flexDirection="column">
42
+ {label && <Text bold>{label}</Text>}
43
+ {options.map((opt, i) => (
44
+ <Text key={opt.value}>
45
+ <Text color={i === idx ? "cyan" : undefined}>
46
+ {i === idx ? "❯ " : " "}{opt.label}
47
+ </Text>
48
+ {opt.hint && <Text dimColor> ({opt.hint})</Text>}
49
+ </Text>
50
+ ))}
51
+ <KeyHint>{onBack ? "↑↓ select · enter confirm · esc back" : "↑↓ select · enter confirm"}</KeyHint>
52
+ </Box>
53
+ );
54
+ }
55
+
56
+ function ConfirmSelect({ label, onConfirm, onCancel, onBack, isActive }) {
57
+ const [idx, setIdx] = useState(0);
58
+ const options = [
59
+ { label: "Yes", value: true },
60
+ { label: "No", value: false },
61
+ ];
62
+
63
+ useInput((input, key) => {
64
+ if (key.escape && onBack) { onBack(); return; }
65
+ if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) {
66
+ setIdx(idx === 0 ? 1 : 0);
67
+ }
68
+ if (key.return) {
69
+ if (options[idx].value) onConfirm();
70
+ else onCancel();
71
+ }
72
+ }, { isActive: isActive !== false });
73
+
74
+ return (
75
+ <Box flexDirection="column">
76
+ <Text bold>{label}</Text>
77
+ <Box gap={2}>
78
+ {options.map((opt, i) => (
79
+ <Text key={opt.label} color={i === idx ? "cyan" : undefined}>
80
+ {i === idx ? "❯ " : " "}{opt.label}
81
+ </Text>
82
+ ))}
83
+ </Box>
84
+ <KeyHint>{onBack ? "←→ select · enter confirm · esc back" : "←→ select · enter confirm"}</KeyHint>
85
+ </Box>
86
+ );
87
+ }
88
+
89
+ function PreviewCard({ species, rarity, eye, hat, shiny, stats }) {
90
+ const color = RARITY_COLORS[rarity] ?? "white";
91
+ const stars = RARITY_STARS[rarity] ?? "";
92
+ const sprite = renderSprite({ species, eye, hat });
93
+
94
+ return (
95
+ <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
96
+ <Box>
97
+ <Box flexDirection="column">
98
+ {sprite.map((line, i) => (
99
+ <Text key={i} color={color}>{line}</Text>
100
+ ))}
101
+ </Box>
102
+ <Box flexDirection="column" marginLeft={2}>
103
+ <Text bold>{species}</Text>
104
+ <Text color={color}>{rarity}{shiny ? " ✦shiny" : ""}</Text>
105
+ <Text dimColor>eye:{eye} hat:{hat}</Text>
106
+ <Text>{stars}</Text>
107
+ </Box>
108
+ </Box>
109
+ {stats && (
110
+ <Box flexDirection="column" marginTop={1}>
111
+ {Object.entries(stats).map(([k, v]) => {
112
+ const filled = Math.min(10, Math.max(0, Math.round(v / 10)));
113
+ return (
114
+ <Text key={k}>
115
+ <Text>{k.padEnd(10)} </Text>
116
+ <Text color={color}>{"█".repeat(filled)}</Text>
117
+ <Text dimColor>{"░".repeat(10 - filled)}</Text>
118
+ <Text> {String(v).padStart(3)}</Text>
119
+ </Text>
120
+ );
121
+ })}
122
+ </Box>
123
+ )}
124
+ </Box>
125
+ );
126
+ }
127
+
128
+ function ShowCurrentStep({ isActive }) {
129
+ const { exit } = useApp();
130
+
131
+ useInput(() => {
132
+ exit();
133
+ }, { isActive });
134
+
135
+ return (
136
+ <Box flexDirection="column">
137
+ <Text color="green">✓ Current companion shown above.</Text>
138
+ <KeyHint>Press any key to exit</KeyHint>
139
+ </Box>
140
+ );
141
+ }
142
+
143
+ function SpeciesStep({ speciesList, current, onChange, onSubmit, onBack, isActive }) {
144
+ const [idx, setIdx] = useState(Math.max(0, speciesList.indexOf(current)));
145
+
146
+ useInput((input, key) => {
147
+ if (key.escape && onBack) { onBack(); return; }
148
+ if (key.leftArrow || key.upArrow) {
149
+ const next = (idx - 1 + speciesList.length) % speciesList.length;
150
+ setIdx(next);
151
+ onChange(speciesList[next]);
152
+ }
153
+ if (key.rightArrow || key.downArrow) {
154
+ const next = (idx + 1) % speciesList.length;
155
+ setIdx(next);
156
+ onChange(speciesList[next]);
157
+ }
158
+ if (key.return) onSubmit();
159
+ }, { isActive });
160
+
161
+ return (
162
+ <Box flexDirection="column">
163
+ <Text bold>Species: <Text color="cyan">{speciesList[idx]}</Text> <Text dimColor>({idx + 1}/{speciesList.length})</Text></Text>
164
+ <KeyHint>←→ browse · enter select · esc back</KeyHint>
165
+ </Box>
166
+ );
167
+ }
168
+
169
+ function SearchStep({ userId, target, bruteForce, onFound, onFail, isActive }) {
170
+ const [progress, setProgress] = useState("");
171
+ const [error, setError] = useState(null);
172
+ const cancelRef = useRef(false);
173
+ const { exit } = useApp();
174
+
175
+ useInput((input, key) => {
176
+ if (key.escape) {
177
+ cancelRef.current = true;
178
+ exit();
179
+ }
180
+ }, { isActive });
181
+
182
+ useEffect(() => {
183
+ (async () => {
184
+ const found = await bruteForce(userId, target, (checked, elapsed) => {
185
+ if (!cancelRef.current) {
186
+ setProgress(`${(checked / 1e6).toFixed(0)}M salts checked (${(elapsed / 1000).toFixed(1)}s)`);
187
+ }
188
+ });
189
+ if (cancelRef.current) return;
190
+ if (found) onFound(found);
191
+ else setError("No matching salt found. Try relaxing constraints.");
192
+ })();
193
+ }, []);
194
+
195
+ if (error) {
196
+ return (
197
+ <Box flexDirection="column">
198
+ <Text color="red">✗ {error}</Text>
199
+ <KeyHint>Press esc to exit</KeyHint>
200
+ </Box>
201
+ );
202
+ }
203
+
204
+ return (
205
+ <Box flexDirection="column">
206
+ <Spinner label={progress || "Searching for matching salt..."} />
207
+ <KeyHint>esc to cancel</KeyHint>
208
+ </Box>
209
+ );
210
+ }
211
+
212
+ function DoneStep({ messages, isActive }) {
213
+ const { exit } = useApp();
214
+
215
+ useInput(() => {
216
+ exit();
217
+ }, { isActive });
218
+
219
+ return (
220
+ <Box flexDirection="column">
221
+ {messages.map((msg, i) => (
222
+ <Text key={i} color="green">✓ {msg}</Text>
223
+ ))}
224
+ <Box marginTop={1}>
225
+ <Text bold>Done! Restart Claude Code and run /buddy to hatch your new companion.</Text>
226
+ </Box>
227
+ <KeyHint>Press any key to exit</KeyHint>
228
+ </Box>
229
+ );
230
+ }
231
+
232
+ const STEP_ORDER = ["action", "species", "rarity", "eye", "hat", "shiny", "confirm"];
233
+
234
+ function getPrevStep(current, rarity) {
235
+ const idx = STEP_ORDER.indexOf(current);
236
+ if (idx <= 0) return null;
237
+ let prev = STEP_ORDER[idx - 1];
238
+ if (prev === "hat" && rarity === "common") prev = "eye";
239
+ return prev;
240
+ }
241
+
242
+ function App({ opts }) {
243
+ const { exit } = useApp();
244
+ const {
245
+ currentRoll, currentSalt, binaryPath, configPath, userId,
246
+ bruteForce, patchBinary, resignBinary, clearCompanion, isClaudeRunning,
247
+ rollFrom, matches, SPECIES, RARITIES, RARITY_LABELS, EYES, HATS,
248
+ } = opts;
249
+
250
+ const [step, setStep] = useState("action");
251
+ const [species, setSpecies] = useState(currentRoll.species);
252
+ const [rarity, setRarity] = useState(currentRoll.rarity);
253
+ const [eye, setEye] = useState(currentRoll.eye);
254
+ const [hat, setHat] = useState(currentRoll.hat);
255
+ const [shiny, setShiny] = useState(currentRoll.shiny);
256
+ const [found, setFound] = useState(null);
257
+ const [doneMessages, setDoneMessages] = useState([]);
258
+
259
+ const showStats = step === "showCurrent" || step === "result" || step === "done";
260
+ const displayRoll = found ? found.result : { species, rarity, eye, hat, shiny, stats: currentRoll.stats };
261
+ const effectiveHat = rarity === "common" ? "none" : hat;
262
+ const buildTarget = (s = shiny) => ({ species, rarity, eye, hat: effectiveHat, shiny: s });
263
+
264
+ const goBack = (toStep) => {
265
+ const prev = toStep || getPrevStep(step, rarity);
266
+ if (prev) setStep(prev);
267
+ else exit();
268
+ };
269
+
270
+ return (
271
+ <Box flexDirection="column" padding={1}>
272
+ <Text bold dimColor>buddy-reroll</Text>
273
+
274
+ <PreviewCard
275
+ species={displayRoll.species}
276
+ rarity={displayRoll.rarity}
277
+ eye={displayRoll.eye}
278
+ hat={displayRoll.hat}
279
+ shiny={displayRoll.shiny}
280
+ stats={showStats ? displayRoll.stats : null}
281
+ />
282
+
283
+ <Box marginTop={1}>
284
+ {step === "action" && (
285
+ <ListSelect
286
+ label="What would you like to do?"
287
+ options={[
288
+ { label: "Reroll companion", value: "reroll" },
289
+ { label: "Restore original", value: "restore" },
290
+ { label: "Show current", value: "current" },
291
+ ]}
292
+ isActive={step === "action"}
293
+ onSubmit={(action) => {
294
+ if (action === "current") {
295
+ setStep("showCurrent");
296
+ } else if (action === "restore") {
297
+ const backupPath = binaryPath + ".backup";
298
+ if (!existsSync(backupPath)) {
299
+ setDoneMessages(["No backup found. Nothing to restore."]);
300
+ setStep("done");
301
+ return;
302
+ }
303
+ copyFileSync(backupPath, binaryPath);
304
+ resignBinary(binaryPath);
305
+ clearCompanion(configPath);
306
+ setDoneMessages(["Restored! Restart Claude Code and run /buddy."]);
307
+ setStep("done");
308
+ } else {
309
+ setStep("species");
310
+ }
311
+ }}
312
+ />
313
+ )}
314
+
315
+ {step === "showCurrent" && (
316
+ <ShowCurrentStep isActive={step === "showCurrent"} />
317
+ )}
318
+
319
+ {step === "species" && (
320
+ <SpeciesStep
321
+ speciesList={SPECIES}
322
+ current={species}
323
+ onChange={setSpecies}
324
+ onSubmit={() => setStep("rarity")}
325
+ onBack={() => goBack("action")}
326
+ isActive={step === "species"}
327
+ />
328
+ )}
329
+
330
+ {step === "rarity" && (
331
+ <ListSelect
332
+ label="Rarity"
333
+ options={RARITIES.map((r) => ({ label: RARITY_LABELS[r], value: r }))}
334
+ defaultValue={rarity}
335
+ onChange={(r) => {
336
+ setRarity(r);
337
+ if (r === "common") setHat("none");
338
+ }}
339
+ onSubmit={() => setStep("eye")}
340
+ onBack={() => goBack()}
341
+ isActive={step === "rarity"}
342
+ />
343
+ )}
344
+
345
+ {step === "eye" && (
346
+ <ListSelect
347
+ label="Eye"
348
+ options={EYES.map((e) => ({ label: e, value: e }))}
349
+ defaultValue={eye}
350
+ onChange={setEye}
351
+ onSubmit={() => setStep(rarity === "common" ? "shiny" : "hat")}
352
+ onBack={() => goBack()}
353
+ isActive={step === "eye"}
354
+ />
355
+ )}
356
+
357
+ {step === "hat" && (
358
+ <ListSelect
359
+ label="Hat"
360
+ options={HATS.map((h) => ({ label: h, value: h }))}
361
+ defaultValue={hat === "none" ? "crown" : hat}
362
+ onChange={setHat}
363
+ onSubmit={() => setStep("shiny")}
364
+ onBack={() => goBack()}
365
+ isActive={step === "hat"}
366
+ />
367
+ )}
368
+
369
+ {step === "shiny" && (
370
+ <ConfirmSelect
371
+ label="Shiny?"
372
+ isActive={step === "shiny"}
373
+ onConfirm={() => {
374
+ setShiny(true);
375
+ if (matches(currentRoll, buildTarget(true))) {
376
+ setDoneMessages(["Already matching! No changes needed."]);
377
+ setStep("done");
378
+ } else {
379
+ setStep("confirm");
380
+ }
381
+ }}
382
+ onCancel={() => {
383
+ setShiny(false);
384
+ if (matches(currentRoll, buildTarget(false))) {
385
+ setDoneMessages(["Already matching! No changes needed."]);
386
+ setStep("done");
387
+ } else {
388
+ setStep("confirm");
389
+ }
390
+ }}
391
+ onBack={() => goBack()}
392
+ />
393
+ )}
394
+
395
+ {step === "confirm" && (
396
+ <Box flexDirection="column">
397
+ <Text>Target: <Text bold>{species}</Text> / <Text bold>{rarity}</Text> / eye:{eye} / hat:{effectiveHat}{shiny ? " / shiny" : ""}</Text>
398
+ {isClaudeRunning() && <Text color="yellow">⚠ Claude Code appears to be running. Quit it before patching.</Text>}
399
+ <ConfirmSelect
400
+ label="Search and apply?"
401
+ isActive={step === "confirm"}
402
+ onConfirm={() => setStep("search")}
403
+ onCancel={() => exit()}
404
+ onBack={() => goBack()}
405
+ />
406
+ </Box>
407
+ )}
408
+
409
+ {step === "search" && (
410
+ <SearchStep
411
+ userId={userId}
412
+ target={buildTarget()}
413
+ bruteForce={bruteForce}
414
+ onFound={(f) => { setFound(f); setStep("result"); }}
415
+ onFail={() => {
416
+ setDoneMessages(["No matching salt found. Try relaxing constraints."]);
417
+ setStep("done");
418
+ }}
419
+ isActive={step === "search"}
420
+ />
421
+ )}
422
+
423
+ {step === "result" && (
424
+ <Box flexDirection="column">
425
+ <Text bold color="green">✓ Found in {found.checked.toLocaleString()} attempts ({(found.elapsed / 1000).toFixed(1)}s)</Text>
426
+ <ConfirmSelect
427
+ label="Apply patch?"
428
+ isActive={step === "result"}
429
+ onConfirm={() => {
430
+ const msgs = [];
431
+ const backupPath = binaryPath + ".backup";
432
+ if (!existsSync(backupPath)) {
433
+ copyFileSync(binaryPath, backupPath);
434
+ msgs.push(`Backup saved to ${backupPath}`);
435
+ }
436
+ const count = patchBinary(binaryPath, currentSalt, found.salt);
437
+ msgs.push(`Patched ${count} occurrence(s)`);
438
+ if (resignBinary(binaryPath)) msgs.push("Binary re-signed (ad-hoc codesign)");
439
+ clearCompanion(configPath);
440
+ msgs.push("Companion data cleared");
441
+ setDoneMessages(msgs);
442
+ setStep("done");
443
+ }}
444
+ onCancel={() => exit()}
445
+ />
446
+ </Box>
447
+ )}
448
+
449
+ {step === "done" && <DoneStep messages={doneMessages} isActive={step === "done"} />}
450
+ </Box>
451
+ </Box>
452
+ );
453
+ }
454
+
455
+ export async function runInteractiveUI(opts) {
456
+ const { waitUntilExit } = render(<App opts={opts} />);
457
+ await waitUntilExit();
458
+ }