ccstats 0.4.0

Fast token and cost usage statistics CLI for Claude Code, OpenAI Codex, Cursor, Grok, and Kimi Code
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
# ccstats 架构文档

## 概述

ccstats 是一个快速的 CLI 工具,用于分析多种 AI CLI 工具的 token 使用统计。采用插件架构,便于添加新的数据源。

## 目录结构

```
src/
├── lib.rs                  # library crate, SDK exports, CLI runtime dispatch
├── cli/                    # 命令行接口
│   ├── args.rs            # CLI 参数定义
│   ├── commands.rs        # 子命令定义
│   └── mod.rs
├── core/                   # 核心共享逻辑
│   ├── types.rs           # 统一数据类型
│   ├── dedup.rs           # 去重算法
│   ├── aggregator.rs      # 聚合函数
│   └── mod.rs
├── source/                 # 数据源插件
│   ├── claude/            # Claude Code 数据源
│   │   ├── config.rs      # Source trait 实现
│   │   ├── parser.rs      # JSONL 解析逻辑
│   │   └── mod.rs
│   ├── codex/             # OpenAI Codex 数据源
│   │   ├── config.rs      # Source trait 实现
│   │   ├── parser.rs      # JSONL 解析逻辑
│   │   └── mod.rs
│   ├── cursor/            # Cursor 数据源
│   │   ├── config.rs      # Source trait 实现
│   │   ├── parser.rs      # SQLite 解析逻辑
│   │   └── mod.rs
│   ├── loader.rs          # 统一数据加载器
│   ├── registry.rs        # 数据源注册表
│   └── mod.rs             # Source trait 定义
├── output/                 # 输出格式化
├── pricing/                # 价格计算
├── sdk.rs                  # public Rust SDK: summarize_cost and DTO types
├── utils/                  # 工具函数
└── main.rs                 # CLI binary thin entry point
```

## 核心概念

### Source Trait

所有数据源必须实现 `Source` trait:

```rust
pub trait Source: Send + Sync {
    /// 数据源名称 (用于 CLI 和注册表)
    fn name(&self) -> &'static str;

    /// 显示名称 (用于用户输出)
    fn display_name(&self) -> &'static str;

    /// 别名列表 (例如 "cc" 是 "claude" 的别名)
    fn aliases(&self) -> &'static [&'static str];

    /// 数据源能力声明
    fn capabilities(&self) -> Capabilities;

    /// 发现所有数据文件
    fn find_files(&self) -> Vec<PathBuf>;

    /// 解析单个文件,返回统一记录和解析错误统计
    fn parse_file(&self, path: &Path, timezone: Timezone, debug: bool) -> ParseOutput;
}
```

### Capabilities (能力声明)

```rust
pub struct Capabilities {
    /// 是否支持项目聚合
    pub has_projects: bool,

    /// 是否支持 5 小时计费块
    pub has_billing_blocks: bool,

    /// 是否有推理 token (如 o1 模型)
    pub has_reasoning_tokens: bool,

    /// 是否需要去重 (流式响应)
    pub needs_dedup: bool,
}
```

### RawEntry (统一数据结构)

所有数据源将其原生格式转换为统一的 `RawEntry`:

```rust
pub struct RawEntry {
    pub timestamp: String,      // UTC 时间戳
    pub timestamp_ms: i64,      // 毫秒时间戳 (用于排序)
    pub date_str: String,       // 本地日期 (YYYY-MM-DD)
    pub message_id: Option<String>,  // 消息 ID (用于去重)
    pub session_id: String,     // 会话 ID
    pub project_path: String,   // 项目路径 (可为空)
    pub model: String,          // 模型名称
    pub input_tokens: i64,      // 输入 token
    pub output_tokens: i64,     // 输出 token
    pub cache_creation: i64,    // 缓存创建 token
    pub cache_read: i64,        // 缓存读取 token
    pub reasoning_tokens: i64,  // 推理 token
    pub stop_reason: Option<String>,  // 停止原因 (用于去重)
}
```

## 数据流

```
┌─────────────────────────────────────────────────────────────────────┐
│                     CLI (main.rs) / SDK (lib.rs)                    │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│                      Source Registry                                 │
│                   (选择数据源: claude/codex)                          │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│                        DataLoader                                    │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐              │
│  │ File Discovery│  │   Parsing    │  │ Date Filter  │              │
│  │  find_files() │  │ parse_file() │  │  + timezone  │              │
│  └──────────────┘  └──────────────┘  └──────────────┘              │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│                    Deduplication (if needed)                         │
│              (保留有 stop_reason 的完整消息)                           │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│                         Aggregation                                  │
│  ┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐           │
│  │   Daily   │ │  Session  │ │  Project  │ │   Blocks  │           │
│  └───────────┘ └───────────┘ └───────────┘ └───────────┘           │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│                    Pricing + Output Formatting                       │
└─────────────────────────────────────────────────────────────────────┘
```

SDK 调用通过 `summarize_cost(SummaryOptions)` 复用同一套 source registry、loader、聚合和 pricing 逻辑,但返回结构化 `CostSummary`,不经过 table/JSON 输出层。需要和 CLI 持久化配置同口径时,SDK 使用 `summarize_cost_with_cli_config` 复用 timezone、offline pricing、strict pricing 和 currency 默认值。

### CLI 配置加载

CLI 启动时先读取可选的 TOML 配置,再合并命令行参数。命令行参数优先级高于配置文件。

配置搜索顺序:

1. `~/.config/ccstats/config.toml`
2. 平台配置目录,例如 macOS 的 `~/Library/Application Support/ccstats/config.toml`
3. `~/.ccstats.toml`

第一个存在的配置文件是权威配置。如果它无法读取、TOML 语法错误或字段类型错误,CLI 直接返回错误;normal、quiet/statusline 路径都不能回落默认值继续输出。只有所有路径都不存在时才使用 `Config::default()`。

配置键保持简单,且不定义 source 根目录路径:

|| 类型 | 取值 |
|----|------|------|
| `offline`, `compact`, `no_cost`, `no_color`, `breakdown`, `debug`, `strict_pricing` | boolean | `true` / `false` |
| `order` | string | `asc` / `desc` |
| `color` | string | `auto` / `always` / `never` |
| `cost` | string | `show` / `hide` |
| `timezone`, `locale`, `currency`, `source` | string | 对应 CLI 参数的字符串值 |

示例:

```toml
source = "codex"
timezone = "Asia/Shanghai"
currency = "USD"
offline = true
strict_pricing = true
compact = true
order = "desc"
color = "auto"
cost = "show"
```

Source 根目录仍由环境变量覆盖:

| Source | Env var | Value | Default |
|--------|---------|-------|---------|
| Claude Code | `CLAUDE_CONFIG_DIR` | Claude config root containing `projects/` | `~/.claude` |
| OpenAI Codex | `CODEX_HOME` | Codex root containing `sessions/` | `~/.codex` |
| Cursor | `CURSOR_HOME` | Cursor `User` directory | Platform Cursor `User` directory |
| Grok | `GROK_HOME` | Grok root containing `sessions/` | `~/.grok` |
| Kimi Code | `KIMI_CODE_HOME` | Kimi Code root containing `sessions/` | `~/.kimi-code` |

## 添加新数据源

### 1. 创建目录结构

```bash
mkdir -p src/source/newcli
touch src/source/newcli/{mod.rs,config.rs,parser.rs}
```

### 2. 实现 parser.rs

```rust
//! NewCLI JSONL parser

use crate::core::RawEntry;
use crate::utils::Timezone;
use std::path::PathBuf;

/// 发现数据文件
pub fn find_files() -> Vec<PathBuf> {
    let Some(home) = dirs::home_dir() else {
        return Vec::new();
    };

    let data_path = home.join(".newcli").join("sessions");
    let mut files = Vec::new();

    if let Ok(entries) = glob::glob(&format!("{}/**/*.jsonl", data_path.display())) {
        for entry in entries.flatten() {
            files.push(entry);
        }
    }
    files
}

/// 解析单个文件
pub fn parse_file(
    path: &PathBuf,
    timezone: &Timezone,
) -> Vec<RawEntry> {
    // 实现解析逻辑
    // 返回 Vec<RawEntry>
    Vec::new()
}
```

### 3. 实现 config.rs

```rust
//! NewCLI data source configuration

use std::path::PathBuf;
use crate::core::RawEntry;
use crate::source::{Capabilities, Source};
use crate::utils::Timezone;
use super::parser::{find_files, parse_file};

pub struct NewCliSource;

impl NewCliSource {
    pub fn new() -> Self { Self }
}

impl Default for NewCliSource {
    fn default() -> Self { Self::new() }
}

impl Source for NewCliSource {
    fn name(&self) -> &'static str { "newcli" }
    fn display_name(&self) -> &'static str { "NewCLI" }
    fn aliases(&self) -> &'static [&'static str] { &["nc"] }

    fn capabilities(&self) -> Capabilities {
        Capabilities {
            has_projects: false,
            has_billing_blocks: false,
            has_reasoning_tokens: false,
            needs_dedup: false,
        }
    }

    fn find_files(&self) -> Vec<PathBuf> { find_files() }

    fn parse_file(&self, path: &Path, timezone: Timezone, debug: bool) -> ParseOutput {
        parse_file_with_debug(path, timezone, debug)
    }
}
```

### 4. 更新 mod.rs

```rust
mod config;
mod parser;

pub use config::NewCliSource;
```

### 5. 注册数据源

在 `src/source/registry.rs` 中添加:

```rust
use super::newcli::NewCliSource;

static SOURCES: LazyLock<Vec<BoxedSource>> = LazyLock::new(|| {
    vec![
        Box::new(ClaudeSource::new()),
        Box::new(CodexSource::new()),
        Box::new(NewCliSource::new()),  // 添加这行
    ]
});
```

### 6. 添加 CLI 命令 (可选)

在 `src/cli/commands.rs` 中添加新的子命令。

## Claude Code 解析算法

```
输入: ~/.claude/projects/**/*.jsonl

每行格式:
{
  "timestamp": "2026-02-05T10:30:00Z",
  "message": {
    "id": "msg_xxx",
    "model": "claude-3-opus-20240229",
    "stop_reason": "end_turn",
    "usage": {
      "input_tokens": 1000,
      "output_tokens": 500,
      "cache_creation_input_tokens": 0,
      "cache_read_input_tokens": 200
    }
  }
}

处理步骤:
1. 文件发现: glob("~/.claude/projects/**/*.jsonl")
2. 并行解析每个文件
3. 提取 session_id (文件名), project_path (父目录名)
4. 规范化模型名称 (去除前缀和日期后缀)
5. 去重: 相同 message_id 保留有 stop_reason 的条目
6. 聚合: daily/session/project/blocks
```

## Codex CLI 解析算法

```
输入: ~/.codex/sessions/**/*.jsonl

每行格式 (token_count 事件):
{
  "timestamp": "2026-02-05T10:30:00Z",
  "type": "event_msg",
  "payload": {
    "type": "token_count",
    "info": {
      "total_token_usage": {
        "input_tokens": 5000,
        "cached_input_tokens": 1000,
        "output_tokens": 2000,
        "reasoning_output_tokens": 500,
        "total_tokens": 7000
      },
      "last_token_usage": { ... },  // 可选
      "model": "gpt-5.2"
    }
  }
}

处理步骤:
1. 文件发现: glob("~/.codex/sessions/**/*.jsonl")
2. 并行解析每个文件
3. 处理 turn_context 事件获取模型信息
4. 处理 event_msg + token_count 事件
5. Delta 计算:
   - 如果有 last_token_usage, 直接使用
   - 否则: delta = total - previous_total
6. 跳过 total 未变化的重复事件
7. input_tokens 包含 cached_input_tokens, 需要减去
8. 无需去重 (Codex 内部已处理)
```

## 定价缓存机制

```
缓存位置: ~/.cache/ccstats/pricing.json

策略:
1. 默认优先使用 24 小时内的本地定价缓存
2. 缓存过期后尝试从 LiteLLM 拉取最新定价并回写缓存
3. 拉取失败时回退到旧缓存
4. 无缓存时使用内置兜底价格
```

## 性能优化

- 并行文件解析 (rayon)
- 统一解析后过滤 (按日期与时区过滤入口)
- 延迟加载定价数据
- 定价缓存 (24h TTL)
- 流式 JSONL 解析