@wbern/cc-ping 0.1.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.
Files changed (2) hide show
  1. package/dist/cli.js +1584 -0
  2. package/package.json +90 -0
package/dist/cli.js ADDED
@@ -0,0 +1,1584 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+
12
+ // src/paths.ts
13
+ import { homedir } from "os";
14
+ import { join as join2 } from "path";
15
+ function setConfigDir(dir) {
16
+ configDirOverride = dir;
17
+ }
18
+ function resolveConfigDir() {
19
+ if (configDirOverride) return configDirOverride;
20
+ if (process.env.CC_PING_CONFIG) return process.env.CC_PING_CONFIG;
21
+ return join2(homedir(), ".config", "cc-ping");
22
+ }
23
+ var configDirOverride;
24
+ var init_paths = __esm({
25
+ "src/paths.ts"() {
26
+ "use strict";
27
+ }
28
+ });
29
+
30
+ // src/config.ts
31
+ var config_exports = {};
32
+ __export(config_exports, {
33
+ addAccount: () => addAccount,
34
+ listAccounts: () => listAccounts,
35
+ loadConfig: () => loadConfig,
36
+ removeAccount: () => removeAccount,
37
+ saveConfig: () => saveConfig
38
+ });
39
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
40
+ import { join as join3 } from "path";
41
+ function loadConfig() {
42
+ const configFile = join3(resolveConfigDir(), "config.json");
43
+ if (!existsSync2(configFile)) {
44
+ return { accounts: [] };
45
+ }
46
+ try {
47
+ const raw = readFileSync2(configFile, "utf-8");
48
+ return JSON.parse(raw);
49
+ } catch {
50
+ return { accounts: [] };
51
+ }
52
+ }
53
+ function saveConfig(config) {
54
+ const configDir = resolveConfigDir();
55
+ mkdirSync(configDir, { recursive: true });
56
+ writeFileSync(
57
+ join3(configDir, "config.json"),
58
+ `${JSON.stringify(config, null, 2)}
59
+ `
60
+ );
61
+ }
62
+ function addAccount(handle, configDir, group) {
63
+ const config = loadConfig();
64
+ const existing = config.accounts.findIndex((a) => a.handle === handle);
65
+ if (existing !== -1) {
66
+ config.accounts[existing].configDir = configDir;
67
+ config.accounts[existing].group = group;
68
+ } else {
69
+ const account = { handle, configDir };
70
+ if (group) account.group = group;
71
+ config.accounts.push(account);
72
+ }
73
+ saveConfig(config);
74
+ }
75
+ function removeAccount(handle) {
76
+ const config = loadConfig();
77
+ const before = config.accounts.length;
78
+ config.accounts = config.accounts.filter((a) => a.handle !== handle);
79
+ if (config.accounts.length === before) return false;
80
+ saveConfig(config);
81
+ return true;
82
+ }
83
+ function listAccounts() {
84
+ return loadConfig().accounts;
85
+ }
86
+ var init_config = __esm({
87
+ "src/config.ts"() {
88
+ "use strict";
89
+ init_paths();
90
+ }
91
+ });
92
+
93
+ // src/state.ts
94
+ var state_exports = {};
95
+ __export(state_exports, {
96
+ QUOTA_WINDOW_MS: () => QUOTA_WINDOW_MS,
97
+ formatTimeRemaining: () => formatTimeRemaining,
98
+ getLastPing: () => getLastPing,
99
+ getLastPingMeta: () => getLastPingMeta,
100
+ getWindowReset: () => getWindowReset,
101
+ loadState: () => loadState,
102
+ recordPing: () => recordPing,
103
+ saveState: () => saveState
104
+ });
105
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
106
+ import { join as join4 } from "path";
107
+ function loadState() {
108
+ const stateFile = join4(resolveConfigDir(), "state.json");
109
+ if (!existsSync3(stateFile)) {
110
+ return { lastPing: {} };
111
+ }
112
+ try {
113
+ const raw = readFileSync3(stateFile, "utf-8");
114
+ return JSON.parse(raw);
115
+ } catch {
116
+ return { lastPing: {} };
117
+ }
118
+ }
119
+ function saveState(state) {
120
+ const configDir = resolveConfigDir();
121
+ mkdirSync2(configDir, { recursive: true });
122
+ writeFileSync2(
123
+ join4(configDir, "state.json"),
124
+ `${JSON.stringify(state, null, 2)}
125
+ `
126
+ );
127
+ }
128
+ function recordPing(handle, timestamp = /* @__PURE__ */ new Date(), meta) {
129
+ const state = loadState();
130
+ state.lastPing[handle] = timestamp.toISOString();
131
+ if (meta) {
132
+ if (!state.lastPingMeta) state.lastPingMeta = {};
133
+ state.lastPingMeta[handle] = meta;
134
+ }
135
+ saveState(state);
136
+ }
137
+ function getLastPingMeta(handle) {
138
+ const state = loadState();
139
+ return state.lastPingMeta?.[handle] ?? null;
140
+ }
141
+ function getLastPing(handle) {
142
+ const state = loadState();
143
+ const iso = state.lastPing[handle];
144
+ if (!iso) return null;
145
+ return new Date(iso);
146
+ }
147
+ function getWindowReset(handle, now = /* @__PURE__ */ new Date()) {
148
+ const lastPing = getLastPing(handle);
149
+ if (!lastPing) return null;
150
+ const resetAt = new Date(lastPing.getTime() + QUOTA_WINDOW_MS);
151
+ const remainingMs = resetAt.getTime() - now.getTime();
152
+ if (remainingMs <= 0) return null;
153
+ if (remainingMs > QUOTA_WINDOW_MS) return null;
154
+ return { resetAt, remainingMs };
155
+ }
156
+ function formatTimeRemaining(ms) {
157
+ if (ms <= 0) return "expired";
158
+ const totalMinutes = Math.ceil(ms / 6e4);
159
+ const hours = Math.floor(totalMinutes / 60);
160
+ const minutes = totalMinutes % 60;
161
+ if (hours > 0) return `${hours}h ${minutes}m`;
162
+ return `${minutes}m`;
163
+ }
164
+ var QUOTA_WINDOW_MS;
165
+ var init_state = __esm({
166
+ "src/state.ts"() {
167
+ "use strict";
168
+ init_paths();
169
+ QUOTA_WINDOW_MS = 5 * 60 * 60 * 1e3;
170
+ }
171
+ });
172
+
173
+ // src/bell.ts
174
+ function ringBell(write) {
175
+ const w = write ?? ((s) => process.stdout.write(s));
176
+ w("\x07");
177
+ }
178
+ var init_bell = __esm({
179
+ "src/bell.ts"() {
180
+ "use strict";
181
+ }
182
+ });
183
+
184
+ // src/history.ts
185
+ import { appendFileSync, existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync4 } from "fs";
186
+ import { join as join5 } from "path";
187
+ function historyFile() {
188
+ return join5(resolveConfigDir(), "history.jsonl");
189
+ }
190
+ function appendHistoryEntry(entry) {
191
+ const dir = resolveConfigDir();
192
+ mkdirSync3(dir, { recursive: true });
193
+ appendFileSync(historyFile(), `${JSON.stringify(entry)}
194
+ `);
195
+ }
196
+ function formatHistoryEntry(entry) {
197
+ const status = entry.success ? "ok" : "FAIL";
198
+ const error = entry.error ? ` (${entry.error})` : "";
199
+ return ` ${entry.timestamp} ${entry.handle}: ${status} ${entry.durationMs}ms${error}`;
200
+ }
201
+ function readHistory(limit) {
202
+ const file = historyFile();
203
+ if (!existsSync4(file)) return [];
204
+ const content = readFileSync4(file, "utf-8").trim();
205
+ if (!content) return [];
206
+ const entries = content.split("\n").map((line) => JSON.parse(line));
207
+ if (limit !== void 0 && limit < entries.length) {
208
+ return entries.slice(-limit);
209
+ }
210
+ return entries;
211
+ }
212
+ var init_history = __esm({
213
+ "src/history.ts"() {
214
+ "use strict";
215
+ init_paths();
216
+ }
217
+ });
218
+
219
+ // src/logger.ts
220
+ function createLogger(options) {
221
+ const stdout = options.stdout ?? console.log;
222
+ const stderr = options.stderr ?? console.error;
223
+ return {
224
+ log: (msg) => {
225
+ if (!options.quiet) {
226
+ stdout(msg);
227
+ }
228
+ },
229
+ error: (msg) => {
230
+ stderr(msg);
231
+ }
232
+ };
233
+ }
234
+ var init_logger = __esm({
235
+ "src/logger.ts"() {
236
+ "use strict";
237
+ }
238
+ });
239
+
240
+ // src/notify.ts
241
+ import { execFile as defaultExecFile } from "child_process";
242
+ function buildNotifyCommand(title, body, platform, options) {
243
+ switch (platform) {
244
+ case "darwin": {
245
+ let script = `display notification "${body}" with title "${title}"`;
246
+ if (options?.sound) script += ` sound name "default"`;
247
+ return ["osascript", ["-e", script]];
248
+ }
249
+ case "linux":
250
+ return ["notify-send", [title, body]];
251
+ case "win32":
252
+ return [
253
+ "powershell",
254
+ [
255
+ "-Command",
256
+ `[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms'); $n = New-Object System.Windows.Forms.NotifyIcon; $n.Icon = [System.Drawing.SystemIcons]::Information; $n.Visible = $true; $n.ShowBalloonTip(5000, '${title}', '${body}', 'Error'); Start-Sleep -Seconds 6; $n.Dispose()`
257
+ ]
258
+ ];
259
+ default:
260
+ return null;
261
+ }
262
+ }
263
+ function sendNotification(title, body, opts) {
264
+ const platform = opts?.platform ?? process.platform;
265
+ const exec = opts?.exec ?? defaultExecFile;
266
+ const cmd = buildNotifyCommand(title, body, platform, { sound: opts?.sound });
267
+ if (!cmd) return Promise.resolve(false);
268
+ return new Promise((resolve) => {
269
+ exec(cmd[0], cmd[1], (error) => {
270
+ resolve(!error);
271
+ });
272
+ });
273
+ }
274
+ var init_notify = __esm({
275
+ "src/notify.ts"() {
276
+ "use strict";
277
+ }
278
+ });
279
+
280
+ // src/parse.ts
281
+ function parseClaudeResponse(stdout) {
282
+ if (!stdout) return null;
283
+ let raw;
284
+ try {
285
+ raw = JSON.parse(stdout);
286
+ } catch {
287
+ return null;
288
+ }
289
+ if (raw.type !== "result") return null;
290
+ if (!raw.usage || typeof raw.usage !== "object") return null;
291
+ const usage = raw.usage;
292
+ const modelUsage = raw.modelUsage ?? raw.model_usage;
293
+ const model = modelUsage ? Object.keys(modelUsage)[0] ?? "unknown" : "unknown";
294
+ return {
295
+ type: raw.type,
296
+ subtype: raw.subtype ?? "",
297
+ session_id: raw.session_id ?? "",
298
+ duration_ms: raw.duration_ms ?? 0,
299
+ duration_api_ms: raw.duration_api_ms ?? 0,
300
+ is_error: raw.is_error ?? false,
301
+ num_turns: raw.num_turns ?? 0,
302
+ result: raw.result ?? "",
303
+ total_cost_usd: raw.total_cost_usd ?? 0,
304
+ usage: {
305
+ input_tokens: usage.input_tokens ?? 0,
306
+ output_tokens: usage.output_tokens ?? 0,
307
+ cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
308
+ cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0
309
+ },
310
+ model
311
+ };
312
+ }
313
+ var init_parse = __esm({
314
+ "src/parse.ts"() {
315
+ "use strict";
316
+ }
317
+ });
318
+
319
+ // src/prompt.ts
320
+ function rand(min, max) {
321
+ return Math.floor(Math.random() * (max - min + 1)) + min;
322
+ }
323
+ function generatePrompt() {
324
+ const op = OPERATIONS[Math.floor(Math.random() * OPERATIONS.length)];
325
+ const [a, b] = op.gen();
326
+ const expr = `${a} ${op.symbol} ${b}`;
327
+ const template = TEMPLATES[Math.floor(Math.random() * TEMPLATES.length)];
328
+ return template(expr);
329
+ }
330
+ var TEMPLATES, OPERATIONS;
331
+ var init_prompt = __esm({
332
+ "src/prompt.ts"() {
333
+ "use strict";
334
+ TEMPLATES = [
335
+ (expr) => `Quick, take a guess: what is ${expr}?`,
336
+ (expr) => `Without thinking too hard, what's ${expr}?`,
337
+ (expr) => `Off the top of your head: ${expr} = ?`,
338
+ (expr) => `Just guess, no need to be exact: ${expr}?`,
339
+ (expr) => `Rough estimate: what does ${expr} equal?`
340
+ ];
341
+ OPERATIONS = [
342
+ { symbol: "+", gen: () => [rand(100, 9999), rand(100, 9999)] },
343
+ { symbol: "-", gen: () => [rand(500, 9999), rand(100, 4999)] },
344
+ { symbol: "*", gen: () => [rand(10, 999), rand(10, 999)] },
345
+ { symbol: "/", gen: () => [rand(100, 9999), rand(2, 99)] }
346
+ ];
347
+ }
348
+ });
349
+
350
+ // src/ping.ts
351
+ import { execFile } from "child_process";
352
+ function formatExecError(error) {
353
+ if ("killed" in error && error.killed) {
354
+ return "timed out";
355
+ }
356
+ const msg = error.message;
357
+ if (msg.startsWith("Command failed:")) {
358
+ return "command failed";
359
+ }
360
+ return msg;
361
+ }
362
+ function pingOne(account) {
363
+ const start = Date.now();
364
+ return new Promise((resolve) => {
365
+ const child = execFile(
366
+ "claude",
367
+ [
368
+ "-p",
369
+ generatePrompt(),
370
+ "--output-format",
371
+ "json",
372
+ "--tools",
373
+ "",
374
+ "--max-turns",
375
+ "1"
376
+ ],
377
+ {
378
+ env: { ...process.env, CLAUDE_CONFIG_DIR: account.configDir },
379
+ timeout: 3e4
380
+ },
381
+ (error, stdout) => {
382
+ const claudeResponse = parseClaudeResponse(stdout) ?? void 0;
383
+ const isError = claudeResponse?.is_error === true;
384
+ let errorMsg;
385
+ if (error) {
386
+ if (isError && claudeResponse?.subtype) {
387
+ errorMsg = claudeResponse.subtype;
388
+ } else {
389
+ errorMsg = formatExecError(error);
390
+ }
391
+ } else if (isError) {
392
+ errorMsg = claudeResponse?.subtype;
393
+ }
394
+ resolve({
395
+ handle: account.handle,
396
+ success: !error && !isError,
397
+ durationMs: Date.now() - start,
398
+ error: errorMsg,
399
+ claudeResponse
400
+ });
401
+ }
402
+ );
403
+ child.stdin?.end();
404
+ });
405
+ }
406
+ async function pingAccounts(accounts, options = {}) {
407
+ if (options.parallel) {
408
+ return Promise.all(accounts.map(pingOne));
409
+ }
410
+ const results = [];
411
+ for (const account of accounts) {
412
+ results.push(await pingOne(account));
413
+ }
414
+ return results;
415
+ }
416
+ var init_ping = __esm({
417
+ "src/ping.ts"() {
418
+ "use strict";
419
+ init_parse();
420
+ init_prompt();
421
+ }
422
+ });
423
+
424
+ // src/run-ping.ts
425
+ var run_ping_exports = {};
426
+ __export(run_ping_exports, {
427
+ runPing: () => runPing
428
+ });
429
+ async function runPing(accounts, options) {
430
+ const stdout = options.stdout ?? console.log;
431
+ const logger = createLogger({
432
+ quiet: options.quiet || options.json === true,
433
+ stdout,
434
+ stderr: options.stderr
435
+ });
436
+ const hadNoWindow = /* @__PURE__ */ new Set();
437
+ for (const a of accounts) {
438
+ if (!getWindowReset(a.handle)) {
439
+ hadNoWindow.add(a.handle);
440
+ }
441
+ }
442
+ logger.log(`Pinging ${accounts.length} account(s)...`);
443
+ const sleep = options._sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
444
+ let results;
445
+ if (options.staggerMs && options.staggerMs > 0 && accounts.length > 1) {
446
+ results = [];
447
+ for (let i = 0; i < accounts.length; i++) {
448
+ if (i > 0) {
449
+ const minutes = Math.round(options.staggerMs / 6e4);
450
+ logger.log(` waiting ${minutes}m before next ping...`);
451
+ await sleep(options.staggerMs);
452
+ }
453
+ const [result] = await pingAccounts([accounts[i]], {});
454
+ results.push(result);
455
+ }
456
+ } else {
457
+ results = await pingAccounts(accounts, {
458
+ parallel: options.parallel
459
+ });
460
+ }
461
+ for (const r of results) {
462
+ const status = r.success ? "ok" : "FAIL";
463
+ const detail = r.error ? ` (${r.error})` : "";
464
+ const cr = r.claudeResponse;
465
+ const costInfo = cr ? ` $${cr.total_cost_usd.toFixed(4)} ${cr.usage.input_tokens + cr.usage.output_tokens} tok` : "";
466
+ logger.log(
467
+ ` ${r.handle}: ${status} ${r.durationMs}ms${detail}${costInfo}`
468
+ );
469
+ appendHistoryEntry({
470
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
471
+ handle: r.handle,
472
+ success: r.success,
473
+ durationMs: r.durationMs,
474
+ error: r.error
475
+ });
476
+ if (r.success) {
477
+ let meta;
478
+ if (cr) {
479
+ meta = {
480
+ costUsd: cr.total_cost_usd,
481
+ inputTokens: cr.usage.input_tokens,
482
+ outputTokens: cr.usage.output_tokens,
483
+ model: cr.model,
484
+ sessionId: cr.session_id
485
+ };
486
+ }
487
+ recordPing(r.handle, /* @__PURE__ */ new Date(), meta);
488
+ }
489
+ }
490
+ const failed = results.filter((r) => !r.success).length;
491
+ if (failed > 0 && options.bell) {
492
+ ringBell();
493
+ }
494
+ if (failed > 0 && options.notify) {
495
+ const failedHandles = results.filter((r) => !r.success).map((r) => r.handle);
496
+ await sendNotification(
497
+ "cc-ping: ping failure",
498
+ `${failed} account(s) failed: ${failedHandles.join(", ")}`
499
+ );
500
+ }
501
+ if (options.notify) {
502
+ const newWindows = results.filter((r) => r.success && hadNoWindow.has(r.handle)).map((r) => r.handle);
503
+ if (newWindows.length > 0) {
504
+ let body = `${newWindows.length} account(s) ready: ${newWindows.join(", ")}`;
505
+ if (options.wakeDelayMs) {
506
+ body += ` (woke ${formatTimeRemaining(options.wakeDelayMs)} late)`;
507
+ }
508
+ await sendNotification("cc-ping: new window", body, { sound: true });
509
+ }
510
+ }
511
+ if (options.json) {
512
+ const jsonResults = results.map((r) => ({
513
+ handle: r.handle,
514
+ success: r.success,
515
+ durationMs: r.durationMs,
516
+ error: r.error
517
+ }));
518
+ stdout(JSON.stringify(jsonResults, null, 2));
519
+ return failed > 0 ? 1 : 0;
520
+ }
521
+ if (failed > 0) {
522
+ logger.error(`${failed}/${results.length} failed`);
523
+ return 1;
524
+ }
525
+ logger.log(`
526
+ All ${results.length} accounts pinged successfully`);
527
+ logger.log("\nWindow resets:");
528
+ for (const r of results) {
529
+ const window = getWindowReset(r.handle);
530
+ if (window) {
531
+ logger.log(
532
+ ` ${r.handle}: resets in ${formatTimeRemaining(window.remainingMs)}`
533
+ );
534
+ }
535
+ }
536
+ return 0;
537
+ }
538
+ var init_run_ping = __esm({
539
+ "src/run-ping.ts"() {
540
+ "use strict";
541
+ init_bell();
542
+ init_history();
543
+ init_logger();
544
+ init_notify();
545
+ init_ping();
546
+ init_state();
547
+ }
548
+ });
549
+
550
+ // src/cli.ts
551
+ import { Command } from "commander";
552
+
553
+ // src/check.ts
554
+ import { existsSync, readFileSync, statSync } from "fs";
555
+ import { join } from "path";
556
+ function checkAccount(account) {
557
+ const issues = [];
558
+ if (!existsSync(account.configDir) || !statSync(account.configDir).isDirectory()) {
559
+ issues.push("config directory does not exist");
560
+ return {
561
+ handle: account.handle,
562
+ configDir: account.configDir,
563
+ healthy: false,
564
+ issues
565
+ };
566
+ }
567
+ const claudeJson = join(account.configDir, ".claude.json");
568
+ if (!existsSync(claudeJson)) {
569
+ issues.push(".claude.json not found");
570
+ return {
571
+ handle: account.handle,
572
+ configDir: account.configDir,
573
+ healthy: false,
574
+ issues
575
+ };
576
+ }
577
+ let parsed;
578
+ try {
579
+ const raw = readFileSync(claudeJson, "utf-8");
580
+ parsed = JSON.parse(raw);
581
+ } catch {
582
+ issues.push(".claude.json is not valid JSON");
583
+ return {
584
+ handle: account.handle,
585
+ configDir: account.configDir,
586
+ healthy: false,
587
+ issues
588
+ };
589
+ }
590
+ if (!parsed.oauthAccount) {
591
+ issues.push("no OAuth credentials found");
592
+ }
593
+ return {
594
+ handle: account.handle,
595
+ configDir: account.configDir,
596
+ healthy: issues.length === 0,
597
+ issues
598
+ };
599
+ }
600
+ function checkAccounts(accounts) {
601
+ return accounts.map((a) => checkAccount(a));
602
+ }
603
+
604
+ // src/completions.ts
605
+ var COMMANDS = "ping scan add remove list status next-reset history suggest check completions moo daemon";
606
+ function bashCompletion() {
607
+ return `_cc_ping() {
608
+ local cur prev commands
609
+ COMPREPLY=()
610
+ cur="\${COMP_WORDS[COMP_CWORD]}"
611
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
612
+ commands="${COMMANDS}"
613
+
614
+ if [[ \${COMP_CWORD} -eq 1 ]]; then
615
+ COMPREPLY=( $(compgen -W "\${commands}" -- "\${cur}") )
616
+ return 0
617
+ fi
618
+
619
+ case "\${COMP_WORDS[1]}" in
620
+ ping)
621
+ if [[ "\${cur}" == -* ]]; then
622
+ COMPREPLY=( $(compgen -W "--parallel --quiet --json --group --bell --stagger" -- "\${cur}") )
623
+ else
624
+ local handles=$(cc-ping list 2>/dev/null | sed 's/ *\\(.*\\) ->.*/\\1/')
625
+ COMPREPLY=( $(compgen -W "\${handles}" -- "\${cur}") )
626
+ fi
627
+ ;;
628
+ add)
629
+ COMPREPLY=( $(compgen -W "--group" -- "\${cur}") )
630
+ ;;
631
+ list|history|status|next-reset|check)
632
+ COMPREPLY=( $(compgen -W "--json" -- "\${cur}") )
633
+ ;;
634
+ completions)
635
+ COMPREPLY=( $(compgen -W "bash zsh fish" -- "\${cur}") )
636
+ ;;
637
+ daemon)
638
+ if [[ \${COMP_CWORD} -eq 2 ]]; then
639
+ COMPREPLY=( $(compgen -W "start stop status" -- "\${cur}") )
640
+ elif [[ "\${COMP_WORDS[2]}" == "start" && "\${cur}" == -* ]]; then
641
+ COMPREPLY=( $(compgen -W "--interval --quiet --bell --notify" -- "\${cur}") )
642
+ elif [[ "\${COMP_WORDS[2]}" == "status" && "\${cur}" == -* ]]; then
643
+ COMPREPLY=( $(compgen -W "--json" -- "\${cur}") )
644
+ fi
645
+ ;;
646
+ esac
647
+ return 0
648
+ }
649
+ complete -F _cc_ping cc-ping
650
+ `;
651
+ }
652
+ function zshCompletion() {
653
+ return `#compdef cc-ping
654
+
655
+ _cc_ping() {
656
+ local -a commands
657
+ commands=(
658
+ 'ping:Ping configured accounts'
659
+ 'scan:Auto-discover accounts'
660
+ 'add:Add an account manually'
661
+ 'remove:Remove an account'
662
+ 'list:List configured accounts'
663
+ 'status:Show account status'
664
+ 'next-reset:Show soonest quota reset'
665
+ 'history:Show ping history'
666
+ 'suggest:Suggest next account'
667
+ 'check:Verify account health'
668
+ 'completions:Generate shell completions'
669
+ 'moo:Send a test notification'
670
+ 'daemon:Run auto-ping on a schedule'
671
+ )
672
+
673
+ _arguments -C \\
674
+ '--config[Config directory]:path:_files -/' \\
675
+ '1:command:->command' \\
676
+ '*::arg:->args'
677
+
678
+ case $state in
679
+ command)
680
+ _describe 'command' commands
681
+ ;;
682
+ args)
683
+ case $words[1] in
684
+ ping)
685
+ _arguments \\
686
+ '--parallel[Ping in parallel]' \\
687
+ '--quiet[Suppress output]' \\
688
+ '--json[JSON output]' \\
689
+ '--group[Filter by group]:group:' \\
690
+ '--bell[Ring bell on failure]' \\
691
+ '--stagger[Delay between pings]:minutes:' \\
692
+ '*:handle:->handles'
693
+ if [[ $state == handles ]]; then
694
+ local -a handles
695
+ handles=(\${(f)"$(cc-ping list 2>/dev/null | sed 's/ *\\(.*\\) ->.*/\\1/')"})
696
+ _describe 'handle' handles
697
+ fi
698
+ ;;
699
+ completions)
700
+ _arguments '1:shell:(bash zsh fish)'
701
+ ;;
702
+ list|history|status|next-reset|check)
703
+ _arguments '--json[JSON output]'
704
+ ;;
705
+ add)
706
+ _arguments '--group[Assign group]:group:'
707
+ ;;
708
+ daemon)
709
+ local -a subcmds
710
+ subcmds=(
711
+ 'start:Start the daemon process'
712
+ 'stop:Stop the daemon process'
713
+ 'status:Show daemon status'
714
+ )
715
+ _arguments '1:subcommand:->subcmd' '*::arg:->subargs'
716
+ case $state in
717
+ subcmd)
718
+ _describe 'subcommand' subcmds
719
+ ;;
720
+ subargs)
721
+ case $words[1] in
722
+ start)
723
+ _arguments \\
724
+ '--interval[Ping interval in minutes]:minutes:' \\
725
+ '--quiet[Suppress ping output]' \\
726
+ '--bell[Ring bell on failure]' \\
727
+ '--notify[Send notification on failure]'
728
+ ;;
729
+ status)
730
+ _arguments '--json[JSON output]'
731
+ ;;
732
+ esac
733
+ ;;
734
+ esac
735
+ ;;
736
+ esac
737
+ ;;
738
+ esac
739
+ }
740
+
741
+ _cc_ping
742
+ `;
743
+ }
744
+ function fishCompletion() {
745
+ return `# Fish completions for cc-ping
746
+ set -l commands ping scan add remove list status next-reset history suggest check completions moo
747
+
748
+ complete -c cc-ping -f
749
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a ping -d "Ping configured accounts"
750
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a scan -d "Auto-discover accounts"
751
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a add -d "Add an account manually"
752
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a remove -d "Remove an account"
753
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a list -d "List configured accounts"
754
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a status -d "Show account status"
755
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a next-reset -d "Show soonest quota reset"
756
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a history -d "Show ping history"
757
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a suggest -d "Suggest next account"
758
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a check -d "Verify account health"
759
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a completions -d "Generate shell completions"
760
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a moo -d "Send a test notification"
761
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a daemon -d "Run auto-ping on a schedule"
762
+
763
+ complete -c cc-ping -n "__fish_seen_subcommand_from ping" -l parallel -d "Ping in parallel"
764
+ complete -c cc-ping -n "__fish_seen_subcommand_from ping" -s q -l quiet -d "Suppress output"
765
+ complete -c cc-ping -n "__fish_seen_subcommand_from ping" -l json -d "JSON output"
766
+ complete -c cc-ping -n "__fish_seen_subcommand_from ping" -s g -l group -r -d "Filter by group"
767
+ complete -c cc-ping -n "__fish_seen_subcommand_from ping" -l bell -d "Ring bell on failure"
768
+ complete -c cc-ping -n "__fish_seen_subcommand_from ping" -l stagger -r -d "Delay between pings"
769
+ complete -c cc-ping -n "__fish_seen_subcommand_from ping" -a "(cc-ping list 2>/dev/null | string replace -r ' *(.*) ->.*' '$1')"
770
+
771
+ complete -c cc-ping -n "__fish_seen_subcommand_from list history status next-reset check" -l json -d "JSON output"
772
+ complete -c cc-ping -n "__fish_seen_subcommand_from add" -s g -l group -r -d "Assign group"
773
+ complete -c cc-ping -n "__fish_seen_subcommand_from completions" -a "bash zsh fish"
774
+
775
+ complete -c cc-ping -n "__fish_seen_subcommand_from daemon; and not __fish_seen_subcommand_from start stop status" -a start -d "Start the daemon"
776
+ complete -c cc-ping -n "__fish_seen_subcommand_from daemon; and not __fish_seen_subcommand_from start stop status" -a stop -d "Stop the daemon"
777
+ complete -c cc-ping -n "__fish_seen_subcommand_from daemon; and not __fish_seen_subcommand_from start stop status" -a status -d "Show daemon status"
778
+ complete -c cc-ping -n "__fish_seen_subcommand_from daemon; and __fish_seen_subcommand_from start" -l interval -r -d "Ping interval in minutes"
779
+ complete -c cc-ping -n "__fish_seen_subcommand_from daemon; and __fish_seen_subcommand_from start" -s q -l quiet -d "Suppress output"
780
+ complete -c cc-ping -n "__fish_seen_subcommand_from daemon; and __fish_seen_subcommand_from start" -l bell -d "Ring bell on failure"
781
+ complete -c cc-ping -n "__fish_seen_subcommand_from daemon; and __fish_seen_subcommand_from start" -l notify -d "Send notification on failure"
782
+ complete -c cc-ping -n "__fish_seen_subcommand_from daemon; and __fish_seen_subcommand_from status" -l json -d "JSON output"
783
+ `;
784
+ }
785
+ function generateCompletion(shell) {
786
+ switch (shell) {
787
+ case "bash":
788
+ return bashCompletion();
789
+ case "zsh":
790
+ return zshCompletion();
791
+ case "fish":
792
+ return fishCompletion();
793
+ default:
794
+ throw new Error(
795
+ `Unsupported shell: ${shell}. Supported: bash, zsh, fish`
796
+ );
797
+ }
798
+ }
799
+
800
+ // src/cli.ts
801
+ init_config();
802
+
803
+ // src/daemon.ts
804
+ init_paths();
805
+ init_state();
806
+ import { execSync, spawn } from "child_process";
807
+ import {
808
+ existsSync as existsSync5,
809
+ closeSync as fsCloseSync,
810
+ openSync as fsOpenSync,
811
+ mkdirSync as mkdirSync4,
812
+ readFileSync as readFileSync5,
813
+ unlinkSync,
814
+ writeFileSync as writeFileSync3
815
+ } from "fs";
816
+ import { join as join6 } from "path";
817
+ var GRACEFUL_POLL_MS = 500;
818
+ var GRACEFUL_POLL_ATTEMPTS = 20;
819
+ var POST_KILL_DELAY_MS = 1e3;
820
+ function daemonPidPath() {
821
+ return join6(resolveConfigDir(), "daemon.json");
822
+ }
823
+ function daemonLogPath() {
824
+ return join6(resolveConfigDir(), "daemon.log");
825
+ }
826
+ function daemonStopPath() {
827
+ return join6(resolveConfigDir(), "daemon.stop");
828
+ }
829
+ function writeDaemonState(state) {
830
+ const configDir = resolveConfigDir();
831
+ mkdirSync4(configDir, { recursive: true });
832
+ writeFileSync3(daemonPidPath(), `${JSON.stringify(state, null, 2)}
833
+ `);
834
+ }
835
+ function readDaemonState() {
836
+ const pidPath = daemonPidPath();
837
+ if (!existsSync5(pidPath)) return null;
838
+ try {
839
+ const raw = readFileSync5(pidPath, "utf-8");
840
+ return JSON.parse(raw);
841
+ } catch {
842
+ return null;
843
+ }
844
+ }
845
+ function removeDaemonState() {
846
+ const pidPath = daemonPidPath();
847
+ if (!existsSync5(pidPath)) return false;
848
+ unlinkSync(pidPath);
849
+ return true;
850
+ }
851
+ function isProcessRunning(pid) {
852
+ try {
853
+ process.kill(pid, 0);
854
+ return true;
855
+ } catch (err) {
856
+ if (err.code === "EPERM") return true;
857
+ return false;
858
+ }
859
+ }
860
+ function isDaemonProcess(pid) {
861
+ try {
862
+ const output = execSync(`ps -p ${pid} -o command=`, {
863
+ encoding: "utf-8",
864
+ timeout: 5e3,
865
+ stdio: ["ignore", "pipe", "ignore"]
866
+ }).trim();
867
+ return output.includes("cc-ping");
868
+ } catch {
869
+ return false;
870
+ }
871
+ }
872
+ function parseInterval(value) {
873
+ if (!value) return QUOTA_WINDOW_MS;
874
+ const minutes = Number(value);
875
+ if (Number.isNaN(minutes)) {
876
+ throw new Error(`Invalid interval value: ${value}`);
877
+ }
878
+ if (minutes <= 0) {
879
+ throw new Error("Interval must be a positive number");
880
+ }
881
+ return minutes * 60 * 1e3;
882
+ }
883
+ function getDaemonStatus(deps) {
884
+ const _isDaemonProcess = deps?.isDaemonProcess ?? isDaemonProcess;
885
+ const state = readDaemonState();
886
+ if (!state) return { running: false };
887
+ if (!isProcessRunning(state.pid) || !_isDaemonProcess(state.pid)) {
888
+ removeDaemonState();
889
+ return { running: false };
890
+ }
891
+ const startedAt = new Date(state.startedAt);
892
+ const uptimeMs = Date.now() - startedAt.getTime();
893
+ const uptime = formatUptime(uptimeMs);
894
+ let nextPingIn;
895
+ if (state.lastPingAt) {
896
+ const nextPingMs = new Date(state.lastPingAt).getTime() + state.intervalMs - Date.now();
897
+ nextPingIn = formatUptime(Math.max(0, nextPingMs));
898
+ }
899
+ return {
900
+ running: true,
901
+ pid: state.pid,
902
+ startedAt: state.startedAt,
903
+ intervalMs: state.intervalMs,
904
+ uptime,
905
+ nextPingIn
906
+ };
907
+ }
908
+ function formatUptime(ms) {
909
+ const totalSeconds = Math.floor(ms / 1e3);
910
+ const hours = Math.floor(totalSeconds / 3600);
911
+ const minutes = Math.floor(totalSeconds % 3600 / 60);
912
+ const seconds = totalSeconds % 60;
913
+ if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`;
914
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
915
+ return `${seconds}s`;
916
+ }
917
+ async function daemonLoop(intervalMs, options, deps) {
918
+ let wakeDelayMs;
919
+ while (!deps.shouldStop()) {
920
+ const allAccounts = deps.listAccounts();
921
+ const accounts = deps.isWindowActive ? allAccounts.filter((a) => !deps.isWindowActive(a.handle)) : allAccounts;
922
+ const skipped = allAccounts.length - accounts.length;
923
+ if (skipped > 0) {
924
+ deps.log(`Skipping ${skipped} account(s) with active window`);
925
+ }
926
+ if (accounts.length === 0) {
927
+ deps.log(
928
+ allAccounts.length === 0 ? "No accounts configured, waiting..." : "All accounts have active windows, waiting..."
929
+ );
930
+ } else {
931
+ deps.log(
932
+ `[${(/* @__PURE__ */ new Date()).toISOString()}] Pinging ${accounts.length} account(s)...`
933
+ );
934
+ await deps.runPing(accounts, {
935
+ parallel: false,
936
+ quiet: options.quiet ?? true,
937
+ bell: options.bell,
938
+ notify: options.notify,
939
+ wakeDelayMs
940
+ });
941
+ deps.updateState?.({ lastPingAt: (/* @__PURE__ */ new Date()).toISOString() });
942
+ }
943
+ if (deps.shouldStop()) break;
944
+ deps.log(`Sleeping ${Math.round(intervalMs / 6e4)}m until next ping...`);
945
+ const sleepStart = Date.now();
946
+ await deps.sleep(intervalMs);
947
+ const overshootMs = Date.now() - sleepStart - intervalMs;
948
+ if (overshootMs > 6e4) {
949
+ wakeDelayMs = overshootMs;
950
+ deps.log(`Woke ${formatUptime(overshootMs)} late (system sleep?)`);
951
+ } else {
952
+ wakeDelayMs = void 0;
953
+ }
954
+ }
955
+ }
956
+ function startDaemon(options, deps) {
957
+ const _getDaemonStatus = deps?.getDaemonStatus ?? getDaemonStatus;
958
+ const _spawn = deps?.spawn ?? spawn;
959
+ const _writeDaemonState = deps?.writeDaemonState ?? writeDaemonState;
960
+ const _openSync = deps?.openSync ?? fsOpenSync;
961
+ const _closeSync = deps?.closeSync ?? fsCloseSync;
962
+ const status = _getDaemonStatus();
963
+ if (status.running) {
964
+ return {
965
+ success: false,
966
+ pid: status.pid,
967
+ error: "Daemon is already running"
968
+ };
969
+ }
970
+ let intervalMs;
971
+ try {
972
+ intervalMs = parseInterval(options.interval);
973
+ } catch (err) {
974
+ return { success: false, error: err.message };
975
+ }
976
+ const configDir = resolveConfigDir();
977
+ mkdirSync4(configDir, { recursive: true });
978
+ const logPath = daemonLogPath();
979
+ const logFd = _openSync(logPath, "a");
980
+ const args = ["daemon", "_run", "--interval-ms", String(intervalMs)];
981
+ if (options.quiet) args.push("--quiet");
982
+ if (options.bell) args.push("--bell");
983
+ if (options.notify) args.push("--notify");
984
+ const child = _spawn(process.execPath, [process.argv[1], ...args], {
985
+ detached: true,
986
+ stdio: ["ignore", logFd, logFd],
987
+ windowsHide: true
988
+ });
989
+ if (!child.pid) {
990
+ _closeSync(logFd);
991
+ return { success: false, error: "Failed to spawn daemon process" };
992
+ }
993
+ child.unref();
994
+ _closeSync(logFd);
995
+ _writeDaemonState({
996
+ pid: child.pid,
997
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
998
+ intervalMs,
999
+ configDir
1000
+ });
1001
+ return { success: true, pid: child.pid };
1002
+ }
1003
+ async function stopDaemon(deps) {
1004
+ const _getDaemonStatus = deps?.getDaemonStatus ?? getDaemonStatus;
1005
+ const _writeStopFile = deps?.writeStopFile ?? /* c8 ignore next 5 -- production default */
1006
+ (() => {
1007
+ const configDir = resolveConfigDir();
1008
+ mkdirSync4(configDir, { recursive: true });
1009
+ writeFileSync3(daemonStopPath(), "");
1010
+ });
1011
+ const _isProcessRunning = deps?.isProcessRunning ?? isProcessRunning;
1012
+ const _removeDaemonState = deps?.removeDaemonState ?? removeDaemonState;
1013
+ const _removeStopFile = deps?.removeStopFile ?? /* c8 ignore next 4 -- production default */
1014
+ (() => {
1015
+ const stopPath = daemonStopPath();
1016
+ if (existsSync5(stopPath)) unlinkSync(stopPath);
1017
+ });
1018
+ const _sleep = deps?.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
1019
+ const _kill = deps?.kill ?? /* c8 ignore next 7 -- production default */
1020
+ ((pid2) => {
1021
+ if (process.platform === "win32") {
1022
+ execSync(`taskkill /PID ${pid2}`);
1023
+ } else {
1024
+ process.kill(pid2, "SIGTERM");
1025
+ }
1026
+ });
1027
+ const status = _getDaemonStatus();
1028
+ if (!status.running || !status.pid) {
1029
+ return { success: false, error: "Daemon is not running" };
1030
+ }
1031
+ const pid = status.pid;
1032
+ _writeStopFile();
1033
+ for (let i = 0; i < GRACEFUL_POLL_ATTEMPTS; i++) {
1034
+ await _sleep(GRACEFUL_POLL_MS);
1035
+ if (!_isProcessRunning(pid)) {
1036
+ _removeDaemonState();
1037
+ _removeStopFile();
1038
+ return { success: true, pid };
1039
+ }
1040
+ }
1041
+ try {
1042
+ _kill(pid);
1043
+ } catch {
1044
+ }
1045
+ await _sleep(POST_KILL_DELAY_MS);
1046
+ _removeDaemonState();
1047
+ _removeStopFile();
1048
+ return { success: true, pid };
1049
+ }
1050
+ async function runDaemon(intervalMs, options, deps) {
1051
+ const stopPath = daemonStopPath();
1052
+ const cleanup = () => {
1053
+ if (existsSync5(stopPath)) unlinkSync(stopPath);
1054
+ removeDaemonState();
1055
+ };
1056
+ const onSigterm = () => {
1057
+ deps.log("Received SIGTERM, shutting down...");
1058
+ cleanup();
1059
+ deps.exit(0);
1060
+ };
1061
+ const onSigint = () => {
1062
+ deps.log("Received SIGINT, shutting down...");
1063
+ cleanup();
1064
+ deps.exit(0);
1065
+ };
1066
+ deps.onSignal("SIGTERM", onSigterm);
1067
+ deps.onSignal("SIGINT", onSigint);
1068
+ deps.log(`Daemon started. Interval: ${Math.round(intervalMs / 6e4)}m`);
1069
+ try {
1070
+ await daemonLoop(intervalMs, options, deps);
1071
+ } finally {
1072
+ deps.removeSignal("SIGTERM", onSigterm);
1073
+ deps.removeSignal("SIGINT", onSigint);
1074
+ deps.log("Daemon stopping...");
1075
+ cleanup();
1076
+ }
1077
+ }
1078
+ async function runDaemonWithDefaults(intervalMs, options) {
1079
+ const stopPath = daemonStopPath();
1080
+ const { runPing: runPing2 } = await Promise.resolve().then(() => (init_run_ping(), run_ping_exports));
1081
+ const { listAccounts: listAccounts2 } = await Promise.resolve().then(() => (init_config(), config_exports));
1082
+ const { getWindowReset: getWindowReset2 } = await Promise.resolve().then(() => (init_state(), state_exports));
1083
+ await runDaemon(intervalMs, options, {
1084
+ runPing: runPing2,
1085
+ listAccounts: listAccounts2,
1086
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
1087
+ shouldStop: () => existsSync5(stopPath),
1088
+ log: (msg) => console.log(msg),
1089
+ isWindowActive: (handle) => getWindowReset2(handle) !== null,
1090
+ updateState: (patch) => {
1091
+ const current = readDaemonState();
1092
+ if (current) writeDaemonState({ ...current, ...patch });
1093
+ },
1094
+ onSignal: (signal, handler) => process.on(signal, handler),
1095
+ removeSignal: (signal, handler) => process.removeListener(signal, handler),
1096
+ exit: (code) => process.exit(code)
1097
+ });
1098
+ }
1099
+
1100
+ // src/filter-accounts.ts
1101
+ function filterAccounts(accounts, handles) {
1102
+ if (handles.length === 0) return accounts;
1103
+ const unknown = handles.filter((h) => !accounts.some((a) => a.handle === h));
1104
+ if (unknown.length > 0) {
1105
+ throw new Error(`Unknown account(s): ${unknown.join(", ")}`);
1106
+ }
1107
+ const set = new Set(handles);
1108
+ return accounts.filter((a) => set.has(a.handle));
1109
+ }
1110
+ function filterByGroup(accounts, group) {
1111
+ if (!group) return accounts;
1112
+ const filtered = accounts.filter((a) => a.group === group);
1113
+ if (filtered.length === 0) {
1114
+ throw new Error(`No accounts in group: ${group}`);
1115
+ }
1116
+ return filtered;
1117
+ }
1118
+
1119
+ // src/cli.ts
1120
+ init_history();
1121
+
1122
+ // src/identity.ts
1123
+ import { readFileSync as readFileSync6 } from "fs";
1124
+ import { join as join7 } from "path";
1125
+ function readAccountIdentity(configDir) {
1126
+ let raw;
1127
+ try {
1128
+ raw = readFileSync6(join7(configDir, ".claude.json"), "utf-8");
1129
+ } catch {
1130
+ return null;
1131
+ }
1132
+ let parsed;
1133
+ try {
1134
+ parsed = JSON.parse(raw);
1135
+ } catch {
1136
+ return null;
1137
+ }
1138
+ const oauth = parsed.oauthAccount;
1139
+ if (!oauth) return null;
1140
+ const accountUuid = oauth.accountUuid;
1141
+ const email = oauth.emailAddress;
1142
+ if (typeof accountUuid !== "string" || typeof email !== "string") return null;
1143
+ return { accountUuid, email };
1144
+ }
1145
+ function findDuplicates(accounts) {
1146
+ const byUuid = /* @__PURE__ */ new Map();
1147
+ for (const account of accounts) {
1148
+ const identity = readAccountIdentity(account.configDir);
1149
+ if (!identity) continue;
1150
+ const existing = byUuid.get(identity.accountUuid);
1151
+ if (existing) {
1152
+ existing.handles.push(account.handle);
1153
+ } else {
1154
+ byUuid.set(identity.accountUuid, {
1155
+ handles: [account.handle],
1156
+ email: identity.email
1157
+ });
1158
+ }
1159
+ }
1160
+ const result = /* @__PURE__ */ new Map();
1161
+ for (const [uuid, group] of byUuid) {
1162
+ if (group.handles.length >= 2) {
1163
+ result.set(uuid, group);
1164
+ }
1165
+ }
1166
+ return result;
1167
+ }
1168
+
1169
+ // src/next-reset.ts
1170
+ init_state();
1171
+ function getNextReset(accounts, now = /* @__PURE__ */ new Date()) {
1172
+ let best = null;
1173
+ for (const account of accounts) {
1174
+ const window = getWindowReset(account.handle, now);
1175
+ if (!window) continue;
1176
+ if (best === null || window.remainingMs < best.remainingMs) {
1177
+ best = {
1178
+ handle: account.handle,
1179
+ configDir: account.configDir,
1180
+ remainingMs: window.remainingMs,
1181
+ resetAt: window.resetAt.toISOString(),
1182
+ timeUntilReset: formatTimeRemaining(window.remainingMs)
1183
+ };
1184
+ }
1185
+ }
1186
+ return best;
1187
+ }
1188
+
1189
+ // src/cli.ts
1190
+ init_notify();
1191
+ init_paths();
1192
+ init_run_ping();
1193
+
1194
+ // src/scan.ts
1195
+ import { existsSync as existsSync6, readdirSync, statSync as statSync2 } from "fs";
1196
+ import { homedir as homedir2 } from "os";
1197
+ import { join as join8 } from "path";
1198
+ var ACCOUNTS_DIR = join8(homedir2(), ".claude-accounts");
1199
+ function scanAccounts() {
1200
+ if (!existsSync6(ACCOUNTS_DIR)) return [];
1201
+ return readdirSync(ACCOUNTS_DIR).filter((name) => {
1202
+ const full = join8(ACCOUNTS_DIR, name);
1203
+ return statSync2(full).isDirectory() && !name.startsWith(".");
1204
+ }).map((name) => ({
1205
+ handle: name,
1206
+ configDir: join8(ACCOUNTS_DIR, name)
1207
+ }));
1208
+ }
1209
+
1210
+ // src/stagger.ts
1211
+ init_state();
1212
+ function calculateStagger(accountCount, windowMs = QUOTA_WINDOW_MS) {
1213
+ if (accountCount <= 1) return 0;
1214
+ return Math.floor(windowMs / accountCount);
1215
+ }
1216
+ function parseStagger(value, accountCount) {
1217
+ if (value === "auto") {
1218
+ return calculateStagger(accountCount);
1219
+ }
1220
+ const minutes = Number(value);
1221
+ if (Number.isNaN(minutes)) {
1222
+ throw new Error(`Invalid stagger value: ${value}`);
1223
+ }
1224
+ if (minutes <= 0) {
1225
+ throw new Error("Stagger must be a positive number");
1226
+ }
1227
+ return minutes * 60 * 1e3;
1228
+ }
1229
+
1230
+ // src/status.ts
1231
+ init_config();
1232
+ init_state();
1233
+ function formatStatusLine(status) {
1234
+ const ping = status.lastPing === null ? "never" : status.lastPing.replace("T", " ").replace(/\.\d+Z$/, "Z");
1235
+ const reset = status.timeUntilReset !== null ? ` (resets in ${status.timeUntilReset})` : "";
1236
+ const cost = status.lastCostUsd !== null && status.lastTokens !== null ? ` $${status.lastCostUsd.toFixed(4)} ${status.lastTokens} tok` : "";
1237
+ const dup = status.duplicateOf ? ` [duplicate of ${status.duplicateOf}]` : "";
1238
+ return ` ${status.handle}: ${status.windowStatus} last ping: ${ping}${reset}${cost}${dup}`;
1239
+ }
1240
+ function getAccountStatuses(accounts, now = /* @__PURE__ */ new Date(), duplicates) {
1241
+ const dupLookup = /* @__PURE__ */ new Map();
1242
+ if (duplicates) {
1243
+ for (const group of duplicates.values()) {
1244
+ for (const handle of group.handles) {
1245
+ const others = group.handles.filter((h) => h !== handle).join(", ");
1246
+ if (others) dupLookup.set(handle, others);
1247
+ }
1248
+ }
1249
+ }
1250
+ return accounts.map((account) => {
1251
+ const lastPing = getLastPing(account.handle);
1252
+ const meta = getLastPingMeta(account.handle);
1253
+ const lastCostUsd = meta?.costUsd ?? null;
1254
+ const lastTokens = meta !== null ? meta.inputTokens + meta.outputTokens : null;
1255
+ const duplicateOf = dupLookup.get(account.handle);
1256
+ if (!lastPing) {
1257
+ return {
1258
+ handle: account.handle,
1259
+ configDir: account.configDir,
1260
+ lastPing: null,
1261
+ windowStatus: "unknown",
1262
+ timeUntilReset: null,
1263
+ lastCostUsd,
1264
+ lastTokens,
1265
+ duplicateOf
1266
+ };
1267
+ }
1268
+ const window = getWindowReset(account.handle, now);
1269
+ return {
1270
+ handle: account.handle,
1271
+ configDir: account.configDir,
1272
+ lastPing: lastPing.toISOString(),
1273
+ windowStatus: window ? "active" : "expired",
1274
+ timeUntilReset: window ? formatTimeRemaining(window.remainingMs) : null,
1275
+ lastCostUsd,
1276
+ lastTokens,
1277
+ duplicateOf
1278
+ };
1279
+ });
1280
+ }
1281
+ function printAccountTable(log = console.log, now = /* @__PURE__ */ new Date()) {
1282
+ const accounts = listAccounts();
1283
+ if (accounts.length === 0) {
1284
+ log("No accounts configured");
1285
+ return;
1286
+ }
1287
+ const dupes = findDuplicates(accounts);
1288
+ const statuses = getAccountStatuses(accounts, now, dupes);
1289
+ for (const s of statuses) {
1290
+ log(formatStatusLine(s));
1291
+ }
1292
+ }
1293
+
1294
+ // src/suggest.ts
1295
+ init_state();
1296
+ function suggestAccount(accounts, now = /* @__PURE__ */ new Date()) {
1297
+ if (accounts.length === 0) return null;
1298
+ for (const account of accounts) {
1299
+ const window = getWindowReset(account.handle, now);
1300
+ if (!window) {
1301
+ return {
1302
+ handle: account.handle,
1303
+ configDir: account.configDir,
1304
+ reason: "no active window",
1305
+ timeUntilReset: null
1306
+ };
1307
+ }
1308
+ }
1309
+ let best = null;
1310
+ for (const account of accounts) {
1311
+ const window = getWindowReset(account.handle, now);
1312
+ if (window && (best === null || window.remainingMs > best.remainingMs)) {
1313
+ best = { account, remainingMs: window.remainingMs };
1314
+ }
1315
+ }
1316
+ return {
1317
+ handle: best.account.handle,
1318
+ configDir: best.account.configDir,
1319
+ reason: "most remaining window time",
1320
+ timeUntilReset: formatTimeRemaining(best.remainingMs)
1321
+ };
1322
+ }
1323
+
1324
+ // src/cli.ts
1325
+ var program = new Command().name("cc-ping").description("Ping Claude Code sessions to trigger quota windows early").version("0.1.0").option(
1326
+ "--config <path>",
1327
+ "Path to config directory (default: ~/.config/cc-ping, env: CC_PING_CONFIG)"
1328
+ ).hook("preAction", (thisCommand) => {
1329
+ const opts = thisCommand.opts();
1330
+ if (opts.config) {
1331
+ setConfigDir(opts.config);
1332
+ }
1333
+ });
1334
+ program.command("ping").description("Ping configured accounts to start quota windows").argument("[handles...]", "Specific account handles to ping (default: all)").option("--parallel", "Ping all accounts in parallel", false).option("-q, --quiet", "Suppress all output except errors (for cron)", false).option("--json", "Output results as JSON", false).option("-g, --group <group>", "Ping only accounts in this group").option("--bell", "Ring terminal bell on ping failure", false).option("--notify", "Send desktop notification on ping failure", false).option(
1335
+ "--stagger <minutes|auto>",
1336
+ "Delay between account pings (minutes or 'auto')"
1337
+ ).action(async (handles, opts) => {
1338
+ const accounts = listAccounts();
1339
+ if (accounts.length === 0) {
1340
+ console.error(
1341
+ "No accounts configured. Run: cc-ping scan or cc-ping add <handle> <dir>"
1342
+ );
1343
+ process.exit(1);
1344
+ }
1345
+ const targets = filterAccounts(
1346
+ filterByGroup(accounts, opts.group),
1347
+ handles
1348
+ );
1349
+ const staggerMs = opts.stagger ? parseStagger(opts.stagger, targets.length) : void 0;
1350
+ const exitCode = await runPing(targets, {
1351
+ parallel: opts.parallel,
1352
+ quiet: opts.quiet,
1353
+ json: opts.json,
1354
+ bell: opts.bell,
1355
+ notify: opts.notify,
1356
+ staggerMs
1357
+ });
1358
+ process.exit(exitCode);
1359
+ });
1360
+ program.command("check").description(
1361
+ "Verify account config directories are valid and have credentials"
1362
+ ).option("--json", "Output as JSON", false).action((opts) => {
1363
+ const accounts = listAccounts();
1364
+ if (accounts.length === 0) {
1365
+ console.log("No accounts configured");
1366
+ return;
1367
+ }
1368
+ const results = checkAccounts(accounts);
1369
+ if (opts.json) {
1370
+ console.log(JSON.stringify(results, null, 2));
1371
+ return;
1372
+ }
1373
+ for (const r of results) {
1374
+ const status = r.healthy ? "healthy" : "UNHEALTHY";
1375
+ const issues = r.issues.length > 0 ? ` (${r.issues.join("; ")})` : "";
1376
+ console.log(` ${r.handle}: ${status}${issues}`);
1377
+ }
1378
+ const unhealthy = results.filter((r) => !r.healthy).length;
1379
+ if (unhealthy > 0) {
1380
+ process.exit(1);
1381
+ }
1382
+ });
1383
+ program.command("scan").description("Auto-discover accounts from ~/.claude-accounts/").option("--dry-run", "Show what would be added without saving", false).action((opts) => {
1384
+ const found = scanAccounts();
1385
+ if (found.length === 0) {
1386
+ console.log("No accounts found in ~/.claude-accounts/");
1387
+ return;
1388
+ }
1389
+ console.log(`Found ${found.length} account(s):`);
1390
+ for (const a of found) {
1391
+ console.log(` ${a.handle} -> ${a.configDir}`);
1392
+ }
1393
+ if (!opts.dryRun) {
1394
+ saveConfig({ accounts: found });
1395
+ console.log("\nSaved to config.");
1396
+ }
1397
+ const dupes = findDuplicates(found);
1398
+ if (dupes.size > 0) {
1399
+ console.log(
1400
+ "\nWarning: duplicate accounts detected (same underlying identity):"
1401
+ );
1402
+ for (const group of dupes.values()) {
1403
+ console.log(` ${group.handles.join(", ")} (${group.email})`);
1404
+ }
1405
+ }
1406
+ });
1407
+ program.command("add").description("Add an account manually").argument("<handle>", "Account handle/name").argument("<config-dir>", "Path to the CLAUDE_CONFIG_DIR for this account").option("-g, --group <group>", "Assign account to a group").action((handle, configDir, opts) => {
1408
+ addAccount(handle, configDir, opts.group);
1409
+ const groupInfo = opts.group ? ` [${opts.group}]` : "";
1410
+ console.log(`Added: ${handle} -> ${configDir}${groupInfo}`);
1411
+ });
1412
+ program.command("remove").description("Remove an account").argument("<handle>", "Account handle to remove").action((handle) => {
1413
+ if (removeAccount(handle)) {
1414
+ console.log(`Removed: ${handle}`);
1415
+ } else {
1416
+ console.error(`Account not found: ${handle}`);
1417
+ process.exit(1);
1418
+ }
1419
+ });
1420
+ program.command("list").description("List configured accounts").option("--json", "Output as JSON", false).action((opts) => {
1421
+ const accounts = listAccounts();
1422
+ if (accounts.length === 0) {
1423
+ console.log(opts.json ? "[]" : "No accounts configured");
1424
+ return;
1425
+ }
1426
+ if (opts.json) {
1427
+ console.log(JSON.stringify(accounts, null, 2));
1428
+ return;
1429
+ }
1430
+ for (const a of accounts) {
1431
+ console.log(` ${a.handle} -> ${a.configDir}`);
1432
+ }
1433
+ });
1434
+ program.command("status").description("Show status of all accounts with window information").option("--json", "Output as JSON", false).action((opts) => {
1435
+ if (opts.json) {
1436
+ const accounts = listAccounts();
1437
+ const dupes = findDuplicates(accounts);
1438
+ const statuses = getAccountStatuses(accounts, /* @__PURE__ */ new Date(), dupes);
1439
+ console.log(JSON.stringify(statuses, null, 2));
1440
+ return;
1441
+ }
1442
+ printAccountTable();
1443
+ });
1444
+ program.command("next-reset").description("Show which account has its quota window resetting soonest").option("--json", "Output as JSON", false).action((opts) => {
1445
+ const accounts = listAccounts();
1446
+ if (accounts.length === 0) {
1447
+ console.log(opts.json ? "null" : "No accounts configured");
1448
+ return;
1449
+ }
1450
+ const result = getNextReset(accounts);
1451
+ if (opts.json) {
1452
+ console.log(JSON.stringify(result ?? null, null, 2));
1453
+ return;
1454
+ }
1455
+ if (!result) {
1456
+ console.log("No active quota windows");
1457
+ return;
1458
+ }
1459
+ console.log(
1460
+ `${result.handle}: resets in ${result.timeUntilReset} (${result.configDir})`
1461
+ );
1462
+ });
1463
+ program.command("suggest").description("Suggest which account to use next based on quota window state").option("--json", "Output as JSON", false).action((opts) => {
1464
+ const accounts = listAccounts();
1465
+ if (accounts.length === 0) {
1466
+ console.log(opts.json ? "null" : "No accounts configured");
1467
+ return;
1468
+ }
1469
+ const result = suggestAccount(accounts);
1470
+ if (opts.json) {
1471
+ console.log(JSON.stringify(result ?? null, null, 2));
1472
+ return;
1473
+ }
1474
+ if (!result) {
1475
+ console.log("No accounts configured");
1476
+ return;
1477
+ }
1478
+ const resetInfo = result.timeUntilReset ? `, resets in ${result.timeUntilReset}` : "";
1479
+ console.log(
1480
+ `${result.handle} (${result.reason}${resetInfo}) -> ${result.configDir}`
1481
+ );
1482
+ });
1483
+ program.command("history").description("Show recent ping history").option("--limit <n>", "Number of entries to show", "20").option("--json", "Output as JSON", false).action((opts) => {
1484
+ const limit = Number.parseInt(opts.limit, 10);
1485
+ const entries = readHistory(limit);
1486
+ if (entries.length === 0) {
1487
+ console.log(opts.json ? "[]" : "No ping history");
1488
+ return;
1489
+ }
1490
+ if (opts.json) {
1491
+ console.log(JSON.stringify(entries, null, 2));
1492
+ return;
1493
+ }
1494
+ for (const entry of entries) {
1495
+ console.log(formatHistoryEntry(entry));
1496
+ }
1497
+ });
1498
+ program.command("completions").description("Generate shell completion script").argument("<shell>", "Shell type: bash, zsh, or fish").action((shell) => {
1499
+ console.log(generateCompletion(shell));
1500
+ });
1501
+ program.command("moo").description("Send a test notification to verify desktop notifications work").action(async () => {
1502
+ const ok = await sendNotification(
1503
+ "cc-ping",
1504
+ "Moo! Notifications are working."
1505
+ );
1506
+ if (ok) {
1507
+ console.log("Notification sent");
1508
+ } else {
1509
+ console.error(
1510
+ "Notification failed (unsupported platform or command error)"
1511
+ );
1512
+ process.exit(1);
1513
+ }
1514
+ });
1515
+ var daemon = program.command("daemon").description("Run auto-ping on a schedule");
1516
+ daemon.command("start").description("Start the daemon process").option(
1517
+ "--interval <minutes>",
1518
+ "Ping interval in minutes (default: 300 = 5h quota window)"
1519
+ ).option("-q, --quiet", "Suppress ping output", false).option("--bell", "Ring terminal bell on ping failure", false).option("--notify", "Send desktop notification on ping failure", false).action((opts) => {
1520
+ const result = startDaemon({
1521
+ interval: opts.interval,
1522
+ quiet: opts.quiet,
1523
+ bell: opts.bell,
1524
+ notify: opts.notify
1525
+ });
1526
+ if (!result.success) {
1527
+ console.error(result.error);
1528
+ process.exit(1);
1529
+ }
1530
+ console.log(`Daemon started (PID: ${result.pid})`);
1531
+ printAccountTable();
1532
+ });
1533
+ daemon.command("stop").description("Stop the daemon process").action(async () => {
1534
+ const result = await stopDaemon();
1535
+ if (!result.success) {
1536
+ console.error(result.error);
1537
+ process.exit(1);
1538
+ }
1539
+ console.log(`Daemon stopped (PID: ${result.pid})`);
1540
+ });
1541
+ daemon.command("status").description("Show daemon status").option("--json", "Output as JSON", false).action((opts) => {
1542
+ const status = getDaemonStatus();
1543
+ if (opts.json) {
1544
+ if (!status.running) {
1545
+ console.log(JSON.stringify(status, null, 2));
1546
+ return;
1547
+ }
1548
+ const accounts = listAccounts();
1549
+ const dupes = findDuplicates(accounts);
1550
+ const accountStatuses = getAccountStatuses(accounts, /* @__PURE__ */ new Date(), dupes);
1551
+ console.log(
1552
+ JSON.stringify({ ...status, accounts: accountStatuses }, null, 2)
1553
+ );
1554
+ return;
1555
+ }
1556
+ if (!status.running) {
1557
+ console.log("Daemon is not running");
1558
+ return;
1559
+ }
1560
+ console.log(`Daemon is running (PID: ${status.pid})`);
1561
+ console.log(` Started: ${status.startedAt}`);
1562
+ console.log(
1563
+ ` Interval: ${Math.round((status.intervalMs ?? 0) / 6e4)}m`
1564
+ );
1565
+ console.log(` Uptime: ${status.uptime}`);
1566
+ if (status.nextPingIn) {
1567
+ console.log(` Next ping in: ${status.nextPingIn}`);
1568
+ }
1569
+ console.log("");
1570
+ printAccountTable();
1571
+ });
1572
+ daemon.command("_run", { hidden: true }).option("--interval-ms <ms>", "Ping interval in milliseconds").option("-q, --quiet", "Suppress ping output", false).option("--bell", "Ring terminal bell on ping failure", false).option("--notify", "Send desktop notification on ping failure", false).action(async (opts) => {
1573
+ const intervalMs = Number(opts.intervalMs);
1574
+ if (!intervalMs || intervalMs <= 0) {
1575
+ console.error("Invalid --interval-ms");
1576
+ process.exit(1);
1577
+ }
1578
+ await runDaemonWithDefaults(intervalMs, {
1579
+ quiet: opts.quiet,
1580
+ bell: opts.bell,
1581
+ notify: opts.notify
1582
+ });
1583
+ });
1584
+ program.parse();