@vibe-cafe/vibe-usage 0.2.5 → 0.2.7

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
@@ -11,8 +11,7 @@ npx vibe-usage
11
11
  This will:
12
12
  1. Ask for your API key (get one at https://vibecafe.ai/usage/setup)
13
13
  2. Detect installed AI coding tools
14
- 3. Install session-end hooks for automatic syncing
15
- 4. Run an initial sync of your usage data
14
+ 3. Run an initial sync of your usage data
16
15
 
17
16
  ## Commands
18
17
 
@@ -20,18 +19,19 @@ This will:
20
19
  npx vibe-usage # Init (first run) or sync (subsequent runs)
21
20
  npx vibe-usage init # Re-run setup
22
21
  npx vibe-usage sync # Manual sync
22
+ npx vibe-usage reset # Delete all data and re-upload from local logs
23
23
  npx vibe-usage status # Show config & detected tools
24
24
  ```
25
25
 
26
26
  ## Supported Tools
27
27
 
28
- | Tool | Auto-sync | Data Location |
29
- |------|-----------|---------------|
30
- | Claude Code | Yes (session hook) | `~/.claude/projects/` |
31
- | Codex CLI | Yes (notify hook) | `~/.codex/sessions/` |
32
- | Gemini CLI | Yes (session hook) | `~/.gemini/tmp/` |
33
- | OpenCode | Manual only | `~/.local/share/opencode/opencode.db` (SQLite) |
34
- | OpenClaw | Manual only | `~/.openclaw/agents/` |
28
+ | Tool | Data Location |
29
+ |------|---------------|
30
+ | Claude Code | `~/.claude/projects/` |
31
+ | Codex CLI | `~/.codex/sessions/` |
32
+ | Gemini CLI | `~/.gemini/tmp/` |
33
+ | OpenCode | `~/.local/share/opencode/opencode.db` (SQLite) |
34
+ | OpenClaw | `~/.openclaw/agents/` |
35
35
 
36
36
  ## How It Works
37
37
 
@@ -39,6 +39,7 @@ npx vibe-usage status # Show config & detected tools
39
39
  - Aggregates token usage into 30-minute buckets
40
40
  - Uploads to your vibecafe.ai dashboard
41
41
  - Only syncs new data since last sync (incremental)
42
+ - For continuous syncing, use the [Vibe Usage Mac app](https://github.com/vibe-cafe/vibe-usage-app) (auto-syncs every 5 minutes)
42
43
 
43
44
  ## Config
44
45
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.js CHANGED
@@ -100,3 +100,52 @@ function _send(apiUrl, apiKey, buckets, onProgress) {
100
100
  writeNext();
101
101
  });
102
102
  }
103
+
104
+ /**
105
+ * DELETE all usage data for the authenticated user.
106
+ * @param {string} apiUrl - Base URL (e.g. "https://vibecafe.ai")
107
+ * @param {string} apiKey - Bearer token (vbu_xxx)
108
+ * @returns {Promise<{deleted: number}>}
109
+ */
110
+ export function deleteAllData(apiUrl, apiKey) {
111
+ return new Promise((resolve, reject) => {
112
+ const url = new URL('/api/usage/ingest', apiUrl);
113
+ const mod = url.protocol === 'https:' ? https : http;
114
+
115
+ const req = mod.request(url, {
116
+ method: 'DELETE',
117
+ timeout: 60_000,
118
+ headers: {
119
+ 'Authorization': `Bearer ${apiKey}`,
120
+ },
121
+ }, (res) => {
122
+ let data = '';
123
+ res.on('data', (chunk) => { data += chunk; });
124
+ res.on('end', () => {
125
+ if (res.statusCode === 401) {
126
+ reject(new Error('UNAUTHORIZED'));
127
+ return;
128
+ }
129
+ if (res.statusCode < 200 || res.statusCode >= 300) {
130
+ const err = new Error(`HTTP ${res.statusCode}: ${data}`);
131
+ err.statusCode = res.statusCode;
132
+ reject(err);
133
+ return;
134
+ }
135
+ try {
136
+ resolve(JSON.parse(data));
137
+ } catch {
138
+ reject(new Error(`Invalid JSON response: ${data}`));
139
+ }
140
+ });
141
+ });
142
+
143
+ req.on('error', (err) => reject(err));
144
+ req.on('timeout', () => {
145
+ req.destroy();
146
+ reject(new Error('Request timed out (60s)'));
147
+ });
148
+
149
+ req.end();
150
+ });
151
+ }
package/src/hooks.js CHANGED
@@ -1,178 +1,32 @@
1
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
- import { join, dirname } from 'node:path';
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
 
5
- const SYNC_CMD = 'npx @vibe-cafe/vibe-usage sync 2>/dev/null &';
6
-
7
- /**
8
- * Check if a SessionEnd hook array (new or old format) already contains a vibe-usage hook.
9
- */
10
- function hasVibeUsageHook(hooks) {
11
- if (!Array.isArray(hooks)) return false;
12
- return hooks.some(entry => {
13
- // New format: { matcher?: "...", hooks: [{ type, command }] }
14
- if (Array.isArray(entry.hooks)) {
15
- return entry.hooks.some(h => h.command && h.command.includes('vibe-usage'));
16
- }
17
- // Old format: { type, command } directly
18
- if (entry.command && entry.command.includes('vibe-usage')) return true;
19
- return false;
20
- });
21
- }
22
-
23
- /**
24
- * Migrate old-format hook entries to the new matcher format.
25
- * Old: [{ type: "command", command: "..." }]
26
- * New: [{ hooks: [{ type: "command", command: "..." }] }]
27
- */
28
- function migrateOldFormatHooks(hooks) {
29
- if (!Array.isArray(hooks)) return hooks;
30
- return hooks.map(entry => {
31
- // Already new format (has "hooks" array)
32
- if (Array.isArray(entry.hooks)) return entry;
33
- // Old format: bare handler → wrap in matcher group
34
- if (entry.type && entry.command) {
35
- return { hooks: [entry] };
36
- }
37
- return entry;
38
- });
39
- }
40
-
41
- export function injectClaudeCode() {
42
- const settingsPath = join(homedir(), '.claude', 'settings.json');
43
- let settings = {};
44
- if (existsSync(settingsPath)) {
45
- try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch { settings = {}; }
46
- } else {
47
- mkdirSync(dirname(settingsPath), { recursive: true });
48
- }
49
-
50
- if (!settings.hooks) settings.hooks = {};
51
- if (!settings.hooks.SessionEnd) settings.hooks.SessionEnd = [];
52
-
53
- // Migrate any old-format hooks first
54
- settings.hooks.SessionEnd = migrateOldFormatHooks(settings.hooks.SessionEnd);
55
-
56
- if (hasVibeUsageHook(settings.hooks.SessionEnd)) {
57
- // Update the command in existing hook to use latest
58
- for (const group of settings.hooks.SessionEnd) {
59
- if (Array.isArray(group.hooks)) {
60
- for (const h of group.hooks) {
61
- if (h.command && h.command.includes('vibe-usage')) {
62
- h.command = SYNC_CMD;
63
- }
64
- }
65
- }
66
- }
67
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
68
- return { injected: false, reason: 'already installed (updated)' };
69
- }
70
-
71
- // New format: matcher group with hooks array
72
- settings.hooks.SessionEnd.push({
73
- hooks: [{ type: 'command', command: SYNC_CMD }],
74
- });
75
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
76
- return { injected: true };
77
- }
78
-
79
- export function injectCodex() {
80
- const configPath = join(homedir(), '.codex', 'config.toml');
81
- let content = '';
82
- if (existsSync(configPath)) {
83
- content = readFileSync(configPath, 'utf-8');
84
- } else {
85
- mkdirSync(dirname(configPath), { recursive: true });
86
- }
87
-
88
- const notifyLine = `notify = "sh -c \\"${SYNC_CMD}\\""`;
89
-
90
- if (content.includes('vibe-usage')) {
91
- // Migrate broken [[notify]] / [notify] table format and array format from previous versions
92
- // to correct string format: notify = "sh -c \"...\""
93
- content = content.replace(
94
- /^\[\[?notify\]\]?\n(?:command\s*=\s*["'][^"']*["']\n?)?/gm,
95
- notifyLine + '\n',
96
- );
97
- // Migrate array format: notify = ["sh", "-c", "..."]
98
- content = content.replace(
99
- /^notify\s*=\s*\[.*vibe-usage.*\]$/gm,
100
- notifyLine,
101
- );
102
- // Update existing string format notify = "..." to use latest command
103
- content = content.replace(
104
- /^notify\s*=\s*".*vibe-usage.*"$/gm,
105
- notifyLine,
106
- );
107
- writeFileSync(configPath, content, 'utf-8');
108
- return { injected: false, reason: 'already installed (updated)' };
109
- }
110
-
111
- // Check if any notify line already exists
112
- const hasNotify = /^notify\s*=/m.test(content);
113
- if (hasNotify) {
114
- // Replace existing notify value
115
- content = content.replace(/^notify\s*=\s*.+$/gm, notifyLine);
116
- } else {
117
- content += `\n${notifyLine}\n`;
118
- }
119
-
120
- writeFileSync(configPath, content, 'utf-8');
121
- return { injected: true };
122
- }
123
-
124
- export function injectGeminiCli() {
125
- const settingsPath = join(homedir(), '.gemini', 'settings.json');
126
- let settings = {};
127
- if (existsSync(settingsPath)) {
128
- try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch { settings = {}; }
129
- } else {
130
- mkdirSync(dirname(settingsPath), { recursive: true });
131
- }
132
-
133
- if (!settings.hooks) settings.hooks = {};
134
- if (!settings.hooks.SessionEnd) settings.hooks.SessionEnd = [];
135
-
136
- if (hasVibeUsageHook(settings.hooks.SessionEnd)) {
137
- return { injected: false, reason: 'already installed' };
138
- }
139
-
140
- // Gemini CLI still uses the flat format (no matcher groups)
141
- settings.hooks.SessionEnd.push({ type: 'command', command: SYNC_CMD });
142
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
143
- return { injected: true };
144
- }
145
-
146
5
  export const TOOLS = [
147
6
  {
148
7
  name: 'Claude Code',
149
8
  id: 'claude-code',
150
9
  dataDir: join(homedir(), '.claude', 'projects'),
151
- inject: injectClaudeCode,
152
10
  },
153
11
  {
154
12
  name: 'Codex CLI',
155
13
  id: 'codex',
156
14
  dataDir: join(homedir(), '.codex', 'sessions'),
157
- inject: injectCodex,
158
15
  },
159
16
  {
160
17
  name: 'Gemini CLI',
161
18
  id: 'gemini-cli',
162
19
  dataDir: join(homedir(), '.gemini', 'tmp'),
163
- inject: injectGeminiCli,
164
20
  },
165
21
  {
166
22
  name: 'OpenCode',
167
23
  id: 'opencode',
168
24
  dataDir: join(homedir(), '.local', 'share', 'opencode'),
169
- inject: null,
170
25
  },
171
26
  {
172
27
  name: 'OpenClaw',
173
28
  id: 'openclaw',
174
29
  dataDir: join(homedir(), '.openclaw', 'agents'),
175
- inject: null,
176
30
  },
177
31
  ];
178
32
 
package/src/index.js CHANGED
@@ -22,8 +22,7 @@ async function showStatus() {
22
22
  console.log(' (none)\n');
23
23
  } else {
24
24
  for (const tool of detected) {
25
- const hookStatus = tool.inject ? 'auto-sync' : 'manual only';
26
- console.log(` ${tool.name} (${hookStatus})`);
25
+ console.log(` ${tool.name}`);
27
26
  }
28
27
  console.log();
29
28
  }
@@ -104,6 +103,11 @@ export async function run(args) {
104
103
  await runSync();
105
104
  break;
106
105
  }
106
+ case 'reset': {
107
+ const { runReset } = await import('./reset.js');
108
+ await runReset();
109
+ break;
110
+ }
107
111
  case 'config': {
108
112
  handleConfig(args.slice(1));
109
113
  break;
@@ -120,8 +124,9 @@ export async function run(args) {
120
124
 
121
125
  Usage:
122
126
  npx vibe-usage Init (first run) or sync
123
- npx vibe-usage init Set up API key and hooks
127
+ npx vibe-usage init Set up API key
124
128
  npx vibe-usage sync Manually sync usage data
129
+ npx vibe-usage reset Delete all data and re-upload
125
130
  npx vibe-usage status Show config and detected tools
126
131
  npx vibe-usage config show Show full config as JSON
127
132
  npx vibe-usage config get <key> Get a config value
package/src/init.js CHANGED
@@ -1,11 +1,10 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import { execFile } from 'node:child_process';
3
3
  import { platform } from 'node:os';
4
- import { existsSync } from 'node:fs';
5
4
  import { loadConfig, saveConfig } from './config.js';
6
- import { detectInstalledTools } from './hooks.js';
7
5
  import { ingest } from './api.js';
8
6
  import { runSync } from './sync.js';
7
+ import { detectInstalledTools } from './hooks.js';
9
8
 
10
9
  function prompt(question) {
11
10
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -67,29 +66,9 @@ export async function runInit() {
67
66
  saveConfig(config);
68
67
 
69
68
  const tools = detectInstalledTools();
70
- const hooked = [];
71
- const manualOnly = [];
72
-
73
- for (const tool of tools) {
74
- if (tool.inject) {
75
- try {
76
- const result = tool.inject();
77
- hooked.push(tool.name + (result.injected ? '' : ' (already installed)'));
78
- } catch (err) {
79
- console.error(` warn: Failed to inject hook for ${tool.name}: ${err.message}`);
80
- }
81
- } else {
82
- manualOnly.push(tool.name);
83
- }
84
- }
85
-
86
- if (hooked.length > 0) {
87
- console.log(`Hooks installed for: ${hooked.join(', ')}`);
88
- }
89
- for (const name of manualOnly) {
90
- console.log(`${name} detected — use \`npx @vibe-cafe/vibe-usage sync\` to sync manually.`);
91
- }
92
- if (tools.length === 0) {
69
+ if (tools.length > 0) {
70
+ console.log(`Detected tools: ${tools.map(t => t.name).join(', ')}`);
71
+ } else {
93
72
  console.log('No AI coding tools detected. Install one and re-run init.');
94
73
  }
95
74
 
@@ -81,18 +81,17 @@ export async function parse(lastSync) {
81
81
  try {
82
82
  const obj = JSON.parse(line);
83
83
 
84
+ // Capture model from top-level turn_context entries
85
+ if (obj.type === 'turn_context' && obj.payload?.model) {
86
+ turnContextModel = obj.payload.model;
87
+ continue;
88
+ }
84
89
 
85
90
  if (obj.type !== 'event_msg') continue;
86
91
 
87
92
  const payload = obj.payload;
88
93
  if (!payload) continue;
89
94
 
90
- // Capture model from turn_context events
91
- if (payload.type === 'turn_context' && payload.model) {
92
- turnContextModel = payload.model;
93
- continue;
94
- }
95
-
96
95
  if (payload.type !== 'token_count') continue;
97
96
 
98
97
  const info = payload.info;
@@ -105,7 +104,7 @@ export async function parse(lastSync) {
105
104
  // Prefer incremental per-request usage; compute delta from cumulative total as fallback
106
105
  let usage = info.last_token_usage;
107
106
  if (!usage && info.total_token_usage) {
108
- const totalKey = `${info.model || payload.model || ''}`;
107
+ const totalKey = `${info.model || payload.model || turnContextModel || ''}`;
109
108
  const prev = prevTotal.get(totalKey);
110
109
  const curr = info.total_token_usage;
111
110
  if (prev) {
package/src/reset.js ADDED
@@ -0,0 +1,68 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { existsSync, unlinkSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { homedir } from 'node:os';
5
+ import { loadConfig, saveConfig } from './config.js';
6
+ import { deleteAllData } from './api.js';
7
+ import { runSync } from './sync.js';
8
+
9
+ const STATE_FILES = [
10
+ join(homedir(), '.vibe-usage', 'claude-code-state.json'),
11
+ ];
12
+
13
+ function prompt(question) {
14
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
15
+ return new Promise((resolve) => {
16
+ rl.question(question, (answer) => {
17
+ rl.close();
18
+ resolve(answer.trim());
19
+ });
20
+ });
21
+ }
22
+
23
+ export async function runReset() {
24
+ const config = loadConfig();
25
+ if (!config?.apiKey) {
26
+ console.error('Not configured. Run `npx @vibe-cafe/vibe-usage init` first.');
27
+ process.exit(1);
28
+ }
29
+
30
+ const answer = await prompt('This will delete ALL your usage data and re-upload from local logs. Continue? (y/N) ');
31
+ if (answer.toLowerCase() !== 'y') {
32
+ console.log('Cancelled.');
33
+ return;
34
+ }
35
+
36
+ const apiUrl = config.apiUrl || 'https://vibecafe.ai';
37
+
38
+ // 1. Delete remote data
39
+ console.log('Deleting remote data...');
40
+ try {
41
+ const result = await deleteAllData(apiUrl, config.apiKey);
42
+ console.log(`Deleted ${result.deleted} buckets from server.`);
43
+ } catch (err) {
44
+ if (err.message === 'UNAUTHORIZED') {
45
+ console.error('Invalid API key. Run `npx @vibe-cafe/vibe-usage init` to reconfigure.');
46
+ process.exit(1);
47
+ }
48
+ console.error(`Failed to delete remote data: ${err.message}`);
49
+ process.exit(1);
50
+ }
51
+
52
+ // 2. Clear local state
53
+ config.lastSync = null;
54
+ saveConfig(config);
55
+
56
+ for (const stateFile of STATE_FILES) {
57
+ if (existsSync(stateFile)) {
58
+ unlinkSync(stateFile);
59
+ }
60
+ }
61
+ console.log('Cleared local sync state.');
62
+
63
+ // 3. Re-upload everything
64
+ console.log('\nRe-syncing all data...');
65
+ await runSync();
66
+
67
+ console.log(`\nReset complete! View your dashboard at: ${apiUrl}/usage`);
68
+ }
package/src/sync.js CHANGED
@@ -1,11 +1,7 @@
1
1
  import { hostname as osHostname } from 'node:os';
2
- import { existsSync, readFileSync, unlinkSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { homedir } from 'node:os';
5
2
  import { loadConfig, saveConfig } from './config.js';
6
3
  import { ingest } from './api.js';
7
4
  import { parsers, postSyncHooks } from './parsers/index.js';
8
- import { TOOLS } from './hooks.js';
9
5
 
10
6
  const BATCH_SIZE = 100;
11
7
 
@@ -16,9 +12,6 @@ function formatBytes(bytes) {
16
12
  }
17
13
 
18
14
  export async function runSync() {
19
- // Self-heal: re-inject any missing hooks before syncing
20
- ensureHooks();
21
-
22
15
  const config = loadConfig();
23
16
  if (!config?.apiKey) {
24
17
  console.error('Not configured. Run `npx @vibe-cafe/vibe-usage init` first.');
@@ -65,7 +58,7 @@ export async function runSync() {
65
58
  const result = await ingest(apiUrl, config.apiKey, batch, {
66
59
  onProgress(sent, total) {
67
60
  const pct = Math.round((sent / total) * 100);
68
- process.stdout.write(`${prefix}${formatBytes(sent)}/${formatBytes(total)} (${pct}%)\r`);
61
+ process.stdout.write(`\r${prefix}${formatBytes(sent)}/${formatBytes(total)} (${pct}%)\x1b[K`);
69
62
  },
70
63
  });
71
64
  totalIngested += result.ingested ?? batch.length;
@@ -102,39 +95,3 @@ export async function runSync() {
102
95
  process.exit(1);
103
96
  }
104
97
  }
105
-
106
- /**
107
- * Re-inject hooks for any installed tool whose hook is missing.
108
- * Runs silently — meant as a self-healing side effect of sync.
109
- */
110
- function ensureHooks() {
111
- // Skip hook injection if Vibe Usage Mac app is running
112
- const markerPath = join(homedir(), '.vibe-usage', 'mac-app-active');
113
- if (existsSync(markerPath)) {
114
- try {
115
- const marker = JSON.parse(readFileSync(markerPath, 'utf-8'));
116
- if (marker.pid) {
117
- try {
118
- process.kill(marker.pid, 0);
119
- return;
120
- } catch {
121
- try { unlinkSync(markerPath); } catch { /* ignore */ }
122
- }
123
- }
124
- } catch {
125
- // Malformed marker file — ignore
126
- }
127
- }
128
-
129
- for (const tool of TOOLS) {
130
- if (!tool.inject) continue;
131
- try {
132
- const result = tool.inject();
133
- if (result.injected) {
134
- process.stderr.write(`hook: re-installed ${tool.name} hook\n`);
135
- }
136
- } catch {
137
- // ignore — best effort
138
- }
139
- }
140
- }