any-buddy 1.0.1 → 1.0.3
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 +25 -6
- package/bin/cli.mjs +15 -1
- package/lib/config.mjs +26 -0
- package/lib/finder-worker.mjs +26 -7
- package/lib/finder.mjs +3 -0
- package/lib/patcher.mjs +166 -50
- package/lib/personalities.mjs +24 -0
- package/lib/tui.mjs +79 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
# claude-code-any-buddy
|
|
2
|
-
|
|
3
2
|
Pick any Claude Code companion pet you want.
|
|
4
3
|
|
|
4
|
+
```bash
|
|
5
|
+
npx any-buddy
|
|
6
|
+
```
|
|
7
|
+
|
|
8
|
+
That's it. Follow the prompts to choose your species, rarity, eyes, hat, and name.
|
|
9
|
+
|
|
5
10
|
Claude Code assigns you a deterministic pet based on your account ID — you can't change it through normal means. This tool lets you choose your own species, rarity, eyes, and hat, then patches the Claude Code binary to make it happen.
|
|
6
11
|
|
|
7
12
|
<p align="center">
|
|
@@ -23,10 +28,19 @@ The patch is safe — it uses `rename()` to atomically swap the binary, which is
|
|
|
23
28
|
|
|
24
29
|
## Requirements
|
|
25
30
|
|
|
26
|
-
- **Linux only** — the tool patches a compiled ELF binary at `~/.local/share/claude/versions/`. macOS and Windows use different binary formats and installation paths
|
|
27
31
|
- **Node.js >= 18** — for the CLI and TUI
|
|
28
32
|
- **Bun** — required for hash computation (Claude Code uses `Bun.hash`/wyhash internally; FNV-1a produces different results). Bun is typically already installed if you have Claude Code
|
|
29
|
-
- **Claude Code** —
|
|
33
|
+
- **Claude Code** — installed via any standard method
|
|
34
|
+
|
|
35
|
+
### Platform support
|
|
36
|
+
|
|
37
|
+
| Platform | Status | Binary location (auto-detected) |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| Linux | Tested | `~/.local/share/claude/versions/<ver>` |
|
|
40
|
+
| macOS | Should work | `~/.local/bin/claude`, `/opt/homebrew/bin/claude`, `~/.claude/local/claude` |
|
|
41
|
+
| Windows | Should work | `%LOCALAPPDATA%\Programs\claude\claude.exe`, npm global shim |
|
|
42
|
+
|
|
43
|
+
The binary is found automatically via `which`/`where` and platform-specific known paths. If auto-detection fails, set `CLAUDE_BINARY=/path/to/binary` manually.
|
|
30
44
|
|
|
31
45
|
## Install
|
|
32
46
|
|
|
@@ -84,7 +98,11 @@ claude-code-any-buddy --species dragon --rarity legendary --eye '✦' --hat wiza
|
|
|
84
98
|
| `--eye <char>` | `-e` | Pre-select eye style |
|
|
85
99
|
| `--hat <name>` | `-t` | Pre-select hat |
|
|
86
100
|
| `--name <name>` | `-n` | Rename your companion |
|
|
101
|
+
| `--personality <desc>` | `-p` | Set companion personality (controls speech bubble tone) |
|
|
87
102
|
| `--yes` | `-y` | Skip all confirmation prompts |
|
|
103
|
+
| `--shiny` | | Require shiny variant (~100x longer search) |
|
|
104
|
+
| `--peak <stat>` | | Best stat: DEBUGGING, PATIENCE, CHAOS, WISDOM, or SNARK |
|
|
105
|
+
| `--dump <stat>` | | Worst stat (~20x longer search with both) |
|
|
88
106
|
| `--no-hook` | | Don't offer to install the auto-patch hook |
|
|
89
107
|
| `--silent` | | Suppress output (for `apply` in hooks) |
|
|
90
108
|
|
|
@@ -170,7 +188,7 @@ Each pet has 5 stats: **DEBUGGING**, **PATIENCE**, **CHAOS**, **WISDOM**, **SNAR
|
|
|
170
188
|
|
|
171
189
|
### Shiny
|
|
172
190
|
|
|
173
|
-
1% chance per seed. The
|
|
191
|
+
1% chance per seed. The interactive flow asks if you want shiny, or pass `--shiny` on the command line. Requiring shiny takes ~100x longer to find a matching salt (seconds instead of milliseconds) since only 1 in 100 seeds produce a shiny pet.
|
|
174
192
|
|
|
175
193
|
## How the auto-patch hook works
|
|
176
194
|
|
|
@@ -234,11 +252,12 @@ This patches the salt back to the original, removes the SessionStart hook, and c
|
|
|
234
252
|
|
|
235
253
|
## Limitations
|
|
236
254
|
|
|
237
|
-
- **Linux
|
|
255
|
+
- **Tested on Linux** — macOS and Windows should work but are not yet tested. Please [open an issue](https://github.com/cpaczek/any-buddy/issues) if you hit problems
|
|
238
256
|
- **Requires Bun** — needed for matching Claude Code's wyhash implementation
|
|
239
257
|
- **Salt string dependent** — if Anthropic changes the salt from `friend-2026-401` in a future version, the patch logic would need updating (but the tool will detect this and warn you)
|
|
240
|
-
- **Stats
|
|
258
|
+
- **Stats partially selectable** — you can pick which stat is highest (peak) and lowest (dump), but not exact values
|
|
241
259
|
- **Personality** — generated by Claude on first `/buddy` run after patching, not controlled by this tool. Delete the `companion` key from `~/.claude.json` to re-hatch with a new personality
|
|
260
|
+
- **Speech bubble** — your buddy's speech bubble reactions are generated by Claude based on the personality and name stored in `~/.claude.json`. After patching the visual, the buddy will still *talk* like the original personality unless you update it. Use the interactive prompt or `--personality "your description here"` to change what your buddy says
|
|
242
261
|
- **Name** — can be changed at any time via the interactive flow or `--name` flag (edits `~/.claude.json` directly)
|
|
243
262
|
|
|
244
263
|
## License
|
package/bin/cli.mjs
CHANGED
|
@@ -14,6 +14,10 @@ function parseArgs(argv) {
|
|
|
14
14
|
else if (arg === '--eye' || arg === '-e') { flags.eye = args[++i]; }
|
|
15
15
|
else if (arg === '--hat' || arg === '-t') { flags.hat = args[++i]; }
|
|
16
16
|
else if (arg === '--name' || arg === '-n') { flags.name = args[++i]; }
|
|
17
|
+
else if (arg === '--personality' || arg === '-p') { flags.personality = args[++i]; }
|
|
18
|
+
else if (arg === '--shiny') { flags.shiny = true; }
|
|
19
|
+
else if (arg === '--peak') { flags.peak = args[++i]; }
|
|
20
|
+
else if (arg === '--dump') { flags.dump = args[++i]; }
|
|
17
21
|
else if (arg === '--silent') { flags.silent = true; }
|
|
18
22
|
else if (arg === '--no-hook') { flags.noHook = true; }
|
|
19
23
|
else if (arg === '--yes' || arg === '-y') { flags.yes = true; }
|
|
@@ -51,7 +55,13 @@ try {
|
|
|
51
55
|
if (err.name === 'ExitPromptError') {
|
|
52
56
|
process.exit(0);
|
|
53
57
|
}
|
|
54
|
-
console.error(err.message);
|
|
58
|
+
console.error(`\n Error: ${err.message}`);
|
|
59
|
+
// If the error message doesn't already include the issue URL, add it
|
|
60
|
+
if (!err.message.includes('github.com/cpaczek/any-buddy')) {
|
|
61
|
+
console.error(`\n If this seems like a bug, please report it at:`);
|
|
62
|
+
console.error(` https://github.com/cpaczek/any-buddy/issues`);
|
|
63
|
+
console.error(`\n Include your OS (${process.platform}), Node ${process.version}, and the error above.`);
|
|
64
|
+
}
|
|
55
65
|
process.exit(1);
|
|
56
66
|
}
|
|
57
67
|
|
|
@@ -78,6 +88,10 @@ Options:
|
|
|
78
88
|
-t, --hat <name> Hat (none, crown, tophat, propeller, halo, wizard,
|
|
79
89
|
beanie, tinyduck)
|
|
80
90
|
-n, --name <name> Rename your companion
|
|
91
|
+
-p, --personality <desc> Set companion personality
|
|
92
|
+
--shiny Require shiny (~100x longer search)
|
|
93
|
+
--peak <stat> Best stat (DEBUGGING, PATIENCE, CHAOS, WISDOM, SNARK)
|
|
94
|
+
--dump <stat> Worst stat (~20x longer search with both)
|
|
81
95
|
-y, --yes Skip confirmation prompts
|
|
82
96
|
--no-hook Don't offer to install the SessionStart hook
|
|
83
97
|
--silent Suppress output (for apply command in hooks)
|
package/lib/config.mjs
CHANGED
|
@@ -78,6 +78,32 @@ export function renameCompanion(newName) {
|
|
|
78
78
|
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
// Read the companion's personality from ~/.claude.json
|
|
82
|
+
export function getCompanionPersonality() {
|
|
83
|
+
const configPath = getClaudeConfigPath();
|
|
84
|
+
if (!existsSync(configPath)) return null;
|
|
85
|
+
try {
|
|
86
|
+
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
87
|
+
return config.companion?.personality ?? null;
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Update the companion's personality in ~/.claude.json
|
|
94
|
+
export function setCompanionPersonality(personality) {
|
|
95
|
+
const configPath = getClaudeConfigPath();
|
|
96
|
+
if (!existsSync(configPath)) {
|
|
97
|
+
throw new Error(`Claude config not found at ${configPath}`);
|
|
98
|
+
}
|
|
99
|
+
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
100
|
+
if (!config.companion) {
|
|
101
|
+
throw new Error('No companion found in config. Run /buddy in Claude Code first to hatch one.');
|
|
102
|
+
}
|
|
103
|
+
config.companion.personality = personality;
|
|
104
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 });
|
|
105
|
+
}
|
|
106
|
+
|
|
81
107
|
// Read or write Claude Code's settings.json for hooks
|
|
82
108
|
const SETTINGS_PATH = join(homedir(), '.claude', 'settings.json');
|
|
83
109
|
|
package/lib/finder-worker.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
// This script runs under Bun for fast native Bun.hash access.
|
|
3
3
|
// Called by finder.mjs as a subprocess.
|
|
4
|
-
// Args: <userId> <species> <rarity> <eye> <hat>
|
|
4
|
+
// Args: <userId> <species> <rarity> <eye> <hat> <shiny> <peak> <dump>
|
|
5
5
|
// Outputs JSON: { salt, attempts, elapsed }
|
|
6
6
|
|
|
7
7
|
const RARITIES = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
|
|
@@ -13,6 +13,7 @@ const SPECIES = [
|
|
|
13
13
|
];
|
|
14
14
|
const EYES = ['·', '✦', '×', '◉', '@', '°'];
|
|
15
15
|
const HATS = ['none', 'crown', 'tophat', 'propeller', 'halo', 'wizard', 'beanie', 'tinyduck'];
|
|
16
|
+
const STAT_NAMES = ['DEBUGGING', 'PATIENCE', 'CHAOS', 'WISDOM', 'SNARK'];
|
|
16
17
|
|
|
17
18
|
function mulberry32(seed) {
|
|
18
19
|
let a = seed >>> 0;
|
|
@@ -39,7 +40,7 @@ function rollRarity(rng) {
|
|
|
39
40
|
return 'common';
|
|
40
41
|
}
|
|
41
42
|
|
|
42
|
-
function quickRoll(userId, salt) {
|
|
43
|
+
function quickRoll(userId, salt, needStats) {
|
|
43
44
|
const key = userId + salt;
|
|
44
45
|
const seed = Number(BigInt(Bun.hash(key)) & 0xffffffffn);
|
|
45
46
|
const rng = mulberry32(seed);
|
|
@@ -47,7 +48,17 @@ function quickRoll(userId, salt) {
|
|
|
47
48
|
const species = pick(rng, SPECIES);
|
|
48
49
|
const eye = pick(rng, EYES);
|
|
49
50
|
const hat = rarity === 'common' ? 'none' : pick(rng, HATS);
|
|
50
|
-
|
|
51
|
+
const shiny = rng() < 0.01;
|
|
52
|
+
|
|
53
|
+
let peak = null, dump = null;
|
|
54
|
+
if (needStats) {
|
|
55
|
+
// These mirror the rollStats() calls exactly — same RNG consumption order
|
|
56
|
+
peak = pick(rng, STAT_NAMES);
|
|
57
|
+
dump = pick(rng, STAT_NAMES);
|
|
58
|
+
while (dump === peak) dump = pick(rng, STAT_NAMES);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { rarity, species, eye, hat, shiny, peak, dump };
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
const SALT_LEN = 15;
|
|
@@ -61,26 +72,34 @@ function randomSalt() {
|
|
|
61
72
|
return s;
|
|
62
73
|
}
|
|
63
74
|
|
|
64
|
-
const [userId, wantSpecies, wantRarity, wantEye, wantHat] = process.argv.slice(2);
|
|
75
|
+
const [userId, wantSpecies, wantRarity, wantEye, wantHat, wantShiny, wantPeak, wantDump] = process.argv.slice(2);
|
|
65
76
|
|
|
66
77
|
if (!userId || !wantSpecies || !wantRarity || !wantEye || !wantHat) {
|
|
67
|
-
console.error('Usage: finder-worker.mjs <userId> <species> <rarity> <eye> <hat>');
|
|
78
|
+
console.error('Usage: finder-worker.mjs <userId> <species> <rarity> <eye> <hat> [shiny] [peak] [dump]');
|
|
68
79
|
process.exit(1);
|
|
69
80
|
}
|
|
70
81
|
|
|
82
|
+
const requireShiny = wantShiny === 'true';
|
|
83
|
+
const requirePeak = wantPeak && wantPeak !== 'any' ? wantPeak : null;
|
|
84
|
+
const requireDump = wantDump && wantDump !== 'any' ? wantDump : null;
|
|
85
|
+
const needStats = !!(requirePeak || requireDump);
|
|
86
|
+
|
|
71
87
|
const start = Date.now();
|
|
72
88
|
let attempts = 0;
|
|
73
89
|
|
|
74
90
|
while (true) {
|
|
75
91
|
attempts++;
|
|
76
92
|
const salt = randomSalt();
|
|
77
|
-
const bones = quickRoll(userId, salt);
|
|
93
|
+
const bones = quickRoll(userId, salt, needStats);
|
|
78
94
|
|
|
79
95
|
if (
|
|
80
96
|
bones.species === wantSpecies &&
|
|
81
97
|
bones.rarity === wantRarity &&
|
|
82
98
|
bones.eye === wantEye &&
|
|
83
|
-
bones.hat === wantHat
|
|
99
|
+
bones.hat === wantHat &&
|
|
100
|
+
(!requireShiny || bones.shiny) &&
|
|
101
|
+
(!requirePeak || bones.peak === requirePeak) &&
|
|
102
|
+
(!requireDump || bones.dump === requireDump)
|
|
84
103
|
) {
|
|
85
104
|
console.log(JSON.stringify({
|
|
86
105
|
salt,
|
package/lib/finder.mjs
CHANGED
package/lib/patcher.mjs
CHANGED
|
@@ -1,54 +1,134 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, copyFileSync, statSync, chmodSync, realpathSync, unlinkSync, renameSync } from 'fs';
|
|
2
2
|
import { existsSync } from 'fs';
|
|
3
3
|
import { execSync } from 'child_process';
|
|
4
|
-
import { join, basename } from 'path';
|
|
5
|
-
import { homedir } from 'os';
|
|
4
|
+
import { join, basename, dirname } from 'path';
|
|
5
|
+
import { homedir, platform } from 'os';
|
|
6
6
|
import { ORIGINAL_SALT } from './constants.mjs';
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
const IS_WIN = platform() === 'win32';
|
|
9
|
+
const IS_MAC = platform() === 'darwin';
|
|
10
|
+
const IS_LINUX = platform() === 'linux';
|
|
11
|
+
|
|
12
|
+
// Cross-platform `which` — returns the resolved path or null
|
|
13
|
+
function which(cmd) {
|
|
14
|
+
try {
|
|
15
|
+
const shellCmd = IS_WIN ? `where ${cmd}` : `which ${cmd}`;
|
|
16
|
+
const result = execSync(shellCmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
17
|
+
// `where` on Windows may return multiple lines; take the first
|
|
18
|
+
const first = result.split(/\r?\n/)[0].trim();
|
|
19
|
+
if (first && existsSync(first)) return first;
|
|
20
|
+
} catch { /* ignore */ }
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Resolve symlinks safely
|
|
25
|
+
function realpath(p) {
|
|
26
|
+
try { return realpathSync(p); } catch { return p; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Resolve the actual Claude Code binary/bundle path.
|
|
30
|
+
// Works on Linux (ELF binary), macOS (Mach-O or app bundle), and Windows (.exe or .cmd shim).
|
|
9
31
|
export function findClaudeBinary() {
|
|
10
|
-
// 1.
|
|
32
|
+
// 1. User-specified override
|
|
11
33
|
if (process.env.CLAUDE_BINARY) {
|
|
12
34
|
const p = process.env.CLAUDE_BINARY;
|
|
13
|
-
if (existsSync(p)) return
|
|
35
|
+
if (existsSync(p)) return realpath(p);
|
|
14
36
|
throw new Error(`CLAUDE_BINARY="${p}" does not exist.`);
|
|
15
37
|
}
|
|
16
38
|
|
|
17
|
-
// 2. Try `
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
39
|
+
// 2. Try finding `claude` on PATH
|
|
40
|
+
const onPath = which('claude');
|
|
41
|
+
if (onPath) {
|
|
42
|
+
const resolved = realpath(onPath);
|
|
43
|
+
|
|
44
|
+
// On Windows, `where claude` might return a .cmd shim from npm.
|
|
45
|
+
// We need the actual binary it points to.
|
|
46
|
+
if (IS_WIN && resolved.endsWith('.cmd')) {
|
|
47
|
+
const target = resolveWindowsShim(resolved);
|
|
48
|
+
if (target) return target;
|
|
22
49
|
}
|
|
23
|
-
} catch { /* ignore */ }
|
|
24
50
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
join(homedir(), '.volta', 'bin', 'claude'),
|
|
32
|
-
];
|
|
51
|
+
// On macOS/Linux, the symlink may point to a versions directory
|
|
52
|
+
return resolved;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 3. Platform-specific known locations
|
|
56
|
+
const candidates = getPlatformCandidates();
|
|
33
57
|
|
|
34
58
|
for (const candidate of candidates) {
|
|
35
59
|
if (existsSync(candidate)) {
|
|
36
|
-
|
|
37
|
-
return realpathSync(candidate);
|
|
38
|
-
} catch {
|
|
39
|
-
return candidate;
|
|
40
|
-
}
|
|
60
|
+
return realpath(candidate);
|
|
41
61
|
}
|
|
42
62
|
}
|
|
43
63
|
|
|
64
|
+
const platformHint = IS_WIN
|
|
65
|
+
? 'On Windows, Claude Code is typically installed via npm or the desktop app.'
|
|
66
|
+
: IS_MAC
|
|
67
|
+
? 'On macOS, Claude Code is typically at ~/.claude/local/ or installed via npm/brew.'
|
|
68
|
+
: 'On Linux, Claude Code is typically at ~/.local/share/claude/versions/.';
|
|
69
|
+
|
|
44
70
|
throw new Error(
|
|
45
71
|
'Could not find Claude Code binary.\n' +
|
|
46
|
-
|
|
72
|
+
` Platform: ${platform()}\n` +
|
|
73
|
+
' Tried `' + (IS_WIN ? 'where' : 'which') + ' claude` and these paths:\n' +
|
|
47
74
|
candidates.map(c => ` - ${c}`).join('\n') +
|
|
48
|
-
'\n\n
|
|
75
|
+
'\n\n ' + platformHint +
|
|
76
|
+
'\n Set CLAUDE_BINARY=/path/to/binary to specify manually.' +
|
|
77
|
+
'\n\n If this is a bug, please report it at:' +
|
|
78
|
+
'\n https://github.com/cpaczek/any-buddy/issues'
|
|
49
79
|
);
|
|
50
80
|
}
|
|
51
81
|
|
|
82
|
+
function getPlatformCandidates() {
|
|
83
|
+
const home = homedir();
|
|
84
|
+
|
|
85
|
+
if (IS_WIN) {
|
|
86
|
+
const appData = process.env.APPDATA || join(home, 'AppData', 'Roaming');
|
|
87
|
+
const localAppData = process.env.LOCALAPPDATA || join(home, 'AppData', 'Local');
|
|
88
|
+
return [
|
|
89
|
+
join(localAppData, 'Programs', 'claude', 'claude.exe'),
|
|
90
|
+
join(appData, 'npm', 'claude.cmd'),
|
|
91
|
+
join(appData, 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'cli.mjs'),
|
|
92
|
+
join(home, '.volta', 'bin', 'claude.exe'),
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (IS_MAC) {
|
|
97
|
+
return [
|
|
98
|
+
join(home, '.local', 'bin', 'claude'),
|
|
99
|
+
join(home, '.claude', 'local', 'claude'),
|
|
100
|
+
'/usr/local/bin/claude',
|
|
101
|
+
'/opt/homebrew/bin/claude',
|
|
102
|
+
join(home, '.npm-global', 'bin', 'claude'),
|
|
103
|
+
join(home, '.volta', 'bin', 'claude'),
|
|
104
|
+
];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Linux
|
|
108
|
+
return [
|
|
109
|
+
join(home, '.local', 'bin', 'claude'),
|
|
110
|
+
'/usr/local/bin/claude',
|
|
111
|
+
'/usr/bin/claude',
|
|
112
|
+
join(home, '.npm-global', 'bin', 'claude'),
|
|
113
|
+
join(home, '.volta', 'bin', 'claude'),
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// On Windows, npm installs a .cmd shim. Parse it to find the actual JS entry or binary.
|
|
118
|
+
function resolveWindowsShim(cmdPath) {
|
|
119
|
+
try {
|
|
120
|
+
const content = readFileSync(cmdPath, 'utf-8');
|
|
121
|
+
// npm shims contain a line like: "%~dp0\node_modules\@anthropic-ai\claude-code\cli.mjs"
|
|
122
|
+
const match = content.match(/node_modules[\\/]@anthropic-ai[\\/]claude-code[\\/][^\s"]+/);
|
|
123
|
+
if (match) {
|
|
124
|
+
const shimDir = dirname(cmdPath);
|
|
125
|
+
const target = join(shimDir, match[0]);
|
|
126
|
+
if (existsSync(target)) return target;
|
|
127
|
+
}
|
|
128
|
+
} catch { /* ignore */ }
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
52
132
|
// Find all byte offsets of a string in a buffer
|
|
53
133
|
function findAllOccurrences(buffer, searchStr) {
|
|
54
134
|
const searchBuf = Buffer.from(searchStr, 'utf-8');
|
|
@@ -67,7 +147,7 @@ function findAllOccurrences(buffer, searchStr) {
|
|
|
67
147
|
export function getCurrentSalt(binaryPath) {
|
|
68
148
|
const buf = readFileSync(binaryPath);
|
|
69
149
|
const origOffsets = findAllOccurrences(buf, ORIGINAL_SALT);
|
|
70
|
-
if (origOffsets.length
|
|
150
|
+
if (origOffsets.length >= 3) {
|
|
71
151
|
return { salt: ORIGINAL_SALT, patched: false, offsets: origOffsets };
|
|
72
152
|
}
|
|
73
153
|
return { salt: null, patched: true, offsets: origOffsets };
|
|
@@ -80,9 +160,13 @@ export function verifySalt(binaryPath, salt) {
|
|
|
80
160
|
return { found: offsets.length, offsets };
|
|
81
161
|
}
|
|
82
162
|
|
|
83
|
-
// Check if
|
|
163
|
+
// Check if Claude is currently running (best-effort, non-fatal)
|
|
84
164
|
export function isClaudeRunning(binaryPath) {
|
|
85
165
|
try {
|
|
166
|
+
if (IS_WIN) {
|
|
167
|
+
const out = execSync('tasklist /FI "IMAGENAME eq claude.exe" /NH 2>nul', { encoding: 'utf-8' });
|
|
168
|
+
return out.includes('claude.exe');
|
|
169
|
+
}
|
|
86
170
|
const name = basename(binaryPath);
|
|
87
171
|
const out = execSync(`pgrep -f "${name}" 2>/dev/null || true`, { encoding: 'utf-8' });
|
|
88
172
|
return out.trim().length > 0;
|
|
@@ -92,7 +176,6 @@ export function isClaudeRunning(binaryPath) {
|
|
|
92
176
|
}
|
|
93
177
|
|
|
94
178
|
// Patch the binary: replace oldSalt with newSalt at all occurrences.
|
|
95
|
-
// Uses copy-patch-rename to handle ETXTBSY (binary currently running).
|
|
96
179
|
export function patchBinary(binaryPath, oldSalt, newSalt) {
|
|
97
180
|
if (oldSalt.length !== newSalt.length) {
|
|
98
181
|
throw new Error(
|
|
@@ -105,11 +188,14 @@ export function patchBinary(binaryPath, oldSalt, newSalt) {
|
|
|
105
188
|
|
|
106
189
|
if (offsets.length === 0) {
|
|
107
190
|
throw new Error(
|
|
108
|
-
`Could not find salt "${oldSalt}" in binary
|
|
191
|
+
`Could not find salt "${oldSalt}" in binary at ${binaryPath}.\n` +
|
|
192
|
+
' The binary may already be patched with a different salt, or Claude Code has changed.\n\n' +
|
|
193
|
+
' If you think this is a bug, please report it at:\n' +
|
|
194
|
+
' https://github.com/cpaczek/any-buddy/issues'
|
|
109
195
|
);
|
|
110
196
|
}
|
|
111
197
|
|
|
112
|
-
// Create backup
|
|
198
|
+
// Create backup
|
|
113
199
|
const backupPath = binaryPath + '.anybuddy-bak';
|
|
114
200
|
if (!existsSync(backupPath)) {
|
|
115
201
|
copyFileSync(binaryPath, backupPath);
|
|
@@ -121,24 +207,36 @@ export function patchBinary(binaryPath, oldSalt, newSalt) {
|
|
|
121
207
|
newBuf.copy(buf, offset);
|
|
122
208
|
}
|
|
123
209
|
|
|
124
|
-
// Write
|
|
125
|
-
//
|
|
126
|
-
//
|
|
210
|
+
// Write strategy depends on platform.
|
|
211
|
+
// Linux/macOS: write to temp then atomic rename (handles ETXTBSY).
|
|
212
|
+
// Windows: can't rename over a running exe, so try direct write first.
|
|
127
213
|
const stats = statSync(binaryPath);
|
|
128
214
|
const tmpPath = binaryPath + '.anybuddy-tmp';
|
|
129
|
-
writeFileSync(tmpPath, buf);
|
|
130
|
-
chmodSync(tmpPath, stats.mode);
|
|
131
215
|
|
|
132
|
-
// Rename: unlink old (may fail if busy, so rename new on top)
|
|
133
216
|
try {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
try {
|
|
138
|
-
|
|
217
|
+
writeFileSync(tmpPath, buf);
|
|
218
|
+
if (!IS_WIN) chmodSync(tmpPath, stats.mode);
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
renameSync(tmpPath, binaryPath);
|
|
222
|
+
} catch {
|
|
223
|
+
try { unlinkSync(binaryPath); } catch { /* ignore */ }
|
|
224
|
+
renameSync(tmpPath, binaryPath);
|
|
225
|
+
}
|
|
226
|
+
} catch (err) {
|
|
227
|
+
// Clean up temp file on failure
|
|
228
|
+
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
229
|
+
|
|
230
|
+
if (IS_WIN && err.code === 'EPERM') {
|
|
231
|
+
throw new Error(
|
|
232
|
+
'Cannot patch: the binary is locked (Claude Code may be running).\n' +
|
|
233
|
+
' Close all Claude Code windows and try again.'
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
throw err;
|
|
139
237
|
}
|
|
140
238
|
|
|
141
|
-
// Verify
|
|
239
|
+
// Verify
|
|
142
240
|
const verifyBuf = readFileSync(binaryPath);
|
|
143
241
|
const verify = findAllOccurrences(verifyBuf, newSalt);
|
|
144
242
|
return {
|
|
@@ -152,17 +250,35 @@ export function patchBinary(binaryPath, oldSalt, newSalt) {
|
|
|
152
250
|
export function restoreBinary(binaryPath) {
|
|
153
251
|
const backupPath = binaryPath + '.anybuddy-bak';
|
|
154
252
|
if (!existsSync(backupPath)) {
|
|
155
|
-
throw new Error(
|
|
253
|
+
throw new Error(
|
|
254
|
+
'No backup found. Cannot restore.\n' +
|
|
255
|
+
` Expected: ${backupPath}`
|
|
256
|
+
);
|
|
156
257
|
}
|
|
258
|
+
|
|
157
259
|
const stats = statSync(backupPath);
|
|
158
260
|
const tmpPath = binaryPath + '.anybuddy-tmp';
|
|
159
|
-
|
|
160
|
-
chmodSync(tmpPath, stats.mode);
|
|
261
|
+
|
|
161
262
|
try {
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
263
|
+
copyFileSync(backupPath, tmpPath);
|
|
264
|
+
if (!IS_WIN) chmodSync(tmpPath, stats.mode);
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
renameSync(tmpPath, binaryPath);
|
|
268
|
+
} catch {
|
|
269
|
+
try { unlinkSync(binaryPath); } catch { /* ignore */ }
|
|
270
|
+
renameSync(tmpPath, binaryPath);
|
|
271
|
+
}
|
|
272
|
+
} catch (err) {
|
|
273
|
+
try { unlinkSync(tmpPath); } catch { /* ignore */ }
|
|
274
|
+
if (IS_WIN && err.code === 'EPERM') {
|
|
275
|
+
throw new Error(
|
|
276
|
+
'Cannot restore: the binary is locked (Claude Code may be running).\n' +
|
|
277
|
+
' Close all Claude Code windows and try again.'
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
throw err;
|
|
166
281
|
}
|
|
282
|
+
|
|
167
283
|
return true;
|
|
168
284
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Default personality suggestions for each species.
|
|
2
|
+
// Claude Code generates these via LLM on first hatch — these are hand-written
|
|
3
|
+
// defaults the user can pick from or use as inspiration.
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_PERSONALITIES = {
|
|
6
|
+
duck: "A cheerful quacker who celebrates your wins with enthusiastic honks and judges your variable names with quiet side-eye.",
|
|
7
|
+
goose: "An agent of chaos who thrives on your merge conflicts and honks menacingly whenever you write a TODO comment.",
|
|
8
|
+
blob: "A formless, chill companion who absorbs your stress and responds to everything with gentle, unhurried wisdom.",
|
|
9
|
+
cat: "An aloof code reviewer who pretends not to care about your bugs but quietly bats at syntax errors when you're not looking.",
|
|
10
|
+
dragon: "A fierce guardian of clean code who breathes fire at spaghetti logic and hoards well-written functions.",
|
|
11
|
+
octopus: "A multitasking genius who juggles eight concerns at once and offers tentacle-loads of unsolicited architectural advice.",
|
|
12
|
+
owl: "A nocturnal sage who comes alive during late-night debugging sessions and asks annoyingly insightful questions.",
|
|
13
|
+
penguin: "A tuxedo-wearing professional who waddles through your codebase with dignified concern and dry wit.",
|
|
14
|
+
turtle: "A patient mentor who reminds you that slow, steady refactoring beats heroic rewrites every time.",
|
|
15
|
+
snail: "A zen minimalist who moves at their own pace and leaves a trail of thoughtful, unhurried observations.",
|
|
16
|
+
ghost: "A spectral presence who haunts your dead code and whispers about the bugs you thought you fixed.",
|
|
17
|
+
axolotl: "A regenerative optimist who believes every broken build can be healed and every test can be unflaked.",
|
|
18
|
+
capybara: "The most relaxed companion possible — nothing fazes them, not even production outages at 3am.",
|
|
19
|
+
cactus: "A prickly but lovable desert dweller who thrives on neglect and offers sharp, pointed feedback.",
|
|
20
|
+
robot: "A logical companion who speaks in precise technical observations and occasionally glitches endearingly.",
|
|
21
|
+
rabbit: "A fast-moving, hyperactive buddy who speed-reads your diffs and bounces between topics at alarming pace.",
|
|
22
|
+
mushroom: "A wry fungal sage who speaks in meandering tangents about your bugs while secretly enjoying the chaos.",
|
|
23
|
+
chonk: "An absolute unit of a companion who sits on your terminal with maximum gravitational presence and minimal urgency.",
|
|
24
|
+
};
|
package/lib/tui.mjs
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { select, confirm, input } from '@inquirer/prompts';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import { SPECIES, EYES, HATS, RARITIES, RARITY_STARS, RARITY_WEIGHTS, ORIGINAL_SALT } from './constants.mjs';
|
|
3
|
+
import { SPECIES, EYES, HATS, RARITIES, RARITY_STARS, RARITY_WEIGHTS, STAT_NAMES, ORIGINAL_SALT } from './constants.mjs';
|
|
4
4
|
import { roll } from './generation.mjs';
|
|
5
5
|
import { renderSprite, renderFace } from './sprites.mjs';
|
|
6
6
|
import { findSalt } from './finder.mjs';
|
|
7
7
|
import { findClaudeBinary, getCurrentSalt, patchBinary, verifySalt, restoreBinary, isClaudeRunning } from './patcher.mjs';
|
|
8
|
-
import { getClaudeUserId, savePetConfig, loadPetConfig, isHookInstalled, installHook, removeHook, getCompanionName, renameCompanion } from './config.mjs';
|
|
8
|
+
import { getClaudeUserId, savePetConfig, loadPetConfig, isHookInstalled, installHook, removeHook, getCompanionName, renameCompanion, getCompanionPersonality, setCompanionPersonality } from './config.mjs';
|
|
9
|
+
import { DEFAULT_PERSONALITIES } from './personalities.mjs';
|
|
9
10
|
|
|
10
11
|
const RARITY_CHALK = {
|
|
11
12
|
common: chalk.gray,
|
|
@@ -37,7 +38,13 @@ function banner() {
|
|
|
37
38
|
function showPet(bones, label = 'Your pet') {
|
|
38
39
|
const rarityColor = RARITY_CHALK[bones.rarity] || chalk.white;
|
|
39
40
|
console.log(rarityColor(`\n ${label}: ${bones.species} ${RARITY_STARS[bones.rarity]}`));
|
|
40
|
-
|
|
41
|
+
let info = ` Rarity: ${bones.rarity} Eyes: ${bones.eye} Hat: ${bones.hat} Shiny: ${bones.shiny ? 'YES' : 'no'}`;
|
|
42
|
+
if (bones.stats && Object.keys(bones.stats).length) {
|
|
43
|
+
const sorted = Object.entries(bones.stats).sort((a, b) => b[1] - a[1]);
|
|
44
|
+
const best = sorted[0], worst = sorted[sorted.length - 1];
|
|
45
|
+
info += `\n Best: ${best[0]} ${best[1]} Worst: ${worst[0]} ${worst[1]}`;
|
|
46
|
+
}
|
|
47
|
+
console.log(rarityColor(info));
|
|
41
48
|
const lines = renderSprite(bones, 0);
|
|
42
49
|
console.log();
|
|
43
50
|
for (const line of lines) {
|
|
@@ -74,7 +81,8 @@ export async function runPreview(flags = {}) {
|
|
|
74
81
|
const hat = rarity === 'common' ? 'none'
|
|
75
82
|
: validateFlag('hat', flags.hat, HATS) ?? await selectHat(species, eye, rarity);
|
|
76
83
|
|
|
77
|
-
const
|
|
84
|
+
const shiny = flags.shiny ?? false;
|
|
85
|
+
const bones = { species, eye, hat, rarity, shiny, stats: {} };
|
|
78
86
|
showPet(bones, 'Preview');
|
|
79
87
|
console.log(chalk.dim(' (Preview only - no changes made)\n'));
|
|
80
88
|
}
|
|
@@ -203,10 +211,23 @@ export async function runInteractive(flags = {}) {
|
|
|
203
211
|
const rarity = validateFlag('rarity', flags.rarity, RARITIES) ?? await selectRarity();
|
|
204
212
|
const hat = rarity === 'common' ? 'none'
|
|
205
213
|
: validateFlag('hat', flags.hat, HATS) ?? await selectHat(species, eye, rarity);
|
|
214
|
+
const shiny = flags.shiny ?? await confirm({
|
|
215
|
+
message: 'Shiny? (1% normally — search takes ~100x longer)',
|
|
216
|
+
default: false,
|
|
217
|
+
});
|
|
218
|
+
const wantStats = flags.peak || flags.dump || await confirm({
|
|
219
|
+
message: 'Customize stats? (best/worst stat — search takes ~20x longer)',
|
|
220
|
+
default: false,
|
|
221
|
+
});
|
|
222
|
+
let peak = null, dump = null;
|
|
223
|
+
if (wantStats) {
|
|
224
|
+
peak = validateFlag('peak', flags.peak, STAT_NAMES) ?? await selectStat('Best stat');
|
|
225
|
+
dump = validateFlag('dump', flags.dump, STAT_NAMES) ?? await selectStat('Worst stat', peak);
|
|
226
|
+
}
|
|
206
227
|
|
|
207
228
|
// Final preview
|
|
208
|
-
const desired = { species, eye, hat, rarity };
|
|
209
|
-
const previewBones = { ...desired,
|
|
229
|
+
const desired = { species, eye, hat, rarity, shiny, peak, dump };
|
|
230
|
+
const previewBones = { ...desired, stats: {} };
|
|
210
231
|
showPet(previewBones, 'Your selection');
|
|
211
232
|
|
|
212
233
|
const proceed = flags.yes || await confirm({
|
|
@@ -328,8 +349,10 @@ export async function runInteractive(flags = {}) {
|
|
|
328
349
|
console.log(chalk.dim(' SessionStart hook already installed.'));
|
|
329
350
|
}
|
|
330
351
|
|
|
331
|
-
// ─── Rename ───
|
|
352
|
+
// ─── Rename & Personality ───
|
|
332
353
|
const currentName = getCompanionName();
|
|
354
|
+
const currentPersonality = getCompanionPersonality();
|
|
355
|
+
|
|
333
356
|
if (currentName) {
|
|
334
357
|
const newName = flags.name ?? await input({
|
|
335
358
|
message: `Rename your companion? (current: "${currentName}", leave blank to keep)`,
|
|
@@ -348,6 +371,46 @@ export async function runInteractive(flags = {}) {
|
|
|
348
371
|
console.log(chalk.dim(' No companion hatched yet — name will be set when you run /buddy'));
|
|
349
372
|
}
|
|
350
373
|
|
|
374
|
+
if (currentPersonality) {
|
|
375
|
+
console.log(chalk.dim(`\n Current personality: "${currentPersonality}"`));
|
|
376
|
+
|
|
377
|
+
// Determine the species — use the desired species from the selection above
|
|
378
|
+
const selectedSpecies = desired.species;
|
|
379
|
+
const speciesDefault = DEFAULT_PERSONALITIES[selectedSpecies] || null;
|
|
380
|
+
|
|
381
|
+
let newPersonality = flags.personality;
|
|
382
|
+
if (!newPersonality) {
|
|
383
|
+
const choices = [
|
|
384
|
+
{ name: 'Keep current', value: 'keep' },
|
|
385
|
+
];
|
|
386
|
+
if (speciesDefault) {
|
|
387
|
+
choices.push({ name: `Use ${selectedSpecies} default: "${speciesDefault.slice(0, 60)}..."`, value: 'default' });
|
|
388
|
+
}
|
|
389
|
+
choices.push({ name: 'Write custom', value: 'custom' });
|
|
390
|
+
|
|
391
|
+
const choice = await select({ message: 'Personality', choices });
|
|
392
|
+
|
|
393
|
+
if (choice === 'default') {
|
|
394
|
+
newPersonality = speciesDefault;
|
|
395
|
+
} else if (choice === 'custom') {
|
|
396
|
+
newPersonality = await input({
|
|
397
|
+
message: 'Describe your companion\'s personality',
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (newPersonality && newPersonality !== currentPersonality) {
|
|
403
|
+
try {
|
|
404
|
+
setCompanionPersonality(newPersonality);
|
|
405
|
+
console.log(chalk.green(' Personality updated.'));
|
|
406
|
+
} catch (err) {
|
|
407
|
+
console.log(chalk.yellow(` Could not update personality: ${err.message}`));
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
} else if (flags.personality) {
|
|
411
|
+
console.log(chalk.dim(' No companion hatched yet — personality will be set when you run /buddy'));
|
|
412
|
+
}
|
|
413
|
+
|
|
351
414
|
if (running) {
|
|
352
415
|
console.log(chalk.bold.yellow('\n Done! Quit all Claude Code sessions and relaunch to see your new pet.'));
|
|
353
416
|
console.log(chalk.dim(' Then run /buddy to meet your new companion.\n'));
|
|
@@ -360,6 +423,7 @@ export async function runInteractive(flags = {}) {
|
|
|
360
423
|
|
|
361
424
|
function validateFlag(name, value, allowed) {
|
|
362
425
|
if (value === undefined) return undefined;
|
|
426
|
+
if (value === 'any') return undefined; // treat 'any' as unset
|
|
363
427
|
if (allowed.includes(value)) return value;
|
|
364
428
|
throw new Error(
|
|
365
429
|
`Invalid --${name} "${value}". Must be one of: ${allowed.join(', ')}`
|
|
@@ -418,3 +482,11 @@ async function selectHat(species, eye, rarity) {
|
|
|
418
482
|
}),
|
|
419
483
|
});
|
|
420
484
|
}
|
|
485
|
+
|
|
486
|
+
async function selectStat(label, exclude) {
|
|
487
|
+
const choices = STAT_NAMES
|
|
488
|
+
.filter(s => s !== exclude)
|
|
489
|
+
.map(s => ({ name: s, value: s }));
|
|
490
|
+
|
|
491
|
+
return select({ message: label, choices });
|
|
492
|
+
}
|