claude-code-tracker 1.2.4 → 1.4.0-beta.3

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
@@ -13,6 +13,8 @@ After every session, it parses the transcript, updates a `tokens.json` ledger, r
13
13
  - **Per-model breakdown**: Opus vs Sonnet cost split
14
14
  - **Key prompts**: high-signal prompts you log manually, with category and context
15
15
  - **Prompt efficiency**: ratio of key prompts to total human messages
16
+ - **Per-agent cost breakdown**: SubagentStop hook captures each spawned agent's token usage
17
+ separately — see which agent types (architect, quick-fixer, Explore, etc.) drive the most cost
16
18
 
17
19
  All data lives in `<project>/.claude/tracking/` alongside your code.
18
20
 
@@ -26,7 +28,7 @@ All data lives in `<project>/.claude/tracking/` alongside your code.
26
28
  npm install -g claude-code-tracker
27
29
  ```
28
30
 
29
- The `postinstall` script copies the tracking scripts to `~/.claude/tracking/` and registers the Stop hook in `~/.claude/settings.json`.
31
+ The `postinstall` script copies the tracking scripts to `~/.claude/tracking/` and registers the Stop hook in `~/.claude/settings.json`. On Windows, the Node.js wrappers (`install.js`, `stop-hook.js`) delegate to bash automatically via Git Bash or WSL.
30
32
 
31
33
  ### Option 2 — Homebrew
32
34
 
@@ -68,7 +70,8 @@ On first use in a project, the Stop hook auto-initializes `<project>/.claude/tra
68
70
 
69
71
  ```
70
72
  .claude/tracking/
71
- tokens.json # session data (auto-updated)
73
+ tokens.json # main session data (auto-updated)
74
+ agents.json # per-agent invocation data (auto-updated)
72
75
  charts.html # Chart.js dashboard (auto-updated)
73
76
  key-prompts.md # index of logged prompts
74
77
  key-prompts/ # one .md per day
@@ -93,6 +96,22 @@ The dashboard shows cumulative cost, cost per day, sessions, output tokens, mode
93
96
 
94
97
  ---
95
98
 
99
+ ## Multi-agent tracking
100
+
101
+ When using Claude Code's Task tool to spawn background agents, each agent's cost is tracked
102
+ separately. The `SubagentStop` hook fires when each agent finishes and appends an entry to
103
+ `agents.json` with:
104
+ - `agent_type` — the subagent type (e.g. `architect`, `quick-fixer`, `Explore`, `Bash`)
105
+ - token counts summed across all internal turns
106
+ - `estimated_cost_usd` using the same list-price formula as main sessions
107
+
108
+ The dashboard shows an "Agents" section with cost and invocation count by agent type, letting
109
+ you identify which agent types are expensive relative to their output.
110
+
111
+ No configuration needed — the SubagentStop hook is registered automatically on install.
112
+
113
+ ---
114
+
96
115
  ## Cost CLI
97
116
 
98
117
  ```bash
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const { spawnSync } = require('child_process');
4
+ const path = require('path');
5
+
6
+ const scriptDir = path.dirname(path.dirname(path.resolve(__filename)));
7
+ const bashScript = path.join(scriptDir, 'bin', 'claude-tracker-cost.sh');
8
+
9
+ if (process.platform === 'win32') {
10
+ const result = spawnSync('bash', [bashScript], {
11
+ stdio: 'inherit',
12
+ shell: false,
13
+ });
14
+ process.exit(result.status || 0);
15
+ } else {
16
+ const result = spawnSync('bash', [bashScript], {
17
+ stdio: 'inherit',
18
+ });
19
+ process.exit(result.status || 0);
20
+ }
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ PREFIX="$(brew --prefix claude-code-tracker 2>/dev/null)" || {
5
+ echo "Error: claude-code-tracker not found via Homebrew." >&2
6
+ echo "If you installed via npm or git clone, run install.sh directly." >&2
7
+ exit 1
8
+ }
9
+
10
+ exec "$PREFIX/libexec/install.sh"
package/install.js ADDED
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const { spawnSync } = require('child_process');
4
+ const path = require('path');
5
+ const fs = require('fs');
6
+
7
+ const scriptDir = path.dirname(path.resolve(__filename));
8
+ const bashScript = path.join(scriptDir, 'install.sh');
9
+
10
+ if (process.platform === 'win32') {
11
+ const result = spawnSync('bash', [bashScript, ...process.argv.slice(2)], {
12
+ stdio: 'inherit',
13
+ shell: false,
14
+ });
15
+ process.exit(result.status || 0);
16
+ } else {
17
+ const result = spawnSync('bash', [bashScript, ...process.argv.slice(2)], {
18
+ stdio: 'inherit',
19
+ });
20
+ process.exit(result.status || 0);
21
+ }
package/install.sh CHANGED
@@ -36,6 +36,8 @@ else
36
36
  echo "Scripts installed to $INSTALL_DIR"
37
37
  fi
38
38
 
39
+ SUBAGENT_HOOK_CMD="${HOOK_CMD/stop-hook.sh/subagent-stop-hook.sh}"
40
+
39
41
  # Install skills to ~/.claude/skills/
40
42
  if [[ -d "$SCRIPT_DIR/skills" ]]; then
41
43
  for skill_dir in "$SCRIPT_DIR/skills"/*/; do
@@ -47,11 +49,12 @@ if [[ -d "$SCRIPT_DIR/skills" ]]; then
47
49
  fi
48
50
 
49
51
  # Patch settings.json — add Stop hook if not already present
50
- python3 - "$SETTINGS" "$HOOK_CMD" <<'PYEOF'
52
+ python3 - "$SETTINGS" "$HOOK_CMD" "$SUBAGENT_HOOK_CMD" <<'PYEOF'
51
53
  import sys, json, os
52
54
 
53
55
  settings_file = sys.argv[1]
54
56
  hook_cmd = sys.argv[2]
57
+ subagent_hook_cmd = sys.argv[3]
55
58
 
56
59
  data = {}
57
60
  if os.path.exists(settings_file):
@@ -81,12 +84,23 @@ session_hooks[:] = [
81
84
  ]
82
85
  session_hooks.append({"hooks": [{"type": "command", "command": backfill_cmd, "timeout": 60, "async": True}]})
83
86
 
87
+ # SubagentStop hook
88
+ subagent_entry = {"type": "command", "command": subagent_hook_cmd, "timeout": 30, "async": True}
89
+ subagent_hooks = hooks.setdefault("SubagentStop", [])
90
+ subagent_hooks[:] = [
91
+ g for g in subagent_hooks
92
+ if not any("subagent-stop-hook.sh" in h.get("command", "") for h in g.get("hooks", []))
93
+ ]
94
+ subagent_hooks.append({"hooks": [subagent_entry]})
95
+
84
96
  # permissions.allow — clean old entries and add current
85
97
  allow_entry = f"Bash({hook_cmd}*)"
98
+ subagent_allow = f"Bash({subagent_hook_cmd}*)"
86
99
  perms = data.setdefault("permissions", {})
87
100
  allow_list = perms.setdefault("allow", [])
88
- allow_list[:] = [e for e in allow_list if "stop-hook.sh" not in e]
101
+ allow_list[:] = [e for e in allow_list if "stop-hook.sh" not in e and "subagent-stop-hook.sh" not in e]
89
102
  allow_list.append(allow_entry)
103
+ allow_list.append(subagent_allow)
90
104
 
91
105
  os.makedirs(os.path.dirname(os.path.abspath(settings_file)), exist_ok=True)
92
106
  with open(settings_file, 'w') as f:
@@ -121,3 +135,13 @@ if [[ "$SCRIPT_DIR" != */Cellar/* ]]; then
121
135
  fi
122
136
 
123
137
  echo "claude-code-tracker installed. Restart Claude Code to activate."
138
+
139
+ # Warn if .claude/ is not covered by .gitignore
140
+ if [[ -f ".gitignore" ]]; then
141
+ if ! grep -q '\.claude/' .gitignore && ! grep -q '^\.claude$' .gitignore; then
142
+ echo ""
143
+ echo "WARNING: .claude/ does not appear to be in your .gitignore."
144
+ echo "Tracking data (tokens, agent costs, session history) will be committed to git."
145
+ echo "Add '.claude/' or '.claude/tracking/' to .gitignore to prevent this."
146
+ fi
147
+ fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-tracker",
3
- "version": "1.2.4",
3
+ "version": "1.4.0-beta.3",
4
4
  "description": "Automatic token, cost, and prompt tracking for Claude Code sessions",
5
5
  "keywords": [
6
6
  "claude",
@@ -16,18 +16,22 @@
16
16
  "url": "git+https://github.com/kelsi-andrewss/claude-code-tracker.git"
17
17
  },
18
18
  "scripts": {
19
- "postinstall": "bash ./install.sh"
19
+ "postinstall": "node ./install.js"
20
20
  },
21
21
  "bin": {
22
- "claude-tracker-cost": "src/cost-summary.py"
22
+ "claude-tracker-cost": "bin/claude-tracker-cost.js"
23
23
  },
24
24
  "engines": {
25
25
  "node": ">=14"
26
26
  },
27
27
  "files": [
28
28
  "src/",
29
+ "bin/",
30
+ "skills/",
29
31
  "install.sh",
32
+ "install.js",
30
33
  "uninstall.sh",
34
+ "uninstall.js",
31
35
  "README.md"
32
36
  ]
33
37
  }
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: view-tracking
3
+ description: Open the charts dashboard and today's key-prompts file for the current project. Use when the user says "open charts", "view tracking", "show dashboard", or "/view-tracking".
4
+ ---
5
+
6
+ Open the tracking dashboard for the current project.
7
+
8
+ Steps:
9
+
10
+ 1. Set the tracking directory: `$CLAUDE_PROJECT_DIR/.claude/tracking/`
11
+
12
+ 2. Open the charts dashboard:
13
+ ```bash
14
+ if [[ "$OSTYPE" == darwin* ]]; then
15
+ open "$CLAUDE_PROJECT_DIR/.claude/tracking/charts.html"
16
+ elif [[ "$OSTYPE" == msys* || "$OSTYPE" == cygwin* || -n "${WINDIR:-}" ]]; then
17
+ start "" "$CLAUDE_PROJECT_DIR/.claude/tracking/charts.html"
18
+ else
19
+ xdg-open "$CLAUDE_PROJECT_DIR/.claude/tracking/charts.html" 2>/dev/null || echo "Could not open charts.html automatically. Path: $CLAUDE_PROJECT_DIR/.claude/tracking/charts.html"
20
+ fi
21
+ ```
22
+ If the file doesn't exist, report: "No charts.html found at $CLAUDE_PROJECT_DIR/.claude/tracking/charts.html"
23
+
24
+ 3. Find and open today's key-prompts file. Today's date is available from the system. The file path is:
25
+ `$CLAUDE_PROJECT_DIR/.claude/tracking/key-prompts/YYYY-MM-DD.md` (using today's date)
26
+
27
+ - If it exists:
28
+ ```bash
29
+ if [[ "$OSTYPE" == darwin* ]]; then
30
+ open "$CLAUDE_PROJECT_DIR/.claude/tracking/key-prompts/YYYY-MM-DD.md"
31
+ elif [[ "$OSTYPE" == msys* || "$OSTYPE" == cygwin* || -n "${WINDIR:-}" ]]; then
32
+ start "" "$CLAUDE_PROJECT_DIR/.claude/tracking/key-prompts/YYYY-MM-DD.md"
33
+ else
34
+ xdg-open "$CLAUDE_PROJECT_DIR/.claude/tracking/key-prompts/YYYY-MM-DD.md" 2>/dev/null || echo "Could not open key-prompts file automatically. Path: $CLAUDE_PROJECT_DIR/.claude/tracking/key-prompts/YYYY-MM-DD.md"
35
+ fi
36
+ ```
37
+ - If it doesn't exist: report "No key-prompts file for today yet." then list the most recent file in that directory:
38
+ ```bash
39
+ ls -t "$CLAUDE_PROJECT_DIR/.claude/tracking/key-prompts/"*.md 2>/dev/null | head -1
40
+ ```
41
+ If a recent file exists, offer: "Most recent: <filename>" and ask if the user wants to open it. If they say yes:
42
+ ```bash
43
+ if [[ "$OSTYPE" == darwin* ]]; then
44
+ open "$CLAUDE_PROJECT_DIR/.claude/tracking/key-prompts/YYYY-MM-DD.md"
45
+ elif [[ "$OSTYPE" == msys* || "$OSTYPE" == cygwin* || -n "${WINDIR:-}" ]]; then
46
+ start "" "$CLAUDE_PROJECT_DIR/.claude/tracking/key-prompts/YYYY-MM-DD.md"
47
+ else
48
+ xdg-open "$CLAUDE_PROJECT_DIR/.claude/tracking/key-prompts/YYYY-MM-DD.md" 2>/dev/null || echo "Could not open key-prompts file automatically. Path: $CLAUDE_PROJECT_DIR/.claude/tracking/key-prompts/YYYY-MM-DD.md"
49
+ fi
50
+ ```
51
+
52
+ 4. If the entire `.claude/tracking/` directory doesn't exist, report: "No tracking directory found for this project. Expected: $CLAUDE_PROJECT_DIR/.claude/tracking/"
53
+
54
+ Run the bash commands using the Bash tool. Do not ask for confirmation before opening files.
package/src/backfill.py CHANGED
@@ -1,55 +1,33 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- Backfill historical Claude Code sessions into tokens.json.
3
+ Backfill historical Claude Code sessions into tracking.db.
4
4
 
5
5
  Usage:
6
6
  python3 backfill.py <project_root>
7
7
 
8
8
  Scans ~/.claude/projects/<slug>/*.jsonl for transcripts belonging to the
9
9
  given project, parses token usage from each turn, and upserts entries to
10
- <project_root>/.claude/tracking/tokens.json. Sessions where all turns are
10
+ <project_root>/.claude/tracking/tracking.db. Sessions where all turns are
11
11
  already present are skipped.
12
-
13
- Old-format entries (no turn_index field) are replaced with per-turn entries.
14
12
  """
15
13
  import sys, json, os, glob
16
14
  from datetime import datetime
15
+ from platform_utils import get_transcripts_dir, slugify_path
16
+ from cost import compute_cost
17
+ import storage
17
18
 
18
19
  project_root = os.path.abspath(sys.argv[1])
19
20
  project_name = os.path.basename(project_root)
20
21
  tracking_dir = os.path.join(project_root, ".claude", "tracking")
21
- tokens_file = os.path.join(tracking_dir, "tokens.json")
22
22
 
23
23
  # Claude Code slugifies project paths: replace "/" with "-"
24
- slug = project_root.replace("/", "-")
25
- transcripts_dir = os.path.expanduser("~/.claude/projects/" + slug)
24
+ slug = slugify_path(project_root)
25
+ transcripts_dir = os.path.join(get_transcripts_dir(), slug)
26
26
 
27
27
  if not os.path.isdir(transcripts_dir):
28
28
  print("No transcript directory found, nothing to backfill.")
29
29
  sys.exit(0)
30
30
 
31
- # Load existing data
32
- data = []
33
- if os.path.exists(tokens_file):
34
- try:
35
- with open(tokens_file) as f:
36
- data = json.load(f)
37
- except Exception:
38
- data = []
39
-
40
- # Remove old-format entries (no turn_index) — they will be re-processed
41
- old_sessions = {e.get("session_id") for e in data if "turn_index" not in e}
42
- data = [e for e in data if "turn_index" in e]
43
-
44
- # Build index of existing (session_id, turn_index) pairs
45
- existing_turns = {(e.get("session_id"), e.get("turn_index")) for e in data}
46
-
47
- # Count turns per known session
48
- turns_per_session = {}
49
- for e in data:
50
- sid = e.get("session_id")
51
- turns_per_session[sid] = turns_per_session.get(sid, 0) + 1
52
-
53
31
  def parse_turns(jf):
54
32
  """Parse a JSONL transcript into per-turn entries. Returns list of dicts."""
55
33
  msgs = [] # (role, timestamp)
@@ -58,7 +36,7 @@ def parse_turns(jf):
58
36
  first_ts = None
59
37
 
60
38
  try:
61
- with open(jf) as f:
39
+ with open(jf, encoding='utf-8') as f:
62
40
  for line in f:
63
41
  try:
64
42
  obj = json.loads(line)
@@ -136,10 +114,7 @@ def compute_turns(msgs, usages, first_ts, model, session_id, project_name):
136
114
  except Exception:
137
115
  pass
138
116
 
139
- if "opus" in model:
140
- cost = inp * 15 / 1e6 + cache_create * 18.75 / 1e6 + cache_read * 1.50 / 1e6 + out * 75 / 1e6
141
- else:
142
- cost = inp * 3 / 1e6 + cache_create * 3.75 / 1e6 + cache_read * 0.30 / 1e6 + out * 15 / 1e6
117
+ cost = compute_cost(inp, out, cache_create, cache_read, model)
143
118
 
144
119
  # Turn timestamp = user message timestamp
145
120
  turn_ts = user_ts
@@ -200,33 +175,38 @@ for jf in jsonl_files:
200
175
  continue
201
176
 
202
177
  expected_count = len(turn_entries)
203
- existing_count = turns_per_session.get(session_id, 0)
178
+ existing_count = storage.count_turns_for_session(tracking_dir, session_id)
204
179
 
205
- # If all turns already present and session not in old-format set, skip
206
- if existing_count >= expected_count and session_id not in old_sessions:
180
+ if existing_count >= expected_count:
207
181
  continue
208
182
 
209
- # Upsert: replace any existing turns for this session with fresh data
210
- data = [e for e in data if e.get("session_id") != session_id]
211
- data.extend(turn_entries)
183
+ # Replace all turns for this session with fresh data
184
+ storage.replace_session_turns(tracking_dir, session_id, turn_entries)
212
185
  new_entries.extend(turn_entries)
213
186
  sessions_processed += 1
214
187
 
215
- # Sort by (date, session_id, turn_index)
216
- data.sort(key=lambda x: (x.get("date", ""), x.get("session_id", ""), x.get("turn_index", 0)))
217
-
218
- # Write updated tokens.json
219
- if new_entries:
220
- os.makedirs(os.path.dirname(tokens_file), exist_ok=True)
221
- with open(tokens_file, "w") as f:
222
- json.dump(data, f, indent=2)
223
- f.write("\n")
224
-
225
188
  total_turns = len(new_entries)
226
189
  print(f"{sessions_processed} session{'s' if sessions_processed != 1 else ''} processed, {total_turns} turn{'s' if total_turns != 1 else ''} written.")
227
190
 
191
+ # Backfill friction events from the same transcripts
192
+ from parse_friction import parse_friction, upsert_friction
193
+
194
+ friction_file = os.path.join(tracking_dir, "friction.json")
195
+ friction_count = 0
196
+ for jf in jsonl_files:
197
+ session_id = os.path.splitext(os.path.basename(jf))[0]
198
+ try:
199
+ events = parse_friction(jf, session_id, project_name, "main")
200
+ upsert_friction(friction_file, session_id, events)
201
+ friction_count += len(events)
202
+ except Exception:
203
+ pass
204
+
205
+ if friction_count:
206
+ print(f"{friction_count} friction event{'s' if friction_count != 1 else ''} backfilled.")
207
+
228
208
  # Regenerate charts if we added anything
229
- if new_entries:
209
+ if new_entries or friction_count:
230
210
  script_dir = os.path.dirname(os.path.abspath(__file__))
231
211
  charts_html = os.path.join(tracking_dir, "charts.html")
232
- os.system(f'python3 "{script_dir}/generate-charts.py" "{tokens_file}" "{charts_html}" 2>/dev/null')
212
+ os.system(f'python3 "{script_dir}/generate-charts.py" "{tracking_dir}" "{charts_html}" 2>/dev/null')
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
3
  Usage:
4
- python3 cost-summary.py <tokens.json>
5
- python3 cost-summary.py (defaults to .claude/tracking/tokens.json in cwd's git root)
6
- python3 cost-summary.py --chart (open tracking charts in browser)
4
+ python3 cost-summary.py (auto-discover tracking dir)
5
+ python3 cost-summary.py /path/to/.claude/tracking (explicit tracking dir)
6
+ python3 cost-summary.py /path/to/tokens.json (legacy compat)
7
+ python3 cost-summary.py --chart (open tracking charts in browser)
7
8
  """
8
9
  import sys
9
10
  import json
@@ -12,6 +13,10 @@ import webbrowser
12
13
  from collections import defaultdict
13
14
  from datetime import date
14
15
 
16
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
17
+ sys.path.insert(0, SCRIPT_DIR)
18
+ import storage
19
+
15
20
  def find_git_root():
16
21
  root = os.getcwd()
17
22
  while root != "/":
@@ -20,12 +25,12 @@ def find_git_root():
20
25
  root = os.path.dirname(root)
21
26
  return root
22
27
 
23
- def find_tokens_file():
28
+ def find_tracking_dir():
24
29
  root = find_git_root()
25
- path = os.path.join(root, ".claude", "tracking", "tokens.json")
26
- if os.path.exists(path):
30
+ path = os.path.join(root, ".claude", "tracking")
31
+ if os.path.isdir(path):
27
32
  return path
28
- sys.exit(f"No tokens.json found at {path}")
33
+ sys.exit(f"No tracking directory found at {path}")
29
34
 
30
35
  def format_duration(seconds):
31
36
  if seconds <= 0:
@@ -44,10 +49,16 @@ if "--chart" in sys.argv:
44
49
  webbrowser.open(f"file://{chart}")
45
50
  sys.exit(0)
46
51
 
47
- tokens_file = sys.argv[1] if len(sys.argv) > 1 else find_tokens_file()
52
+ # Backward compat: accept tokens.json path or tracking dir
53
+ arg = sys.argv[1] if len(sys.argv) > 1 and sys.argv[1] != "--chart" else None
54
+ if arg and arg.endswith('.json'):
55
+ tracking_dir = os.path.dirname(os.path.abspath(arg))
56
+ elif arg:
57
+ tracking_dir = os.path.abspath(arg)
58
+ else:
59
+ tracking_dir = find_tracking_dir()
48
60
 
49
- with open(tokens_file) as f:
50
- data = json.load(f)
61
+ data = storage.get_all_turns(tracking_dir)
51
62
 
52
63
  if not data:
53
64
  print("No sessions recorded yet.")
@@ -89,7 +100,8 @@ total_input = sum(e.get("input_tokens", 0) for e in data)
89
100
  # --- Print ---
90
101
  W = 60
91
102
  print("=" * W)
92
- print(f" Cost Summary — {os.path.basename(os.path.dirname(os.path.dirname(tokens_file)))}")
103
+ project_name = os.path.basename(os.path.dirname(os.path.dirname(tracking_dir)))
104
+ print(f" Cost Summary — {project_name}")
93
105
  print("=" * W)
94
106
 
95
107
  print(f"\nBy date:")
@@ -125,4 +137,29 @@ days = len(by_date)
125
137
  if days > 1:
126
138
  print(f"\n Avg cost/day: ${total_cost/days:>11.2f} over {days} days")
127
139
 
140
+ # --- Friction summary ---
141
+ friction_file = os.path.join(tracking_dir, "friction.json")
142
+ if os.path.exists(friction_file):
143
+ try:
144
+ with open(friction_file, encoding='utf-8') as f:
145
+ friction_data = json.load(f)
146
+ if friction_data:
147
+ print(f"\nFriction:")
148
+ friction_total = len(friction_data)
149
+ cat_counts = defaultdict(int)
150
+ tool_counts = defaultdict(int)
151
+ for fe in friction_data:
152
+ cat_counts[fe.get('category', 'unknown')] += 1
153
+ tn = fe.get('tool_name')
154
+ if tn:
155
+ tool_counts[tn] += 1
156
+ top_cat = max(cat_counts, key=cat_counts.get)
157
+ print(f" Total events: {friction_total:>8}")
158
+ print(f" Top category: {top_cat:>8} ({cat_counts[top_cat]} events)")
159
+ if tool_counts:
160
+ top_tool = max(tool_counts, key=tool_counts.get)
161
+ print(f" Top tool: {top_tool:>8} ({tool_counts[top_tool]} events)")
162
+ except Exception:
163
+ pass
164
+
128
165
  print("=" * W)
package/src/cost.py ADDED
@@ -0,0 +1,7 @@
1
+ def compute_cost(input_tokens, output_tokens, cache_creation, cache_read, model):
2
+ """Compute estimated API list-price cost in USD."""
3
+ if 'opus' in model:
4
+ return (input_tokens * 15 / 1e6 + cache_creation * 18.75 / 1e6 +
5
+ cache_read * 1.50 / 1e6 + output_tokens * 75 / 1e6)
6
+ return (input_tokens * 3 / 1e6 + cache_creation * 3.75 / 1e6 +
7
+ cache_read * 0.30 / 1e6 + output_tokens * 15 / 1e6)
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env python3
2
+ """Export tracking.db to JSON files for portability.
3
+
4
+ Usage: python3 export-json.py [<tracking_dir>]
5
+ Defaults to .claude/tracking/ in the current git root.
6
+ """
7
+ import sys, os
8
+
9
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
10
+ sys.path.insert(0, SCRIPT_DIR)
11
+ import storage
12
+
13
+ def find_tracking_dir():
14
+ root = os.getcwd()
15
+ while root != "/":
16
+ if os.path.exists(os.path.join(root, ".git")):
17
+ return os.path.join(root, ".claude", "tracking")
18
+ root = os.path.dirname(root)
19
+ return os.path.join(os.getcwd(), ".claude", "tracking")
20
+
21
+ tracking_dir = sys.argv[1] if len(sys.argv) > 1 else find_tracking_dir()
22
+
23
+ if not os.path.exists(os.path.join(tracking_dir, "tracking.db")):
24
+ sys.exit(f"No tracking.db found in {tracking_dir}")
25
+
26
+ storage.export_json(tracking_dir)
27
+ print(f"Exported to {tracking_dir}/tokens.json and agents.json")