@wbern/cc-ping 0.1.0 → 1.0.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 (3) hide show
  1. package/README.md +241 -0
  2. package/dist/cli.js +648 -267
  3. package/package.json +15 -13
package/README.md ADDED
@@ -0,0 +1,241 @@
1
+ # @wbern/cc-ping
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@wbern/cc-ping)](https://www.npmjs.com/package/@wbern/cc-ping)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@wbern/cc-ping)](https://www.npmjs.com/package/@wbern/cc-ping)
5
+ [![CI](https://github.com/wbern/cc-ping/actions/workflows/ci.yml/badge.svg)](https://github.com/wbern/cc-ping/actions/workflows/ci.yml)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ **Ping Claude Code sessions to trigger quota windows early across multiple accounts.**
9
+
10
+ Claude Code has a 5-hour quota window that starts on your first message. If you rotate between accounts, your idle accounts sit there with full quota doing nothing. cc-ping pings them so their windows start ticking — when you need them, they've already reset.
11
+
12
+ Zero telemetry. No data is collected, sent, or phoned home. Everything stays in `~/.config/cc-ping/`. The only network activity is the `claude` CLI call itself, which is subject to Anthropic's normal Claude Code telemetry.
13
+
14
+ ## Prerequisites
15
+
16
+ [Claude Code](https://docs.anthropic.com/en/docs/claude-code) must be installed and on your PATH.
17
+
18
+ ```bash
19
+ claude --version # verify it's available
20
+ ```
21
+
22
+ ## Quick run (no install)
23
+
24
+ ```bash
25
+ pnpm dlx @wbern/cc-ping ping
26
+ ```
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pnpm add -g @wbern/cc-ping
32
+ ```
33
+
34
+ ```bash
35
+ npm install -g @wbern/cc-ping # also works
36
+ ```
37
+
38
+ ## Setup
39
+
40
+ Discover accounts from `~/.claude-accounts/`, then verify they have valid credentials:
41
+
42
+ ```bash
43
+ cc-ping scan # auto-discover accounts
44
+ cc-ping check # verify credentials are valid
45
+ cc-ping list # show configured accounts
46
+ ```
47
+
48
+ Or add accounts manually:
49
+
50
+ ```bash
51
+ cc-ping add my-account ~/.claude-accounts/my-account
52
+ ```
53
+
54
+ ## Usage
55
+
56
+ ```bash
57
+ cc-ping ping # ping all accounts
58
+ cc-ping ping alice bob # ping specific accounts
59
+ cc-ping ping --parallel # ping all at once
60
+ cc-ping ping --notify # desktop notification on new windows
61
+ cc-ping ping --bell # terminal bell on failure
62
+ cc-ping ping --stagger 5s # wait 5s between each account
63
+ cc-ping status # show account status table
64
+ cc-ping suggest # which account should I use next?
65
+ cc-ping next-reset # when does the next window expire?
66
+ cc-ping daemon start --interval 300m # auto-ping every 5 hours
67
+ cc-ping history # recent ping results
68
+ ```
69
+
70
+ ## Commands
71
+
72
+ ### `cc-ping ping [handles...]`
73
+
74
+ Ping configured accounts to start their quota windows. Pings accounts sequentially by default.
75
+
76
+ | Flag | Default | Description |
77
+ |------|---------|-------------|
78
+ | `--parallel` | `false` | Ping all accounts simultaneously |
79
+ | `--stagger <duration>` | — | Wait between each account (e.g. `5s`, `1m`) |
80
+ | `--notify` | `false` | Desktop notification when new windows open |
81
+ | `--bell` | `false` | Terminal bell on failure |
82
+ | `--json` | `false` | Output results as JSON |
83
+ | `--quiet` | `false` | Suppress per-account output |
84
+ | `--group <name>` | — | Only ping accounts in this group |
85
+ | `--exclude <handles>` | — | Skip specific accounts |
86
+
87
+ ### `cc-ping status`
88
+
89
+ Show all accounts with their quota window state — whether they have an active window, when it resets, and duplicate detection.
90
+
91
+ ### `cc-ping suggest`
92
+
93
+ Recommend which account to use next based on quota window state. Prefers accounts whose windows have already expired or are about to.
94
+
95
+ ### `cc-ping next-reset`
96
+
97
+ Show which account has its quota window resetting soonest — useful for knowing when capacity frees up.
98
+
99
+ ### `cc-ping scan`
100
+
101
+ Auto-discover accounts from `~/.claude-accounts/`. Each subdirectory with a `claude_user.json` is detected as an account. Duplicate identities (same `accountUuid` across directories) are flagged.
102
+
103
+ ### `cc-ping check`
104
+
105
+ Verify that each configured account's config directory exists and has credentials.
106
+
107
+ ### `cc-ping add <handle> <config-dir>`
108
+
109
+ Manually add an account by name and Claude Code config directory path.
110
+
111
+ ### `cc-ping remove <handle>`
112
+
113
+ Remove an account from the configuration.
114
+
115
+ ### `cc-ping list`
116
+
117
+ List all configured accounts with their config directory paths.
118
+
119
+ ### `cc-ping history`
120
+
121
+ Show recent ping results — handle, success/failure, duration, cost.
122
+
123
+ ### `cc-ping completions <shell>`
124
+
125
+ Generate shell completion scripts for `bash`, `zsh`, or `fish`.
126
+
127
+ ### `cc-ping moo`
128
+
129
+ Send a test desktop notification to verify notifications work on your platform.
130
+
131
+ ## Daemon
132
+
133
+ The daemon auto-pings on a schedule so you don't have to remember.
134
+
135
+ ```bash
136
+ cc-ping daemon start --interval 300m # every 5 hours
137
+ cc-ping daemon status # check if running, next ping time
138
+ cc-ping daemon stop # graceful shutdown
139
+ ```
140
+
141
+ | Flag | Default | Description |
142
+ |------|---------|-------------|
143
+ | `--interval <duration>` | `300m` | Time between ping cycles |
144
+ | `--notify` | `false` | Desktop notification when new windows open |
145
+ | `--bell` | `false` | Terminal bell on failure |
146
+ | `--quiet` | `true` | Suppress per-account output in logs |
147
+
148
+ The daemon is smart about what it pings:
149
+
150
+ - **Skips active windows** — accounts with a quota window still running are skipped to avoid wasting pings
151
+ - **Detects system sleep** — if the machine wakes from sleep and a ping cycle is overdue, the daemon notices and factors the delay into notifications
152
+ - **Singleton enforcement** — only one daemon runs at a time, verified by PID and process name
153
+ - **Graceful shutdown** — `daemon stop` writes a sentinel file and waits for a clean exit before force-killing
154
+
155
+ Logs are written to `~/.config/cc-ping/daemon.log`.
156
+
157
+ ### System service (survive reboots)
158
+
159
+ `daemon start` runs as a detached process that won't survive a reboot. Use `daemon install` to register as a system service that starts automatically on login:
160
+
161
+ ```bash
162
+ cc-ping daemon install --interval 300m --notify # install and start
163
+ cc-ping daemon status # shows "System service: installed"
164
+ cc-ping daemon uninstall # remove service and stop
165
+ ```
166
+
167
+ | Platform | Service manager | Service file |
168
+ |----------|----------------|--------------|
169
+ | macOS | launchd | `~/Library/LaunchAgents/com.cc-ping.daemon.plist` |
170
+ | Linux | systemd (user) | `~/.config/systemd/user/cc-ping-daemon.service` |
171
+
172
+ The service restarts the daemon on crash (but not on clean exit via `daemon stop`). No `sudo` required — both use user-level service managers.
173
+
174
+ **`daemon stop` vs `daemon uninstall`:** When a service is installed, `daemon stop` kills the process but the service manager may restart it on crash. Use `daemon uninstall` to fully remove the service, or `daemon stop` if you just need a temporary pause.
175
+
176
+ ## Notifications
177
+
178
+ Desktop notifications work on macOS, Linux, and Windows:
179
+
180
+ | Platform | Mechanism |
181
+ |----------|-----------|
182
+ | macOS | `osascript` (AppleScript `display notification`) |
183
+ | Linux | `notify-send` |
184
+ | Windows | PowerShell `New-BurntToastNotification` |
185
+
186
+ Use `cc-ping moo` to verify notifications work on your system.
187
+
188
+ ## Shell completions
189
+
190
+ ```bash
191
+ # bash
192
+ cc-ping completions bash >> ~/.bashrc
193
+
194
+ # zsh
195
+ cc-ping completions zsh >> ~/.zshrc
196
+
197
+ # fish
198
+ cc-ping completions fish > ~/.config/fish/completions/cc-ping.fish
199
+ ```
200
+
201
+ ## How it works
202
+
203
+ Each ping spawns the `claude` CLI with a trivial arithmetic prompt:
204
+
205
+ ```bash
206
+ claude -p "Quick, take a guess: what is 2847 + 6192?" \
207
+ --output-format json \
208
+ --tools "" \
209
+ --max-turns 1
210
+ ```
211
+
212
+ The account is selected by setting `CLAUDE_CONFIG_DIR` to the account's config directory, so the `claude` CLI authenticates as that account.
213
+
214
+ Key design choices:
215
+
216
+ - **Arithmetic prompts** — random math questions minimize token usage (~150 input tokens, ~10 output). Templates and operands are randomized to avoid cache hits across pings.
217
+ - **Tools disabled** — `--tools ""` prevents the model from doing anything beyond answering the question.
218
+ - **Single turn** — `--max-turns 1` ensures one request-response cycle, no follow-ups.
219
+ - **30s timeout** — pings that take longer are killed.
220
+ - **Cost tracking** — each ping records its USD cost and token usage so you can audit spend.
221
+
222
+ After a successful ping, the account's last-ping timestamp is saved to `~/.config/cc-ping/state.json`. The 5-hour quota window is calculated from this timestamp — commands like `status`, `suggest`, and the daemon all use it to determine window state.
223
+
224
+ ## Privacy
225
+
226
+ cc-ping sends **zero telemetry**. No analytics, no tracking, no phoning home.
227
+
228
+ All data stays local in `~/.config/cc-ping/`:
229
+
230
+ | File | Contents |
231
+ |------|----------|
232
+ | `config.json` | Account names and config directory paths |
233
+ | `state.json` | Last ping timestamp and cost metadata per account |
234
+ | `daemon.json` | Daemon PID, interval, start time |
235
+ | `daemon.log` | Daemon output log |
236
+
237
+ The only network activity is the `claude` CLI call itself, which communicates with Anthropic's API under their standard [terms](https://www.anthropic.com/terms) and [privacy policy](https://www.anthropic.com/privacy). cc-ping does not intercept, modify, or inspect this traffic beyond reading the JSON response for cost metadata.
238
+
239
+ ## License
240
+
241
+ MIT
package/dist/cli.js CHANGED
@@ -10,6 +10,11 @@ var __export = (target, all) => {
10
10
  };
11
11
 
12
12
  // src/paths.ts
13
+ var paths_exports = {};
14
+ __export(paths_exports, {
15
+ resolveConfigDir: () => resolveConfigDir,
16
+ setConfigDir: () => setConfigDir
17
+ });
13
18
  import { homedir } from "os";
14
19
  import { join as join2 } from "path";
15
20
  function setConfigDir(dir) {
@@ -547,262 +552,24 @@ var init_run_ping = __esm({
547
552
  }
548
553
  });
549
554
 
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
555
  // src/daemon.ts
804
- init_paths();
805
- init_state();
556
+ var daemon_exports = {};
557
+ __export(daemon_exports, {
558
+ daemonLogPath: () => daemonLogPath,
559
+ daemonLoop: () => daemonLoop,
560
+ daemonPidPath: () => daemonPidPath,
561
+ daemonStopPath: () => daemonStopPath,
562
+ getDaemonStatus: () => getDaemonStatus,
563
+ isProcessRunning: () => isProcessRunning,
564
+ parseInterval: () => parseInterval,
565
+ readDaemonState: () => readDaemonState,
566
+ removeDaemonState: () => removeDaemonState,
567
+ runDaemon: () => runDaemon,
568
+ runDaemonWithDefaults: () => runDaemonWithDefaults,
569
+ startDaemon: () => startDaemon,
570
+ stopDaemon: () => stopDaemon,
571
+ writeDaemonState: () => writeDaemonState
572
+ });
806
573
  import { execSync, spawn } from "child_process";
807
574
  import {
808
575
  existsSync as existsSync5,
@@ -814,9 +581,6 @@ import {
814
581
  writeFileSync as writeFileSync3
815
582
  } from "fs";
816
583
  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
584
  function daemonPidPath() {
821
585
  return join6(resolveConfigDir(), "daemon.json");
822
586
  }
@@ -1049,6 +813,7 @@ async function stopDaemon(deps) {
1049
813
  }
1050
814
  async function runDaemon(intervalMs, options, deps) {
1051
815
  const stopPath = daemonStopPath();
816
+ if (existsSync5(stopPath)) unlinkSync(stopPath);
1052
817
  const cleanup = () => {
1053
818
  if (existsSync5(stopPath)) unlinkSync(stopPath);
1054
819
  removeDaemonState();
@@ -1096,10 +861,550 @@ async function runDaemonWithDefaults(intervalMs, options) {
1096
861
  exit: (code) => process.exit(code)
1097
862
  });
1098
863
  }
864
+ var GRACEFUL_POLL_MS, GRACEFUL_POLL_ATTEMPTS, POST_KILL_DELAY_MS;
865
+ var init_daemon = __esm({
866
+ "src/daemon.ts"() {
867
+ "use strict";
868
+ init_paths();
869
+ init_state();
870
+ GRACEFUL_POLL_MS = 500;
871
+ GRACEFUL_POLL_ATTEMPTS = 20;
872
+ POST_KILL_DELAY_MS = 1e3;
873
+ }
874
+ });
1099
875
 
1100
- // src/filter-accounts.ts
1101
- function filterAccounts(accounts, handles) {
1102
- if (handles.length === 0) return accounts;
876
+ // src/service.ts
877
+ var service_exports = {};
878
+ __export(service_exports, {
879
+ generateLaunchdPlist: () => generateLaunchdPlist,
880
+ generateSystemdUnit: () => generateSystemdUnit,
881
+ getServiceStatus: () => getServiceStatus,
882
+ installService: () => installService,
883
+ resolveExecutable: () => resolveExecutable,
884
+ servicePath: () => servicePath,
885
+ uninstallService: () => uninstallService
886
+ });
887
+ import { execSync as nodeExecSync } from "child_process";
888
+ import {
889
+ existsSync as nodeExistsSync,
890
+ mkdirSync as nodeMkdirSync,
891
+ unlinkSync as nodeUnlinkSync,
892
+ writeFileSync as nodeWriteFileSync
893
+ } from "fs";
894
+ import { homedir as nodeHomedir } from "os";
895
+ import { dirname, join as join9 } from "path";
896
+ function resolveExecutable(deps) {
897
+ try {
898
+ const path = deps.execSync("which cc-ping", {
899
+ encoding: "utf-8",
900
+ stdio: ["ignore", "pipe", "ignore"]
901
+ }).trim();
902
+ if (path) {
903
+ return { executable: path, args: [] };
904
+ }
905
+ } catch {
906
+ }
907
+ return {
908
+ executable: process.execPath,
909
+ args: [process.argv[1]]
910
+ };
911
+ }
912
+ function generateLaunchdPlist(options, execInfo, configDir) {
913
+ const intervalMs = parseIntervalForService(options.interval);
914
+ const programArgs = [
915
+ ...execInfo.args,
916
+ "daemon",
917
+ "_run",
918
+ "--interval-ms",
919
+ String(intervalMs)
920
+ ];
921
+ if (options.quiet) programArgs.push("--quiet");
922
+ if (options.bell) programArgs.push("--bell");
923
+ if (options.notify) programArgs.push("--notify");
924
+ const allArgs = [execInfo.executable, ...programArgs];
925
+ const argsXml = allArgs.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
926
+ const logPath = join9(
927
+ configDir || join9(nodeHomedir(), ".config", "cc-ping"),
928
+ "daemon.log"
929
+ );
930
+ let envSection = "";
931
+ if (configDir) {
932
+ envSection = `
933
+ <key>EnvironmentVariables</key>
934
+ <dict>
935
+ <key>CC_PING_CONFIG</key>
936
+ <string>${escapeXml(configDir)}</string>
937
+ </dict>`;
938
+ }
939
+ return `<?xml version="1.0" encoding="UTF-8"?>
940
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
941
+ <plist version="1.0">
942
+ <dict>
943
+ <key>Label</key>
944
+ <string>${PLIST_LABEL}</string>
945
+ <key>ProgramArguments</key>
946
+ <array>
947
+ ${argsXml}
948
+ </array>
949
+ <key>RunAtLoad</key>
950
+ <true/>
951
+ <key>KeepAlive</key>
952
+ <dict>
953
+ <key>SuccessfulExit</key>
954
+ <false/>
955
+ </dict>
956
+ <key>StandardOutPath</key>
957
+ <string>${escapeXml(logPath)}</string>
958
+ <key>StandardErrorPath</key>
959
+ <string>${escapeXml(logPath)}</string>${envSection}
960
+ </dict>
961
+ </plist>
962
+ `;
963
+ }
964
+ function generateSystemdUnit(options, execInfo, configDir) {
965
+ const intervalMs = parseIntervalForService(options.interval);
966
+ const programArgs = [
967
+ ...execInfo.args,
968
+ "daemon",
969
+ "_run",
970
+ "--interval-ms",
971
+ String(intervalMs)
972
+ ];
973
+ if (options.quiet) programArgs.push("--quiet");
974
+ if (options.bell) programArgs.push("--bell");
975
+ if (options.notify) programArgs.push("--notify");
976
+ const execStart = [execInfo.executable, ...programArgs].map((a) => a.includes(" ") ? `"${a}"` : a).join(" ");
977
+ let envLine = "";
978
+ if (configDir) {
979
+ envLine = `
980
+ Environment=CC_PING_CONFIG=${configDir}`;
981
+ }
982
+ return `[Unit]
983
+ Description=cc-ping daemon - auto-ping Claude Code sessions
984
+
985
+ [Service]
986
+ Type=simple
987
+ ExecStart=${execStart}${envLine}
988
+ Restart=on-failure
989
+ RestartSec=10
990
+
991
+ [Install]
992
+ WantedBy=default.target
993
+ `;
994
+ }
995
+ function servicePath(platform, home) {
996
+ switch (platform) {
997
+ case "darwin":
998
+ return join9(home, "Library", "LaunchAgents", `${PLIST_LABEL}.plist`);
999
+ case "linux":
1000
+ return join9(
1001
+ home,
1002
+ ".config",
1003
+ "systemd",
1004
+ "user",
1005
+ `${SYSTEMD_SERVICE}.service`
1006
+ );
1007
+ default:
1008
+ throw new Error(
1009
+ `Unsupported platform: ${platform}. Only macOS and Linux are supported.`
1010
+ );
1011
+ }
1012
+ }
1013
+ async function installService(options, deps) {
1014
+ const _platform = deps?.platform ?? process.platform;
1015
+ const _homedir = deps?.homedir ?? nodeHomedir;
1016
+ const _existsSync = deps?.existsSync ?? nodeExistsSync;
1017
+ const _mkdirSync = deps?.mkdirSync ?? nodeMkdirSync;
1018
+ const _writeFileSync = deps?.writeFileSync ?? nodeWriteFileSync;
1019
+ const _execSync = deps?.execSync ?? ((cmd) => nodeExecSync(cmd, { encoding: "utf-8" }));
1020
+ const _stopDaemon = deps?.stopDaemon ?? (async () => {
1021
+ const { stopDaemon: stopDaemon2 } = await Promise.resolve().then(() => (init_daemon(), daemon_exports));
1022
+ return stopDaemon2();
1023
+ });
1024
+ const _configDir = deps?.configDir;
1025
+ const home = _homedir();
1026
+ let path;
1027
+ try {
1028
+ path = servicePath(_platform, home);
1029
+ } catch (err) {
1030
+ return {
1031
+ success: false,
1032
+ servicePath: "",
1033
+ error: err.message
1034
+ };
1035
+ }
1036
+ if (_existsSync(path)) {
1037
+ return {
1038
+ success: false,
1039
+ servicePath: path,
1040
+ error: `Service already installed at ${path}. Run \`daemon uninstall\` first.`
1041
+ };
1042
+ }
1043
+ try {
1044
+ await _stopDaemon();
1045
+ } catch {
1046
+ }
1047
+ const execInfo = resolveExecutable({
1048
+ execSync: _execSync
1049
+ });
1050
+ const configDir = _configDir || void 0;
1051
+ let content;
1052
+ if (_platform === "darwin") {
1053
+ content = generateLaunchdPlist(options, execInfo, configDir);
1054
+ } else {
1055
+ content = generateSystemdUnit(options, execInfo, configDir);
1056
+ }
1057
+ _mkdirSync(dirname(path), { recursive: true });
1058
+ _writeFileSync(path, content);
1059
+ try {
1060
+ if (_platform === "darwin") {
1061
+ _execSync(`launchctl load ${path}`);
1062
+ } else {
1063
+ _execSync("systemctl --user daemon-reload");
1064
+ _execSync(`systemctl --user enable --now ${SYSTEMD_SERVICE}`);
1065
+ }
1066
+ } catch (err) {
1067
+ return {
1068
+ success: false,
1069
+ servicePath: path,
1070
+ error: `Service file written but failed to load: ${err.message}`
1071
+ };
1072
+ }
1073
+ return { success: true, servicePath: path };
1074
+ }
1075
+ async function uninstallService(deps) {
1076
+ const _platform = deps?.platform ?? process.platform;
1077
+ const _homedir = deps?.homedir ?? nodeHomedir;
1078
+ const _existsSync = deps?.existsSync ?? nodeExistsSync;
1079
+ const _unlinkSync = deps?.unlinkSync ?? nodeUnlinkSync;
1080
+ const _execSync = deps?.execSync ?? ((cmd) => nodeExecSync(cmd, { encoding: "utf-8" }));
1081
+ const home = _homedir();
1082
+ let path;
1083
+ try {
1084
+ path = servicePath(_platform, home);
1085
+ } catch (err) {
1086
+ return {
1087
+ success: false,
1088
+ error: err.message
1089
+ };
1090
+ }
1091
+ if (!_existsSync(path)) {
1092
+ return {
1093
+ success: false,
1094
+ servicePath: path,
1095
+ error: "No service installed."
1096
+ };
1097
+ }
1098
+ try {
1099
+ if (_platform === "darwin") {
1100
+ _execSync(`launchctl unload ${path}`);
1101
+ } else {
1102
+ _execSync(`systemctl --user disable --now ${SYSTEMD_SERVICE}`);
1103
+ }
1104
+ } catch {
1105
+ }
1106
+ _unlinkSync(path);
1107
+ return { success: true, servicePath: path };
1108
+ }
1109
+ function getServiceStatus(deps) {
1110
+ const _platform = deps?.platform ?? process.platform;
1111
+ const _homedir = deps?.homedir ?? nodeHomedir;
1112
+ const _existsSync = deps?.existsSync ?? nodeExistsSync;
1113
+ const home = _homedir();
1114
+ let path;
1115
+ try {
1116
+ path = servicePath(_platform, home);
1117
+ } catch {
1118
+ return { installed: false, platform: _platform };
1119
+ }
1120
+ return {
1121
+ installed: _existsSync(path),
1122
+ servicePath: path,
1123
+ platform: _platform
1124
+ };
1125
+ }
1126
+ function escapeXml(str) {
1127
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
1128
+ }
1129
+ function parseIntervalForService(value) {
1130
+ if (!value) return QUOTA_WINDOW_MS2;
1131
+ const minutes = Number(value);
1132
+ if (Number.isNaN(minutes) || minutes <= 0) return QUOTA_WINDOW_MS2;
1133
+ return minutes * 60 * 1e3;
1134
+ }
1135
+ var PLIST_LABEL, SYSTEMD_SERVICE, QUOTA_WINDOW_MS2;
1136
+ var init_service = __esm({
1137
+ "src/service.ts"() {
1138
+ "use strict";
1139
+ PLIST_LABEL = "com.cc-ping.daemon";
1140
+ SYSTEMD_SERVICE = "cc-ping-daemon";
1141
+ QUOTA_WINDOW_MS2 = 5 * 60 * 60 * 1e3;
1142
+ }
1143
+ });
1144
+
1145
+ // src/cli.ts
1146
+ import { Command } from "commander";
1147
+
1148
+ // src/check.ts
1149
+ import { existsSync, readFileSync, statSync } from "fs";
1150
+ import { join } from "path";
1151
+ function checkAccount(account) {
1152
+ const issues = [];
1153
+ if (!existsSync(account.configDir) || !statSync(account.configDir).isDirectory()) {
1154
+ issues.push("config directory does not exist");
1155
+ return {
1156
+ handle: account.handle,
1157
+ configDir: account.configDir,
1158
+ healthy: false,
1159
+ issues
1160
+ };
1161
+ }
1162
+ const claudeJson = join(account.configDir, ".claude.json");
1163
+ if (!existsSync(claudeJson)) {
1164
+ issues.push(".claude.json not found");
1165
+ return {
1166
+ handle: account.handle,
1167
+ configDir: account.configDir,
1168
+ healthy: false,
1169
+ issues
1170
+ };
1171
+ }
1172
+ let parsed;
1173
+ try {
1174
+ const raw = readFileSync(claudeJson, "utf-8");
1175
+ parsed = JSON.parse(raw);
1176
+ } catch {
1177
+ issues.push(".claude.json is not valid JSON");
1178
+ return {
1179
+ handle: account.handle,
1180
+ configDir: account.configDir,
1181
+ healthy: false,
1182
+ issues
1183
+ };
1184
+ }
1185
+ if (!parsed.oauthAccount) {
1186
+ issues.push("no OAuth credentials found");
1187
+ }
1188
+ return {
1189
+ handle: account.handle,
1190
+ configDir: account.configDir,
1191
+ healthy: issues.length === 0,
1192
+ issues
1193
+ };
1194
+ }
1195
+ function checkAccounts(accounts) {
1196
+ return accounts.map((a) => checkAccount(a));
1197
+ }
1198
+
1199
+ // src/completions.ts
1200
+ var COMMANDS = "ping scan add remove list status next-reset history suggest check completions moo daemon";
1201
+ function bashCompletion() {
1202
+ return `_cc_ping() {
1203
+ local cur prev commands
1204
+ COMPREPLY=()
1205
+ cur="\${COMP_WORDS[COMP_CWORD]}"
1206
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
1207
+ commands="${COMMANDS}"
1208
+
1209
+ if [[ \${COMP_CWORD} -eq 1 ]]; then
1210
+ COMPREPLY=( $(compgen -W "\${commands}" -- "\${cur}") )
1211
+ return 0
1212
+ fi
1213
+
1214
+ case "\${COMP_WORDS[1]}" in
1215
+ ping)
1216
+ if [[ "\${cur}" == -* ]]; then
1217
+ COMPREPLY=( $(compgen -W "--parallel --quiet --json --group --bell --stagger" -- "\${cur}") )
1218
+ else
1219
+ local handles=$(cc-ping list 2>/dev/null | sed 's/ *\\(.*\\) ->.*/\\1/')
1220
+ COMPREPLY=( $(compgen -W "\${handles}" -- "\${cur}") )
1221
+ fi
1222
+ ;;
1223
+ add)
1224
+ COMPREPLY=( $(compgen -W "--group" -- "\${cur}") )
1225
+ ;;
1226
+ list|history|status|next-reset|check)
1227
+ COMPREPLY=( $(compgen -W "--json" -- "\${cur}") )
1228
+ ;;
1229
+ completions)
1230
+ COMPREPLY=( $(compgen -W "bash zsh fish" -- "\${cur}") )
1231
+ ;;
1232
+ daemon)
1233
+ if [[ \${COMP_CWORD} -eq 2 ]]; then
1234
+ COMPREPLY=( $(compgen -W "start stop status install uninstall" -- "\${cur}") )
1235
+ elif [[ "\${COMP_WORDS[2]}" == "start" && "\${cur}" == -* ]]; then
1236
+ COMPREPLY=( $(compgen -W "--interval --quiet --bell --notify" -- "\${cur}") )
1237
+ elif [[ "\${COMP_WORDS[2]}" == "install" && "\${cur}" == -* ]]; then
1238
+ COMPREPLY=( $(compgen -W "--interval --quiet --bell --notify" -- "\${cur}") )
1239
+ elif [[ "\${COMP_WORDS[2]}" == "status" && "\${cur}" == -* ]]; then
1240
+ COMPREPLY=( $(compgen -W "--json" -- "\${cur}") )
1241
+ fi
1242
+ ;;
1243
+ esac
1244
+ return 0
1245
+ }
1246
+ complete -F _cc_ping cc-ping
1247
+ `;
1248
+ }
1249
+ function zshCompletion() {
1250
+ return `#compdef cc-ping
1251
+
1252
+ _cc_ping() {
1253
+ local -a commands
1254
+ commands=(
1255
+ 'ping:Ping configured accounts'
1256
+ 'scan:Auto-discover accounts'
1257
+ 'add:Add an account manually'
1258
+ 'remove:Remove an account'
1259
+ 'list:List configured accounts'
1260
+ 'status:Show account status'
1261
+ 'next-reset:Show soonest quota reset'
1262
+ 'history:Show ping history'
1263
+ 'suggest:Suggest next account'
1264
+ 'check:Verify account health'
1265
+ 'completions:Generate shell completions'
1266
+ 'moo:Send a test notification'
1267
+ 'daemon:Run auto-ping on a schedule'
1268
+ )
1269
+
1270
+ _arguments -C \\
1271
+ '--config[Config directory]:path:_files -/' \\
1272
+ '1:command:->command' \\
1273
+ '*::arg:->args'
1274
+
1275
+ case $state in
1276
+ command)
1277
+ _describe 'command' commands
1278
+ ;;
1279
+ args)
1280
+ case $words[1] in
1281
+ ping)
1282
+ _arguments \\
1283
+ '--parallel[Ping in parallel]' \\
1284
+ '--quiet[Suppress output]' \\
1285
+ '--json[JSON output]' \\
1286
+ '--group[Filter by group]:group:' \\
1287
+ '--bell[Ring bell on failure]' \\
1288
+ '--stagger[Delay between pings]:minutes:' \\
1289
+ '*:handle:->handles'
1290
+ if [[ $state == handles ]]; then
1291
+ local -a handles
1292
+ handles=(\${(f)"$(cc-ping list 2>/dev/null | sed 's/ *\\(.*\\) ->.*/\\1/')"})
1293
+ _describe 'handle' handles
1294
+ fi
1295
+ ;;
1296
+ completions)
1297
+ _arguments '1:shell:(bash zsh fish)'
1298
+ ;;
1299
+ list|history|status|next-reset|check)
1300
+ _arguments '--json[JSON output]'
1301
+ ;;
1302
+ add)
1303
+ _arguments '--group[Assign group]:group:'
1304
+ ;;
1305
+ daemon)
1306
+ local -a subcmds
1307
+ subcmds=(
1308
+ 'start:Start the daemon process'
1309
+ 'stop:Stop the daemon process'
1310
+ 'status:Show daemon status'
1311
+ 'install:Install as system service'
1312
+ 'uninstall:Remove system service'
1313
+ )
1314
+ _arguments '1:subcommand:->subcmd' '*::arg:->subargs'
1315
+ case $state in
1316
+ subcmd)
1317
+ _describe 'subcommand' subcmds
1318
+ ;;
1319
+ subargs)
1320
+ case $words[1] in
1321
+ start|install)
1322
+ _arguments \\
1323
+ '--interval[Ping interval in minutes]:minutes:' \\
1324
+ '--quiet[Suppress ping output]' \\
1325
+ '--bell[Ring bell on failure]' \\
1326
+ '--notify[Send notification on failure]'
1327
+ ;;
1328
+ status)
1329
+ _arguments '--json[JSON output]'
1330
+ ;;
1331
+ esac
1332
+ ;;
1333
+ esac
1334
+ ;;
1335
+ esac
1336
+ ;;
1337
+ esac
1338
+ }
1339
+
1340
+ _cc_ping
1341
+ `;
1342
+ }
1343
+ function fishCompletion() {
1344
+ return `# Fish completions for cc-ping
1345
+ set -l commands ping scan add remove list status next-reset history suggest check completions moo
1346
+
1347
+ complete -c cc-ping -f
1348
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a ping -d "Ping configured accounts"
1349
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a scan -d "Auto-discover accounts"
1350
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a add -d "Add an account manually"
1351
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a remove -d "Remove an account"
1352
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a list -d "List configured accounts"
1353
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a status -d "Show account status"
1354
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a next-reset -d "Show soonest quota reset"
1355
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a history -d "Show ping history"
1356
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a suggest -d "Suggest next account"
1357
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a check -d "Verify account health"
1358
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a completions -d "Generate shell completions"
1359
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a moo -d "Send a test notification"
1360
+ complete -c cc-ping -n "not __fish_seen_subcommand_from $commands" -a daemon -d "Run auto-ping on a schedule"
1361
+
1362
+ complete -c cc-ping -n "__fish_seen_subcommand_from ping" -l parallel -d "Ping in parallel"
1363
+ complete -c cc-ping -n "__fish_seen_subcommand_from ping" -s q -l quiet -d "Suppress output"
1364
+ complete -c cc-ping -n "__fish_seen_subcommand_from ping" -l json -d "JSON output"
1365
+ complete -c cc-ping -n "__fish_seen_subcommand_from ping" -s g -l group -r -d "Filter by group"
1366
+ complete -c cc-ping -n "__fish_seen_subcommand_from ping" -l bell -d "Ring bell on failure"
1367
+ complete -c cc-ping -n "__fish_seen_subcommand_from ping" -l stagger -r -d "Delay between pings"
1368
+ complete -c cc-ping -n "__fish_seen_subcommand_from ping" -a "(cc-ping list 2>/dev/null | string replace -r ' *(.*) ->.*' '$1')"
1369
+
1370
+ complete -c cc-ping -n "__fish_seen_subcommand_from list history status next-reset check" -l json -d "JSON output"
1371
+ complete -c cc-ping -n "__fish_seen_subcommand_from add" -s g -l group -r -d "Assign group"
1372
+ complete -c cc-ping -n "__fish_seen_subcommand_from completions" -a "bash zsh fish"
1373
+
1374
+ complete -c cc-ping -n "__fish_seen_subcommand_from daemon; and not __fish_seen_subcommand_from start stop status install uninstall" -a start -d "Start the daemon"
1375
+ complete -c cc-ping -n "__fish_seen_subcommand_from daemon; and not __fish_seen_subcommand_from start stop status install uninstall" -a stop -d "Stop the daemon"
1376
+ complete -c cc-ping -n "__fish_seen_subcommand_from daemon; and not __fish_seen_subcommand_from start stop status install uninstall" -a status -d "Show daemon status"
1377
+ complete -c cc-ping -n "__fish_seen_subcommand_from daemon; and not __fish_seen_subcommand_from start stop status install uninstall" -a install -d "Install as system service"
1378
+ complete -c cc-ping -n "__fish_seen_subcommand_from daemon; and not __fish_seen_subcommand_from start stop status install uninstall" -a uninstall -d "Remove system service"
1379
+ complete -c cc-ping -n "__fish_seen_subcommand_from daemon; and __fish_seen_subcommand_from start install" -l interval -r -d "Ping interval in minutes"
1380
+ complete -c cc-ping -n "__fish_seen_subcommand_from daemon; and __fish_seen_subcommand_from start install" -s q -l quiet -d "Suppress output"
1381
+ complete -c cc-ping -n "__fish_seen_subcommand_from daemon; and __fish_seen_subcommand_from start install" -l bell -d "Ring bell on failure"
1382
+ complete -c cc-ping -n "__fish_seen_subcommand_from daemon; and __fish_seen_subcommand_from start install" -l notify -d "Send notification on failure"
1383
+ complete -c cc-ping -n "__fish_seen_subcommand_from daemon; and __fish_seen_subcommand_from status" -l json -d "JSON output"
1384
+ `;
1385
+ }
1386
+ function generateCompletion(shell) {
1387
+ switch (shell) {
1388
+ case "bash":
1389
+ return bashCompletion();
1390
+ case "zsh":
1391
+ return zshCompletion();
1392
+ case "fish":
1393
+ return fishCompletion();
1394
+ default:
1395
+ throw new Error(
1396
+ `Unsupported shell: ${shell}. Supported: bash, zsh, fish`
1397
+ );
1398
+ }
1399
+ }
1400
+
1401
+ // src/cli.ts
1402
+ init_config();
1403
+ init_daemon();
1404
+
1405
+ // src/filter-accounts.ts
1406
+ function filterAccounts(accounts, handles) {
1407
+ if (handles.length === 0) return accounts;
1103
1408
  const unknown = handles.filter((h) => !accounts.some((a) => a.handle === h));
1104
1409
  if (unknown.length > 0) {
1105
1410
  throw new Error(`Unknown account(s): ${unknown.join(", ")}`);
@@ -1322,7 +1627,7 @@ function suggestAccount(accounts, now = /* @__PURE__ */ new Date()) {
1322
1627
  }
1323
1628
 
1324
1629
  // 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(
1630
+ var program = new Command().name("cc-ping").description("Ping Claude Code sessions to trigger quota windows early").version("1.0.0").option(
1326
1631
  "--config <path>",
1327
1632
  "Path to config directory (default: ~/.config/cc-ping, env: CC_PING_CONFIG)"
1328
1633
  ).hook("preAction", (thisCommand) => {
@@ -1516,7 +1821,7 @@ var daemon = program.command("daemon").description("Run auto-ping on a schedule"
1516
1821
  daemon.command("start").description("Start the daemon process").option(
1517
1822
  "--interval <minutes>",
1518
1823
  "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) => {
1824
+ ).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) => {
1520
1825
  const result = startDaemon({
1521
1826
  interval: opts.interval,
1522
1827
  quiet: opts.quiet,
@@ -1528,6 +1833,13 @@ daemon.command("start").description("Start the daemon process").option(
1528
1833
  process.exit(1);
1529
1834
  }
1530
1835
  console.log(`Daemon started (PID: ${result.pid})`);
1836
+ const { getServiceStatus: getServiceStatus2 } = await Promise.resolve().then(() => (init_service(), service_exports));
1837
+ const svc = getServiceStatus2();
1838
+ if (!svc.installed) {
1839
+ console.log(
1840
+ "Hint: won't survive a reboot. Use `daemon install` for a persistent service."
1841
+ );
1842
+ }
1531
1843
  printAccountTable();
1532
1844
  });
1533
1845
  daemon.command("stop").description("Stop the daemon process").action(async () => {
@@ -1537,24 +1849,51 @@ daemon.command("stop").description("Stop the daemon process").action(async () =>
1537
1849
  process.exit(1);
1538
1850
  }
1539
1851
  console.log(`Daemon stopped (PID: ${result.pid})`);
1852
+ const { getServiceStatus: getServiceStatus2 } = await Promise.resolve().then(() => (init_service(), service_exports));
1853
+ const svc = getServiceStatus2();
1854
+ if (svc.installed) {
1855
+ console.log(
1856
+ "Note: system service is installed. The daemon may restart. Use `daemon uninstall` to fully remove."
1857
+ );
1858
+ }
1540
1859
  });
1541
- daemon.command("status").description("Show daemon status").option("--json", "Output as JSON", false).action((opts) => {
1860
+ daemon.command("status").description("Show daemon status").option("--json", "Output as JSON", false).action(async (opts) => {
1861
+ const { getServiceStatus: getServiceStatus2 } = await Promise.resolve().then(() => (init_service(), service_exports));
1862
+ const svc = getServiceStatus2();
1542
1863
  const status = getDaemonStatus();
1543
1864
  if (opts.json) {
1865
+ const serviceInfo = svc.installed ? {
1866
+ service: {
1867
+ installed: true,
1868
+ path: svc.servicePath,
1869
+ platform: svc.platform
1870
+ }
1871
+ } : { service: { installed: false } };
1544
1872
  if (!status.running) {
1545
- console.log(JSON.stringify(status, null, 2));
1873
+ console.log(JSON.stringify({ ...status, ...serviceInfo }, null, 2));
1546
1874
  return;
1547
1875
  }
1548
1876
  const accounts = listAccounts();
1549
1877
  const dupes = findDuplicates(accounts);
1550
1878
  const accountStatuses = getAccountStatuses(accounts, /* @__PURE__ */ new Date(), dupes);
1551
1879
  console.log(
1552
- JSON.stringify({ ...status, accounts: accountStatuses }, null, 2)
1880
+ JSON.stringify(
1881
+ { ...status, ...serviceInfo, accounts: accountStatuses },
1882
+ null,
1883
+ 2
1884
+ )
1553
1885
  );
1554
1886
  return;
1555
1887
  }
1556
1888
  if (!status.running) {
1557
- console.log("Daemon is not running");
1889
+ if (svc.installed) {
1890
+ const kind = svc.platform === "darwin" ? "launchd" : "systemd";
1891
+ console.log(
1892
+ `Daemon is not running (system service: installed via ${kind})`
1893
+ );
1894
+ } else {
1895
+ console.log("Daemon is not running");
1896
+ }
1558
1897
  return;
1559
1898
  }
1560
1899
  console.log(`Daemon is running (PID: ${status.pid})`);
@@ -1566,15 +1905,57 @@ daemon.command("status").description("Show daemon status").option("--json", "Out
1566
1905
  if (status.nextPingIn) {
1567
1906
  console.log(` Next ping in: ${status.nextPingIn}`);
1568
1907
  }
1908
+ if (svc.installed) {
1909
+ const kind = svc.platform === "darwin" ? "launchd" : "systemd";
1910
+ console.log(` System service: installed (${kind})`);
1911
+ }
1569
1912
  console.log("");
1570
1913
  printAccountTable();
1571
1914
  });
1915
+ daemon.command("install").description("Install daemon as a system service (launchd/systemd)").option(
1916
+ "--interval <minutes>",
1917
+ "Ping interval in minutes (default: 300 = 5h quota window)"
1918
+ ).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) => {
1919
+ const { installService: installService2 } = await Promise.resolve().then(() => (init_service(), service_exports));
1920
+ const result = await installService2({
1921
+ interval: opts.interval,
1922
+ quiet: opts.quiet,
1923
+ bell: opts.bell,
1924
+ notify: opts.notify
1925
+ });
1926
+ if (!result.success) {
1927
+ console.error(result.error);
1928
+ process.exit(1);
1929
+ }
1930
+ console.log(`Service installed: ${result.servicePath}`);
1931
+ console.log(
1932
+ "The daemon will start automatically on login. Use `daemon uninstall` to remove."
1933
+ );
1934
+ });
1935
+ daemon.command("uninstall").description("Remove daemon system service").action(async () => {
1936
+ const { uninstallService: uninstallService2 } = await Promise.resolve().then(() => (init_service(), service_exports));
1937
+ const result = await uninstallService2();
1938
+ if (!result.success) {
1939
+ console.error(result.error);
1940
+ process.exit(1);
1941
+ }
1942
+ console.log(`Service removed: ${result.servicePath}`);
1943
+ });
1572
1944
  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
1945
  const intervalMs = Number(opts.intervalMs);
1574
1946
  if (!intervalMs || intervalMs <= 0) {
1575
1947
  console.error("Invalid --interval-ms");
1576
1948
  process.exit(1);
1577
1949
  }
1950
+ if (!readDaemonState()) {
1951
+ const { resolveConfigDir: resolveConfigDir2 } = await Promise.resolve().then(() => (init_paths(), paths_exports));
1952
+ writeDaemonState({
1953
+ pid: process.pid,
1954
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1955
+ intervalMs,
1956
+ configDir: resolveConfigDir2()
1957
+ });
1958
+ }
1578
1959
  await runDaemonWithDefaults(intervalMs, {
1579
1960
  quiet: opts.quiet,
1580
1961
  bell: opts.bell,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wbern/cc-ping",
3
- "version": "0.1.0",
3
+ "version": "1.0.0",
4
4
  "description": "Ping Claude Code sessions to trigger quota windows early across multiple accounts",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,19 @@
9
9
  "files": [
10
10
  "dist"
11
11
  ],
12
+ "scripts": {
13
+ "build": "tsup",
14
+ "prepublishOnly": "node scripts/check-publish.js && pnpm run build",
15
+ "test": "vitest run",
16
+ "test:coverage": "vitest run --coverage",
17
+ "test:e2e": "vitest run --config vitest.e2e.config.ts",
18
+ "test:e2e:ping": "E2E_PING=1 vitest run --config vitest.e2e.config.ts",
19
+ "typecheck": "tsc --noEmit",
20
+ "lint": "biome check .",
21
+ "lint:fix": "biome check --write .",
22
+ "knip": "knip --no-config-hints",
23
+ "prepare": "husky"
24
+ },
12
25
  "keywords": [
13
26
  "claude",
14
27
  "claude-code",
@@ -75,16 +88,5 @@
75
88
  }
76
89
  ]
77
90
  ]
78
- },
79
- "scripts": {
80
- "build": "tsup",
81
- "test": "vitest run",
82
- "test:coverage": "vitest run --coverage",
83
- "test:e2e": "vitest run --config vitest.e2e.config.ts",
84
- "test:e2e:ping": "E2E_PING=1 vitest run --config vitest.e2e.config.ts",
85
- "typecheck": "tsc --noEmit",
86
- "lint": "biome check .",
87
- "lint:fix": "biome check --write .",
88
- "knip": "knip --no-config-hints"
89
91
  }
90
- }
92
+ }