@usagefleet/cli 1.2.75 → 1.2.77

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 CHANGED
@@ -12,15 +12,17 @@ then, on that machine:
12
12
 
13
13
  ```bash
14
14
  npm i -g @usagefleet/cli
15
- usagefleet install --token uf_xxx
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 `&&`). `install` sets the collector to start at login and
20
- writes `~/.config/usagefleet/config.json` (mode `600`). The dashboard fills in
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
- Running your own server? Add `--endpoint https://track.example.com`.
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 install # (re)install the background service, idempotent
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
- `install` sets up completions for you, for each shell you actually use — zsh
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
- `~/.claude/.credentials.json`; falling back to `ANTHROPIC_API_KEY`), sends a
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 install` registers a Claude Code `UserPromptSubmit` hook in
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 `install` merges, so rotating a token doesn't reset offsets.
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, `install` bakes every `USAGEFLEET_*` value currently set
134
- (plus `ANTHROPIC_API_KEY`) into the launchd/systemd unit, written mode `600`.
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
- `install` is idempotent and reload-safe: re-running it rewrites the service
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 install` under the new one.
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 install` so limits use the API key.
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 `install` to
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.
@@ -1,17 +1,14 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { readFileSync } from 'node:fs';
3
- import { homedir, userInfo } from 'node:os';
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
- function credentialsFilePath() {
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(credentialsFilePath(), 'utf-8'));
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(credentialsFilePath(), json, 0o600);
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
- const blob = fileBlob ?? fromMacKeychain();
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 install --token <t>` with a fresh token');
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(cfg.endpoint));
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 install` survives.
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(endpoint) {
212
- return `device outside your plan's device limit · free a slot or upgrade at ${endpoint}/devices · nothing is lost, uploads resume once it fits`;
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(cfg.endpoint));
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 install --token <device-token>`');
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');
@@ -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: 'install', args: '--token <t>', meaning: 'configure + install the service and prompt guard' },
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 hosted service. Only self-hosted deployments have to name an endpoint,
6
- * so setup on the hosted one is a token and nothing else. */
7
- export const DEFAULT_ENDPOINT = 'https://usagefleet.com';
8
- const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
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(`${cfg.endpoint}/api/v1/limits`, {
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 install`; set USAGEFLEET_HOOK=0 to keep settings.json
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(cfg.endpoint)}`));
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 install`')}`));
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(cfg.endpoint)));
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: persist the flags (when given), then install the
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 install rotates the
195
- * token without resetting tail offsets. Endpoint only matters when
196
- * self-hosting; unset keeps whatever is configured. */
197
- async function cmdInstall() {
198
- // Apply the flags to the env loadConfig() reads rather than writing them to the
199
- // store, so there is exactly one precedence chain and install() persists its
200
- // single winner. Writing to the store first inverted the precedence: loadConfig
201
- // prefers the env, so a stale USAGEFLEET_TOKEN in the install shell beat the
202
- // flag and got written back over it, silently voiding token rotation. It also
203
- // means a rejected value never reaches disk.
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 `install`, which keeps completions in step with
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` was the separate config step; it now just does the whole setup.
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 cmdInstall();
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
- default: {
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 user settings file, where the prompt guard hook is registered.
45
- * CLAUDE_CONFIG_DIR is Claude Code's own relocation knob. */
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(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'settings.json');
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.75";
2
+ export const RELEASE_VERSION = "1.2.77";
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 { DEFAULT_ENDPOINT, loadConfig } from './config.js';
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 var the service needs that does not carry the USAGEFLEET_ prefix. */
13
- const EXTRA_PASSTHROUGH_ENV = 'ANTHROPIC_API_KEY';
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] === EXTRA_PASSTHROUGH_ENV));
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 install`.",
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 or a non-https endpoint fails here or not at all.
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 install --endpoint <url> --token <device-token>'));
232
+ console.error(hint(' usagefleet login <device-token>'));
231
233
  return process.exit(1);
232
234
  }
233
- // Config that only ever lived in this shell's env is lost to every later
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). The
236
- // endpoint would fall back to the hosted default and send this device's token
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 || stored.endpoint !== desiredEndpoint) {
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(cfg.endpoint)));
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 install` change the token while the service is mid-cycle: the
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: `install` rewrites the service definition and restarts it, which
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, 'install'], { detached: true, stdio: 'ignore' }).unref();
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(`${cfg.endpoint}/api/v1/usage`, {
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(`${cfg.endpoint}/api/v1/limits`, {
89
+ const res = await fetch(`${ENDPOINT}/api/v1/limits`, {
89
90
  body: JSON.stringify(report),
90
91
  headers: {
91
92
  'content-type': 'application/json',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usagefleet/cli",
3
- "version": "1.2.75",
3
+ "version": "1.2.77",
4
4
  "description": "Tails Claude Code, Claude Desktop, and pi agent JSONL logs and reports token usage to a UsageFleet server.",
5
5
  "keywords": [
6
6
  "claude",