any-buddy 1.0.4 → 1.0.6

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
@@ -202,14 +202,21 @@ When you choose to install the hook, it adds this to `~/.claude/settings.json`:
202
202
  "hooks": {
203
203
  "SessionStart": [
204
204
  {
205
- "type": "command",
206
- "command": "claude-code-any-buddy apply --silent"
205
+ "matcher": "",
206
+ "hooks": [
207
+ {
208
+ "type": "command",
209
+ "command": "claude-code-any-buddy apply --silent"
210
+ }
211
+ ]
207
212
  }
208
213
  ]
209
214
  }
210
215
  }
211
216
  ```
212
217
 
218
+ The hook is **optional and defaults to No** — you'll be asked during the interactive flow. If you prefer, just run `any-buddy apply` manually after Claude Code updates.
219
+
213
220
  On every Claude Code session start, this runs `apply --silent` which:
214
221
  1. Reads your saved salt from `~/.claude-code-any-buddy.json`
215
222
  2. Checks if the current binary already has the correct salt (fast `Buffer.indexOf`)
package/lib/config.mjs CHANGED
@@ -135,11 +135,17 @@ export function saveClaudeSettings(settings) {
135
135
 
136
136
  const HOOK_COMMAND = 'claude-code-any-buddy apply --silent';
137
137
 
138
+ // Claude Code hooks schema: { "SessionStart": [{ "matcher": "", "hooks": [{ "type": "command", "command": "..." }] }] }
139
+ function findHookEntry(matchers) {
140
+ if (!Array.isArray(matchers)) return null;
141
+ return matchers.find(m =>
142
+ Array.isArray(m.hooks) && m.hooks.some(h => h.command === HOOK_COMMAND)
143
+ ) ?? null;
144
+ }
145
+
138
146
  export function isHookInstalled() {
139
147
  const settings = getClaudeSettings();
140
- const hooks = settings.hooks?.SessionStart;
141
- if (!Array.isArray(hooks)) return false;
142
- return hooks.some(h => h.command === HOOK_COMMAND);
148
+ return findHookEntry(settings.hooks?.SessionStart) !== null;
143
149
  }
144
150
 
145
151
  export function installHook() {
@@ -147,10 +153,10 @@ export function installHook() {
147
153
  if (!settings.hooks) settings.hooks = {};
148
154
  if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = [];
149
155
 
150
- if (!settings.hooks.SessionStart.some(h => h.command === HOOK_COMMAND)) {
156
+ if (!findHookEntry(settings.hooks.SessionStart)) {
151
157
  settings.hooks.SessionStart.push({
152
- type: 'command',
153
- command: HOOK_COMMAND,
158
+ matcher: '',
159
+ hooks: [{ type: 'command', command: HOOK_COMMAND }],
154
160
  });
155
161
  }
156
162
 
@@ -161,7 +167,7 @@ export function removeHook() {
161
167
  const settings = getClaudeSettings();
162
168
  if (!settings.hooks?.SessionStart) return;
163
169
  settings.hooks.SessionStart = settings.hooks.SessionStart.filter(
164
- h => h.command !== HOOK_COMMAND
170
+ m => !Array.isArray(m.hooks) || !m.hooks.some(h => h.command === HOOK_COMMAND)
165
171
  );
166
172
  if (settings.hooks.SessionStart.length === 0) delete settings.hooks.SessionStart;
167
173
  if (Object.keys(settings.hooks).length === 0) delete settings.hooks;
@@ -109,7 +109,7 @@ while (true) {
109
109
  process.exit(0);
110
110
  }
111
111
 
112
- if (attempts % 500000 === 0) {
113
- process.stderr.write(`${(attempts / 1000).toFixed(0)}k seeds tried...\n`);
112
+ if (attempts % 25000 === 0) {
113
+ process.stderr.write(JSON.stringify({ attempts, elapsed: Date.now() - start }) + '\n');
114
114
  }
115
115
  }
package/lib/finder.mjs CHANGED
@@ -1,28 +1,105 @@
1
- import { execFileSync } from 'child_process';
1
+ import { spawn } from 'child_process';
2
2
  import { fileURLToPath } from 'url';
3
3
  import { dirname, join } from 'path';
4
+ import { RARITY_WEIGHTS } from './constants.mjs';
4
5
 
5
6
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
7
  const WORKER_PATH = join(__dirname, 'finder-worker.mjs');
7
8
 
9
+ // Calculate expected attempts based on probability of matching all desired traits.
10
+ export function estimateAttempts(desired) {
11
+ // Species: 1/18
12
+ let p = 1 / 18;
13
+
14
+ // Rarity: weight / 100
15
+ p *= RARITY_WEIGHTS[desired.rarity] / 100;
16
+
17
+ // Eye: 1/6
18
+ p *= 1 / 6;
19
+
20
+ // Hat: common is always 'none' (guaranteed), otherwise 1/8
21
+ if (desired.rarity !== 'common') {
22
+ p *= 1 / 8;
23
+ }
24
+
25
+ // Shiny: 1/100
26
+ if (desired.shiny) {
27
+ p *= 0.01;
28
+ }
29
+
30
+ // Peak stat: 1/5
31
+ if (desired.peak) {
32
+ p *= 1 / 5;
33
+ }
34
+
35
+ // Dump stat: ~1/4 (picked from remaining 4, but rerolls on collision)
36
+ if (desired.dump) {
37
+ p *= 1 / 4;
38
+ }
39
+
40
+ // Expected attempts = 1/p (geometric distribution)
41
+ return Math.round(1 / p);
42
+ }
43
+
8
44
  // 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
- String(desired.shiny ?? false),
19
- desired.peak ?? 'any',
20
- desired.dump ?? 'any',
21
- ], {
22
- encoding: 'utf-8',
23
- timeout: 120000, // 2 minute timeout
24
- stdio: ['pipe', 'pipe', 'inherit'], // stderr passes through for progress
25
- });
45
+ // Calls onProgress with { attempts, elapsed, rate, expected, pct, eta } on each tick.
46
+ // Returns a promise resolving to { salt, attempts, elapsed }.
47
+ export function findSalt(userId, desired, { onProgress } = {}) {
48
+ const expected = estimateAttempts(desired);
49
+
50
+ return new Promise((resolve, reject) => {
51
+ const child = spawn('bun', [
52
+ WORKER_PATH,
53
+ userId,
54
+ desired.species,
55
+ desired.rarity,
56
+ desired.eye,
57
+ desired.hat,
58
+ String(desired.shiny ?? false),
59
+ desired.peak ?? 'any',
60
+ desired.dump ?? 'any',
61
+ ], {
62
+ stdio: ['pipe', 'pipe', 'pipe'],
63
+ timeout: 300000,
64
+ });
65
+
66
+ let stdout = '';
26
67
 
27
- return JSON.parse(result.trim());
68
+ child.stdout.on('data', (chunk) => {
69
+ stdout += chunk.toString();
70
+ });
71
+
72
+ child.stderr.on('data', (chunk) => {
73
+ if (!onProgress) return;
74
+ // Worker writes JSON progress lines to stderr
75
+ const lines = chunk.toString().split('\n').filter(Boolean);
76
+ for (const line of lines) {
77
+ try {
78
+ const progress = JSON.parse(line);
79
+ const rate = progress.attempts / (progress.elapsed / 1000); // attempts/sec
80
+ const pct = Math.min(100, (progress.attempts / expected) * 100);
81
+ // ETA based on expected remaining attempts at current rate
82
+ const remaining = Math.max(0, expected - progress.attempts);
83
+ const eta = rate > 0 ? remaining / rate : Infinity;
84
+ onProgress({ ...progress, rate, expected, pct, eta });
85
+ } catch {
86
+ // Not JSON — ignore
87
+ }
88
+ }
89
+ });
90
+
91
+ child.on('close', (code) => {
92
+ if (code === 0 && stdout.trim()) {
93
+ try {
94
+ resolve(JSON.parse(stdout.trim()));
95
+ } catch (err) {
96
+ reject(new Error(`Failed to parse finder result: ${stdout.trim()}`));
97
+ }
98
+ } else {
99
+ reject(new Error(`Salt finder exited with code ${code}`));
100
+ }
101
+ });
102
+
103
+ child.on('error', reject);
104
+ });
28
105
  }
package/lib/tui.mjs CHANGED
@@ -3,11 +3,23 @@ import chalk from 'chalk';
3
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
- import { findSalt } from './finder.mjs';
6
+ import { findSalt, estimateAttempts } from './finder.mjs';
7
7
  import { findClaudeBinary, getCurrentSalt, patchBinary, verifySalt, restoreBinary, isClaudeRunning } from './patcher.mjs';
8
8
  import { getClaudeUserId, savePetConfig, loadPetConfig, isHookInstalled, installHook, removeHook, getCompanionName, renameCompanion, getCompanionPersonality, setCompanionPersonality, deleteCompanion } from './config.mjs';
9
9
  import { DEFAULT_PERSONALITIES } from './personalities.mjs';
10
10
 
11
+ function progressBar(pct, width) {
12
+ const filled = Math.min(width, Math.round((pct / 100) * width));
13
+ const empty = width - filled;
14
+ return chalk.cyan('█'.repeat(filled)) + chalk.dim('░'.repeat(empty));
15
+ }
16
+
17
+ function formatCount(n) {
18
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
19
+ if (n >= 1e3) return `${(n / 1e3).toFixed(0)}k`;
20
+ return String(n);
21
+ }
22
+
11
23
  const RARITY_CHALK = {
12
24
  common: chalk.gray,
13
25
  uncommon: chalk.green,
@@ -272,15 +284,23 @@ export async function runInteractive(flags = {}) {
272
284
  }
273
285
 
274
286
  // ─── Find salt ───
275
- console.log(chalk.dim('\n Searching for matching salt...'));
276
-
277
- const result = findSalt(userId, desired, {
278
- onProgress: (attempts, ms) => {
279
- process.stdout.write(chalk.dim(`\r Tried ${(attempts / 1000).toFixed(0)}k seeds (${(ms / 1000).toFixed(1)}s)...`));
287
+ const expected = estimateAttempts(desired);
288
+ console.log(chalk.dim(`\n Searching (~${formatCount(expected)} expected attempts)...`));
289
+
290
+ const result = await findSalt(userId, desired, {
291
+ onProgress: ({ attempts, elapsed, rate, pct, eta }) => {
292
+ const bar = progressBar(pct, 20);
293
+ const etaStr = eta < 1 ? '<1s' : eta < 60 ? `${Math.ceil(eta)}s` : `${(eta / 60).toFixed(1)}m`;
294
+ const rateStr = rate > 1e6 ? `${(rate / 1e6).toFixed(1)}M/s` : `${(rate / 1e3).toFixed(0)}k/s`;
295
+ process.stdout.write(
296
+ `\r ${bar} ${chalk.dim(`${Math.min(99, Math.floor(pct))}%`)} ${chalk.cyan(formatCount(attempts))} tried ${chalk.dim(rateStr)} ${chalk.dim(`ETA ${etaStr}`)} `
297
+ );
280
298
  },
281
299
  });
282
300
 
283
- console.log(chalk.green(`\r Found salt "${result.salt}" in ${result.attempts.toLocaleString()} attempts (${(result.elapsed / 1000).toFixed(1)}s)`));
301
+ // Clear the progress line and show result
302
+ process.stdout.write('\r' + ' '.repeat(80) + '\r');
303
+ console.log(chalk.green(` Found in ${result.attempts.toLocaleString()} attempts (${(result.elapsed / 1000).toFixed(1)}s)`));
284
304
  const foundBones = roll(userId, result.salt).bones;
285
305
  showPet(foundBones, 'Your new pet');
286
306
 
@@ -367,14 +387,20 @@ export async function runInteractive(flags = {}) {
367
387
 
368
388
  // ─── Hook setup ───
369
389
  if (!isHookInstalled() && !flags.noHook) {
370
- const setupHook = flags.yes || await confirm({
371
- message: 'Install SessionStart hook to auto-re-apply after updates?',
372
- default: true,
390
+ console.log(chalk.dim('\n Optional: install a SessionStart hook to auto-re-apply after Claude Code updates.'));
391
+ console.log(chalk.yellow(' Note: this modifies ~/.claude/settings.json. If you have issues, run:'));
392
+ console.log(chalk.yellow(' any-buddy restore'));
393
+
394
+ const setupHook = await confirm({
395
+ message: 'Install auto-patch hook?',
396
+ default: false,
373
397
  });
374
398
 
375
399
  if (setupHook) {
376
400
  installHook();
377
401
  console.log(chalk.green(' Hook installed in ~/.claude/settings.json'));
402
+ } else {
403
+ console.log(chalk.dim(' No hook installed. Run `any-buddy apply` manually after updates.'));
378
404
  }
379
405
  } else if (isHookInstalled()) {
380
406
  console.log(chalk.dim(' SessionStart hook already installed.'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "any-buddy",
3
- "version": "1.0.4",
3
+ "version": "1.0.6",
4
4
  "description": "Pick any Claude Code companion pet you want",
5
5
  "type": "module",
6
6
  "bin": {