@vibe-cafe/vibe-usage 0.10.15 → 0.10.16

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
@@ -8,10 +8,10 @@ Track your AI coding tool token usage and sync to [vibecafe.ai](https://vibecafe
8
8
  npx @vibe-cafe/vibe-usage
9
9
  ```
10
10
 
11
- That's it. On first setup, the CLI asks whether project names and the device name may leave the machine (both default to **No**), then opens [vibecafe.ai/usage/device](https://vibecafe.ai/usage/device) in your browser. Sign in, confirm the verification code shown in your terminal, click 「确认链接」, and the CLI receives an API key automatically.
11
+ That's it. The CLI opens [vibecafe.ai/usage/device](https://vibecafe.ai/usage/device) in your browser; sign in, confirm the verification code shown in your terminal, click 「确认链接」, and the CLI receives an API key automatically.
12
12
 
13
13
  After approval, it will:
14
- 1. Save your API key and local privacy choices to `~/.vibe-usage/config.json`
14
+ 1. Save your API key to `~/.vibe-usage/config.json`
15
15
  2. Detect installed AI coding tools
16
16
  3. Run an initial sync of your usage data
17
17
  4. Prompt you to enable the background daemon for continuous syncing (recommended)
@@ -32,8 +32,6 @@ npx @vibe-cafe/vibe-usage init # Re-run setup via browser login
32
32
  npx @vibe-cafe/vibe-usage init --manual-key <vbu_...> # Skip browser, use pre-issued key (CI/headless)
33
33
  npx @vibe-cafe/vibe-usage sync # Manual sync
34
34
  npx @vibe-cafe/vibe-usage sync --extra-codex-home /path/to/.codex # Add another Codex Home for this run only
35
- npx @vibe-cafe/vibe-usage config set uploadProject false # Never upload project names
36
- npx @vibe-cafe/vibe-usage config set uploadHostname false # Use an opaque per-install device id
37
35
  npx @vibe-cafe/vibe-usage summary # Print last 7 days as markdown (cost / tokens / by model / by project)
38
36
  npx @vibe-cafe/vibe-usage summary --days N # Same, over the last N days (1-90)
39
37
  npx @vibe-cafe/vibe-usage daemon # Continuous sync (every 30m, foreground)
@@ -157,40 +155,10 @@ Config stored at `~/.vibe-usage/config.json` (dev: `config.dev.json`).
157
155
  |-----|-------------|
158
156
  | `apiKey` | Your API key (starts with `vbu_`) |
159
157
  | `apiUrl` | Server URL (default: `https://vibecafe.ai`) |
160
- | `hostname` | Stable device name or user-chosen alias; stays local when `uploadHostname=false` |
161
- | `uploadProject` | Local project-name control. `false` always wins over the Web setting |
162
- | `uploadHostname` | Local device-name control. `false` replaces the name at the final network boundary |
163
- | `deviceId` | Generated opaque per-install identity used when `uploadHostname=false` |
158
+ | `hostname` | Stable device name for usage tracking (set at init, reused across syncs) |
164
159
  | `codexExtraHome` | Optional additional Codex Home scanned together with `$CODEX_HOME` / `~/.codex` |
165
160
 
166
- New setups default both local upload controls to `false`. Existing configs without
167
- these keys retain their previous behavior until you choose a value: the Web
168
- project-name setting remains authoritative, and the configured device name is
169
- uploaded.
170
-
171
- ```bash
172
- # Local false cannot be overridden by a later Web setting.
173
- npx @vibe-cafe/vibe-usage config set uploadProject false
174
-
175
- # Replaces the device name in buckets, sessions, and sync metadata with a
176
- # persistent random id such as device-0011223344556677.
177
- npx @vibe-cafe/vibe-usage config set uploadHostname false
178
- ```
179
-
180
- The sanitization happens after every parser and before hashing or HTTP
181
- serialization. `cursor-cloud` remains a fixed, non-identifying sentinel so
182
- Cursor account exports still deduplicate across computers.
183
-
184
- These controls prevent future transmissions; they do not silently delete data
185
- already stored in the cloud. To remove previously uploaded identifiers, run
186
- `vibe-usage reset` after enabling both controls. A full reset deletes the
187
- account's existing usage before re-uploading the logs available on this
188
- computer, so coordinate first if the account syncs multiple computers.
189
-
190
- When device-name upload is enabled, `hostname` is captured once during `init`
191
- and reused for all future syncs. This prevents macOS mDNS hostname changes
192
- (for example, `MacBook-Pro` → `MacBook-Pro-2`) from creating duplicate device
193
- entries. It can also be set to a non-identifying alias:
161
+ The `hostname` is captured once during `init` and reused for all future syncs. This prevents macOS mDNS hostname changes (e.g., `MacBook-Pro` `MacBook-Pro-2`) from creating duplicate device entries. To change it manually:
194
162
 
195
163
  ```bash
196
164
  npx @vibe-cafe/vibe-usage config set hostname my-device-name
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.15",
3
+ "version": "0.10.16",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/index.js CHANGED
@@ -24,8 +24,6 @@ async function showStatus() {
24
24
  if (config.codexExtraHome) {
25
25
  console.log(` Extra Codex Home: ${config.codexExtraHome}`);
26
26
  }
27
- console.log(` Project names: ${config.uploadProject === false ? 'hidden locally' : 'server setting'}`);
28
- console.log(` Device name: ${config.uploadHostname === false ? 'anonymous device id' : 'uploaded'}`);
29
27
  }
30
28
 
31
29
  console.log('\n Detected tools:');
@@ -50,15 +48,7 @@ async function showStatus() {
50
48
  console.log();
51
49
  }
52
50
 
53
- const BOOLEAN_CONFIG_KEYS = new Set(['uploadProject', 'uploadHostname']);
54
- const VALID_CONFIG_KEYS = [
55
- 'apiKey',
56
- 'apiUrl',
57
- 'hostname',
58
- 'uploadProject',
59
- 'uploadHostname',
60
- 'codexExtraHome',
61
- ];
51
+ const VALID_CONFIG_KEYS = ['apiKey', 'apiUrl', 'hostname', 'codexExtraHome'];
62
52
 
63
53
  function handleConfig(args) {
64
54
  const sub = args[0];
@@ -91,14 +81,6 @@ function handleConfig(args) {
91
81
  console.error(`Valid keys: ${VALID_CONFIG_KEYS.join(', ')}`);
92
82
  process.exit(1);
93
83
  }
94
- if (BOOLEAN_CONFIG_KEYS.has(key)) {
95
- const normalized = value.toLowerCase();
96
- if (normalized !== 'true' && normalized !== 'false') {
97
- console.error(`${key} must be true or false.`);
98
- process.exit(1);
99
- }
100
- value = normalized === 'true';
101
- }
102
84
  if (key === 'codexExtraHome' && value !== '') {
103
85
  const validation = validateExtraCodexHome(value);
104
86
  if (!validation.ok) {
@@ -251,8 +233,6 @@ export async function run(rawArgs) {
251
233
  npx @vibe-cafe/vibe-usage config get <key> Get a config value
252
234
  npx @vibe-cafe/vibe-usage config set <key> <value> Set a config value
253
235
  npx @vibe-cafe/vibe-usage config set codexExtraHome <path> Persist another Codex Home
254
- npx @vibe-cafe/vibe-usage config set uploadProject false Never upload project names
255
- npx @vibe-cafe/vibe-usage config set uploadHostname false Replace the device name with an anonymous id
256
236
  npx @vibe-cafe/vibe-usage help Show this help
257
237
  `);
258
238
  break;
package/src/init.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import { execFile } from 'node:child_process';
3
- import { platform } from 'node:os';
3
+ import { hostname as osHostname, platform } from 'node:os';
4
4
  import { loadConfig, saveConfig } from './config.js';
5
5
  import { ingest, requestDeviceCode, pollDeviceCode } from './api.js';
6
- import { resolveOptionalBoolean, resolveSyncHostname, runSync } from './sync.js';
6
+ import { runSync } from './sync.js';
7
7
  import { detectInstalledTools } from './tools.js';
8
8
  import { bigHeader, success, failure, warn, arrow, link, dim, divider } from './output.js';
9
9
 
@@ -30,14 +30,6 @@ function isDaemonPlatform() {
30
30
  return process.platform === 'linux' || process.platform === 'darwin';
31
31
  }
32
32
 
33
- async function resolvePrivacyChoice(existingValue, key, question) {
34
- const configured = resolveOptionalBoolean(existingValue, key);
35
- if (configured !== undefined) return configured;
36
- if (!process.stdin.isTTY) return false;
37
- const answer = (await prompt(question)).toLowerCase();
38
- return answer === 'y' || answer === 'yes';
39
- }
40
-
41
33
  export async function runInit(options = {}) {
42
34
  const { apiKey: providedKey, codexExtraHome } = options;
43
35
 
@@ -59,31 +51,7 @@ export async function runInit(options = {}) {
59
51
  }
60
52
 
61
53
  const apiUrl = process.env.VIBE_USAGE_API_URL || 'https://vibecafe.ai';
62
- let uploadProject;
63
- let uploadHostname;
64
- let draftConfig;
65
- let host;
66
- try {
67
- uploadProject = await resolvePrivacyChoice(
68
- existing?.uploadProject,
69
- 'uploadProject',
70
- '上传项目名以查看按项目统计?项目名可能包含客户或内部代号。 [y/N] ',
71
- );
72
- uploadHostname = await resolvePrivacyChoice(
73
- existing?.uploadHostname,
74
- 'uploadHostname',
75
- '上传设备名以区分电脑?选择否将使用匿名设备 ID。 [y/N] ',
76
- );
77
- draftConfig = {
78
- ...(existing || {}),
79
- uploadProject,
80
- uploadHostname,
81
- };
82
- host = resolveSyncHostname(draftConfig).hostname;
83
- } catch (err) {
84
- console.error(failure(err.message));
85
- process.exit(1);
86
- }
54
+ const host = existing?.hostname || osHostname().replace(/\.local$/, '');
87
55
 
88
56
  let apiKey;
89
57
  if (providedKey) {
@@ -109,9 +77,10 @@ export async function runInit(options = {}) {
109
77
  }
110
78
 
111
79
  const config = {
112
- ...draftConfig,
113
80
  apiKey,
114
81
  apiUrl,
82
+ hostname: host,
83
+ ...(existing?.codexExtraHome ? { codexExtraHome: existing.codexExtraHome } : {}),
115
84
  };
116
85
  saveConfig(config);
117
86
 
package/src/reset.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { createInterface } from 'node:readline';
2
- import { loadConfig, saveConfig } from './config.js';
2
+ import { hostname as getHostname } from 'node:os';
3
+ import { loadConfig } from './config.js';
3
4
  import { deleteAllData } from './api.js';
4
- import { resolveSyncHostname, runSync } from './sync.js';
5
+ import { runSync } from './sync.js';
5
6
  import { clearState } from './state.js';
6
7
  import { success, failure, arrow, link, dim } from './output.js';
7
8
 
@@ -33,18 +34,10 @@ export async function runReset(args = [], deps = {}) {
33
34
  process.exit(1);
34
35
  }
35
36
 
36
- // Target the exact privacy-safe identity sync.js uploads under. This keeps
37
- // `reset --local` aligned with both the stable configured hostname and the
38
- // anonymous per-install device id used when hostname upload is disabled.
39
- let hostIdentity;
40
- try {
41
- hostIdentity = resolveSyncHostname(config);
42
- if (hostIdentity.changed) saveConfig(config);
43
- } catch (err) {
44
- console.error(failure(err.message));
45
- process.exit(1);
46
- }
47
- const currentHost = hostIdentity.hostname;
37
+ // Target the hostname persisted at init — the same one sync.js uploads
38
+ // under. A fresh os.hostname() can have drifted since (macOS mDNS adds -2
39
+ // suffixes), which would delete zero rows, or another machine's rows.
40
+ const currentHost = config.hostname || getHostname().replace(/\.local$/, '');
48
41
  const apiUrl = config.apiUrl || 'https://vibecafe.ai';
49
42
 
50
43
  if (hostOnly) {
package/src/sync.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { hostname as osHostname } from 'node:os';
2
- import { randomBytes } from 'node:crypto';
3
2
  import { loadConfig, saveConfig } from './config.js';
4
3
  import {
5
4
  loadState, saveState, pruneState,
@@ -15,83 +14,13 @@ import { success, failure, warn, arrow, link, dim } from './output.js';
15
14
  const BATCH_SIZE = 100;
16
15
  const SESSION_BATCH_SIZE = 500;
17
16
 
18
- const ANONYMOUS_DEVICE_ID_PATTERN = /^device-[0-9a-f]{16}$/;
19
- const SHARED_HOSTNAME_SENTINELS = new Set(['cursor-cloud']);
20
-
21
- export function resolveOptionalBoolean(value, key) {
22
- if (value === undefined) return undefined;
23
- if (typeof value === 'boolean') return value;
24
- const error = new Error(`配置 ${key} 必须是 true 或 false。`);
25
- error.code = 'INVALID_CONFIG';
26
- throw error;
27
- }
28
-
29
- export function resolveSyncHostname(config, {
30
- systemHostname = () => osHostname().replace(/\.local$/, ''),
31
- createDeviceId = () => `device-${randomBytes(8).toString('hex')}`,
32
- } = {}) {
33
- const uploadHostname = resolveOptionalBoolean(config.uploadHostname, 'uploadHostname') ?? true;
34
- const previousHostname = typeof config.hostname === 'string' && config.hostname.trim()
35
- ? config.hostname.trim()
36
- : undefined;
37
-
38
- if (!uploadHostname) {
39
- const existingDeviceId = typeof config.deviceId === 'string'
40
- ? config.deviceId.trim().toLowerCase()
41
- : '';
42
- const hostname = ANONYMOUS_DEVICE_ID_PATTERN.test(existingDeviceId)
43
- ? existingDeviceId
44
- : createDeviceId();
45
- const changed = config.deviceId !== hostname;
46
- if (changed) config.deviceId = hostname;
47
- return { hostname, previousHostname, uploadHostname, changed };
48
- }
49
-
50
- const hostname = previousHostname || systemHostname();
51
- const changed = config.hostname !== hostname;
52
- if (changed) config.hostname = hostname;
53
- return { hostname, previousHostname, uploadHostname, changed };
54
- }
55
-
56
- export function applyHostnamePrivacy(records, hostname, uploadHostname) {
57
- for (const record of records) {
58
- if (uploadHostname) {
59
- if (!record.hostname) record.hostname = hostname;
60
- } else if (!SHARED_HOSTNAME_SENTINELS.has(record.hostname)) {
61
- record.hostname = hostname;
62
- }
63
- }
64
- }
65
-
66
- // A hostname privacy toggle changes the server bucket key. Carry unchanged
67
- // local state across that key change so enabling privacy does not re-upload
68
- // all historical buckets beside their older server rows. Changed buckets still
69
- // upload under the anonymous id; `reset` remains the explicit way to remove
70
- // identifiers that were uploaded before the local control was enabled.
71
- export function migrateHiddenHostnameState(state, buckets, previousHostname, hostname) {
72
- if (!previousHostname || previousHostname === hostname) return false;
73
- let changed = false;
74
- for (const bucket of buckets) {
75
- if (bucket.hostname !== hostname) continue;
76
- const oldKey = bucketKey({ ...bucket, hostname: previousHostname });
77
- const newKey = bucketKey(bucket);
78
- const currentHash = bucketHash(bucket);
79
- if (state.buckets[oldKey] !== currentHash) continue;
80
- if (!(newKey in state.buckets)) state.buckets[newKey] = currentHash;
81
- delete state.buckets[oldKey];
82
- changed = true;
83
- }
84
- return changed;
85
- }
86
-
87
17
  function formatBytes(bytes) {
88
18
  if (bytes < 1024) return `${bytes}B`;
89
19
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
90
20
  return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
91
21
  }
92
22
 
93
- export function resolveUploadProjectSetting(settings, localSetting) {
94
- if (localSetting === false) return false;
23
+ export function resolveUploadProjectSetting(settings) {
95
24
  if (typeof settings?.uploadProject !== 'boolean') {
96
25
  const error = new Error('SETTINGS_UNAVAILABLE');
97
26
  error.code = 'SETTINGS_UNAVAILABLE';
@@ -161,18 +90,6 @@ export async function runSync({
161
90
  saveConfig(config);
162
91
  }
163
92
 
164
- let localUploadProject;
165
- let hostIdentity;
166
- try {
167
- localUploadProject = resolveOptionalBoolean(config.uploadProject, 'uploadProject');
168
- hostIdentity = resolveSyncHostname(config);
169
- if (hostIdentity.changed) saveConfig(config);
170
- } catch (err) {
171
- console.error(failure(err.message));
172
- if (throws) throw err;
173
- process.exit(1);
174
- }
175
-
176
93
  // Privacy is a required input, not an optional hint. If the settings API is
177
94
  // unavailable, treating it as `false` changes every project-bearing item's
178
95
  // incremental identity to `unknown` and can trigger a full-history upload.
@@ -180,45 +97,36 @@ export async function runSync({
180
97
  // no-op: no data upload and no state mutation.
181
98
  const apiUrl = config.apiUrl || 'https://vibecafe.ai';
182
99
  let uploadProject;
183
- if (localUploadProject === false) {
184
- // A local deny is authoritative and needs no server round trip. This is
185
- // both fail-closed and usable when the settings endpoint is unavailable.
186
- uploadProject = false;
187
- } else {
188
- try {
189
- const settings = await fetchSettings(apiUrl, config.apiKey);
190
- uploadProject = resolveUploadProjectSetting(settings, localUploadProject);
191
- // Scope the cached privacy choice to the server that returned it. Reusing
192
- // the value after `apiUrl` changes could expose project names to a
193
- // different server during its first settings outage.
194
- if (
195
- config.lastUploadProject !== uploadProject
196
- || config.lastUploadProjectApiUrl !== apiUrl
197
- ) {
198
- config.lastUploadProject = uploadProject;
199
- config.lastUploadProjectApiUrl = apiUrl;
200
- saveConfig(config);
201
- }
202
- } catch (err) {
203
- if (err.message === 'UNAUTHORIZED') {
204
- console.error(failure('API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。'));
205
- if (throws) throw err;
206
- process.exit(1);
207
- }
208
- // Settings endpoint unreachable (not auth): degrade to the last confirmed
209
- // choice for this same server rather than hard-aborting every upload.
210
- const cachedUploadProject = resolveCachedUploadProjectSetting(config, apiUrl);
211
- if (typeof cachedUploadProject === 'boolean') {
212
- uploadProject = resolveUploadProjectSetting(
213
- { uploadProject: cachedUploadProject },
214
- localUploadProject,
215
- );
216
- if (!quiet) console.log(warn('设置接口不可用,沿用上次的项目名设置。'));
217
- } else {
218
- console.error(failure('暂时无法读取上传设置,本次同步已安全取消(未上传数据)。请稍后重试。'));
219
- if (throws) throw err;
220
- process.exit(1);
221
- }
100
+ try {
101
+ const settings = await fetchSettings(apiUrl, config.apiKey);
102
+ uploadProject = resolveUploadProjectSetting(settings);
103
+ // Scope the cached privacy choice to the server that returned it. Reusing
104
+ // the value after `apiUrl` changes could expose project names to a
105
+ // different server during its first settings outage.
106
+ if (
107
+ config.lastUploadProject !== uploadProject
108
+ || config.lastUploadProjectApiUrl !== apiUrl
109
+ ) {
110
+ config.lastUploadProject = uploadProject;
111
+ config.lastUploadProjectApiUrl = apiUrl;
112
+ saveConfig(config);
113
+ }
114
+ } catch (err) {
115
+ if (err.message === 'UNAUTHORIZED') {
116
+ console.error(failure('API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。'));
117
+ if (throws) throw err;
118
+ process.exit(1);
119
+ }
120
+ // Settings endpoint unreachable (not auth): degrade to the last confirmed
121
+ // choice for this same server rather than hard-aborting every upload.
122
+ const cachedUploadProject = resolveCachedUploadProjectSetting(config, apiUrl);
123
+ if (typeof cachedUploadProject === 'boolean') {
124
+ uploadProject = cachedUploadProject;
125
+ if (!quiet) console.log(warn('设置接口不可用,沿用上次的项目名设置。'));
126
+ } else {
127
+ console.error(failure('暂时无法读取上传设置,本次同步已安全取消(未上传数据)。请稍后重试。'));
128
+ if (throws) throw err;
129
+ process.exit(1);
222
130
  }
223
131
  }
224
132
 
@@ -313,24 +221,23 @@ export async function runSync({
313
221
  }
314
222
  }
315
223
 
316
- const host = hostIdentity.hostname;
317
- // Cloud-backed parsers use explicit non-identifying sentinels (currently
318
- // `cursor-cloud`) so the same account data deduplicates across computers.
319
- // Every other hostname is assigned here, at the final network boundary.
320
- applyHostnamePrivacy(allBuckets, host, hostIdentity.uploadHostname);
321
- applyHostnamePrivacy(allSessions, host, hostIdentity.uploadHostname);
224
+ let host = config.hostname;
225
+ if (!host) {
226
+ host = osHostname().replace(/\.local$/, '');
227
+ config.hostname = host;
228
+ saveConfig(config);
229
+ }
230
+ // Cloud-sourced parsers (e.g. cursor) pre-set their own hostname sentinel so
231
+ // the same account data isn't stored as separate rows per machine.
232
+ for (const b of allBuckets) if (!b.hostname) b.hostname = host;
233
+ for (const s of allSessions) if (!s.hostname) s.hostname = host;
322
234
 
323
235
  if (!quiet) {
324
236
  if (uploadProject) {
325
- console.log(dim(' 项目名: 上传(本机或 Web 设置均可关闭)'));
237
+ console.log(dim(' 项目名: 上传(可在 Web 设置中关闭)'));
326
238
  } else {
327
239
  console.log(dim(' 项目名: 已隐藏'));
328
240
  }
329
- console.log(dim(
330
- hostIdentity.uploadHostname
331
- ? ' 设备名: 上传'
332
- : ` 设备名: 已替换为匿名 ID (${host})`,
333
- ));
334
241
  }
335
242
  if (!uploadProject) {
336
243
  for (const b of allBuckets) b.project = 'unknown';
@@ -346,13 +253,6 @@ export async function runSync({
346
253
  // Missing/corrupt state.json => empty maps => one-time full upload, then
347
254
  // incremental forever after.
348
255
  const state = loadState();
349
- const migratedHostnameState = !hostIdentity.uploadHostname
350
- && migrateHiddenHostnameState(
351
- state,
352
- allBuckets,
353
- hostIdentity.previousHostname,
354
- host,
355
- );
356
256
  const changedBuckets = [];
357
257
  const changedSessions = [];
358
258
  const liveBucketKeys = new Set();
@@ -390,7 +290,7 @@ export async function runSync({
390
290
  const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
391
291
  pruneState(state, liveBucketKeys, liveSessionKeys, okSources);
392
292
  const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
393
- if (pruned > 0 || migratedHostnameState) saveState(state);
293
+ if (pruned > 0) saveState(state);
394
294
 
395
295
  if (changedBuckets.length === 0 && changedSessions.length === 0) {
396
296
  if (!quiet) console.log(dim('无新增数据。'));