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 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** — must be installed via the standard method (binary at `~/.local/bin/claude`)
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 brute-force search ignores shiny by default, but you could modify the finder to require it (at the cost of ~100x longer search time).
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 only** — different binary format on macOS/Windows
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 not selectable** — you pick species/rarity/eyes/hat; stats are whatever the matching salt produces
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
 
@@ -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
- return { rarity, species, eye, hat };
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
@@ -15,6 +15,9 @@ export function findSalt(userId, desired) {
15
15
  desired.rarity,
16
16
  desired.eye,
17
17
  desired.hat,
18
+ String(desired.shiny ?? false),
19
+ desired.peak ?? 'any',
20
+ desired.dump ?? 'any',
18
21
  ], {
19
22
  encoding: 'utf-8',
20
23
  timeout: 120000, // 2 minute timeout
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
- // Resolve the actual Claude Code binary path dynamically
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. Check if user specified a path via env var
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 realpathSync(p);
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 `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);
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
- // 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
- ];
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
- try {
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
- ' Tried `which claude` and these paths:\n' +
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 Set CLAUDE_BINARY=/path/to/claude to specify manually.'
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 === 3) {
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 the Claude binary is currently running
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. The binary may already be patched with a different salt, or Claude Code was updated.`
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 (read from original since it's still readable)
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 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.
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
- renameSync(tmpPath, binaryPath);
135
- } catch {
136
- // If rename fails, try unlink + rename
137
- try { unlinkSync(binaryPath); } catch { /* ignore */ }
138
- renameSync(tmpPath, binaryPath);
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 from the newly written file
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('No backup found. Cannot restore.');
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
- copyFileSync(backupPath, tmpPath);
160
- chmodSync(tmpPath, stats.mode);
261
+
161
262
  try {
162
- renameSync(tmpPath, binaryPath);
163
- } catch {
164
- try { unlinkSync(binaryPath); } catch { /* ignore */ }
165
- renameSync(tmpPath, binaryPath);
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
- console.log(rarityColor(` Rarity: ${bones.rarity} Eyes: ${bones.eye} Hat: ${bones.hat} Shiny: ${bones.shiny ? 'YES' : 'no'}`));
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 bones = { species, eye, hat, rarity, shiny: false, stats: {} };
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, shiny: false, stats: {} };
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "any-buddy",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Pick any Claude Code companion pet you want",
5
5
  "type": "module",
6
6
  "bin": {