@vibe-cafe/vibe-usage 0.10.34 → 0.11.1

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
@@ -51,11 +51,24 @@ npx @vibe-cafe/vibe-usage reset --local # Delete this host's data only and re-u
51
51
  npx @vibe-cafe/vibe-usage skill # Install skill for AI coding assistants
52
52
  npx @vibe-cafe/vibe-usage skill --remove # Remove installed skills
53
53
  npx @vibe-cafe/vibe-usage status # Config, detected tools, and what each tool has uploaded so far
54
+ npx @vibe-cafe/vibe-usage quota discover --json # Detect subscription-quota products locally
55
+ npx @vibe-cafe/vibe-usage quota fetch --product kimi-code --product zcode --product grok --json # Fetch only selected quotas
54
56
  npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short version)
55
57
  ```
56
58
 
57
59
  </details>
58
60
 
61
+ ## Subscription Quotas
62
+
63
+ The versioned `quota` JSON contract is designed for local desktop clients. `discover` only checks ordinary app, config-directory, and executable presence signals: it does not open credentials or use the network. `fetch` invokes only products explicitly named with `--product`, and one provider failure does not prevent results for the others.
64
+
65
+ - **Kimi Code** reads the official Kimi CLI OAuth file (`$KIMI_SHARE_DIR/credentials/kimi-code.json`, otherwise `~/.kimi/credentials/kimi-code.json`) and calls `https://api.kimi.com/coding/v1/usages`. When the short-lived access token is close to expiry, it uses Kimi's standard OAuth refresh flow and atomically rotates the official credential with owner-only permissions. Refreshes are serialized across Vibe Usage processes and re-check the file before writing so a concurrent Kimi CLI refresh wins safely.
66
+ - **ZCode / GLM Coding Plan** accepts only a caller-supplied regional key: `BIGMODEL_API_KEY` calls the domestic `https://open.bigmodel.cn/api/monitor/usage/quota/limit`, while the existing `Z_AI_API_KEY` keeps using `https://api.z.ai/api/monitor/usage/quota/limit`. If both are present, the explicitly named BigModel key wins. It does not read ZCode's private OAuth state.
67
+ - **Grok** reads at most the final 2 MiB of the official CLI's ordinary `$GROK_HOME/logs/unified.jsonl` (default `~/.grok/logs/unified.jsonl`). It accepts only the structured `billing: fetched credits config` event and projects the current utilization, period bounds, subscription tier, and event timestamp. It performs no network request, reads no credentials, and never returns or retains other log fields.
68
+ - **Cursor** is detected independently but remains non-fetchable until an official or stable quota protocol is available. Quota monitoring does not read Cursor's login token/database, browser cookies, another app's Keychain, network traffic, or UI.
69
+
70
+ Quota results are never uploaded or added to incremental sync state. Credential-backed providers may use the disposable cache at `~/.vibe-usage/quota-cache.json`; it contains normalized meters only, is scoped to a one-way hash of the active credential, rejects expired windows, and has a seven-day hard expiry. Grok's local-log result is not cached. No credential is stored in that cache, logs, or command output; the only credential write is Kimi's standard token rotation back to Kimi's own credential file.
71
+
59
72
  ## Supported Tools
60
73
 
61
74
  | Tool | Data Location |
@@ -236,6 +249,10 @@ For OpenCode, each root uses its SQLite database when present; only roots withou
236
249
 
237
250
  The first `npx @vibe-cafe/vibe-usage` run installs a user-level service (systemd on Linux, launchd on macOS, Task Scheduler on Windows — no admin rights needed) that syncs every 30 minutes and starts automatically on login. Nothing else to do.
238
251
 
252
+ Switching the CLI to a different account (running `init` again, or `config set apiKey`) rebinds the upload state, so the next sync re-uploads your full local history to the new account instead of treating it as already sent.
253
+
254
+ 换绑账号后(重新 `init` 或 `config set apiKey`),下一次同步会自动全量重传本地历史,不会因为旧账号的同步记录而漏传。
255
+
239
256
  <details>
240
257
  <summary>Managing the service, and how it is launched</summary>
241
258
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.34",
3
+ "version": "0.11.1",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { writeFileSync, readFileSync, unlinkSync, mkdirSync, existsSync } from 'node:fs';
3
- import { join, dirname, win32 as winPath } from 'node:path';
3
+ import { join, win32 as winPath, posix as posixPath } from 'node:path';
4
4
  import { homedir, platform } from 'node:os';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { success, failure, warn, dim } from './output.js';
@@ -41,8 +41,9 @@ export function isNpxCachePath(binPath) {
41
41
  * falls back to pinning the path and warning, as before).
42
42
  */
43
43
  export function npxLauncher(nodePath, exists = existsSync, os = platform()) {
44
- const nodeDir = dirname(nodePath);
45
- const npxPath = join(nodeDir, os === 'win32' ? 'npx.cmd' : 'npx');
44
+ const paths = os === 'win32' ? winPath : posixPath;
45
+ const nodeDir = paths.dirname(nodePath);
46
+ const npxPath = paths.join(nodeDir, os === 'win32' ? 'npx.cmd' : 'npx');
46
47
  return exists(npxPath) ? { mode: 'npx', npxPath, nodeDir } : null;
47
48
  }
48
49
 
package/src/index.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  import { dim as dimText, failure, hint, smallHeader, warn } from './output.js';
12
12
  import { loadState } from './state.js';
13
13
  import { fetchAccount } from './api.js';
14
+ import { COLLECTOR_VERSION } from './client-meta.js';
14
15
 
15
16
  function printSmallHeader() {
16
17
  console.log();
@@ -298,6 +299,8 @@ const FULL_HELP = `
298
299
  ${BARE} skill Install skill for AI coding tools
299
300
  ${BARE} skill --remove Remove installed skills
300
301
  ${BARE} status Show config and detected tools
302
+ ${BARE} quota discover --json Detect subscription-quota products locally
303
+ ${BARE} quota fetch --product <id> --json Fetch only selected subscription quotas
301
304
  ${BARE} config show Show full config as JSON
302
305
  ${BARE} config get <key> Get a config value
303
306
  ${BARE} config set <key> <value> Set a config value
@@ -307,6 +310,7 @@ const FULL_HELP = `
307
310
  ${BARE} config roots Show added data roots as JSON
308
311
  ${BARE} help Show the short help
309
312
  ${BARE} help --all Show this full list
313
+ ${BARE} --version Print the installed CLI version
310
314
  `;
311
315
 
312
316
  export async function run(rawArgs) {
@@ -358,6 +362,16 @@ export async function run(rawArgs) {
358
362
  await runSummary(args.slice(1));
359
363
  break;
360
364
  }
365
+ case 'quota': {
366
+ const { runQuota } = await import('./quotas/index.js');
367
+ try {
368
+ await runQuota(args.slice(1));
369
+ } catch (error) {
370
+ console.error(error?.message || String(error));
371
+ process.exitCode = 1;
372
+ }
373
+ break;
374
+ }
361
375
  case 'reset': {
362
376
  printSmallHeader();
363
377
  if (args.includes('--host')) hint('reset --host 已改名 reset --local,旧写法仍可用');
@@ -399,6 +413,11 @@ export async function run(rawArgs) {
399
413
  handleConfig(args.slice(1));
400
414
  break;
401
415
  }
416
+ case '--version':
417
+ case '-v': {
418
+ console.log(COLLECTOR_VERSION);
419
+ break;
420
+ }
402
421
  case 'status': {
403
422
  await showStatus();
404
423
  break;
@@ -384,6 +384,19 @@ export async function parse({ extraRoots = [] } = {}) {
384
384
  const roots = getClaudeRoots({
385
385
  onWarning: (message) => addWarning(ctx, message),
386
386
  extraRoots,
387
+ }).filter((root) => {
388
+ // On Windows readdir("file/projects") can return ENOENT rather than
389
+ // ENOTDIR. Validate the root first so that an invalid store cannot look
390
+ // like a successful empty scan and allow incremental state to be pruned.
391
+ try {
392
+ if (statSync(root).isDirectory()) return true;
393
+ addWarning(ctx, `Claude Code: cannot read directory ${root}: not a directory`);
394
+ } catch (err) {
395
+ if (err?.code !== 'ENOENT') {
396
+ addWarning(ctx, `Claude Code: cannot read directory ${root}: ${err.message}`);
397
+ }
398
+ }
399
+ return false;
387
400
  });
388
401
  const projectGroups = collectCandidates(roots, 'projects', ctx);
389
402
  const projectSessionIds = new Set();
@@ -0,0 +1,91 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { normalizeMeter, quotaResult } from './schema.js';
6
+
7
+ const CACHE_VERSION = 1;
8
+ const MAX_CACHE_AGE_MS = 7 * 24 * 60 * 60 * 1000;
9
+
10
+ function cachePath(environment = process.env) {
11
+ const root = environment.VIBE_USAGE_QUOTA_CACHE_DIR?.trim()
12
+ || join(homedir(), '.vibe-usage');
13
+ return join(root, 'quota-cache.json');
14
+ }
15
+
16
+ function loadDocument(environment) {
17
+ try {
18
+ const parsed = JSON.parse(readFileSync(cachePath(environment), 'utf8'));
19
+ if (!isRecord(parsed) || parsed.version !== CACHE_VERSION || !isRecord(parsed.products)) return null;
20
+ return parsed;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ function isRecord(value) {
27
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
28
+ }
29
+
30
+ export function attachCacheScope(result, secret) {
31
+ const scope = createHash('sha256').update(`${result.id}\0${secret}`).digest('hex');
32
+ Object.defineProperty(result, 'cacheScope', { value: scope, enumerable: false });
33
+ return result;
34
+ }
35
+
36
+ export function loadCachedQuota(id, scope, environment = process.env, now = new Date()) {
37
+ if (!scope) return null;
38
+ const raw = loadDocument(environment)?.products?.[id];
39
+ if (!raw || raw.scope !== scope || raw.status !== 'ok' || !Array.isArray(raw.meters)) return null;
40
+ try {
41
+ const dataAsOf = new Date(raw.dataAsOf || raw.fetchedAt);
42
+ if (Number.isNaN(dataAsOf.getTime()) || now.getTime() - dataAsOf.getTime() > MAX_CACHE_AGE_MS) {
43
+ return null;
44
+ }
45
+ const meters = raw.meters.map(normalizeMeter).filter(meter => {
46
+ if (meter.resetsAt) return new Date(meter.resetsAt) > now;
47
+ if (meter.windowSeconds) {
48
+ return dataAsOf.getTime() + meter.windowSeconds * 1000 > now.getTime();
49
+ }
50
+ return true;
51
+ });
52
+ if (!meters.length) return null;
53
+ return quotaResult({
54
+ ...raw,
55
+ id,
56
+ status: 'ok',
57
+ meters,
58
+ source: 'cache',
59
+ fetchedAt: raw.fetchedAt,
60
+ dataAsOf: raw.dataAsOf || raw.fetchedAt,
61
+ });
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ export function saveCachedQuota(result, scope, environment = process.env) {
68
+ if (result?.status !== 'ok' || !scope) return;
69
+ try {
70
+ // All disposable-cache work belongs inside the failure boundary, including
71
+ // path resolution and updating a document recovered from disk.
72
+ const path = cachePath(environment);
73
+ const document = loadDocument(environment) || { version: CACHE_VERSION, products: {} };
74
+ document.products[result.id] = {
75
+ id: result.id,
76
+ status: 'ok',
77
+ meters: result.meters,
78
+ planLabel: result.planLabel,
79
+ fetchedAt: result.fetchedAt,
80
+ dataAsOf: result.dataAsOf,
81
+ scope,
82
+ };
83
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
84
+ const temporary = `${path}.${process.pid}.tmp`;
85
+ writeFileSync(temporary, `${JSON.stringify(document)}\n`, { encoding: 'utf8', mode: 0o600 });
86
+ renameSync(temporary, path);
87
+ } catch {
88
+ // The cache is disposable. A read-only home must not turn a successful
89
+ // provider response into a CLI failure.
90
+ }
91
+ }
@@ -0,0 +1,35 @@
1
+ import { discoverQuotaProducts, fetchQuotaProducts } from './registry.js';
2
+
3
+ function fail(message) {
4
+ throw new Error(message);
5
+ }
6
+
7
+ function parseFetchArguments(args) {
8
+ const products = [];
9
+ for (let index = 0; index < args.length; index += 1) {
10
+ const argument = args[index];
11
+ if (argument === '--json') continue;
12
+ if (argument !== '--product') fail(`Unknown quota fetch option: ${argument}`);
13
+ const value = args[index + 1];
14
+ if (!value || value.startsWith('--')) fail('Option --product requires a value.');
15
+ products.push(value);
16
+ index += 1;
17
+ }
18
+ if (!products.length) fail('quota fetch requires at least one --product.');
19
+ return products;
20
+ }
21
+
22
+ export async function runQuota(args) {
23
+ const subcommand = args[0];
24
+ if (subcommand === 'discover') {
25
+ const unknown = args.slice(1).filter(argument => argument !== '--json');
26
+ if (unknown.length) fail(`Unknown quota discover option: ${unknown[0]}`);
27
+ console.log(JSON.stringify(discoverQuotaProducts()));
28
+ return;
29
+ }
30
+ if (subcommand === 'fetch') {
31
+ console.log(JSON.stringify(await fetchQuotaProducts(parseFetchArguments(args.slice(1)))));
32
+ return;
33
+ }
34
+ fail(`Unknown quota subcommand: ${subcommand || '(none)'}`);
35
+ }
@@ -0,0 +1,151 @@
1
+ import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { quotaResult } from '../schema.js';
5
+
6
+ const PRODUCT_ID = 'grok';
7
+ const DEFAULT_MAX_LOG_BYTES = 2 * 1024 * 1024;
8
+
9
+ export function grokBillingLogPath(environment = process.env, home = homedir()) {
10
+ const configured = environment.GROK_HOME?.trim();
11
+ let root = configured || join(home, '.grok');
12
+ if (root === '~') root = home;
13
+ else if (root.startsWith('~/') || root.startsWith('~\\')) root = join(home, root.slice(2));
14
+ return join(root, 'logs', 'unified.jsonl');
15
+ }
16
+
17
+ function readLogTail(path, maxBytes = DEFAULT_MAX_LOG_BYTES) {
18
+ const descriptor = openSync(path, 'r');
19
+ try {
20
+ const size = fstatSync(descriptor).size;
21
+ const length = Math.min(size, maxBytes);
22
+ if (length <= 0) return '';
23
+ const buffer = Buffer.allocUnsafe(length);
24
+ const offset = size - length;
25
+ let total = 0;
26
+ while (total < length) {
27
+ const count = readSync(descriptor, buffer, total, length - total, offset + total);
28
+ if (count <= 0) break;
29
+ total += count;
30
+ }
31
+ let text = buffer.subarray(0, total).toString('utf8');
32
+ // A bounded tail may begin in the middle of a UTF-8 JSON line. Drop only
33
+ // that incomplete line; the CLI emits this billing snapshot repeatedly.
34
+ if (offset > 0) {
35
+ const newline = text.indexOf('\n');
36
+ text = newline < 0 ? '' : text.slice(newline + 1);
37
+ }
38
+ return text;
39
+ } finally {
40
+ closeSync(descriptor);
41
+ }
42
+ }
43
+
44
+ function finiteNumber(value) {
45
+ const number = typeof value === 'number' ? value : Number(value);
46
+ return Number.isFinite(number) ? number : null;
47
+ }
48
+
49
+ function date(value) {
50
+ if (typeof value !== 'string' || !value.trim()) return null;
51
+ const parsed = new Date(value);
52
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
53
+ }
54
+
55
+ function periodLabel(type, seconds) {
56
+ if (type === 'USAGE_PERIOD_TYPE_DAILY') return '1d';
57
+ if (type === 'USAGE_PERIOD_TYPE_WEEKLY') return '7d';
58
+ if (type === 'USAGE_PERIOD_TYPE_MONTHLY') return 'Month';
59
+ const days = seconds / 86_400;
60
+ return Number.isInteger(days) && days > 0 ? `${days}d` : 'Credits';
61
+ }
62
+
63
+ /**
64
+ * Reads only Grok CLI's structured, non-secret billing status event. Other log
65
+ * messages are parsed only far enough to reject them and are never returned,
66
+ * cached, logged, or uploaded by Vibe Usage.
67
+ */
68
+ export function parseGrokBillingLog(text, now = new Date()) {
69
+ if (typeof text !== 'string' || !text) return null;
70
+ const lines = text.split(/\r?\n/);
71
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
72
+ const line = lines[index].trim();
73
+ if (!line) continue;
74
+ let event;
75
+ try {
76
+ event = JSON.parse(line);
77
+ } catch {
78
+ continue;
79
+ }
80
+ if (event?.msg !== 'billing: fetched credits config') continue;
81
+ const config = event?.ctx?.config;
82
+ if (!config || typeof config !== 'object' || Array.isArray(config)) continue;
83
+ const utilization = finiteNumber(config.creditUsagePercent);
84
+ const dataAsOf = date(event.ts);
85
+ const startsAt = date(config.currentPeriod?.start || config.billingPeriodStart);
86
+ const resetsAt = date(config.currentPeriod?.end || config.billingPeriodEnd);
87
+ if (utilization === null || !dataAsOf || !startsAt || !resetsAt) continue;
88
+ const windowSeconds = (resetsAt.getTime() - startsAt.getTime()) / 1000;
89
+ if (windowSeconds <= 0) continue;
90
+ const planLabel = [event.ctx?.subscriptionTier, config.subscriptionTier]
91
+ .find(value => typeof value === 'string' && value.trim())?.trim();
92
+ return {
93
+ active: resetsAt > now,
94
+ dataAsOf,
95
+ meters: [{
96
+ id: 'subscription-credits',
97
+ label: periodLabel(config.currentPeriod?.type, windowSeconds),
98
+ utilization,
99
+ resetsAt: resetsAt.toISOString(),
100
+ windowSeconds,
101
+ }],
102
+ planLabel,
103
+ };
104
+ }
105
+ return null;
106
+ }
107
+
108
+ export function fetchGrokQuota({
109
+ environment = process.env,
110
+ home = homedir(),
111
+ now = new Date(),
112
+ maxLogBytes = DEFAULT_MAX_LOG_BYTES,
113
+ } = {}) {
114
+ let parsed;
115
+ try {
116
+ parsed = parseGrokBillingLog(
117
+ readLogTail(grokBillingLogPath(environment, home), maxLogBytes),
118
+ now
119
+ );
120
+ } catch (error) {
121
+ const status = error?.code === 'ENOENT' ? 'no_data' : 'retryable_error';
122
+ return quotaResult({
123
+ id: PRODUCT_ID,
124
+ status,
125
+ message: status === 'no_data'
126
+ ? 'Grok billing status is not available yet'
127
+ : 'Grok billing status could not be read',
128
+ fetchedAt: now,
129
+ source: 'local',
130
+ });
131
+ }
132
+ if (!parsed || !parsed.active) {
133
+ return quotaResult({
134
+ id: PRODUCT_ID,
135
+ status: 'no_data',
136
+ message: 'Grok billing status is not available yet',
137
+ fetchedAt: now,
138
+ dataAsOf: parsed?.dataAsOf,
139
+ source: 'local',
140
+ });
141
+ }
142
+ return quotaResult({
143
+ id: PRODUCT_ID,
144
+ status: 'ok',
145
+ meters: parsed.meters,
146
+ planLabel: parsed.planLabel,
147
+ fetchedAt: now,
148
+ dataAsOf: parsed.dataAsOf,
149
+ source: 'local',
150
+ });
151
+ }