buddy-reroll 0.1.0 → 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 +141 -237
  2. package/package.json +16 -4
  3. package/sprites.js +137 -0
  4. package/ui.jsx +469 -0
package/index.js CHANGED
@@ -1,21 +1,27 @@
1
1
  #!/usr/bin/env bun
2
- // buddy-reroll — Reroll your Claude Code companion to any combo you want.
3
- // Requires Bun (uses Bun.hash which matches Claude Code's internal hash).
4
2
 
5
- import { readFileSync, writeFileSync, existsSync, copyFileSync, readdirSync, statSync } from "fs";
3
+ import { readFileSync, writeFileSync, existsSync, copyFileSync, readdirSync, statSync, realpathSync } from "fs";
6
4
  import { join } from "path";
7
5
  import { homedir, platform } from "os";
8
6
  import { execSync } from "child_process";
9
7
  import { parseArgs } from "util";
10
- import * as p from "@clack/prompts";
8
+ import chalk from "chalk";
9
+ import { renderSprite, colorizeSprite, RARITY_STARS, RARITY_COLORS } from "./sprites.js";
11
10
 
12
- // ── Constants (must match Claude Code internals) ──────────────────────────
11
+ if (typeof Bun === "undefined") {
12
+ console.error("buddy-reroll requires Bun runtime (uses Bun.hash).\nInstall: https://bun.sh");
13
+ process.exit(1);
14
+ }
15
+
16
+ // ── Constants (synced with Claude Code src/buddy/types.ts) ───────────────
13
17
 
14
18
  const ORIGINAL_SALT = "friend-2026-401";
15
- const SALT_LEN = ORIGINAL_SALT.length; // 15
19
+ const SALT_LEN = ORIGINAL_SALT.length;
16
20
 
17
21
  const RARITIES = ["common", "uncommon", "rare", "epic", "legendary"];
18
22
  const RARITY_WEIGHTS = { common: 60, uncommon: 25, rare: 10, epic: 4, legendary: 1 };
23
+ const RARITY_TOTAL = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0);
24
+ const RARITY_FLOOR = { common: 5, uncommon: 15, rare: 25, epic: 35, legendary: 50 };
19
25
 
20
26
  const SPECIES = [
21
27
  "duck", "goose", "blob", "cat", "dragon", "octopus", "owl", "penguin",
@@ -24,9 +30,7 @@ const SPECIES = [
24
30
  ];
25
31
 
26
32
  const EYES = ["·", "✦", "×", "◉", "@", "°"];
27
-
28
33
  const HATS = ["none", "crown", "tophat", "propeller", "halo", "wizard", "beanie", "tinyduck"];
29
-
30
34
  const STAT_NAMES = ["DEBUGGING", "PATIENCE", "CHAOS", "WISDOM", "SNARK"];
31
35
 
32
36
  const RARITY_LABELS = {
@@ -37,7 +41,7 @@ const RARITY_LABELS = {
37
41
  legendary: "Legendary (1%)",
38
42
  };
39
43
 
40
- // ── PRNG (Mulberry32 matches Claude Code) ───────────────────────────────
44
+ // ── PRNG (synced with Claude Code src/buddy/companion.ts) ────────────────
41
45
 
42
46
  function mulberry32(seed) {
43
47
  let a = seed >>> 0;
@@ -59,7 +63,7 @@ function pick(rng, arr) {
59
63
  }
60
64
 
61
65
  function rollRarity(rng) {
62
- let roll = rng() * 100;
66
+ let roll = rng() * RARITY_TOTAL;
63
67
  for (const r of RARITIES) {
64
68
  roll -= RARITY_WEIGHTS[r];
65
69
  if (roll < 0) return r;
@@ -75,7 +79,6 @@ function rollFrom(salt, userId) {
75
79
  const hat = rarity === "common" ? "none" : pick(rng, HATS);
76
80
  const shiny = rng() < 0.01;
77
81
 
78
- const RARITY_FLOOR = { common: 5, uncommon: 15, rare: 25, epic: 35, legendary: 50 };
79
82
  const floor = RARITY_FLOOR[rarity];
80
83
  const peak = pick(rng, STAT_NAMES);
81
84
  let dump = pick(rng, STAT_NAMES);
@@ -90,31 +93,29 @@ function rollFrom(salt, userId) {
90
93
  return { rarity, species, eye, hat, shiny, stats };
91
94
  }
92
95
 
93
- // ── Path detection ────────────────────────────────────────────────────────
96
+ // ── Path detection ───────────────────────────────────────────────────────
94
97
 
95
- function resolveSymlink(filePath) {
96
- try {
97
- const resolved = execSync(`readlink "${filePath}" 2>/dev/null`, { encoding: "utf-8" }).trim();
98
- if (resolved && existsSync(resolved)) return resolved;
99
- } catch {}
100
- return filePath;
98
+ function getClaudeConfigDir() {
99
+ return process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
101
100
  }
102
101
 
103
102
  function findBinaryPath() {
104
- // Find all `claude` in PATH, skip shell wrappers, resolve symlinks
105
103
  try {
106
104
  const allPaths = execSync("which -a claude 2>/dev/null", { encoding: "utf-8" }).trim().split("\n");
107
- for (const p of allPaths) {
108
- const resolved = resolveSymlink(p.trim());
109
- if (resolved && existsSync(resolved) && statSync(resolved).size > 1_000_000) return resolved;
105
+ for (const entry of allPaths) {
106
+ try {
107
+ const resolved = realpathSync(entry.trim());
108
+ if (resolved && existsSync(resolved) && statSync(resolved).size > 1_000_000) return resolved;
109
+ } catch {}
110
110
  }
111
111
  } catch {}
112
112
 
113
- // Fallback: ~/.local/share/claude/versions/<latest>
114
113
  const versionsDir = join(homedir(), ".local", "share", "claude", "versions");
115
114
  if (existsSync(versionsDir)) {
116
115
  try {
117
- const versions = readdirSync(versionsDir).sort();
116
+ const versions = readdirSync(versionsDir)
117
+ .filter((f) => !f.includes(".backup"))
118
+ .sort();
118
119
  if (versions.length > 0) return join(versionsDir, versions[versions.length - 1]);
119
120
  } catch {}
120
121
  }
@@ -123,11 +124,15 @@ function findBinaryPath() {
123
124
  }
124
125
 
125
126
  function findConfigPath() {
126
- const home = homedir();
127
- const legacyPath = join(home, ".claude", ".config.json");
127
+ const claudeDir = getClaudeConfigDir();
128
+
129
+ const legacyPath = join(claudeDir, ".config.json");
128
130
  if (existsSync(legacyPath)) return legacyPath;
131
+
132
+ const home = process.env.CLAUDE_CONFIG_DIR || homedir();
129
133
  const defaultPath = join(home, ".claude.json");
130
134
  if (existsSync(defaultPath)) return defaultPath;
135
+
131
136
  return null;
132
137
  }
133
138
 
@@ -136,64 +141,53 @@ function getUserId(configPath) {
136
141
  return config.oauthAccount?.accountUuid ?? config.userID ?? "anon";
137
142
  }
138
143
 
139
- // ── Salt detection ────────────────────────────────────────────────────────
140
- // The salt appears in the JS bundle inside the binary as a quoted string.
141
- // Instead of relying on minified variable names (which change per build),
142
- // we search for any 15-byte ASCII string that, when used as a salt with
143
- // the user's ID, reproduces the companion roll stored in config — or falls
144
- // back to known patterns.
144
+ // ── Salt detection ───────────────────────────────────────────────────────
145
145
 
146
- function findCurrentSalt(binaryData) {
147
- // 1. Try the original salt
146
+ function findCurrentSalt(binaryData, userId) {
148
147
  if (binaryData.includes(Buffer.from(ORIGINAL_SALT))) return ORIGINAL_SALT;
149
148
 
150
- // 2. Try to find a previously patched salt by scanning for known patterns
151
- // Our patches use "xxxxxxx" prefix or "friend-2026-" prefix
152
- const patterns = [/xxxxxxx\d{8}/, /friend-2026-.{3}/];
153
149
  const text = binaryData.toString("utf-8");
150
+
151
+ // Scan for previously patched salts (our known patterns)
152
+ const patterns = [
153
+ new RegExp(`x{${SALT_LEN - 8}}\\d{8}`, "g"),
154
+ new RegExp(`friend-\\d{4}-.{${SALT_LEN - 12}}`, "g"),
155
+ ];
154
156
  for (const pat of patterns) {
155
- const m = text.match(pat);
156
- if (m && m[0].length === SALT_LEN) return m[0];
157
+ let m;
158
+ while ((m = pat.exec(text)) !== null) {
159
+ if (m[0].length === SALT_LEN) return m[0];
160
+ }
157
161
  }
158
162
 
159
- // 3. Generic scan: look for the salt near the PRNG/companion code markers.
160
- // Search for strings near "friend-" or near "Mulberry" or "rollRarity"
161
- // that are exactly SALT_LEN printable ASCII chars inside quotes.
162
- const saltRegex = /"([a-zA-Z0-9_-]{15})"/g;
163
+ // Contextual scan near companion code markers
164
+ const saltRegex = new RegExp(`"([a-zA-Z0-9_-]{${SALT_LEN}})"`, "g");
163
165
  const candidates = new Set();
164
- let match;
165
- // Narrow the search to regions near companion-related strings
166
- const markers = ["rollRarity", "CompanionBones", "inspirationSeed", "mulberry32"];
166
+ const markers = ["rollRarity", "CompanionBones", "inspirationSeed", "companionUserId"];
167
167
  for (const marker of markers) {
168
168
  const markerIdx = text.indexOf(marker);
169
169
  if (markerIdx === -1) continue;
170
- // Search in a ±5000 char window around the marker
171
- const windowStart = Math.max(0, markerIdx - 5000);
172
- const windowEnd = Math.min(text.length, markerIdx + 5000);
173
- const window = text.slice(windowStart, windowEnd);
170
+ const window = text.slice(Math.max(0, markerIdx - 5000), Math.min(text.length, markerIdx + 5000));
171
+ let match;
174
172
  while ((match = saltRegex.exec(window)) !== null) {
175
173
  candidates.add(match[1]);
176
174
  }
177
175
  }
178
176
 
179
- // Test each candidate: the one that produces a valid companion roll is our salt
180
- if (candidates.size > 0) {
181
- // We can't verify without userId, but return the first plausible one
182
- for (const c of candidates) {
183
- if (c.length === SALT_LEN) return c;
184
- }
177
+ // Filter: real salts contain digits or hyphens (rules out "projectSettings" etc.)
178
+ for (const c of candidates) {
179
+ if (/[\d-]/.test(c)) return c;
185
180
  }
186
181
 
187
182
  return null;
188
183
  }
189
184
 
190
- // ── Brute-force ───────────────────────────────────────────────────────────
185
+ // ── Brute-force ──────────────────────────────────────────────────────────
191
186
 
192
- function bruteForce(userId, target, spinner) {
187
+ async function bruteForce(userId, target, onProgress) {
193
188
  const startTime = Date.now();
194
189
  let checked = 0;
195
190
 
196
- // Phase 1: "friend-2026-XXX" pattern (262K combinations)
197
191
  const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_";
198
192
  const suffixLen = SALT_LEN - "friend-2026-".length;
199
193
  if (suffixLen > 0 && suffixLen <= 4) {
@@ -209,17 +203,17 @@ function bruteForce(userId, target, spinner) {
209
203
  }
210
204
  }
211
205
 
212
- // Phase 2: "xxxxxxxNNNNNNNN" pattern (up to 1B)
213
206
  for (let i = 0; i < 1_000_000_000; i++) {
214
207
  const salt = String(i).padStart(SALT_LEN, "x");
215
- if (salt.length !== SALT_LEN) continue;
216
208
  checked++;
217
209
  const r = rollFrom(salt, userId);
218
210
  if (matches(r, target)) return { salt, result: r, checked, elapsed: Date.now() - startTime };
219
211
 
212
+ if (checked % 100_000 === 0) {
213
+ await new Promise((r) => setTimeout(r, 0));
214
+ }
220
215
  if (checked % 5_000_000 === 0) {
221
- const secs = ((Date.now() - startTime) / 1000).toFixed(1);
222
- if (spinner) spinner.message(`${(checked / 1e6).toFixed(0)}M salts checked (${secs}s)`);
216
+ if (onProgress) onProgress(checked, Date.now() - startTime);
223
217
  }
224
218
  }
225
219
 
@@ -235,7 +229,16 @@ function matches(roll, target) {
235
229
  return true;
236
230
  }
237
231
 
238
- // ── Binary patch ──────────────────────────────────────────────────────────
232
+ // ── Binary patch ─────────────────────────────────────────────────────────
233
+
234
+ function isClaudeRunning() {
235
+ try {
236
+ const out = execSync("pgrep -af claude 2>/dev/null", { encoding: "utf-8" });
237
+ return out.split("\n").some((line) => !line.includes("buddy-reroll") && line.trim().length > 0);
238
+ } catch {
239
+ return false;
240
+ }
241
+ }
239
242
 
240
243
  function patchBinary(binaryPath, oldSalt, newSalt) {
241
244
  if (oldSalt.length !== newSalt.length) {
@@ -273,203 +276,110 @@ function resignBinary(binaryPath) {
273
276
  }
274
277
 
275
278
  function clearCompanion(configPath) {
276
- const config = JSON.parse(readFileSync(configPath, "utf-8"));
279
+ const raw = readFileSync(configPath, "utf-8");
280
+ const config = JSON.parse(raw);
277
281
  delete config.companion;
278
282
  delete config.companionMuted;
279
- writeFileSync(configPath, JSON.stringify(config, null, 2));
283
+ const indent = raw.match(/^(\s+)"/m)?.[1] ?? " ";
284
+ writeFileSync(configPath, JSON.stringify(config, null, indent) + "\n");
280
285
  }
281
286
 
282
- // ── Display helpers ───────────────────────────────────────────────────────
287
+ // ── Display ──────────────────────────────────────────────────────────────
288
+
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);
283
299
 
284
- function formatRoll(result) {
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
- // ── Interactive mode ──────────────────────────────────────────────────────
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
- const currentSalt = findCurrentSalt(binaryData);
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
- ],
325
+
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,
315
345
  });
316
- if (p.isCancel(action)) { p.cancel(); process.exit(0); }
346
+ }
317
347
 
318
- if (action === "current") {
319
- p.outro("Done!");
320
- return;
321
- }
348
+ // ── Non-interactive mode ─────────────────────────────────────────────────
322
349
 
323
- if (action === "restore") {
350
+ async function nonInteractiveMode(args, binaryPath, configPath, userId) {
351
+ console.log(` Binary: ${binaryPath}`);
352
+ console.log(` Config: ${configPath}`);
353
+ console.log(` User ID: ${userId.slice(0, 8)}...`);
354
+
355
+ if (args.restore) {
324
356
  const backupPath = binaryPath + ".backup";
325
357
  if (!existsSync(backupPath)) {
326
- p.cancel("No backup found. Nothing to restore.");
358
+ console.error("No backup found at", backupPath);
327
359
  process.exit(1);
328
360
  }
329
361
  copyFileSync(backupPath, binaryPath);
330
362
  resignBinary(binaryPath);
331
363
  clearCompanion(configPath);
332
- p.outro("Restored! Restart Claude Code and run /buddy.");
333
- return;
334
- }
335
-
336
- // ── Reroll flow ──
337
-
338
- const species = await p.select({
339
- message: "Species",
340
- options: SPECIES.map((s) => ({
341
- value: s,
342
- label: s,
343
- hint: s === currentRoll.species ? "current" : undefined,
344
- })),
345
- initialValue: currentRoll.species,
346
- });
347
- if (p.isCancel(species)) { p.cancel(); process.exit(0); }
348
-
349
- const rarity = await p.select({
350
- message: "Rarity",
351
- options: RARITIES.map((r) => ({
352
- value: r,
353
- label: RARITY_LABELS[r],
354
- hint: r === currentRoll.rarity ? "current" : undefined,
355
- })),
356
- initialValue: currentRoll.rarity,
357
- });
358
- if (p.isCancel(rarity)) { p.cancel(); process.exit(0); }
359
-
360
- const eye = await p.select({
361
- message: "Eye",
362
- options: EYES.map((e) => ({
363
- value: e,
364
- label: e,
365
- hint: e === currentRoll.eye ? "current" : undefined,
366
- })),
367
- initialValue: currentRoll.eye,
368
- });
369
- if (p.isCancel(eye)) { p.cancel(); process.exit(0); }
370
-
371
- let hat = "none";
372
- if (rarity === "common") {
373
- p.log.info("Common rarity always gets hat=none");
374
- } else {
375
- hat = await p.select({
376
- message: "Hat",
377
- options: HATS.filter((h) => h !== "none").map((h) => ({
378
- value: h,
379
- label: h,
380
- hint: h === currentRoll.hat ? "current" : undefined,
381
- })),
382
- initialValue: currentRoll.hat === "none" ? "crown" : currentRoll.hat,
383
- });
384
- if (p.isCancel(hat)) { p.cancel(); process.exit(0); }
385
- }
386
-
387
- const shiny = await p.confirm({
388
- message: "Shiny?",
389
- initialValue: false,
390
- });
391
- if (p.isCancel(shiny)) { p.cancel(); process.exit(0); }
392
-
393
- const target = { species, rarity, eye, hat, shiny };
394
-
395
- // Check if already matching
396
- if (matches(currentRoll, target)) {
397
- p.outro("Already matching! No changes needed.");
364
+ console.log("Restored. Restart Claude Code and run /buddy.");
398
365
  return;
399
366
  }
400
367
 
401
- // Preview target
402
- p.log.info(`Target: ${species} / ${rarity} / eye:${eye} / hat:${hat}${shiny ? " / ✨ shiny" : ""}`);
403
-
404
- const confirm = await p.confirm({
405
- message: "Search and apply?",
406
- });
407
- if (p.isCancel(confirm) || !confirm) { p.cancel(); process.exit(0); }
408
-
409
- // ── Search ──
410
- const spinner = p.spinner();
411
- spinner.start("Searching for matching salt...");
412
-
413
- const found = bruteForce(userId, target, spinner);
414
- if (!found) {
415
- spinner.stop("No matching salt found. Try relaxing constraints.");
368
+ const binaryData = readFileSync(binaryPath);
369
+ const currentSalt = findCurrentSalt(binaryData, userId);
370
+ if (!currentSalt) {
371
+ console.error(" Could not find companion salt in binary.");
416
372
  process.exit(1);
417
373
  }
418
- spinner.stop(`Found in ${found.checked.toLocaleString()} attempts (${(found.elapsed / 1000).toFixed(1)}s)`);
419
-
420
- p.note(formatRoll(found.result), "New companion");
421
-
422
- // ── Apply ──
423
- const backupPath = binaryPath + ".backup";
424
- if (!existsSync(backupPath)) {
425
- copyFileSync(binaryPath, backupPath);
426
- p.log.info(`Backup saved to ${backupPath}`);
427
- }
428
-
429
- const patchCount = patchBinary(binaryPath, currentSalt, found.salt);
430
- p.log.success(`Patched ${patchCount} occurrence(s)`);
431
-
432
- if (resignBinary(binaryPath)) {
433
- p.log.success("Binary re-signed (ad-hoc codesign)");
434
- }
435
-
436
- clearCompanion(configPath);
437
- p.log.success("Companion data cleared");
438
-
439
- p.outro("Done! Restart Claude Code and run /buddy to hatch your new companion.");
440
- }
441
-
442
- // ── Non-interactive mode ──────────────────────────────────────────────────
443
-
444
- function nonInteractiveMode(args, binaryPath, configPath, userId) {
445
- console.log(` Binary: ${binaryPath}`);
446
- console.log(` Config: ${configPath}`);
447
- console.log(` User ID: ${userId.slice(0, 8)}...`);
448
374
 
449
375
  if (args.current) {
450
- const binaryData = readFileSync(binaryPath);
451
- const currentSalt = findCurrentSalt(binaryData) ?? ORIGINAL_SALT;
452
376
  const result = rollFrom(currentSalt, userId);
453
377
  console.log(`\n Current companion (salt: ${currentSalt}):`);
454
- console.log(formatRoll(result));
378
+ console.log(formatCompanionCard(result));
455
379
  console.log();
456
380
  return;
457
381
  }
458
382
 
459
- if (args.restore) {
460
- const backupPath = binaryPath + ".backup";
461
- if (!existsSync(backupPath)) {
462
- console.error(" ✗ No backup found at", backupPath);
463
- process.exit(1);
464
- }
465
- copyFileSync(backupPath, binaryPath);
466
- resignBinary(binaryPath);
467
- clearCompanion(configPath);
468
- console.log(" ✓ Restored. Restart Claude Code and run /buddy.");
469
- return;
470
- }
471
-
472
- // Build target from flags
473
383
  const target = {};
474
384
  if (args.species) {
475
385
  if (!SPECIES.includes(args.species)) { console.error(` ✗ Unknown species "${args.species}". Use --list.`); process.exit(1); }
@@ -496,27 +406,24 @@ function nonInteractiveMode(args, binaryPath, configPath, userId) {
496
406
 
497
407
  console.log(` Target: ${Object.entries(target).map(([k, v]) => `${k}=${v}`).join(" ")}\n`);
498
408
 
499
- const binaryData = readFileSync(binaryPath);
500
- const currentSalt = findCurrentSalt(binaryData);
501
- if (!currentSalt) {
502
- console.error(" ✗ Could not find companion salt in binary.");
503
- process.exit(1);
504
- }
505
-
506
409
  const currentRoll = rollFrom(currentSalt, userId);
507
410
  if (matches(currentRoll, target)) {
508
- console.log(" ✓ Already matches!\n" + formatRoll(currentRoll));
411
+ console.log(" ✓ Already matches!\n" + formatCompanionCard(currentRoll));
509
412
  return;
510
413
  }
511
414
 
415
+ if (isClaudeRunning()) {
416
+ console.warn(" ⚠ Claude Code appears to be running. Quit it before patching to avoid issues.");
417
+ }
418
+
512
419
  console.log(" Searching...");
513
- const found = bruteForce(userId, target, null);
420
+ const found = await bruteForce(userId, target, null);
514
421
  if (!found) {
515
422
  console.error(" ✗ No matching salt found. Try relaxing constraints.");
516
423
  process.exit(1);
517
424
  }
518
425
  console.log(` ✓ Found in ${found.checked.toLocaleString()} attempts (${(found.elapsed / 1000).toFixed(1)}s)`);
519
- console.log(formatRoll(found.result));
426
+ console.log(formatCompanionCard(found.result));
520
427
 
521
428
  const backupPath = binaryPath + ".backup";
522
429
  if (!existsSync(backupPath)) {
@@ -532,7 +439,7 @@ function nonInteractiveMode(args, binaryPath, configPath, userId) {
532
439
  console.log("\n Done! Restart Claude Code and run /buddy.\n");
533
440
  }
534
441
 
535
- // ── Main ──────────────────────────────────────────────────────────────────
442
+ // ── Main ─────────────────────────────────────────────────────────────────
536
443
 
537
444
  async function main() {
538
445
  const { values: args } = parseArgs({
@@ -578,14 +485,9 @@ async function main() {
578
485
  console.log(" Eye: " + EYES.join(" "));
579
486
  console.log(" Hat: ", HATS.join(", "));
580
487
  console.log(" Shiny: true / false (1% natural chance)\n");
581
- console.log(" Notes:");
582
- console.log(" - common rarity always gets hat=none");
583
- console.log(" - more constraints = longer search time");
584
- console.log(" - shiny + legendary + specific species can take ~30s\n");
585
488
  return;
586
489
  }
587
490
 
588
- // ── Detect paths ──
589
491
  const binaryPath = findBinaryPath();
590
492
  if (!binaryPath) {
591
493
  console.error("✗ Could not find Claude Code binary. Is it installed?");
@@ -599,15 +501,17 @@ async function main() {
599
501
  }
600
502
 
601
503
  const userId = getUserId(configPath);
504
+ if (userId === "anon") {
505
+ console.warn("⚠ No user ID found — using anonymous identity. Roll will change if you log in later.");
506
+ }
602
507
 
603
- // If no target flags given → interactive mode
604
508
  const hasTargetFlags = args.species || args.rarity || args.eye || args.hat || args.shiny !== undefined;
605
509
  const isCommand = args.restore || args.current;
606
510
 
607
511
  if (!hasTargetFlags && !isCommand) {
608
512
  await interactiveMode(binaryPath, configPath, userId);
609
513
  } else {
610
- nonInteractiveMode(args, binaryPath, configPath, userId);
514
+ await nonInteractiveMode(args, binaryPath, configPath, userId);
611
515
  }
612
516
  }
613
517
 
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "buddy-reroll",
3
- "version": "0.1.0",
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
- "files": ["index.js"],
9
+ "files": [
10
+ "index.js",
11
+ "ui.jsx",
12
+ "sprites.js"
13
+ ],
10
14
  "engines": {
11
15
  "bun": ">=1.0.0"
12
16
  },
@@ -15,8 +19,16 @@
15
19
  "type": "git",
16
20
  "url": "https://github.com/grayashh/buddy-reroll"
17
21
  },
18
- "keywords": ["claude", "claude-code", "buddy", "companion", "reroll"],
22
+ "keywords": [
23
+ "claude",
24
+ "claude-code",
25
+ "buddy",
26
+ "companion",
27
+ "reroll"
28
+ ],
19
29
  "dependencies": {
20
- "@clack/prompts": "^1.2.0"
30
+ "chalk": "^5.6.2",
31
+ "ink": "^6.8.0",
32
+ "react": "^19.2.4"
21
33
  }
22
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
+ }