buddy-reroll 0.2.0 → 0.3.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 +78 -7
- package/index.js +256 -311
- package/lib/companion.js +183 -0
- package/lib/companion.test.js +134 -0
- package/lib/doctor.js +73 -0
- package/lib/estimator.js +43 -0
- package/lib/estimator.test.js +97 -0
- package/lib/finder.js +104 -0
- package/lib/finder.test.js +35 -0
- package/lib/hooks.js +96 -0
- package/lib/hooks.test.js +240 -0
- package/lib/runtime.js +184 -0
- package/lib/runtime.test.js +110 -0
- package/lib/wyhash.js +88 -0
- package/lib/wyhash.test.js +50 -0
- package/package.json +13 -2
- package/scripts/verify-runtime.js +34 -0
- package/scripts/worker.js +40 -0
- package/ui-fallback.js +181 -0
- package/ui.jsx +134 -64
package/index.js
CHANGED
|
@@ -1,139 +1,34 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
1
|
+
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { readFileSync, writeFileSync, existsSync, copyFileSync,
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import { execSync } from "child_process";
|
|
3
|
+
import { readFileSync, writeFileSync, existsSync, copyFileSync, renameSync, unlinkSync } from "fs";
|
|
4
|
+
import { platform } from "os";
|
|
5
|
+
import { execFileSync } from "child_process";
|
|
7
6
|
import { parseArgs } from "util";
|
|
8
7
|
import chalk from "chalk";
|
|
9
8
|
import { renderSprite, colorizeSprite, RARITY_STARS, RARITY_COLORS } from "./sprites.js";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const HATS = ["none", "crown", "tophat", "propeller", "halo", "wizard", "beanie", "tinyduck"];
|
|
34
|
-
const STAT_NAMES = ["DEBUGGING", "PATIENCE", "CHAOS", "WISDOM", "SNARK"];
|
|
35
|
-
|
|
36
|
-
const RARITY_LABELS = {
|
|
37
|
-
common: "Common (60%)",
|
|
38
|
-
uncommon: "Uncommon (25%)",
|
|
39
|
-
rare: "Rare (10%)",
|
|
40
|
-
epic: "Epic (4%)",
|
|
41
|
-
legendary: "Legendary (1%)",
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
// ── PRNG (synced with Claude Code src/buddy/companion.ts) ────────────────
|
|
45
|
-
|
|
46
|
-
function mulberry32(seed) {
|
|
47
|
-
let a = seed >>> 0;
|
|
48
|
-
return () => {
|
|
49
|
-
a |= 0;
|
|
50
|
-
a = (a + 0x6d2b79f5) | 0;
|
|
51
|
-
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
52
|
-
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
53
|
-
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
54
|
-
};
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
function hashString(s) {
|
|
58
|
-
return Number(BigInt(Bun.hash(s)) & 0xffffffffn);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function pick(rng, arr) {
|
|
62
|
-
return arr[Math.floor(rng() * arr.length)];
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function rollRarity(rng) {
|
|
66
|
-
let roll = rng() * RARITY_TOTAL;
|
|
67
|
-
for (const r of RARITIES) {
|
|
68
|
-
roll -= RARITY_WEIGHTS[r];
|
|
69
|
-
if (roll < 0) return r;
|
|
70
|
-
}
|
|
71
|
-
return "common";
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function rollFrom(salt, userId) {
|
|
75
|
-
const rng = mulberry32(hashString(userId + salt));
|
|
76
|
-
const rarity = rollRarity(rng);
|
|
77
|
-
const species = pick(rng, SPECIES);
|
|
78
|
-
const eye = pick(rng, EYES);
|
|
79
|
-
const hat = rarity === "common" ? "none" : pick(rng, HATS);
|
|
80
|
-
const shiny = rng() < 0.01;
|
|
81
|
-
|
|
82
|
-
const floor = RARITY_FLOOR[rarity];
|
|
83
|
-
const peak = pick(rng, STAT_NAMES);
|
|
84
|
-
let dump = pick(rng, STAT_NAMES);
|
|
85
|
-
while (dump === peak) dump = pick(rng, STAT_NAMES);
|
|
86
|
-
const stats = {};
|
|
87
|
-
for (const name of STAT_NAMES) {
|
|
88
|
-
if (name === peak) stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30));
|
|
89
|
-
else if (name === dump) stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15));
|
|
90
|
-
else stats[name] = floor + Math.floor(rng() * 40);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return { rarity, species, eye, hat, shiny, stats };
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// ── Path detection ───────────────────────────────────────────────────────
|
|
97
|
-
|
|
98
|
-
function getClaudeConfigDir() {
|
|
99
|
-
return process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function findBinaryPath() {
|
|
103
|
-
try {
|
|
104
|
-
const allPaths = execSync("which -a claude 2>/dev/null", { encoding: "utf-8" }).trim().split("\n");
|
|
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
|
-
}
|
|
111
|
-
} catch {}
|
|
112
|
-
|
|
113
|
-
const versionsDir = join(homedir(), ".local", "share", "claude", "versions");
|
|
114
|
-
if (existsSync(versionsDir)) {
|
|
115
|
-
try {
|
|
116
|
-
const versions = readdirSync(versionsDir)
|
|
117
|
-
.filter((f) => !f.includes(".backup"))
|
|
118
|
-
.sort();
|
|
119
|
-
if (versions.length > 0) return join(versionsDir, versions[versions.length - 1]);
|
|
120
|
-
} catch {}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return null;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function findConfigPath() {
|
|
127
|
-
const claudeDir = getClaudeConfigDir();
|
|
128
|
-
|
|
129
|
-
const legacyPath = join(claudeDir, ".config.json");
|
|
130
|
-
if (existsSync(legacyPath)) return legacyPath;
|
|
131
|
-
|
|
132
|
-
const home = process.env.CLAUDE_CONFIG_DIR || homedir();
|
|
133
|
-
const defaultPath = join(home, ".claude.json");
|
|
134
|
-
if (existsSync(defaultPath)) return defaultPath;
|
|
135
|
-
|
|
136
|
-
return null;
|
|
9
|
+
import {
|
|
10
|
+
EYES,
|
|
11
|
+
HATS,
|
|
12
|
+
RARITIES,
|
|
13
|
+
RARITY_LABELS,
|
|
14
|
+
RARITY_WEIGHTS,
|
|
15
|
+
SPECIES,
|
|
16
|
+
STAT_NAMES,
|
|
17
|
+
bruteForce,
|
|
18
|
+
findCurrentSalt,
|
|
19
|
+
matches,
|
|
20
|
+
rollFrom,
|
|
21
|
+
} from "./lib/companion.js";
|
|
22
|
+
import { formatDoctorReport, getDoctorReport } from "./lib/doctor.js";
|
|
23
|
+
import { findBinaryPath, findConfigPath, getClaudeBinaryOverride, getPatchability } from "./lib/runtime.js";
|
|
24
|
+
import { parallelBruteForce } from "./lib/finder.js";
|
|
25
|
+
import { estimateAttempts, formatProgress } from "./lib/estimator.js";
|
|
26
|
+
import { installHook, removeHook, storeSalt, readStoredSalt } from "./lib/hooks.js";
|
|
27
|
+
|
|
28
|
+
const IS_BUN = typeof Bun !== "undefined";
|
|
29
|
+
const IS_APPLY_HOOK = process.argv.includes("--apply-hook");
|
|
30
|
+
if (!IS_BUN && !IS_APPLY_HOOK) {
|
|
31
|
+
console.warn("⚠ Running without Bun — searching will be a bit slower. For the speediest experience: https://bun.sh");
|
|
137
32
|
}
|
|
138
33
|
|
|
139
34
|
function getUserId(configPath) {
|
|
@@ -141,105 +36,32 @@ function getUserId(configPath) {
|
|
|
141
36
|
return config.oauthAccount?.accountUuid ?? config.userID ?? "anon";
|
|
142
37
|
}
|
|
143
38
|
|
|
144
|
-
// ── Salt detection ───────────────────────────────────────────────────────
|
|
145
|
-
|
|
146
|
-
function findCurrentSalt(binaryData, userId) {
|
|
147
|
-
if (binaryData.includes(Buffer.from(ORIGINAL_SALT))) return ORIGINAL_SALT;
|
|
148
|
-
|
|
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
|
-
];
|
|
156
|
-
for (const pat of patterns) {
|
|
157
|
-
let m;
|
|
158
|
-
while ((m = pat.exec(text)) !== null) {
|
|
159
|
-
if (m[0].length === SALT_LEN) return m[0];
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Contextual scan near companion code markers
|
|
164
|
-
const saltRegex = new RegExp(`"([a-zA-Z0-9_-]{${SALT_LEN}})"`, "g");
|
|
165
|
-
const candidates = new Set();
|
|
166
|
-
const markers = ["rollRarity", "CompanionBones", "inspirationSeed", "companionUserId"];
|
|
167
|
-
for (const marker of markers) {
|
|
168
|
-
const markerIdx = text.indexOf(marker);
|
|
169
|
-
if (markerIdx === -1) continue;
|
|
170
|
-
const window = text.slice(Math.max(0, markerIdx - 5000), Math.min(text.length, markerIdx + 5000));
|
|
171
|
-
let match;
|
|
172
|
-
while ((match = saltRegex.exec(window)) !== null) {
|
|
173
|
-
candidates.add(match[1]);
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
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;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// ── Brute-force ──────────────────────────────────────────────────────────
|
|
186
|
-
|
|
187
|
-
async function bruteForce(userId, target, onProgress) {
|
|
188
|
-
const startTime = Date.now();
|
|
189
|
-
let checked = 0;
|
|
190
|
-
|
|
191
|
-
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_";
|
|
192
|
-
const suffixLen = SALT_LEN - "friend-2026-".length;
|
|
193
|
-
if (suffixLen > 0 && suffixLen <= 4) {
|
|
194
|
-
const gen = function* (prefix, depth) {
|
|
195
|
-
if (depth === 0) { yield prefix; return; }
|
|
196
|
-
for (const ch of chars) yield* gen(prefix + ch, depth - 1);
|
|
197
|
-
};
|
|
198
|
-
for (const suffix of gen("", suffixLen)) {
|
|
199
|
-
const salt = `friend-2026-${suffix}`;
|
|
200
|
-
checked++;
|
|
201
|
-
const r = rollFrom(salt, userId);
|
|
202
|
-
if (matches(r, target)) return { salt, result: r, checked, elapsed: Date.now() - startTime };
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
for (let i = 0; i < 1_000_000_000; i++) {
|
|
207
|
-
const salt = String(i).padStart(SALT_LEN, "x");
|
|
208
|
-
checked++;
|
|
209
|
-
const r = rollFrom(salt, userId);
|
|
210
|
-
if (matches(r, target)) return { salt, result: r, checked, elapsed: Date.now() - startTime };
|
|
211
|
-
|
|
212
|
-
if (checked % 100_000 === 0) {
|
|
213
|
-
await new Promise((r) => setTimeout(r, 0));
|
|
214
|
-
}
|
|
215
|
-
if (checked % 5_000_000 === 0) {
|
|
216
|
-
if (onProgress) onProgress(checked, Date.now() - startTime);
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
return null;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function matches(roll, target) {
|
|
224
|
-
if (target.species && roll.species !== target.species) return false;
|
|
225
|
-
if (target.rarity && roll.rarity !== target.rarity) return false;
|
|
226
|
-
if (target.eye && roll.eye !== target.eye) return false;
|
|
227
|
-
if (target.hat && roll.hat !== target.hat) return false;
|
|
228
|
-
if (target.shiny !== undefined && roll.shiny !== target.shiny) return false;
|
|
229
|
-
return true;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
39
|
// ── Binary patch ─────────────────────────────────────────────────────────
|
|
233
40
|
|
|
234
41
|
function isClaudeRunning() {
|
|
235
42
|
try {
|
|
236
|
-
|
|
43
|
+
if (platform() === "win32") {
|
|
44
|
+
const out = execFileSync("tasklist", ["/FI", "IMAGENAME eq claude.exe", "/FO", "CSV"], {
|
|
45
|
+
encoding: "utf-8",
|
|
46
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
47
|
+
});
|
|
48
|
+
return out.toLowerCase().includes("claude.exe");
|
|
49
|
+
}
|
|
50
|
+
const out = execFileSync("pgrep", ["-af", "claude"], {
|
|
51
|
+
encoding: "utf-8",
|
|
52
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
53
|
+
});
|
|
237
54
|
return out.split("\n").some((line) => !line.includes("buddy-reroll") && line.trim().length > 0);
|
|
238
55
|
} catch {
|
|
239
56
|
return false;
|
|
240
57
|
}
|
|
241
58
|
}
|
|
242
59
|
|
|
60
|
+
function sleepMs(ms) {
|
|
61
|
+
if (typeof Bun !== "undefined") return Bun.sleepSync(ms);
|
|
62
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
63
|
+
}
|
|
64
|
+
|
|
243
65
|
function patchBinary(binaryPath, oldSalt, newSalt) {
|
|
244
66
|
if (oldSalt.length !== newSalt.length) {
|
|
245
67
|
throw new Error(`Salt length mismatch: "${oldSalt}" (${oldSalt.length}) vs "${newSalt}" (${newSalt.length})`);
|
|
@@ -261,14 +83,31 @@ function patchBinary(binaryPath, oldSalt, newSalt) {
|
|
|
261
83
|
|
|
262
84
|
if (count === 0) throw new Error(`Salt "${oldSalt}" not found in binary`);
|
|
263
85
|
|
|
264
|
-
|
|
265
|
-
|
|
86
|
+
const isWin = platform() === "win32";
|
|
87
|
+
const maxRetries = isWin ? 3 : 1;
|
|
88
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
89
|
+
try {
|
|
90
|
+
const tmpPath = binaryPath + ".tmp";
|
|
91
|
+
writeFileSync(tmpPath, data);
|
|
92
|
+
renameSync(tmpPath, binaryPath);
|
|
93
|
+
return count;
|
|
94
|
+
} catch (err) {
|
|
95
|
+
try { unlinkSync(binaryPath + ".tmp"); } catch {}
|
|
96
|
+
if (isWin && (err.code === "EACCES" || err.code === "EPERM" || err.code === "EBUSY") && attempt < maxRetries - 1) {
|
|
97
|
+
sleepMs(2000);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
throw new Error(`Failed to write binary: ${err.message}${isWin ? " (ensure Claude Code is fully closed)" : ""}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
266
103
|
}
|
|
267
104
|
|
|
268
105
|
function resignBinary(binaryPath) {
|
|
269
106
|
if (platform() !== "darwin") return false;
|
|
270
107
|
try {
|
|
271
|
-
|
|
108
|
+
execFileSync("codesign", ["-s", "-", "--force", binaryPath], {
|
|
109
|
+
stdio: "ignore",
|
|
110
|
+
});
|
|
272
111
|
return true;
|
|
273
112
|
} catch {
|
|
274
113
|
return false;
|
|
@@ -284,6 +123,59 @@ function clearCompanion(configPath) {
|
|
|
284
123
|
writeFileSync(configPath, JSON.stringify(config, null, indent) + "\n");
|
|
285
124
|
}
|
|
286
125
|
|
|
126
|
+
function fail(message) {
|
|
127
|
+
console.error(message);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readCurrentCompanion(binaryPath, userId) {
|
|
132
|
+
const binaryData = readFileSync(binaryPath);
|
|
133
|
+
const currentSalt = findCurrentSalt(binaryData);
|
|
134
|
+
if (!currentSalt) fail(" ✗ Couldn't read your current buddy from the Claude binary.");
|
|
135
|
+
return { currentSalt, currentRoll: rollFrom(currentSalt, userId) };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function buildTargetFromArgs(args) {
|
|
139
|
+
const target = {};
|
|
140
|
+
|
|
141
|
+
if (args.species) {
|
|
142
|
+
if (!SPECIES.includes(args.species)) fail(` ✗ Unknown species "${args.species}". Use --list.`);
|
|
143
|
+
target.species = args.species;
|
|
144
|
+
}
|
|
145
|
+
if (args.rarity) {
|
|
146
|
+
if (!RARITIES.includes(args.rarity)) fail(` ✗ Unknown rarity "${args.rarity}". Use --list.`);
|
|
147
|
+
target.rarity = args.rarity;
|
|
148
|
+
}
|
|
149
|
+
if (args.eye) {
|
|
150
|
+
if (!EYES.includes(args.eye)) fail(` ✗ Unknown eye "${args.eye}". Use --list.`);
|
|
151
|
+
target.eye = args.eye;
|
|
152
|
+
}
|
|
153
|
+
if (args.hat) {
|
|
154
|
+
if (!HATS.includes(args.hat)) fail(` ✗ Unknown hat "${args.hat}". Use --list.`);
|
|
155
|
+
target.hat = args.hat;
|
|
156
|
+
}
|
|
157
|
+
if (args.shiny !== undefined) target.shiny = args.shiny;
|
|
158
|
+
if (args.peak) {
|
|
159
|
+
const p = args.peak.toUpperCase();
|
|
160
|
+
if (!STAT_NAMES.includes(p)) fail(` ✗ "${args.peak}" isn't a stat. Pick one: ${STAT_NAMES.join(", ")}`);
|
|
161
|
+
target.peak = p;
|
|
162
|
+
}
|
|
163
|
+
if (args.dump) {
|
|
164
|
+
const d = args.dump.toUpperCase();
|
|
165
|
+
if (!STAT_NAMES.includes(d)) fail(` ✗ "${args.dump}" isn't a stat. Pick one: ${STAT_NAMES.join(", ")}`);
|
|
166
|
+
if (target.peak && d === target.peak) fail(" ✗ Your weakest stat can't be the same as your strongest!");
|
|
167
|
+
target.dump = d;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return target;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function assertPatchable(binaryPath) {
|
|
174
|
+
const patchability = getPatchability(binaryPath);
|
|
175
|
+
if (!patchability.ok) fail(` ✗ ${patchability.message}`);
|
|
176
|
+
return patchability;
|
|
177
|
+
}
|
|
178
|
+
|
|
287
179
|
// ── Display ──────────────────────────────────────────────────────────────
|
|
288
180
|
|
|
289
181
|
function formatCompanionCard(result) {
|
|
@@ -305,7 +197,8 @@ function formatCompanionCard(result) {
|
|
|
305
197
|
}
|
|
306
198
|
|
|
307
199
|
for (const [k, v] of Object.entries(result.stats)) {
|
|
308
|
-
const
|
|
200
|
+
const filled = Math.min(10, Math.max(0, Math.round(v / 10)));
|
|
201
|
+
const bar = colorFn("█".repeat(filled) + "░".repeat(10 - filled));
|
|
309
202
|
lines.push(` ${k.padEnd(10)} ${bar} ${String(v).padStart(3)}`);
|
|
310
203
|
}
|
|
311
204
|
|
|
@@ -315,16 +208,9 @@ function formatCompanionCard(result) {
|
|
|
315
208
|
// ── Interactive mode ─────────────────────────────────────────────────────
|
|
316
209
|
|
|
317
210
|
async function interactiveMode(binaryPath, configPath, userId) {
|
|
318
|
-
const
|
|
319
|
-
const currentSalt = findCurrentSalt(binaryData, userId);
|
|
320
|
-
if (!currentSalt) {
|
|
321
|
-
console.error("✗ Could not find companion salt in binary.");
|
|
322
|
-
process.exit(1);
|
|
323
|
-
}
|
|
324
|
-
const currentRoll = rollFrom(currentSalt, userId);
|
|
211
|
+
const { currentSalt, currentRoll } = readCurrentCompanion(binaryPath, userId);
|
|
325
212
|
|
|
326
|
-
const
|
|
327
|
-
await runInteractiveUI({
|
|
213
|
+
const uiOpts = {
|
|
328
214
|
currentRoll,
|
|
329
215
|
currentSalt,
|
|
330
216
|
binaryPath,
|
|
@@ -334,6 +220,7 @@ async function interactiveMode(binaryPath, configPath, userId) {
|
|
|
334
220
|
patchBinary,
|
|
335
221
|
resignBinary,
|
|
336
222
|
clearCompanion,
|
|
223
|
+
getPatchability,
|
|
337
224
|
isClaudeRunning,
|
|
338
225
|
rollFrom,
|
|
339
226
|
matches,
|
|
@@ -342,7 +229,16 @@ async function interactiveMode(binaryPath, configPath, userId) {
|
|
|
342
229
|
RARITY_LABELS,
|
|
343
230
|
EYES,
|
|
344
231
|
HATS,
|
|
345
|
-
|
|
232
|
+
STAT_NAMES,
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
const { runInteractiveUI } = await import("./ui.jsx");
|
|
237
|
+
await runInteractiveUI(uiOpts);
|
|
238
|
+
} catch {
|
|
239
|
+
const { runInteractiveUI } = await import("./ui-fallback.js");
|
|
240
|
+
await runInteractiveUI(uiOpts);
|
|
241
|
+
}
|
|
346
242
|
}
|
|
347
243
|
|
|
348
244
|
// ── Non-interactive mode ─────────────────────────────────────────────────
|
|
@@ -353,90 +249,78 @@ async function nonInteractiveMode(args, binaryPath, configPath, userId) {
|
|
|
353
249
|
console.log(` User ID: ${userId.slice(0, 8)}...`);
|
|
354
250
|
|
|
355
251
|
if (args.restore) {
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
252
|
+
const patchability = assertPatchable(binaryPath);
|
|
253
|
+
const { backupPath } = patchability;
|
|
254
|
+
if (!existsSync(backupPath)) fail(` ✗ No backup found at ${backupPath}`);
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
copyFileSync(backupPath, binaryPath);
|
|
258
|
+
resignBinary(binaryPath);
|
|
259
|
+
clearCompanion(configPath);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
fail(` ✗ ${err.message}`);
|
|
360
262
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
clearCompanion(configPath);
|
|
364
|
-
console.log(" ✓ Restored. Restart Claude Code and run /buddy.");
|
|
263
|
+
|
|
264
|
+
console.log(" ✓ Restored! Restart Claude Code and say /buddy to see your original friend.");
|
|
365
265
|
return;
|
|
366
266
|
}
|
|
367
267
|
|
|
368
|
-
const
|
|
369
|
-
const currentSalt = findCurrentSalt(binaryData, userId);
|
|
370
|
-
if (!currentSalt) {
|
|
371
|
-
console.error(" ✗ Could not find companion salt in binary.");
|
|
372
|
-
process.exit(1);
|
|
373
|
-
}
|
|
268
|
+
const { currentSalt, currentRoll } = readCurrentCompanion(binaryPath, userId);
|
|
374
269
|
|
|
375
270
|
if (args.current) {
|
|
376
|
-
const result = rollFrom(currentSalt, userId);
|
|
377
271
|
console.log(`\n Current companion (salt: ${currentSalt}):`);
|
|
378
|
-
console.log(formatCompanionCard(
|
|
272
|
+
console.log(formatCompanionCard(currentRoll));
|
|
379
273
|
console.log();
|
|
380
274
|
return;
|
|
381
275
|
}
|
|
382
276
|
|
|
383
|
-
const target =
|
|
384
|
-
if (
|
|
385
|
-
if (!SPECIES.includes(args.species)) { console.error(` ✗ Unknown species "${args.species}". Use --list.`); process.exit(1); }
|
|
386
|
-
target.species = args.species;
|
|
387
|
-
}
|
|
388
|
-
if (args.rarity) {
|
|
389
|
-
if (!RARITIES.includes(args.rarity)) { console.error(` ✗ Unknown rarity "${args.rarity}". Use --list.`); process.exit(1); }
|
|
390
|
-
target.rarity = args.rarity;
|
|
391
|
-
}
|
|
392
|
-
if (args.eye) {
|
|
393
|
-
if (!EYES.includes(args.eye)) { console.error(` ✗ Unknown eye "${args.eye}". Use --list.`); process.exit(1); }
|
|
394
|
-
target.eye = args.eye;
|
|
395
|
-
}
|
|
396
|
-
if (args.hat) {
|
|
397
|
-
if (!HATS.includes(args.hat)) { console.error(` ✗ Unknown hat "${args.hat}". Use --list.`); process.exit(1); }
|
|
398
|
-
target.hat = args.hat;
|
|
399
|
-
}
|
|
400
|
-
if (args.shiny !== undefined) target.shiny = args.shiny;
|
|
277
|
+
const target = buildTargetFromArgs(args);
|
|
278
|
+
if (Object.keys(target).length === 0) fail(" ✗ Tell me what kind of buddy you want! Use --help to see options.");
|
|
401
279
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
}
|
|
280
|
+
const expected = estimateAttempts(target);
|
|
281
|
+
console.log(` Target: ${Object.entries(target).map(([k, v]) => `${k}=${v}`).join(" ")}`);
|
|
282
|
+
console.log(` This might take ~${expected.toLocaleString()} tries\n`);
|
|
406
283
|
|
|
407
|
-
console.log(` Target: ${Object.entries(target).map(([k, v]) => `${k}=${v}`).join(" ")}\n`);
|
|
408
|
-
|
|
409
|
-
const currentRoll = rollFrom(currentSalt, userId);
|
|
410
284
|
if (matches(currentRoll, target)) {
|
|
411
|
-
console.log(" ✓
|
|
285
|
+
console.log(" ✓ Your buddy already looks like that!\n" + formatCompanionCard(currentRoll));
|
|
412
286
|
return;
|
|
413
287
|
}
|
|
414
288
|
|
|
289
|
+
const patchability = assertPatchable(binaryPath);
|
|
290
|
+
|
|
415
291
|
if (isClaudeRunning()) {
|
|
416
|
-
console.warn(" ⚠ Claude Code
|
|
292
|
+
console.warn(" ⚠ Claude Code is still running — close it first so the changes stick.");
|
|
417
293
|
}
|
|
418
294
|
|
|
419
|
-
console.log("
|
|
420
|
-
const found = await
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
}
|
|
425
|
-
console.log(` ✓ Found in ${found.checked.toLocaleString()} attempts (${(found.elapsed / 1000).toFixed(1)}s)`);
|
|
295
|
+
console.log(" Looking for your buddy...");
|
|
296
|
+
const found = await parallelBruteForce(userId, target, (attempts, elapsed, est, workers) => {
|
|
297
|
+
process.stdout.write(`\r ${formatProgress(attempts, elapsed, est, workers)}`);
|
|
298
|
+
});
|
|
299
|
+
if (!found) fail("\n ✗ Couldn't find a match. Try being less picky!");
|
|
300
|
+
console.log(`\n ✓ Found it! (${found.checked.toLocaleString()} tries, ${(found.elapsed / 1000).toFixed(1)}s)`);
|
|
426
301
|
console.log(formatCompanionCard(found.result));
|
|
427
302
|
|
|
428
|
-
const backupPath =
|
|
303
|
+
const { backupPath } = patchability;
|
|
429
304
|
if (!existsSync(backupPath)) {
|
|
430
|
-
|
|
431
|
-
|
|
305
|
+
try {
|
|
306
|
+
copyFileSync(binaryPath, backupPath);
|
|
307
|
+
console.log(`\n Backup: ${backupPath}`);
|
|
308
|
+
} catch (err) {
|
|
309
|
+
fail(` ✗ ${err.message}`);
|
|
310
|
+
}
|
|
432
311
|
}
|
|
433
312
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
313
|
+
try {
|
|
314
|
+
const patchCount = patchBinary(binaryPath, currentSalt, found.salt);
|
|
315
|
+
console.log(" Applied ✓");
|
|
316
|
+
if (resignBinary(binaryPath)) console.log(" Re-signed for macOS ✓");
|
|
317
|
+
clearCompanion(configPath);
|
|
318
|
+
storeSalt(found.salt);
|
|
319
|
+
console.log(" Cleaned up old buddy data ✓");
|
|
320
|
+
console.log("\n All set! Restart Claude Code and say /buddy to meet your new friend.\n");
|
|
321
|
+
} catch (err) {
|
|
322
|
+
fail(` ✗ ${err.message}`);
|
|
323
|
+
}
|
|
440
324
|
}
|
|
441
325
|
|
|
442
326
|
// ── Main ─────────────────────────────────────────────────────────────────
|
|
@@ -449,35 +333,98 @@ async function main() {
|
|
|
449
333
|
eye: { type: "string" },
|
|
450
334
|
hat: { type: "string" },
|
|
451
335
|
shiny: { type: "boolean", default: undefined },
|
|
336
|
+
peak: { type: "string" },
|
|
337
|
+
dump: { type: "string" },
|
|
452
338
|
list: { type: "boolean", default: false },
|
|
453
339
|
restore: { type: "boolean", default: false },
|
|
454
340
|
current: { type: "boolean", default: false },
|
|
341
|
+
doctor: { type: "boolean", default: false },
|
|
342
|
+
version: { type: "boolean", short: "v", default: false },
|
|
343
|
+
hook: { type: "boolean", default: false },
|
|
344
|
+
unhook: { type: "boolean", default: false },
|
|
345
|
+
"apply-hook": { type: "boolean", default: false },
|
|
455
346
|
help: { type: "boolean", short: "h", default: false },
|
|
456
347
|
},
|
|
457
348
|
strict: false,
|
|
458
349
|
});
|
|
459
350
|
|
|
351
|
+
if (args.version) {
|
|
352
|
+
const pkg = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf-8"));
|
|
353
|
+
console.log(`buddy-reroll v${pkg.version}`);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
460
357
|
if (args.help) {
|
|
461
358
|
console.log(`
|
|
462
|
-
buddy-reroll —
|
|
359
|
+
buddy-reroll — Pick the perfect /buddy companion
|
|
463
360
|
|
|
464
361
|
Usage:
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
362
|
+
buddy-reroll Pick your buddy (interactive)
|
|
363
|
+
buddy-reroll --species dragon --rarity legendary --eye ✦ --shiny
|
|
364
|
+
buddy-reroll --list See all options
|
|
365
|
+
buddy-reroll --current Show current buddy
|
|
366
|
+
buddy-reroll --doctor Check setup
|
|
367
|
+
buddy-reroll --restore Undo changes
|
|
368
|
+
buddy-reroll --hook Keep my buddy after updates
|
|
369
|
+
buddy-reroll --unhook Stop keeping after updates
|
|
370
|
+
|
|
371
|
+
Appearance (all optional — skip to leave random):
|
|
472
372
|
--species <name> ${SPECIES.join(", ")}
|
|
473
373
|
--rarity <name> ${RARITIES.join(", ")}
|
|
474
374
|
--eye <char> ${EYES.join(" ")}
|
|
475
375
|
--hat <name> ${HATS.join(", ")}
|
|
476
376
|
--shiny / --no-shiny
|
|
377
|
+
|
|
378
|
+
Stats (optional):
|
|
379
|
+
--peak <stat> Best at: ${STAT_NAMES.join(", ")}
|
|
380
|
+
--dump <stat> Worst at (can't match peak)
|
|
381
|
+
|
|
382
|
+
Other:
|
|
383
|
+
--version, -v
|
|
477
384
|
`);
|
|
478
385
|
return;
|
|
479
386
|
}
|
|
480
387
|
|
|
388
|
+
if (args.hook) {
|
|
389
|
+
const result = installHook();
|
|
390
|
+
if (result.installed) console.log(`✓ Got it — your buddy will survive Claude updates now.\n Settings: ${result.path}`);
|
|
391
|
+
else console.log(" Already set up!");
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (args.unhook) {
|
|
396
|
+
const result = removeHook();
|
|
397
|
+
if (result.removed) console.log("✓ Removed — your buddy won't be kept after updates anymore.");
|
|
398
|
+
else console.log(" Nothing to remove.");
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (args["apply-hook"]) {
|
|
403
|
+
try {
|
|
404
|
+
const stored = readStoredSalt();
|
|
405
|
+
if (!stored) process.exit(0);
|
|
406
|
+
const bp = findBinaryPath();
|
|
407
|
+
const cp = findConfigPath();
|
|
408
|
+
if (!bp || !cp) process.exit(0);
|
|
409
|
+
const uid = getUserId(cp);
|
|
410
|
+
const binaryData = readFileSync(bp);
|
|
411
|
+
const currentSalt = findCurrentSalt(binaryData);
|
|
412
|
+
if (!currentSalt) process.exit(0);
|
|
413
|
+
if (currentSalt === stored.salt) process.exit(0);
|
|
414
|
+
const patchability = getPatchability(bp);
|
|
415
|
+
if (!patchability.ok) process.exit(0);
|
|
416
|
+
patchBinary(bp, currentSalt, stored.salt);
|
|
417
|
+
resignBinary(bp);
|
|
418
|
+
clearCompanion(cp);
|
|
419
|
+
} catch {}
|
|
420
|
+
process.exit(0);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (args.doctor) {
|
|
424
|
+
console.log(`\n${formatDoctorReport(getDoctorReport(), "buddy-reroll doctor")}\n`);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
481
428
|
if (args.list) {
|
|
482
429
|
console.log("\n buddy-reroll — available options\n");
|
|
483
430
|
console.log(" Species: ", SPECIES.join(", "));
|
|
@@ -490,23 +437,21 @@ async function main() {
|
|
|
490
437
|
|
|
491
438
|
const binaryPath = findBinaryPath();
|
|
492
439
|
if (!binaryPath) {
|
|
493
|
-
|
|
494
|
-
|
|
440
|
+
const override = getClaudeBinaryOverride();
|
|
441
|
+
if (override) fail(`✗ CLAUDE_BINARY_PATH is set to "${override}" but no valid Claude binary was found at that path.`);
|
|
442
|
+
fail("✗ Could not find Claude Code binary. Checked PATH and known install locations.");
|
|
495
443
|
}
|
|
496
444
|
|
|
497
445
|
const configPath = findConfigPath();
|
|
498
|
-
if (!configPath)
|
|
499
|
-
console.error("✗ Could not find Claude Code config file.");
|
|
500
|
-
process.exit(1);
|
|
501
|
-
}
|
|
446
|
+
if (!configPath) fail("✗ Could not find Claude Code config file. Checked ~/.claude/.config.json and ~/.claude.json.");
|
|
502
447
|
|
|
503
448
|
const userId = getUserId(configPath);
|
|
504
449
|
if (userId === "anon") {
|
|
505
|
-
console.warn("⚠ No user ID found — using anonymous
|
|
450
|
+
console.warn("⚠ No user ID found — using anonymous. Your buddy might change when you log in.");
|
|
506
451
|
}
|
|
507
452
|
|
|
508
|
-
const hasTargetFlags = args.species || args.rarity || args.eye || args.hat || args.shiny !== undefined;
|
|
509
|
-
const isCommand = args.restore || args.current;
|
|
453
|
+
const hasTargetFlags = args.species || args.rarity || args.eye || args.hat || args.shiny !== undefined || args.peak || args.dump;
|
|
454
|
+
const isCommand = args.restore || args.current || args.doctor || args.hook || args.unhook || args["apply-hook"];
|
|
510
455
|
|
|
511
456
|
if (!hasTargetFlags && !isCommand) {
|
|
512
457
|
await interactiveMode(binaryPath, configPath, userId);
|