buddy-reroll 0.1.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/README.md +47 -0
- package/index.js +614 -0
- package/package.json +22 -0
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# buddy-reroll
|
|
2
|
+
|
|
3
|
+
Reroll your [Claude Code](https://docs.anthropic.com/en/docs/claude-code) `/buddy` companion to any species, rarity, eye, hat, and shiny combination.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun install -g buddy-reroll
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# Interactive mode (recommended)
|
|
15
|
+
buddy-reroll
|
|
16
|
+
|
|
17
|
+
# Non-interactive
|
|
18
|
+
buddy-reroll --species dragon --rarity legendary --eye ✦ --hat propeller --shiny
|
|
19
|
+
|
|
20
|
+
# Partial spec (unspecified fields are left random)
|
|
21
|
+
buddy-reroll --species cat --rarity epic
|
|
22
|
+
|
|
23
|
+
# Show current companion
|
|
24
|
+
buddy-reroll --current
|
|
25
|
+
|
|
26
|
+
# Restore original binary
|
|
27
|
+
buddy-reroll --restore
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Options
|
|
31
|
+
|
|
32
|
+
| Flag | Values |
|
|
33
|
+
|---|---|
|
|
34
|
+
| `--species` | duck, goose, blob, cat, dragon, octopus, owl, penguin, turtle, snail, ghost, axolotl, capybara, cactus, robot, rabbit, mushroom, chonk |
|
|
35
|
+
| `--rarity` | common, uncommon, rare, epic, legendary |
|
|
36
|
+
| `--eye` | `·` `✦` `×` `◉` `@` `°` |
|
|
37
|
+
| `--hat` | none, crown, tophat, propeller, halo, wizard, beanie, tinyduck |
|
|
38
|
+
| `--shiny` | `--shiny` / `--no-shiny` |
|
|
39
|
+
|
|
40
|
+
## Requirements
|
|
41
|
+
|
|
42
|
+
- [Bun](https://bun.sh) (uses `Bun.hash()` to match Claude Code's internal hashing)
|
|
43
|
+
- Claude Code
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
|
|
47
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,614 @@
|
|
|
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
|
+
|
|
5
|
+
import { readFileSync, writeFileSync, existsSync, copyFileSync, readdirSync, statSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import { homedir, platform } from "os";
|
|
8
|
+
import { execSync } from "child_process";
|
|
9
|
+
import { parseArgs } from "util";
|
|
10
|
+
import * as p from "@clack/prompts";
|
|
11
|
+
|
|
12
|
+
// ── Constants (must match Claude Code internals) ──────────────────────────
|
|
13
|
+
|
|
14
|
+
const ORIGINAL_SALT = "friend-2026-401";
|
|
15
|
+
const SALT_LEN = ORIGINAL_SALT.length; // 15
|
|
16
|
+
|
|
17
|
+
const RARITIES = ["common", "uncommon", "rare", "epic", "legendary"];
|
|
18
|
+
const RARITY_WEIGHTS = { common: 60, uncommon: 25, rare: 10, epic: 4, legendary: 1 };
|
|
19
|
+
|
|
20
|
+
const SPECIES = [
|
|
21
|
+
"duck", "goose", "blob", "cat", "dragon", "octopus", "owl", "penguin",
|
|
22
|
+
"turtle", "snail", "ghost", "axolotl", "capybara", "cactus", "robot",
|
|
23
|
+
"rabbit", "mushroom", "chonk",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const EYES = ["·", "✦", "×", "◉", "@", "°"];
|
|
27
|
+
|
|
28
|
+
const HATS = ["none", "crown", "tophat", "propeller", "halo", "wizard", "beanie", "tinyduck"];
|
|
29
|
+
|
|
30
|
+
const STAT_NAMES = ["DEBUGGING", "PATIENCE", "CHAOS", "WISDOM", "SNARK"];
|
|
31
|
+
|
|
32
|
+
const RARITY_LABELS = {
|
|
33
|
+
common: "Common (60%)",
|
|
34
|
+
uncommon: "Uncommon (25%)",
|
|
35
|
+
rare: "Rare (10%)",
|
|
36
|
+
epic: "Epic (4%)",
|
|
37
|
+
legendary: "Legendary (1%)",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ── PRNG (Mulberry32 — matches Claude Code) ───────────────────────────────
|
|
41
|
+
|
|
42
|
+
function mulberry32(seed) {
|
|
43
|
+
let a = seed >>> 0;
|
|
44
|
+
return () => {
|
|
45
|
+
a |= 0;
|
|
46
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
47
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
48
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
49
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function hashString(s) {
|
|
54
|
+
return Number(BigInt(Bun.hash(s)) & 0xffffffffn);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function pick(rng, arr) {
|
|
58
|
+
return arr[Math.floor(rng() * arr.length)];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function rollRarity(rng) {
|
|
62
|
+
let roll = rng() * 100;
|
|
63
|
+
for (const r of RARITIES) {
|
|
64
|
+
roll -= RARITY_WEIGHTS[r];
|
|
65
|
+
if (roll < 0) return r;
|
|
66
|
+
}
|
|
67
|
+
return "common";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function rollFrom(salt, userId) {
|
|
71
|
+
const rng = mulberry32(hashString(userId + salt));
|
|
72
|
+
const rarity = rollRarity(rng);
|
|
73
|
+
const species = pick(rng, SPECIES);
|
|
74
|
+
const eye = pick(rng, EYES);
|
|
75
|
+
const hat = rarity === "common" ? "none" : pick(rng, HATS);
|
|
76
|
+
const shiny = rng() < 0.01;
|
|
77
|
+
|
|
78
|
+
const RARITY_FLOOR = { common: 5, uncommon: 15, rare: 25, epic: 35, legendary: 50 };
|
|
79
|
+
const floor = RARITY_FLOOR[rarity];
|
|
80
|
+
const peak = pick(rng, STAT_NAMES);
|
|
81
|
+
let dump = pick(rng, STAT_NAMES);
|
|
82
|
+
while (dump === peak) dump = pick(rng, STAT_NAMES);
|
|
83
|
+
const stats = {};
|
|
84
|
+
for (const name of STAT_NAMES) {
|
|
85
|
+
if (name === peak) stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30));
|
|
86
|
+
else if (name === dump) stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15));
|
|
87
|
+
else stats[name] = floor + Math.floor(rng() * 40);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { rarity, species, eye, hat, shiny, stats };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ── Path detection ────────────────────────────────────────────────────────
|
|
94
|
+
|
|
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;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function findBinaryPath() {
|
|
104
|
+
// Find all `claude` in PATH, skip shell wrappers, resolve symlinks
|
|
105
|
+
try {
|
|
106
|
+
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;
|
|
110
|
+
}
|
|
111
|
+
} catch {}
|
|
112
|
+
|
|
113
|
+
// Fallback: ~/.local/share/claude/versions/<latest>
|
|
114
|
+
const versionsDir = join(homedir(), ".local", "share", "claude", "versions");
|
|
115
|
+
if (existsSync(versionsDir)) {
|
|
116
|
+
try {
|
|
117
|
+
const versions = readdirSync(versionsDir).sort();
|
|
118
|
+
if (versions.length > 0) return join(versionsDir, versions[versions.length - 1]);
|
|
119
|
+
} catch {}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function findConfigPath() {
|
|
126
|
+
const home = homedir();
|
|
127
|
+
const legacyPath = join(home, ".claude", ".config.json");
|
|
128
|
+
if (existsSync(legacyPath)) return legacyPath;
|
|
129
|
+
const defaultPath = join(home, ".claude.json");
|
|
130
|
+
if (existsSync(defaultPath)) return defaultPath;
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getUserId(configPath) {
|
|
135
|
+
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
136
|
+
return config.oauthAccount?.accountUuid ?? config.userID ?? "anon";
|
|
137
|
+
}
|
|
138
|
+
|
|
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.
|
|
145
|
+
|
|
146
|
+
function findCurrentSalt(binaryData) {
|
|
147
|
+
// 1. Try the original salt
|
|
148
|
+
if (binaryData.includes(Buffer.from(ORIGINAL_SALT))) return ORIGINAL_SALT;
|
|
149
|
+
|
|
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
|
+
const text = binaryData.toString("utf-8");
|
|
154
|
+
for (const pat of patterns) {
|
|
155
|
+
const m = text.match(pat);
|
|
156
|
+
if (m && m[0].length === SALT_LEN) return m[0];
|
|
157
|
+
}
|
|
158
|
+
|
|
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
|
+
const candidates = new Set();
|
|
164
|
+
let match;
|
|
165
|
+
// Narrow the search to regions near companion-related strings
|
|
166
|
+
const markers = ["rollRarity", "CompanionBones", "inspirationSeed", "mulberry32"];
|
|
167
|
+
for (const marker of markers) {
|
|
168
|
+
const markerIdx = text.indexOf(marker);
|
|
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);
|
|
174
|
+
while ((match = saltRegex.exec(window)) !== null) {
|
|
175
|
+
candidates.add(match[1]);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
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
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ── Brute-force ───────────────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
function bruteForce(userId, target, spinner) {
|
|
193
|
+
const startTime = Date.now();
|
|
194
|
+
let checked = 0;
|
|
195
|
+
|
|
196
|
+
// Phase 1: "friend-2026-XXX" pattern (262K combinations)
|
|
197
|
+
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_";
|
|
198
|
+
const suffixLen = SALT_LEN - "friend-2026-".length;
|
|
199
|
+
if (suffixLen > 0 && suffixLen <= 4) {
|
|
200
|
+
const gen = function* (prefix, depth) {
|
|
201
|
+
if (depth === 0) { yield prefix; return; }
|
|
202
|
+
for (const ch of chars) yield* gen(prefix + ch, depth - 1);
|
|
203
|
+
};
|
|
204
|
+
for (const suffix of gen("", suffixLen)) {
|
|
205
|
+
const salt = `friend-2026-${suffix}`;
|
|
206
|
+
checked++;
|
|
207
|
+
const r = rollFrom(salt, userId);
|
|
208
|
+
if (matches(r, target)) return { salt, result: r, checked, elapsed: Date.now() - startTime };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Phase 2: "xxxxxxxNNNNNNNN" pattern (up to 1B)
|
|
213
|
+
for (let i = 0; i < 1_000_000_000; i++) {
|
|
214
|
+
const salt = String(i).padStart(SALT_LEN, "x");
|
|
215
|
+
if (salt.length !== SALT_LEN) continue;
|
|
216
|
+
checked++;
|
|
217
|
+
const r = rollFrom(salt, userId);
|
|
218
|
+
if (matches(r, target)) return { salt, result: r, checked, elapsed: Date.now() - startTime };
|
|
219
|
+
|
|
220
|
+
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)`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function matches(roll, target) {
|
|
230
|
+
if (target.species && roll.species !== target.species) return false;
|
|
231
|
+
if (target.rarity && roll.rarity !== target.rarity) return false;
|
|
232
|
+
if (target.eye && roll.eye !== target.eye) return false;
|
|
233
|
+
if (target.hat && roll.hat !== target.hat) return false;
|
|
234
|
+
if (target.shiny !== undefined && roll.shiny !== target.shiny) return false;
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Binary patch ──────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
function patchBinary(binaryPath, oldSalt, newSalt) {
|
|
241
|
+
if (oldSalt.length !== newSalt.length) {
|
|
242
|
+
throw new Error(`Salt length mismatch: "${oldSalt}" (${oldSalt.length}) vs "${newSalt}" (${newSalt.length})`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const data = readFileSync(binaryPath);
|
|
246
|
+
const oldBuf = Buffer.from(oldSalt);
|
|
247
|
+
const newBuf = Buffer.from(newSalt);
|
|
248
|
+
|
|
249
|
+
let count = 0;
|
|
250
|
+
let idx = 0;
|
|
251
|
+
while (true) {
|
|
252
|
+
idx = data.indexOf(oldBuf, idx);
|
|
253
|
+
if (idx === -1) break;
|
|
254
|
+
newBuf.copy(data, idx);
|
|
255
|
+
count++;
|
|
256
|
+
idx += newBuf.length;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (count === 0) throw new Error(`Salt "${oldSalt}" not found in binary`);
|
|
260
|
+
|
|
261
|
+
writeFileSync(binaryPath, data);
|
|
262
|
+
return count;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function resignBinary(binaryPath) {
|
|
266
|
+
if (platform() !== "darwin") return false;
|
|
267
|
+
try {
|
|
268
|
+
execSync(`codesign -s - --force "${binaryPath}" 2>/dev/null`);
|
|
269
|
+
return true;
|
|
270
|
+
} catch {
|
|
271
|
+
return false;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function clearCompanion(configPath) {
|
|
276
|
+
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
277
|
+
delete config.companion;
|
|
278
|
+
delete config.companionMuted;
|
|
279
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ── Display helpers ───────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
function formatRoll(result) {
|
|
285
|
+
const lines = [];
|
|
286
|
+
lines.push(` ${result.species} / ${result.rarity} / eye:${result.eye} / hat:${result.hat}${result.shiny ? " / ✨ shiny" : ""}`);
|
|
287
|
+
for (const [k, v] of Object.entries(result.stats)) {
|
|
288
|
+
const bar = "█".repeat(Math.round(v / 10)) + "░".repeat(10 - Math.round(v / 10));
|
|
289
|
+
lines.push(` ${k.padEnd(10)} ${bar} ${String(v).padStart(3)}`);
|
|
290
|
+
}
|
|
291
|
+
return lines.join("\n");
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Interactive mode ──────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
async function interactiveMode(binaryPath, configPath, userId) {
|
|
297
|
+
p.intro("buddy-reroll");
|
|
298
|
+
|
|
299
|
+
const binaryData = readFileSync(binaryPath);
|
|
300
|
+
const currentSalt = findCurrentSalt(binaryData);
|
|
301
|
+
if (!currentSalt) {
|
|
302
|
+
p.cancel("Could not find companion salt in binary.");
|
|
303
|
+
process.exit(1);
|
|
304
|
+
}
|
|
305
|
+
const currentRoll = rollFrom(currentSalt, userId);
|
|
306
|
+
p.note(formatRoll(currentRoll), "Current companion");
|
|
307
|
+
|
|
308
|
+
const action = await p.select({
|
|
309
|
+
message: "What would you like to do?",
|
|
310
|
+
options: [
|
|
311
|
+
{ value: "reroll", label: "Reroll companion", hint: "pick species, rarity, etc." },
|
|
312
|
+
{ value: "restore", label: "Restore original", hint: "undo all patches" },
|
|
313
|
+
{ value: "current", label: "Show current", hint: "just display info" },
|
|
314
|
+
],
|
|
315
|
+
});
|
|
316
|
+
if (p.isCancel(action)) { p.cancel(); process.exit(0); }
|
|
317
|
+
|
|
318
|
+
if (action === "current") {
|
|
319
|
+
p.outro("Done!");
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (action === "restore") {
|
|
324
|
+
const backupPath = binaryPath + ".backup";
|
|
325
|
+
if (!existsSync(backupPath)) {
|
|
326
|
+
p.cancel("No backup found. Nothing to restore.");
|
|
327
|
+
process.exit(1);
|
|
328
|
+
}
|
|
329
|
+
copyFileSync(backupPath, binaryPath);
|
|
330
|
+
resignBinary(binaryPath);
|
|
331
|
+
clearCompanion(configPath);
|
|
332
|
+
p.outro("Restored! Restart Claude Code and run /buddy.");
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── 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.");
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
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.");
|
|
416
|
+
process.exit(1);
|
|
417
|
+
}
|
|
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
|
+
|
|
449
|
+
if (args.current) {
|
|
450
|
+
const binaryData = readFileSync(binaryPath);
|
|
451
|
+
const currentSalt = findCurrentSalt(binaryData) ?? ORIGINAL_SALT;
|
|
452
|
+
const result = rollFrom(currentSalt, userId);
|
|
453
|
+
console.log(`\n Current companion (salt: ${currentSalt}):`);
|
|
454
|
+
console.log(formatRoll(result));
|
|
455
|
+
console.log();
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
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
|
+
const target = {};
|
|
474
|
+
if (args.species) {
|
|
475
|
+
if (!SPECIES.includes(args.species)) { console.error(` ✗ Unknown species "${args.species}". Use --list.`); process.exit(1); }
|
|
476
|
+
target.species = args.species;
|
|
477
|
+
}
|
|
478
|
+
if (args.rarity) {
|
|
479
|
+
if (!RARITIES.includes(args.rarity)) { console.error(` ✗ Unknown rarity "${args.rarity}". Use --list.`); process.exit(1); }
|
|
480
|
+
target.rarity = args.rarity;
|
|
481
|
+
}
|
|
482
|
+
if (args.eye) {
|
|
483
|
+
if (!EYES.includes(args.eye)) { console.error(` ✗ Unknown eye "${args.eye}". Use --list.`); process.exit(1); }
|
|
484
|
+
target.eye = args.eye;
|
|
485
|
+
}
|
|
486
|
+
if (args.hat) {
|
|
487
|
+
if (!HATS.includes(args.hat)) { console.error(` ✗ Unknown hat "${args.hat}". Use --list.`); process.exit(1); }
|
|
488
|
+
target.hat = args.hat;
|
|
489
|
+
}
|
|
490
|
+
if (args.shiny !== undefined) target.shiny = args.shiny;
|
|
491
|
+
|
|
492
|
+
if (Object.keys(target).length === 0) {
|
|
493
|
+
console.error(" ✗ Specify at least one target. Use --help for usage.");
|
|
494
|
+
process.exit(1);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
console.log(` Target: ${Object.entries(target).map(([k, v]) => `${k}=${v}`).join(" ")}\n`);
|
|
498
|
+
|
|
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
|
+
const currentRoll = rollFrom(currentSalt, userId);
|
|
507
|
+
if (matches(currentRoll, target)) {
|
|
508
|
+
console.log(" ✓ Already matches!\n" + formatRoll(currentRoll));
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
console.log(" Searching...");
|
|
513
|
+
const found = bruteForce(userId, target, null);
|
|
514
|
+
if (!found) {
|
|
515
|
+
console.error(" ✗ No matching salt found. Try relaxing constraints.");
|
|
516
|
+
process.exit(1);
|
|
517
|
+
}
|
|
518
|
+
console.log(` ✓ Found in ${found.checked.toLocaleString()} attempts (${(found.elapsed / 1000).toFixed(1)}s)`);
|
|
519
|
+
console.log(formatRoll(found.result));
|
|
520
|
+
|
|
521
|
+
const backupPath = binaryPath + ".backup";
|
|
522
|
+
if (!existsSync(backupPath)) {
|
|
523
|
+
copyFileSync(binaryPath, backupPath);
|
|
524
|
+
console.log(`\n Backup: ${backupPath}`);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const patchCount = patchBinary(binaryPath, currentSalt, found.salt);
|
|
528
|
+
console.log(` Patched: ${patchCount} occurrence(s)`);
|
|
529
|
+
if (resignBinary(binaryPath)) console.log(" Signed: ad-hoc codesign ✓");
|
|
530
|
+
clearCompanion(configPath);
|
|
531
|
+
console.log(" Config: companion data cleared");
|
|
532
|
+
console.log("\n Done! Restart Claude Code and run /buddy.\n");
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ── Main ──────────────────────────────────────────────────────────────────
|
|
536
|
+
|
|
537
|
+
async function main() {
|
|
538
|
+
const { values: args } = parseArgs({
|
|
539
|
+
options: {
|
|
540
|
+
species: { type: "string" },
|
|
541
|
+
rarity: { type: "string" },
|
|
542
|
+
eye: { type: "string" },
|
|
543
|
+
hat: { type: "string" },
|
|
544
|
+
shiny: { type: "boolean", default: undefined },
|
|
545
|
+
list: { type: "boolean", default: false },
|
|
546
|
+
restore: { type: "boolean", default: false },
|
|
547
|
+
current: { type: "boolean", default: false },
|
|
548
|
+
help: { type: "boolean", short: "h", default: false },
|
|
549
|
+
},
|
|
550
|
+
strict: false,
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
if (args.help) {
|
|
554
|
+
console.log(`
|
|
555
|
+
buddy-reroll — Reroll your Claude Code companion
|
|
556
|
+
|
|
557
|
+
Usage:
|
|
558
|
+
bunx buddy-reroll Interactive mode (recommended)
|
|
559
|
+
bunx buddy-reroll --species dragon --rarity legendary --eye ✦ --shiny
|
|
560
|
+
bunx buddy-reroll --list Show all available options
|
|
561
|
+
bunx buddy-reroll --current Show current companion
|
|
562
|
+
bunx buddy-reroll --restore Restore original binary
|
|
563
|
+
|
|
564
|
+
Flags (all optional — omit to leave random):
|
|
565
|
+
--species <name> ${SPECIES.join(", ")}
|
|
566
|
+
--rarity <name> ${RARITIES.join(", ")}
|
|
567
|
+
--eye <char> ${EYES.join(" ")}
|
|
568
|
+
--hat <name> ${HATS.join(", ")}
|
|
569
|
+
--shiny / --no-shiny
|
|
570
|
+
`);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (args.list) {
|
|
575
|
+
console.log("\n buddy-reroll — available options\n");
|
|
576
|
+
console.log(" Species: ", SPECIES.join(", "));
|
|
577
|
+
console.log(" Rarity: ", RARITIES.map((r) => `${r} (${RARITY_WEIGHTS[r]}%)`).join(", "));
|
|
578
|
+
console.log(" Eye: " + EYES.join(" "));
|
|
579
|
+
console.log(" Hat: ", HATS.join(", "));
|
|
580
|
+
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
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ── Detect paths ──
|
|
589
|
+
const binaryPath = findBinaryPath();
|
|
590
|
+
if (!binaryPath) {
|
|
591
|
+
console.error("✗ Could not find Claude Code binary. Is it installed?");
|
|
592
|
+
process.exit(1);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const configPath = findConfigPath();
|
|
596
|
+
if (!configPath) {
|
|
597
|
+
console.error("✗ Could not find Claude Code config file.");
|
|
598
|
+
process.exit(1);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const userId = getUserId(configPath);
|
|
602
|
+
|
|
603
|
+
// If no target flags given → interactive mode
|
|
604
|
+
const hasTargetFlags = args.species || args.rarity || args.eye || args.hat || args.shiny !== undefined;
|
|
605
|
+
const isCommand = args.restore || args.current;
|
|
606
|
+
|
|
607
|
+
if (!hasTargetFlags && !isCommand) {
|
|
608
|
+
await interactiveMode(binaryPath, configPath, userId);
|
|
609
|
+
} else {
|
|
610
|
+
nonInteractiveMode(args, binaryPath, configPath, userId);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "buddy-reroll",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Reroll your Claude Code buddy companion to any species/rarity/eye/hat/shiny combo",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"buddy-reroll": "index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": ["index.js"],
|
|
10
|
+
"engines": {
|
|
11
|
+
"bun": ">=1.0.0"
|
|
12
|
+
},
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/grayashh/buddy-reroll"
|
|
17
|
+
},
|
|
18
|
+
"keywords": ["claude", "claude-code", "buddy", "companion", "reroll"],
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@clack/prompts": "^1.2.0"
|
|
21
|
+
}
|
|
22
|
+
}
|