@usagefleet/cli 1.2.73 → 1.2.75

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
@@ -43,12 +43,12 @@ usagefleet uninstall # remove it
43
43
  usagefleet completion zsh # print a shell completion script (zsh, fish)
44
44
  ```
45
45
 
46
- Completions are printed, not installed send them where your shell looks:
46
+ `install` sets up completions for you, for each shell you actually use zsh
47
+ gets `~/.zsh/completions/_usagefleet` plus an `fpath` block appended to
48
+ `.zshrc`, fish gets `~/.config/fish/completions/usagefleet.fish`. Restart the
49
+ shell once. `uninstall` removes both again, and self-update keeps them current.
47
50
 
48
- ```bash
49
- usagefleet completion zsh > ~/.zsh/completions/_usagefleet # a dir on your fpath
50
- usagefleet completion fish > ~/.config/fish/completions/usagefleet.fish
51
- ```
51
+ `completion` stays for piping a script somewhere else yourself.
52
52
 
53
53
  ## What it collects
54
54
 
@@ -0,0 +1,34 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { claudeStatePath } from './paths.js';
3
+ /** Pull the account out of a parsed `~/.claude.json`. Everything but the uuid is
4
+ * cosmetic, so a missing display field yields null rather than no account. */
5
+ export function parseClaudeAccount(raw) {
6
+ if (typeof raw !== 'object' || raw === null) {
7
+ return null;
8
+ }
9
+ const acc = raw.oauthAccount;
10
+ if (typeof acc !== 'object' || acc === null) {
11
+ return null;
12
+ }
13
+ const { accountUuid, emailAddress, organizationName } = acc;
14
+ if (typeof accountUuid !== 'string' || accountUuid === '') {
15
+ return null;
16
+ }
17
+ const str = (v) => (typeof v === 'string' && v !== '' ? v.slice(0, 200) : null);
18
+ return { email: str(emailAddress), extId: accountUuid.slice(0, 100), org: str(organizationName) };
19
+ }
20
+ /**
21
+ * Read the local Claude Code login. Purely a local file read — no network, no
22
+ * credentials — so it is safe to call on every limits cycle. Returns null when
23
+ * Claude Code has never signed in on this machine (an ANTHROPIC_API_KEY-only
24
+ * setup has no account identity at all); those devices fall back to the
25
+ * server's unidentified-account bucket, i.e. today's behaviour.
26
+ */
27
+ export function detectClaudeAccount(path = claudeStatePath()) {
28
+ try {
29
+ return parseClaudeAccount(JSON.parse(readFileSync(path, 'utf-8')));
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
package/dist/collector.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { hostname } from 'node:os';
3
3
  import { sep } from 'node:path';
4
+ import { detectClaudeAccount } from './claude-account.js';
4
5
  import { detectClaudeCreds, macKeychainDenied } from './claude-creds.js';
5
6
  import { fetchLimits } from './claude-limits.js';
6
7
  import { maybeNotify } from './notifier.js';
@@ -243,7 +244,11 @@ export async function reportLimitsOnce(cfg, log = () => {
243
244
  // Same vocabulary as the usage leg: a rejection the operator can act on has to
244
245
  // say which one it is, since this runs every cycle and an anonymous failure
245
246
  // would repeat forever without ever naming the fix.
246
- const outcome = await postLimits(report, cfg);
247
+ // Tag the reading with the Claude account it came from, so a fleet split over
248
+ // two subscriptions gets two independent budgets instead of one row both
249
+ // machines overwrite. Subscription logins only — an API key has no account.
250
+ const account = report.source === 'sub' ? detectClaudeAccount() : null;
251
+ const outcome = await postLimits({ ...report, account }, cfg);
247
252
  if (outcome === 'plan') {
248
253
  log('warn', planWall(cfg.endpoint));
249
254
  }
@@ -1,9 +1,16 @@
1
- /** The command list, shared by `help` and the generated completion scripts, so
2
- * a new command can't land in one and be missing from the other. `args` is the
3
- * hint `help` prints after the name; completion only needs the bare name. */
1
+ import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { writeFileAtomic } from './atomic-write.js';
5
+ /** The user-facing command list, shared by `help` and the generated completion
6
+ * scripts, so a command can't land in one and be missing from the other. `args`
7
+ * is the hint `help` prints after the name; completion only needs the bare name.
8
+ *
9
+ * `watch` is deliberately absent: it is the entrypoint the installed service
10
+ * runs, not something to type. It still dispatches — every plist and unit on
11
+ * disk names it — it just isn't advertised. */
4
12
  export const commands = [
5
13
  { name: 'run', meaning: 'scan once, upload usage + report limits' },
6
- { name: 'watch', args: '[--interval s]', meaning: 'poll continuously (default 15s)' },
7
14
  { name: 'limits', meaning: 'report only your real 5h/weekly usage' },
8
15
  { name: 'guard', meaning: 'exit 2 when the group is over a blocking limit' },
9
16
  { name: 'update', meaning: 'update to the latest release now' },
@@ -11,17 +18,94 @@ export const commands = [
11
18
  { name: 'status', meaning: 'service health, limits, resolved config' },
12
19
  { name: 'config', meaning: 'config file location and env overrides' },
13
20
  { name: 'completion', args: '<zsh|fish>', meaning: 'print a shell completion script' },
14
- { name: 'version', meaning: 'print the release version' },
15
21
  { name: 'install', args: '--token <t>', meaning: 'configure + install the service and prompt guard' },
16
22
  { name: 'uninstall', meaning: 'remove the service and the guard' },
17
23
  ];
18
24
  export const shells = ['zsh', 'fish'];
19
- /** A completion script for `shell`, on stdout the user decides where it goes
20
- * (`> ~/.zsh/completions/_usagefleet`, `> ~/.config/fish/completions/…`).
21
- * Writing those files ourselves would mean guessing at fpath and rc files. */
25
+ /** A completion script for `shell`, on stdout. `install` writes these to the
26
+ * right place automatically; this command stays for piping one somewhere else. */
22
27
  export function completionScript(shell) {
23
28
  return shell === 'zsh' ? zsh() : fish();
24
29
  }
30
+ /** Sentinels around our .zshrc block, so `uninstall` removes exactly what we
31
+ * added even after the user has edited around it. */
32
+ const RC_START = '# >>> usagefleet completions >>>';
33
+ const RC_END = '# <<< usagefleet completions <<<';
34
+ // Resolved per call, not once at import: homedir() is what the tests vary, and
35
+ // service.ts derives its paths the same way.
36
+ const zshDir = () => join(homedir(), '.zsh', 'completions');
37
+ const zshrc = () => join(homedir(), '.zshrc');
38
+ /** Where each shell loads completions from. fish scans its directory with no
39
+ * further setup; zsh only scans what is on fpath, which is why zsh also needs
40
+ * the .zshrc block. */
41
+ function completionPath(shell) {
42
+ return shell === 'fish'
43
+ ? join(homedir(), '.config', 'fish', 'completions', 'usagefleet.fish')
44
+ : join(zshDir(), '_usagefleet');
45
+ }
46
+ /** Only touch a shell the user actually runs — as the login shell or by already
47
+ * having its config on disk. Installing should not scatter dotfiles for shells
48
+ * that were never used. */
49
+ function shellInUse(shell) {
50
+ if ((process.env.SHELL ?? '').includes(shell)) {
51
+ return true;
52
+ }
53
+ return existsSync(shell === 'fish' ? join(homedir(), '.config', 'fish') : zshrc());
54
+ }
55
+ /**
56
+ * Put the completion scripts where each shell looks, and for zsh make sure that
57
+ * directory is on fpath. Re-running is a no-op beyond rewriting the scripts, so
58
+ * self-update refreshes completions for free.
59
+ *
60
+ * Returns one row per shell touched: the script path, plus the rc file when this
61
+ * call was the one that added the block.
62
+ */
63
+ export function installCompletions() {
64
+ const done = [];
65
+ for (const shell of shells) {
66
+ if (!shellInUse(shell)) {
67
+ continue;
68
+ }
69
+ const path = completionPath(shell);
70
+ mkdirSync(dirname(path), { recursive: true });
71
+ writeFileAtomic(path, completionScript(shell));
72
+ done.push(shell === 'zsh' ? { shell, path, rc: ensureZshFpath() } : { shell, path });
73
+ }
74
+ return done;
75
+ }
76
+ /** Append the fpath block to .zshrc, once. Returns the file when this call added
77
+ * it, undefined when it was already there. */
78
+ function ensureZshFpath() {
79
+ const rc = zshrc();
80
+ const current = existsSync(rc) ? readFileSync(rc, 'utf-8') : '';
81
+ if (current.includes(RC_START)) {
82
+ return undefined;
83
+ }
84
+ // Appended, never prepended: fpath has to be set before the compinit that
85
+ // reads it, and we cannot know whether the user already ran compinit earlier
86
+ // in the file. Adding our own after theirs is the only ordering that works
87
+ // from the end of the file; -C skips the dump check so the second run is cheap.
88
+ const block = `${RC_START}\nfpath=("${zshDir().replace(homedir(), '$HOME')}" $fpath)\nautoload -Uz compinit && compinit -C\n${RC_END}\n`;
89
+ writeFileAtomic(rc, current === '' || current.endsWith('\n') ? current + block : `${current}\n${block}`);
90
+ return rc;
91
+ }
92
+ /** Undo installCompletions: drop the scripts and strip the .zshrc block. */
93
+ export function removeCompletions() {
94
+ for (const shell of shells) {
95
+ rmSync(completionPath(shell), { force: true });
96
+ }
97
+ const rc = zshrc();
98
+ if (!existsSync(rc)) {
99
+ return;
100
+ }
101
+ const current = readFileSync(rc, 'utf-8');
102
+ const start = current.indexOf(RC_START);
103
+ const end = current.indexOf(RC_END);
104
+ if (start === -1 || end < start) {
105
+ return;
106
+ }
107
+ writeFileAtomic(rc, current.slice(0, start) + current.slice(end + RC_END.length).replace(/^\n/, ''));
108
+ }
25
109
  function zsh() {
26
110
  // zsh splits a _describe entry on the first colon, so any colon in the text
27
111
  // has to be escaped or the description gets cut in half.
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
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, shells } from './completion.js';
4
+ import { commands, completionScript, installCompletions, removeCompletions, shells } from './completion.js';
5
5
  import { loadConfig } from './config.js';
6
6
  import { runGuard } from './guard.js';
7
7
  import { loadNotifyConfig } from './notifier.js';
@@ -211,6 +211,20 @@ async function cmdInstall() {
211
211
  }
212
212
  const { install } = await import('./service.js');
213
213
  install();
214
+ // 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
216
+ // new commands without the user doing anything.
217
+ try {
218
+ for (const { shell, path, rc } of installCompletions()) {
219
+ console.log(step(`${shell} completions`, tilde(path)));
220
+ if (rc) {
221
+ console.log(row('shell', `${tilde(rc)} updated · restart the shell to pick it up`));
222
+ }
223
+ }
224
+ }
225
+ catch (error) {
226
+ console.log(warn('completions', error.message));
227
+ }
214
228
  }
215
229
  /** Padded two-column list — name in white, meaning in gray, like the result
216
230
  * lines. Shared by `help` and `config`. */
@@ -310,8 +324,7 @@ async function main() {
310
324
  case 'completion': {
311
325
  return cmdCompletion();
312
326
  }
313
- // Bare version, so the installer can compare builds without parsing help.
314
- case 'version':
327
+ // Flags only: bare `usagefleet` already prints the version in its header.
315
328
  case '--version':
316
329
  case '-v': {
317
330
  console.log(RELEASE_VERSION);
@@ -324,7 +337,9 @@ async function main() {
324
337
  }
325
338
  case 'uninstall': {
326
339
  const { uninstall } = await import('./service.js');
327
- return uninstall();
340
+ uninstall();
341
+ removeCompletions();
342
+ return;
328
343
  }
329
344
  default: {
330
345
  return help();
package/dist/paths.js CHANGED
@@ -46,3 +46,10 @@ export function defaultPiSessionsDirs() {
46
46
  export function claudeSettingsPath() {
47
47
  return join(process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude'), 'settings.json');
48
48
  }
49
+ /** Claude Code's global state file (`~/.claude.json`), which records which
50
+ * Anthropic account this machine is logged into. Unlike settings.json it sits
51
+ * next to the config dir, not inside it. */
52
+ export function claudeStatePath() {
53
+ const dir = process.env.CLAUDE_CONFIG_DIR;
54
+ return dir ? join(dir, '.claude.json') : join(homedir(), '.claude.json');
55
+ }
package/dist/release.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by .github/workflows/release.yml.
2
- export const RELEASE_VERSION = "1.2.73";
2
+ export const RELEASE_VERSION = "1.2.75";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usagefleet/cli",
3
- "version": "1.2.73",
3
+ "version": "1.2.75",
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",