diff --git a/README.md b/README.md index e6d4d3d..00848d3 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,15 @@ cp claudeplus/commands/* ~/.claude/commands/ 重启 Claude Code 后即可使用。 +## 模块列表 + +| 模块 | 路径 | 说明 | +|------|------|------| +| 命令集 | `commands/` | Claude Code 自定义斜杠命令 | +| Stop Hook | `hooks/token-stats-hook/` | 会话结束时自动备份 token 用量(Go 二进制) | + +--- + ## 命令列表 ### /token-stats [username|all] diff --git a/hooks/token-stats-hook/README.md b/hooks/token-stats-hook/README.md new file mode 100644 index 0000000..351ad41 --- /dev/null +++ b/hooks/token-stats-hook/README.md @@ -0,0 +1,39 @@ +# token-stats-hook + +Claude Code Stop hook:每次会话结束时自动将 token 用量快照追加到 `~/.claude/token-stats-backup.jsonl`。 + +用 Go 编写,编译为二进制,启动时间 <5ms。 + +## 安装 + +```bash +cd hooks/token-stats-hook +go build -o ~/.claude/token-stats-hook-bin . +``` + +然后在 `~/.claude/settings.json` 中加入: + +```json +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "~/.claude/token-stats-hook-bin 2>/dev/null || true" + } + ] + } + ] + } +} +``` + +## 备份格式 + +每行一条 JSON 记录: + +```json +{"timestamp":"2026-04-18T02:03:11Z","date":"2026-04-18","user":"charles","input":12861,"output":176499,"cache_write":1352380,"cache_read":15525643,"actual":189360} +``` diff --git a/hooks/token-stats-hook/go.mod b/hooks/token-stats-hook/go.mod new file mode 100644 index 0000000..6733ccb --- /dev/null +++ b/hooks/token-stats-hook/go.mod @@ -0,0 +1,3 @@ +module token-stats-hook + +go 1.18 diff --git a/hooks/token-stats-hook/main.go b/hooks/token-stats-hook/main.go new file mode 100644 index 0000000..0f4b041 --- /dev/null +++ b/hooks/token-stats-hook/main.go @@ -0,0 +1,92 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" +) + +type usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` +} + +type message struct { + Usage usage `json:"usage"` +} + +type entry struct { + Message message `json:"message"` +} + +type record struct { + Timestamp string `json:"timestamp"` + Date string `json:"date"` + User string `json:"user"` + Input int `json:"input"` + Output int `json:"output"` + CacheWrite int `json:"cache_write"` + CacheRead int `json:"cache_read"` + Actual int `json:"actual"` +} + +func main() { + home, _ := os.UserHomeDir() + user := os.Getenv("USER") + if user == "" { + user = filepath.Base(home) + } + + var total record + pattern := filepath.Join(home, ".claude", "projects", "**", "*.jsonl") + matches, _ := filepath.Glob(pattern) + + // filepath.Glob doesn't support **, so walk manually + projectsDir := filepath.Join(home, ".claude", "projects") + filepath.Walk(projectsDir, func(path string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || filepath.Ext(path) != ".jsonl" { + return nil + } + _ = matches + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + scanner := bufio.NewScanner(f) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + for scanner.Scan() { + var e entry + if json.Unmarshal(scanner.Bytes(), &e) == nil { + u := e.Message.Usage + total.Input += u.InputTokens + total.Output += u.OutputTokens + total.CacheWrite += u.CacheCreationInputTokens + total.CacheRead += u.CacheReadInputTokens + } + } + return nil + }) + + now := time.Now().UTC() + total.Timestamp = now.Format(time.RFC3339) + total.Date = now.Format("2006-01-02") + total.User = user + total.Actual = total.Input + total.Output + + backup := filepath.Join(home, ".claude", "token-stats-backup.jsonl") + f, err := os.OpenFile(backup, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + os.Exit(1) + } + defer f.Close() + + line, _ := json.Marshal(total) + fmt.Fprintf(f, "%s\n", line) +}