any-buddy 1.0.1 → 1.0.2

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
 
@@ -85,6 +99,7 @@ claude-code-any-buddy --species dragon --rarity legendary --eye '✦' --hat wiza
85
99
  | `--hat <name>` | `-t` | Pre-select hat |
86
100
  | `--name <name>` | `-n` | Rename your companion |
87
101
  | `--yes` | `-y` | Skip all confirmation prompts |
102
+ | `--shiny` | | Require shiny variant (~100x longer search) |
88
103
  | `--no-hook` | | Don't offer to install the auto-patch hook |
89
104
  | `--silent` | | Suppress output (for `apply` in hooks) |
90
105
 
@@ -170,7 +185,7 @@ Each pet has 5 stats: **DEBUGGING**, **PATIENCE**, **CHAOS**, **WISDOM**, **SNAR
170
185
 
171
186
  ### Shiny
172
187
 
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).
188
+ 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
189
 
175
190
  ## How the auto-patch hook works
176
191
 
@@ -234,7 +249,7 @@ This patches the salt back to the original, removes the SessionStart hook, and c
234
249
 
235
250
  ## Limitations
236
251
 
237
- - **Linux only** — different binary format on macOS/Windows
252
+ - **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
253
  - **Requires Bun** — needed for matching Claude Code's wyhash implementation
239
254
  - **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
255
  - **Stats not selectable** — you pick species/rarity/eyes/hat; stats are whatever the matching salt produces
package/bin/cli.mjs CHANGED
@@ -14,6 +14,7 @@ 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 === '--shiny') { flags.shiny = true; }
17
18
  else if (arg === '--silent') { flags.silent = true; }
18
19
  else if (arg === '--no-hook') { flags.noHook = true; }
19
20
  else if (arg === '--yes' || arg === '-y') { flags.yes = true; }
@@ -51,7 +52,13 @@ try {
51
52
  if (err.name === 'ExitPromptError') {
52
53
  process.exit(0);
53
54
  }
54
- console.error(err.message);
55
+ console.error(`\n Error: ${err.message}`);
56
+ // If the error message doesn't already include the issue URL, add it
57
+ if (!err.message.includes('github.com/cpaczek/any-buddy')) {
58
+ console.error(`\n If this seems like a bug, please report it at:`);
59
+ console.error(` https://github.com/cpaczek/any-buddy/issues`);
60
+ console.error(`\n Include your OS (${process.platform}), Node ${process.version}, and the error above.`);
61
+ }
55
62
  process.exit(1);
56
63
  }
57
64
 
@@ -78,6 +85,7 @@ Options:
78
85
  -t, --hat <name> Hat (none, crown, tophat, propeller, halo, wizard,
79
86
  beanie, tinyduck)
80
87
  -n, --name <name> Rename your companion
88
+ --shiny Require shiny (~100x longer search)
81
89
  -y, --yes Skip confirmation prompts
82
90
  --no-hook Don't offer to install the SessionStart hook
83
91
  --silent Suppress output (for apply command in hooks)
@@ -47,7 +47,8 @@ function quickRoll(userId, salt) {
47
47
  const species = pick(rng, SPECIES);
48
48
  const eye = pick(rng, EYES);
49
49
  const hat = rarity === 'common' ? 'none' : pick(rng, HATS);
50
- return { rarity, species, eye, hat };
50
+ const shiny = rng() < 0.01;
51
+ return { rarity, species, eye, hat, shiny };
51
52
  }
52
53
 
53
54
  const SALT_LEN = 15;
@@ -61,13 +62,15 @@ function randomSalt() {
61
62
  return s;
62
63
  }
63
64
 
64
- const [userId, wantSpecies, wantRarity, wantEye, wantHat] = process.argv.slice(2);
65
+ const [userId, wantSpecies, wantRarity, wantEye, wantHat, wantShiny] = process.argv.slice(2);
65
66
 
66
67
  if (!userId || !wantSpecies || !wantRarity || !wantEye || !wantHat) {
67
- console.error('Usage: finder-worker.mjs <userId> <species> <rarity> <eye> <hat>');
68
+ console.error('Usage: finder-worker.mjs <userId> <species> <rarity> <eye> <hat> [shiny]');
68
69
  process.exit(1);
69
70
  }
70
71
 
72
+ const requireShiny = wantShiny === 'true';
73
+
71
74
  const start = Date.now();
72
75
  let attempts = 0;
73
76
 
@@ -80,7 +83,8 @@ while (true) {
80
83
  bones.species === wantSpecies &&
81
84
  bones.rarity === wantRarity &&
82
85
  bones.eye === wantEye &&
83
- bones.hat === wantHat
86
+ bones.hat === wantHat &&
87
+ (!requireShiny || bones.shiny)
84
88
  ) {
85
89
  console.log(JSON.stringify({
86
90
  salt,
package/lib/finder.mjs CHANGED
@@ -15,6 +15,7 @@ export function findSalt(userId, desired) {
15
15
  desired.rarity,
16
16
  desired.eye,
17
17
  desired.hat,
18
+ String(desired.shiny ?? false),
18
19
  ], {
19
20
  encoding: 'utf-8',
20
21
  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
  }
package/lib/tui.mjs CHANGED
@@ -74,7 +74,8 @@ export async function runPreview(flags = {}) {
74
74
  const hat = rarity === 'common' ? 'none'
75
75
  : validateFlag('hat', flags.hat, HATS) ?? await selectHat(species, eye, rarity);
76
76
 
77
- const bones = { species, eye, hat, rarity, shiny: false, stats: {} };
77
+ const shiny = flags.shiny ?? false;
78
+ const bones = { species, eye, hat, rarity, shiny, stats: {} };
78
79
  showPet(bones, 'Preview');
79
80
  console.log(chalk.dim(' (Preview only - no changes made)\n'));
80
81
  }
@@ -203,10 +204,14 @@ export async function runInteractive(flags = {}) {
203
204
  const rarity = validateFlag('rarity', flags.rarity, RARITIES) ?? await selectRarity();
204
205
  const hat = rarity === 'common' ? 'none'
205
206
  : validateFlag('hat', flags.hat, HATS) ?? await selectHat(species, eye, rarity);
207
+ const shiny = flags.shiny ?? await confirm({
208
+ message: 'Shiny? (1% normally — search takes ~100x longer)',
209
+ default: false,
210
+ });
206
211
 
207
212
  // Final preview
208
- const desired = { species, eye, hat, rarity };
209
- const previewBones = { ...desired, shiny: false, stats: {} };
213
+ const desired = { species, eye, hat, rarity, shiny };
214
+ const previewBones = { ...desired, stats: {} };
210
215
  showPet(previewBones, 'Your selection');
211
216
 
212
217
  const proceed = flags.yes || await confirm({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "any-buddy",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Pick any Claude Code companion pet you want",
5
5
  "type": "module",
6
6
  "bin": {