buddy-reroll 0.1.1 → 0.2.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.
Files changed (4) hide show
  1. package/index.js +55 -147
  2. package/package.json +7 -3
  3. package/sprites.js +137 -0
  4. package/ui.jsx +469 -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");
@@ -112,7 +113,9 @@ function findBinaryPath() {
112
113
  const versionsDir = join(homedir(), ".local", "share", "claude", "versions");
113
114
  if (existsSync(versionsDir)) {
114
115
  try {
115
- const versions = readdirSync(versionsDir).sort();
116
+ const versions = readdirSync(versionsDir)
117
+ .filter((f) => !f.includes(".backup"))
118
+ .sort();
116
119
  if (versions.length > 0) return join(versionsDir, versions[versions.length - 1]);
117
120
  } catch {}
118
121
  }
@@ -181,7 +184,7 @@ function findCurrentSalt(binaryData, userId) {
181
184
 
182
185
  // ── Brute-force ──────────────────────────────────────────────────────────
183
186
 
184
- function bruteForce(userId, target, spinner) {
187
+ async function bruteForce(userId, target, onProgress) {
185
188
  const startTime = Date.now();
186
189
  let checked = 0;
187
190
 
@@ -206,9 +209,11 @@ function bruteForce(userId, target, spinner) {
206
209
  const r = rollFrom(salt, userId);
207
210
  if (matches(r, target)) return { salt, result: r, checked, elapsed: Date.now() - startTime };
208
211
 
212
+ if (checked % 100_000 === 0) {
213
+ await new Promise((r) => setTimeout(r, 0));
214
+ }
209
215
  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)`);
216
+ if (onProgress) onProgress(checked, Date.now() - startTime);
212
217
  }
213
218
  }
214
219
 
@@ -281,165 +286,68 @@ function clearCompanion(configPath) {
281
286
 
282
287
  // ── Display ──────────────────────────────────────────────────────────────
283
288
 
284
- function formatRoll(result) {
289
+ function formatCompanionCard(result) {
290
+ const sprite = renderSprite({ species: result.species, eye: result.eye, hat: result.hat });
291
+ const colored = colorizeSprite(sprite, result.rarity);
292
+ const colorFn = chalk[RARITY_COLORS[result.rarity]] ?? chalk.white;
293
+ const stars = RARITY_STARS[result.rarity] ?? "";
294
+
295
+ const meta = [];
296
+ meta.push(`${result.species} / ${result.rarity}${result.shiny ? " / shiny" : ""}`);
297
+ meta.push(`eye:${result.eye} / hat:${result.hat}`);
298
+ meta.push(stars);
299
+
285
300
  const lines = [];
286
- lines.push(` ${result.species} / ${result.rarity} / eye:${result.eye} / hat:${result.hat}${result.shiny ? " / shiny" : ""}`);
301
+ const spriteWidth = 14;
302
+ for (let i = 0; i < colored.length; i++) {
303
+ const right = meta[i] ?? "";
304
+ lines.push(` ${colored[i]}${" ".repeat(Math.max(0, spriteWidth - sprite[i].length))}${right}`);
305
+ }
306
+
287
307
  for (const [k, v] of Object.entries(result.stats)) {
288
- const bar = "█".repeat(Math.round(v / 10)) + "░".repeat(10 - Math.round(v / 10));
308
+ const bar = colorFn("█".repeat(Math.round(v / 10)) + "░".repeat(10 - Math.round(v / 10)));
289
309
  lines.push(` ${k.padEnd(10)} ${bar} ${String(v).padStart(3)}`);
290
310
  }
311
+
291
312
  return lines.join("\n");
292
313
  }
293
314
 
294
315
  // ── Interactive mode ─────────────────────────────────────────────────────
295
316
 
296
317
  async function interactiveMode(binaryPath, configPath, userId) {
297
- p.intro("buddy-reroll");
298
-
299
318
  const binaryData = readFileSync(binaryPath);
300
319
  const currentSalt = findCurrentSalt(binaryData, userId);
301
320
  if (!currentSalt) {
302
- p.cancel("Could not find companion salt in binary.");
321
+ console.error("Could not find companion salt in binary.");
303
322
  process.exit(1);
304
323
  }
305
324
  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
325
 
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,
326
+ const { runInteractiveUI } = await import("./ui.jsx");
327
+ await runInteractiveUI({
328
+ currentRoll,
329
+ currentSalt,
330
+ binaryPath,
331
+ configPath,
332
+ userId,
333
+ bruteForce,
334
+ patchBinary,
335
+ resignBinary,
336
+ clearCompanion,
337
+ isClaudeRunning,
338
+ rollFrom,
339
+ matches,
340
+ SPECIES,
341
+ RARITIES,
342
+ RARITY_LABELS,
343
+ EYES,
344
+ HATS,
344
345
  });
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
-
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,
388
- });
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
346
  }
439
347
 
440
348
  // ── Non-interactive mode ─────────────────────────────────────────────────
441
349
 
442
- function nonInteractiveMode(args, binaryPath, configPath, userId) {
350
+ async function nonInteractiveMode(args, binaryPath, configPath, userId) {
443
351
  console.log(` Binary: ${binaryPath}`);
444
352
  console.log(` Config: ${configPath}`);
445
353
  console.log(` User ID: ${userId.slice(0, 8)}...`);
@@ -467,7 +375,7 @@ function nonInteractiveMode(args, binaryPath, configPath, userId) {
467
375
  if (args.current) {
468
376
  const result = rollFrom(currentSalt, userId);
469
377
  console.log(`\n Current companion (salt: ${currentSalt}):`);
470
- console.log(formatRoll(result));
378
+ console.log(formatCompanionCard(result));
471
379
  console.log();
472
380
  return;
473
381
  }
@@ -500,7 +408,7 @@ function nonInteractiveMode(args, binaryPath, configPath, userId) {
500
408
 
501
409
  const currentRoll = rollFrom(currentSalt, userId);
502
410
  if (matches(currentRoll, target)) {
503
- console.log(" ✓ Already matches!\n" + formatRoll(currentRoll));
411
+ console.log(" ✓ Already matches!\n" + formatCompanionCard(currentRoll));
504
412
  return;
505
413
  }
506
414
 
@@ -509,13 +417,13 @@ function nonInteractiveMode(args, binaryPath, configPath, userId) {
509
417
  }
510
418
 
511
419
  console.log(" Searching...");
512
- const found = bruteForce(userId, target, null);
420
+ const found = await bruteForce(userId, target, null);
513
421
  if (!found) {
514
422
  console.error(" ✗ No matching salt found. Try relaxing constraints.");
515
423
  process.exit(1);
516
424
  }
517
425
  console.log(` ✓ Found in ${found.checked.toLocaleString()} attempts (${(found.elapsed / 1000).toFixed(1)}s)`);
518
- console.log(formatRoll(found.result));
426
+ console.log(formatCompanionCard(found.result));
519
427
 
520
428
  const backupPath = binaryPath + ".backup";
521
429
  if (!existsSync(backupPath)) {
@@ -603,7 +511,7 @@ async function main() {
603
511
  if (!hasTargetFlags && !isCommand) {
604
512
  await interactiveMode(binaryPath, configPath, userId);
605
513
  } else {
606
- nonInteractiveMode(args, binaryPath, configPath, userId);
514
+ await nonInteractiveMode(args, binaryPath, configPath, userId);
607
515
  }
608
516
  }
609
517
 
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "buddy-reroll",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
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,469 @@
1
+ import React, { useState, useEffect, useRef } from "react";
2
+ import { render, Box, Text, useApp, useInput } from "ink";
3
+ // Simple spinner — avoids @inkjs/ui dependency for one component
4
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
5
+ function Spinner({ label }) {
6
+ const [frame, setFrame] = useState(0);
7
+ useEffect(() => {
8
+ const timer = setInterval(() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length), 80);
9
+ return () => clearInterval(timer);
10
+ }, []);
11
+ return <Text><Text color="cyan">{SPINNER_FRAMES[frame]}</Text> {label}</Text>;
12
+ }
13
+ import { renderSprite, RARITY_STARS, RARITY_COLORS } from "./sprites.js";
14
+ import { existsSync, copyFileSync } from "fs";
15
+
16
+ // ── Shared 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
+ // ── PreviewCard ─────────────────────────────────────────────────────────
90
+
91
+ function PreviewCard({ species, rarity, eye, hat, shiny, stats }) {
92
+ const color = RARITY_COLORS[rarity] ?? "white";
93
+ const stars = RARITY_STARS[rarity] ?? "";
94
+ const sprite = renderSprite({ species, eye, hat });
95
+
96
+ return (
97
+ <Box flexDirection="column" borderStyle="round" borderColor={color} paddingX={1}>
98
+ <Box>
99
+ <Box flexDirection="column">
100
+ {sprite.map((line, i) => (
101
+ <Text key={i} color={color}>{line}</Text>
102
+ ))}
103
+ </Box>
104
+ <Box flexDirection="column" marginLeft={2}>
105
+ <Text bold>{species}</Text>
106
+ <Text color={color}>{rarity}{shiny ? " ✦shiny" : ""}</Text>
107
+ <Text dimColor>eye:{eye} hat:{hat}</Text>
108
+ <Text>{stars}</Text>
109
+ </Box>
110
+ </Box>
111
+ {stats && (
112
+ <Box flexDirection="column" marginTop={1}>
113
+ {Object.entries(stats).map(([k, v]) => {
114
+ const filled = Math.min(10, Math.max(0, Math.round(v / 10)));
115
+ return (
116
+ <Text key={k}>
117
+ <Text>{k.padEnd(10)} </Text>
118
+ <Text color={color}>{"█".repeat(filled)}</Text>
119
+ <Text dimColor>{"░".repeat(10 - filled)}</Text>
120
+ <Text> {String(v).padStart(3)}</Text>
121
+ </Text>
122
+ );
123
+ })}
124
+ </Box>
125
+ )}
126
+ </Box>
127
+ );
128
+ }
129
+
130
+ // ── Step Components ─────────────────────────────────────────────────────
131
+
132
+ function ShowCurrentStep({ isActive }) {
133
+ const { exit } = useApp();
134
+
135
+ useInput(() => {
136
+ exit();
137
+ }, { isActive });
138
+
139
+ return (
140
+ <Box flexDirection="column">
141
+ <Text color="green">✓ Current companion shown above.</Text>
142
+ <KeyHint>Press any key to exit</KeyHint>
143
+ </Box>
144
+ );
145
+ }
146
+
147
+ function SpeciesStep({ speciesList, current, onChange, onSubmit, onBack, isActive }) {
148
+ const [idx, setIdx] = useState(Math.max(0, speciesList.indexOf(current)));
149
+
150
+ useInput((input, key) => {
151
+ if (key.escape && onBack) { onBack(); return; }
152
+ if (key.leftArrow || key.upArrow) {
153
+ const next = (idx - 1 + speciesList.length) % speciesList.length;
154
+ setIdx(next);
155
+ onChange(speciesList[next]);
156
+ }
157
+ if (key.rightArrow || key.downArrow) {
158
+ const next = (idx + 1) % speciesList.length;
159
+ setIdx(next);
160
+ onChange(speciesList[next]);
161
+ }
162
+ if (key.return) onSubmit();
163
+ }, { isActive });
164
+
165
+ return (
166
+ <Box flexDirection="column">
167
+ <Text bold>Species: <Text color="cyan">{speciesList[idx]}</Text> <Text dimColor>({idx + 1}/{speciesList.length})</Text></Text>
168
+ <KeyHint>←→ browse · enter select · esc back</KeyHint>
169
+ </Box>
170
+ );
171
+ }
172
+
173
+ function SearchStep({ userId, target, bruteForce, onFound, onFail, isActive }) {
174
+ const [progress, setProgress] = useState("");
175
+ const [error, setError] = useState(null);
176
+ const cancelRef = useRef(false);
177
+ const { exit } = useApp();
178
+
179
+ useInput((input, key) => {
180
+ if (key.escape) {
181
+ cancelRef.current = true;
182
+ exit();
183
+ }
184
+ }, { isActive });
185
+
186
+ useEffect(() => {
187
+ (async () => {
188
+ const found = await bruteForce(userId, target, (checked, elapsed) => {
189
+ if (!cancelRef.current) {
190
+ setProgress(`${(checked / 1e6).toFixed(0)}M salts checked (${(elapsed / 1000).toFixed(1)}s)`);
191
+ }
192
+ });
193
+ if (cancelRef.current) return;
194
+ if (found) onFound(found);
195
+ else setError("No matching salt found. Try relaxing constraints.");
196
+ })();
197
+ }, []);
198
+
199
+ if (error) {
200
+ return (
201
+ <Box flexDirection="column">
202
+ <Text color="red">✗ {error}</Text>
203
+ <KeyHint>Press esc to exit</KeyHint>
204
+ </Box>
205
+ );
206
+ }
207
+
208
+ return (
209
+ <Box flexDirection="column">
210
+ <Spinner label={progress || "Searching for matching salt..."} />
211
+ <KeyHint>esc to cancel</KeyHint>
212
+ </Box>
213
+ );
214
+ }
215
+
216
+ function DoneStep({ messages, isActive }) {
217
+ const { exit } = useApp();
218
+
219
+ useInput(() => {
220
+ exit();
221
+ }, { isActive });
222
+
223
+ return (
224
+ <Box flexDirection="column">
225
+ {messages.map((msg, i) => (
226
+ <Text key={i} color="green">✓ {msg}</Text>
227
+ ))}
228
+ <Box marginTop={1}>
229
+ <Text bold>Done! Restart Claude Code and run /buddy to hatch your new companion.</Text>
230
+ </Box>
231
+ <KeyHint>Press any key to exit</KeyHint>
232
+ </Box>
233
+ );
234
+ }
235
+
236
+ // ── Step Flow ───────────────────────────────────────────────────────────
237
+
238
+ const STEP_ORDER = ["action", "species", "rarity", "eye", "hat", "shiny", "confirm"];
239
+
240
+ function getPrevStep(current, rarity) {
241
+ const idx = STEP_ORDER.indexOf(current);
242
+ if (idx <= 0) return null;
243
+ let prev = STEP_ORDER[idx - 1];
244
+ // Skip hat when going back if common
245
+ if (prev === "hat" && rarity === "common") prev = "eye";
246
+ return prev;
247
+ }
248
+
249
+ // ── Main App ────────────────────────────────────────────────────────────
250
+
251
+ function App({ opts }) {
252
+ const { exit } = useApp();
253
+ const {
254
+ currentRoll, currentSalt, binaryPath, configPath, userId,
255
+ bruteForce, patchBinary, resignBinary, clearCompanion, isClaudeRunning,
256
+ rollFrom, matches, SPECIES, RARITIES, RARITY_LABELS, EYES, HATS,
257
+ } = opts;
258
+
259
+ const [step, setStep] = useState("action");
260
+ const [species, setSpecies] = useState(currentRoll.species);
261
+ const [rarity, setRarity] = useState(currentRoll.rarity);
262
+ const [eye, setEye] = useState(currentRoll.eye);
263
+ const [hat, setHat] = useState(currentRoll.hat);
264
+ const [shiny, setShiny] = useState(currentRoll.shiny);
265
+ const [found, setFound] = useState(null);
266
+ const [doneMessages, setDoneMessages] = useState([]);
267
+
268
+ const showStats = step === "showCurrent" || step === "result" || step === "done";
269
+ const displayRoll = found ? found.result : { species, rarity, eye, hat, shiny, stats: currentRoll.stats };
270
+
271
+ const goBack = (toStep) => {
272
+ const prev = toStep || getPrevStep(step, rarity);
273
+ if (prev) setStep(prev);
274
+ else exit();
275
+ };
276
+
277
+ return (
278
+ <Box flexDirection="column" padding={1}>
279
+ <Text bold dimColor>buddy-reroll</Text>
280
+
281
+ <PreviewCard
282
+ species={displayRoll.species}
283
+ rarity={displayRoll.rarity}
284
+ eye={displayRoll.eye}
285
+ hat={displayRoll.hat}
286
+ shiny={displayRoll.shiny}
287
+ stats={showStats ? displayRoll.stats : null}
288
+ />
289
+
290
+ <Box marginTop={1}>
291
+ {step === "action" && (
292
+ <ListSelect
293
+ label="What would you like to do?"
294
+ options={[
295
+ { label: "Reroll companion", value: "reroll" },
296
+ { label: "Restore original", value: "restore" },
297
+ { label: "Show current", value: "current" },
298
+ ]}
299
+ isActive={step === "action"}
300
+ onSubmit={(action) => {
301
+ if (action === "current") {
302
+ setStep("showCurrent");
303
+ } else if (action === "restore") {
304
+ const backupPath = binaryPath + ".backup";
305
+ if (!existsSync(backupPath)) {
306
+ setDoneMessages(["No backup found. Nothing to restore."]);
307
+ setStep("done");
308
+ return;
309
+ }
310
+ copyFileSync(backupPath, binaryPath);
311
+ resignBinary(binaryPath);
312
+ clearCompanion(configPath);
313
+ setDoneMessages(["Restored! Restart Claude Code and run /buddy."]);
314
+ setStep("done");
315
+ } else {
316
+ setStep("species");
317
+ }
318
+ }}
319
+ />
320
+ )}
321
+
322
+ {step === "showCurrent" && (
323
+ <ShowCurrentStep isActive={step === "showCurrent"} />
324
+ )}
325
+
326
+ {step === "species" && (
327
+ <SpeciesStep
328
+ speciesList={SPECIES}
329
+ current={species}
330
+ onChange={setSpecies}
331
+ onSubmit={() => setStep("rarity")}
332
+ onBack={() => goBack("action")}
333
+ isActive={step === "species"}
334
+ />
335
+ )}
336
+
337
+ {step === "rarity" && (
338
+ <ListSelect
339
+ label="Rarity"
340
+ options={RARITIES.map((r) => ({ label: RARITY_LABELS[r], value: r }))}
341
+ defaultValue={rarity}
342
+ onChange={(r) => {
343
+ setRarity(r);
344
+ if (r === "common") setHat("none");
345
+ }}
346
+ onSubmit={() => setStep("eye")}
347
+ onBack={() => goBack()}
348
+ isActive={step === "rarity"}
349
+ />
350
+ )}
351
+
352
+ {step === "eye" && (
353
+ <ListSelect
354
+ label="Eye"
355
+ options={EYES.map((e) => ({ label: e, value: e }))}
356
+ defaultValue={eye}
357
+ onChange={setEye}
358
+ onSubmit={() => setStep(rarity === "common" ? "shiny" : "hat")}
359
+ onBack={() => goBack()}
360
+ isActive={step === "eye"}
361
+ />
362
+ )}
363
+
364
+ {step === "hat" && (
365
+ <ListSelect
366
+ label="Hat"
367
+ options={HATS.map((h) => ({ label: h, value: h }))}
368
+ defaultValue={hat === "none" ? "crown" : hat}
369
+ onChange={setHat}
370
+ onSubmit={() => setStep("shiny")}
371
+ onBack={() => goBack()}
372
+ isActive={step === "hat"}
373
+ />
374
+ )}
375
+
376
+ {step === "shiny" && (
377
+ <ConfirmSelect
378
+ label="Shiny?"
379
+ isActive={step === "shiny"}
380
+ onConfirm={() => {
381
+ setShiny(true);
382
+ const target = { species, rarity, eye, hat: rarity === "common" ? "none" : hat, shiny: true };
383
+ if (matches(currentRoll, target)) {
384
+ setDoneMessages(["Already matching! No changes needed."]);
385
+ setStep("done");
386
+ } else {
387
+ setStep("confirm");
388
+ }
389
+ }}
390
+ onCancel={() => {
391
+ setShiny(false);
392
+ const target = { species, rarity, eye, hat: rarity === "common" ? "none" : hat, shiny: false };
393
+ if (matches(currentRoll, target)) {
394
+ setDoneMessages(["Already matching! No changes needed."]);
395
+ setStep("done");
396
+ } else {
397
+ setStep("confirm");
398
+ }
399
+ }}
400
+ onBack={() => goBack()}
401
+ />
402
+ )}
403
+
404
+ {step === "confirm" && (
405
+ <Box flexDirection="column">
406
+ <Text>Target: <Text bold>{species}</Text> / <Text bold>{rarity}</Text> / eye:{eye} / hat:{rarity === "common" ? "none" : hat}{shiny ? " / shiny" : ""}</Text>
407
+ {isClaudeRunning() && <Text color="yellow">⚠ Claude Code appears to be running. Quit it before patching.</Text>}
408
+ <ConfirmSelect
409
+ label="Search and apply?"
410
+ isActive={step === "confirm"}
411
+ onConfirm={() => setStep("search")}
412
+ onCancel={() => exit()}
413
+ onBack={() => goBack()}
414
+ />
415
+ </Box>
416
+ )}
417
+
418
+ {step === "search" && (
419
+ <SearchStep
420
+ userId={userId}
421
+ target={{ species, rarity, eye, hat: rarity === "common" ? "none" : hat, shiny }}
422
+ bruteForce={bruteForce}
423
+ onFound={(f) => { setFound(f); setStep("result"); }}
424
+ onFail={() => {
425
+ setDoneMessages(["No matching salt found. Try relaxing constraints."]);
426
+ setStep("done");
427
+ }}
428
+ isActive={step === "search"}
429
+ />
430
+ )}
431
+
432
+ {step === "result" && (
433
+ <Box flexDirection="column">
434
+ <Text bold color="green">✓ Found in {found.checked.toLocaleString()} attempts ({(found.elapsed / 1000).toFixed(1)}s)</Text>
435
+ <ConfirmSelect
436
+ label="Apply patch?"
437
+ isActive={step === "result"}
438
+ onConfirm={() => {
439
+ const msgs = [];
440
+ const backupPath = binaryPath + ".backup";
441
+ if (!existsSync(backupPath)) {
442
+ copyFileSync(binaryPath, backupPath);
443
+ msgs.push(`Backup saved to ${backupPath}`);
444
+ }
445
+ const count = patchBinary(binaryPath, currentSalt, found.salt);
446
+ msgs.push(`Patched ${count} occurrence(s)`);
447
+ if (resignBinary(binaryPath)) msgs.push("Binary re-signed (ad-hoc codesign)");
448
+ clearCompanion(configPath);
449
+ msgs.push("Companion data cleared");
450
+ setDoneMessages(msgs);
451
+ setStep("done");
452
+ }}
453
+ onCancel={() => exit()}
454
+ />
455
+ </Box>
456
+ )}
457
+
458
+ {step === "done" && <DoneStep messages={doneMessages} isActive={step === "done"} />}
459
+ </Box>
460
+ </Box>
461
+ );
462
+ }
463
+
464
+ // ── Export ───────────────────────────────────────────────────────────────
465
+
466
+ export async function runInteractiveUI(opts) {
467
+ const { waitUntilExit } = render(<App opts={opts} />);
468
+ await waitUntilExit();
469
+ }