any-buddy 1.0.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 +246 -0
- package/assets/current.svg +44 -0
- package/assets/demo.svg +44 -0
- package/assets/options.svg +44 -0
- package/assets/species.svg +44 -0
- package/bin/cli.mjs +88 -0
- package/lib/config.mjs +132 -0
- package/lib/constants.mjs +41 -0
- package/lib/finder-worker.mjs +96 -0
- package/lib/finder.mjs +25 -0
- package/lib/generation.mjs +96 -0
- package/lib/patcher.mjs +168 -0
- package/lib/sprites.mjs +147 -0
- package/lib/tui.mjs +420 -0
- package/package.json +38 -0
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { runInteractive, runPreview, runCurrent, runApply, runRestore } from '../lib/tui.mjs';
|
|
4
|
+
|
|
5
|
+
function parseArgs(argv) {
|
|
6
|
+
const args = argv.slice(2);
|
|
7
|
+
const flags = {};
|
|
8
|
+
const positional = [];
|
|
9
|
+
|
|
10
|
+
for (let i = 0; i < args.length; i++) {
|
|
11
|
+
const arg = args[i];
|
|
12
|
+
if (arg === '--species' || arg === '-s') { flags.species = args[++i]; }
|
|
13
|
+
else if (arg === '--rarity' || arg === '-r') { flags.rarity = args[++i]; }
|
|
14
|
+
else if (arg === '--eye' || arg === '-e') { flags.eye = args[++i]; }
|
|
15
|
+
else if (arg === '--hat' || arg === '-t') { flags.hat = args[++i]; }
|
|
16
|
+
else if (arg === '--name' || arg === '-n') { flags.name = args[++i]; }
|
|
17
|
+
else if (arg === '--silent') { flags.silent = true; }
|
|
18
|
+
else if (arg === '--no-hook') { flags.noHook = true; }
|
|
19
|
+
else if (arg === '--yes' || arg === '-y') { flags.yes = true; }
|
|
20
|
+
else if (!arg.startsWith('-')) { positional.push(arg); }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return { command: positional[0], flags };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { command, flags } = parseArgs(process.argv);
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
switch (command) {
|
|
30
|
+
case 'apply':
|
|
31
|
+
await runApply({ silent: flags.silent });
|
|
32
|
+
break;
|
|
33
|
+
case 'preview':
|
|
34
|
+
await runPreview(flags);
|
|
35
|
+
break;
|
|
36
|
+
case 'current':
|
|
37
|
+
await runCurrent();
|
|
38
|
+
break;
|
|
39
|
+
case 'restore':
|
|
40
|
+
await runRestore();
|
|
41
|
+
break;
|
|
42
|
+
case 'help':
|
|
43
|
+
printHelp();
|
|
44
|
+
break;
|
|
45
|
+
default:
|
|
46
|
+
if (command === '--help' || command === '-h') { printHelp(); break; }
|
|
47
|
+
await runInteractive(flags);
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
if (err.name === 'ExitPromptError') {
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
console.error(err.message);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function printHelp() {
|
|
59
|
+
console.log(`
|
|
60
|
+
claude-code-any-buddy — Pick any Claude Code companion pet
|
|
61
|
+
|
|
62
|
+
Usage:
|
|
63
|
+
claude-code-any-buddy Interactive pet picker
|
|
64
|
+
claude-code-any-buddy --species dragon Skip species prompt
|
|
65
|
+
claude-code-any-buddy -s cat -r legendary -e ✦ -t wizard -y
|
|
66
|
+
Fully non-interactive
|
|
67
|
+
claude-code-any-buddy preview Browse pets without applying
|
|
68
|
+
claude-code-any-buddy current Show your current pet
|
|
69
|
+
claude-code-any-buddy apply [--silent] Re-apply saved pet after update
|
|
70
|
+
claude-code-any-buddy restore Restore original pet
|
|
71
|
+
|
|
72
|
+
Options:
|
|
73
|
+
-s, --species <name> Species (duck, goose, blob, cat, dragon, octopus, owl,
|
|
74
|
+
penguin, turtle, snail, ghost, axolotl, capybara,
|
|
75
|
+
cactus, robot, rabbit, mushroom, chonk)
|
|
76
|
+
-r, --rarity <level> Rarity (common, uncommon, rare, epic, legendary)
|
|
77
|
+
-e, --eye <char> Eye style (· ✦ × ◉ @ °)
|
|
78
|
+
-t, --hat <name> Hat (none, crown, tophat, propeller, halo, wizard,
|
|
79
|
+
beanie, tinyduck)
|
|
80
|
+
-n, --name <name> Rename your companion
|
|
81
|
+
-y, --yes Skip confirmation prompts
|
|
82
|
+
--no-hook Don't offer to install the SessionStart hook
|
|
83
|
+
--silent Suppress output (for apply command in hooks)
|
|
84
|
+
|
|
85
|
+
Environment:
|
|
86
|
+
CLAUDE_BINARY Path to Claude Code binary (auto-detected by default)
|
|
87
|
+
`);
|
|
88
|
+
}
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
|
|
5
|
+
const OUR_CONFIG = join(homedir(), '.claude-code-any-buddy.json');
|
|
6
|
+
|
|
7
|
+
// Read the user's Claude userId from ~/.claude.json
|
|
8
|
+
export function getClaudeUserId() {
|
|
9
|
+
const paths = [
|
|
10
|
+
join(homedir(), '.claude.json'),
|
|
11
|
+
join(homedir(), '.claude', '.config.json'),
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
for (const p of paths) {
|
|
15
|
+
if (existsSync(p)) {
|
|
16
|
+
try {
|
|
17
|
+
const config = JSON.parse(readFileSync(p, 'utf-8'));
|
|
18
|
+
return config.oauthAccount?.accountUuid ?? config.userID ?? 'anon';
|
|
19
|
+
} catch {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return 'anon';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Save our pet config
|
|
29
|
+
export function savePetConfig(data) {
|
|
30
|
+
writeFileSync(OUR_CONFIG, JSON.stringify(data, null, 2) + '\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Load our pet config
|
|
34
|
+
export function loadPetConfig() {
|
|
35
|
+
if (!existsSync(OUR_CONFIG)) return null;
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(readFileSync(OUR_CONFIG, 'utf-8'));
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Get the path to ~/.claude.json
|
|
44
|
+
function getClaudeConfigPath() {
|
|
45
|
+
const paths = [
|
|
46
|
+
join(homedir(), '.claude.json'),
|
|
47
|
+
join(homedir(), '.claude', '.config.json'),
|
|
48
|
+
];
|
|
49
|
+
for (const p of paths) {
|
|
50
|
+
if (existsSync(p)) return p;
|
|
51
|
+
}
|
|
52
|
+
return paths[0]; // default
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Read the companion's current name from ~/.claude.json
|
|
56
|
+
export function getCompanionName() {
|
|
57
|
+
const configPath = getClaudeConfigPath();
|
|
58
|
+
if (!existsSync(configPath)) return null;
|
|
59
|
+
try {
|
|
60
|
+
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
61
|
+
return config.companion?.name ?? null;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Rename the companion in ~/.claude.json
|
|
68
|
+
export function renameCompanion(newName) {
|
|
69
|
+
const configPath = getClaudeConfigPath();
|
|
70
|
+
if (!existsSync(configPath)) {
|
|
71
|
+
throw new Error(`Claude config not found at ${configPath}`);
|
|
72
|
+
}
|
|
73
|
+
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
74
|
+
if (!config.companion) {
|
|
75
|
+
throw new Error('No companion found in config. Run /buddy in Claude Code first to hatch one.');
|
|
76
|
+
}
|
|
77
|
+
config.companion.name = newName;
|
|
78
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Read or write Claude Code's settings.json for hooks
|
|
82
|
+
const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
|
|
83
|
+
|
|
84
|
+
export function getClaudeSettings() {
|
|
85
|
+
if (!existsSync(SETTINGS_PATH)) return {};
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(readFileSync(SETTINGS_PATH, 'utf-8'));
|
|
88
|
+
} catch {
|
|
89
|
+
return {};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function saveClaudeSettings(settings) {
|
|
94
|
+
const dir = join(homedir(), '.claude');
|
|
95
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
96
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const HOOK_COMMAND = 'claude-code-any-buddy apply --silent';
|
|
100
|
+
|
|
101
|
+
export function isHookInstalled() {
|
|
102
|
+
const settings = getClaudeSettings();
|
|
103
|
+
const hooks = settings.hooks?.SessionStart;
|
|
104
|
+
if (!Array.isArray(hooks)) return false;
|
|
105
|
+
return hooks.some(h => h.command === HOOK_COMMAND);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function installHook() {
|
|
109
|
+
const settings = getClaudeSettings();
|
|
110
|
+
if (!settings.hooks) settings.hooks = {};
|
|
111
|
+
if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = [];
|
|
112
|
+
|
|
113
|
+
if (!settings.hooks.SessionStart.some(h => h.command === HOOK_COMMAND)) {
|
|
114
|
+
settings.hooks.SessionStart.push({
|
|
115
|
+
type: 'command',
|
|
116
|
+
command: HOOK_COMMAND,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
saveClaudeSettings(settings);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function removeHook() {
|
|
124
|
+
const settings = getClaudeSettings();
|
|
125
|
+
if (!settings.hooks?.SessionStart) return;
|
|
126
|
+
settings.hooks.SessionStart = settings.hooks.SessionStart.filter(
|
|
127
|
+
h => h.command !== HOOK_COMMAND
|
|
128
|
+
);
|
|
129
|
+
if (settings.hooks.SessionStart.length === 0) delete settings.hooks.SessionStart;
|
|
130
|
+
if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
|
|
131
|
+
saveClaudeSettings(settings);
|
|
132
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export const ORIGINAL_SALT = 'friend-2026-401';
|
|
2
|
+
|
|
3
|
+
export const RARITIES = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
|
|
4
|
+
|
|
5
|
+
export const RARITY_WEIGHTS = {
|
|
6
|
+
common: 60,
|
|
7
|
+
uncommon: 25,
|
|
8
|
+
rare: 10,
|
|
9
|
+
epic: 4,
|
|
10
|
+
legendary: 1,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const RARITY_STARS = {
|
|
14
|
+
common: '★',
|
|
15
|
+
uncommon: '★★',
|
|
16
|
+
rare: '★★★',
|
|
17
|
+
epic: '★★★★',
|
|
18
|
+
legendary: '★★★★★',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const SPECIES = [
|
|
22
|
+
'duck', 'goose', 'blob', 'cat', 'dragon', 'octopus', 'owl', 'penguin',
|
|
23
|
+
'turtle', 'snail', 'ghost', 'axolotl', 'capybara', 'cactus', 'robot',
|
|
24
|
+
'rabbit', 'mushroom', 'chonk',
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
export const EYES = ['·', '✦', '×', '◉', '@', '°'];
|
|
28
|
+
|
|
29
|
+
export const HATS = [
|
|
30
|
+
'none', 'crown', 'tophat', 'propeller', 'halo', 'wizard', 'beanie', 'tinyduck',
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
export const STAT_NAMES = ['DEBUGGING', 'PATIENCE', 'CHAOS', 'WISDOM', 'SNARK'];
|
|
34
|
+
|
|
35
|
+
export const RARITY_FLOOR = {
|
|
36
|
+
common: 5,
|
|
37
|
+
uncommon: 15,
|
|
38
|
+
rare: 25,
|
|
39
|
+
epic: 35,
|
|
40
|
+
legendary: 50,
|
|
41
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// This script runs under Bun for fast native Bun.hash access.
|
|
3
|
+
// Called by finder.mjs as a subprocess.
|
|
4
|
+
// Args: <userId> <species> <rarity> <eye> <hat>
|
|
5
|
+
// Outputs JSON: { salt, attempts, elapsed }
|
|
6
|
+
|
|
7
|
+
const RARITIES = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
|
|
8
|
+
const RARITY_WEIGHTS = { common: 60, uncommon: 25, rare: 10, epic: 4, legendary: 1 };
|
|
9
|
+
const SPECIES = [
|
|
10
|
+
'duck', 'goose', 'blob', 'cat', 'dragon', 'octopus', 'owl', 'penguin',
|
|
11
|
+
'turtle', 'snail', 'ghost', 'axolotl', 'capybara', 'cactus', 'robot',
|
|
12
|
+
'rabbit', 'mushroom', 'chonk',
|
|
13
|
+
];
|
|
14
|
+
const EYES = ['·', '✦', '×', '◉', '@', '°'];
|
|
15
|
+
const HATS = ['none', 'crown', 'tophat', 'propeller', 'halo', 'wizard', 'beanie', 'tinyduck'];
|
|
16
|
+
|
|
17
|
+
function mulberry32(seed) {
|
|
18
|
+
let a = seed >>> 0;
|
|
19
|
+
return function () {
|
|
20
|
+
a |= 0;
|
|
21
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
22
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
23
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
24
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function pick(rng, arr) {
|
|
29
|
+
return arr[Math.floor(rng() * arr.length)];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function rollRarity(rng) {
|
|
33
|
+
const total = 100;
|
|
34
|
+
let roll = rng() * total;
|
|
35
|
+
for (const rarity of RARITIES) {
|
|
36
|
+
roll -= RARITY_WEIGHTS[rarity];
|
|
37
|
+
if (roll < 0) return rarity;
|
|
38
|
+
}
|
|
39
|
+
return 'common';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function quickRoll(userId, salt) {
|
|
43
|
+
const key = userId + salt;
|
|
44
|
+
const seed = Number(BigInt(Bun.hash(key)) & 0xffffffffn);
|
|
45
|
+
const rng = mulberry32(seed);
|
|
46
|
+
const rarity = rollRarity(rng);
|
|
47
|
+
const species = pick(rng, SPECIES);
|
|
48
|
+
const eye = pick(rng, EYES);
|
|
49
|
+
const hat = rarity === 'common' ? 'none' : pick(rng, HATS);
|
|
50
|
+
return { rarity, species, eye, hat };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const SALT_LEN = 15;
|
|
54
|
+
const CHARSET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_';
|
|
55
|
+
|
|
56
|
+
function randomSalt() {
|
|
57
|
+
let s = '';
|
|
58
|
+
for (let i = 0; i < SALT_LEN; i++) {
|
|
59
|
+
s += CHARSET[(Math.random() * CHARSET.length) | 0];
|
|
60
|
+
}
|
|
61
|
+
return s;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const [userId, wantSpecies, wantRarity, wantEye, wantHat] = process.argv.slice(2);
|
|
65
|
+
|
|
66
|
+
if (!userId || !wantSpecies || !wantRarity || !wantEye || !wantHat) {
|
|
67
|
+
console.error('Usage: finder-worker.mjs <userId> <species> <rarity> <eye> <hat>');
|
|
68
|
+
process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const start = Date.now();
|
|
72
|
+
let attempts = 0;
|
|
73
|
+
|
|
74
|
+
while (true) {
|
|
75
|
+
attempts++;
|
|
76
|
+
const salt = randomSalt();
|
|
77
|
+
const bones = quickRoll(userId, salt);
|
|
78
|
+
|
|
79
|
+
if (
|
|
80
|
+
bones.species === wantSpecies &&
|
|
81
|
+
bones.rarity === wantRarity &&
|
|
82
|
+
bones.eye === wantEye &&
|
|
83
|
+
bones.hat === wantHat
|
|
84
|
+
) {
|
|
85
|
+
console.log(JSON.stringify({
|
|
86
|
+
salt,
|
|
87
|
+
attempts,
|
|
88
|
+
elapsed: Date.now() - start,
|
|
89
|
+
}));
|
|
90
|
+
process.exit(0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (attempts % 500000 === 0) {
|
|
94
|
+
process.stderr.write(`${(attempts / 1000).toFixed(0)}k seeds tried...\n`);
|
|
95
|
+
}
|
|
96
|
+
}
|
package/lib/finder.mjs
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import { dirname, join } from 'path';
|
|
4
|
+
|
|
5
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
6
|
+
const WORKER_PATH = join(__dirname, 'finder-worker.mjs');
|
|
7
|
+
|
|
8
|
+
// Spawns a Bun subprocess that brute-forces salts using native Bun.hash.
|
|
9
|
+
// Returns { salt, attempts, elapsed }.
|
|
10
|
+
export function findSalt(userId, desired) {
|
|
11
|
+
const result = execFileSync('bun', [
|
|
12
|
+
WORKER_PATH,
|
|
13
|
+
userId,
|
|
14
|
+
desired.species,
|
|
15
|
+
desired.rarity,
|
|
16
|
+
desired.eye,
|
|
17
|
+
desired.hat,
|
|
18
|
+
], {
|
|
19
|
+
encoding: 'utf-8',
|
|
20
|
+
timeout: 120000, // 2 minute timeout
|
|
21
|
+
stdio: ['pipe', 'pipe', 'inherit'], // stderr passes through for progress
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return JSON.parse(result.trim());
|
|
25
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import {
|
|
3
|
+
RARITIES, RARITY_WEIGHTS, RARITY_FLOOR, SPECIES, EYES, HATS, STAT_NAMES,
|
|
4
|
+
} from './constants.mjs';
|
|
5
|
+
|
|
6
|
+
// Bun.hash (wyhash) — spawns bun to get the exact same hash Claude Code uses.
|
|
7
|
+
// String passed via stdin since bun -e doesn't forward argv.
|
|
8
|
+
// Cached to avoid repeated subprocess calls for the same input.
|
|
9
|
+
const hashCache = new Map();
|
|
10
|
+
|
|
11
|
+
export function hashString(s) {
|
|
12
|
+
if (hashCache.has(s)) return hashCache.get(s);
|
|
13
|
+
try {
|
|
14
|
+
const result = execFileSync('bun', ['-e',
|
|
15
|
+
'const s=await Bun.stdin.text();process.stdout.write(String(Number(BigInt(Bun.hash(s))&0xffffffffn)))',
|
|
16
|
+
], { encoding: 'utf-8', input: s, timeout: 5000 });
|
|
17
|
+
const h = parseInt(result.trim(), 10);
|
|
18
|
+
hashCache.set(s, h);
|
|
19
|
+
return h;
|
|
20
|
+
} catch {
|
|
21
|
+
// Fallback to FNV-1a if bun isn't available (won't match Claude Code but works for testing)
|
|
22
|
+
let h = 2166136261;
|
|
23
|
+
for (let i = 0; i < s.length; i++) {
|
|
24
|
+
h ^= s.charCodeAt(i);
|
|
25
|
+
h = Math.imul(h, 16777619);
|
|
26
|
+
}
|
|
27
|
+
const result = h >>> 0;
|
|
28
|
+
hashCache.set(s, result);
|
|
29
|
+
return result;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Mulberry32 seeded PRNG — matches buddy/companion.ts exactly
|
|
34
|
+
export function mulberry32(seed) {
|
|
35
|
+
let a = seed >>> 0;
|
|
36
|
+
return function () {
|
|
37
|
+
a |= 0;
|
|
38
|
+
a = (a + 0x6d2b79f5) | 0;
|
|
39
|
+
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
|
40
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
41
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function pick(rng, arr) {
|
|
46
|
+
return arr[Math.floor(rng() * arr.length)];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function rollRarity(rng) {
|
|
50
|
+
const total = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0);
|
|
51
|
+
let roll = rng() * total;
|
|
52
|
+
for (const rarity of RARITIES) {
|
|
53
|
+
roll -= RARITY_WEIGHTS[rarity];
|
|
54
|
+
if (roll < 0) return rarity;
|
|
55
|
+
}
|
|
56
|
+
return 'common';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function rollStats(rng, rarity) {
|
|
60
|
+
const floor = RARITY_FLOOR[rarity];
|
|
61
|
+
const peak = pick(rng, STAT_NAMES);
|
|
62
|
+
let dump = pick(rng, STAT_NAMES);
|
|
63
|
+
while (dump === peak) dump = pick(rng, STAT_NAMES);
|
|
64
|
+
|
|
65
|
+
const stats = {};
|
|
66
|
+
for (const name of STAT_NAMES) {
|
|
67
|
+
if (name === peak) {
|
|
68
|
+
stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30));
|
|
69
|
+
} else if (name === dump) {
|
|
70
|
+
stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15));
|
|
71
|
+
} else {
|
|
72
|
+
stats[name] = floor + Math.floor(rng() * 40);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return stats;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function rollFrom(rng) {
|
|
79
|
+
const rarity = rollRarity(rng);
|
|
80
|
+
const bones = {
|
|
81
|
+
rarity,
|
|
82
|
+
species: pick(rng, SPECIES),
|
|
83
|
+
eye: pick(rng, EYES),
|
|
84
|
+
hat: rarity === 'common' ? 'none' : pick(rng, HATS),
|
|
85
|
+
shiny: rng() < 0.01,
|
|
86
|
+
stats: rollStats(rng, rarity),
|
|
87
|
+
};
|
|
88
|
+
const inspirationSeed = Math.floor(rng() * 1e9);
|
|
89
|
+
return { bones, inspirationSeed };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Roll with explicit salt param (unlike source which hardcodes it)
|
|
93
|
+
export function roll(userId, salt) {
|
|
94
|
+
const key = userId + salt;
|
|
95
|
+
return rollFrom(mulberry32(hashString(key)));
|
|
96
|
+
}
|
package/lib/patcher.mjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, copyFileSync, statSync, chmodSync, realpathSync, unlinkSync, renameSync } from 'fs';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import { join, basename } from 'path';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
import { ORIGINAL_SALT } from './constants.mjs';
|
|
7
|
+
|
|
8
|
+
// Resolve the actual Claude Code binary path dynamically
|
|
9
|
+
export function findClaudeBinary() {
|
|
10
|
+
// 1. Check if user specified a path via env var
|
|
11
|
+
if (process.env.CLAUDE_BINARY) {
|
|
12
|
+
const p = process.env.CLAUDE_BINARY;
|
|
13
|
+
if (existsSync(p)) return realpathSync(p);
|
|
14
|
+
throw new Error(`CLAUDE_BINARY="${p}" does not exist.`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// 2. Try `which claude` to find it on PATH (works for any install method)
|
|
18
|
+
try {
|
|
19
|
+
const which = execSync('which claude 2>/dev/null', { encoding: 'utf-8' }).trim();
|
|
20
|
+
if (which && existsSync(which)) {
|
|
21
|
+
return realpathSync(which);
|
|
22
|
+
}
|
|
23
|
+
} catch { /* ignore */ }
|
|
24
|
+
|
|
25
|
+
// 3. Common known locations as fallback
|
|
26
|
+
const candidates = [
|
|
27
|
+
join(homedir(), '.local', 'bin', 'claude'),
|
|
28
|
+
'/usr/local/bin/claude',
|
|
29
|
+
'/usr/bin/claude',
|
|
30
|
+
join(homedir(), '.npm-global', 'bin', 'claude'),
|
|
31
|
+
join(homedir(), '.volta', 'bin', 'claude'),
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
for (const candidate of candidates) {
|
|
35
|
+
if (existsSync(candidate)) {
|
|
36
|
+
try {
|
|
37
|
+
return realpathSync(candidate);
|
|
38
|
+
} catch {
|
|
39
|
+
return candidate;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
throw new Error(
|
|
45
|
+
'Could not find Claude Code binary.\n' +
|
|
46
|
+
' Tried `which claude` and these paths:\n' +
|
|
47
|
+
candidates.map(c => ` - ${c}`).join('\n') +
|
|
48
|
+
'\n\n Set CLAUDE_BINARY=/path/to/claude to specify manually.'
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Find all byte offsets of a string in a buffer
|
|
53
|
+
function findAllOccurrences(buffer, searchStr) {
|
|
54
|
+
const searchBuf = Buffer.from(searchStr, 'utf-8');
|
|
55
|
+
const offsets = [];
|
|
56
|
+
let pos = 0;
|
|
57
|
+
while (pos < buffer.length) {
|
|
58
|
+
const idx = buffer.indexOf(searchBuf, pos);
|
|
59
|
+
if (idx === -1) break;
|
|
60
|
+
offsets.push(idx);
|
|
61
|
+
pos = idx + 1;
|
|
62
|
+
}
|
|
63
|
+
return offsets;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Read the current salt from the binary (checks if patched or original)
|
|
67
|
+
export function getCurrentSalt(binaryPath) {
|
|
68
|
+
const buf = readFileSync(binaryPath);
|
|
69
|
+
const origOffsets = findAllOccurrences(buf, ORIGINAL_SALT);
|
|
70
|
+
if (origOffsets.length === 3) {
|
|
71
|
+
return { salt: ORIGINAL_SALT, patched: false, offsets: origOffsets };
|
|
72
|
+
}
|
|
73
|
+
return { salt: null, patched: true, offsets: origOffsets };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Check if a specific salt is present in the binary
|
|
77
|
+
export function verifySalt(binaryPath, salt) {
|
|
78
|
+
const buf = readFileSync(binaryPath);
|
|
79
|
+
const offsets = findAllOccurrences(buf, salt);
|
|
80
|
+
return { found: offsets.length, offsets };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Check if the Claude binary is currently running
|
|
84
|
+
export function isClaudeRunning(binaryPath) {
|
|
85
|
+
try {
|
|
86
|
+
const name = basename(binaryPath);
|
|
87
|
+
const out = execSync(`pgrep -f "${name}" 2>/dev/null || true`, { encoding: 'utf-8' });
|
|
88
|
+
return out.trim().length > 0;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Patch the binary: replace oldSalt with newSalt at all occurrences.
|
|
95
|
+
// Uses copy-patch-rename to handle ETXTBSY (binary currently running).
|
|
96
|
+
export function patchBinary(binaryPath, oldSalt, newSalt) {
|
|
97
|
+
if (oldSalt.length !== newSalt.length) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Salt length mismatch: old=${oldSalt.length}, new=${newSalt.length}. Must be ${ORIGINAL_SALT.length} chars.`
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const buf = readFileSync(binaryPath);
|
|
104
|
+
const offsets = findAllOccurrences(buf, oldSalt);
|
|
105
|
+
|
|
106
|
+
if (offsets.length === 0) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`Could not find salt "${oldSalt}" in binary. The binary may already be patched with a different salt, or Claude Code was updated.`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Create backup (read from original since it's still readable)
|
|
113
|
+
const backupPath = binaryPath + '.anybuddy-bak';
|
|
114
|
+
if (!existsSync(backupPath)) {
|
|
115
|
+
copyFileSync(binaryPath, backupPath);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Replace all occurrences in the buffer
|
|
119
|
+
const newBuf = Buffer.from(newSalt, 'utf-8');
|
|
120
|
+
for (const offset of offsets) {
|
|
121
|
+
newBuf.copy(buf, offset);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Write to a temp file then rename (avoids ETXTBSY on running binary).
|
|
125
|
+
// On Linux, renaming over a running binary is allowed — the old inode
|
|
126
|
+
// stays open for the running process, and the new file takes the path.
|
|
127
|
+
const stats = statSync(binaryPath);
|
|
128
|
+
const tmpPath = binaryPath + '.anybuddy-tmp';
|
|
129
|
+
writeFileSync(tmpPath, buf);
|
|
130
|
+
chmodSync(tmpPath, stats.mode);
|
|
131
|
+
|
|
132
|
+
// Rename: unlink old (may fail if busy, so rename new on top)
|
|
133
|
+
try {
|
|
134
|
+
renameSync(tmpPath, binaryPath);
|
|
135
|
+
} catch {
|
|
136
|
+
// If rename fails, try unlink + rename
|
|
137
|
+
try { unlinkSync(binaryPath); } catch { /* ignore */ }
|
|
138
|
+
renameSync(tmpPath, binaryPath);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Verify from the newly written file
|
|
142
|
+
const verifyBuf = readFileSync(binaryPath);
|
|
143
|
+
const verify = findAllOccurrences(verifyBuf, newSalt);
|
|
144
|
+
return {
|
|
145
|
+
replacements: offsets.length,
|
|
146
|
+
verified: verify.length === offsets.length,
|
|
147
|
+
backupPath,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Restore the binary from backup
|
|
152
|
+
export function restoreBinary(binaryPath) {
|
|
153
|
+
const backupPath = binaryPath + '.anybuddy-bak';
|
|
154
|
+
if (!existsSync(backupPath)) {
|
|
155
|
+
throw new Error('No backup found. Cannot restore.');
|
|
156
|
+
}
|
|
157
|
+
const stats = statSync(backupPath);
|
|
158
|
+
const tmpPath = binaryPath + '.anybuddy-tmp';
|
|
159
|
+
copyFileSync(backupPath, tmpPath);
|
|
160
|
+
chmodSync(tmpPath, stats.mode);
|
|
161
|
+
try {
|
|
162
|
+
renameSync(tmpPath, binaryPath);
|
|
163
|
+
} catch {
|
|
164
|
+
try { unlinkSync(binaryPath); } catch { /* ignore */ }
|
|
165
|
+
renameSync(tmpPath, binaryPath);
|
|
166
|
+
}
|
|
167
|
+
return true;
|
|
168
|
+
}
|