codebuddy-stats 1.0.0 → 1.1.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 ADDED
@@ -0,0 +1,120 @@
1
+ # CodeBuddy Stats
2
+
3
+ 一个用于分析 CodeBuddy 系列产品使用成本的命令行工具,支持交互式 TUI 界面和纯文本输出。
4
+
5
+ ## 功能特性
6
+
7
+ - **双数据源支持** - 同时支持 CodeBuddy Code 和 CodeBuddy IDE(VS Code 扩展)
8
+ - **成本热力图** - 可视化每日 AI 使用成本分布
9
+ - **模型统计** - 按模型分类的费用、请求数、Token 用量
10
+ - **项目统计** - 按项目分类的费用汇总
11
+ - **每日明细** - 查看每日详细使用情况
12
+ - **缓存命中率** - 显示 prompt cache 命中率(Code 模式)
13
+ - **多模型定价** - 支持 GPT-5.2、Claude 4.5、Gemini 等模型
14
+
15
+ ## 安装
16
+
17
+ ```bash
18
+ npm install -g codebuddy-stats
19
+ ```
20
+
21
+ ## 使用方法
22
+
23
+ ```bash
24
+ # 启动交互式 TUI 界面
25
+ cbs
26
+
27
+ # 或使用完整命令名
28
+ codebuddy-stats
29
+
30
+ # 纯文本输出模式
31
+ cbs --no-tui
32
+
33
+ # 只显示最近 7 天的数据
34
+ cbs --days 7
35
+
36
+ # 显示帮助
37
+ cbs --help
38
+ ```
39
+
40
+ ## TUI 界面操作
41
+
42
+ | 按键 | 功能 |
43
+ | ----------- | ------------------------ |
44
+ | `Tab` | 切换到下一个视图 |
45
+ | `Shift+Tab` | 切换到上一个视图 |
46
+ | `s` | 切换数据源(Code / IDE) |
47
+ | `↑` / `k` | 向上滚动 (Daily 视图) |
48
+ | `↓` / `j` | 向下滚动 (Daily 视图) |
49
+ | `r` | 刷新数据 |
50
+ | `q` | 退出 |
51
+
52
+ ## 视图说明
53
+
54
+ ### Overview
55
+
56
+ 显示成本热力图和汇总统计,包括:
57
+
58
+ - 总费用、总 Token 数、总请求数
59
+ - 活跃天数、缓存命中率、日均费用
60
+ - 使用最多的模型和项目
61
+
62
+ ### By Model
63
+
64
+ 按 AI 模型分类的详细统计表格,包含每个模型的费用、请求数、Token 数和平均每次请求费用。
65
+
66
+ ### By Project
67
+
68
+ 按项目分类的费用统计,方便了解不同项目的 AI 使用成本。
69
+
70
+ 工具会自动将项目标识解析为可读路径:
71
+ - **Code 模式**: 将 `Users-anoti-Documents-project-xxx` 还原为 `~/Documents/project/xxx`
72
+ - **IDE 模式**: 将 MD5 hash 映射为实际工作区路径
73
+
74
+ ### Daily
75
+
76
+ 每日使用明细,显示日期、费用、请求数以及当天使用最多的模型和项目。
77
+
78
+ ## 支持的模型
79
+
80
+ | 模型 | 输入价格 | 输出价格 |
81
+ | --------------- | -------- | -------- |
82
+ | GPT-5.2 | $1.75/M | $14.00/M |
83
+ | GPT-5.1 / GPT-5 | $1.25/M | $10.00/M |
84
+ | GPT-5-mini | $0.25/M | $2.00/M |
85
+ | GPT-5-nano | $0.05/M | $0.40/M |
86
+ | Claude Opus 4.5 | $5.00/M | $25.00/M |
87
+ | Claude 4.5 | $3.00/M | $15.00/M |
88
+ | Gemini 3 Pro | $2.00/M | $12.00/M |
89
+ | Gemini 2.5 Pro | $1.25/M | $10.00/M |
90
+
91
+ _价格单位:USD / 1M tokens,部分模型支持分层定价_
92
+
93
+ ## 数据来源
94
+
95
+ 工具支持两种数据源,可在 TUI 界面中按 `s` 键切换:
96
+
97
+ ### CodeBuddy Code(CLI 版本)
98
+
99
+ - **macOS**: `~/.codebuddy/projects/`
100
+ - **Windows**: `%APPDATA%/CodeBuddy/projects/`
101
+ - **Linux**: `$XDG_CONFIG_HOME/codebuddy/projects/` 或 `~/.codebuddy/projects/`
102
+
103
+ 特点:包含完整的缓存命中/写入 token 数据,可计算缓存命中率和精确成本。
104
+
105
+ ### CodeBuddy IDE(VS Code 扩展)
106
+
107
+ - **macOS**: `~/Library/Application Support/CodeBuddyExtension/Data/`
108
+ - **Windows**: `%APPDATA%/CodeBuddyExtension/Data/`
109
+ - **Linux**: `$XDG_CONFIG_HOME/CodeBuddyExtension/Data/` 或 `~/.config/CodeBuddyExtension/Data/`
110
+
111
+ 特点:基于 input/output tokens 估算成本,不包含缓存相关数据。
112
+
113
+ ## 系统要求
114
+
115
+ - Node.js >= 18
116
+ - 终端支持 Unicode 字符(用于热力图显示)
117
+
118
+ ## License
119
+
120
+ ISC
package/dist/index.js CHANGED
@@ -1,8 +1,16 @@
1
1
  #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
2
5
  import blessed from 'blessed';
3
6
  import { loadUsageData } from './lib/data-loader.js';
4
- import { shortenProjectName } from './lib/paths.js';
7
+ import { resolveProjectName } from './lib/workspace-resolver.js';
5
8
  import { formatCost, formatNumber, formatPercent, formatTokens, truncate } from './lib/utils.js';
9
+ // 读取 package.json 获取版本号
10
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
+ const pkgPath = path.resolve(__dirname, '../package.json');
12
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
13
+ const VERSION = pkg.version;
6
14
  // 解析命令行参数
7
15
  function parseArgs() {
8
16
  const args = process.argv.slice(2);
@@ -59,7 +67,7 @@ function getHeatChar(cost, maxCost) {
59
67
  return '█';
60
68
  }
61
69
  // 渲染 Overview 视图
62
- function renderOverview(box, data, width) {
70
+ function renderOverview(box, data, width, note) {
63
71
  const { dailySummary, grandTotal, topModel, topProject, cacheHitRate, activeDays } = data;
64
72
  const heatmap = generateHeatmapData(dailySummary);
65
73
  // 根据宽度计算热力图周数
@@ -135,13 +143,16 @@ function renderOverview(box, data, width) {
135
143
  }
136
144
  if (topProject) {
137
145
  const projectMaxLen = width >= 100 ? 60 : 35;
138
- const shortName = shortenProjectName(topProject.name);
146
+ const shortName = resolveProjectName(topProject.name, data.workspaceMappings);
139
147
  content += `{cyan-fg}Top project:{/cyan-fg} ${truncate(shortName, projectMaxLen)} (${formatCost(topProject.cost)})\n`;
140
148
  }
149
+ if (note) {
150
+ content += `\n{gray-fg}备注:${note}{/gray-fg}\n`;
151
+ }
141
152
  box.setContent(content);
142
153
  }
143
154
  // 渲染 By Model 视图
144
- function renderByModel(box, data, width) {
155
+ function renderByModel(box, data, width, note) {
145
156
  const { modelTotals, grandTotal } = data;
146
157
  const sorted = Object.entries(modelTotals).sort((a, b) => b[1].cost - a[1].cost);
147
158
  // 根据宽度计算列宽
@@ -176,10 +187,13 @@ function renderByModel(box, data, width) {
176
187
  formatNumber(grandTotal.requests).padStart(12) +
177
188
  formatTokens(grandTotal.tokens).padStart(12) +
178
189
  '{/bold}\n';
190
+ if (note) {
191
+ content += `\n{gray-fg}备注:${note}{/gray-fg}\n`;
192
+ }
179
193
  box.setContent(content);
180
194
  }
181
195
  // 渲染 By Project 视图
182
- function renderByProject(box, data, width) {
196
+ function renderByProject(box, data, width, note) {
183
197
  const { projectTotals, grandTotal } = data;
184
198
  const sorted = Object.entries(projectTotals).sort((a, b) => b[1].cost - a[1].cost);
185
199
  // 根据宽度计算列宽
@@ -197,7 +211,7 @@ function renderByProject(box, data, width) {
197
211
  '{/underline}\n';
198
212
  for (const [projectName, stats] of sorted) {
199
213
  // 简化项目名
200
- const shortName = shortenProjectName(projectName);
214
+ const shortName = resolveProjectName(projectName, data.workspaceMappings);
201
215
  content +=
202
216
  truncate(shortName, projectCol - 1).padEnd(projectCol) +
203
217
  formatCost(stats.cost).padStart(12) +
@@ -213,10 +227,13 @@ function renderByProject(box, data, width) {
213
227
  formatNumber(grandTotal.requests).padStart(12) +
214
228
  formatTokens(grandTotal.tokens).padStart(12) +
215
229
  '{/bold}\n';
230
+ if (note) {
231
+ content += `\n{gray-fg}备注:${note}{/gray-fg}\n`;
232
+ }
216
233
  box.setContent(content);
217
234
  }
218
235
  // 渲染 Daily 视图
219
- function renderDaily(box, data, scrollOffset = 0, width) {
236
+ function renderDaily(box, data, scrollOffset = 0, width, note) {
220
237
  const { dailySummary, dailyData } = data;
221
238
  const sortedDates = Object.keys(dailySummary).sort().reverse();
222
239
  // 根据宽度计算列宽
@@ -259,7 +276,7 @@ function renderDaily(box, data, scrollOffset = 0, width) {
259
276
  topProject = { name: project, cost: projectCost };
260
277
  }
261
278
  }
262
- const shortProject = shortenProjectName(topProject.name);
279
+ const shortProject = resolveProjectName(topProject.name, data.workspaceMappings);
263
280
  content +=
264
281
  date.padEnd(dateCol) +
265
282
  formatCost(daySummary.cost).padStart(costCol) +
@@ -271,6 +288,9 @@ function renderDaily(box, data, scrollOffset = 0, width) {
271
288
  if (sortedDates.length > 20) {
272
289
  content += `\n{gray-fg}Showing ${scrollOffset + 1}-${Math.min(scrollOffset + 20, sortedDates.length)} of ${sortedDates.length} days (↑↓ to scroll){/gray-fg}`;
273
290
  }
291
+ if (note) {
292
+ content += `\n\n{gray-fg}备注:${note}{/gray-fg}\n`;
293
+ }
274
294
  box.setContent(content);
275
295
  }
276
296
  // 纯文本输出模式
@@ -287,7 +307,8 @@ function printTextReport(data) {
287
307
  console.log(`\nTop model: ${topModel.id} (${formatCost(topModel.cost)})`);
288
308
  }
289
309
  if (topProject) {
290
- console.log(`Top project: ${topProject.name}`);
310
+ const shortName = resolveProjectName(topProject.name, data.workspaceMappings);
311
+ console.log(`Top project: ${shortName}`);
291
312
  console.log(` (${formatCost(topProject.cost)})`);
292
313
  }
293
314
  console.log('\n' + '-'.repeat(50));
@@ -300,7 +321,7 @@ function printTextReport(data) {
300
321
  for (const [project, stats] of Object.entries(projectTotals)
301
322
  .sort((a, b) => b[1].cost - a[1].cost)
302
323
  .slice(0, 10)) {
303
- const shortName = shortenProjectName(project);
324
+ const shortName = resolveProjectName(project, data.workspaceMappings);
304
325
  console.log(` ${truncate(shortName, 40)}: ${formatCost(stats.cost)}`); // eslint-disable-line no-console
305
326
  }
306
327
  console.log('\n' + '='.repeat(50) + '\n');
@@ -309,7 +330,8 @@ function printTextReport(data) {
309
330
  async function main() {
310
331
  const options = parseArgs();
311
332
  console.log('Loading data...');
312
- let data = await loadUsageData({ days: options.days });
333
+ let currentSource = 'code';
334
+ let data = await loadUsageData({ days: options.days, source: currentSource });
313
335
  if (options.noTui) {
314
336
  printTextReport(data);
315
337
  return;
@@ -318,6 +340,8 @@ async function main() {
318
340
  const screen = blessed.screen({
319
341
  smartCSR: true,
320
342
  title: 'CodeBuddy Cost Analyzer',
343
+ forceUnicode: true,
344
+ fullUnicode: true,
321
345
  });
322
346
  // Tab 状态
323
347
  const tabs = ['Overview', 'By Model', 'By Project', 'Daily'];
@@ -374,6 +398,16 @@ async function main() {
374
398
  // 更新 Tab 栏
375
399
  function updateTabBar() {
376
400
  let content = ' Cost Analysis ';
401
+ content += '{gray-fg}Source:{/gray-fg} ';
402
+ if (currentSource === 'code') {
403
+ content += '{black-fg}{green-bg} Code {/green-bg}{/black-fg} ';
404
+ content += '{gray-fg}IDE{/gray-fg} ';
405
+ }
406
+ else {
407
+ content += '{gray-fg}Code{/gray-fg} ';
408
+ content += '{black-fg}{green-bg} IDE {/green-bg}{/black-fg} ';
409
+ }
410
+ content += '{gray-fg}Views:{/gray-fg} ';
377
411
  for (let i = 0; i < tabs.length; i++) {
378
412
  if (i === currentTab) {
379
413
  content += `{black-fg}{green-bg} ${tabs[i]} {/green-bg}{/black-fg} `;
@@ -382,31 +416,39 @@ async function main() {
382
416
  content += `{gray-fg}${tabs[i]}{/gray-fg} `;
383
417
  }
384
418
  }
385
- content += ' {gray-fg}(Tab to switch){/gray-fg}';
419
+ content += ' {gray-fg}(Tab view, s source){/gray-fg}';
386
420
  tabBar.setContent(content);
387
421
  }
388
422
  // 更新内容
389
423
  function updateContent() {
390
424
  const width = Number(screen.width) || 80;
425
+ const note = currentSource === 'code'
426
+ ? `针对 CodeBuddy Code ≤ 2.20.0 版本产生的数据,由于没有请求级别的 model ID,用量是基于当前 CodeBuddy Code 设置的 model ID(${data.defaultModelId})计算价格的`
427
+ : 'IDE 的 usage 不包含缓存命中/写入 tokens,无法计算缓存相关价格与命中率;成本按 input/output tokens 估算';
391
428
  switch (currentTab) {
392
429
  case 0:
393
- renderOverview(contentBox, data, width);
430
+ renderOverview(contentBox, data, width, note);
394
431
  break;
395
432
  case 1:
396
- renderByModel(contentBox, data, width);
433
+ renderByModel(contentBox, data, width, note);
397
434
  break;
398
435
  case 2:
399
- renderByProject(contentBox, data, width);
436
+ renderByProject(contentBox, data, width, note);
400
437
  break;
401
438
  case 3:
402
- renderDaily(contentBox, data, dailyScrollOffset, width);
439
+ renderDaily(contentBox, data, dailyScrollOffset, width, note);
403
440
  break;
404
441
  }
405
442
  }
406
443
  // 更新状态栏
407
444
  function updateStatusBar() {
408
445
  const daysInfo = options.days ? `Last ${options.days} days` : 'All time';
409
- statusBar.setContent(` ${daysInfo} | Total: ${formatCost(data.grandTotal.cost)} | q quit, Tab switch, r refresh`);
446
+ const sourceInfo = currentSource === 'code' ? 'Code' : 'IDE';
447
+ const leftContent = ` ${daysInfo} | Source: ${sourceInfo} | Total: ${formatCost(data.grandTotal.cost)} | q quit, Tab view, s source, r refresh`;
448
+ const rightContent = `v${VERSION} `;
449
+ const width = Number(screen.width) || 80;
450
+ const padding = Math.max(0, width - leftContent.length - rightContent.length);
451
+ statusBar.setContent(leftContent + ' '.repeat(padding) + rightContent);
410
452
  }
411
453
  // 键盘事件
412
454
  screen.key(['tab'], () => {
@@ -446,7 +488,7 @@ async function main() {
446
488
  statusBar.setContent(' {yellow-fg}Reloading...{/yellow-fg}');
447
489
  screen.render();
448
490
  try {
449
- data = await loadUsageData({ days: options.days });
491
+ data = await loadUsageData({ days: options.days, source: currentSource });
450
492
  dailyScrollOffset = 0;
451
493
  updateTabBar();
452
494
  updateContent();
@@ -457,6 +499,22 @@ async function main() {
457
499
  }
458
500
  screen.render();
459
501
  });
502
+ screen.key(['s'], async () => {
503
+ statusBar.setContent(' {yellow-fg}Switching source...{/yellow-fg}');
504
+ screen.render();
505
+ try {
506
+ currentSource = currentSource === 'code' ? 'ide' : 'code';
507
+ data = await loadUsageData({ days: options.days, source: currentSource });
508
+ dailyScrollOffset = 0;
509
+ updateTabBar();
510
+ updateContent();
511
+ updateStatusBar();
512
+ }
513
+ catch (err) {
514
+ statusBar.setContent(` {red-fg}Switch source failed: ${String(err)}{/red-fg}`);
515
+ }
516
+ screen.render();
517
+ });
460
518
  // 监听窗口大小变化
461
519
  screen.on('resize', () => {
462
520
  updateContent();
@@ -472,4 +530,3 @@ main().catch(err => {
472
530
  console.error('Error:', err);
473
531
  process.exit(1);
474
532
  });
475
- //# sourceMappingURL=index.js.map