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.
- package/index.js +141 -237
- package/package.json +16 -4
- package/sprites.js +137 -0
- 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
|
|
8
|
+
import chalk from "chalk";
|
|
9
|
+
import { renderSprite, colorizeSprite, RARITY_STARS, RARITY_COLORS } from "./sprites.js";
|
|
11
10
|
|
|
12
|
-
|
|
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;
|
|
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 (
|
|
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() *
|
|
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
|
|
96
|
-
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
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)
|
|
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
|
|
127
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
//
|
|
160
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
279
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
280
|
+
const config = JSON.parse(raw);
|
|
277
281
|
delete config.companion;
|
|
278
282
|
delete config.companionMuted;
|
|
279
|
-
|
|
283
|
+
const indent = raw.match(/^(\s+)"/m)?.[1] ?? " ";
|
|
284
|
+
writeFileSync(configPath, JSON.stringify(config, null, indent) + "\n");
|
|
280
285
|
}
|
|
281
286
|
|
|
282
|
-
// ── Display
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
346
|
+
}
|
|
317
347
|
|
|
318
|
-
|
|
319
|
-
p.outro("Done!");
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
348
|
+
// ── Non-interactive mode ─────────────────────────────────────────────────
|
|
322
349
|
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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(
|
|
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" +
|
|
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(
|
|
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.
|
|
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": [
|
|
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": [
|
|
22
|
+
"keywords": [
|
|
23
|
+
"claude",
|
|
24
|
+
"claude-code",
|
|
25
|
+
"buddy",
|
|
26
|
+
"companion",
|
|
27
|
+
"reroll"
|
|
28
|
+
],
|
|
19
29
|
"dependencies": {
|
|
20
|
-
"
|
|
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
|
+
}
|