@usagefleet/cli 1.2.59 → 1.2.69
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 +5 -6
- package/dist/claude-creds.js +2 -2
- package/dist/collector.js +29 -16
- package/dist/config.js +4 -4
- package/dist/hook.js +4 -3
- package/dist/index.js +105 -85
- package/dist/notifier.js +3 -3
- package/dist/release.js +2 -8
- package/dist/service.js +42 -27
- package/dist/store.js +1 -1
- package/dist/tailer.js +2 -1
- package/dist/ui.js +65 -8
- package/dist/update.js +12 -9
- package/dist/uploader.js +4 -1
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -9,14 +9,13 @@ UsageFleet server. Read-only on the log files. Zero runtime dependencies
|
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Two commands, identical on macOS, Linux and Windows. You only need a device
|
|
13
13
|
**token** from the server's Devices page (endpoint defaults to
|
|
14
14
|
`https://usagefleet.com`):
|
|
15
15
|
|
|
16
16
|
```bash
|
|
17
17
|
npm i -g @usagefleet/cli
|
|
18
|
-
usagefleet
|
|
19
|
-
usagefleet install # autostart at login
|
|
18
|
+
usagefleet install --token uf_xxx # autostart at login; --endpoint <url> when self-hosting
|
|
20
19
|
```
|
|
21
20
|
|
|
22
21
|
In PowerShell chain them with `;` instead of `&&` — Windows PowerShell 5.1 has
|
|
@@ -44,7 +43,7 @@ bun run src/index.ts status # or: npm run build && node dist/index.js status
|
|
|
44
43
|
Get a device **token** from the server's Devices page, then either:
|
|
45
44
|
|
|
46
45
|
```bash
|
|
47
|
-
usagefleet
|
|
46
|
+
usagefleet install --endpoint https://track.example.com --token uf_xxx
|
|
48
47
|
# writes ~/.config/usagefleet/config.json
|
|
49
48
|
```
|
|
50
49
|
|
|
@@ -120,9 +119,9 @@ set USAGEFLEET_TOKEN=uf_xxx
|
|
|
120
119
|
```
|
|
121
120
|
|
|
122
121
|
> Prefer not to put a long-lived token in shell history/rc files? Use
|
|
123
|
-
> `usagefleet
|
|
122
|
+
> `usagefleet install --endpoint <url> --token <t>` instead — it writes
|
|
124
123
|
> `~/.config/usagefleet/config.json` (mode `600`), which the collector reads
|
|
125
|
-
> automatically.
|
|
124
|
+
> automatically. The write merges, so re-running it rotates the token without
|
|
126
125
|
> resetting your tail offsets.
|
|
127
126
|
> When run as a service, `usagefleet install` bakes every `USAGEFLEET_*` value
|
|
128
127
|
> that is currently set (plus `ANTHROPIC_API_KEY`) into the launchd/systemd unit.
|
package/dist/claude-creds.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
|
|
|
3
3
|
import { homedir, userInfo } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { writeFileAtomic } from './atomic-write.js';
|
|
6
|
+
import { dim, line, yellow } from './ui.js';
|
|
6
7
|
const KEYCHAIN_SERVICE = 'Claude Code-credentials';
|
|
7
8
|
function credentialsFilePath() {
|
|
8
9
|
return join(homedir(), '.claude', '.credentials.json');
|
|
@@ -112,8 +113,7 @@ async function refreshOauth(blob, from) {
|
|
|
112
113
|
// The refresh already rotated the token server-side, so the stored one is
|
|
113
114
|
// now dead: keep using the new one in memory (valid for hours) and make the
|
|
114
115
|
// failure loud — on restart the user will have to `claude login` again.
|
|
115
|
-
|
|
116
|
-
'run `claude login` if limits stop reporting after a restart');
|
|
116
|
+
line(yellow('!'), `could not save the refreshed claude token ${dim(`· ${error.message} · run \`claude login\` if limits stop reporting after a restart`)}`);
|
|
117
117
|
}
|
|
118
118
|
return {
|
|
119
119
|
source: 'sub',
|
package/dist/collector.js
CHANGED
|
@@ -9,6 +9,7 @@ import { RELEASE_VERSION } from './release.js';
|
|
|
9
9
|
import { listJsonlFiles } from './scanner.js';
|
|
10
10
|
import { readStore, updateStore } from './store.js';
|
|
11
11
|
import { tailFile } from './tailer.js';
|
|
12
|
+
import { tilde } from './ui.js';
|
|
12
13
|
import { postLimits, uploadBatch } from './uploader.js';
|
|
13
14
|
/** Only files inside a `.../.claude/projects/...` subtree are real usage logs.
|
|
14
15
|
* Desktop session roots also hold `audit.jsonl` (a full duplicate of the same
|
|
@@ -56,6 +57,9 @@ export async function runOnce(cfg, log = () => {
|
|
|
56
57
|
// Defensive: a bad batchSize must never stall the chunk loop.
|
|
57
58
|
const step = cfg.batchSize > 0 ? Math.floor(cfg.batchSize) : 100;
|
|
58
59
|
let advanced = false;
|
|
60
|
+
// A server that is down or unreachable fails every file for the same reason,
|
|
61
|
+
// so the cycle reports one count instead of a screenful of identical lines.
|
|
62
|
+
let transientFiles = 0;
|
|
59
63
|
for (const { fp, source } of files) {
|
|
60
64
|
let tail;
|
|
61
65
|
try {
|
|
@@ -63,7 +67,7 @@ export async function runOnce(cfg, log = () => {
|
|
|
63
67
|
}
|
|
64
68
|
catch (error) {
|
|
65
69
|
// One unreadable/oversized file must not abort the whole cycle.
|
|
66
|
-
log(`
|
|
70
|
+
log('warn', `skipped ${tilde(fp)} · ${error.message}`);
|
|
67
71
|
continue;
|
|
68
72
|
}
|
|
69
73
|
if (!tail || tail.consumedBytes === 0) {
|
|
@@ -75,7 +79,7 @@ export async function runOnce(cfg, log = () => {
|
|
|
75
79
|
advanced = true;
|
|
76
80
|
continue;
|
|
77
81
|
}
|
|
78
|
-
// sendChunk absorbs "invalid" by bisecting, so only auth/transient escape.
|
|
82
|
+
// sendChunk absorbs "invalid" by bisecting, so only auth/plan/transient escape.
|
|
79
83
|
let outcome = 'ok';
|
|
80
84
|
for (let i = 0; i < tail.records.length; i += step) {
|
|
81
85
|
outcome = await sendChunk(tail.records.slice(i, i + step), cfg, result, log);
|
|
@@ -91,27 +95,37 @@ export async function runOnce(cfg, log = () => {
|
|
|
91
95
|
// Token revoked/expired. The data is valid and must NOT be skipped — keep
|
|
92
96
|
// the offset so it uploads once a fresh token is configured. Retrying the
|
|
93
97
|
// remaining files would 401 identically, so stop this cycle and surface.
|
|
94
|
-
log(
|
|
98
|
+
log('warn', 'auth rejected · device token invalid or revoked · re-run `usagefleet install --token <t>` with a fresh token');
|
|
99
|
+
result.failed = true;
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
else if (outcome === 'plan') {
|
|
103
|
+
// The device sits outside the account's device limit (402). Every other
|
|
104
|
+
// file gets the same answer, so stop and say what unblocks it once.
|
|
105
|
+
log('warn', `device outside your plan's device limit · free a slot or upgrade at ${cfg.endpoint}/devices · nothing is lost, uploads resume once it fits`);
|
|
95
106
|
result.failed = true;
|
|
96
107
|
break;
|
|
97
108
|
}
|
|
98
109
|
else if (outcome === 'invalid') {
|
|
99
110
|
// The whole batch was rejected, not individual records (see sendChunk).
|
|
100
111
|
// Keep the offset: this needs a collector or server fix, not a purge.
|
|
101
|
-
log(`
|
|
112
|
+
log('warn', `batch rejected for ${tilde(fp)} · offset kept · check this collector version and OS are supported by the server`);
|
|
102
113
|
result.failed = true;
|
|
103
114
|
}
|
|
104
115
|
else {
|
|
105
|
-
// transient (
|
|
106
|
-
//
|
|
107
|
-
|
|
116
|
+
// transient (5xx, network, timeout): keep the offset and retry next cycle,
|
|
117
|
+
// but DO NOT break — later files must still get a turn.
|
|
118
|
+
transientFiles += 1;
|
|
108
119
|
result.failed = true;
|
|
109
120
|
}
|
|
110
121
|
}
|
|
122
|
+
if (transientFiles > 0) {
|
|
123
|
+
log('warn', `upload failed for ${transientFiles} file${transientFiles === 1 ? '' : 's'} · retrying next cycle`);
|
|
124
|
+
}
|
|
111
125
|
// One durable write per cycle rather than one per file: the store is fsynced
|
|
112
126
|
// on every save, and a crash mid-cycle only costs a re-upload the server
|
|
113
127
|
// dedups. Only our own section is replaced, so a token written by a
|
|
114
|
-
// concurrent `usagefleet
|
|
128
|
+
// concurrent `usagefleet install` survives.
|
|
115
129
|
if (pruneMissingFiles(state, files) || advanced) {
|
|
116
130
|
updateStore(cfg.storePath, store => {
|
|
117
131
|
store.state.files = state.files;
|
|
@@ -158,11 +172,11 @@ async function sendChunk(records, cfg, result, log, dropCeiling = result.dropped
|
|
|
158
172
|
// Give up on the record theory. The caller keeps the offset, so the
|
|
159
173
|
// records counted on the way down are retried, not lost — untally them
|
|
160
174
|
// rather than report a loss that did not happen.
|
|
161
|
-
log(
|
|
175
|
+
log('warn', 'every split was rejected · the batch is bad, not its records · nothing skipped');
|
|
162
176
|
result.dropped = dropCeiling - MAX_DROPPED_PER_CHUNK;
|
|
163
177
|
return 'invalid';
|
|
164
178
|
}
|
|
165
|
-
log(`
|
|
179
|
+
log('warn', `record ${single.uuid} rejected as malformed · skipped`);
|
|
166
180
|
result.dropped += 1;
|
|
167
181
|
result.failed = true;
|
|
168
182
|
return 'ok';
|
|
@@ -204,12 +218,11 @@ export async function reportLimitsOnce(cfg, log = () => {
|
|
|
204
218
|
if (process.platform === 'darwin' && macKeychainDenied()) {
|
|
205
219
|
// "Works by hand, broken as a service" signature: a launchd agent can be
|
|
206
220
|
// denied the login-Keychain read. Make it diagnosable instead of silent.
|
|
207
|
-
log("limits skipped
|
|
208
|
-
'
|
|
209
|
-
'item, or set ANTHROPIC_API_KEY for the service.');
|
|
221
|
+
log('warn', "limits skipped · keychain read for 'Claude Code-credentials' denied, typical under a launchd agent · " +
|
|
222
|
+
'grant /usr/bin/security access to the item, or set ANTHROPIC_API_KEY for the service');
|
|
210
223
|
}
|
|
211
224
|
else {
|
|
212
|
-
log('no usable
|
|
225
|
+
log('warn', 'no usable claude login · missing, or expired with a failed refresh · ' +
|
|
213
226
|
'sign in with `claude` or set ANTHROPIC_API_KEY');
|
|
214
227
|
}
|
|
215
228
|
return null;
|
|
@@ -219,12 +232,12 @@ export async function reportLimitsOnce(cfg, log = () => {
|
|
|
219
232
|
report = await fetchLimits(creds);
|
|
220
233
|
}
|
|
221
234
|
catch (error) {
|
|
222
|
-
log(`limits fetch failed
|
|
235
|
+
log('warn', `limits fetch failed · ${error.message}`);
|
|
223
236
|
return null;
|
|
224
237
|
}
|
|
225
238
|
const ok = await postLimits(report, cfg);
|
|
226
239
|
if (!ok) {
|
|
227
|
-
log('limits upload failed');
|
|
240
|
+
log('warn', 'limits upload failed');
|
|
228
241
|
}
|
|
229
242
|
// Cache the reading so `status` can show current usage without spending
|
|
230
243
|
// another billable API call.
|
package/dist/config.js
CHANGED
|
@@ -2,17 +2,17 @@ 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';
|
|
5
8
|
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
|
6
9
|
/** Resolve config from env first, then the stored settings (see store.ts). */
|
|
7
10
|
export function loadConfig() {
|
|
8
11
|
const file = readStore();
|
|
9
12
|
// Use `||` (not `??`) so an empty-string env var falls back to the config
|
|
10
13
|
// file — launchd/systemd units may inject empty USAGEFLEET_* values.
|
|
11
|
-
const endpoint = (process.env.USAGEFLEET_ENDPOINT || file.endpoint ||
|
|
14
|
+
const endpoint = (process.env.USAGEFLEET_ENDPOINT || file.endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, '');
|
|
12
15
|
const token = process.env.USAGEFLEET_TOKEN || file.token || '';
|
|
13
|
-
if (!endpoint) {
|
|
14
|
-
throw new Error('USAGEFLEET_ENDPOINT is not set');
|
|
15
|
-
}
|
|
16
16
|
if (!token) {
|
|
17
17
|
throw new Error('USAGEFLEET_TOKEN is not set');
|
|
18
18
|
}
|
package/dist/hook.js
CHANGED
|
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync } from 'node:fs';
|
|
|
2
2
|
import { dirname } from 'node:path';
|
|
3
3
|
import { writeFileAtomic } from './atomic-write.js';
|
|
4
4
|
import { claudeSettingsPath } from './paths.js';
|
|
5
|
+
import { step, tilde, warn } from './ui.js';
|
|
5
6
|
/** Outer bound on the hook, in seconds. runGuard's own fetch gives up after 5s
|
|
6
7
|
* and fails open; this only matters if the process itself wedges. */
|
|
7
8
|
const HOOK_TIMEOUT_S = 10;
|
|
@@ -71,7 +72,7 @@ function editSettings(transform, onWrite) {
|
|
|
71
72
|
settings = parsed;
|
|
72
73
|
}
|
|
73
74
|
catch {
|
|
74
|
-
console.warn(
|
|
75
|
+
console.log(warn('hook', `${tilde(path)} is not valid JSON · left untouched`));
|
|
75
76
|
return;
|
|
76
77
|
}
|
|
77
78
|
}
|
|
@@ -96,8 +97,8 @@ export function installPromptHook(program) {
|
|
|
96
97
|
return;
|
|
97
98
|
}
|
|
98
99
|
const command = guardCommand(program);
|
|
99
|
-
editSettings(s => withGuardHook(s, command), path => console.log(`
|
|
100
|
+
editSettings(s => withGuardHook(s, command), path => console.log(step('hook', `prompt guard · ${tilde(path)}`)));
|
|
100
101
|
}
|
|
101
102
|
export function uninstallPromptHook() {
|
|
102
|
-
editSettings(withoutGuardHook, path => console.log(`
|
|
103
|
+
editSettings(withoutGuardHook, path => console.log(step('removed', `prompt guard · ${tilde(path)}`)));
|
|
103
104
|
}
|
package/dist/index.js
CHANGED
|
@@ -9,7 +9,7 @@ import { detectOs } from './os.js';
|
|
|
9
9
|
import { RELEASE_VERSION } from './release.js';
|
|
10
10
|
import { serviceStatus } from './service.js';
|
|
11
11
|
import { readStore, storePath, updateStore } from './store.js';
|
|
12
|
-
import { ago, bar,
|
|
12
|
+
import { ago, bar, blue, dim, fail, green, header, hint, host, line, note, pct, row, state as stateLine, step, tilde, warn, yellow, } from './ui.js';
|
|
13
13
|
import { checkForUpdate } from './update.js';
|
|
14
14
|
function flag(name) {
|
|
15
15
|
const prefix = `--${name}`;
|
|
@@ -27,22 +27,30 @@ function flag(name) {
|
|
|
27
27
|
}
|
|
28
28
|
return undefined;
|
|
29
29
|
}
|
|
30
|
-
function ts() {
|
|
31
|
-
return new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
32
|
-
}
|
|
33
30
|
/** "5h ██░░░░░░░░ 2% · weekly ████░░░░░░ 13%" — the shared limits line.
|
|
34
31
|
* Bars are plain characters, so they survive a service log as well as a TTY. */
|
|
35
32
|
function limitsSummary(limits) {
|
|
36
33
|
const models = limits.modelLimits.map(m => ` · ${m.model}(${m.window}) ${bar(m.pct, 6)} ${pct(m.pct)}`).join('');
|
|
37
34
|
return `5h ${bar(limits.fiveHourPct)} ${pct(limits.fiveHourPct)} · weekly ${bar(limits.sevenDayPct)} ${pct(limits.sevenDayPct)}${models}`;
|
|
38
35
|
}
|
|
36
|
+
/** Every message the collector, notifier and self-update emit, as a stream
|
|
37
|
+
* line. The level picks the glyph so a problem never reads like a result. */
|
|
38
|
+
const stream = (level, m) => {
|
|
39
|
+
line(level === 'warn' ? yellow('!') : note, m);
|
|
40
|
+
};
|
|
41
|
+
/** Upload result as one stream line — the shape `run` and `watch` share.
|
|
42
|
+
* Dropped records are the only part worth a colour: they are lost data. */
|
|
43
|
+
function cycleLine(r) {
|
|
44
|
+
const dropped = r.dropped > 0 ? ` · ${yellow(`${r.dropped} dropped`)}` : '';
|
|
45
|
+
line(r.dropped > 0 ? yellow('!') : green('↑'), `${r.sent} sent ${dim(`· ${r.accepted} accepted · ${r.duplicates} dup · ${r.files} file${r.files === 1 ? '' : 's'}`)}${dropped}`);
|
|
46
|
+
}
|
|
39
47
|
async function cmdRun() {
|
|
40
48
|
const cfg = loadConfig();
|
|
41
|
-
const r = await runOnce(cfg,
|
|
42
|
-
|
|
43
|
-
const limits = await reportLimitsOnce(cfg,
|
|
49
|
+
const r = await runOnce(cfg, stream);
|
|
50
|
+
cycleLine(r);
|
|
51
|
+
const limits = await reportLimitsOnce(cfg, stream);
|
|
44
52
|
if (limits) {
|
|
45
|
-
|
|
53
|
+
line(note, `${limitsSummary(limits)} ${dim(`· ${limits.source}`)}`);
|
|
46
54
|
}
|
|
47
55
|
if (r.failed) {
|
|
48
56
|
process.exitCode = 1;
|
|
@@ -50,12 +58,12 @@ async function cmdRun() {
|
|
|
50
58
|
}
|
|
51
59
|
async function cmdLimits() {
|
|
52
60
|
const cfg = loadConfig();
|
|
53
|
-
const limits = await reportLimitsOnce(cfg,
|
|
61
|
+
const limits = await reportLimitsOnce(cfg, stream);
|
|
54
62
|
if (!limits) {
|
|
55
63
|
process.exitCode = 1;
|
|
56
64
|
return;
|
|
57
65
|
}
|
|
58
|
-
|
|
66
|
+
line(note, `${limitsSummary(limits)} ${dim(`· ${limits.source}`)}`);
|
|
59
67
|
}
|
|
60
68
|
async function cmdWatch() {
|
|
61
69
|
const cfg = loadConfig();
|
|
@@ -73,11 +81,10 @@ async function cmdWatch() {
|
|
|
73
81
|
const rawUpdate = Number(process.env.USAGEFLEET_UPDATE_INTERVAL ?? 6 * 60 * 60);
|
|
74
82
|
const updateInterval = Math.max(60, Number.isFinite(rawUpdate) && rawUpdate > 0 ? rawUpdate : 6 * 60 * 60) * 1000;
|
|
75
83
|
let lastUpdateAt = 0;
|
|
76
|
-
|
|
77
|
-
console.log(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
84
|
+
const watching = [cfg.projectsDir, cfg.desktopDir, ...cfg.piDirs].filter((d) => !!d);
|
|
85
|
+
console.log(header(`watching every ${interval / 1000}s`));
|
|
86
|
+
console.log(hint(`${watching.map(tilde).join(' · ')} → ${host(cfg.endpoint)}`));
|
|
87
|
+
console.log('');
|
|
81
88
|
let stopping = false;
|
|
82
89
|
let timer = null;
|
|
83
90
|
let running = false;
|
|
@@ -87,27 +94,27 @@ async function cmdWatch() {
|
|
|
87
94
|
}
|
|
88
95
|
running = true;
|
|
89
96
|
try {
|
|
90
|
-
const r = await runOnce(cfg,
|
|
97
|
+
const r = await runOnce(cfg, stream);
|
|
91
98
|
// Dropped records are real data loss, so they must show up even in a
|
|
92
99
|
// cycle that uploaded nothing.
|
|
93
100
|
if (r.sent > 0 || r.dropped > 0) {
|
|
94
|
-
|
|
101
|
+
cycleLine(r);
|
|
95
102
|
}
|
|
96
103
|
const nowMs = Date.now();
|
|
97
104
|
if (nowMs - lastUpdateAt >= updateInterval) {
|
|
98
105
|
lastUpdateAt = nowMs;
|
|
99
|
-
await checkForUpdate(m =>
|
|
106
|
+
await checkForUpdate((level, m) => line(level === 'ok' ? blue('↻') : yellow('!'), m));
|
|
100
107
|
}
|
|
101
108
|
if (nowMs - lastLimitsAt >= limitsInterval) {
|
|
102
109
|
lastLimitsAt = nowMs;
|
|
103
|
-
const limits = await reportLimitsOnce(cfg,
|
|
110
|
+
const limits = await reportLimitsOnce(cfg, stream);
|
|
104
111
|
if (limits) {
|
|
105
|
-
|
|
112
|
+
line(note, limitsSummary(limits));
|
|
106
113
|
}
|
|
107
114
|
}
|
|
108
115
|
}
|
|
109
116
|
catch (error) {
|
|
110
|
-
|
|
117
|
+
line(yellow('!'), `cycle error ${dim(error.message)}`);
|
|
111
118
|
}
|
|
112
119
|
finally {
|
|
113
120
|
running = false;
|
|
@@ -121,7 +128,7 @@ async function cmdWatch() {
|
|
|
121
128
|
if (timer) {
|
|
122
129
|
clearTimeout(timer);
|
|
123
130
|
}
|
|
124
|
-
|
|
131
|
+
line(note, dim('stopping…'));
|
|
125
132
|
// Let an in-flight cycle finish committing offsets; hard-exit fallback.
|
|
126
133
|
const bail = setTimeout(() => process.exit(0), 5000);
|
|
127
134
|
bail.unref();
|
|
@@ -140,13 +147,13 @@ async function cmdWatch() {
|
|
|
140
147
|
function cmdNotifyTest() {
|
|
141
148
|
const cfg = loadNotifyConfig();
|
|
142
149
|
if (!cfg.enabled) {
|
|
143
|
-
console.log('
|
|
150
|
+
console.log(warn('notify', 'disabled · unset USAGEFLEET_NOTIFY=0 to enable'));
|
|
144
151
|
return;
|
|
145
152
|
}
|
|
146
153
|
sendNotification('usagefleet', 'Test notification — desktop alerts are working.', {
|
|
147
154
|
urgency: 'normal',
|
|
148
155
|
});
|
|
149
|
-
console.log(
|
|
156
|
+
console.log(step('notified', `${detectOs()} · thresholds ${cfg.thresholds.join(', ')}%`));
|
|
150
157
|
}
|
|
151
158
|
async function cmdStatus() {
|
|
152
159
|
const cfg = loadConfig();
|
|
@@ -167,82 +174,95 @@ async function cmdStatus() {
|
|
|
167
174
|
? 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))}`)
|
|
168
175
|
: stateLine('warn', 'limits', `no reading yet ${dim('· run `usagefleet limits`')}`));
|
|
169
176
|
console.log('');
|
|
170
|
-
console.log(row('endpoint', cfg.endpoint));
|
|
177
|
+
console.log(row('endpoint', host(cfg.endpoint)));
|
|
171
178
|
console.log(row('device', `${state.deviceId} · token ${cfg.token.slice(0, 8)}…`));
|
|
172
179
|
const watching = [cfg.projectsDir, cfg.desktopDir, ...cfg.piDirs].filter((d) => !!d);
|
|
173
180
|
for (const [i, dir] of watching.entries()) {
|
|
174
|
-
console.log(row(i === 0 ? 'watching' : '', dir));
|
|
181
|
+
console.log(row(i === 0 ? 'watching' : '', tilde(dir)));
|
|
175
182
|
}
|
|
176
183
|
console.log(row('tracked', `${tracked} file${tracked === 1 ? '' : 's'} · ${mb} MB read · synced ${ago(state.updatedAt)}`));
|
|
177
|
-
console.log(row('config', cfg.storePath));
|
|
184
|
+
console.log(row('config', tilde(cfg.storePath)));
|
|
178
185
|
}
|
|
179
186
|
/** Worst of the two windows decides the dot colour. */
|
|
180
187
|
function limitHealth(fiveHour, sevenDay) {
|
|
181
188
|
const worst = Math.max(fiveHour ?? 0, sevenDay ?? 0);
|
|
182
189
|
return worst >= 95 ? 'bad' : worst >= 80 ? 'warn' : 'ok';
|
|
183
190
|
}
|
|
184
|
-
/**
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
function
|
|
190
|
-
const endpoint = flag('endpoint')
|
|
191
|
-
const token = flag('token')
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
191
|
+
/** Setup in one command: persist the flags (when given), then install the
|
|
192
|
+
* background service, which refuses to install without a resolvable token.
|
|
193
|
+
* The write merges over the existing store, so re-running install rotates the
|
|
194
|
+
* token without resetting tail offsets. Endpoint only matters when
|
|
195
|
+
* self-hosting; unset keeps whatever is configured. */
|
|
196
|
+
async function cmdInstall() {
|
|
197
|
+
const endpoint = flag('endpoint');
|
|
198
|
+
const token = flag('token');
|
|
199
|
+
if (endpoint || token) {
|
|
200
|
+
updateStore(storePath(), store => {
|
|
201
|
+
if (endpoint) {
|
|
202
|
+
store.endpoint = endpoint;
|
|
203
|
+
}
|
|
204
|
+
if (token) {
|
|
205
|
+
store.token = token;
|
|
206
|
+
}
|
|
207
|
+
});
|
|
195
208
|
}
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
// survive a re-init and the device does not re-upload its whole history.
|
|
199
|
-
updateStore(path, store => {
|
|
200
|
-
store.endpoint = endpoint;
|
|
201
|
-
store.token = token;
|
|
202
|
-
});
|
|
203
|
-
console.log(step('configured', `${endpoint} · ${path}`));
|
|
209
|
+
const { install } = await import('./service.js');
|
|
210
|
+
install();
|
|
204
211
|
}
|
|
212
|
+
/** Command list and env reference, in the same padded-column style as the
|
|
213
|
+
* result lines: name in white, meaning in gray. */
|
|
205
214
|
function help() {
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
215
|
+
const commands = [
|
|
216
|
+
['run', 'scan once, upload usage + report limits'],
|
|
217
|
+
['watch [--interval s]', 'poll continuously (default 15s)'],
|
|
218
|
+
['limits', 'report only your real 5h/weekly usage'],
|
|
219
|
+
['guard', 'exit 2 when the group is over a blocking limit'],
|
|
220
|
+
['update', 'update to the latest release now'],
|
|
221
|
+
['notify-test', 'fire a test desktop notification'],
|
|
222
|
+
['status', 'service health, limits, resolved config'],
|
|
223
|
+
['version', 'print the release version'],
|
|
224
|
+
['install --token <t>', 'configure + install the service and prompt guard'],
|
|
225
|
+
['uninstall', 'remove the service and the guard'],
|
|
226
|
+
];
|
|
227
|
+
const env = [
|
|
228
|
+
['USAGEFLEET_ENDPOINT', 'server base URL (self-hosting only)'],
|
|
229
|
+
['USAGEFLEET_TOKEN', 'device token from the Devices page'],
|
|
230
|
+
['USAGEFLEET_PROJECTS', 'override ~/.claude/projects'],
|
|
231
|
+
['USAGEFLEET_DESKTOP', 'override the Claude Desktop dir ("off" disables)'],
|
|
232
|
+
['USAGEFLEET_PI', 'override pi session dirs, comma-separated'],
|
|
233
|
+
['USAGEFLEET_INTERVAL', 'watch interval seconds'],
|
|
234
|
+
['USAGEFLEET_NOTIFY', 'desktop notifications (0 disables)'],
|
|
235
|
+
['USAGEFLEET_HOOK', 'register the guard on install (0 skips)'],
|
|
236
|
+
['USAGEFLEET_UPDATE', 'self-update while watching (0 disables)'],
|
|
237
|
+
['USAGEFLEET_UPDATE_INTERVAL', 'seconds between update checks (default 21600)'],
|
|
238
|
+
['USAGEFLEET_NOTIFY_THRESHOLDS', 'comma list of % alerts (default 80,95)'],
|
|
239
|
+
['USAGEFLEET_CONFIG', 'relocate the config file'],
|
|
240
|
+
];
|
|
241
|
+
const pad = (rows) => Math.max(...rows.map(([name]) => name.length));
|
|
242
|
+
const print = (rows) => {
|
|
243
|
+
const width = pad(rows);
|
|
244
|
+
for (const [name, meaning] of rows) {
|
|
245
|
+
console.log(` ${name.padEnd(width)} ${dim(meaning)}`);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
console.log(header());
|
|
249
|
+
console.log(hint('claude usage collector'));
|
|
250
|
+
console.log('');
|
|
251
|
+
print(commands);
|
|
252
|
+
console.log('');
|
|
253
|
+
console.log(hint('env — overrides ~/.config/usagefleet/config.json, which holds'));
|
|
254
|
+
console.log(hint('settings, tail offsets and notification marks'));
|
|
255
|
+
print(env);
|
|
236
256
|
}
|
|
237
257
|
async function main() {
|
|
238
258
|
// Log-and-continue for the long-running watch daemon: a stray rejection must
|
|
239
259
|
// not silently kill the background service. One-shot commands still set a
|
|
240
260
|
// non-zero exit via their own error paths.
|
|
241
261
|
process.on('unhandledRejection', reason => {
|
|
242
|
-
|
|
262
|
+
line(yellow('!'), `unhandled rejection ${dim(String(reason))}`);
|
|
243
263
|
});
|
|
244
264
|
process.on('uncaughtException', err => {
|
|
245
|
-
|
|
265
|
+
line(yellow('!'), `uncaught exception ${dim(err.message)}`);
|
|
246
266
|
});
|
|
247
267
|
const cmd = process.argv[2] ?? 'help';
|
|
248
268
|
switch (cmd) {
|
|
@@ -260,7 +280,9 @@ async function main() {
|
|
|
260
280
|
return;
|
|
261
281
|
}
|
|
262
282
|
case 'update': {
|
|
263
|
-
|
|
283
|
+
console.log(header());
|
|
284
|
+
console.log('');
|
|
285
|
+
await checkForUpdate((level, m) => console.log(level === 'ok' ? step('update', m) : warn('update', m)), true);
|
|
264
286
|
return;
|
|
265
287
|
}
|
|
266
288
|
case 'notify-test': {
|
|
@@ -276,12 +298,10 @@ async function main() {
|
|
|
276
298
|
console.log(RELEASE_VERSION);
|
|
277
299
|
return;
|
|
278
300
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
301
|
+
// `init` was the separate config step; it now just does the whole setup.
|
|
302
|
+
case 'init':
|
|
282
303
|
case 'install': {
|
|
283
|
-
|
|
284
|
-
return install();
|
|
304
|
+
return cmdInstall();
|
|
285
305
|
}
|
|
286
306
|
case 'uninstall': {
|
|
287
307
|
const { uninstall } = await import('./service.js');
|
|
@@ -293,6 +313,6 @@ async function main() {
|
|
|
293
313
|
}
|
|
294
314
|
}
|
|
295
315
|
main().catch(error => {
|
|
296
|
-
console.error(error.message);
|
|
316
|
+
console.error(fail('error', error.message));
|
|
297
317
|
process.exit(1);
|
|
298
318
|
});
|
package/dist/notifier.js
CHANGED
|
@@ -96,17 +96,17 @@ export function maybeNotify(report, cfg = loadNotifyConfig(), log = () => {
|
|
|
96
96
|
const seven = evaluateWindow(state.sevenDay, report.sevenDayPct, report.sevenDayResetsAt, cfg.thresholds);
|
|
97
97
|
if (five.fire != null) {
|
|
98
98
|
sendNotification('Claude usage · 5-hour limit', `${report.fiveHourPct}% of your 5-hour limit used${resetSuffix(report.fiveHourResetsAt)}.`, { urgency: urgencyFor(five.fire) });
|
|
99
|
-
log(`notified
|
|
99
|
+
log('ok', `notified · 5h at ${report.fiveHourPct}% · crossed ${five.fire}%`);
|
|
100
100
|
}
|
|
101
101
|
if (seven.fire != null) {
|
|
102
102
|
sendNotification('Claude usage · weekly limit', `${report.sevenDayPct}% of your weekly limit used${resetSuffix(report.sevenDayResetsAt)}.`, { urgency: urgencyFor(seven.fire) });
|
|
103
|
-
log(`notified
|
|
103
|
+
log('ok', `notified · weekly at ${report.sevenDayPct}% · crossed ${seven.fire}%`);
|
|
104
104
|
}
|
|
105
105
|
updateStore(path, store => {
|
|
106
106
|
store.notify = { fiveHour: five.next, sevenDay: seven.next };
|
|
107
107
|
});
|
|
108
108
|
}
|
|
109
109
|
catch (error) {
|
|
110
|
-
log(`notify skipped
|
|
110
|
+
log('warn', `notify skipped · ${error.message}`);
|
|
111
111
|
}
|
|
112
112
|
}
|
package/dist/release.js
CHANGED
|
@@ -1,8 +1,2 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
3
|
-
// must never be replaced by a published one behind your back.
|
|
4
|
-
// The annotation is load-bearing: without it the literal type would be 'dev'
|
|
5
|
-
// here and '1.2.3' in CI, so every `=== '1.2.59'` check compiles locally and
|
|
6
|
-
// fails the release build as a comparison with no overlap.
|
|
7
|
-
// oxlint-disable-next-line typescript/no-inferrable-types -- see above
|
|
8
|
-
export const RELEASE_VERSION = '1.2.59';
|
|
1
|
+
// Generated by .github/workflows/release.yml.
|
|
2
|
+
export const RELEASE_VERSION = "1.2.69";
|
package/dist/service.js
CHANGED
|
@@ -2,9 +2,10 @@ 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 } from './config.js';
|
|
5
6
|
import { installPromptHook, uninstallPromptHook } from './hook.js';
|
|
6
7
|
import { readStore } from './store.js';
|
|
7
|
-
import { row, step } from './ui.js';
|
|
8
|
+
import { fail, header, hint, host, row, step, tilde, warn } from './ui.js';
|
|
8
9
|
const LABEL = 'dev.usagefleet.collector';
|
|
9
10
|
/** Scheduled Task name on Windows (mirrors the launchd label / systemd unit). */
|
|
10
11
|
const TASK = 'usagefleet';
|
|
@@ -215,18 +216,23 @@ function xml(s) {
|
|
|
215
216
|
.replaceAll("'", ''');
|
|
216
217
|
}
|
|
217
218
|
export function install() {
|
|
218
|
-
// Pre-flight: refuse to install a service that can't resolve
|
|
219
|
+
// Pre-flight: refuse to install a service that can't resolve a token,
|
|
219
220
|
// otherwise the baked `watch` process throws on every launch and the service
|
|
220
221
|
// manager crash-loops it invisibly (only the log file shows it). Use the same
|
|
221
|
-
// env-OR-file precedence loadConfig() uses so
|
|
222
|
+
// env-OR-file precedence loadConfig() uses so an earlier install's token is
|
|
223
|
+
// honored; the endpoint needs no check, it falls back to the hosted default.
|
|
222
224
|
const file = readStore();
|
|
223
|
-
const endpoint = process.env.USAGEFLEET_ENDPOINT || file.endpoint ||
|
|
225
|
+
const endpoint = process.env.USAGEFLEET_ENDPOINT || file.endpoint || DEFAULT_ENDPOINT;
|
|
224
226
|
const token = process.env.USAGEFLEET_TOKEN || file.token || '';
|
|
225
|
-
if (!
|
|
226
|
-
console.error('
|
|
227
|
-
|
|
227
|
+
if (!token) {
|
|
228
|
+
console.error(fail('config', 'no device token resolved'));
|
|
229
|
+
console.error(hint(' usagefleet install --token <device-token>'));
|
|
230
|
+
console.error(hint(' or set USAGEFLEET_TOKEN'));
|
|
228
231
|
process.exit(1);
|
|
229
232
|
}
|
|
233
|
+
console.log(header());
|
|
234
|
+
console.log('');
|
|
235
|
+
console.log(step('configured', host(endpoint)));
|
|
230
236
|
// Windows: stop a running task first, or `schtasks /run` below is ignored (the
|
|
231
237
|
// task is IgnoreNew) — leaving the OLD version resident after an "update".
|
|
232
238
|
if (process.platform === 'win32') {
|
|
@@ -238,8 +244,7 @@ export function install() {
|
|
|
238
244
|
const prog = programArgs();
|
|
239
245
|
const shadow = shadowingBinary(process.env.PATH, process.argv[1] ?? process.execPath);
|
|
240
246
|
if (shadow) {
|
|
241
|
-
console.warn(`
|
|
242
|
-
`but your shell keeps running that one — delete it: rm ${shadow}`);
|
|
247
|
+
console.log(warn('path', `another usagefleet runs first · rm ${tilde(shadow)}`));
|
|
243
248
|
}
|
|
244
249
|
const env = presentEnv();
|
|
245
250
|
// Same binary, different entry point: the service watches, the hook enforces.
|
|
@@ -276,7 +281,7 @@ ${envXml}
|
|
|
276
281
|
mkdirSync(join(homedir(), 'Library', 'LaunchAgents'), { recursive: true });
|
|
277
282
|
mkdirSync(macLogDir(), { recursive: true });
|
|
278
283
|
// 0600: this file carries USAGEFLEET_TOKEN and ANTHROPIC_API_KEY, the same
|
|
279
|
-
// secrets
|
|
284
|
+
// secrets the config file deliberately holds at 0600.
|
|
280
285
|
writeFileSync(path, plist, { encoding: 'utf-8', mode: 0o600 });
|
|
281
286
|
chmodSync(path, 0o600); // writeFileSync's mode does not apply to an existing file
|
|
282
287
|
const domain = `gui/${process.getuid?.()}`;
|
|
@@ -313,7 +318,8 @@ ${envXml}
|
|
|
313
318
|
/* best-effort */
|
|
314
319
|
}
|
|
315
320
|
console.log(step('service', 'launchd · starts at login'));
|
|
316
|
-
|
|
321
|
+
console.log(row('logs', tilde(macLogDir())));
|
|
322
|
+
return collectingNow();
|
|
317
323
|
}
|
|
318
324
|
if (process.platform === 'linux') {
|
|
319
325
|
// systemd: quote values, escape backslash/quote, reject newlines.
|
|
@@ -378,13 +384,12 @@ WantedBy=default.target
|
|
|
378
384
|
}
|
|
379
385
|
}
|
|
380
386
|
console.log(step('service', 'systemd · starts at login'));
|
|
387
|
+
return collectingNow();
|
|
381
388
|
}
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
console.log(' loginctl enable-linger $USER # keep running after logout');
|
|
387
|
-
}
|
|
389
|
+
console.log(warn('service', 'systemctl not driveable · enable it manually'));
|
|
390
|
+
console.log(hint(' systemctl --user daemon-reload'));
|
|
391
|
+
console.log(hint(' systemctl --user enable --now usagefleet'));
|
|
392
|
+
console.log(hint(' loginctl enable-linger $USER keep running after logout'));
|
|
388
393
|
return;
|
|
389
394
|
}
|
|
390
395
|
if (process.platform === 'win32') {
|
|
@@ -405,18 +410,26 @@ WantedBy=default.target
|
|
|
405
410
|
schtasks('/create', '/tn', TASK, '/sc', 'onlogon', '/f', '/tr', `wscript.exe //B //Nologo "${vbsPath}"`);
|
|
406
411
|
rmSync(xmlPath, { force: true });
|
|
407
412
|
if (!created) {
|
|
408
|
-
console.error('
|
|
409
|
-
|
|
413
|
+
console.error(fail('service', 'scheduled task rejected · register it manually'));
|
|
414
|
+
console.error(hint(` schtasks /create /tn ${TASK} /sc onlogon /tr "wscript.exe //B //Nologo \\"${vbsPath}\\""`));
|
|
410
415
|
process.exit(1);
|
|
411
416
|
}
|
|
412
417
|
// Start now so install/update takes effect immediately, not at next logon.
|
|
413
418
|
schtasks('/run', '/tn', TASK);
|
|
414
419
|
console.log(step('service', 'scheduled task · starts at logon'));
|
|
415
|
-
console.log(row('logs', windowsLogPath()));
|
|
416
|
-
return;
|
|
420
|
+
console.log(row('logs', tilde(windowsLogPath())));
|
|
421
|
+
return collectingNow();
|
|
417
422
|
}
|
|
418
|
-
console.log(`
|
|
419
|
-
console.log(`
|
|
423
|
+
console.log(warn('service', `no autostart on ${process.platform} · run it yourself`));
|
|
424
|
+
console.log(hint(` ${prog.join(' ')}`));
|
|
425
|
+
}
|
|
426
|
+
/** Closing lines of a successful install: what is happening, and the two
|
|
427
|
+
* commands worth knowing next. */
|
|
428
|
+
function collectingNow() {
|
|
429
|
+
console.log('');
|
|
430
|
+
console.log(hint('collecting now.'));
|
|
431
|
+
console.log(hint(' usagefleet status current state'));
|
|
432
|
+
console.log(hint(' usagefleet watch foreground, live log'));
|
|
420
433
|
}
|
|
421
434
|
/** Is the background service actually up? This is the one question `status`
|
|
422
435
|
* has to answer, so every probe is best-effort: an unreadable or unparseable
|
|
@@ -491,7 +504,8 @@ export function uninstall() {
|
|
|
491
504
|
}
|
|
492
505
|
}
|
|
493
506
|
removeStableBin();
|
|
494
|
-
console.log(
|
|
507
|
+
console.log(step('removed', 'launchd agent'));
|
|
508
|
+
console.log(row('leftover', `${tilde(path)} · delete to fully clean up`));
|
|
495
509
|
return;
|
|
496
510
|
}
|
|
497
511
|
if (process.platform === 'linux') {
|
|
@@ -504,7 +518,8 @@ export function uninstall() {
|
|
|
504
518
|
/* ignore */
|
|
505
519
|
}
|
|
506
520
|
removeStableBin();
|
|
507
|
-
console.log(
|
|
521
|
+
console.log(step('removed', 'systemd unit'));
|
|
522
|
+
console.log(row('leftover', `${tilde(systemdUnitPath())} · delete to fully clean up`));
|
|
508
523
|
return;
|
|
509
524
|
}
|
|
510
525
|
if (process.platform === 'win32') {
|
|
@@ -517,8 +532,8 @@ export function uninstall() {
|
|
|
517
532
|
/* ignore */
|
|
518
533
|
}
|
|
519
534
|
removeStableBin();
|
|
520
|
-
console.log(deleted ? `
|
|
535
|
+
console.log(deleted ? step('removed', `scheduled task ${TASK}`) : row('service', `no task ${TASK} found`));
|
|
521
536
|
return;
|
|
522
537
|
}
|
|
523
|
-
console.log(`
|
|
538
|
+
console.log(row('service', `nothing to uninstall on ${process.platform}`));
|
|
524
539
|
}
|
package/dist/store.js
CHANGED
|
@@ -86,7 +86,7 @@ export function readStore(path = storePath()) {
|
|
|
86
86
|
}
|
|
87
87
|
/**
|
|
88
88
|
* Read-modify-write the store atomically. Re-reading inside the call is what
|
|
89
|
-
* lets `usagefleet
|
|
89
|
+
* lets `usagefleet install` change the token while the service is mid-cycle: the
|
|
90
90
|
* service's next save picks up the new token instead of overwriting it with the
|
|
91
91
|
* copy it loaded minutes ago.
|
|
92
92
|
*
|
package/dist/tailer.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { closeSync, openSync, readSync, statSync } from 'node:fs';
|
|
2
2
|
import { parseLine } from './parser.js';
|
|
3
|
+
import { dim, line, tilde, yellow } from './ui.js';
|
|
3
4
|
/** Max bytes read from a single file per cycle (bounds memory on huge backlogs). */
|
|
4
5
|
const MAX_READ = 16 * 1024 * 1024;
|
|
5
6
|
/**
|
|
@@ -38,7 +39,7 @@ export function tailFile(filePath, prev, source = 'cli') {
|
|
|
38
39
|
// No newline in a full MAX_READ window = one pathologically long line.
|
|
39
40
|
// Skip past it so the file can't stall forever.
|
|
40
41
|
if (length >= MAX_READ) {
|
|
41
|
-
|
|
42
|
+
line(yellow('!'), `skipped a line > ${MAX_READ} bytes ${dim(`· ${tilde(filePath)} at ${start}`)}`);
|
|
42
43
|
return {
|
|
43
44
|
consumedBytes: length,
|
|
44
45
|
nextState: { ...base, offset: start + length },
|
package/dist/ui.js
CHANGED
|
@@ -1,35 +1,92 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* a padded label column so lines align.
|
|
2
|
+
* The CLI's only output surface, "quiet" style: lowercase labels, one accent
|
|
3
|
+
* colour, detail in gray, a padded label column so lines align. Every command
|
|
4
|
+
* — install, status, watch, the collector's own stream — prints through these
|
|
5
|
+
* helpers, so a TTY and a service log read the same.
|
|
4
6
|
*
|
|
5
7
|
* Colour is dropped when stdout is not a TTY (service logs, CI, pipes) or when
|
|
6
|
-
* NO_COLOR is set.
|
|
8
|
+
* NO_COLOR is set. Glyphs and bars are plain characters, so a log file still
|
|
9
|
+
* shows them.
|
|
7
10
|
*/
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { detectOs } from './os.js';
|
|
13
|
+
import { RELEASE_VERSION } from './release.js';
|
|
8
14
|
const useColor = process.stdout.isTTY === true && !process.env.NO_COLOR;
|
|
9
15
|
function paint(code) {
|
|
10
16
|
return s => (useColor ? `\u001B[${code}m${s}\u001B[0m` : s);
|
|
11
17
|
}
|
|
12
18
|
export const dim = paint('90');
|
|
19
|
+
/** One step below `dim`: timestamps, which are structure rather than content. */
|
|
20
|
+
export const dimmer = paint('2;90');
|
|
13
21
|
export const green = paint('32');
|
|
14
22
|
export const yellow = paint('33');
|
|
15
23
|
export const red = paint('31');
|
|
16
24
|
export const blue = paint('34');
|
|
17
25
|
export const bold = paint('1');
|
|
18
|
-
/** Width of the label column shared by
|
|
19
|
-
const LABEL =
|
|
20
|
-
/** "
|
|
26
|
+
/** Width of the label column shared by every labelled line. */
|
|
27
|
+
const LABEL = 12;
|
|
28
|
+
/** "usagefleet 1.2.55 mac-arm64" — the banner an interactive command opens
|
|
29
|
+
* with. `detail` replaces the platform when a command has something better to
|
|
30
|
+
* say about itself (watch states its interval). */
|
|
31
|
+
export function header(detail = `${detectOs()}-${process.arch}`) {
|
|
32
|
+
const build = RELEASE_VERSION === 'dev' ? ' · local build, self-update off' : '';
|
|
33
|
+
return `${blue('usagefleet')} ${dim(`${RELEASE_VERSION}${build} ${detail}`)}`;
|
|
34
|
+
}
|
|
35
|
+
/** Server without its scheme: the host is the part worth reading. */
|
|
36
|
+
export function host(endpoint) {
|
|
37
|
+
return endpoint.replace(/^https?:\/\//, '').replace(/\/$/, '');
|
|
38
|
+
}
|
|
39
|
+
/** "✓ installed ~/.local/bin/usagefleet" — a completed step. */
|
|
21
40
|
export function step(label, detail = '') {
|
|
22
41
|
return `${green('✓')} ${label.padEnd(LABEL)} ${dim(detail)}`;
|
|
23
42
|
}
|
|
24
|
-
/** "
|
|
43
|
+
/** "✗ verify failed expected 4f8c…" — a step that did not happen. */
|
|
44
|
+
export function fail(label, detail = '') {
|
|
45
|
+
return `${red('✗')} ${label.padEnd(LABEL)} ${dim(detail)}`;
|
|
46
|
+
}
|
|
47
|
+
/** "! service systemctl unavailable" — worked, but not fully. */
|
|
48
|
+
export function warn(label, detail = '') {
|
|
49
|
+
return `${yellow('!')} ${label.padEnd(LABEL)} ${dim(detail)}`;
|
|
50
|
+
}
|
|
51
|
+
/** "● service running" — state with a health-coloured dot. */
|
|
25
52
|
export function state(health, label, detail) {
|
|
26
53
|
const dot = health === 'ok' ? green('●') : health === 'warn' ? yellow('●') : red('●');
|
|
27
54
|
return `${dot} ${label.padEnd(LABEL)} ${detail}`;
|
|
28
55
|
}
|
|
29
|
-
/** " config
|
|
56
|
+
/** " config ~/.config/usagefleet/config.json" — a plain detail line. */
|
|
30
57
|
export function row(label, detail) {
|
|
31
58
|
return ` ${label.padEnd(LABEL)} ${dim(detail)}`;
|
|
32
59
|
}
|
|
60
|
+
/** A closing suggestion, or any line that is context rather than result. */
|
|
61
|
+
export function hint(text) {
|
|
62
|
+
return dim(text);
|
|
63
|
+
}
|
|
64
|
+
/** Home-relative path, because `~/.claude/projects` reads and wraps better than
|
|
65
|
+
* the absolute one — and hides the user's account name in a pasted terminal. */
|
|
66
|
+
export function tilde(path) {
|
|
67
|
+
const home = homedir();
|
|
68
|
+
return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
69
|
+
}
|
|
70
|
+
/** Day of the last printed stream line. The date is stated on rollover only:
|
|
71
|
+
* HH:MM:SS alone is unreadable in a service log that spans a week, while a
|
|
72
|
+
* one-shot command is already dated by the shell that ran it. */
|
|
73
|
+
let lastDay = '';
|
|
74
|
+
/**
|
|
75
|
+
* One line of the live stream: "09:14:02 ↑ 12 sent · 12 accepted".
|
|
76
|
+
* Used by `watch`, `run` and every message the collector emits, so the service
|
|
77
|
+
* log is the same stream a foreground run shows.
|
|
78
|
+
*/
|
|
79
|
+
export function line(glyph, text) {
|
|
80
|
+
const now = new Date();
|
|
81
|
+
const day = now.toLocaleDateString('en-CA');
|
|
82
|
+
if (lastDay && day !== lastDay) {
|
|
83
|
+
console.log(dimmer(`── ${day}`));
|
|
84
|
+
}
|
|
85
|
+
lastDay = day;
|
|
86
|
+
console.log(`${dimmer(now.toTimeString().slice(0, 8))} ${glyph} ${text}`);
|
|
87
|
+
}
|
|
88
|
+
/** Neutral stream glyph, for messages that are neither good nor bad news. */
|
|
89
|
+
export const note = dim('·');
|
|
33
90
|
/** Percentage as a fixed-width string, so successive log lines line up. */
|
|
34
91
|
export function pct(value) {
|
|
35
92
|
return `${value ?? '?'}%`.padStart(4);
|
package/dist/update.js
CHANGED
|
@@ -37,11 +37,14 @@ function run(cmd, args) {
|
|
|
37
37
|
* install is worse than one that skips a release. `force` is the manual
|
|
38
38
|
* `usagefleet update`, which ignores USAGEFLEET_UPDATE=0 but still refuses to
|
|
39
39
|
* touch a dev build.
|
|
40
|
+
*
|
|
41
|
+
* `log` carries the level so the caller can pick the right glyph: the CLI
|
|
42
|
+
* renders progress as a step and every dead end as a warning.
|
|
40
43
|
*/
|
|
41
44
|
export async function checkForUpdate(log, force = false) {
|
|
42
45
|
if (RELEASE_VERSION === 'dev') {
|
|
43
46
|
if (force) {
|
|
44
|
-
log('
|
|
47
|
+
log('warn', 'dev build · install the published package first');
|
|
45
48
|
}
|
|
46
49
|
return null;
|
|
47
50
|
}
|
|
@@ -62,7 +65,7 @@ export async function checkForUpdate(log, force = false) {
|
|
|
62
65
|
const res = await fetch(`${REGISTRY}/${PACKAGE}/latest`, { signal: AbortSignal.timeout(15_000) });
|
|
63
66
|
if (!res.ok) {
|
|
64
67
|
if (force) {
|
|
65
|
-
log(`
|
|
68
|
+
log('warn', `registry has no release info (${res.status})`);
|
|
66
69
|
}
|
|
67
70
|
return null;
|
|
68
71
|
}
|
|
@@ -70,7 +73,7 @@ export async function checkForUpdate(log, force = false) {
|
|
|
70
73
|
}
|
|
71
74
|
catch (error) {
|
|
72
75
|
if (force) {
|
|
73
|
-
log(`
|
|
76
|
+
log('warn', `npm registry unreachable · ${error.message}`);
|
|
74
77
|
}
|
|
75
78
|
return null;
|
|
76
79
|
}
|
|
@@ -79,22 +82,22 @@ export async function checkForUpdate(log, force = false) {
|
|
|
79
82
|
}
|
|
80
83
|
if (latest === RELEASE_VERSION) {
|
|
81
84
|
if (force) {
|
|
82
|
-
log(`
|
|
85
|
+
log('ok', `already current · ${RELEASE_VERSION}`);
|
|
83
86
|
}
|
|
84
87
|
return null;
|
|
85
88
|
}
|
|
86
|
-
log(
|
|
89
|
+
log('ok', `${RELEASE_VERSION} → ${latest} · installing ${PACKAGE}…`);
|
|
87
90
|
const code = await run(npmCommand(), ['install', '--global', `${PACKAGE}@${latest}`]);
|
|
88
91
|
if (code !== 0) {
|
|
89
|
-
log(code === null
|
|
90
|
-
? '
|
|
91
|
-
: `
|
|
92
|
+
log('warn', code === null
|
|
93
|
+
? 'npm not available · reinstall with `npm i -g @usagefleet/cli`'
|
|
94
|
+
: `npm install failed (exit ${code}) · if the global prefix needs root, run it yourself`);
|
|
92
95
|
return null;
|
|
93
96
|
}
|
|
94
97
|
// Detached: `install` rewrites the service definition and restarts it, which
|
|
95
98
|
// kills this process tree. npm replaced the file behind `self`, so this is
|
|
96
99
|
// already the new version.
|
|
97
100
|
spawn(process.execPath, [self, 'install'], { detached: true, stdio: 'ignore' }).unref();
|
|
98
|
-
log(`
|
|
101
|
+
log('ok', `installed ${latest} · restarting service`);
|
|
99
102
|
return latest;
|
|
100
103
|
}
|
package/dist/uploader.js
CHANGED
|
@@ -52,7 +52,10 @@ function classifyClientError(status) {
|
|
|
52
52
|
if (status === 400 || status === 422) {
|
|
53
53
|
return 'invalid';
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
if (status === 402) {
|
|
56
|
+
return 'plan';
|
|
57
|
+
}
|
|
58
|
+
return 'transient'; // 404, 408, 413, … — the data is fine
|
|
56
59
|
}
|
|
57
60
|
/** Parse a Retry-After header (delta-seconds OR HTTP-date), clamped to [0, 60s]. */
|
|
58
61
|
function retryAfterMs(header, fallback) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usagefleet/cli",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.69",
|
|
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",
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
],
|
|
12
12
|
"homepage": "https://usagefleet.com",
|
|
13
13
|
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/rokartur/usagefleet.git",
|
|
17
|
+
"directory": "apps/cli"
|
|
18
|
+
},
|
|
14
19
|
"bin": {
|
|
15
20
|
"usagefleet": "dist/index.js"
|
|
16
21
|
},
|