@usagefleet/cli 1.2.75 → 1.2.76
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 +37 -17
- package/dist/claude-creds.js +10 -9
- package/dist/collector.js +8 -7
- package/dist/completion.js +36 -2
- package/dist/config.js +4 -27
- package/dist/guard.js +2 -2
- package/dist/hook.js +1 -1
- package/dist/index.js +43 -25
- package/dist/paths.js +13 -3
- package/dist/release.js +1 -1
- package/dist/service.js +14 -17
- package/dist/store.js +1 -2
- package/dist/update.js +2 -2
- package/dist/uploader.js +3 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,15 +12,17 @@ then, on that machine:
|
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
14
|
npm i -g @usagefleet/cli
|
|
15
|
-
usagefleet
|
|
15
|
+
usagefleet login uf_xxx
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
Same two commands on macOS, Linux and Windows (in PowerShell chain them with
|
|
19
|
-
`;` — 5.1 has no `&&`). `
|
|
20
|
-
writes `~/.config/usagefleet/config.json` (mode `600`).
|
|
21
|
-
within a minute.
|
|
19
|
+
`;` — 5.1 has no `&&`). `login` pairs the device, sets the collector to start
|
|
20
|
+
with your session and writes `~/.config/usagefleet/config.json` (mode `600`).
|
|
21
|
+
The dashboard fills in within a minute.
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
`login` takes the token and nothing else. The collector reports to
|
|
24
|
+
`usagefleet.com` and there is no way to redirect it: the request carries your
|
|
25
|
+
device token and a log of what this machine is working on.
|
|
24
26
|
|
|
25
27
|
If `npm i -g` fails with EACCES your global prefix is root-owned: use a Node
|
|
26
28
|
version manager (nvm, fnm, volta) or `npm config set prefix ~/.local` with
|
|
@@ -38,12 +40,12 @@ usagefleet guard # exit 2 if this device's group is over a blocking lim
|
|
|
38
40
|
usagefleet notify-test # fire a sample desktop notification
|
|
39
41
|
usagefleet update # upgrade now (it also self-updates every 6h)
|
|
40
42
|
usagefleet config # config file location + every env override
|
|
41
|
-
usagefleet
|
|
43
|
+
usagefleet login <token> # pair this device + (re)install the service, idempotent
|
|
42
44
|
usagefleet uninstall # remove it
|
|
43
45
|
usagefleet completion zsh # print a shell completion script (zsh, fish)
|
|
44
46
|
```
|
|
45
47
|
|
|
46
|
-
`
|
|
48
|
+
`login` sets up completions for you, for each shell you actually use — zsh
|
|
47
49
|
gets `~/.zsh/completions/_usagefleet` plus an `fpath` block appended to
|
|
48
50
|
`.zshrc`, fish gets `~/.config/fish/completions/usagefleet.fish`. Restart the
|
|
49
51
|
shell once. `uninstall` removes both again, and self-update keeps them current.
|
|
@@ -61,7 +63,7 @@ shell once. `uninstall` removes both again, and self-update keeps them current.
|
|
|
61
63
|
is at-least-once — the server dedups on `uuid`.
|
|
62
64
|
- **Your real limit %** — the collector uses the Claude login already on the
|
|
63
65
|
machine (subscription OAuth from `claude`: macOS login Keychain, elsewhere
|
|
64
|
-
|
|
66
|
+
`<config dir>/.credentials.json`; falling back to `ANTHROPIC_API_KEY`), sends a
|
|
65
67
|
1-token ping to the Messages API, and reads Anthropic's
|
|
66
68
|
`anthropic-ratelimit-unified-5h/7d-utilization` headers. Credentials never
|
|
67
69
|
leave the machine — only the percentages do. `usagefleet status` shows which
|
|
@@ -85,7 +87,7 @@ WinRT toast via `powershell.exe`.
|
|
|
85
87
|
|
|
86
88
|
A group can be set to **refuse new prompts** once it has burned its budget slice
|
|
87
89
|
(1/N of the account limit) for a window — a switch per window on the Groups
|
|
88
|
-
page, both off by default. `usagefleet
|
|
90
|
+
page, both off by default. `usagefleet login` registers a Claude Code `UserPromptSubmit` hook in
|
|
89
91
|
`~/.claude/settings.json` (removed by `uninstall`, refreshed rather than stacked
|
|
90
92
|
on re-install, and skipped entirely with `USAGEFLEET_HOOK=0`):
|
|
91
93
|
|
|
@@ -109,13 +111,12 @@ finishes.
|
|
|
109
111
|
`~/.config/usagefleet/config.json` (honours `XDG_CONFIG_HOME`) holds everything
|
|
110
112
|
the CLI persists: your settings plus two machine-managed sections, `state` (tail
|
|
111
113
|
offsets) and `notify` (which thresholds already fired). Delete it to start
|
|
112
|
-
clean. Re-running `
|
|
114
|
+
clean. Re-running `login` merges, so rotating a token doesn't reset offsets.
|
|
113
115
|
|
|
114
116
|
Env vars override the file:
|
|
115
117
|
|
|
116
118
|
| Variable | Meaning |
|
|
117
119
|
|----------|---------|
|
|
118
|
-
| `USAGEFLEET_ENDPOINT` | server base URL (default `https://usagefleet.com`). Must be `https://`, loopback may be `http://` |
|
|
119
120
|
| `USAGEFLEET_TOKEN` | device token |
|
|
120
121
|
| `USAGEFLEET_PROJECTS` | override `~/.claude/projects` |
|
|
121
122
|
| `USAGEFLEET_DESKTOP` | override the Claude Desktop sessions dir; `off` to skip it |
|
|
@@ -129,17 +130,36 @@ Env vars override the file:
|
|
|
129
130
|
| `USAGEFLEET_UPDATE` | `0` turns the self-update check off |
|
|
130
131
|
| `USAGEFLEET_UPDATE_INTERVAL` | seconds between update checks (default `21600` = 6h) |
|
|
131
132
|
| `USAGEFLEET_HOOK` | `0` keeps the prompt-blocking hook out of `~/.claude/settings.json` |
|
|
133
|
+
| `CLAUDE_CONFIG_DIR` | Claude Code's own knob: which login to watch (default `~/.claude`) |
|
|
132
134
|
|
|
133
|
-
When run as a service, `
|
|
134
|
-
(plus `ANTHROPIC_API_KEY`) into the launchd/systemd
|
|
135
|
+
When run as a service, `login` bakes every `USAGEFLEET_*` value currently set
|
|
136
|
+
(plus `ANTHROPIC_API_KEY` and `CLAUDE_CONFIG_DIR`) into the launchd/systemd
|
|
137
|
+
unit, written mode `600`.
|
|
138
|
+
|
|
139
|
+
### Two Claude accounts on one machine
|
|
140
|
+
|
|
141
|
+
One collector watches one login. To report a second subscription from the same
|
|
142
|
+
machine, run a second collector against Claude Code's other config dir, with its
|
|
143
|
+
own device token and its own state:
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
CLAUDE_CONFIG_DIR=~/.claude-work \
|
|
147
|
+
USAGEFLEET_CONFIG=~/.config/usagefleet/work.json \
|
|
148
|
+
USAGEFLEET_PROJECTS=~/.claude-work/projects \
|
|
149
|
+
usagefleet login uf_...
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Each reports its own account, and the dashboard keeps their limits apart. With a
|
|
153
|
+
relocated config dir the macOS Keychain is skipped on purpose: that item belongs
|
|
154
|
+
to the default login.
|
|
135
155
|
|
|
136
156
|
## Background service
|
|
137
157
|
|
|
138
|
-
`
|
|
158
|
+
`login` is idempotent and reload-safe: re-running it rewrites the service
|
|
139
159
|
definition and restarts it, so it doubles as the update step. It launches an
|
|
140
160
|
absolute `node` plus the installed package path, so an empty service PATH is
|
|
141
161
|
fine — but removing that Node version (`nvm uninstall`) stops the collector
|
|
142
|
-
until you re-run `usagefleet
|
|
162
|
+
until you re-run `usagefleet login` under the new one.
|
|
143
163
|
|
|
144
164
|
- **macOS** — a LaunchAgent (`~/Library/LaunchAgents`, RunAtLoad + KeepAlive),
|
|
145
165
|
booted immediately. Logs in `~/Library/Logs/usagefleet/`. Plist is mode `600`:
|
|
@@ -156,12 +176,12 @@ until you re-run `usagefleet install` under the new one.
|
|
|
156
176
|
> the login Keychain for the real limit % may be denied to a non-interactive
|
|
157
177
|
> agent — the collector logs a clear hint. Either approve `/usr/bin/security`
|
|
158
178
|
> access to the `Claude Code-credentials` item once, or set `ANTHROPIC_API_KEY`
|
|
159
|
-
> before `usagefleet
|
|
179
|
+
> before `usagefleet login` so limits use the API key.
|
|
160
180
|
|
|
161
181
|
## Updates
|
|
162
182
|
|
|
163
183
|
`watch` checks the npm registry at startup and every 6 hours; on a new version
|
|
164
|
-
it runs `npm install -g @usagefleet/cli@<version>` and re-runs `
|
|
184
|
+
it runs `npm install -g @usagefleet/cli@<version>` and re-runs `login` to
|
|
165
185
|
restart the service on it. `usagefleet update` does the same on demand. npm is
|
|
166
186
|
called through the absolute path next to the running `node`, because a
|
|
167
187
|
launchd/systemd service gets a minimal PATH.
|
package/dist/claude-creds.js
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
|
-
import {
|
|
4
|
-
import { join } from 'node:path';
|
|
3
|
+
import { userInfo } from 'node:os';
|
|
5
4
|
import { writeFileAtomic } from './atomic-write.js';
|
|
5
|
+
import { claudeCredentialsPath } from './paths.js';
|
|
6
6
|
import { dim, line, yellow } from './ui.js';
|
|
7
7
|
const KEYCHAIN_SERVICE = 'Claude Code-credentials';
|
|
8
|
-
|
|
9
|
-
return join(homedir(), '.claude', '.credentials.json');
|
|
10
|
-
}
|
|
11
|
-
/** Linux/Windows (and sometimes macOS): ~/.claude/.credentials.json */
|
|
8
|
+
/** Linux/Windows (and sometimes macOS): <config dir>/.credentials.json */
|
|
12
9
|
function fromCredentialsFile() {
|
|
13
10
|
try {
|
|
14
|
-
return JSON.parse(readFileSync(
|
|
11
|
+
return JSON.parse(readFileSync(claudeCredentialsPath(), 'utf-8'));
|
|
15
12
|
}
|
|
16
13
|
catch {
|
|
17
14
|
return null;
|
|
@@ -55,7 +52,7 @@ function persist(blob, from) {
|
|
|
55
52
|
if (from === 'file') {
|
|
56
53
|
// Atomic: an interrupted write here truncates the user's live credentials
|
|
57
54
|
// and logs them out of Claude Code entirely.
|
|
58
|
-
writeFileAtomic(
|
|
55
|
+
writeFileAtomic(claudeCredentialsPath(), json, 0o600);
|
|
59
56
|
return;
|
|
60
57
|
}
|
|
61
58
|
// The password must go in argv: `security`'s stdin prompt reads at most 128
|
|
@@ -130,7 +127,11 @@ async function refreshOauth(blob, from) {
|
|
|
130
127
|
*/
|
|
131
128
|
export async function detectClaudeCreds() {
|
|
132
129
|
const fileBlob = fromCredentialsFile();
|
|
133
|
-
|
|
130
|
+
// The Keychain item is global, so it belongs to whoever owns the default
|
|
131
|
+
// config dir. A collector watching a relocated one is a different login and
|
|
132
|
+
// must not fall back to it: it would report that login's limits under this
|
|
133
|
+
// account's uuid.
|
|
134
|
+
const blob = fileBlob ?? (process.env.CLAUDE_CONFIG_DIR ? null : fromMacKeychain());
|
|
134
135
|
const from = fileBlob ? 'file' : 'keychain';
|
|
135
136
|
const oauth = blob?.claudeAiOauth;
|
|
136
137
|
// Only use the OAuth token if it isn't expired (60s skew margin).
|
package/dist/collector.js
CHANGED
|
@@ -4,6 +4,7 @@ import { sep } from 'node:path';
|
|
|
4
4
|
import { detectClaudeAccount } from './claude-account.js';
|
|
5
5
|
import { detectClaudeCreds, macKeychainDenied } from './claude-creds.js';
|
|
6
6
|
import { fetchLimits } from './claude-limits.js';
|
|
7
|
+
import { ENDPOINT } from './config.js';
|
|
7
8
|
import { maybeNotify } from './notifier.js';
|
|
8
9
|
import { detectOs } from './os.js';
|
|
9
10
|
import { RELEASE_VERSION } from './release.js';
|
|
@@ -96,14 +97,14 @@ export async function runOnce(cfg, log = () => {
|
|
|
96
97
|
// Token revoked/expired. The data is valid and must NOT be skipped — keep
|
|
97
98
|
// the offset so it uploads once a fresh token is configured. Retrying the
|
|
98
99
|
// remaining files would 401 identically, so stop this cycle and surface.
|
|
99
|
-
log('warn', 'auth rejected · device token invalid or revoked · re-run `usagefleet
|
|
100
|
+
log('warn', 'auth rejected · device token invalid or revoked · re-run `usagefleet login --token <t>` with a fresh token');
|
|
100
101
|
result.failed = true;
|
|
101
102
|
break;
|
|
102
103
|
}
|
|
103
104
|
else if (outcome === 'plan') {
|
|
104
105
|
// The device sits outside the account's device limit (402). Every other
|
|
105
106
|
// file gets the same answer, so stop and say what unblocks it once.
|
|
106
|
-
log('warn', planWall(
|
|
107
|
+
log('warn', planWall());
|
|
107
108
|
result.failed = true;
|
|
108
109
|
break;
|
|
109
110
|
}
|
|
@@ -126,7 +127,7 @@ export async function runOnce(cfg, log = () => {
|
|
|
126
127
|
// One durable write per cycle rather than one per file: the store is fsynced
|
|
127
128
|
// on every save, and a crash mid-cycle only costs a re-upload the server
|
|
128
129
|
// dedups. Only our own section is replaced, so a token written by a
|
|
129
|
-
// concurrent `usagefleet
|
|
130
|
+
// concurrent `usagefleet login` survives.
|
|
130
131
|
if (pruneMissingFiles(state, files) || advanced) {
|
|
131
132
|
updateStore(cfg.storePath, store => {
|
|
132
133
|
store.state.files = state.files;
|
|
@@ -208,8 +209,8 @@ function pruneMissingFiles(state, scanned) {
|
|
|
208
209
|
}
|
|
209
210
|
/** The one thing that unblocks a device parked outside the account's device limit.
|
|
210
211
|
* Shared by both upload legs so the wording cannot drift between them. */
|
|
211
|
-
function planWall(
|
|
212
|
-
return `device outside your plan's device limit · free a slot or upgrade at ${
|
|
212
|
+
function planWall() {
|
|
213
|
+
return `device outside your plan's device limit · free a slot or upgrade at ${ENDPOINT}/devices · nothing is lost, uploads resume once it fits`;
|
|
213
214
|
}
|
|
214
215
|
/**
|
|
215
216
|
* Auto-detect the local Claude login, read the real 5h/weekly utilization from
|
|
@@ -250,10 +251,10 @@ export async function reportLimitsOnce(cfg, log = () => {
|
|
|
250
251
|
const account = report.source === 'sub' ? detectClaudeAccount() : null;
|
|
251
252
|
const outcome = await postLimits({ ...report, account }, cfg);
|
|
252
253
|
if (outcome === 'plan') {
|
|
253
|
-
log('warn', planWall(
|
|
254
|
+
log('warn', planWall());
|
|
254
255
|
}
|
|
255
256
|
else if (outcome === 'auth') {
|
|
256
|
-
log('warn', 'limits rejected · device token invalid or revoked · re-run `usagefleet
|
|
257
|
+
log('warn', 'limits rejected · device token invalid or revoked · re-run `usagefleet login --token <device-token>`');
|
|
257
258
|
}
|
|
258
259
|
else if (outcome === 'invalid') {
|
|
259
260
|
log('warn', 'limits rejected as malformed · this is a bug, please report it');
|
package/dist/completion.js
CHANGED
|
@@ -8,7 +8,8 @@ import { writeFileAtomic } from './atomic-write.js';
|
|
|
8
8
|
*
|
|
9
9
|
* `watch` is deliberately absent: it is the entrypoint the installed service
|
|
10
10
|
* runs, not something to type. It still dispatches — every plist and unit on
|
|
11
|
-
* disk names it — it just isn't advertised.
|
|
11
|
+
* disk names it — it just isn't advertised. So are `install`/`init`, the former
|
|
12
|
+
* names of `login`, kept dispatching for commands already pasted into scripts. */
|
|
12
13
|
export const commands = [
|
|
13
14
|
{ name: 'run', meaning: 'scan once, upload usage + report limits' },
|
|
14
15
|
{ name: 'limits', meaning: 'report only your real 5h/weekly usage' },
|
|
@@ -18,10 +19,43 @@ export const commands = [
|
|
|
18
19
|
{ name: 'status', meaning: 'service health, limits, resolved config' },
|
|
19
20
|
{ name: 'config', meaning: 'config file location and env overrides' },
|
|
20
21
|
{ name: 'completion', args: '<zsh|fish>', meaning: 'print a shell completion script' },
|
|
21
|
-
{ name: '
|
|
22
|
+
{ name: 'login', args: '<token>', meaning: 'pair this device, install the service and prompt guard' },
|
|
22
23
|
{ name: 'uninstall', meaning: 'remove the service and the guard' },
|
|
23
24
|
];
|
|
24
25
|
export const shells = ['zsh', 'fish'];
|
|
26
|
+
/** Everything `main` dispatches, advertised or not — the pool a mistyped command
|
|
27
|
+
* is matched against. Kept next to `commands` so a new case in the switch has
|
|
28
|
+
* one obvious place to be listed. */
|
|
29
|
+
const dispatchable = [...commands.map(c => c.name), 'watch', 'install', 'init', 'help'];
|
|
30
|
+
/** The command a typo probably meant, or undefined when nothing is close enough
|
|
31
|
+
* to guess: `statu` → `status`, `frobnicate` → nothing. */
|
|
32
|
+
export function suggest(input) {
|
|
33
|
+
const typed = input.toLowerCase().replace(/^-+/, '');
|
|
34
|
+
// Scales with the input so short words don't match everything: at most two
|
|
35
|
+
// edits for a normal command, more only for the long ones.
|
|
36
|
+
const cutoff = Math.max(2, Math.floor(typed.length / 3));
|
|
37
|
+
let best;
|
|
38
|
+
for (const name of dispatchable) {
|
|
39
|
+
const d = distance(typed, name);
|
|
40
|
+
if (d <= cutoff && (!best || d < best.d)) {
|
|
41
|
+
best = { name, d };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return best?.name;
|
|
45
|
+
}
|
|
46
|
+
/** Plain Levenshtein, two rows at a time. Ten commands of ten characters: not
|
|
47
|
+
* worth a dependency. */
|
|
48
|
+
function distance(a, b) {
|
|
49
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
50
|
+
for (let i = 1; i <= a.length; i++) {
|
|
51
|
+
const row = [i];
|
|
52
|
+
for (let j = 1; j <= b.length; j++) {
|
|
53
|
+
row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
54
|
+
}
|
|
55
|
+
prev = row;
|
|
56
|
+
}
|
|
57
|
+
return prev[b.length];
|
|
58
|
+
}
|
|
25
59
|
/** A completion script for `shell`, on stdout. `install` writes these to the
|
|
26
60
|
* right place automatically; this command stays for piping one somewhere else. */
|
|
27
61
|
export function completionScript(shell) {
|
package/dist/config.js
CHANGED
|
@@ -2,23 +2,19 @@ import { defaultDesktopSessionsDir, defaultPiSessionsDirs, defaultProjectsDir }
|
|
|
2
2
|
import { readStore, storePath } from './store.js';
|
|
3
3
|
/** Matches the server's BatchSchema `.max(1000)`. */
|
|
4
4
|
const MAX_BATCH = 1000;
|
|
5
|
-
/** The
|
|
6
|
-
*
|
|
7
|
-
|
|
8
|
-
const
|
|
5
|
+
/** The only server a collector talks to. Not configurable: the request carries
|
|
6
|
+
* a device token and a log of what this machine is working on, so there is one
|
|
7
|
+
* https destination and no way to redirect it. */
|
|
8
|
+
export const ENDPOINT = 'https://usagefleet.com';
|
|
9
9
|
/** Resolve config from env first, then the stored settings (see store.ts). */
|
|
10
10
|
export function loadConfig() {
|
|
11
11
|
const file = readStore();
|
|
12
12
|
// Use `||` (not `??`) so an empty-string env var falls back to the config
|
|
13
13
|
// file — launchd/systemd units may inject empty USAGEFLEET_* values.
|
|
14
|
-
const endpoint = (process.env.USAGEFLEET_ENDPOINT || file.endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, '');
|
|
15
14
|
const token = process.env.USAGEFLEET_TOKEN || file.token || '';
|
|
16
15
|
if (!token) {
|
|
17
16
|
throw new Error('USAGEFLEET_TOKEN is not set');
|
|
18
17
|
}
|
|
19
|
-
if (!isSecureEndpoint(endpoint)) {
|
|
20
|
-
throw new Error(`endpoint must be https (got ${endpoint}). It carries the device token on every request. Set --endpoint or USAGEFLEET_ENDPOINT.`);
|
|
21
|
-
}
|
|
22
18
|
// Guard batch size: "0" (infinite loop), NaN (silent drop), fractional → 100.
|
|
23
19
|
// Clamped to the server's own 1000-record cap, since a larger batch is
|
|
24
20
|
// rejected as malformed and would cost the whole chunk a bisect to discover.
|
|
@@ -27,7 +23,6 @@ export function loadConfig() {
|
|
|
27
23
|
return {
|
|
28
24
|
batchSize,
|
|
29
25
|
desktopDir: resolveOptionalDir(process.env.USAGEFLEET_DESKTOP, file.desktopDir, defaultDesktopSessionsDir()),
|
|
30
|
-
endpoint,
|
|
31
26
|
piDirs: resolvePiDirs(process.env.USAGEFLEET_PI, file.piDir),
|
|
32
27
|
projectsDir: process.env.USAGEFLEET_PROJECTS || file.projectsDir || defaultProjectsDir(),
|
|
33
28
|
storePath: storePath(),
|
|
@@ -54,21 +49,3 @@ function resolveOptionalDir(env, fromFile, fallback) {
|
|
|
54
49
|
}
|
|
55
50
|
return env || fromFile || fallback;
|
|
56
51
|
}
|
|
57
|
-
/**
|
|
58
|
-
* The endpoint must be https: it carries the device token on every request, and
|
|
59
|
-
* the payload is a log of what this machine is working on. Loopback is exempt so
|
|
60
|
-
* local development keeps working.
|
|
61
|
-
*/
|
|
62
|
-
export function isSecureEndpoint(endpoint) {
|
|
63
|
-
let url;
|
|
64
|
-
try {
|
|
65
|
-
url = new URL(endpoint);
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
if (url.protocol === 'https:') {
|
|
71
|
-
return true;
|
|
72
|
-
}
|
|
73
|
-
return url.protocol === 'http:' && LOOPBACK_HOSTS.has(url.hostname);
|
|
74
|
-
}
|
package/dist/guard.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadConfig } from './config.js';
|
|
1
|
+
import { ENDPOINT, loadConfig } from './config.js';
|
|
2
2
|
/** Hooks run on the interactive path — a slow/hung server must not stall a
|
|
3
3
|
* prompt for long. On timeout we fail open (see {@link runGuard}). */
|
|
4
4
|
const TIMEOUT_MS = 5000;
|
|
@@ -38,7 +38,7 @@ export async function runGuard() {
|
|
|
38
38
|
}
|
|
39
39
|
let view;
|
|
40
40
|
try {
|
|
41
|
-
const res = await fetch(`${
|
|
41
|
+
const res = await fetch(`${ENDPOINT}/api/v1/limits`, {
|
|
42
42
|
headers: { 'x-api-key': cfg.token },
|
|
43
43
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
44
44
|
});
|
package/dist/hook.js
CHANGED
|
@@ -89,7 +89,7 @@ function editSettings(transform, onWrite) {
|
|
|
89
89
|
/**
|
|
90
90
|
* Register `usagefleet guard` as a Claude Code UserPromptSubmit hook, so a
|
|
91
91
|
* group with blocking enabled actually refuses prompts. Called by
|
|
92
|
-
* `usagefleet
|
|
92
|
+
* `usagefleet login`; set USAGEFLEET_HOOK=0 to keep settings.json
|
|
93
93
|
* untouched.
|
|
94
94
|
*/
|
|
95
95
|
export function installPromptHook(program) {
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { detectClaudeCreds } from './claude-creds.js';
|
|
3
3
|
import { reportLimitsOnce, runOnce } from './collector.js';
|
|
4
|
-
import { commands, completionScript, installCompletions, removeCompletions, shells } from './completion.js';
|
|
5
|
-
import { loadConfig } from './config.js';
|
|
4
|
+
import { commands, completionScript, installCompletions, removeCompletions, shells, suggest } from './completion.js';
|
|
5
|
+
import { ENDPOINT, loadConfig } from './config.js';
|
|
6
6
|
import { runGuard } from './guard.js';
|
|
7
7
|
import { loadNotifyConfig } from './notifier.js';
|
|
8
8
|
import { sendNotification } from './notify.js';
|
|
@@ -84,7 +84,7 @@ async function cmdWatch() {
|
|
|
84
84
|
let lastUpdateAt = 0;
|
|
85
85
|
const watching = [cfg.projectsDir, cfg.desktopDir, ...cfg.piDirs].filter((d) => !!d);
|
|
86
86
|
console.log(header(`watching every ${interval / 1000}s`));
|
|
87
|
-
console.log(hint(`${watching.map(tilde).join(' · ')} → ${host(
|
|
87
|
+
console.log(hint(`${watching.map(tilde).join(' · ')} → ${host(ENDPOINT)}`));
|
|
88
88
|
console.log('');
|
|
89
89
|
let stopping = false;
|
|
90
90
|
let timer = null;
|
|
@@ -167,7 +167,7 @@ async function cmdStatus() {
|
|
|
167
167
|
console.log('');
|
|
168
168
|
console.log(svc.state === 'running'
|
|
169
169
|
? stateLine('ok', 'service', `running${svc.pid ? dim(` · pid ${svc.pid}`) : ''}`)
|
|
170
|
-
: stateLine('bad', 'service', `${svc.state} ${dim(svc.state === 'stopped' ? '· check the log' : '· run `usagefleet
|
|
170
|
+
: stateLine('bad', 'service', `${svc.state} ${dim(svc.state === 'stopped' ? '· check the log' : '· run `usagefleet login`')}`));
|
|
171
171
|
console.log(creds
|
|
172
172
|
? stateLine('ok', 'claude', `${creds.source}${dim(creds.subscriptionType ? ` · ${creds.subscriptionType}` : '')}`)
|
|
173
173
|
: stateLine('warn', 'claude', `no login ${dim('· sign in with `claude` or set ANTHROPIC_API_KEY')}`));
|
|
@@ -175,7 +175,7 @@ async function cmdStatus() {
|
|
|
175
175
|
? stateLine(limitHealth(limits.fiveHourPct, limits.sevenDayPct), 'limits', `5h ${bar(limits.fiveHourPct)} ${pct(limits.fiveHourPct)} · weekly ${bar(limits.sevenDayPct)} ${pct(limits.sevenDayPct)} ${dim(ago(limits.at))}`)
|
|
176
176
|
: stateLine('warn', 'limits', `no reading yet ${dim('· run `usagefleet limits`')}`));
|
|
177
177
|
console.log('');
|
|
178
|
-
console.log(row('endpoint', host(
|
|
178
|
+
console.log(row('endpoint', host(ENDPOINT)));
|
|
179
179
|
console.log(row('device', `${state.deviceId} · token ${cfg.token.slice(0, 8)}…`));
|
|
180
180
|
const watching = [cfg.projectsDir, cfg.desktopDir, ...cfg.piDirs].filter((d) => !!d);
|
|
181
181
|
for (const [i, dir] of watching.entries()) {
|
|
@@ -189,30 +189,35 @@ function limitHealth(fiveHour, sevenDay) {
|
|
|
189
189
|
const worst = Math.max(fiveHour ?? 0, sevenDay ?? 0);
|
|
190
190
|
return worst >= 95 ? 'bad' : worst >= 80 ? 'warn' : 'ok';
|
|
191
191
|
}
|
|
192
|
-
/** Setup in one command:
|
|
192
|
+
/** Setup in one command: `usagefleet login <device-token>`, then install the
|
|
193
193
|
* background service, which refuses to install without a resolvable token.
|
|
194
|
-
* The write merges over the existing store, so re-running
|
|
195
|
-
* token without resetting tail offsets.
|
|
196
|
-
*
|
|
197
|
-
async function
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
const endpoint = flag('endpoint');
|
|
205
|
-
const token = flag('token');
|
|
206
|
-
if (endpoint) {
|
|
207
|
-
process.env.USAGEFLEET_ENDPOINT = endpoint;
|
|
194
|
+
* The write merges over the existing store, so re-running login rotates the
|
|
195
|
+
* token without resetting tail offsets. Omitting the token re-runs setup with
|
|
196
|
+
* whatever is already configured, which is what self-update does. */
|
|
197
|
+
async function cmdLogin() {
|
|
198
|
+
// Rejected loudly rather than ignored: a collector that used to report to a
|
|
199
|
+
// self-hosted server would otherwise start shipping this device's usage to the
|
|
200
|
+
// hosted one on the next self-update, silently.
|
|
201
|
+
if (process.argv.some(a => a === '--endpoint' || a.startsWith('--endpoint='))) {
|
|
202
|
+
console.error(fail('endpoint', 'no longer configurable · this collector reports to usagefleet.com'));
|
|
203
|
+
return process.exit(1);
|
|
208
204
|
}
|
|
205
|
+
// Positional, with the retired `--token` flag still accepted: it is printed by
|
|
206
|
+
// every collector older than the rename and pasted into provisioning scripts.
|
|
207
|
+
// Apply it to the env loadConfig() reads rather than writing it to the store,
|
|
208
|
+
// so there is exactly one precedence chain and install() persists its single
|
|
209
|
+
// winner. Writing to the store first inverted the precedence: loadConfig
|
|
210
|
+
// prefers the env, so a stale USAGEFLEET_TOKEN in the login shell beat the
|
|
211
|
+
// argument and got written back over it, silently voiding token rotation. It
|
|
212
|
+
// also means a rejected value never reaches disk.
|
|
213
|
+
const token = process.argv.slice(3).find(a => !a.startsWith('-')) ?? flag('token');
|
|
209
214
|
if (token) {
|
|
210
215
|
process.env.USAGEFLEET_TOKEN = token;
|
|
211
216
|
}
|
|
212
217
|
const { install } = await import('./service.js');
|
|
213
218
|
install();
|
|
214
219
|
// After the service, so a completion problem can never fail the part that
|
|
215
|
-
// matters. Self-update re-runs `
|
|
220
|
+
// matters. Self-update re-runs `login`, which keeps completions in step with
|
|
216
221
|
// new commands without the user doing anything.
|
|
217
222
|
try {
|
|
218
223
|
for (const { shell, path, rc } of installCompletions()) {
|
|
@@ -238,7 +243,6 @@ function print(rows) {
|
|
|
238
243
|
* it must work before a token exists, when the config is what you're fixing. */
|
|
239
244
|
function cmdConfig() {
|
|
240
245
|
const env = [
|
|
241
|
-
['USAGEFLEET_ENDPOINT', 'server base URL (self-hosting only)'],
|
|
242
246
|
['USAGEFLEET_TOKEN', 'device token from the Devices page'],
|
|
243
247
|
['USAGEFLEET_PROJECTS', 'override ~/.claude/projects'],
|
|
244
248
|
['USAGEFLEET_DESKTOP', 'override the Claude Desktop dir ("off" disables)'],
|
|
@@ -330,10 +334,13 @@ async function main() {
|
|
|
330
334
|
console.log(RELEASE_VERSION);
|
|
331
335
|
return;
|
|
332
336
|
}
|
|
333
|
-
// `init`
|
|
337
|
+
// `install` (and `init` before it) named this step until it became `login`.
|
|
338
|
+
// Both still dispatch: they are pasted into scripts and printed by every
|
|
339
|
+
// collector old enough to predate the rename.
|
|
340
|
+
case 'login':
|
|
334
341
|
case 'init':
|
|
335
342
|
case 'install': {
|
|
336
|
-
return
|
|
343
|
+
return cmdLogin();
|
|
337
344
|
}
|
|
338
345
|
case 'uninstall': {
|
|
339
346
|
const { uninstall } = await import('./service.js');
|
|
@@ -341,9 +348,20 @@ async function main() {
|
|
|
341
348
|
removeCompletions();
|
|
342
349
|
return;
|
|
343
350
|
}
|
|
344
|
-
|
|
351
|
+
case 'help':
|
|
352
|
+
case '--help':
|
|
353
|
+
case '-h': {
|
|
345
354
|
return help();
|
|
346
355
|
}
|
|
356
|
+
// Anything else is a mistake, not a request for help: say so, guess what was
|
|
357
|
+
// meant, and exit non-zero so a script notices the typo instead of reading a
|
|
358
|
+
// help screen as success.
|
|
359
|
+
default: {
|
|
360
|
+
const near = suggest(cmd);
|
|
361
|
+
console.error(fail('unknown', `${cmd}${near ? ` · did you mean \`${near}\`?` : ''}`));
|
|
362
|
+
console.error(hint('`usagefleet help` lists every command'));
|
|
363
|
+
process.exitCode = 1;
|
|
364
|
+
}
|
|
347
365
|
}
|
|
348
366
|
}
|
|
349
367
|
main().catch(error => {
|
package/dist/paths.js
CHANGED
|
@@ -41,10 +41,20 @@ export function defaultPiSessionsDirs() {
|
|
|
41
41
|
}
|
|
42
42
|
return [...new Set(dirs)];
|
|
43
43
|
}
|
|
44
|
-
/** Claude Code's
|
|
45
|
-
*
|
|
44
|
+
/** Claude Code's config dir. CLAUDE_CONFIG_DIR is Claude Code's own relocation
|
|
45
|
+
* knob, and a relocated dir is a second, independent login: its own settings,
|
|
46
|
+
* its own credentials, its own Anthropic account. */
|
|
47
|
+
export function claudeConfigDir() {
|
|
48
|
+
return process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude');
|
|
49
|
+
}
|
|
50
|
+
/** Claude Code's user settings file, where the prompt guard hook is registered. */
|
|
46
51
|
export function claudeSettingsPath() {
|
|
47
|
-
return join(
|
|
52
|
+
return join(claudeConfigDir(), 'settings.json');
|
|
53
|
+
}
|
|
54
|
+
/** Where Claude Code keeps its OAuth blob on Linux and Windows — and on macOS
|
|
55
|
+
* too when the login Keychain is unavailable. */
|
|
56
|
+
export function claudeCredentialsPath() {
|
|
57
|
+
return join(claudeConfigDir(), '.credentials.json');
|
|
48
58
|
}
|
|
49
59
|
/** Claude Code's global state file (`~/.claude.json`), which records which
|
|
50
60
|
* Anthropic account this machine is logged into. Unlike settings.json it sits
|
package/dist/release.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by .github/workflows/release.yml.
|
|
2
|
-
export const RELEASE_VERSION = "1.2.
|
|
2
|
+
export const RELEASE_VERSION = "1.2.76";
|
package/dist/service.js
CHANGED
|
@@ -2,15 +2,17 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import { chmodSync, existsSync, mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { homedir, tmpdir } from 'node:os';
|
|
4
4
|
import { delimiter, join } from 'node:path';
|
|
5
|
-
import {
|
|
5
|
+
import { ENDPOINT, loadConfig } from './config.js';
|
|
6
6
|
import { installPromptHook, uninstallPromptHook } from './hook.js';
|
|
7
7
|
import { readStore, storePath, updateStore } from './store.js';
|
|
8
8
|
import { fail, header, hint, host, row, step, tilde, warn } from './ui.js';
|
|
9
9
|
const LABEL = 'dev.usagefleet.collector';
|
|
10
10
|
/** Scheduled Task name on Windows (mirrors the launchd label / systemd unit). */
|
|
11
11
|
const TASK = 'usagefleet';
|
|
12
|
-
/** Extra env
|
|
13
|
-
|
|
12
|
+
/** Extra env the service needs that does not carry the USAGEFLEET_ prefix.
|
|
13
|
+
* CLAUDE_CONFIG_DIR picks which Claude login this collector watches, so a
|
|
14
|
+
* service that lost it would silently report the default account instead. */
|
|
15
|
+
const EXTRA_PASSTHROUGH_ENV = new Set(['ANTHROPIC_API_KEY', 'CLAUDE_CONFIG_DIR']);
|
|
14
16
|
/** Per-user dir for the collector's own runtime files: the Windows launcher and
|
|
15
17
|
* its log, plus the binary copy that pre-npm releases left there. */
|
|
16
18
|
function stableBinDir() {
|
|
@@ -106,7 +108,7 @@ function systemdUnitPath() {
|
|
|
106
108
|
* USAGEFLEET_PI, USAGEFLEET_DESKTOP and USAGEFLEET_LIMITS_INTERVAL, so a
|
|
107
109
|
* documented override did nothing once the collector ran as a service. */
|
|
108
110
|
function presentEnv() {
|
|
109
|
-
return Object.entries(process.env).filter((entry) => !!entry[1] && (entry[0].startsWith('USAGEFLEET_') || entry[0]
|
|
111
|
+
return Object.entries(process.env).filter((entry) => !!entry[1] && (entry[0].startsWith('USAGEFLEET_') || EXTRA_PASSTHROUGH_ENV.has(entry[0])));
|
|
110
112
|
}
|
|
111
113
|
/** Escape a string for a VBScript double-quoted literal (only `"` is special). */
|
|
112
114
|
function vbs(s) {
|
|
@@ -129,7 +131,7 @@ export function windowsLauncherVbs(prog, env, logPath) {
|
|
|
129
131
|
.filter(([, v]) => !/[\r\n]/.test(v))
|
|
130
132
|
.map(([k, v]) => `env(${vbs(k)}) = ${vbs(v)}`);
|
|
131
133
|
return [
|
|
132
|
-
"' usagefleet background launcher — generated by `usagefleet
|
|
134
|
+
"' usagefleet background launcher — generated by `usagefleet login`.",
|
|
133
135
|
'Set sh = CreateObject("WScript.Shell")',
|
|
134
136
|
'Set env = sh.Environment("Process")',
|
|
135
137
|
...envLines,
|
|
@@ -220,36 +222,31 @@ export function install() {
|
|
|
220
222
|
// every launch, because the service manager crash-loops it invisibly (only the
|
|
221
223
|
// log file shows it). Resolving through loadConfig() is what makes this a real
|
|
222
224
|
// pre-flight rather than a lookalike: it is the same call `watch` makes, so a
|
|
223
|
-
// missing token
|
|
225
|
+
// missing token fails here or not at all.
|
|
224
226
|
let cfg;
|
|
225
227
|
try {
|
|
226
228
|
cfg = loadConfig();
|
|
227
229
|
}
|
|
228
230
|
catch (error) {
|
|
229
231
|
console.error(fail('config', error.message));
|
|
230
|
-
console.error(hint(' usagefleet
|
|
232
|
+
console.error(hint(' usagefleet login <device-token>'));
|
|
231
233
|
return process.exit(1);
|
|
232
234
|
}
|
|
233
|
-
//
|
|
235
|
+
// A token that only ever lived in this shell's env is lost to every later
|
|
234
236
|
// invocation: `usagefleet guard` runs from Claude Code's environment, which
|
|
235
|
-
// carries no USAGEFLEET_* vars (hook.ts bakes the command, not the env)
|
|
236
|
-
//
|
|
237
|
-
// there; the token would be missing outright and the guard would fail open. So
|
|
238
|
-
// pin both to disk. The default endpoint is stored as absent rather than
|
|
239
|
-
// written out, so it can still move under an existing install.
|
|
240
|
-
const desiredEndpoint = cfg.endpoint === DEFAULT_ENDPOINT ? undefined : cfg.endpoint;
|
|
237
|
+
// carries no USAGEFLEET_* vars (hook.ts bakes the command, not the env), so the
|
|
238
|
+
// token would be missing outright and the guard would fail open. Pin it to disk.
|
|
241
239
|
const stored = readStore();
|
|
242
|
-
if (stored.token !== cfg.token
|
|
240
|
+
if (stored.token !== cfg.token) {
|
|
243
241
|
// Only on a real change: `update` re-runs install every six hours, and this
|
|
244
242
|
// file is shared with the running collector's offset writes.
|
|
245
243
|
updateStore(storePath(), store => {
|
|
246
244
|
store.token = cfg.token;
|
|
247
|
-
store.endpoint = desiredEndpoint;
|
|
248
245
|
});
|
|
249
246
|
}
|
|
250
247
|
console.log(header());
|
|
251
248
|
console.log('');
|
|
252
|
-
console.log(step('configured', host(
|
|
249
|
+
console.log(step('configured', host(ENDPOINT)));
|
|
253
250
|
// Windows: stop a running task first, or `schtasks /run` below is ignored (the
|
|
254
251
|
// task is IgnoreNew) — leaving the OLD version resident after an "update".
|
|
255
252
|
if (process.platform === 'win32') {
|
package/dist/store.js
CHANGED
|
@@ -45,7 +45,6 @@ export function freshWindow() {
|
|
|
45
45
|
function normalize(raw) {
|
|
46
46
|
return {
|
|
47
47
|
desktopDir: raw.desktopDir,
|
|
48
|
-
endpoint: raw.endpoint,
|
|
49
48
|
limits: raw.limits,
|
|
50
49
|
notify: {
|
|
51
50
|
fiveHour: { ...freshWindow(), ...raw.notify?.fiveHour },
|
|
@@ -86,7 +85,7 @@ export function readStore(path = storePath()) {
|
|
|
86
85
|
}
|
|
87
86
|
/**
|
|
88
87
|
* Read-modify-write the store atomically. Re-reading inside the call is what
|
|
89
|
-
* lets `usagefleet
|
|
88
|
+
* lets `usagefleet login` change the token while the service is mid-cycle: the
|
|
90
89
|
* service's next save picks up the new token instead of overwriting it with the
|
|
91
90
|
* copy it loaded minutes ago.
|
|
92
91
|
*
|
package/dist/update.js
CHANGED
|
@@ -94,10 +94,10 @@ export async function checkForUpdate(log, force = false) {
|
|
|
94
94
|
: `npm install failed (exit ${code}) · if the global prefix needs root, run it yourself`);
|
|
95
95
|
return null;
|
|
96
96
|
}
|
|
97
|
-
// Detached: `
|
|
97
|
+
// Detached: `login` rewrites the service definition and restarts it, which
|
|
98
98
|
// kills this process tree. npm replaced the file behind `self`, so this is
|
|
99
99
|
// already the new version.
|
|
100
|
-
spawn(process.execPath, [self, '
|
|
100
|
+
spawn(process.execPath, [self, 'login'], { detached: true, stdio: 'ignore' }).unref();
|
|
101
101
|
log('ok', `installed ${latest} · restarting service`);
|
|
102
102
|
return latest;
|
|
103
103
|
}
|
package/dist/uploader.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ENDPOINT } from './config.js';
|
|
1
2
|
const MAX_ATTEMPTS = 6;
|
|
2
3
|
const REQUEST_TIMEOUT_MS = 15_000;
|
|
3
4
|
function sleep(ms) {
|
|
@@ -14,7 +15,7 @@ export async function uploadBatch(payload, cfg) {
|
|
|
14
15
|
for (let attempt = 0; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
15
16
|
let res = null;
|
|
16
17
|
try {
|
|
17
|
-
res = await fetch(`${
|
|
18
|
+
res = await fetch(`${ENDPOINT}/api/v1/usage`, {
|
|
18
19
|
body: JSON.stringify(payload),
|
|
19
20
|
headers: {
|
|
20
21
|
'content-type': 'application/json',
|
|
@@ -85,7 +86,7 @@ function retryAfterMs(header, fallback) {
|
|
|
85
86
|
* reading is worth less than the next cycle's fresh one. */
|
|
86
87
|
export async function postLimits(report, cfg) {
|
|
87
88
|
try {
|
|
88
|
-
const res = await fetch(`${
|
|
89
|
+
const res = await fetch(`${ENDPOINT}/api/v1/limits`, {
|
|
89
90
|
body: JSON.stringify(report),
|
|
90
91
|
headers: {
|
|
91
92
|
'content-type': 'application/json',
|