@usagefleet/cli 1.2.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,284 @@
1
+ # @usagefleet/cli
2
+
3
+ Tails the local JSONL logs of Claude Code, Claude Desktop's agent-mode
4
+ (Cowork) sessions, **and** the [pi](https://github.com/badlogic/pi-mono) coding
5
+ agent (`~/.pi/agent/sessions`; only its Anthropic-provider records — usage via
6
+ other providers doesn't touch Claude limits), reporting token usage to a
7
+ UsageFleet server. Read-only on the log files. Zero runtime dependencies
8
+ (Node ≥ 20).
9
+
10
+ ## Install
11
+
12
+ Three commands, identical on macOS, Linux and Windows. You only need a device
13
+ **token** from the server's Devices page (endpoint defaults to
14
+ `https://usagefleet.com`):
15
+
16
+ ```bash
17
+ npm i -g @usagefleet/cli
18
+ usagefleet init --token uf_xxx # add --endpoint <url> when self-hosting
19
+ usagefleet install # autostart at login
20
+ ```
21
+
22
+ In PowerShell chain them with `;` instead of `&&` — Windows PowerShell 5.1 has
23
+ no `&&`.
24
+
25
+ Autostart uses launchd (macOS), systemd `--user` (Linux) and Task Scheduler
26
+ (Windows). If `npm i -g` fails with EACCES your global prefix is root-owned:
27
+ either use a Node version manager (nvm, fnm, volta) or
28
+ `npm config set prefix ~/.local` and put `~/.local/bin` on your PATH. Installing
29
+ the collector under `sudo` would run it as the wrong user.
30
+
31
+ **Update** happens on its own (see below) or on demand with
32
+ `usagefleet update`. `npm i -g @usagefleet/cli` does the same thing.
33
+
34
+ ### Run from source
35
+
36
+ ```bash
37
+ cd apps/cli
38
+ bun install
39
+ bun run src/index.ts status # or: npm run build && node dist/index.js status
40
+ ```
41
+
42
+ ## Configure
43
+
44
+ Get a device **token** from the server's Devices page, then either:
45
+
46
+ ```bash
47
+ usagefleet init --endpoint https://track.example.com --token uf_xxx
48
+ # writes ~/.config/usagefleet/config.json
49
+ ```
50
+
51
+ That one file (mode `600`) holds everything the CLI persists — your settings
52
+ plus the two machine-managed sections, `state` (per-log tail offsets) and
53
+ `notify` (which alert thresholds already fired). Delete it to start clean.
54
+ `XDG_CONFIG_HOME` is honoured. Installs predating the consolidation are folded
55
+ in automatically from `~/.usagefleet.json`, `~/.usagefleet-state.json` and
56
+ `~/.usagefleet-notify.json` on first write; those files are then unused and
57
+ safe to delete.
58
+
59
+ Or set env vars (they override the config file):
60
+
61
+ | Variable | Meaning |
62
+ |----------|---------|
63
+ | `USAGEFLEET_ENDPOINT` | server base URL. Must be `https://` (loopback may be `http://`): it carries the device token on every request |
64
+ | `USAGEFLEET_TOKEN` | device token |
65
+ | `USAGEFLEET_PROJECTS` | override `~/.claude/projects` (Claude Code logs) |
66
+ | `USAGEFLEET_DESKTOP` | override the Claude Desktop agent-mode sessions dir (auto-detected per-OS); set `off`/`0` to skip desktop collection |
67
+ | `USAGEFLEET_PI` | override the pi agent sessions dirs, comma-separated (default `~/.pi/agent/sessions` plus whatever `PI_CODING_AGENT_DIR`/`PI_CODING_AGENT_SESSION_DIR` point at — a service inherits neither, so set this if you relocated pi's agent dir); set `off`/`0` to skip pi collection |
68
+ | `USAGEFLEET_INTERVAL` | watch poll seconds (default 15) |
69
+ | `USAGEFLEET_LIMITS_INTERVAL` | how often to ping for real 5h/weekly limits, seconds (default 300; decoupled from the faster usage poll so the 1-token ping doesn't run every cycle) |
70
+ | `USAGEFLEET_NOTIFY` | desktop notifications on/off (default **on**; set `0`/`false`/`off` to disable) |
71
+ | `USAGEFLEET_NOTIFY_THRESHOLDS` | comma list of utilization % that trigger an alert (default `80,95`) |
72
+ | `USAGEFLEET_BATCH` | records per upload request (default 100, capped at the server's limit of 1000) |
73
+ | `USAGEFLEET_CONFIG` | override the whole config file path (default `~/.config/usagefleet/config.json`) |
74
+ | `USAGEFLEET_UPDATE` | set `0` to turn the self-update check off |
75
+ | `USAGEFLEET_UPDATE_INTERVAL` | seconds between self-update checks (default `21600` = 6h, i.e. 4×/day) |
76
+ | `USAGEFLEET_HOOK` | set `0` to keep the prompt-blocking hook out of `~/.claude/settings.json` |
77
+
78
+ ### Setting the token per shell
79
+
80
+ **bash / zsh** (macOS, Linux) — one-off for the current session:
81
+
82
+ ```bash
83
+ export USAGEFLEET_ENDPOINT="https://track.example.com"
84
+ export USAGEFLEET_TOKEN="uf_xxx"
85
+ ```
86
+
87
+ Persist it by appending those lines to `~/.bashrc` / `~/.zshrc`, then
88
+ `source ~/.zshrc`. Or set it inline for a single command:
89
+
90
+ ```bash
91
+ USAGEFLEET_ENDPOINT=https://track.example.com USAGEFLEET_TOKEN=uf_xxx usagefleet run
92
+ ```
93
+
94
+ **fish**:
95
+
96
+ ```fish
97
+ set -x USAGEFLEET_ENDPOINT "https://track.example.com"
98
+ set -x USAGEFLEET_TOKEN "uf_xxx"
99
+ # persist (writes to universal vars, survives restarts):
100
+ set -Ux USAGEFLEET_TOKEN "uf_xxx"
101
+ set -Ux USAGEFLEET_ENDPOINT "https://track.example.com"
102
+ ```
103
+
104
+ **PowerShell** (Windows) — current session:
105
+
106
+ ```powershell
107
+ $env:USAGEFLEET_ENDPOINT = "https://track.example.com"
108
+ $env:USAGEFLEET_TOKEN = "uf_xxx"
109
+ # persist for your user (new shells only):
110
+ setx USAGEFLEET_TOKEN "uf_xxx"
111
+ setx USAGEFLEET_ENDPOINT "https://track.example.com"
112
+ ```
113
+
114
+ **cmd.exe** (Windows):
115
+
116
+ ```cmd
117
+ set USAGEFLEET_ENDPOINT=https://track.example.com
118
+ set USAGEFLEET_TOKEN=uf_xxx
119
+ :: persist: setx USAGEFLEET_TOKEN "uf_xxx"
120
+ ```
121
+
122
+ > Prefer not to put a long-lived token in shell history/rc files? Use
123
+ > `usagefleet init --endpoint <url> --token <t>` instead — it writes
124
+ > `~/.config/usagefleet/config.json` (mode `600`), which the collector reads
125
+ > automatically. `init` merges, so re-running it rotates the token without
126
+ > resetting your tail offsets.
127
+ > When run as a service, `usagefleet install` bakes every `USAGEFLEET_*` value
128
+ > that is currently set (plus `ANTHROPIC_API_KEY`) into the launchd/systemd unit.
129
+ > The unit is written mode `600`, since it holds those secrets.
130
+
131
+ ## Run
132
+
133
+ ```bash
134
+ usagefleet run # one scan: upload usage + report limits
135
+ usagefleet watch # poll continuously
136
+ usagefleet limits # report ONLY your real 5h/weekly limit usage
137
+ usagefleet guard # exit 2 if this device's group is over a blocking limit
138
+ usagefleet update # upgrade to the latest published version now
139
+ usagefleet status # service health, last limits reading, resolved config
140
+ usagefleet version # bare release version
141
+ ```
142
+
143
+ ### Updates
144
+
145
+ `watch` asks the npm registry for the published version at startup and then
146
+ every 6 hours (`USAGEFLEET_UPDATE_INTERVAL`, in seconds); when it differs from
147
+ the one baked into this build it runs `npm install -g @usagefleet/cli@<version>`
148
+ and re-runs `install` to restart the service on it. `usagefleet update` does the
149
+ same on demand.
150
+
151
+ npm is called through the absolute path next to the `node` running the
152
+ collector, because a launchd/systemd service gets a minimal PATH that rarely has
153
+ your version manager on it.
154
+
155
+ Every failure is a no-op: registry unreachable, a version string that isn't
156
+ plain semver, npm missing, npm exiting non-zero (a root-owned global prefix is
157
+ the usual cause) and locally-built (`dev`) builds all leave the install as it
158
+ was — the service is only restarted after npm reports success. Set
159
+ `USAGEFLEET_UPDATE=0` to turn the check off.
160
+
161
+ The collector tracks a per-file byte offset in the config file's `state`
162
+ section, so each line is sent once; it handles rotation/truncation and never
163
+ sends a partial line. Delivery is at-least-once — the server dedups on `uuid`.
164
+
165
+ ### Real limit % (auto-detected)
166
+
167
+ On `run`/`watch`/`limits`, the collector reads your **local Claude login** on
168
+ this machine and reports your true utilization — no keys pasted anywhere:
169
+
170
+ 1. **Subscription** — the OAuth token from `claude` (Claude Code). On macOS it's
171
+ read from the login Keychain (`Claude Code-credentials`); on Linux/Windows from
172
+ `~/.claude/.credentials.json`. Sign in once with `claude` and it's detected.
173
+ 2. **API key** — falls back to `ANTHROPIC_API_KEY` if no subscription login.
174
+
175
+ It sends a 1-token ping to the Messages API and reads Anthropic's
176
+ `anthropic-ratelimit-unified-5h/7d-utilization` (and `-reset`) headers, then POSTs
177
+ the percentages to the server. `usagefleet status` shows which login was found.
178
+ The token/credentials never leave your machine — only the resulting percentages
179
+ are uploaded.
180
+
181
+ ### Blocking prompts over the limit
182
+
183
+ A group can be set to **refuse new prompts** once it has burned its budget slice
184
+ (1/group count of the account limit) for a window. Two switches per group, both
185
+ off by default — flip them in the database:
186
+
187
+ ```sql
188
+ UPDATE groups SET block_on_session_limit = true WHERE name = 'Backend'; -- 5h window
189
+ UPDATE groups SET block_on_weekly_limit = true WHERE name = 'Backend'; -- 7d window
190
+ ```
191
+
192
+ Enforcement is a Claude Code `UserPromptSubmit` hook, registered in
193
+ `~/.claude/settings.json` automatically by `usagefleet install` (and removed
194
+ by `usagefleet uninstall`). Re-running install refreshes the path instead of
195
+ stacking a second hook; a settings file that doesn't parse is left untouched.
196
+ Set `USAGEFLEET_HOOK=0` to keep your settings file out of it and add the hook
197
+ yourself:
198
+
199
+ ```json
200
+ {
201
+ "hooks": {
202
+ "UserPromptSubmit": [
203
+ { "hooks": [{ "type": "command", "command": "usagefleet guard" }] }
204
+ ]
205
+ }
206
+ }
207
+ ```
208
+
209
+ `usagefleet guard` asks the server whether the calling device's group is over
210
+ a window it blocks on; exit code 2 refuses the prompt and shows the reason.
211
+ It **fails open** everywhere else — no config, server down, timeout (5s), old
212
+ server, 429 — because a tracker problem must never stop you from working.
213
+ Only whole prompts are blocked, never tool calls mid-turn, so the current turn
214
+ always finishes.
215
+
216
+ ### Desktop notifications
217
+
218
+ When the collector reads your real 5h/weekly utilization, it raises a **desktop
219
+ notification** the first time each window crosses a threshold (default `80%` and
220
+ `95%`). It fires at most once per threshold per window and re-arms when the
221
+ window resets, so it never spams.
222
+
223
+ ```bash
224
+ usagefleet notify-test # fire a sample notification to confirm it works
225
+ ```
226
+
227
+ - **macOS** — uses `osascript` → Notification Center (no extra install).
228
+ - **Linux (KDE Plasma / freedesktop)** — uses `notify-send`; if that's missing it
229
+ falls back to KDE's `kdialog --passivepopup`. Install `notify-send` via
230
+ `libnotify` (e.g. `apt install libnotify-bin`) if neither is present.
231
+ - **Windows** — a WinRT toast via built-in `powershell.exe` → Action Center (no
232
+ extra install; it appears under "Windows PowerShell"). Check Settings →
233
+ Notifications if nothing shows up.
234
+
235
+ Tune or disable:
236
+
237
+ ```bash
238
+ export USAGEFLEET_NOTIFY_THRESHOLDS="50,80,95" # alert at 50/80/95%
239
+ export USAGEFLEET_NOTIFY=0 # turn notifications off
240
+ ```
241
+
242
+ > **Under a background service.** On macOS the LaunchAgent runs in your GUI
243
+ > session, so notifications appear normally. On Linux a `systemd --user` service
244
+ > needs access to your session bus (`DBUS_SESSION_BUS_ADDRESS`) for `notify-send`
245
+ > to reach the notification daemon — typical for `--user` units in a graphical
246
+ > login. `usagefleet install` bakes `USAGEFLEET_NOTIFY*` into the unit.
247
+
248
+ ## Run as a background service
249
+
250
+ ```bash
251
+ usagefleet install # launchd (macOS) / systemd --user (Linux) / Task Scheduler (Windows)
252
+ usagefleet uninstall
253
+ ```
254
+
255
+ `install` is idempotent and reload-safe: re-running it rewrites the service
256
+ definition and restarts it, so it doubles as the update step. The service is
257
+ launched as an absolute `node` plus the installed package path, so an empty
258
+ service PATH is fine — but removing that Node version (`nvm uninstall`) stops
259
+ the collector until you re-run `usagefleet install` under the new one.
260
+
261
+ - **macOS** — installs a LaunchAgent (`~/Library/LaunchAgents`, RunAtLoad +
262
+ KeepAlive) and boots it (bootout → bootstrap → kickstart). Logs at
263
+ `~/Library/Logs/usagefleet/usagefleet.*.log` (not `/tmp`, which is
264
+ world-writable). The plist is written mode `600`: it holds your token.
265
+ - **Linux** — writes a `--user` unit and runs `systemctl --user daemon-reload`,
266
+ `enable --now`, `restart`, plus `loginctl enable-linger $USER` automatically so
267
+ it survives logout. If `systemctl` can't be driven, it prints the manual steps.
268
+ - **Windows** — registers a Scheduled Task (`usagefleet`) that starts at logon,
269
+ restarts on failure, and runs **hidden** (no console window) through a
270
+ generated `wscript` launcher in `%LOCALAPPDATA%\usagefleet`. Task XML has no
271
+ env support, so the launcher carries the `USAGEFLEET_*` values that were set
272
+ when you ran `install`, and redirects output to
273
+ `%LOCALAPPDATA%\usagefleet\usagefleet.log` (truncated on each start).
274
+ Inspect it with `schtasks /query /tn usagefleet /v /fo list`.
275
+
276
+ The OS is reported automatically (`process.platform` → `mac`/`linux`/`windows`).
277
+
278
+ > **macOS limits under the service.** Usage collection (reading JSONL files) works
279
+ > headless. The **real limit %** feature reads the login Keychain, and a
280
+ > non-interactive launchd agent may be denied that read — the collector logs a
281
+ > clear hint when this happens. If it does, either approve `/usr/bin/security`
282
+ > access to the `Claude Code-credentials` item once, or set `ANTHROPIC_API_KEY`
283
+ > before `usagefleet install` (it's baked into the service) so limits use the
284
+ > API key instead.
@@ -0,0 +1,72 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { closeSync, fsyncSync, openSync, realpathSync, renameSync, statSync, unlinkSync, writeSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ /**
5
+ * Durable atomic write: write to a per-process tmp file, fsync it, rename over
6
+ * the target, then fsync the directory. The per-pid tmp name means two
7
+ * concurrent collectors (e.g. the installed service + a manual `run`) can never
8
+ * clobber a shared `.tmp` and publish corrupt content; the rename is atomic so a
9
+ * reader never sees a half-written file, and the fsyncs make the result survive
10
+ * a power loss.
11
+ *
12
+ * Used for every file the collector rewrites in place. Two of them belong to the
13
+ * user rather than to us — `~/.claude/settings.json` and Claude's credentials —
14
+ * where a torn write breaks their editor or logs them out, so a plain
15
+ * writeFileSync is not good enough anywhere here.
16
+ *
17
+ * `mode` forces the result's permissions; omit it to inherit whatever the file
18
+ * already had.
19
+ */
20
+ export function writeFileAtomic(path, data, mode) {
21
+ // Resolve a symlink to its target before writing. Dotfile setups routinely
22
+ // link ~/.claude/settings.json into a repo, and renaming over the link would
23
+ // replace it with a plain file and orphan the user's real config. Resolving
24
+ // also keeps the tmp file on the target's filesystem, so the rename stays
25
+ // atomic. Inheriting the current mode stops a hand-tightened 0600 from being
26
+ // widened to the umask default on rewrite.
27
+ let target = path;
28
+ let fileMode = mode;
29
+ try {
30
+ const existing = statSync(path); // follows symlinks
31
+ target = realpathSync(path);
32
+ fileMode ??= existing.mode & 0o777;
33
+ }
34
+ catch {
35
+ /* new or broken link — the caller's mode (or the umask default) applies */
36
+ }
37
+ const tmp = `${target}.${process.pid}.${randomUUID().slice(0, 8)}.tmp`;
38
+ try {
39
+ const fd = openSync(tmp, 'w', fileMode);
40
+ try {
41
+ writeSync(fd, data, 0, 'utf-8');
42
+ fsyncSync(fd);
43
+ }
44
+ finally {
45
+ closeSync(fd);
46
+ }
47
+ renameSync(tmp, target);
48
+ }
49
+ catch (error) {
50
+ try {
51
+ unlinkSync(tmp);
52
+ }
53
+ catch {
54
+ /* tmp may not exist */
55
+ }
56
+ throw error;
57
+ }
58
+ // Best-effort directory fsync so the rename's entry is durable. Some platforms
59
+ // (e.g. Windows) reject opening a directory for fsync — ignore there.
60
+ try {
61
+ const dirFd = openSync(dirname(target), 'r');
62
+ try {
63
+ fsyncSync(dirFd);
64
+ }
65
+ finally {
66
+ closeSync(dirFd);
67
+ }
68
+ }
69
+ catch {
70
+ /* directory fsync unsupported — the rename is still atomic */
71
+ }
72
+ }
@@ -0,0 +1,161 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { readFileSync } from 'node:fs';
3
+ import { homedir, userInfo } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { writeFileAtomic } from './atomic-write.js';
6
+ const KEYCHAIN_SERVICE = 'Claude Code-credentials';
7
+ function credentialsFilePath() {
8
+ return join(homedir(), '.claude', '.credentials.json');
9
+ }
10
+ /** Linux/Windows (and sometimes macOS): ~/.claude/.credentials.json */
11
+ function fromCredentialsFile() {
12
+ try {
13
+ return JSON.parse(readFileSync(credentialsFilePath(), 'utf-8'));
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ /** Set by the most recent fromMacKeychain() call so callers can distinguish a
20
+ * genuine "no login" from an access denial (common when the collector runs as a
21
+ * non-interactive launchd agent and the login Keychain read is refused). */
22
+ let macKeychainError = null;
23
+ /** True if the last Keychain read failed for a reason other than item-not-found
24
+ * (exit 44) — i.e. the item likely exists but access was denied. */
25
+ export function macKeychainDenied() {
26
+ return macKeychainError === 'denied';
27
+ }
28
+ /** macOS: Claude Code stores the same JSON in the login Keychain. */
29
+ function fromMacKeychain() {
30
+ if (process.platform !== 'darwin') {
31
+ return null;
32
+ }
33
+ macKeychainError = null;
34
+ try {
35
+ const out = execFileSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w'], {
36
+ encoding: 'utf-8',
37
+ stdio: ['ignore', 'pipe', 'ignore'],
38
+ });
39
+ return JSON.parse(out);
40
+ }
41
+ catch (error) {
42
+ // `security` exits 44 (errSecItemNotFound) when there is genuinely no item;
43
+ // any other non-zero status usually means the read was denied.
44
+ const code = error.status;
45
+ macKeychainError = code === 44 ? 'notfound' : 'denied';
46
+ return null;
47
+ }
48
+ }
49
+ /** Store the refreshed blob back where Claude Code keeps it, so the rotated
50
+ * refresh token stays in sync with the CLI (a rotation we dropped on the floor
51
+ * would log the user out of Claude Code). Throws if it can't be persisted. */
52
+ function persist(blob, from) {
53
+ const json = JSON.stringify(blob);
54
+ if (from === 'file') {
55
+ // Atomic: an interrupted write here truncates the user's live credentials
56
+ // and logs them out of Claude Code entirely.
57
+ writeFileAtomic(credentialsFilePath(), json, 0o600);
58
+ return;
59
+ }
60
+ // The password must go in argv: `security`'s stdin prompt reads at most 128
61
+ // bytes and would silently store a truncated (unparseable) blob.
62
+ execFileSync('security', ['add-generic-password', '-U', '-s', KEYCHAIN_SERVICE, '-a', userInfo().username, '-w', json], { stdio: 'ignore' });
63
+ // Trust nothing: a partial write here means a broken Claude Code login.
64
+ const stored = execFileSync('security', ['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w'], {
65
+ encoding: 'utf-8',
66
+ stdio: ['ignore', 'pipe', 'ignore'],
67
+ }).trim();
68
+ if (stored !== json) {
69
+ throw new Error('keychain write did not round-trip');
70
+ }
71
+ }
72
+ /** Claude Code's public OAuth client id — the same one the CLI refreshes with. */
73
+ const OAUTH_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
74
+ const OAUTH_TOKEN_URL = 'https://console.anthropic.com/v1/oauth/token';
75
+ /** Trade the refresh token for a fresh access token. Access tokens live ~8h, so
76
+ * without this the collector goes dark whenever the user hasn't opened Claude
77
+ * Code for a few hours — the CLI is the only thing that would refresh it. */
78
+ async function refreshOauth(blob, from) {
79
+ const oauth = blob.claudeAiOauth;
80
+ if (!oauth?.refreshToken) {
81
+ return null;
82
+ }
83
+ const res = await fetch(OAUTH_TOKEN_URL, {
84
+ body: JSON.stringify({
85
+ client_id: OAUTH_CLIENT_ID,
86
+ grant_type: 'refresh_token',
87
+ refresh_token: oauth.refreshToken,
88
+ }),
89
+ headers: { 'content-type': 'application/json' },
90
+ method: 'POST',
91
+ signal: AbortSignal.timeout(15_000),
92
+ });
93
+ if (!res.ok) {
94
+ return null;
95
+ }
96
+ const tok = (await res.json());
97
+ if (!tok.access_token) {
98
+ return null;
99
+ }
100
+ try {
101
+ persist({
102
+ ...blob,
103
+ claudeAiOauth: {
104
+ ...oauth,
105
+ accessToken: tok.access_token,
106
+ expiresAt: Date.now() + (tok.expires_in ?? 3600) * 1000,
107
+ refreshToken: tok.refresh_token ?? oauth.refreshToken,
108
+ },
109
+ }, from);
110
+ }
111
+ catch (error) {
112
+ // The refresh already rotated the token server-side, so the stored one is
113
+ // now dead: keep using the new one in memory (valid for hours) and make the
114
+ // failure loud — on restart the user will have to `claude login` again.
115
+ console.error(`could not save the refreshed Claude token (${error.message}) — ` +
116
+ 'run `claude login` if limits stop reporting after a restart');
117
+ }
118
+ return {
119
+ source: 'sub',
120
+ subscriptionType: oauth.subscriptionType ?? null,
121
+ token: tok.access_token,
122
+ };
123
+ }
124
+ /**
125
+ * Auto-detect the local Claude login on THIS machine, with no manual entry:
126
+ * 1. Subscription — OAuth from a `claude` (Claude Code) login, refreshed
127
+ * in place when the access token has expired.
128
+ * 2. API key — ANTHROPIC_API_KEY env var.
129
+ * Returns null if neither is present.
130
+ */
131
+ export async function detectClaudeCreds() {
132
+ const fileBlob = fromCredentialsFile();
133
+ const blob = fileBlob ?? fromMacKeychain();
134
+ const from = fileBlob ? 'file' : 'keychain';
135
+ const oauth = blob?.claudeAiOauth;
136
+ // Only use the OAuth token if it isn't expired (60s skew margin).
137
+ const exp = oauth?.expiresAt;
138
+ if (oauth?.accessToken && (exp == null || exp - 60_000 > Date.now())) {
139
+ return {
140
+ source: 'sub',
141
+ subscriptionType: oauth.subscriptionType ?? null,
142
+ token: oauth.accessToken,
143
+ };
144
+ }
145
+ if (blob) {
146
+ try {
147
+ const refreshed = await refreshOauth(blob, from);
148
+ if (refreshed) {
149
+ return refreshed;
150
+ }
151
+ }
152
+ catch {
153
+ /* refresh endpoint or keychain write failed — fall through to the API key */
154
+ }
155
+ }
156
+ const apiKey = process.env.ANTHROPIC_API_KEY;
157
+ if (apiKey) {
158
+ return { source: 'api', token: apiKey };
159
+ }
160
+ return null;
161
+ }