@rulecatch/ai-pooler 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js ADDED
@@ -0,0 +1,1338 @@
1
+ #!/usr/bin/env node
2
+ import * as fs from 'fs';
3
+ import { existsSync, unlinkSync, rmSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, chmodSync } from 'fs';
4
+ import * as os from 'os';
5
+ import { homedir } from 'os';
6
+ import * as path from 'path';
7
+ import { join, dirname } from 'path';
8
+ import { randomBytes, pbkdf2Sync, createDecipheriv } from 'crypto';
9
+ import { createInterface } from 'readline';
10
+ import { execSync } from 'child_process';
11
+
12
+ /**
13
+ * Rulecatch AI Pooler initialization
14
+ * Interactive setup: API key validation, encryption, hook installation
15
+ */
16
+ // Paths
17
+ const CLAUDE_DIR$1 = join(homedir(), '.claude');
18
+ const HOOKS_DIR = join(CLAUDE_DIR$1, 'hooks');
19
+ const RULECATCH_DIR$2 = join(CLAUDE_DIR$1, 'rulecatch');
20
+ const CONFIG_PATH$1 = join(RULECATCH_DIR$2, 'config.json');
21
+ const BUFFER_DIR$1 = join(RULECATCH_DIR$2, 'buffer');
22
+ const SETTINGS_PATH$1 = join(CLAUDE_DIR$1, 'settings.json');
23
+ const HOOK_SCRIPT_DEST = join(HOOKS_DIR, 'rulecatch-track.sh');
24
+ const FLUSH_SCRIPT_DEST = join(HOOKS_DIR, 'rulecatch-flush.js');
25
+ const MCP_SERVER_DEST = join(RULECATCH_DIR$2, 'mcp-server.js');
26
+ // Colors
27
+ const green$1 = (s) => `\x1b[32m${s}\x1b[0m`;
28
+ const red$1 = (s) => `\x1b[31m${s}\x1b[0m`;
29
+ const yellow$1 = (s) => `\x1b[33m${s}\x1b[0m`;
30
+ const dim$1 = (s) => `\x1b[2m${s}\x1b[0m`;
31
+ function prompt(question) {
32
+ return new Promise((resolve) => {
33
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
34
+ rl.question(question, (answer) => {
35
+ rl.close();
36
+ resolve(answer.trim());
37
+ });
38
+ });
39
+ }
40
+ function promptPassword(promptText) {
41
+ return new Promise((resolve) => {
42
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
43
+ if (process.stdin.isTTY) {
44
+ process.stdin.setRawMode?.(true);
45
+ }
46
+ process.stdout.write(promptText);
47
+ let password = '';
48
+ const onData = (char) => {
49
+ const c = char.toString();
50
+ if (c === '\n' || c === '\r') {
51
+ process.stdin.removeListener('data', onData);
52
+ if (process.stdin.isTTY) {
53
+ process.stdin.setRawMode?.(false);
54
+ }
55
+ process.stdout.write('\n');
56
+ rl.close();
57
+ resolve(password);
58
+ }
59
+ else if (c === '\u0003') {
60
+ process.exit(0);
61
+ }
62
+ else if (c === '\u007F' || c === '\b') {
63
+ if (password.length > 0) {
64
+ password = password.slice(0, -1);
65
+ process.stdout.write('\b \b');
66
+ }
67
+ }
68
+ else {
69
+ password += c;
70
+ process.stdout.write('*');
71
+ }
72
+ };
73
+ process.stdin.on('data', onData);
74
+ process.stdin.resume();
75
+ });
76
+ }
77
+ async function validateApiKey(apiKey) {
78
+ try {
79
+ const response = await fetch('https://api.rulecatch.ai/api/v1/ai/validate-key', {
80
+ method: 'POST',
81
+ headers: {
82
+ 'Content-Type': 'application/json',
83
+ 'Authorization': `Bearer ${apiKey}`,
84
+ },
85
+ body: JSON.stringify({ apiKey }),
86
+ });
87
+ if (!response.ok) {
88
+ return { valid: false };
89
+ }
90
+ return await response.json();
91
+ }
92
+ catch {
93
+ // API unreachable - allow setup anyway (offline mode)
94
+ return { valid: true, region: 'us', plan: 'unknown', daysRemaining: -1 };
95
+ }
96
+ }
97
+ function findFile(filename) {
98
+ const base = dirname(new URL(import.meta.url).pathname);
99
+ const candidates = [
100
+ join(base, '..', 'templates', filename),
101
+ join(base, '..', '..', 'templates', filename),
102
+ join(base, 'templates', filename),
103
+ ];
104
+ for (const c of candidates) {
105
+ if (existsSync(c))
106
+ return c;
107
+ }
108
+ return null;
109
+ }
110
+ function findFlushScript() {
111
+ const base = dirname(new URL(import.meta.url).pathname);
112
+ const candidates = [
113
+ join(base, 'flush.js'),
114
+ join(base, '..', 'dist', 'flush.js'),
115
+ ];
116
+ for (const c of candidates) {
117
+ if (existsSync(c))
118
+ return c;
119
+ }
120
+ return null;
121
+ }
122
+ function findMcpServer() {
123
+ const base = dirname(new URL(import.meta.url).pathname);
124
+ const candidates = [
125
+ // Built MCP server (from packages/mcp-server/dist/)
126
+ join(base, '..', '..', 'mcp-server', 'dist', 'index.js'),
127
+ // Already installed
128
+ join(RULECATCH_DIR$2, 'mcp-server.js'),
129
+ ];
130
+ for (const c of candidates) {
131
+ if (existsSync(c))
132
+ return c;
133
+ }
134
+ return null;
135
+ }
136
+ async function init(options = {}) {
137
+ console.log('\nRulecatch AI Analytics Setup');
138
+ console.log('----------------------------\n');
139
+ // 0. Check if user has an account
140
+ if (!options.apiKey) {
141
+ const hasAccount = await prompt('Do you have a RuleCatch account?\n' +
142
+ ' 1) Yes, I have an API key\n' +
143
+ ' 2) No, I need to create an account\n' +
144
+ '> ');
145
+ if (hasAccount === '2' || hasAccount.toLowerCase().startsWith('n')) {
146
+ console.log('\nOpening RuleCatch signup in your browser...');
147
+ console.log('After creating an account, run this command again with your API key.\n');
148
+ // Open browser to signup page
149
+ const signupUrl = 'https://dashboard.rulecatch.ai/signup';
150
+ try {
151
+ const { exec } = await import('child_process');
152
+ const platform = process.platform;
153
+ if (platform === 'darwin') {
154
+ exec(`open "${signupUrl}"`);
155
+ }
156
+ else if (platform === 'win32') {
157
+ exec(`start "" "${signupUrl}"`);
158
+ }
159
+ else {
160
+ // Linux and others
161
+ exec(`xdg-open "${signupUrl}" 2>/dev/null || sensible-browser "${signupUrl}" 2>/dev/null || x-www-browser "${signupUrl}" 2>/dev/null || echo "Please open: ${signupUrl}"`);
162
+ }
163
+ console.log(`${dim$1('If browser didn\'t open, visit:')} ${signupUrl}\n`);
164
+ }
165
+ catch {
166
+ console.log(`Visit: ${signupUrl}\n`);
167
+ }
168
+ process.exit(0);
169
+ }
170
+ }
171
+ // 1. Get API key
172
+ let apiKey = options.apiKey;
173
+ if (!apiKey) {
174
+ apiKey = await prompt('Enter your API key (get one at https://dashboard.rulecatch.ai):\n> ');
175
+ }
176
+ if (!apiKey || !apiKey.startsWith('dc_')) {
177
+ console.log(red$1('\nInvalid API key. Keys start with "dc_".'));
178
+ process.exit(1);
179
+ }
180
+ // 2. Validate API key
181
+ process.stdout.write('Validating API key... ');
182
+ const validation = await validateApiKey(apiKey);
183
+ if (!validation.valid) {
184
+ console.log(red$1('Invalid'));
185
+ console.log(red$1('\nAPI key is not valid. Get a key at https://dashboard.rulecatch.ai\n'));
186
+ process.exit(1);
187
+ }
188
+ const region = options.region || validation.region || 'us';
189
+ const planInfo = validation.plan || 'unknown';
190
+ const daysInfo = validation.daysRemaining && validation.daysRemaining > 0
191
+ ? ` - ${validation.daysRemaining} days remaining`
192
+ : '';
193
+ console.log(green$1(`Valid (${planInfo}${daysInfo})`));
194
+ console.log(`Region: ${region === 'eu' ? 'EU (Frankfurt)' : 'US (Virginia)'} - set in your dashboard account\n`);
195
+ // 3. Get encryption password
196
+ let encryptionPassword;
197
+ let autoGeneratedKey = false;
198
+ if (options.encryptionKey) {
199
+ // Passed via --encryption-key flag (e.g. easy AI install mode)
200
+ encryptionPassword = options.encryptionKey;
201
+ if (encryptionPassword.length < 8) {
202
+ console.log(red$1('\nEncryption key must be at least 8 characters.\n'));
203
+ process.exit(1);
204
+ }
205
+ console.log(green$1('+ Encryption key set from --encryption-key flag'));
206
+ console.log(dim$1("Use this same key in the dashboard to decrypt your personal data.\n"));
207
+ }
208
+ else if (process.stdin.isTTY) {
209
+ encryptionPassword = await promptPassword('Enter an encryption password (used locally to encrypt PII before sending):\n> ');
210
+ if (encryptionPassword.length < 8) {
211
+ console.log(red$1('\nPassword must be at least 8 characters.\n'));
212
+ process.exit(1);
213
+ }
214
+ console.log(dim$1("\nYou'll need this same password in the dashboard to decrypt your personal data.\n"));
215
+ }
216
+ else {
217
+ encryptionPassword = randomBytes(32).toString('base64');
218
+ autoGeneratedKey = true;
219
+ console.log(dim$1('Non-interactive mode: auto-generated encryption password.'));
220
+ console.log(dim$1('Retrieve it with: npx @rulecatch/ai-pooler config --show-key\n'));
221
+ }
222
+ // 4. Create config
223
+ const salt = randomBytes(32).toString('base64');
224
+ const config = {
225
+ apiKey,
226
+ region,
227
+ batchSize: options.batchSize || 20,
228
+ salt,
229
+ encryptionKey: encryptionPassword,
230
+ ...(autoGeneratedKey && { autoGeneratedKey: true }),
231
+ };
232
+ // 5. Create directory structure
233
+ mkdirSync(RULECATCH_DIR$2, { recursive: true });
234
+ mkdirSync(BUFFER_DIR$1, { recursive: true });
235
+ mkdirSync(HOOKS_DIR, { recursive: true });
236
+ // 6. Write config
237
+ writeFileSync(CONFIG_PATH$1, JSON.stringify(config, null, 2), { mode: 0o600 });
238
+ console.log(green$1('+ Config saved to ~/.claude/rulecatch/config.json'));
239
+ // 7. Copy hook script from template
240
+ const hookTemplate = findFile('rulecatch-track.sh');
241
+ if (hookTemplate) {
242
+ copyFileSync(hookTemplate, HOOK_SCRIPT_DEST);
243
+ chmodSync(HOOK_SCRIPT_DEST, 0o755);
244
+ console.log(green$1('+ Hook script installed to ~/.claude/hooks/rulecatch-track.sh'));
245
+ }
246
+ else {
247
+ console.log(yellow$1('! Hook script template not found. You may need to reinstall.'));
248
+ }
249
+ // 8. Copy flush script
250
+ const flushSource = findFlushScript();
251
+ if (flushSource) {
252
+ copyFileSync(flushSource, FLUSH_SCRIPT_DEST);
253
+ chmodSync(FLUSH_SCRIPT_DEST, 0o755);
254
+ console.log(green$1('+ Flush script installed to ~/.claude/hooks/rulecatch-flush.js'));
255
+ }
256
+ else {
257
+ console.log(yellow$1('! Flush script not found. Run `pnpm build` first if developing locally.'));
258
+ }
259
+ // 9. Copy MCP server
260
+ const mcpServerSource = findMcpServer();
261
+ if (mcpServerSource) {
262
+ copyFileSync(mcpServerSource, MCP_SERVER_DEST);
263
+ console.log(green$1('+ MCP server installed to ~/.claude/rulecatch/mcp-server.js'));
264
+ }
265
+ else {
266
+ console.log(yellow$1('! MCP server not found. MCP tools will not be available.'));
267
+ }
268
+ // 10. Register hooks + MCP server in settings.json
269
+ registerHooks(!!mcpServerSource);
270
+ console.log(green$1('+ Hooks registered in ~/.claude/settings.json'));
271
+ if (mcpServerSource) {
272
+ console.log(green$1('+ MCP server registered in ~/.claude/settings.json'));
273
+ }
274
+ console.log(green$1('\n+ Setup complete!') + ' Hooks activate on your next Claude Code session.');
275
+ console.log(' If Claude is running now, type /exit and reopen to activate.\n');
276
+ }
277
+ function registerHooks(includeMcpServer = false) {
278
+ let settings = {};
279
+ if (existsSync(SETTINGS_PATH$1)) {
280
+ try {
281
+ settings = JSON.parse(readFileSync(SETTINGS_PATH$1, 'utf-8'));
282
+ }
283
+ catch {
284
+ // Start fresh
285
+ }
286
+ }
287
+ const hookConfig = {
288
+ hooks: [
289
+ {
290
+ type: 'command',
291
+ command: HOOK_SCRIPT_DEST,
292
+ timeout: 10,
293
+ },
294
+ ],
295
+ };
296
+ settings.hooks = {
297
+ SessionStart: [{ ...hookConfig }],
298
+ SessionEnd: [{ ...hookConfig }],
299
+ PostToolUse: [{ matcher: '*', ...hookConfig }],
300
+ PostToolUseFailure: [{ matcher: '*', ...hookConfig }],
301
+ Stop: [{ ...hookConfig }],
302
+ };
303
+ // Register MCP server so Claude Code can use Rulecatch tools
304
+ if (includeMcpServer) {
305
+ const mcpServers = (settings.mcpServers || {});
306
+ mcpServers.rulecatch = {
307
+ command: 'node',
308
+ args: [MCP_SERVER_DEST],
309
+ };
310
+ settings.mcpServers = mcpServers;
311
+ }
312
+ // Remove old RULECATCH_*/DWELLCOUNT_* env vars (cleanup)
313
+ if (settings.env && typeof settings.env === 'object') {
314
+ const env = settings.env;
315
+ for (const key of Object.keys(env)) {
316
+ if (key.startsWith('RULECATCH_') || key.startsWith('DWELLCOUNT_')) {
317
+ delete env[key];
318
+ }
319
+ }
320
+ if (Object.keys(env).length === 0) {
321
+ delete settings.env;
322
+ }
323
+ }
324
+ writeFileSync(SETTINGS_PATH$1, JSON.stringify(settings, null, 2));
325
+ }
326
+ function uninstall() {
327
+ console.log('\nRemoving Rulecatch tracking...\n');
328
+ // Remove hook script
329
+ if (existsSync(HOOK_SCRIPT_DEST)) {
330
+ unlinkSync(HOOK_SCRIPT_DEST);
331
+ console.log(green$1('+ Removed hook script'));
332
+ }
333
+ // Remove flush script
334
+ if (existsSync(FLUSH_SCRIPT_DEST)) {
335
+ unlinkSync(FLUSH_SCRIPT_DEST);
336
+ console.log(green$1('+ Removed flush script'));
337
+ }
338
+ // Remove old hook script name
339
+ const oldHookPath = join(HOOKS_DIR, 'dwellcount-track.sh');
340
+ if (existsSync(oldHookPath)) {
341
+ unlinkSync(oldHookPath);
342
+ console.log(green$1('+ Removed old hook script (dwellcount-track.sh)'));
343
+ }
344
+ // Remove entire rulecatch directory
345
+ if (existsSync(RULECATCH_DIR$2)) {
346
+ rmSync(RULECATCH_DIR$2, { recursive: true, force: true });
347
+ console.log(green$1('+ Removed ~/.claude/rulecatch/ directory'));
348
+ }
349
+ // Remove old config files
350
+ for (const oldFile of ['rulecatch.json', 'rulecatch-privacy.json', '.rulecatch-key']) {
351
+ const p = join(CLAUDE_DIR$1, oldFile);
352
+ if (existsSync(p)) {
353
+ unlinkSync(p);
354
+ console.log(green$1(`+ Removed old file: ${oldFile}`));
355
+ }
356
+ }
357
+ // Clean settings.json
358
+ if (existsSync(SETTINGS_PATH$1)) {
359
+ try {
360
+ const settings = JSON.parse(readFileSync(SETTINGS_PATH$1, 'utf-8'));
361
+ delete settings.hooks;
362
+ if (settings.env) {
363
+ for (const key of Object.keys(settings.env)) {
364
+ if (key.startsWith('RULECATCH_') || key.startsWith('DWELLCOUNT_')) {
365
+ delete settings.env[key];
366
+ }
367
+ }
368
+ if (Object.keys(settings.env).length === 0) {
369
+ delete settings.env;
370
+ }
371
+ }
372
+ writeFileSync(SETTINGS_PATH$1, JSON.stringify(settings, null, 2));
373
+ console.log(green$1('+ Cleaned up settings.json'));
374
+ }
375
+ catch {
376
+ console.log(yellow$1('! Could not update settings.json'));
377
+ }
378
+ }
379
+ console.log(green$1('\n+ Rulecatch tracking removed.\n'));
380
+ }
381
+
382
+ /**
383
+ * Backpressure & Flow Control for Rulecatch AI Pooler
384
+ *
385
+ * Implements smart throttling that:
386
+ * 1. Asks server "how much can I send?" before flushing
387
+ * 2. Respects rate limits (429) with exponential backoff
388
+ * 3. Gradually drains buffer when server recovers
389
+ * 4. Prevents thundering herd after outages
390
+ */
391
+ // Paths
392
+ const RULECATCH_DIR$1 = join(homedir(), '.claude', 'rulecatch');
393
+ const BACKPRESSURE_STATE_FILE$1 = join(RULECATCH_DIR$1, '.backpressure-state');
394
+ join(RULECATCH_DIR$1, 'flush.log');
395
+ /**
396
+ * Load persisted backpressure state
397
+ */
398
+ function loadState() {
399
+ try {
400
+ if (existsSync(BACKPRESSURE_STATE_FILE$1)) {
401
+ const content = readFileSync(BACKPRESSURE_STATE_FILE$1, 'utf-8');
402
+ return JSON.parse(content);
403
+ }
404
+ }
405
+ catch {
406
+ // Corrupt state, start fresh
407
+ }
408
+ return {
409
+ backoffLevel: 0,
410
+ nextAttemptAfter: 0,
411
+ lastCapacity: null,
412
+ consecutiveFailures: 0,
413
+ lastSuccessTime: 0,
414
+ pendingEventCount: 0,
415
+ };
416
+ }
417
+
418
+ /**
419
+ * Rulecatch AI Pooler - Zero Token Overhead Tracking
420
+ *
421
+ * Commands:
422
+ * npx @rulecatch/ai-pooler init - Interactive setup
423
+ * npx @rulecatch/ai-pooler uninstall - Remove everything
424
+ * npx @rulecatch/ai-pooler status - Check setup and buffer
425
+ * npx @rulecatch/ai-pooler flush - Force flush buffered events
426
+ * npx @rulecatch/ai-pooler logs - Show flush activity logs
427
+ * npx @rulecatch/ai-pooler config - Update configuration
428
+ * npx @rulecatch/ai-pooler monitor - Live event stream (alias: live)
429
+ * -v / --verbose - Show file paths, git context
430
+ * -vv / --debug - Full JSON event dump
431
+ * npx @rulecatch/ai-pooler backpressure - Show backpressure status
432
+ */
433
+ const args = process.argv.slice(2);
434
+ const command = args[0];
435
+ // Colors
436
+ const green = (s) => `\x1b[32m${s}\x1b[0m`;
437
+ const red = (s) => `\x1b[31m${s}\x1b[0m`;
438
+ const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
439
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
440
+ // Paths
441
+ const CLAUDE_DIR = path.join(os.homedir(), '.claude');
442
+ const RULECATCH_DIR = path.join(CLAUDE_DIR, 'rulecatch');
443
+ const CONFIG_PATH = path.join(RULECATCH_DIR, 'config.json');
444
+ const BUFFER_DIR = path.join(RULECATCH_DIR, 'buffer');
445
+ const LOG_FILE = path.join(RULECATCH_DIR, 'flush.log');
446
+ const HOOK_LOG = '/tmp/rulecatch-hook.log';
447
+ const SESSION_FILE = path.join(RULECATCH_DIR, '.session');
448
+ const HOOK_SCRIPT = path.join(CLAUDE_DIR, 'hooks', 'rulecatch-track.sh');
449
+ const FLUSH_SCRIPT = path.join(CLAUDE_DIR, 'hooks', 'rulecatch-flush.js');
450
+ const SETTINGS_PATH = path.join(CLAUDE_DIR, 'settings.json');
451
+ const BACKPRESSURE_STATE_FILE = path.join(RULECATCH_DIR, '.backpressure-state');
452
+ const PAUSED_FILE = path.join(RULECATCH_DIR, '.paused');
453
+ function parseArgs() {
454
+ const result = {};
455
+ for (const arg of args) {
456
+ if (arg.startsWith('--')) {
457
+ const [key, value] = arg.slice(2).split('=');
458
+ if (key && value) {
459
+ result[key.replace(/-/g, '')] = value;
460
+ }
461
+ }
462
+ }
463
+ return result;
464
+ }
465
+ function getBufferCount() {
466
+ try {
467
+ if (!fs.existsSync(BUFFER_DIR))
468
+ return 0;
469
+ return fs.readdirSync(BUFFER_DIR).filter(f => f.endsWith('.json')).length;
470
+ }
471
+ catch {
472
+ return 0;
473
+ }
474
+ }
475
+ async function main() {
476
+ switch (command) {
477
+ case 'init': {
478
+ const flags = parseArgs();
479
+ await init({
480
+ apiKey: flags.apikey || flags.key,
481
+ region: flags.region || undefined,
482
+ batchSize: flags.batchsize ? parseInt(flags.batchsize, 10) : undefined,
483
+ encryptionKey: flags.encryptionkey,
484
+ });
485
+ break;
486
+ }
487
+ case 'uninstall':
488
+ case 'remove': {
489
+ uninstall();
490
+ break;
491
+ }
492
+ case 'status': {
493
+ console.log('\nRulecatch Status\n');
494
+ // Check if paused due to subscription
495
+ if (fs.existsSync(PAUSED_FILE)) {
496
+ try {
497
+ const pausedInfo = JSON.parse(fs.readFileSync(PAUSED_FILE, 'utf-8'));
498
+ console.log(`Collection: ${red('⏸ PAUSED')}`);
499
+ console.log(` Reason: ${dim(pausedInfo.reason || 'subscription_expired')}`);
500
+ console.log(` Since: ${dim(pausedInfo.pausedAt || 'unknown')}`);
501
+ console.log(` ${yellow('Run `npx @rulecatch/ai-pooler reactivate` after subscribing')}\n`);
502
+ }
503
+ catch {
504
+ console.log(`Collection: ${red('⏸ PAUSED')} (corrupt file)\n`);
505
+ }
506
+ }
507
+ else {
508
+ console.log(`Collection: ${green('+ Active')}\n`);
509
+ }
510
+ // Check config
511
+ if (fs.existsSync(CONFIG_PATH)) {
512
+ try {
513
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
514
+ console.log(`Config: ${green('+ Found')}`);
515
+ console.log(` API key: ${dim(config.apiKey?.slice(0, 8) + '...')}`);
516
+ console.log(` Region: ${dim(config.region === 'eu' ? 'EU (Frankfurt)' : 'US (Virginia)')}`);
517
+ console.log(` Batch size: ${dim(String(config.batchSize || 20))}`);
518
+ console.log(` Encrypted: ${config.encryptionKey ? green('Yes') : yellow('No')}`);
519
+ }
520
+ catch {
521
+ console.log(`Config: ${red('x Parse error')}`);
522
+ }
523
+ }
524
+ else {
525
+ console.log(`Config: ${red('x Not found')}`);
526
+ console.log(dim(' Run `npx @rulecatch/ai-pooler init` to set up.\n'));
527
+ break;
528
+ }
529
+ // Check hook script
530
+ console.log(`\nHook script: ${fs.existsSync(HOOK_SCRIPT) ? green('+ Installed') : red('x Not found')}`);
531
+ console.log(`Flush script: ${fs.existsSync(FLUSH_SCRIPT) ? green('+ Installed') : red('x Not found')}`);
532
+ // Check hooks in settings.json
533
+ let hooksConfigured = false;
534
+ if (fs.existsSync(SETTINGS_PATH)) {
535
+ try {
536
+ const settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8'));
537
+ hooksConfigured = !!settings.hooks?.PostToolUse;
538
+ }
539
+ catch { /* ignore */ }
540
+ }
541
+ console.log(`Hooks config: ${hooksConfigured ? green('+ Registered') : red('x Not registered')}`);
542
+ // Buffer status
543
+ const bufferCount = getBufferCount();
544
+ console.log(`\nBuffer: ${bufferCount} events pending`);
545
+ // Session token
546
+ if (fs.existsSync(SESSION_FILE)) {
547
+ try {
548
+ const session = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf-8'));
549
+ const expiresIn = Math.max(0, Math.round((session.expiry - Date.now()) / 1000 / 60));
550
+ console.log(`Session token: ${green('+ Valid')} (expires in ${expiresIn}m)`);
551
+ }
552
+ catch {
553
+ console.log(`Session token: ${yellow('o Expired/corrupt')}`);
554
+ }
555
+ }
556
+ else {
557
+ console.log(`Session token: ${dim('Not acquired yet')}`);
558
+ }
559
+ // Hook log
560
+ if (fs.existsSync(HOOK_LOG)) {
561
+ const lines = fs.readFileSync(HOOK_LOG, 'utf-8').trim().split('\n');
562
+ const lastLine = lines[lines.length - 1] || '';
563
+ console.log(`\nHook log: ${green('+ Active')} (${lines.length} entries)`);
564
+ console.log(`Last activity: ${dim(lastLine)}`);
565
+ }
566
+ else {
567
+ console.log(`\nHook log: ${yellow('o No activity yet')}`);
568
+ }
569
+ // Backpressure status (brief summary)
570
+ const bpState = loadState();
571
+ if (bpState.consecutiveFailures > 0 || bpState.backoffLevel > 0) {
572
+ console.log(`\nBackpressure: ${yellow('o Active')}`);
573
+ if (bpState.consecutiveFailures > 0) {
574
+ console.log(` Failures: ${bpState.consecutiveFailures} consecutive`);
575
+ }
576
+ if (bpState.backoffLevel > 0) {
577
+ console.log(` Backoff: Level ${bpState.backoffLevel}/10`);
578
+ }
579
+ if (bpState.nextAttemptAfter > Date.now()) {
580
+ const waitSec = Math.ceil((bpState.nextAttemptAfter - Date.now()) / 1000);
581
+ console.log(` Next retry: ${waitSec}s`);
582
+ }
583
+ console.log(dim(' Run `npx @rulecatch/ai-pooler backpressure` for details'));
584
+ }
585
+ else {
586
+ console.log(`\nBackpressure: ${green('+ Healthy')}`);
587
+ }
588
+ console.log('');
589
+ break;
590
+ }
591
+ case 'flush': {
592
+ console.log('\nFlushing buffered events...\n');
593
+ const bufferCount = getBufferCount();
594
+ if (bufferCount === 0) {
595
+ console.log(yellow('No events in buffer.\n'));
596
+ break;
597
+ }
598
+ console.log(`${bufferCount} events in buffer.`);
599
+ if (fs.existsSync(FLUSH_SCRIPT)) {
600
+ try {
601
+ execSync(`node "${FLUSH_SCRIPT}" --force`, { stdio: 'inherit' });
602
+ const remaining = getBufferCount();
603
+ if (remaining === 0) {
604
+ console.log(green('\n+ All events flushed.\n'));
605
+ }
606
+ else {
607
+ console.log(yellow(`\n${remaining} events remaining (API may be unreachable).\n`));
608
+ }
609
+ }
610
+ catch {
611
+ console.log(red('\nFlush failed. Check logs with `npx @rulecatch/ai-pooler logs`.\n'));
612
+ }
613
+ }
614
+ else {
615
+ console.log(red('Flush script not found. Run `npx @rulecatch/ai-pooler init` to reinstall.\n'));
616
+ }
617
+ break;
618
+ }
619
+ case 'logs': {
620
+ const flags = parseArgs();
621
+ const lineCount = parseInt(flags.lines || '30', 10);
622
+ const source = flags.source || 'flush';
623
+ const logPath = source === 'hook' ? HOOK_LOG : LOG_FILE;
624
+ const label = source === 'hook' ? 'Hook' : 'Flush';
625
+ console.log(`\nRulecatch ${label} Logs (last ${lineCount} entries)\n`);
626
+ if (fs.existsSync(logPath)) {
627
+ const content = fs.readFileSync(logPath, 'utf-8').trim().split('\n');
628
+ const recent = content.slice(-lineCount);
629
+ recent.forEach((line) => console.log(dim(line)));
630
+ console.log(`\n${dim(`Total entries: ${content.length}`)}`);
631
+ console.log(`${dim(`Log file: ${logPath}`)}\n`);
632
+ }
633
+ else {
634
+ console.log(yellow(`No ${label.toLowerCase()} log found.`));
635
+ console.log('No events have been processed yet.\n');
636
+ }
637
+ break;
638
+ }
639
+ case 'config': {
640
+ const flags = parseArgs();
641
+ if (!fs.existsSync(CONFIG_PATH)) {
642
+ console.log(red('\nNot configured. Run `npx @rulecatch/ai-pooler init` first.\n'));
643
+ process.exit(1);
644
+ }
645
+ const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
646
+ let changed = false;
647
+ if (flags.batchsize) {
648
+ config.batchSize = parseInt(flags.batchsize, 10);
649
+ changed = true;
650
+ }
651
+ if (flags.region && (flags.region === 'us' || flags.region === 'eu')) {
652
+ config.region = flags.region;
653
+ changed = true;
654
+ }
655
+ if (flags.showkey === 'true' || args.includes('--show-key')) {
656
+ if (config.encryptionKey) {
657
+ console.log('\nYour encryption key:\n');
658
+ console.log(` ${green(config.encryptionKey)}\n`);
659
+ console.log(dim('Use this key in the dashboard "Decrypt Data" button to view your personal data.'));
660
+ console.log(dim('Keep it safe — we cannot recover it if lost.\n'));
661
+ }
662
+ else {
663
+ console.log(yellow('\nNo encryption key configured. Run `npx @rulecatch/ai-pooler init` to set one up.\n'));
664
+ }
665
+ break;
666
+ }
667
+ if (changed) {
668
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
669
+ console.log(green('\n+ Config updated.\n'));
670
+ }
671
+ else {
672
+ console.log('\nCurrent config:\n');
673
+ console.log(` API key: ${config.apiKey?.slice(0, 8)}...`);
674
+ console.log(` Region: ${config.region === 'eu' ? 'EU (Frankfurt)' : 'US (Virginia)'}`);
675
+ console.log(` Batch size: ${config.batchSize || 20}`);
676
+ console.log(` Encrypted: ${config.encryptionKey ? 'Yes' : 'No'}`);
677
+ console.log(dim('\nOverrides:'));
678
+ console.log(dim(' --batch-size=30'));
679
+ console.log(dim(' --region=eu'));
680
+ console.log(dim(' --show-key Show your encryption key\n'));
681
+ }
682
+ break;
683
+ }
684
+ case 'backpressure':
685
+ case 'bp': {
686
+ console.log('\nBackpressure Status\n');
687
+ const state = loadState();
688
+ // Overall health
689
+ const isHealthy = state.consecutiveFailures === 0 && state.backoffLevel === 0;
690
+ if (isHealthy) {
691
+ console.log(`Status: ${green('Healthy')}`);
692
+ }
693
+ else if (state.consecutiveFailures >= 10) {
694
+ console.log(`Status: ${red('Circuit Breaker OPEN')}`);
695
+ }
696
+ else if (state.backoffLevel >= 5) {
697
+ console.log(`Status: ${red('High Backoff')}`);
698
+ }
699
+ else {
700
+ console.log(`Status: ${yellow('Backing Off')}`);
701
+ }
702
+ // Failure info
703
+ console.log(`\nFailures: ${state.consecutiveFailures} consecutive`);
704
+ console.log(`Backoff level: ${state.backoffLevel}/10`);
705
+ // Timing
706
+ if (state.nextAttemptAfter > Date.now()) {
707
+ const waitSec = Math.ceil((state.nextAttemptAfter - Date.now()) / 1000);
708
+ console.log(`Next attempt in: ${waitSec}s`);
709
+ }
710
+ else {
711
+ console.log(`Next attempt: ${green('Ready now')}`);
712
+ }
713
+ if (state.lastSuccessTime > 0) {
714
+ const ago = Math.floor((Date.now() - state.lastSuccessTime) / 1000);
715
+ if (ago < 60) {
716
+ console.log(`Last success: ${ago}s ago`);
717
+ }
718
+ else if (ago < 3600) {
719
+ console.log(`Last success: ${Math.floor(ago / 60)}m ago`);
720
+ }
721
+ else {
722
+ console.log(`Last success: ${Math.floor(ago / 3600)}h ago`);
723
+ }
724
+ }
725
+ else {
726
+ console.log(`Last success: ${dim('Never')}`);
727
+ }
728
+ // Buffer
729
+ console.log(`\nPending events: ${state.pendingEventCount}`);
730
+ // Last known server capacity
731
+ if (state.lastCapacity) {
732
+ console.log(`\nLast Server Response:`);
733
+ console.log(` Ready: ${state.lastCapacity.ready ? green('Yes') : red('No')}`);
734
+ console.log(` Max batch: ${state.lastCapacity.maxBatchSize}`);
735
+ console.log(` Delay between: ${state.lastCapacity.delayBetweenBatches}ms`);
736
+ if (state.lastCapacity.loadPercent !== undefined) {
737
+ const loadColor = state.lastCapacity.loadPercent > 80 ? red :
738
+ state.lastCapacity.loadPercent > 50 ? yellow : green;
739
+ console.log(` Server load: ${loadColor(state.lastCapacity.loadPercent + '%')}`);
740
+ }
741
+ if (state.lastCapacity.message) {
742
+ console.log(` Message: ${dim(state.lastCapacity.message)}`);
743
+ }
744
+ }
745
+ // Reset option
746
+ const flags = parseArgs();
747
+ if (flags.reset === 'true') {
748
+ if (fs.existsSync(BACKPRESSURE_STATE_FILE)) {
749
+ fs.unlinkSync(BACKPRESSURE_STATE_FILE);
750
+ }
751
+ console.log(green('\n+ Backpressure state reset.\n'));
752
+ }
753
+ else if (state.consecutiveFailures > 0 || state.backoffLevel > 0) {
754
+ console.log(dim('\nTo reset: npx @rulecatch/ai-pooler backpressure --reset=true'));
755
+ }
756
+ console.log('');
757
+ break;
758
+ }
759
+ case 'reactivate': {
760
+ console.log('\nChecking subscription status...\n');
761
+ // Check if configured
762
+ if (!fs.existsSync(CONFIG_PATH)) {
763
+ console.log(red('Not configured. Run `npx @rulecatch/ai-pooler init` first.\n'));
764
+ process.exit(1);
765
+ }
766
+ // Read config for region
767
+ let config;
768
+ try {
769
+ config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
770
+ }
771
+ catch {
772
+ console.log(red('Failed to read config. Run `npx @rulecatch/ai-pooler init` to reconfigure.\n'));
773
+ process.exit(1);
774
+ }
775
+ if (!config.apiKey) {
776
+ console.log(red('No API key configured. Run `npx @rulecatch/ai-pooler init` first.\n'));
777
+ process.exit(1);
778
+ }
779
+ // Remove paused file to allow session token request
780
+ if (fs.existsSync(PAUSED_FILE)) {
781
+ fs.unlinkSync(PAUSED_FILE);
782
+ }
783
+ // Remove old session file to force re-auth
784
+ if (fs.existsSync(SESSION_FILE)) {
785
+ fs.unlinkSync(SESSION_FILE);
786
+ }
787
+ // Try to acquire a session token to check subscription
788
+ const region = config.region || 'us';
789
+ const baseUrl = region === 'eu'
790
+ ? 'https://api-eu.rulecatch.ai'
791
+ : 'https://api.rulecatch.ai';
792
+ try {
793
+ const response = await fetch(`${baseUrl}/api/v1/ai/pooler/session`, {
794
+ method: 'POST',
795
+ headers: {
796
+ 'Content-Type': 'application/json',
797
+ 'Authorization': `Bearer ${config.apiKey}`,
798
+ },
799
+ body: JSON.stringify({
800
+ projectId: 'reactivate-check',
801
+ region,
802
+ encrypted: true,
803
+ }),
804
+ });
805
+ if (response.ok) {
806
+ // Subscription is active!
807
+ console.log(green('✓ Subscription active!'));
808
+ console.log('\nData collection has been reactivated.');
809
+ console.log('Events will be sent on your next Claude Code session.\n');
810
+ process.exit(0);
811
+ }
812
+ // Check for paused response
813
+ if (response.status === 403) {
814
+ const errorBody = await response.json();
815
+ if (errorBody.status === 'paused') {
816
+ // Write paused file again
817
+ fs.writeFileSync(PAUSED_FILE, JSON.stringify({
818
+ reason: errorBody.reason,
819
+ message: errorBody.message,
820
+ region,
821
+ dashboardUrl: `https://dashboard${region === 'eu' ? '-eu' : ''}.rulecatch.ai`,
822
+ billingUrl: errorBody.billingUrl,
823
+ pausedAt: new Date().toISOString(),
824
+ }, null, 2), { mode: 0o600 });
825
+ console.log(red('✗ Subscription still inactive.\n'));
826
+ console.log('╔═══════════════════════════════════════════════════════════════╗');
827
+ console.log('║ SUBSCRIPTION REQUIRED ║');
828
+ console.log('╠═══════════════════════════════════════════════════════════════╣');
829
+ console.log('║ ║');
830
+ console.log(`║ ${errorBody.message || 'Your subscription has expired.'}`.padEnd(64) + '║');
831
+ console.log('║ ║');
832
+ console.log('║ To reactivate data collection: ║');
833
+ console.log('║ ║');
834
+ console.log('║ 1. Visit your billing page: ║');
835
+ console.log(`║ ${errorBody.billingUrl || `https://dashboard${region === 'eu' ? '-eu' : ''}.rulecatch.ai/billing`}`.padEnd(60) + '║');
836
+ console.log('║ ║');
837
+ console.log('║ 2. Subscribe or update your payment method ║');
838
+ console.log('║ ║');
839
+ console.log('║ 3. Return here and run: ║');
840
+ console.log('║ npx @rulecatch/ai-pooler reactivate ║');
841
+ console.log('║ ║');
842
+ console.log('╚═══════════════════════════════════════════════════════════════╝');
843
+ console.log('');
844
+ process.exit(1);
845
+ }
846
+ }
847
+ // Other error
848
+ console.log(red(`✗ Failed to check subscription (${response.status})`));
849
+ console.log('Please try again or contact support.\n');
850
+ process.exit(1);
851
+ }
852
+ catch (err) {
853
+ console.log(red('✗ Network error'));
854
+ console.log(`Could not connect to ${baseUrl}`);
855
+ console.log('Please check your internet connection and try again.\n');
856
+ process.exit(1);
857
+ }
858
+ break;
859
+ }
860
+ case 'monitor':
861
+ case 'live': {
862
+ // Live monitoring view — watches buffer + flush log in real-time
863
+ // Debug levels: (none) = compact, -v = verbose (paths), -vv/--debug = full JSON
864
+ if (!fs.existsSync(CONFIG_PATH)) {
865
+ console.log(red('Not configured. Run `npx @rulecatch/ai-pooler init` first.\n'));
866
+ process.exit(1);
867
+ }
868
+ const verboseCount = args.filter(a => a === '-v').length + (args.includes('--verbose') ? 1 : 0);
869
+ const debugMode = args.includes('-vv') || args.includes('--debug') || verboseCount >= 2;
870
+ const verboseMode = debugMode || verboseCount >= 1;
871
+ const monConfig = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
872
+ const monEndpoint = monConfig.endpoint || (monConfig.region === 'eu' ? 'https://api-eu.rulecatch.ai' : 'https://api.rulecatch.ai');
873
+ // Derive encryption key once for decrypting buffer events
874
+ let decryptKey = null;
875
+ if (monConfig.encryptionKey) {
876
+ try {
877
+ decryptKey = pbkdf2Sync(monConfig.encryptionKey, 'rulecatch', 100000, 32, 'sha256');
878
+ }
879
+ catch { /* no decryption available */ }
880
+ }
881
+ // Decrypt a hook-encrypted field (format: iv_base64:ciphertext+tag_base64)
882
+ const decryptHookField = (encrypted) => {
883
+ if (!decryptKey || !encrypted || !encrypted.includes(':'))
884
+ return '';
885
+ try {
886
+ const [ivB64, ctB64] = encrypted.split(':');
887
+ const iv = Buffer.from(ivB64, 'base64');
888
+ const ctWithTag = Buffer.from(ctB64, 'base64');
889
+ const tag = ctWithTag.subarray(ctWithTag.length - 16);
890
+ const ciphertext = ctWithTag.subarray(0, ctWithTag.length - 16);
891
+ const decipher = createDecipheriv('aes-256-gcm', decryptKey, iv);
892
+ decipher.setAuthTag(tag);
893
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
894
+ }
895
+ catch {
896
+ return '';
897
+ }
898
+ };
899
+ // Cache decrypted cwd per session (populated from session_start events)
900
+ const cwdCache = new Map();
901
+ const getCwd = (evt) => {
902
+ const sessionId = evt.sessionId;
903
+ if (sessionId && cwdCache.has(sessionId))
904
+ return cwdCache.get(sessionId);
905
+ // Try plaintext first (privacy disabled)
906
+ let cwd = evt.cwd || '';
907
+ // Try decrypting (privacy enabled)
908
+ if (!cwd) {
909
+ const encrypted = evt.cwdEncrypted || '';
910
+ if (encrypted)
911
+ cwd = decryptHookField(encrypted);
912
+ }
913
+ if (cwd && sessionId)
914
+ cwdCache.set(sessionId, cwd);
915
+ return cwd;
916
+ };
917
+ const cyan = (s) => `\x1b[36m${s}\x1b[0m`;
918
+ const magenta = (s) => `\x1b[35m${s}\x1b[0m`;
919
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
920
+ const blue = (s) => `\x1b[34m${s}\x1b[0m`;
921
+ // Check API connectivity + fetch plan/usage info
922
+ let apiStatus = yellow('⚠️ API not running');
923
+ let planInfo = '';
924
+ let usageInfo = '';
925
+ let modelInfo = '';
926
+ try {
927
+ const res = await fetch(`${monEndpoint}/api/v1/ai/validate-key`, {
928
+ method: 'POST',
929
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${monConfig.apiKey}` },
930
+ body: JSON.stringify({ apiKey: monConfig.apiKey }),
931
+ signal: AbortSignal.timeout(3000),
932
+ });
933
+ if (res.ok) {
934
+ apiStatus = green('✅ connected');
935
+ const data = await res.json();
936
+ // Plan info
937
+ const plan = (data.planId || 'starter').charAt(0).toUpperCase() + (data.planId || 'starter').slice(1);
938
+ const status = data.subscriptionStatus || '';
939
+ const trialDays = data.trialDaysLeft;
940
+ if (status === 'trialing' && trialDays !== undefined) {
941
+ planInfo = `${cyan(plan)} ${yellow(`(trial: ${trialDays}d left)`)}`;
942
+ }
943
+ else {
944
+ planInfo = `${cyan(plan)} ${dim(`(${status})`)}`;
945
+ }
946
+ // Token usage
947
+ const usage = data.tokenUsage;
948
+ if (usage) {
949
+ const tokens = usage.totalTokens > 1000000 ? `${(usage.totalTokens / 1000000).toFixed(1)}M` : usage.totalTokens > 1000 ? `${(usage.totalTokens / 1000).toFixed(1)}K` : `${usage.totalTokens}`;
950
+ usageInfo = `${tokens} tokens ${green(`$${usage.totalCost.toFixed(2)}`)} ${dim(`${usage.sessions} sessions`)}`;
951
+ }
952
+ // Model
953
+ const model = data.model;
954
+ if (model) {
955
+ // Pretty-print model name: "claude-opus-4-6" → "Claude Opus 4.6"
956
+ const pretty = model.replace('claude-', '').replace(/-(\d+)-(\d+)/, ' $1.$2').replace(/\b\w/g, c => c.toUpperCase());
957
+ modelInfo = magenta(pretty);
958
+ }
959
+ }
960
+ else if (res.status === 401) {
961
+ console.log(red('\n ✗ Invalid API key. Run `npx @rulecatch/ai-pooler init` to reconfigure.\n'));
962
+ process.exit(1);
963
+ }
964
+ else {
965
+ apiStatus = yellow(`⚠️ ${res.status}`);
966
+ }
967
+ }
968
+ catch {
969
+ apiStatus = yellow('⚠️ API not running — will show events locally');
970
+ }
971
+ // Print header box
972
+ const BOX_W = 62;
973
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
974
+ // Count visible terminal columns (emojis are 2 wide)
975
+ const visibleWidth = (s) => {
976
+ const plain = stripAnsi(s);
977
+ let w = 0;
978
+ for (const ch of plain) {
979
+ w += ch.charCodeAt(0) > 0xFF ? 2 : 1;
980
+ }
981
+ return w;
982
+ };
983
+ const boxLine = (content) => {
984
+ const w = visibleWidth(content);
985
+ const pad = Math.max(0, BOX_W - w);
986
+ return ` │${content}${' '.repeat(pad)}│`;
987
+ };
988
+ const hLine = dim('─'.repeat(BOX_W));
989
+ console.log('');
990
+ console.log(` ┌${hLine}┐`);
991
+ console.log(boxLine(` ${bold('RuleCatch.AI Monitor')}`));
992
+ console.log(` ├${hLine}┤`);
993
+ console.log(boxLine(` API: ${monEndpoint} ${apiStatus}`));
994
+ console.log(boxLine(` Key: ${dim(monConfig.apiKey?.slice(0, 12) + '...')}`));
995
+ console.log(boxLine(` Project: ${cyan(monConfig.projectId || '(not set)')}`));
996
+ console.log(boxLine(` Region: ${monConfig.region === 'eu' ? 'EU' : 'US'}`));
997
+ if (planInfo)
998
+ console.log(boxLine(` Plan: ${planInfo}`));
999
+ if (modelInfo)
1000
+ console.log(boxLine(` Model: ${modelInfo}`));
1001
+ if (usageInfo)
1002
+ console.log(boxLine(` Usage: ${usageInfo}`));
1003
+ console.log(boxLine(` Buffer: ${getBufferCount()} events pending`));
1004
+ console.log(boxLine(` Level: ${debugMode ? magenta('debug (full JSON)') : verboseMode ? cyan('verbose (file paths)') : dim('compact')}`));
1005
+ console.log(` └${hLine}┘`);
1006
+ console.log('');
1007
+ console.log(` ${dim('Watching for events... (Ctrl+C to stop)')}`);
1008
+ if (!verboseMode)
1009
+ console.log(` ${dim('Tip: use -v for file paths, -vv for full event JSON')}`);
1010
+ console.log('');
1011
+ // Running session counters (start from server totals, increment locally)
1012
+ const serverUsage = { totalTokens: 0, totalCost: 0, sessions: 0 };
1013
+ try {
1014
+ const res2 = await fetch(`${monEndpoint}/api/v1/ai/validate-key`, {
1015
+ method: 'POST',
1016
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${monConfig.apiKey}` },
1017
+ body: JSON.stringify({ apiKey: monConfig.apiKey }),
1018
+ signal: AbortSignal.timeout(3000),
1019
+ });
1020
+ if (res2.ok) {
1021
+ const d2 = await res2.json();
1022
+ const u = d2.tokenUsage;
1023
+ if (u) {
1024
+ serverUsage.totalTokens = u.totalTokens;
1025
+ serverUsage.totalCost = u.totalCost;
1026
+ serverUsage.sessions = u.sessions;
1027
+ }
1028
+ }
1029
+ }
1030
+ catch { /* use zeros */ }
1031
+ let runningTokens = serverUsage.totalTokens;
1032
+ let runningCost = serverUsage.totalCost;
1033
+ const runningModel = modelInfo || dim('unknown');
1034
+ const fmtRunning = () => {
1035
+ const tk = runningTokens > 1000000 ? `${(runningTokens / 1000000).toFixed(1)}M` : runningTokens > 1000 ? `${(runningTokens / 1000).toFixed(1)}K` : `${runningTokens}`;
1036
+ return dim(`[${runningModel} │ ${tk} tk │ $${runningCost.toFixed(2)}]`);
1037
+ };
1038
+ // Track seen files so we don't double-print
1039
+ const seenFiles = new Set();
1040
+ // Preload existing buffer files as "seen" + pre-scan for session_start cwds
1041
+ if (fs.existsSync(BUFFER_DIR)) {
1042
+ for (const f of fs.readdirSync(BUFFER_DIR)) {
1043
+ seenFiles.add(f);
1044
+ // Pre-populate cwd cache from existing session_start events
1045
+ if (f.endsWith('.json')) {
1046
+ try {
1047
+ const content = fs.readFileSync(path.join(BUFFER_DIR, f), 'utf-8');
1048
+ const evt = JSON.parse(content);
1049
+ if (evt.type === 'session_start')
1050
+ getCwd(evt);
1051
+ }
1052
+ catch { /* skip */ }
1053
+ }
1054
+ }
1055
+ }
1056
+ let lastBufferCount = getBufferCount();
1057
+ // Format a timestamp
1058
+ const fmtTime = (ts) => {
1059
+ try {
1060
+ const d = new Date(ts);
1061
+ return dim(d.toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }));
1062
+ }
1063
+ catch {
1064
+ return dim('--:--:--');
1065
+ }
1066
+ };
1067
+ // Format a tool call event
1068
+ const fmtEvent = (evt) => {
1069
+ const time = fmtTime(evt.timestamp);
1070
+ const type = evt.type;
1071
+ // Increment running counters from event data
1072
+ const evtInputTk = evt.toolInputSize || 0;
1073
+ const evtOutputTk = evt.toolOutputSize || 0;
1074
+ if (type === 'tool_call') {
1075
+ runningTokens += evtInputTk + evtOutputTk;
1076
+ // Estimate cost from model pricing (per token)
1077
+ const m = (evt.model || '').toLowerCase();
1078
+ let inRate = 0.000003;
1079
+ let outRate = 0.000015; // default sonnet
1080
+ if (m.includes('opus')) {
1081
+ inRate = 0.000015;
1082
+ outRate = 0.000075;
1083
+ }
1084
+ else if (m.includes('haiku')) {
1085
+ inRate = 0.00000025;
1086
+ outRate = 0.00000125;
1087
+ }
1088
+ runningCost += (evtInputTk * inRate) + (evtOutputTk * outRate);
1089
+ }
1090
+ if (type === 'session_end') {
1091
+ const endCost = evt.estimatedCost || 0;
1092
+ const endTokens = (evt.inputTokens || 0) + (evt.outputTokens || 0);
1093
+ runningCost += endCost;
1094
+ runningTokens += endTokens;
1095
+ }
1096
+ const stats = fmtRunning();
1097
+ if (type === 'tool_call') {
1098
+ const tool = evt.toolName || '?';
1099
+ const ok = evt.toolSuccess ? green('✓') : red('✗');
1100
+ const fp = evt.filePath || '';
1101
+ const op = evt.fileOperation ? dim(`(${evt.fileOperation})`) : '';
1102
+ const inputSize = evt.toolInputSize ? dim(`${evt.toolInputSize}b`) : '';
1103
+ const outputSize = evt.toolOutputSize ? dim(`→ ${evt.toolOutputSize}b`) : '';
1104
+ if (debugMode) {
1105
+ // Full JSON dump
1106
+ console.log(` ${time} ${ok} ${cyan(tool)} ${op} ${stats}`);
1107
+ const display = { ...evt };
1108
+ delete display.type;
1109
+ delete display.timestamp;
1110
+ delete display.toolName;
1111
+ console.log(dim(` ${JSON.stringify(display, null, 2).split('\n').join('\n ')}`));
1112
+ }
1113
+ else if (verboseMode) {
1114
+ // Full file path, git context, input+output sizes, line changes
1115
+ const repo = evt.gitRepo ? blue(evt.gitRepo) : '';
1116
+ const branch = evt.gitBranch ? dim(`(${evt.gitBranch})`) : '';
1117
+ const lang = evt.language ? dim(`[${evt.language}]`) : '';
1118
+ const la = evt.linesAdded || 0;
1119
+ const lr = evt.linesRemoved || 0;
1120
+ const lineInfo = (la > 0 || lr > 0) ? ` ${green(`+${la}`)}/${red(`-${lr}`)}` : '';
1121
+ console.log(` ${time} ${ok} ${cyan(tool.padEnd(12))} ${op} ${inputSize} ${outputSize} ${lang}${lineInfo} ${stats}`);
1122
+ if (fp)
1123
+ console.log(` ${dim('path:')} ${fp}`);
1124
+ const evtCwd = getCwd(evt);
1125
+ if (evtCwd) {
1126
+ let projLine = ` ${dim('project:')} ${blue(evtCwd)}`;
1127
+ if (repo) {
1128
+ projLine += ` ${dim('repo:')} ${repo} ${branch}`;
1129
+ }
1130
+ else if (evt.gitBranch || evt.gitCommit) {
1131
+ const gb = evt.gitBranch ? cyan(evt.gitBranch) : '';
1132
+ const gc = evt.gitCommit ? dim(evt.gitCommit) : '';
1133
+ const gd = evt.gitDirty ? yellow('*') : '';
1134
+ projLine += ` ${gb} ${gc}${gd}`;
1135
+ }
1136
+ console.log(projLine);
1137
+ }
1138
+ }
1139
+ else {
1140
+ // Compact (current default)
1141
+ const shortPath = fp ? fp.split('/').slice(-2).join('/') : '';
1142
+ console.log(` ${time} ${ok} ${cyan(tool.padEnd(8))} ${shortPath} ${op} ${inputSize} ${stats}`);
1143
+ }
1144
+ }
1145
+ else if (type === 'session_start') {
1146
+ const evtCwd = getCwd(evt);
1147
+ console.log(` ${time} ${green('▶')} ${bold('Session started')} ${stats}`);
1148
+ if (evtCwd) {
1149
+ let projLine = ` ${dim('project:')} ${blue(evtCwd)}`;
1150
+ const gitRepo = evt.gitRepo;
1151
+ if (gitRepo) {
1152
+ const gitBranch = evt.gitBranch ? dim(`(${evt.gitBranch})`) : '';
1153
+ projLine += ` ${dim('repo:')} ${blue(gitRepo)} ${gitBranch}`;
1154
+ }
1155
+ else if (evt.gitBranch || evt.gitCommit) {
1156
+ const gb = evt.gitBranch ? cyan(evt.gitBranch) : '';
1157
+ const gc = evt.gitCommit ? dim(evt.gitCommit) : '';
1158
+ const gd = evt.gitDirty ? yellow('*') : '';
1159
+ projLine += ` ${gb} ${gc}${gd}`;
1160
+ }
1161
+ console.log(projLine);
1162
+ }
1163
+ if (verboseMode) {
1164
+ const model = evt.model;
1165
+ const account = evt.account;
1166
+ if (model)
1167
+ console.log(` ${dim('model:')} ${model}`);
1168
+ if (account)
1169
+ console.log(` ${dim('account:')} ${account}`);
1170
+ }
1171
+ if (debugMode) {
1172
+ console.log(dim(` ${JSON.stringify(evt, null, 2).split('\n').join('\n ')}`));
1173
+ }
1174
+ }
1175
+ else if (type === 'session_end') {
1176
+ console.log(` ${time} ${red('■')} ${bold('Session ended')} ${stats}`);
1177
+ if (verboseMode) {
1178
+ const cost = evt.estimatedCost;
1179
+ const tokens = (evt.inputTokens || 0) + (evt.outputTokens || 0);
1180
+ if (cost)
1181
+ console.log(` ${dim('cost:')} ${yellow(`$${cost.toFixed(4)}`)}`);
1182
+ if (tokens)
1183
+ console.log(` ${dim('tokens:')} ${tokens.toLocaleString()}`);
1184
+ }
1185
+ if (debugMode) {
1186
+ console.log(dim(` ${JSON.stringify(evt, null, 2).split('\n').join('\n ')}`));
1187
+ }
1188
+ }
1189
+ else if (type === 'turn_complete') {
1190
+ console.log(` ${time} ${dim('↩')} ${dim('Turn complete')}`);
1191
+ }
1192
+ else {
1193
+ console.log(` ${time} ${dim('·')} ${dim(type)}`);
1194
+ if (debugMode) {
1195
+ console.log(dim(` ${JSON.stringify(evt, null, 2).split('\n').join('\n ')}`));
1196
+ }
1197
+ }
1198
+ };
1199
+ // Watch flush log for activity
1200
+ let lastFlushSize = 0;
1201
+ const flushLog = path.join(RULECATCH_DIR, 'flush.log');
1202
+ if (fs.existsSync(flushLog)) {
1203
+ lastFlushSize = fs.statSync(flushLog).size;
1204
+ }
1205
+ const checkFlushLog = () => {
1206
+ if (!fs.existsSync(flushLog))
1207
+ return;
1208
+ const stat = fs.statSync(flushLog);
1209
+ if (stat.size > lastFlushSize) {
1210
+ // Read new lines
1211
+ const fd = fs.openSync(flushLog, 'r');
1212
+ const newBytes = Buffer.alloc(stat.size - lastFlushSize);
1213
+ fs.readSync(fd, newBytes, 0, newBytes.length, lastFlushSize);
1214
+ fs.closeSync(fd);
1215
+ const newLines = newBytes.toString().trim().split('\n').filter(Boolean);
1216
+ for (const line of newLines) {
1217
+ // Parse flush log entries
1218
+ if (line.includes('Flushed') || line.includes('flushed')) {
1219
+ console.log(` ${dim(new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }))} ${green('↑')} ${green(line.replace(/^\[.*?\]\s*/, ''))}`);
1220
+ }
1221
+ else if (line.includes('Error') || line.includes('error') || line.includes('fail')) {
1222
+ console.log(` ${dim(new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }))} ${red('✗')} ${red(line.replace(/^\[.*?\]\s*/, ''))}`);
1223
+ }
1224
+ }
1225
+ lastFlushSize = stat.size;
1226
+ }
1227
+ };
1228
+ // Watch buffer directory for new files
1229
+ if (!fs.existsSync(BUFFER_DIR)) {
1230
+ fs.mkdirSync(BUFFER_DIR, { recursive: true });
1231
+ }
1232
+ const watcher = fs.watch(BUFFER_DIR, (eventType, filename) => {
1233
+ if (!filename || !filename.endsWith('.json'))
1234
+ return;
1235
+ if (eventType === 'rename') {
1236
+ const filepath = path.join(BUFFER_DIR, filename);
1237
+ // New file appeared
1238
+ if (!seenFiles.has(filename) && fs.existsSync(filepath)) {
1239
+ seenFiles.add(filename);
1240
+ try {
1241
+ const content = fs.readFileSync(filepath, 'utf-8');
1242
+ const evt = JSON.parse(content);
1243
+ fmtEvent(evt);
1244
+ }
1245
+ catch { /* file might be mid-write */ }
1246
+ }
1247
+ // File removed (flushed)
1248
+ if (seenFiles.has(filename) && !fs.existsSync(filepath)) {
1249
+ seenFiles.delete(filename);
1250
+ }
1251
+ }
1252
+ // Update buffer count periodically
1253
+ const newCount = getBufferCount();
1254
+ if (newCount !== lastBufferCount) {
1255
+ if (newCount < lastBufferCount) {
1256
+ const flushed = lastBufferCount - newCount;
1257
+ console.log(` ${dim(new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }))} ${green('↑')} ${green(`Flushed ${flushed} events`)} ${dim(`(${newCount} remaining)`)}`);
1258
+ }
1259
+ lastBufferCount = newCount;
1260
+ }
1261
+ });
1262
+ // Periodic checks
1263
+ const interval = setInterval(() => {
1264
+ checkFlushLog();
1265
+ }, 2000);
1266
+ // Handle Ctrl+C gracefully
1267
+ process.on('SIGINT', () => {
1268
+ watcher.close();
1269
+ clearInterval(interval);
1270
+ console.log(`\n ${dim('Monitor stopped.')}\n`);
1271
+ process.exit(0);
1272
+ });
1273
+ // Keep the process alive
1274
+ await new Promise(() => { });
1275
+ break;
1276
+ }
1277
+ case 'help':
1278
+ case '--help':
1279
+ case '-h': {
1280
+ console.log(`
1281
+ Rulecatch AI Pooler - Development Analytics (Zero Token Overhead)
1282
+
1283
+ Usage:
1284
+ npx @rulecatch/ai-pooler [command] [options]
1285
+
1286
+ Commands:
1287
+ init Interactive setup (API key, encryption, hooks)
1288
+ uninstall Remove all Rulecatch files and hooks
1289
+ status Check setup, buffer count, session token
1290
+ flush Force send all buffered events
1291
+ logs Show flush activity logs
1292
+ config View or update configuration
1293
+ monitor Live event stream — watch events + flushes in real-time (alias: live)
1294
+ backpressure Show detailed backpressure/throttling status (alias: bp)
1295
+ reactivate Resume data collection after subscription renewal
1296
+
1297
+ Init Options:
1298
+ --api-key=KEY Your Rulecatch API key (starts with dc_)
1299
+ --region=us|eu Override region (normally auto-detected)
1300
+ --encryption-key=PWD Your encryption password (min 8 chars)
1301
+ --batch-size=20 Events before auto-flush
1302
+
1303
+ Config Options:
1304
+ --batch-size=30 Change batch threshold
1305
+ --region=us|eu Change data region
1306
+
1307
+ Log Options:
1308
+ --lines=50 Number of log lines to show
1309
+ --source=hook Show hook log instead of flush log
1310
+
1311
+ Backpressure Options:
1312
+ --reset=true Reset backpressure state (clears backoff)
1313
+
1314
+ Data Flow (Zero Token Overhead):
1315
+ Hook fires -> writes JSON to ~/.claude/rulecatch/buffer/
1316
+ Flush script -> encrypts PII -> sends batch to API
1317
+
1318
+ Quick Start:
1319
+ npx @rulecatch/ai-pooler init
1320
+ # Follow interactive prompts, then restart Claude Code
1321
+
1322
+ Documentation: https://rulecatch.ai/docs
1323
+ `);
1324
+ break;
1325
+ }
1326
+ default: {
1327
+ if (command) {
1328
+ console.log(red(`\nUnknown command: ${command}`));
1329
+ }
1330
+ console.log('Run `npx @rulecatch/ai-pooler help` for usage.\n');
1331
+ }
1332
+ }
1333
+ }
1334
+ main().catch((error) => {
1335
+ console.error('Error:', error.message);
1336
+ process.exit(1);
1337
+ });
1338
+ //# sourceMappingURL=cli.js.map