@vibe-cafe/vibe-usage 0.8.4 → 0.9.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 +16 -12
- package/package.json +1 -1
- package/src/api.js +58 -0
- package/src/index.js +20 -5
- package/src/init.js +80 -12
- package/src/skill.js +25 -36
- package/src/summary.js +146 -0
package/README.md
CHANGED
|
@@ -4,31 +4,35 @@ Track your AI coding tool token usage and sync to [vibecafe.ai](https://vibecafe
|
|
|
4
4
|
|
|
5
5
|
## Quick Start
|
|
6
6
|
|
|
7
|
-
Get your API key at [vibecafe.ai/usage/setup](https://vibecafe.ai/usage/setup), then copy the one-liner shown there:
|
|
8
|
-
|
|
9
|
-
```bash
|
|
10
|
-
npx @vibe-cafe/vibe-usage --key vbu_xxxxxxxxxxxx
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
Or run without a key and paste it interactively:
|
|
14
|
-
|
|
15
7
|
```bash
|
|
16
8
|
npx @vibe-cafe/vibe-usage
|
|
17
9
|
```
|
|
18
10
|
|
|
19
|
-
|
|
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
|
+
|
|
13
|
+
After approval, it will:
|
|
20
14
|
1. Save your API key to `~/.vibe-usage/config.json`
|
|
21
15
|
2. Detect installed AI coding tools
|
|
22
16
|
3. Run an initial sync of your usage data
|
|
23
17
|
4. Prompt you to enable the background daemon for continuous syncing (recommended)
|
|
24
18
|
|
|
19
|
+
### CI / Headless
|
|
20
|
+
|
|
21
|
+
If you don't have a local browser (CI, remote SSH session, container), pre-issue a key at [vibecafe.ai/usage/setup](https://vibecafe.ai/usage/setup) and pass it on the command line:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npx @vibe-cafe/vibe-usage init --manual-key vbu_xxxxxxxxxxxx
|
|
25
|
+
```
|
|
26
|
+
|
|
25
27
|
## Commands
|
|
26
28
|
|
|
27
29
|
```bash
|
|
28
|
-
npx @vibe-cafe/vibe-usage # Init (first run) or sync (subsequent runs)
|
|
29
|
-
npx @vibe-cafe/vibe-usage
|
|
30
|
-
npx @vibe-cafe/vibe-usage init
|
|
30
|
+
npx @vibe-cafe/vibe-usage # Init (first run, browser login) or sync (subsequent runs)
|
|
31
|
+
npx @vibe-cafe/vibe-usage init # Re-run setup via browser login
|
|
32
|
+
npx @vibe-cafe/vibe-usage init --manual-key <vbu_...> # Skip browser, use pre-issued key (CI/headless)
|
|
31
33
|
npx @vibe-cafe/vibe-usage sync # Manual sync
|
|
34
|
+
npx @vibe-cafe/vibe-usage summary # Print last 7 days as markdown (cost / tokens / by model / by project)
|
|
35
|
+
npx @vibe-cafe/vibe-usage summary --days N # Same, over the last N days (1-90)
|
|
32
36
|
npx @vibe-cafe/vibe-usage daemon # Continuous sync (every 30m, foreground)
|
|
33
37
|
npx @vibe-cafe/vibe-usage daemon install # Install background service (systemd/launchd)
|
|
34
38
|
npx @vibe-cafe/vibe-usage daemon uninstall # Remove background service
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -154,6 +154,64 @@ export function deleteAllData(apiUrl, apiKey, opts) {
|
|
|
154
154
|
});
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Start a device authorization flow.
|
|
159
|
+
* Returns the deviceCode (for polling) + userCode + URLs (for the user).
|
|
160
|
+
*/
|
|
161
|
+
export function requestDeviceCode(apiUrl, { clientName, hostname }) {
|
|
162
|
+
return _jsonRequest(apiUrl, '/api/usage/device/code', 'POST', { clientName, hostname }, 10_000);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* One poll iteration. Resolves with one of:
|
|
167
|
+
* { apiKey, apiUrl } — approved, key delivered
|
|
168
|
+
* { error: 'authorization_pending' } — keep polling
|
|
169
|
+
* { error: 'access_denied' } — user pressed deny
|
|
170
|
+
* { error: 'expired_token' } — code expired or already consumed
|
|
171
|
+
* { error: 'invalid_grant' | ... } — unrecoverable
|
|
172
|
+
* Rejects only on network/server errors.
|
|
173
|
+
*/
|
|
174
|
+
export function pollDeviceCode(apiUrl, deviceCode) {
|
|
175
|
+
return _jsonRequest(apiUrl, '/api/usage/device/poll', 'POST', { deviceCode }, 15_000);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function _jsonRequest(apiUrl, path, method, body, timeoutMs) {
|
|
179
|
+
return new Promise((resolve, reject) => {
|
|
180
|
+
const url = new URL(path, apiUrl);
|
|
181
|
+
const mod = url.protocol === 'https:' ? https : http;
|
|
182
|
+
const raw = Buffer.from(JSON.stringify(body));
|
|
183
|
+
|
|
184
|
+
const req = mod.request(url, {
|
|
185
|
+
method,
|
|
186
|
+
timeout: timeoutMs,
|
|
187
|
+
headers: {
|
|
188
|
+
'Content-Type': 'application/json',
|
|
189
|
+
'Content-Length': raw.length,
|
|
190
|
+
},
|
|
191
|
+
}, (res) => {
|
|
192
|
+
let data = '';
|
|
193
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
194
|
+
res.on('end', () => {
|
|
195
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
196
|
+
const err = new Error(`HTTP ${res.statusCode}: ${data}`);
|
|
197
|
+
err.statusCode = res.statusCode;
|
|
198
|
+
reject(err);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
try {
|
|
202
|
+
resolve(JSON.parse(data));
|
|
203
|
+
} catch {
|
|
204
|
+
reject(new Error(`Invalid JSON response: ${data}`));
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
req.on('error', reject);
|
|
209
|
+
req.on('timeout', () => { req.destroy(); reject(new Error(`Request timed out (${timeoutMs}ms)`)); });
|
|
210
|
+
req.write(raw);
|
|
211
|
+
req.end();
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
157
215
|
/**
|
|
158
216
|
* GET user settings from the vibecafe API.
|
|
159
217
|
* Returns null on any failure (network, auth, timeout) — caller should fail-safe.
|
package/src/index.js
CHANGED
|
@@ -108,7 +108,16 @@ function extractOption(args, name) {
|
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
export async function run(rawArgs) {
|
|
111
|
-
|
|
111
|
+
// --key and --manual-key both mean "skip device flow, take this vbu_ key".
|
|
112
|
+
// --manual-key is the documented name; --key is kept as a legacy alias so
|
|
113
|
+
// existing scripts/docs don't break when device flow becomes the default.
|
|
114
|
+
let stripped;
|
|
115
|
+
let apiKey;
|
|
116
|
+
({ args: stripped, value: apiKey } = extractOption(rawArgs, 'manual-key'));
|
|
117
|
+
if (apiKey === undefined) {
|
|
118
|
+
({ args: stripped, value: apiKey } = extractOption(stripped, 'key'));
|
|
119
|
+
}
|
|
120
|
+
const args = stripped;
|
|
112
121
|
const command = args[0];
|
|
113
122
|
|
|
114
123
|
switch (command) {
|
|
@@ -123,6 +132,11 @@ export async function run(rawArgs) {
|
|
|
123
132
|
await runSync();
|
|
124
133
|
break;
|
|
125
134
|
}
|
|
135
|
+
case 'summary': {
|
|
136
|
+
const { runSummary } = await import('./summary.js');
|
|
137
|
+
await runSummary(args.slice(1));
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
126
140
|
case 'reset': {
|
|
127
141
|
printSmallHeader();
|
|
128
142
|
const { runReset } = await import('./reset.js');
|
|
@@ -164,11 +178,12 @@ export async function run(rawArgs) {
|
|
|
164
178
|
vibe-usage - Vibe Usage Tracker by VibeCafé
|
|
165
179
|
|
|
166
180
|
Usage:
|
|
167
|
-
npx @vibe-cafe/vibe-usage Init (first run) or sync
|
|
168
|
-
npx @vibe-cafe/vibe-usage
|
|
169
|
-
npx @vibe-cafe/vibe-usage init
|
|
170
|
-
npx @vibe-cafe/vibe-usage init --key <vbu_...> Init with key, skip paste prompt
|
|
181
|
+
npx @vibe-cafe/vibe-usage Init (first run, browser login) or sync
|
|
182
|
+
npx @vibe-cafe/vibe-usage init Set up via browser login (default)
|
|
183
|
+
npx @vibe-cafe/vibe-usage init --manual-key <vbu_...> Skip browser, use a pre-issued key (CI/headless)
|
|
171
184
|
npx @vibe-cafe/vibe-usage sync Manually sync usage data
|
|
185
|
+
npx @vibe-cafe/vibe-usage summary Print last 7 days as markdown (cost/tokens/model/project)
|
|
186
|
+
npx @vibe-cafe/vibe-usage summary --days N Same, but over the last N days (1-90)
|
|
172
187
|
npx @vibe-cafe/vibe-usage daemon Continuous sync (every 30m, foreground)
|
|
173
188
|
npx @vibe-cafe/vibe-usage daemon install Install background service (systemd/launchd)
|
|
174
189
|
npx @vibe-cafe/vibe-usage daemon uninstall Remove background service
|
package/src/init.js
CHANGED
|
@@ -2,11 +2,13 @@ import { createInterface } from 'node:readline';
|
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { hostname as osHostname, platform } from 'node:os';
|
|
4
4
|
import { loadConfig, saveConfig } from './config.js';
|
|
5
|
-
import { ingest } from './api.js';
|
|
5
|
+
import { ingest, requestDeviceCode, pollDeviceCode } from './api.js';
|
|
6
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
|
|
|
10
|
+
const CLIENT_NAME = 'vibe-usage CLI';
|
|
11
|
+
|
|
10
12
|
function prompt(question) {
|
|
11
13
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
12
14
|
return new Promise((resolve) => {
|
|
@@ -49,6 +51,7 @@ export async function runInit(options = {}) {
|
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
const apiUrl = process.env.VIBE_USAGE_API_URL || 'https://vibecafe.ai';
|
|
54
|
+
const host = existing?.hostname || osHostname().replace(/\.local$/, '');
|
|
52
55
|
|
|
53
56
|
let apiKey;
|
|
54
57
|
if (providedKey) {
|
|
@@ -58,16 +61,8 @@ export async function runInit(options = {}) {
|
|
|
58
61
|
}
|
|
59
62
|
apiKey = providedKey;
|
|
60
63
|
} else {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
console.log();
|
|
64
|
-
openBrowser(`${apiUrl}/usage/setup`);
|
|
65
|
-
|
|
66
|
-
while (true) {
|
|
67
|
-
apiKey = await prompt('粘贴 API Key: ');
|
|
68
|
-
if (apiKey.startsWith('vbu_')) break;
|
|
69
|
-
console.log(warn('必须以 vbu_ 开头,请重试。'));
|
|
70
|
-
}
|
|
64
|
+
apiKey = await runDeviceFlow(apiUrl, host);
|
|
65
|
+
if (!apiKey) process.exit(1);
|
|
71
66
|
}
|
|
72
67
|
|
|
73
68
|
try {
|
|
@@ -84,7 +79,7 @@ export async function runInit(options = {}) {
|
|
|
84
79
|
const config = {
|
|
85
80
|
apiKey,
|
|
86
81
|
apiUrl,
|
|
87
|
-
hostname:
|
|
82
|
+
hostname: host,
|
|
88
83
|
};
|
|
89
84
|
saveConfig(config);
|
|
90
85
|
|
|
@@ -119,3 +114,76 @@ export async function runInit(options = {}) {
|
|
|
119
114
|
}
|
|
120
115
|
}
|
|
121
116
|
}
|
|
117
|
+
|
|
118
|
+
async function runDeviceFlow(apiUrl, hostname) {
|
|
119
|
+
let device;
|
|
120
|
+
try {
|
|
121
|
+
device = await requestDeviceCode(apiUrl, { clientName: CLIENT_NAME, hostname });
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error(failure(`无法连接 ${apiUrl}:${err.message}`));
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
console.log(`${arrow('登录确认')} ${link(device.verificationUriComplete)}`);
|
|
128
|
+
console.log(` 验证码: ${device.userCode}`);
|
|
129
|
+
console.log(dim(' 浏览器会自动打开;如果没反应,请手动复制上方链接。'));
|
|
130
|
+
console.log();
|
|
131
|
+
openBrowser(device.verificationUriComplete);
|
|
132
|
+
|
|
133
|
+
const intervalMs = (device.interval || 5) * 1000;
|
|
134
|
+
const deadline = Date.now() + (device.expiresIn || 900) * 1000;
|
|
135
|
+
|
|
136
|
+
process.stdout.write(dim('等待审批…'));
|
|
137
|
+
const aborter = new AbortController();
|
|
138
|
+
const onSigint = () => { aborter.abort(); };
|
|
139
|
+
process.on('SIGINT', onSigint);
|
|
140
|
+
try {
|
|
141
|
+
while (Date.now() < deadline) {
|
|
142
|
+
if (aborter.signal.aborted) {
|
|
143
|
+
process.stdout.write('\n');
|
|
144
|
+
console.log(warn('已取消。'));
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
await sleep(intervalMs);
|
|
148
|
+
let res;
|
|
149
|
+
try {
|
|
150
|
+
res = await pollDeviceCode(apiUrl, device.deviceCode);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
// Transient network blip — keep polling until deadline.
|
|
153
|
+
process.stdout.write(dim('.'));
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (res.apiKey) {
|
|
157
|
+
process.stdout.write('\n');
|
|
158
|
+
console.log(success('已批准,获取到 API Key。'));
|
|
159
|
+
return res.apiKey;
|
|
160
|
+
}
|
|
161
|
+
if (res.error === 'authorization_pending') {
|
|
162
|
+
process.stdout.write(dim('.'));
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (res.error === 'access_denied') {
|
|
166
|
+
process.stdout.write('\n');
|
|
167
|
+
console.error(failure('请求被拒绝。'));
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
if (res.error === 'expired_token') {
|
|
171
|
+
process.stdout.write('\n');
|
|
172
|
+
console.error(failure('验证码已过期,请重跑 init。'));
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
process.stdout.write('\n');
|
|
176
|
+
console.error(failure(`服务端返回未知错误:${res.error}`));
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
process.stdout.write('\n');
|
|
180
|
+
console.error(failure('验证码已过期,请重跑 init。'));
|
|
181
|
+
return null;
|
|
182
|
+
} finally {
|
|
183
|
+
process.removeListener('SIGINT', onSigint);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function sleep(ms) {
|
|
188
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
189
|
+
}
|
package/src/skill.js
CHANGED
|
@@ -33,53 +33,42 @@ function tildePath(absPath) {
|
|
|
33
33
|
|
|
34
34
|
const SKILL_CONTENT = `---
|
|
35
35
|
name: vibe-usage
|
|
36
|
-
description:
|
|
36
|
+
description: 查询和同步 AI 编程工具的 token 用量(VibeCafé 旗下 Vibe Usage 数据)。
|
|
37
37
|
---
|
|
38
38
|
|
|
39
39
|
# Vibe Usage
|
|
40
40
|
|
|
41
|
-
Track
|
|
41
|
+
Track and answer questions about the user's AI coding token spend via [Vibe Usage](https://vibecafe.ai/usage) (VibeCafé).
|
|
42
42
|
|
|
43
|
-
##
|
|
43
|
+
## 查询用量(默认行为)
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
当用户问以下问题时,运行命令并**原样展示输出**(不要总结、不要换算单位、不要翻译):
|
|
46
46
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
| 用户说... | 运行 |
|
|
48
|
+
|---|---|
|
|
49
|
+
| 我这周花了多少 / 查 usage / 看看消费 | \`npx @vibe-cafe/vibe-usage summary\` |
|
|
50
|
+
| 今天花了多少 / 今天 token | \`npx @vibe-cafe/vibe-usage summary --days 1\` |
|
|
51
|
+
| 本月花费 / 这个月用量 | \`npx @vibe-cafe/vibe-usage summary --days 30\` |
|
|
52
|
+
| 哪个模型最贵 / 模型对比 | \`npx @vibe-cafe/vibe-usage summary\`(输出已按模型拆) |
|
|
53
|
+
| 哪个项目花得最多 | \`npx @vibe-cafe/vibe-usage summary\`(输出已按项目拆) |
|
|
50
54
|
|
|
51
|
-
|
|
55
|
+
输出是 markdown 表格,直接展示给用户即可。
|
|
52
56
|
|
|
53
|
-
##
|
|
57
|
+
## 维护命令
|
|
54
58
|
|
|
55
|
-
|
|
59
|
+
| 用户说... | 运行 |
|
|
60
|
+
|---|---|
|
|
61
|
+
| 同步数据 / 上传 usage / 数据没更新 | \`npx @vibe-cafe/vibe-usage sync\` |
|
|
62
|
+
| 看 daemon 状态 / 后台同步还在跑吗 | \`npx @vibe-cafe/vibe-usage daemon status\` |
|
|
63
|
+
| 启动后台同步 | \`npx @vibe-cafe/vibe-usage daemon install\` |
|
|
64
|
+
| 重置数据 / 重新上传 | \`npx @vibe-cafe/vibe-usage reset\` |
|
|
56
65
|
|
|
57
|
-
|
|
58
|
-
npx @vibe-cafe/vibe-usage sync
|
|
59
|
-
\`\`\`
|
|
66
|
+
## 注意
|
|
60
67
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
| \`npx @vibe-cafe/vibe-usage sync\` | Sync latest usage data |
|
|
66
|
-
| \`npx @vibe-cafe/vibe-usage status\` | Show config and detected tools |
|
|
67
|
-
| \`npx @vibe-cafe/vibe-usage daemon\` | Continuous sync every 30 minutes |
|
|
68
|
-
| \`npx @vibe-cafe/vibe-usage reset\` | Delete all data and re-upload |
|
|
69
|
-
| \`npx @vibe-cafe/vibe-usage reset --local\` | Delete this host's data and re-upload |
|
|
70
|
-
|
|
71
|
-
## When to Use
|
|
72
|
-
|
|
73
|
-
- User says "sync my usage", "upload usage", "track tokens"
|
|
74
|
-
- User asks "how much have I spent?", "what's my cost?"
|
|
75
|
-
- User wants to check if sync is working: run \`status\`
|
|
76
|
-
- User wants continuous background sync: run \`daemon\`
|
|
77
|
-
|
|
78
|
-
## Notes
|
|
79
|
-
|
|
80
|
-
- Requires initial setup with an API key (run \`npx @vibe-cafe/vibe-usage\` first)
|
|
81
|
-
- Config is stored at \`~/.vibe-usage/config.json\`
|
|
82
|
-
- Supports: Claude Code, Codex CLI, Copilot CLI, Gemini CLI, OpenCode, OpenClaw, Qwen Code, Kimi Code, Amp, Droid
|
|
68
|
+
- \`summary\` 读 \`~/.vibe-usage/config.json\` 里已有的 API key,不需要用户额外输入
|
|
69
|
+
- summary 输出已是 markdown,**原样展示,不要复述**
|
|
70
|
+
- 用户没装过 vibe-usage?提示运行 \`npx @vibe-cafe/vibe-usage\` 先用浏览器登录链接账号
|
|
71
|
+
- 支持的工具:Claude Code, Codex CLI, Copilot CLI, Gemini CLI, OpenCode, OpenClaw, Qwen Code, Kimi Code, Amp, Droid 等
|
|
83
72
|
`;
|
|
84
73
|
|
|
85
74
|
export async function runSkill(args = []) {
|
|
@@ -88,7 +77,7 @@ export async function runSkill(args = []) {
|
|
|
88
77
|
console.log(' 检测到的工具:');
|
|
89
78
|
for (const t of SKILL_TARGETS) {
|
|
90
79
|
const found = existsSync(t.detectDir);
|
|
91
|
-
const mark = found ? green('
|
|
80
|
+
const mark = found ? green('[OK]') : red('[未装]');
|
|
92
81
|
console.log(` ${mark} ${t.name}`);
|
|
93
82
|
}
|
|
94
83
|
console.log();
|
package/src/summary.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import https from 'node:https';
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import { URL } from 'node:url';
|
|
4
|
+
import { loadConfig } from './config.js';
|
|
5
|
+
|
|
6
|
+
export async function runSummary(args = []) {
|
|
7
|
+
const days = parseDays(args);
|
|
8
|
+
const config = loadConfig();
|
|
9
|
+
if (!config?.apiKey) {
|
|
10
|
+
console.error('No vibe-usage config found. Run `npx @vibe-cafe/vibe-usage init` first.');
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const url = new URL('/api/usage', config.apiUrl || 'https://vibecafe.ai');
|
|
15
|
+
url.searchParams.set('days', String(days));
|
|
16
|
+
|
|
17
|
+
let data;
|
|
18
|
+
try {
|
|
19
|
+
data = await fetchJson(url, config.apiKey);
|
|
20
|
+
} catch (err) {
|
|
21
|
+
if (err.statusCode === 401) {
|
|
22
|
+
console.error('API key invalid or revoked. Run `npx @vibe-cafe/vibe-usage init` to re-link.');
|
|
23
|
+
} else {
|
|
24
|
+
console.error(`Failed to fetch usage: ${err.message}`);
|
|
25
|
+
}
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
console.log(render(data, days));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseDays(args) {
|
|
33
|
+
const idx = args.findIndex(a => a === '--days');
|
|
34
|
+
if (idx === -1) return 7;
|
|
35
|
+
const v = parseInt(args[idx + 1], 10);
|
|
36
|
+
if (!v || v < 1) return 7;
|
|
37
|
+
if (v > 90) return 90;
|
|
38
|
+
return v;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function render(data, days) {
|
|
42
|
+
const buckets = Array.isArray(data?.buckets) ? data.buckets : [];
|
|
43
|
+
const sessions = Array.isArray(data?.sessions) ? data.sessions : [];
|
|
44
|
+
|
|
45
|
+
if (buckets.length === 0) {
|
|
46
|
+
return `# Vibe Usage Summary (Last ${days} ${days === 1 ? 'day' : 'days'})\n\n暂无数据。运行 \`npx @vibe-cafe/vibe-usage sync\` 上传本地 token 记录。\n\n详情: https://vibecafe.ai/usage\n`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let totalCost = 0;
|
|
50
|
+
let totalTokens = 0;
|
|
51
|
+
const byModel = new Map();
|
|
52
|
+
const byProject = new Map();
|
|
53
|
+
|
|
54
|
+
for (const b of buckets) {
|
|
55
|
+
const cost = Number(b.estimatedCost ?? 0);
|
|
56
|
+
const tokens = Number(b.totalTokens ?? 0);
|
|
57
|
+
totalCost += cost;
|
|
58
|
+
totalTokens += tokens;
|
|
59
|
+
accumulate(byModel, b.model, { cost, tokens });
|
|
60
|
+
accumulate(byProject, b.project || 'unknown', { cost, tokens, sessions: 0 });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const sessionsCount = sessions.length;
|
|
64
|
+
let activeSeconds = 0;
|
|
65
|
+
for (const s of sessions) {
|
|
66
|
+
activeSeconds += Number(s.activeSeconds ?? 0);
|
|
67
|
+
const proj = byProject.get(s.project || 'unknown');
|
|
68
|
+
if (proj) proj.sessions += 1;
|
|
69
|
+
}
|
|
70
|
+
const activeHours = activeSeconds / 3600;
|
|
71
|
+
|
|
72
|
+
const lines = [];
|
|
73
|
+
lines.push(`# Vibe Usage Summary (Last ${days} ${days === 1 ? 'day' : 'days'})`);
|
|
74
|
+
lines.push('');
|
|
75
|
+
lines.push(`**总览**: $${totalCost.toFixed(2)} · ${formatTokens(totalTokens)} tokens · ${sessionsCount} sessions · ${activeHours.toFixed(1)}h active`);
|
|
76
|
+
lines.push('');
|
|
77
|
+
|
|
78
|
+
lines.push('## 按模型');
|
|
79
|
+
lines.push('');
|
|
80
|
+
lines.push('| 模型 | 费用 | Tokens | 占比 |');
|
|
81
|
+
lines.push('|---|---:|---:|---:|');
|
|
82
|
+
for (const [model, { cost, tokens }] of topN(byModel, 'cost', 8)) {
|
|
83
|
+
const pct = totalCost > 0 ? ((cost / totalCost) * 100).toFixed(0) : '0';
|
|
84
|
+
lines.push(`| ${model} | $${cost.toFixed(2)} | ${formatTokens(tokens)} | ${pct}% |`);
|
|
85
|
+
}
|
|
86
|
+
lines.push('');
|
|
87
|
+
|
|
88
|
+
lines.push('## 按项目');
|
|
89
|
+
lines.push('');
|
|
90
|
+
lines.push('| 项目 | 费用 | Sessions |');
|
|
91
|
+
lines.push('|---|---:|---:|');
|
|
92
|
+
for (const [project, { cost, sessions: ss }] of topN(byProject, 'cost', 8)) {
|
|
93
|
+
lines.push(`| ${project} | $${cost.toFixed(2)} | ${ss} |`);
|
|
94
|
+
}
|
|
95
|
+
lines.push('');
|
|
96
|
+
|
|
97
|
+
lines.push('详情: https://vibecafe.ai/usage');
|
|
98
|
+
return lines.join('\n');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function accumulate(map, key, delta) {
|
|
102
|
+
const cur = map.get(key) || { cost: 0, tokens: 0, sessions: 0 };
|
|
103
|
+
for (const k of Object.keys(delta)) cur[k] = (cur[k] || 0) + delta[k];
|
|
104
|
+
map.set(key, cur);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function topN(map, sortBy, n) {
|
|
108
|
+
return [...map.entries()]
|
|
109
|
+
.sort((a, b) => b[1][sortBy] - a[1][sortBy])
|
|
110
|
+
.slice(0, n);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function formatTokens(n) {
|
|
114
|
+
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
|
|
115
|
+
if (n >= 1_000) return (n / 1_000).toFixed(0) + 'K';
|
|
116
|
+
return String(n);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function fetchJson(url, apiKey) {
|
|
120
|
+
return new Promise((resolve, reject) => {
|
|
121
|
+
const mod = url.protocol === 'https:' ? https : http;
|
|
122
|
+
const req = mod.request(url, {
|
|
123
|
+
method: 'GET',
|
|
124
|
+
timeout: 15_000,
|
|
125
|
+
headers: { 'Authorization': `Bearer ${apiKey}` },
|
|
126
|
+
}, (res) => {
|
|
127
|
+
let data = '';
|
|
128
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
129
|
+
res.on('end', () => {
|
|
130
|
+
if (res.statusCode === 401) {
|
|
131
|
+
const err = new Error('Unauthorized'); err.statusCode = 401; reject(err); return;
|
|
132
|
+
}
|
|
133
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
134
|
+
const err = new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`);
|
|
135
|
+
err.statusCode = res.statusCode;
|
|
136
|
+
reject(err); return;
|
|
137
|
+
}
|
|
138
|
+
try { resolve(JSON.parse(data)); }
|
|
139
|
+
catch { reject(new Error('Invalid JSON response')); }
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
req.on('error', reject);
|
|
143
|
+
req.on('timeout', () => { req.destroy(); reject(new Error('timeout (15s)')); });
|
|
144
|
+
req.end();
|
|
145
|
+
});
|
|
146
|
+
}
|