buddy-reroll 0.3.2 → 0.3.4
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 +52 -27
- package/lib/doctor.js +8 -0
- package/lib/finder.js +23 -20
- package/lib/hooks.js +56 -39
- package/lib/hooks.test.js +143 -145
- package/package.json +1 -1
- package/ui.jsx +57 -35
package/index.js
CHANGED
|
@@ -40,8 +40,12 @@ if (!IS_BUN && !IS_APPLY_HOOK) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
function getUserId(configPath) {
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
try {
|
|
44
|
+
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
45
|
+
return config.oauthAccount?.accountUuid ?? config.userID ?? "anon";
|
|
46
|
+
} catch {
|
|
47
|
+
return "anon";
|
|
48
|
+
}
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
// ── Binary patch ─────────────────────────────────────────────────────────
|
|
@@ -93,29 +97,24 @@ function patchBinary(binaryPath, oldSalt, newSalt) {
|
|
|
93
97
|
if (count === 0) throw new Error(`Salt "${oldSalt}" not found in binary`);
|
|
94
98
|
|
|
95
99
|
const isWin = platform() === "win32";
|
|
100
|
+
const tmpPath = binaryPath + ".tmp";
|
|
96
101
|
const maxRetries = isWin ? 3 : 1;
|
|
102
|
+
|
|
97
103
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
98
104
|
try {
|
|
99
|
-
const tmpPath = binaryPath + ".tmp";
|
|
100
105
|
writeFileSync(tmpPath, data, { mode: originalMode });
|
|
101
|
-
try {
|
|
102
|
-
renameSync(tmpPath, binaryPath);
|
|
103
|
-
} catch {
|
|
104
|
-
if (existsSync(binaryPath + ".backup")) {
|
|
105
|
-
try { unlinkSync(binaryPath); } catch {}
|
|
106
|
-
}
|
|
107
|
-
renameSync(tmpPath, binaryPath);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
chmodSync(binaryPath, originalMode);
|
|
111
106
|
|
|
112
|
-
const verify = readFileSync(
|
|
113
|
-
|
|
114
|
-
|
|
107
|
+
const verify = readFileSync(tmpPath);
|
|
108
|
+
if (verify.indexOf(Buffer.from(newSalt)) === -1) {
|
|
109
|
+
try { unlinkSync(tmpPath); } catch {}
|
|
110
|
+
throw new Error("Patch verification failed — new salt not found in temp file");
|
|
111
|
+
}
|
|
115
112
|
|
|
113
|
+
renameSync(tmpPath, binaryPath);
|
|
114
|
+
try { chmodSync(binaryPath, originalMode); } catch {}
|
|
116
115
|
return count;
|
|
117
116
|
} catch (err) {
|
|
118
|
-
try { unlinkSync(
|
|
117
|
+
try { unlinkSync(tmpPath); } catch {}
|
|
119
118
|
if (isWin && (err.code === "EACCES" || err.code === "EPERM" || err.code === "EBUSY") && attempt < maxRetries - 1) {
|
|
120
119
|
sleepMs(2000);
|
|
121
120
|
continue;
|
|
@@ -123,7 +122,7 @@ function patchBinary(binaryPath, oldSalt, newSalt) {
|
|
|
123
122
|
if (isWin && (err.code === "EPERM" || err.code === "EBUSY")) {
|
|
124
123
|
throw new Error("Can't write — Claude Code might still be running. Close it and try again.");
|
|
125
124
|
}
|
|
126
|
-
throw
|
|
125
|
+
throw err;
|
|
127
126
|
}
|
|
128
127
|
}
|
|
129
128
|
}
|
|
@@ -143,12 +142,18 @@ function resignBinary(binaryPath) {
|
|
|
143
142
|
}
|
|
144
143
|
|
|
145
144
|
function clearCompanion(configPath) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
145
|
+
try {
|
|
146
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
147
|
+
const config = JSON.parse(raw);
|
|
148
|
+
if (!config.companion && !config.companionMuted) return;
|
|
149
|
+
delete config.companion;
|
|
150
|
+
delete config.companionMuted;
|
|
151
|
+
const indent = raw.match(/^(\s+)"/m)?.[1] ?? " ";
|
|
152
|
+
const mode = statSync(configPath).mode;
|
|
153
|
+
const tmpPath = configPath + ".tmp";
|
|
154
|
+
writeFileSync(tmpPath, JSON.stringify(config, null, indent) + "\n", { mode });
|
|
155
|
+
renameSync(tmpPath, configPath);
|
|
156
|
+
} catch {}
|
|
152
157
|
}
|
|
153
158
|
|
|
154
159
|
function fail(message) {
|
|
@@ -460,13 +465,13 @@ async function main() {
|
|
|
460
465
|
}
|
|
461
466
|
|
|
462
467
|
if (args["apply-hook"]) {
|
|
468
|
+
let mutated = false;
|
|
463
469
|
try {
|
|
464
470
|
const stored = readStoredSalt();
|
|
465
471
|
if (!stored) process.exit(0);
|
|
466
472
|
const bp = findBinaryPath();
|
|
467
473
|
const cp = findConfigPath();
|
|
468
474
|
if (!bp || !cp) process.exit(0);
|
|
469
|
-
const uid = getUserId(cp);
|
|
470
475
|
const binaryData = readFileSync(bp);
|
|
471
476
|
const currentSalt = findCurrentSalt(binaryData);
|
|
472
477
|
if (!currentSalt) process.exit(0);
|
|
@@ -476,21 +481,41 @@ async function main() {
|
|
|
476
481
|
const backupPath = patchability.backupPath;
|
|
477
482
|
if (!existsSync(backupPath)) copyFileSync(bp, backupPath);
|
|
478
483
|
patchBinary(bp, currentSalt, stored.salt);
|
|
484
|
+
mutated = true;
|
|
479
485
|
if (platform() === "darwin") {
|
|
480
486
|
try {
|
|
481
487
|
execFileSync("codesign", ["-s", "-", "--force", bp], { stdio: "ignore", timeout: 30000 });
|
|
482
488
|
} catch {
|
|
483
489
|
copyFileSync(backupPath, bp);
|
|
490
|
+
try { chmodSync(bp, statSync(backupPath).mode); } catch {}
|
|
484
491
|
process.exit(1);
|
|
485
492
|
}
|
|
486
493
|
}
|
|
487
494
|
clearCompanion(cp);
|
|
488
|
-
} catch {
|
|
495
|
+
} catch (err) {
|
|
496
|
+
process.stderr.write(`buddy-reroll --apply-hook failed: ${err.message}\n`);
|
|
497
|
+
process.exit(mutated ? 1 : 0);
|
|
498
|
+
}
|
|
489
499
|
process.exit(0);
|
|
490
500
|
}
|
|
491
501
|
|
|
492
502
|
if (args.doctor) {
|
|
493
|
-
|
|
503
|
+
const report = getDoctorReport();
|
|
504
|
+
if (report.status === "not-executable" && report.binaryPath) {
|
|
505
|
+
try {
|
|
506
|
+
const { chmodSync, statSync } = await import("fs");
|
|
507
|
+
const mode = statSync(report.binaryPath).mode | 0o111;
|
|
508
|
+
chmodSync(report.binaryPath, mode);
|
|
509
|
+
console.log(`\n ✓ Fixed: restored execute permission on ${report.binaryPath}`);
|
|
510
|
+
const fixed = getDoctorReport();
|
|
511
|
+
console.log(`\n${formatDoctorReport(fixed, "buddy-reroll doctor")}\n`);
|
|
512
|
+
} catch (err) {
|
|
513
|
+
console.log(`\n${formatDoctorReport(report, "buddy-reroll doctor")}\n`);
|
|
514
|
+
console.log(` ⚠ Auto-fix failed: ${err.message}\n Run manually: chmod +x "${report.binaryPath}"\n`);
|
|
515
|
+
}
|
|
516
|
+
} else {
|
|
517
|
+
console.log(`\n${formatDoctorReport(report, "buddy-reroll doctor")}\n`);
|
|
518
|
+
}
|
|
494
519
|
return;
|
|
495
520
|
}
|
|
496
521
|
|
package/lib/doctor.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { accessSync, constants, chmodSync, statSync } from "fs";
|
|
1
2
|
import { getClaudeBinaryOverride, getClaudeConfigDir, findBinaryPath, findConfigPath, getPatchability } from "./runtime.js";
|
|
2
3
|
|
|
3
4
|
function buildStatus(binaryPath, configPath, patchability) {
|
|
@@ -5,6 +6,11 @@ function buildStatus(binaryPath, configPath, patchability) {
|
|
|
5
6
|
if (!binaryPath) return "missing-binary";
|
|
6
7
|
if (!configPath) return "missing-config";
|
|
7
8
|
if (!patchability.ok) return "read-only";
|
|
9
|
+
try {
|
|
10
|
+
accessSync(binaryPath, constants.X_OK);
|
|
11
|
+
} catch {
|
|
12
|
+
return "not-executable";
|
|
13
|
+
}
|
|
8
14
|
return "ready";
|
|
9
15
|
}
|
|
10
16
|
|
|
@@ -18,6 +24,8 @@ function buildNextStep(status) {
|
|
|
18
24
|
return "Open Claude Code once so it writes config, or point `CLAUDE_CONFIG_DIR` to the correct config directory.";
|
|
19
25
|
case "read-only":
|
|
20
26
|
return "Use a user-writable Claude install, or point `CLAUDE_BINARY_PATH` to a writable Claude binary copy; `--current` can still work with a read-only install.";
|
|
27
|
+
case "not-executable":
|
|
28
|
+
return "Claude binary lost its execute permission (likely from a patch). Run: chmod +x <binary-path>";
|
|
21
29
|
default:
|
|
22
30
|
return "`--current` and reroll commands are ready to run on this machine.";
|
|
23
31
|
}
|
package/lib/finder.js
CHANGED
|
@@ -7,7 +7,7 @@ import { rollFrom } from "./companion.js";
|
|
|
7
7
|
|
|
8
8
|
const WORKER_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), "..", "scripts", "worker.js");
|
|
9
9
|
|
|
10
|
-
export async function parallelBruteForce(userId, target, onProgress) {
|
|
10
|
+
export async function parallelBruteForce(userId, target, onProgress, signal) {
|
|
11
11
|
const numWorkers = Math.max(1, Math.min(cpus().length, 8));
|
|
12
12
|
const expected = estimateAttempts(target);
|
|
13
13
|
|
|
@@ -18,13 +18,25 @@ export async function parallelBruteForce(userId, target, onProgress) {
|
|
|
18
18
|
const workerAttempts = [];
|
|
19
19
|
let resolved = false;
|
|
20
20
|
let exited = 0;
|
|
21
|
+
let timer;
|
|
22
|
+
|
|
23
|
+
function finish(fn, val) {
|
|
24
|
+
if (resolved) return;
|
|
25
|
+
resolved = true;
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
killAll();
|
|
28
|
+
fn(val);
|
|
29
|
+
}
|
|
21
30
|
|
|
22
31
|
function killAll() {
|
|
23
32
|
for (const child of children) {
|
|
24
|
-
try { child.kill(); } catch {}
|
|
33
|
+
try { child.kill("SIGKILL"); } catch {}
|
|
25
34
|
}
|
|
26
35
|
}
|
|
27
36
|
|
|
37
|
+
if (signal?.aborted) { reject(new Error("Cancelled")); return; }
|
|
38
|
+
signal?.addEventListener("abort", () => finish(reject, new Error("Cancelled")), { once: true });
|
|
39
|
+
|
|
28
40
|
for (let i = 0; i < numWorkers; i++) {
|
|
29
41
|
workerStdout[i] = "";
|
|
30
42
|
workerStderr[i] = "";
|
|
@@ -50,9 +62,7 @@ export async function parallelBruteForce(userId, target, onProgress) {
|
|
|
50
62
|
workerAttempts[i] = progress.attempts;
|
|
51
63
|
const totalAttempts = workerAttempts.reduce((a, b) => a + b, 0);
|
|
52
64
|
const elapsed = progress.elapsed || 0;
|
|
53
|
-
if (onProgress)
|
|
54
|
-
onProgress(totalAttempts, elapsed, expected, numWorkers);
|
|
55
|
-
}
|
|
65
|
+
if (onProgress) onProgress(totalAttempts, elapsed, expected, numWorkers);
|
|
56
66
|
}
|
|
57
67
|
} catch {}
|
|
58
68
|
}
|
|
@@ -63,14 +73,11 @@ export async function parallelBruteForce(userId, target, onProgress) {
|
|
|
63
73
|
if (resolved) return;
|
|
64
74
|
|
|
65
75
|
if (code === 0 && workerStdout[i].trim()) {
|
|
66
|
-
resolved = true;
|
|
67
|
-
killAll();
|
|
68
|
-
|
|
69
76
|
try {
|
|
70
77
|
const result = JSON.parse(workerStdout[i].trim());
|
|
71
78
|
const totalAttempts = workerAttempts.reduce((a, b) => a + b, 0);
|
|
72
79
|
const roll = rollFrom(result.salt, userId);
|
|
73
|
-
resolve
|
|
80
|
+
finish(resolve, {
|
|
74
81
|
salt: result.salt,
|
|
75
82
|
result: roll,
|
|
76
83
|
checked: Math.max(totalAttempts, result.attempts),
|
|
@@ -78,35 +85,31 @@ export async function parallelBruteForce(userId, target, onProgress) {
|
|
|
78
85
|
workers: numWorkers,
|
|
79
86
|
});
|
|
80
87
|
} catch (err) {
|
|
81
|
-
reject
|
|
88
|
+
finish(reject, err);
|
|
82
89
|
}
|
|
83
90
|
return;
|
|
84
91
|
}
|
|
85
92
|
|
|
86
|
-
if (exited === numWorkers
|
|
93
|
+
if (exited === numWorkers) {
|
|
87
94
|
const totalAttempts = workerAttempts.reduce((a, b) => a + b, 0);
|
|
88
95
|
const stderr = workerStderr.filter(Boolean).join("\n").trim();
|
|
89
96
|
const detail = stderr ? `\n Worker output: ${stderr.slice(0, 200)}` : "";
|
|
90
|
-
reject
|
|
97
|
+
finish(reject, new Error(`All ${numWorkers} workers exited without finding a match (${totalAttempts.toLocaleString()} tries).${detail}`));
|
|
91
98
|
}
|
|
92
99
|
});
|
|
93
100
|
|
|
94
101
|
child.on("error", (err) => {
|
|
95
102
|
exited++;
|
|
96
103
|
if (exited === numWorkers && !resolved) {
|
|
97
|
-
reject
|
|
104
|
+
finish(reject, new Error(`Worker failed to start: ${err.message}`));
|
|
98
105
|
}
|
|
99
106
|
});
|
|
100
107
|
}
|
|
101
108
|
|
|
102
109
|
const timeoutMs = Math.max(600_000, Math.ceil(expected / 50_000_000) * 60_000 + 600_000);
|
|
103
|
-
setTimeout(() => {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
killAll();
|
|
107
|
-
const totalAttempts = workerAttempts.reduce((a, b) => a + b, 0);
|
|
108
|
-
reject(new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s (${totalAttempts.toLocaleString()} tries). This combination might be extremely rare — try fewer constraints.`));
|
|
109
|
-
}
|
|
110
|
+
timer = setTimeout(() => {
|
|
111
|
+
const totalAttempts = workerAttempts.reduce((a, b) => a + b, 0);
|
|
112
|
+
finish(reject, new Error(`Timed out after ${Math.round(timeoutMs / 1000)}s (${totalAttempts.toLocaleString()} tries). This combination might be extremely rare — try fewer constraints.`));
|
|
110
113
|
}, timeoutMs);
|
|
111
114
|
});
|
|
112
115
|
}
|
package/lib/hooks.js
CHANGED
|
@@ -1,75 +1,92 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, statSync } from "fs";
|
|
2
2
|
import { join, dirname } from "path";
|
|
3
3
|
import { homedir } from "os";
|
|
4
4
|
|
|
5
5
|
const HOOK_COMMAND = "npx buddy-reroll --apply-hook";
|
|
6
6
|
|
|
7
|
+
const HOOK_ENTRY = {
|
|
8
|
+
hooks: [{ type: "command", command: HOOK_COMMAND }],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function isOurHook(entry) {
|
|
12
|
+
if (typeof entry === "string") return entry === HOOK_COMMAND;
|
|
13
|
+
if (entry?.hooks) return entry.hooks.some((h) => h.command === HOOK_COMMAND);
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
|
|
7
17
|
export function getSettingsPath() {
|
|
8
18
|
return join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"), "settings.json");
|
|
9
19
|
}
|
|
10
20
|
|
|
11
21
|
export function readSettings(settingsPath) {
|
|
12
|
-
if (!existsSync(settingsPath)) {
|
|
13
|
-
return {};
|
|
14
|
-
}
|
|
22
|
+
if (!existsSync(settingsPath)) return {};
|
|
15
23
|
try {
|
|
16
24
|
return JSON.parse(readFileSync(settingsPath, "utf-8"));
|
|
17
25
|
} catch {
|
|
18
|
-
return
|
|
26
|
+
return null;
|
|
19
27
|
}
|
|
20
28
|
}
|
|
21
29
|
|
|
22
|
-
|
|
30
|
+
function writeSettingsAtomic(settingsPath, obj) {
|
|
23
31
|
const dir = dirname(settingsPath);
|
|
24
32
|
mkdirSync(dir, { recursive: true });
|
|
25
|
-
|
|
33
|
+
const tmpPath = settingsPath + ".tmp";
|
|
34
|
+
const content = JSON.stringify(obj, null, 2) + "\n";
|
|
35
|
+
let mode;
|
|
36
|
+
try { mode = statSync(settingsPath).mode; } catch {}
|
|
37
|
+
writeFileSync(tmpPath, content, mode ? { mode } : undefined);
|
|
38
|
+
renameSync(tmpPath, settingsPath);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function writeSettings(settingsPath, obj) {
|
|
42
|
+
writeSettingsAtomic(settingsPath, obj);
|
|
26
43
|
}
|
|
27
44
|
|
|
28
45
|
export function installHook(settingsPath = getSettingsPath()) {
|
|
29
46
|
const settings = readSettings(settingsPath);
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (settings.hooks.SessionStart.includes(HOOK_COMMAND)) {
|
|
47
|
+
if (!settings) return { installed: false, reason: "settings file is corrupted, not modifying" };
|
|
48
|
+
|
|
49
|
+
if (!settings.hooks) settings.hooks = {};
|
|
50
|
+
if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = [];
|
|
51
|
+
|
|
52
|
+
const existing = settings.hooks.SessionStart.find(isOurHook);
|
|
53
|
+
if (existing && typeof existing === "object") {
|
|
39
54
|
return { installed: false, reason: "already installed" };
|
|
40
55
|
}
|
|
41
|
-
|
|
42
|
-
settings.hooks.SessionStart.
|
|
43
|
-
|
|
44
|
-
|
|
56
|
+
|
|
57
|
+
settings.hooks.SessionStart = settings.hooks.SessionStart.filter((e) => !isOurHook(e));
|
|
58
|
+
settings.hooks.SessionStart.push(HOOK_ENTRY);
|
|
59
|
+
writeSettingsAtomic(settingsPath, settings);
|
|
60
|
+
|
|
45
61
|
return { installed: true, path: settingsPath };
|
|
46
62
|
}
|
|
47
63
|
|
|
48
64
|
export function removeHook(settingsPath = getSettingsPath()) {
|
|
49
65
|
const settings = readSettings(settingsPath);
|
|
50
|
-
|
|
51
|
-
|
|
66
|
+
if (!settings) return { removed: false, reason: "settings file is corrupted, not modifying" };
|
|
67
|
+
|
|
68
|
+
if (!settings.hooks || !Array.isArray(settings.hooks.SessionStart)) {
|
|
52
69
|
return { removed: false, reason: "not installed" };
|
|
53
70
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (Object.keys(settings.hooks).length === 0) {
|
|
62
|
-
delete settings.hooks;
|
|
71
|
+
|
|
72
|
+
const before = settings.hooks.SessionStart.length;
|
|
73
|
+
settings.hooks.SessionStart = settings.hooks.SessionStart.filter((e) => !isOurHook(e));
|
|
74
|
+
|
|
75
|
+
if (settings.hooks.SessionStart.length === before) {
|
|
76
|
+
return { removed: false, reason: "not installed" };
|
|
63
77
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
78
|
+
|
|
79
|
+
if (settings.hooks.SessionStart.length === 0) delete settings.hooks.SessionStart;
|
|
80
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
81
|
+
|
|
82
|
+
writeSettingsAtomic(settingsPath, settings);
|
|
67
83
|
return { removed: true, path: settingsPath };
|
|
68
84
|
}
|
|
69
85
|
|
|
70
86
|
export function isHookInstalled(settingsPath = getSettingsPath()) {
|
|
71
87
|
const settings = readSettings(settingsPath);
|
|
72
|
-
|
|
88
|
+
if (!settings) return false;
|
|
89
|
+
return Array.isArray(settings.hooks?.SessionStart) && settings.hooks.SessionStart.some(isOurHook);
|
|
73
90
|
}
|
|
74
91
|
|
|
75
92
|
export function getSaltStorePath() {
|
|
@@ -80,14 +97,14 @@ export function storeSalt(salt) {
|
|
|
80
97
|
const path = getSaltStorePath();
|
|
81
98
|
const dir = dirname(path);
|
|
82
99
|
mkdirSync(dir, { recursive: true });
|
|
83
|
-
|
|
100
|
+
const tmpPath = path + ".tmp";
|
|
101
|
+
writeFileSync(tmpPath, JSON.stringify({ salt, timestamp: Date.now() }, null, 2) + "\n");
|
|
102
|
+
renameSync(tmpPath, path);
|
|
84
103
|
}
|
|
85
104
|
|
|
86
105
|
export function readStoredSalt() {
|
|
87
106
|
const path = getSaltStorePath();
|
|
88
|
-
if (!existsSync(path))
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
107
|
+
if (!existsSync(path)) return null;
|
|
91
108
|
try {
|
|
92
109
|
return JSON.parse(readFileSync(path, "utf-8"));
|
|
93
110
|
} catch {
|
package/lib/hooks.test.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
2
|
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
|
-
import { tmpdir } from "os";
|
|
4
|
+
import { tmpdir, homedir } from "os";
|
|
5
|
+
import { realpathSync } from "fs";
|
|
5
6
|
import {
|
|
6
7
|
readSettings,
|
|
7
8
|
writeSettings,
|
|
@@ -12,10 +13,19 @@ import {
|
|
|
12
13
|
readStoredSalt,
|
|
13
14
|
} from "./hooks.js";
|
|
14
15
|
|
|
16
|
+
const HOOK_COMMAND = "npx buddy-reroll --apply-hook";
|
|
17
|
+
|
|
18
|
+
function findOurHook(settings) {
|
|
19
|
+
const entries = settings.hooks?.SessionStart ?? [];
|
|
20
|
+
return entries.find(
|
|
21
|
+
(e) => typeof e === "object" && e.hooks?.some((h) => h.command === HOOK_COMMAND)
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
15
25
|
let tempDir;
|
|
16
26
|
|
|
17
27
|
beforeEach(() => {
|
|
18
|
-
tempDir = mkdtempSync(join(tmpdir(), "buddy-reroll-test-"));
|
|
28
|
+
tempDir = mkdtempSync(join(realpathSync(tmpdir()), "buddy-reroll-test-"));
|
|
19
29
|
process.env.CLAUDE_CONFIG_DIR = tempDir;
|
|
20
30
|
});
|
|
21
31
|
|
|
@@ -26,215 +36,203 @@ afterEach(() => {
|
|
|
26
36
|
|
|
27
37
|
describe("readSettings", () => {
|
|
28
38
|
it("returns empty object when file doesn't exist", () => {
|
|
29
|
-
|
|
30
|
-
expect(readSettings(settingsPath)).toEqual({});
|
|
39
|
+
expect(readSettings(join(tempDir, "settings.json"))).toEqual({});
|
|
31
40
|
});
|
|
32
41
|
|
|
33
42
|
it("parses valid JSON file", () => {
|
|
34
|
-
const
|
|
35
|
-
writeSettings(
|
|
36
|
-
expect(readSettings(
|
|
43
|
+
const p = join(tempDir, "settings.json");
|
|
44
|
+
writeSettings(p, { permissions: { allow: [] } });
|
|
45
|
+
expect(readSettings(p)).toEqual({ permissions: { allow: [] } });
|
|
37
46
|
});
|
|
38
47
|
|
|
39
|
-
it("returns
|
|
40
|
-
const
|
|
41
|
-
writeFileSync(
|
|
42
|
-
expect(readSettings(
|
|
48
|
+
it("returns null for corrupted JSON", () => {
|
|
49
|
+
const p = join(tempDir, "settings.json");
|
|
50
|
+
writeFileSync(p, "{ invalid json");
|
|
51
|
+
expect(readSettings(p)).toBeNull();
|
|
43
52
|
});
|
|
44
53
|
});
|
|
45
54
|
|
|
46
55
|
describe("writeSettings", () => {
|
|
47
56
|
it("creates parent directories if needed", () => {
|
|
48
|
-
const
|
|
49
|
-
writeSettings(
|
|
50
|
-
expect(readSettings(
|
|
57
|
+
const p = join(tempDir, "nested", "dir", "settings.json");
|
|
58
|
+
writeSettings(p, { test: true });
|
|
59
|
+
expect(readSettings(p)).toEqual({ test: true });
|
|
51
60
|
});
|
|
52
61
|
|
|
53
|
-
it("writes JSON with 2-space indent and newline", () => {
|
|
54
|
-
const
|
|
55
|
-
writeSettings(
|
|
56
|
-
const content = readFileSync(
|
|
62
|
+
it("writes JSON with 2-space indent and trailing newline", () => {
|
|
63
|
+
const p = join(tempDir, "settings.json");
|
|
64
|
+
writeSettings(p, { a: 1 });
|
|
65
|
+
const content = readFileSync(p, "utf-8");
|
|
57
66
|
expect(content).toContain(" ");
|
|
58
67
|
expect(content.endsWith("\n")).toBe(true);
|
|
59
68
|
});
|
|
60
69
|
});
|
|
61
70
|
|
|
62
71
|
describe("installHook", () => {
|
|
63
|
-
it("creates settings.json
|
|
64
|
-
const
|
|
65
|
-
const result = installHook(
|
|
72
|
+
it("creates settings.json with correct hook format", () => {
|
|
73
|
+
const p = join(tempDir, "settings.json");
|
|
74
|
+
const result = installHook(p);
|
|
66
75
|
expect(result.installed).toBe(true);
|
|
67
|
-
const settings = readSettings(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
76
|
+
const settings = readSettings(p);
|
|
77
|
+
const hook = findOurHook(settings);
|
|
78
|
+
expect(hook).toBeDefined();
|
|
79
|
+
expect(hook.matcher).toBeUndefined();
|
|
80
|
+
expect(hook.hooks[0].type).toBe("command");
|
|
81
|
+
expect(hook.hooks[0].command).toBe(HOOK_COMMAND);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("adds hook to existing settings without clobbering", () => {
|
|
85
|
+
const p = join(tempDir, "settings.json");
|
|
86
|
+
writeSettings(p, { permissions: { allow: ["test"] } });
|
|
87
|
+
installHook(p);
|
|
88
|
+
const settings = readSettings(p);
|
|
89
|
+
expect(settings.permissions).toEqual({ allow: ["test"] });
|
|
90
|
+
expect(findOurHook(settings)).toBeDefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("is idempotent", () => {
|
|
94
|
+
const p = join(tempDir, "settings.json");
|
|
95
|
+
installHook(p);
|
|
96
|
+
const result = installHook(p);
|
|
84
97
|
expect(result.installed).toBe(false);
|
|
85
98
|
expect(result.reason).toBe("already installed");
|
|
86
|
-
const settings = readSettings(
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
});
|
|
96
|
-
installHook(
|
|
97
|
-
const settings = readSettings(
|
|
98
|
-
|
|
99
|
-
expect(
|
|
99
|
+
const settings = readSettings(p);
|
|
100
|
+
const hooks = settings.hooks.SessionStart.filter(
|
|
101
|
+
(e) => typeof e === "object" && e.hooks?.some((h) => h.command === HOOK_COMMAND)
|
|
102
|
+
);
|
|
103
|
+
expect(hooks.length).toBe(1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("migrates old string-format hook to new format", () => {
|
|
107
|
+
const p = join(tempDir, "settings.json");
|
|
108
|
+
writeSettings(p, { hooks: { SessionStart: [HOOK_COMMAND] } });
|
|
109
|
+
installHook(p);
|
|
110
|
+
const settings = readSettings(p);
|
|
111
|
+
const strings = settings.hooks.SessionStart.filter((e) => typeof e === "string");
|
|
112
|
+
expect(strings.length).toBe(0);
|
|
113
|
+
expect(findOurHook(settings)).toBeDefined();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("preserves other hooks in SessionStart array", () => {
|
|
117
|
+
const p = join(tempDir, "settings.json");
|
|
118
|
+
const otherHook = { matcher: "Bash", hooks: [{ type: "command", command: "echo hi" }] };
|
|
119
|
+
writeSettings(p, { hooks: { SessionStart: [otherHook] } });
|
|
120
|
+
installHook(p);
|
|
121
|
+
const settings = readSettings(p);
|
|
122
|
+
expect(settings.hooks.SessionStart.length).toBe(2);
|
|
123
|
+
expect(settings.hooks.SessionStart[0]).toEqual(otherHook);
|
|
100
124
|
});
|
|
101
125
|
});
|
|
102
126
|
|
|
103
127
|
describe("removeHook", () => {
|
|
104
|
-
it("removes hook
|
|
105
|
-
const
|
|
106
|
-
installHook(
|
|
107
|
-
const result = removeHook(
|
|
128
|
+
it("removes hook", () => {
|
|
129
|
+
const p = join(tempDir, "settings.json");
|
|
130
|
+
installHook(p);
|
|
131
|
+
const result = removeHook(p);
|
|
108
132
|
expect(result.removed).toBe(true);
|
|
109
|
-
|
|
110
|
-
expect(settings.hooks?.SessionStart?.includes("npx buddy-reroll --apply-hook") ?? false).toBe(false);
|
|
133
|
+
expect(findOurHook(readSettings(p))).toBeUndefined();
|
|
111
134
|
});
|
|
112
135
|
|
|
113
|
-
it("
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
removeHook(
|
|
117
|
-
|
|
118
|
-
expect(settings.hooks?.SessionStart).toBeUndefined();
|
|
136
|
+
it("removes old string-format hook too", () => {
|
|
137
|
+
const p = join(tempDir, "settings.json");
|
|
138
|
+
writeSettings(p, { hooks: { SessionStart: [HOOK_COMMAND] } });
|
|
139
|
+
const result = removeHook(p);
|
|
140
|
+
expect(result.removed).toBe(true);
|
|
119
141
|
});
|
|
120
142
|
|
|
121
|
-
it("deletes empty hooks
|
|
122
|
-
const
|
|
123
|
-
installHook(
|
|
124
|
-
removeHook(
|
|
125
|
-
const settings = readSettings(
|
|
143
|
+
it("deletes empty SessionStart and hooks", () => {
|
|
144
|
+
const p = join(tempDir, "settings.json");
|
|
145
|
+
installHook(p);
|
|
146
|
+
removeHook(p);
|
|
147
|
+
const settings = readSettings(p);
|
|
126
148
|
expect(settings.hooks).toBeUndefined();
|
|
127
149
|
});
|
|
128
150
|
|
|
129
|
-
it("returns false when
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
expect(result.removed).toBe(false);
|
|
133
|
-
expect(result.reason).toBe("not installed");
|
|
151
|
+
it("returns false when not installed", () => {
|
|
152
|
+
const p = join(tempDir, "settings.json");
|
|
153
|
+
expect(removeHook(p).removed).toBe(false);
|
|
134
154
|
});
|
|
135
155
|
|
|
136
|
-
it("preserves other hooks
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
expect(settings.hooks.SessionStart).toContain("other-hook");
|
|
144
|
-
expect(settings.hooks.SessionStart).not.toContain("npx buddy-reroll --apply-hook");
|
|
156
|
+
it("preserves other hooks", () => {
|
|
157
|
+
const p = join(tempDir, "settings.json");
|
|
158
|
+
const otherHook = { matcher: "Bash", hooks: [{ type: "command", command: "echo hi" }] };
|
|
159
|
+
writeSettings(p, { hooks: { SessionStart: [otherHook, HOOK_COMMAND] } });
|
|
160
|
+
removeHook(p);
|
|
161
|
+
const settings = readSettings(p);
|
|
162
|
+
expect(settings.hooks.SessionStart).toEqual([otherHook]);
|
|
145
163
|
});
|
|
146
164
|
|
|
147
165
|
it("preserves other hook types", () => {
|
|
148
|
-
const
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const settings = readSettings(settingsPath);
|
|
157
|
-
expect(settings.hooks.OnExit).toContain("some-command");
|
|
166
|
+
const p = join(tempDir, "settings.json");
|
|
167
|
+
installHook(p);
|
|
168
|
+
const settings = readSettings(p);
|
|
169
|
+
settings.hooks.PostToolUse = [{ matcher: "Edit", hooks: [{ type: "command", command: "lint" }] }];
|
|
170
|
+
writeSettings(p, settings);
|
|
171
|
+
removeHook(p);
|
|
172
|
+
const after = readSettings(p);
|
|
173
|
+
expect(after.hooks.PostToolUse).toBeDefined();
|
|
158
174
|
});
|
|
159
175
|
});
|
|
160
176
|
|
|
161
177
|
describe("isHookInstalled", () => {
|
|
162
|
-
it("
|
|
163
|
-
const
|
|
164
|
-
installHook(
|
|
165
|
-
expect(isHookInstalled(
|
|
178
|
+
it("true after install", () => {
|
|
179
|
+
const p = join(tempDir, "settings.json");
|
|
180
|
+
installHook(p);
|
|
181
|
+
expect(isHookInstalled(p)).toBe(true);
|
|
166
182
|
});
|
|
167
183
|
|
|
168
|
-
it("
|
|
169
|
-
const
|
|
170
|
-
installHook(
|
|
171
|
-
removeHook(
|
|
172
|
-
expect(isHookInstalled(
|
|
184
|
+
it("false after remove", () => {
|
|
185
|
+
const p = join(tempDir, "settings.json");
|
|
186
|
+
installHook(p);
|
|
187
|
+
removeHook(p);
|
|
188
|
+
expect(isHookInstalled(p)).toBe(false);
|
|
173
189
|
});
|
|
174
190
|
|
|
175
|
-
it("
|
|
176
|
-
|
|
177
|
-
expect(isHookInstalled(settingsPath)).toBe(false);
|
|
191
|
+
it("false when no file", () => {
|
|
192
|
+
expect(isHookInstalled(join(tempDir, "settings.json"))).toBe(false);
|
|
178
193
|
});
|
|
179
194
|
|
|
180
|
-
it("
|
|
181
|
-
const
|
|
182
|
-
writeSettings(
|
|
183
|
-
expect(isHookInstalled(
|
|
195
|
+
it("detects old string-format as installed", () => {
|
|
196
|
+
const p = join(tempDir, "settings.json");
|
|
197
|
+
writeSettings(p, { hooks: { SessionStart: [HOOK_COMMAND] } });
|
|
198
|
+
expect(isHookInstalled(p)).toBe(true);
|
|
184
199
|
});
|
|
185
200
|
});
|
|
186
201
|
|
|
187
202
|
describe("storeSalt and readStoredSalt", () => {
|
|
188
|
-
it("roundtrips salt
|
|
189
|
-
|
|
190
|
-
storeSalt(salt);
|
|
203
|
+
it("roundtrips salt", () => {
|
|
204
|
+
storeSalt("test-salt");
|
|
191
205
|
const stored = readStoredSalt();
|
|
192
|
-
expect(stored.salt).toBe(salt);
|
|
193
|
-
expect(typeof stored.timestamp).toBe("number");
|
|
206
|
+
expect(stored.salt).toBe("test-salt");
|
|
194
207
|
expect(stored.timestamp).toBeGreaterThan(0);
|
|
195
208
|
});
|
|
196
209
|
|
|
197
|
-
it("returns null when
|
|
210
|
+
it("returns null when missing", () => {
|
|
198
211
|
expect(readStoredSalt()).toBeNull();
|
|
199
212
|
});
|
|
200
213
|
|
|
201
|
-
it("returns null for corrupted
|
|
202
|
-
|
|
203
|
-
writeFileSync(saltPath, "{ invalid json");
|
|
214
|
+
it("returns null for corrupted file", () => {
|
|
215
|
+
writeFileSync(join(tempDir, ".buddy-reroll.json"), "broken");
|
|
204
216
|
expect(readStoredSalt()).toBeNull();
|
|
205
217
|
});
|
|
206
|
-
|
|
207
|
-
it("creates parent directories for salt file", () => {
|
|
208
|
-
const salt = "another-test-salt";
|
|
209
|
-
storeSalt(salt);
|
|
210
|
-
const stored = readStoredSalt();
|
|
211
|
-
expect(stored.salt).toBe(salt);
|
|
212
|
-
});
|
|
213
218
|
});
|
|
214
219
|
|
|
215
220
|
describe("integration", () => {
|
|
216
|
-
it("preserves
|
|
217
|
-
const
|
|
218
|
-
writeSettings(
|
|
221
|
+
it("preserves complex settings through install and remove", () => {
|
|
222
|
+
const p = join(tempDir, "settings.json");
|
|
223
|
+
writeSettings(p, {
|
|
219
224
|
permissions: { allow: ["some-permission"] },
|
|
220
225
|
other: { nested: { value: 42 } },
|
|
221
226
|
});
|
|
222
|
-
installHook(
|
|
223
|
-
const
|
|
224
|
-
expect(
|
|
225
|
-
expect(
|
|
226
|
-
expect(
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
permissions: { allow: ["some-permission"] },
|
|
233
|
-
hooks: { SessionStart: ["npx buddy-reroll --apply-hook"] },
|
|
234
|
-
});
|
|
235
|
-
removeHook(settingsPath);
|
|
236
|
-
const settings = readSettings(settingsPath);
|
|
237
|
-
expect(settings.permissions).toEqual({ allow: ["some-permission"] });
|
|
238
|
-
expect(settings.hooks).toBeUndefined();
|
|
227
|
+
installHook(p);
|
|
228
|
+
const after = readSettings(p);
|
|
229
|
+
expect(after.permissions).toEqual({ allow: ["some-permission"] });
|
|
230
|
+
expect(after.other).toEqual({ nested: { value: 42 } });
|
|
231
|
+
expect(findOurHook(after)).toBeDefined();
|
|
232
|
+
|
|
233
|
+
removeHook(p);
|
|
234
|
+
const final = readSettings(p);
|
|
235
|
+
expect(final.permissions).toEqual({ allow: ["some-permission"] });
|
|
236
|
+
expect(final.hooks).toBeUndefined();
|
|
239
237
|
});
|
|
240
238
|
});
|
package/package.json
CHANGED
package/ui.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useEffect, useRef } from "react";
|
|
1
|
+
import React, { useState, useEffect, useRef, useCallback } from "react";
|
|
2
2
|
import { render, Box, Text, useApp, useInput } from "ink";
|
|
3
3
|
import { renderSprite, RARITY_STARS, RARITY_COLORS } from "./sprites.js";
|
|
4
4
|
import { existsSync, copyFileSync } from "fs";
|
|
@@ -125,19 +125,41 @@ function PreviewCard({ species, rarity, eye, hat, shiny, stats }) {
|
|
|
125
125
|
);
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
function
|
|
128
|
+
function ExitOnKey({ isActive, children }) {
|
|
129
129
|
const { exit } = useApp();
|
|
130
|
+
const exiting = useRef(false);
|
|
130
131
|
|
|
131
|
-
|
|
132
|
+
const doExit = useCallback(() => {
|
|
133
|
+
if (exiting.current) return;
|
|
134
|
+
exiting.current = true;
|
|
132
135
|
exit();
|
|
133
|
-
setTimeout(() => process.exit(0),
|
|
134
|
-
},
|
|
136
|
+
setTimeout(() => process.exit(0), 200);
|
|
137
|
+
}, [exit]);
|
|
138
|
+
|
|
139
|
+
useInput(() => doExit(), { isActive });
|
|
140
|
+
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (!isActive) return;
|
|
143
|
+
const fallback = () => doExit();
|
|
144
|
+
process.stdin.once("data", fallback);
|
|
145
|
+
const timer = setTimeout(() => doExit(), 30000);
|
|
146
|
+
return () => {
|
|
147
|
+
process.stdin.removeListener("data", fallback);
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
};
|
|
150
|
+
}, [isActive, doExit]);
|
|
151
|
+
|
|
152
|
+
return children;
|
|
153
|
+
}
|
|
135
154
|
|
|
155
|
+
function ShowCurrentStep({ isActive }) {
|
|
136
156
|
return (
|
|
137
|
-
<
|
|
138
|
-
<
|
|
139
|
-
|
|
140
|
-
|
|
157
|
+
<ExitOnKey isActive={isActive}>
|
|
158
|
+
<Box flexDirection="column">
|
|
159
|
+
<Text color="green">✓ Current companion shown above.</Text>
|
|
160
|
+
<KeyHint>Press any key to exit</KeyHint>
|
|
161
|
+
</Box>
|
|
162
|
+
</ExitOnKey>
|
|
141
163
|
);
|
|
142
164
|
}
|
|
143
165
|
|
|
@@ -169,40 +191,44 @@ function SpeciesStep({ speciesList, current, onChange, onSubmit, onBack, isActiv
|
|
|
169
191
|
|
|
170
192
|
function SearchStep({ userId, target, bruteForce, onFound, onFail, isActive }) {
|
|
171
193
|
const [progress, setProgress] = useState("");
|
|
172
|
-
const
|
|
194
|
+
const abortRef = useRef(null);
|
|
173
195
|
const hasStarted = useRef(false);
|
|
174
196
|
const { exit } = useApp();
|
|
175
197
|
|
|
176
198
|
useInput((input, key) => {
|
|
177
199
|
if (key.escape) {
|
|
178
|
-
|
|
200
|
+
if (abortRef.current) abortRef.current.abort();
|
|
179
201
|
exit();
|
|
202
|
+
setTimeout(() => process.exit(0), 200);
|
|
180
203
|
}
|
|
181
204
|
}, { isActive });
|
|
182
205
|
|
|
183
206
|
useEffect(() => {
|
|
184
207
|
if (hasStarted.current) return;
|
|
185
208
|
hasStarted.current = true;
|
|
209
|
+
const ac = new AbortController();
|
|
210
|
+
abortRef.current = ac;
|
|
186
211
|
(async () => {
|
|
187
212
|
let found;
|
|
188
213
|
try {
|
|
189
214
|
found = await bruteForce(userId, target, (attempts, elapsed, expected, workers) => {
|
|
190
|
-
if (!
|
|
215
|
+
if (!ac.signal.aborted) {
|
|
191
216
|
const pct = Math.min(100, Math.round((attempts / expected) * 100));
|
|
192
217
|
const rate = attempts / (elapsed / 1000);
|
|
193
218
|
const rateStr = rate >= 1e6 ? `${(rate / 1e6).toFixed(1)}M` : `${(rate / 1e3).toFixed(1)}k`;
|
|
194
219
|
const eta = Math.max(0, (expected - attempts) / rate);
|
|
195
220
|
setProgress(`${pct}% | ${rateStr} tries/s | ~${Math.round(eta)}s left | ${workers} cores`);
|
|
196
221
|
}
|
|
197
|
-
});
|
|
222
|
+
}, ac.signal);
|
|
198
223
|
} catch {
|
|
199
|
-
if (!
|
|
224
|
+
if (!ac.signal.aborted) onFail();
|
|
200
225
|
return;
|
|
201
226
|
}
|
|
202
|
-
if (
|
|
227
|
+
if (ac.signal.aborted) return;
|
|
203
228
|
if (found) onFound(found);
|
|
204
229
|
else onFail();
|
|
205
230
|
})();
|
|
231
|
+
return () => ac.abort();
|
|
206
232
|
}, [bruteForce, userId, target, onFound, onFail]);
|
|
207
233
|
|
|
208
234
|
return (
|
|
@@ -214,30 +240,26 @@ function SearchStep({ userId, target, bruteForce, onFound, onFail, isActive }) {
|
|
|
214
240
|
}
|
|
215
241
|
|
|
216
242
|
function DoneStep({ messages, isActive }) {
|
|
217
|
-
const { exit } = useApp();
|
|
218
243
|
const hasErrors = messages.some((msg) => msg.type === "error");
|
|
219
244
|
|
|
220
|
-
useInput(() => {
|
|
221
|
-
exit();
|
|
222
|
-
setTimeout(() => process.exit(0), 100);
|
|
223
|
-
}, { isActive });
|
|
224
|
-
|
|
225
245
|
return (
|
|
226
|
-
<
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
{msg.type === "error" ? "
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
<
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
246
|
+
<ExitOnKey isActive={isActive}>
|
|
247
|
+
<Box flexDirection="column">
|
|
248
|
+
{messages.map((msg) => (
|
|
249
|
+
<Text key={`${msg.type}-${msg.text}`} color={msg.type === "error" ? "red" : "green"}>
|
|
250
|
+
{msg.type === "error" ? "✗ " : "✓ "}{msg.text}
|
|
251
|
+
</Text>
|
|
252
|
+
))}
|
|
253
|
+
<Box marginTop={1}>
|
|
254
|
+
<Text bold>
|
|
255
|
+
{hasErrors
|
|
256
|
+
? "Something went wrong — check the issue above and try again."
|
|
257
|
+
: "All set! Your buddy will stick around even after updates. Restart Claude Code and say /buddy!"}
|
|
258
|
+
</Text>
|
|
259
|
+
</Box>
|
|
260
|
+
<KeyHint>Press any key to exit</KeyHint>
|
|
238
261
|
</Box>
|
|
239
|
-
|
|
240
|
-
</Box>
|
|
262
|
+
</ExitOnKey>
|
|
241
263
|
);
|
|
242
264
|
}
|
|
243
265
|
|