claude-code-tracker 1.0.0

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,169 @@
1
+ # claude-code-tracker
2
+
3
+ Automatic token usage, cost estimation, and prompt quality tracking for [Claude Code](https://claude.ai/claude-code) sessions.
4
+
5
+ After every session, it parses the transcript, updates a `tokens.json` ledger, regenerates a Chart.js dashboard (`charts.html`), and rebuilds the key-prompts index. Zero external dependencies — pure bash and Python stdlib.
6
+
7
+ ---
8
+
9
+ ## What it tracks
10
+
11
+ - **Token usage** per session: input, cache write, cache read, output
12
+ - **Estimated API cost** (list-price equivalent — not a subscription charge)
13
+ - **Per-model breakdown**: Opus vs Sonnet cost split
14
+ - **Key prompts**: high-signal prompts you log manually, with category and context
15
+ - **Prompt efficiency**: ratio of key prompts to total human messages
16
+
17
+ All data lives in `<project>/.claude/tracking/` alongside your code.
18
+
19
+ ---
20
+
21
+ ## Install
22
+
23
+ ### Option 1 — npm (global)
24
+
25
+ ```bash
26
+ npm install -g claude-code-tracker
27
+ ```
28
+
29
+ The `postinstall` script copies the tracking scripts to `~/.claude/tracking/` and registers the Stop hook in `~/.claude/settings.json`.
30
+
31
+ ### Option 2 — Homebrew
32
+
33
+ ```bash
34
+ brew tap ${YOUR_USER}/claude-code-tracker
35
+ brew install claude-code-tracker
36
+ ```
37
+
38
+ ### Option 3 — git clone
39
+
40
+ ```bash
41
+ git clone https://github.com/kelsi-andrewss/claude-code-tracker.git
42
+ cd claude-code-tracker
43
+ ./install.sh
44
+ ```
45
+
46
+ Restart Claude Code after any install method.
47
+
48
+ ---
49
+
50
+ ## What gets created
51
+
52
+ On first use in a project, the Stop hook auto-initializes `<project>/.claude/tracking/`:
53
+
54
+ ```
55
+ .claude/tracking/
56
+ tokens.json # session data (auto-updated)
57
+ charts.html # Chart.js dashboard (auto-updated)
58
+ key-prompts.md # index of logged prompts
59
+ key-prompts/ # one .md per day
60
+ 2026-02-18.md
61
+ cost-analysis.md # template for manual notes
62
+ ai-dev-log.md # template for dev log
63
+ sessions.md # session log template
64
+ ```
65
+
66
+ ---
67
+
68
+ ## View the dashboard
69
+
70
+ Open `charts.html` in a browser:
71
+
72
+ ```bash
73
+ open .claude/tracking/charts.html # macOS
74
+ xdg-open .claude/tracking/charts.html # Linux
75
+ ```
76
+
77
+ The dashboard shows cumulative cost, cost per day, sessions, output tokens, model breakdown, and prompt analytics — all updated automatically after each session.
78
+
79
+ ---
80
+
81
+ ## Cost CLI
82
+
83
+ ```bash
84
+ claude-tracker-cost
85
+ # or
86
+ python3 ~/.claude/tracking/cost-summary.py
87
+ ```
88
+
89
+ Prints a cost summary table for the current project.
90
+
91
+ ---
92
+
93
+ ## Logging key prompts
94
+
95
+ Add entries to `<project>/.claude/tracking/key-prompts/YYYY-MM-DD.md` (today's date). The index and charts update automatically on the next session end.
96
+
97
+ Entry format:
98
+
99
+ ```markdown
100
+ ## 2026-02-18 — Short title
101
+
102
+ **Category**: breakthrough | bug-resolution | architecture | feature
103
+ **Context**: What problem was being solved?
104
+ **The Prompt**: (exact or close paraphrase)
105
+ **Why It Worked**: (what made the phrasing/framing effective)
106
+ **Prior Attempts That Failed**: (for bugs: what didn't work; otherwise: N/A)
107
+ ```
108
+
109
+ ### Auto-logging with CLAUDE.md
110
+
111
+ Add the following to your project's `CLAUDE.md` to have Claude log prompts automatically:
112
+
113
+ ```markdown
114
+ ## Tracking
115
+ After completing significant work (bug fix, feature, refactor, plan approval), append a prompt
116
+ assessment entry to `<project>/.claude/tracking/key-prompts/YYYY-MM-DD.md` (today's date).
117
+ Create the file if it doesn't exist, using the same header format as existing files.
118
+
119
+ Use this format:
120
+ ## [date] — [short title]
121
+ **Category**: breakthrough | bug-resolution | architecture | feature
122
+ **Context**: What problem was being solved?
123
+ **The Prompt**: (exact or close paraphrase)
124
+ **Why It Worked**: (what made the phrasing/framing effective)
125
+ **Prior Attempts That Failed**: (for bugs: what didn't work; otherwise: N/A)
126
+
127
+ Only write entries for genuinely high-signal prompts. Skip routine exchanges.
128
+ Do not ask permission — just append after significant work.
129
+ ```
130
+
131
+ ---
132
+
133
+ ## Uninstall
134
+
135
+ ### npm
136
+ ```bash
137
+ npm uninstall -g claude-code-tracker
138
+ ~/.claude/tracking/uninstall.sh
139
+ ```
140
+
141
+ ### Homebrew
142
+ ```bash
143
+ brew uninstall claude-code-tracker
144
+ ```
145
+
146
+ ### Manual
147
+ ```bash
148
+ ./uninstall.sh
149
+ ```
150
+
151
+ The uninstaller removes the scripts from `~/.claude/tracking/` and removes the Stop hook from `~/.claude/settings.json`. Your project tracking data (tokens.json, charts.html, key-prompts/) is left intact.
152
+
153
+ ---
154
+
155
+ ## Cost note
156
+
157
+ Figures shown are **API list-price equivalents** — what pay-as-you-go API customers would be charged at current Anthropic pricing. If you are on a Max subscription, these are not amounts billed to you.
158
+
159
+ Current rates used:
160
+ | Model | Input | Cache write | Cache read | Output |
161
+ |-------|-------|-------------|------------|--------|
162
+ | Sonnet | $3/M | $3.75/M | $0.30/M | $15/M |
163
+ | Opus | $15/M | $18.75/M | $1.50/M | $75/M |
164
+
165
+ ---
166
+
167
+ ## License
168
+
169
+ MIT
package/install.sh ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ INSTALL_DIR="$HOME/.claude/tracking"
6
+ SETTINGS="$HOME/.claude/settings.json"
7
+
8
+ echo "Installing claude-code-tracker..."
9
+
10
+ # Copy scripts
11
+ mkdir -p "$INSTALL_DIR"
12
+ cp "$SCRIPT_DIR/src/"*.sh "$SCRIPT_DIR/src/"*.py "$INSTALL_DIR/"
13
+ chmod +x "$INSTALL_DIR/"*.sh "$INSTALL_DIR/"*.py
14
+
15
+ echo "Scripts installed to $INSTALL_DIR"
16
+
17
+ # Patch settings.json — add Stop hook if not already present
18
+ python3 - "$SETTINGS" "$INSTALL_DIR/stop-hook.sh" <<'PYEOF'
19
+ import sys, json, os
20
+
21
+ settings_file = sys.argv[1]
22
+ hook_cmd = sys.argv[2]
23
+
24
+ data = {}
25
+ if os.path.exists(settings_file):
26
+ try:
27
+ with open(settings_file) as f:
28
+ data = json.load(f)
29
+ except Exception:
30
+ data = {}
31
+
32
+ hook_entry = {"type": "command", "command": hook_cmd, "timeout": 30, "async": True}
33
+ hooks = data.setdefault("hooks", {})
34
+ stop_hooks = hooks.setdefault("Stop", [])
35
+
36
+ # Check if already registered
37
+ for group in stop_hooks:
38
+ for h in group.get("hooks", []):
39
+ if h.get("command") == hook_cmd:
40
+ print("Hook already registered.")
41
+ sys.exit(0)
42
+
43
+ stop_hooks.append({"hooks": [hook_entry]})
44
+
45
+ os.makedirs(os.path.dirname(os.path.abspath(settings_file)), exist_ok=True)
46
+ with open(settings_file, 'w') as f:
47
+ json.dump(data, f, indent=2)
48
+ f.write('\n')
49
+ print("Hook registered in", settings_file)
50
+ PYEOF
51
+
52
+ echo "claude-code-tracker installed. Restart Claude Code to activate."
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "claude-code-tracker",
3
+ "version": "1.0.0",
4
+ "description": "Automatic token, cost, and prompt tracking for Claude Code sessions",
5
+ "keywords": ["claude", "claude-code", "anthropic", "tracking", "cost", "tokens"],
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/kelsi-andrewss/claude-code-tracker"
10
+ },
11
+ "scripts": {
12
+ "postinstall": "bash ./install.sh"
13
+ },
14
+ "bin": {
15
+ "claude-tracker-cost": "./src/cost-summary.py"
16
+ },
17
+ "engines": {
18
+ "node": ">=14"
19
+ },
20
+ "files": [
21
+ "src/",
22
+ "install.sh",
23
+ "uninstall.sh",
24
+ "README.md"
25
+ ]
26
+ }
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env python3
2
+ """
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
+ """
7
+ import sys
8
+ import json
9
+ import os
10
+ from collections import defaultdict
11
+ from datetime import date
12
+
13
+ def find_tokens_file():
14
+ cwd = os.getcwd()
15
+ root = cwd
16
+ while root != "/":
17
+ if os.path.isdir(os.path.join(root, ".git")):
18
+ break
19
+ root = os.path.dirname(root)
20
+ path = os.path.join(root, ".claude", "tracking", "tokens.json")
21
+ if os.path.exists(path):
22
+ return path
23
+ sys.exit(f"No tokens.json found at {path}")
24
+
25
+ tokens_file = sys.argv[1] if len(sys.argv) > 1 else find_tokens_file()
26
+
27
+ with open(tokens_file) as f:
28
+ data = json.load(f)
29
+
30
+ if not data:
31
+ print("No sessions recorded yet.")
32
+ sys.exit(0)
33
+
34
+ # --- Aggregate ---
35
+ by_date = defaultdict(lambda: {"cost": 0, "sessions": 0, "output": 0, "cache_read": 0, "cache_create": 0, "input": 0})
36
+ by_model = defaultdict(lambda: {"cost": 0, "sessions": 0})
37
+ total_cost = 0
38
+ total_sessions = len(data)
39
+ sessions_with_tokens = 0
40
+
41
+ for e in data:
42
+ d = e.get("date", "unknown")
43
+ cost = e.get("estimated_cost_usd", 0)
44
+ model = e.get("model", "unknown")
45
+ short_model = model.split("-20")[0] if "-20" in model else model
46
+
47
+ by_date[d]["cost"] += cost
48
+ by_date[d]["sessions"] += 1
49
+ by_date[d]["output"] += e.get("output_tokens", 0)
50
+ by_date[d]["cache_read"] += e.get("cache_read_tokens", 0)
51
+ by_date[d]["cache_create"] += e.get("cache_creation_tokens", 0)
52
+ by_date[d]["input"] += e.get("input_tokens", 0)
53
+
54
+ by_model[short_model]["cost"] += cost
55
+ by_model[short_model]["sessions"] += 1
56
+
57
+ total_cost += cost
58
+ if e.get("total_tokens", 0) > 0:
59
+ sessions_with_tokens += 1
60
+
61
+ total_output = sum(e.get("output_tokens", 0) for e in data)
62
+ total_cache_read = sum(e.get("cache_read_tokens", 0) for e in data)
63
+ total_cache_create = sum(e.get("cache_creation_tokens", 0) for e in data)
64
+ total_input = sum(e.get("input_tokens", 0) for e in data)
65
+
66
+ # --- Print ---
67
+ W = 60
68
+ print("=" * W)
69
+ print(f" Cost Summary — {os.path.basename(os.path.dirname(os.path.dirname(tokens_file)))}")
70
+ print("=" * W)
71
+
72
+ print(f"\nBy date:")
73
+ print(f" {'Date':<12} {'Sessions':>8} {'Output':>10} {'Cache Read':>12} {'Cost':>10}")
74
+ print(f" {'-'*12} {'-'*8} {'-'*10} {'-'*12} {'-'*10}")
75
+ for d in sorted(by_date):
76
+ r = by_date[d]
77
+ print(f" {d:<12} {r['sessions']:>8} {r['output']:>10,} {r['cache_read']:>12,} ${r['cost']:>9.2f}")
78
+
79
+ print(f"\nBy model:")
80
+ print(f" {'Model':<30} {'Sessions':>8} {'Cost':>10}")
81
+ print(f" {'-'*30} {'-'*8} {'-'*10}")
82
+ for m in sorted(by_model, key=lambda x: -by_model[x]["cost"]):
83
+ r = by_model[m]
84
+ print(f" {m:<30} {r['sessions']:>8} ${r['cost']:>9.2f}")
85
+
86
+ print(f"\nTotals:")
87
+ print(f" Sessions: {total_sessions:>8} ({sessions_with_tokens} with token data)")
88
+ print(f" Input tokens: {total_input:>12,}")
89
+ print(f" Cache write: {total_cache_create:>12,}")
90
+ print(f" Cache read: {total_cache_read:>12,}")
91
+ print(f" Output tokens: {total_output:>12,}")
92
+ print(f" Estimated cost: ${total_cost:>11.2f}")
93
+
94
+ if total_output > 0:
95
+ cache_pct = total_cache_read / (total_input + total_cache_create + total_cache_read + total_output) * 100
96
+ print(f" Cache read share: {cache_pct:>10.1f}% of all tokens")
97
+
98
+ days = len(by_date)
99
+ if days > 1:
100
+ print(f"\n Avg cost/day: ${total_cost/days:>11.2f} over {days} days")
101
+
102
+ print("=" * W)
@@ -0,0 +1,497 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Generates tracking/charts.html from tokens.json + key-prompts/ folder.
4
+ Called by stop-hook.sh after each session update.
5
+
6
+ Usage: python3 generate-charts.py <tokens.json> <output.html>
7
+ """
8
+ import sys, json, os, re, glob
9
+ from collections import defaultdict
10
+
11
+ tokens_file = sys.argv[1]
12
+ output_file = sys.argv[2]
13
+
14
+ with open(tokens_file) as f:
15
+ data = json.load(f)
16
+
17
+ if not data:
18
+ sys.exit(0)
19
+
20
+ # --- Aggregate by date ---
21
+ by_date = defaultdict(lambda: {"cost": 0, "sessions": 0, "output": 0,
22
+ "cache_read": 0, "cache_create": 0, "input": 0,
23
+ "opus_cost": 0, "sonnet_cost": 0})
24
+ by_model = defaultdict(lambda: {"cost": 0, "sessions": 0})
25
+ cumulative = []
26
+
27
+ running_cost = 0
28
+ for e in sorted(data, key=lambda x: (x.get("date", ""), x.get("session_id", ""))):
29
+ d = e.get("date", "unknown")
30
+ cost = e.get("estimated_cost_usd", 0)
31
+ model = e.get("model", "unknown")
32
+ short = model.split("-20")[0] if "-20" in model else model
33
+
34
+ by_date[d]["cost"] += cost
35
+ by_date[d]["sessions"] += 1
36
+ by_date[d]["output"] += e.get("output_tokens", 0)
37
+ by_date[d]["cache_read"] += e.get("cache_read_tokens", 0)
38
+ by_date[d]["cache_create"] += e.get("cache_creation_tokens", 0)
39
+ by_date[d]["input"] += e.get("input_tokens", 0)
40
+ if "opus" in model:
41
+ by_date[d]["opus_cost"] += cost
42
+ else:
43
+ by_date[d]["sonnet_cost"] += cost
44
+
45
+ by_model[short]["cost"] += cost
46
+ by_model[short]["sessions"] += 1
47
+
48
+ running_cost += cost
49
+ cumulative.append({"date": d, "cumulative_cost": round(running_cost, 4),
50
+ "session_id": e.get("session_id", "")[:8]})
51
+
52
+ dates = sorted(by_date.keys())
53
+ total_cost = sum(e.get("estimated_cost_usd", 0) for e in data)
54
+ total_sessions = len(data)
55
+ sessions_with_data = sum(1 for e in data if e.get("total_tokens", 0) > 0)
56
+ total_output = sum(e.get("output_tokens", 0) for e in data)
57
+ total_cache_read = sum(e.get("cache_read_tokens", 0) for e in data)
58
+ total_all_tokens = sum(e.get("total_tokens", 0) for e in data)
59
+ cache_pct = round(total_cache_read / total_all_tokens * 100, 1) if total_all_tokens > 0 else 0
60
+
61
+ project_name = data[0].get("project", "Project") if data else "Project"
62
+
63
+ # --- Count total human messages per date from JSONL transcripts ---
64
+ project_dir = os.path.dirname(os.path.dirname(os.path.dirname(tokens_file))) # project root
65
+ # Claude Code slugifies paths as: replace every "/" with "-" (keeping leading slash → leading dash)
66
+ transcripts_dir = os.path.expanduser(
67
+ "~/.claude/projects/" + project_dir.replace("/", "-")
68
+ )
69
+ human_by_date = defaultdict(int)
70
+
71
+ if os.path.isdir(transcripts_dir):
72
+ for jf in glob.glob(os.path.join(transcripts_dir, "*.jsonl")):
73
+ # Use session date from tokens.json if available, else file mtime
74
+ sid = os.path.splitext(os.path.basename(jf))[0]
75
+ session_date = None
76
+ for e in data:
77
+ if e.get("session_id") == sid:
78
+ session_date = e.get("date")
79
+ break
80
+ if not session_date:
81
+ import datetime
82
+ session_date = datetime.datetime.fromtimestamp(
83
+ os.path.getmtime(jf)).strftime("%Y-%m-%d")
84
+
85
+ try:
86
+ with open(jf) as f:
87
+ for line in f:
88
+ try:
89
+ obj = json.loads(line)
90
+ # Human messages have type="user" and userType="human" at the top level
91
+ if obj.get("type") != "user":
92
+ continue
93
+ if obj.get("userType") not in ("human", "external", None):
94
+ continue
95
+ if obj.get("isSidechain"):
96
+ continue
97
+ content = obj.get("message", {}).get("content", "")
98
+ if isinstance(content, list):
99
+ # Skip pure tool-result messages
100
+ has_real_text = any(
101
+ isinstance(c, dict) and c.get("type") == "text"
102
+ and not str(c.get("text", "")).strip().startswith("<")
103
+ for c in content
104
+ )
105
+ if has_real_text:
106
+ human_by_date[session_date] += 1
107
+ elif isinstance(content, str):
108
+ text = content.strip()
109
+ # Skip slash commands and empty
110
+ if text and not text.startswith("<") and not text.startswith("/"):
111
+ human_by_date[session_date] += 1
112
+ except:
113
+ pass
114
+ except:
115
+ pass
116
+
117
+ total_human_msgs = sum(human_by_date.values())
118
+
119
+ # --- Aggregate prompt data from key-prompts/ folder ---
120
+ prompts_dir = os.path.join(os.path.dirname(tokens_file), "key-prompts")
121
+ prompt_files = sorted(glob.glob(os.path.join(prompts_dir, "????-??-??.md")))
122
+
123
+ prompt_by_date = {} # date -> {total, by_category}
124
+ all_categories = set()
125
+
126
+ for f in prompt_files:
127
+ date = os.path.splitext(os.path.basename(f))[0]
128
+ content = open(f).read()
129
+ cats = re.findall(r'^\*\*Category\*\*: (\S+)', content, re.MULTILINE)
130
+ by_cat = defaultdict(int)
131
+ for c in cats:
132
+ by_cat[c] += 1
133
+ all_categories.add(c)
134
+ prompt_by_date[date] = {"total": len(cats), "by_category": dict(by_cat)}
135
+
136
+ all_categories = sorted(all_categories)
137
+ prompt_dates = sorted(prompt_by_date.keys())
138
+ total_prompts = sum(v["total"] for v in prompt_by_date.values())
139
+
140
+ # Build JS data structures
141
+ dates_js = json.dumps(dates)
142
+ cost_by_date_js = json.dumps([round(by_date[d]["cost"], 4) for d in dates])
143
+ sessions_by_date_js = json.dumps([by_date[d]["sessions"] for d in dates])
144
+ output_by_date_js = json.dumps([by_date[d]["output"] for d in dates])
145
+ cache_read_by_date_js = json.dumps([by_date[d]["cache_read"] for d in dates])
146
+ opus_by_date_js = json.dumps([round(by_date[d]["opus_cost"], 4) for d in dates])
147
+ sonnet_by_date_js = json.dumps([round(by_date[d]["sonnet_cost"], 4) for d in dates])
148
+
149
+ cumul_labels_js = json.dumps([f"{c['date']} #{i+1}" for i, c in enumerate(cumulative)])
150
+ cumul_values_js = json.dumps([c["cumulative_cost"] for c in cumulative])
151
+
152
+ model_labels_js = json.dumps(list(by_model.keys()))
153
+ model_costs_js = json.dumps([round(by_model[m]["cost"], 4) for m in by_model])
154
+ model_sessions_js = json.dumps([by_model[m]["sessions"] for m in by_model])
155
+
156
+ # All dates union for prompts vs total chart
157
+ all_prompt_dates = sorted(set(list(prompt_by_date.keys()) + list(human_by_date.keys())))
158
+ all_prompt_dates_js = json.dumps(all_prompt_dates)
159
+ total_msgs_by_date_js = json.dumps([human_by_date.get(d, 0) for d in all_prompt_dates])
160
+ key_prompts_by_date_js = json.dumps([prompt_by_date.get(d, {}).get("total", 0) for d in all_prompt_dates])
161
+
162
+ # Efficiency ratio per date (key / total * 100), None if no messages
163
+ efficiency_by_date = []
164
+ for d in all_prompt_dates:
165
+ total = human_by_date.get(d, 0)
166
+ key = prompt_by_date.get(d, {}).get("total", 0)
167
+ efficiency_by_date.append(round(key / total * 100, 1) if total > 0 else None)
168
+ efficiency_by_date_js = json.dumps(efficiency_by_date)
169
+
170
+ overall_efficiency = round(total_prompts / total_human_msgs * 100, 1) if total_human_msgs > 0 else 0
171
+
172
+ # Prompt chart data
173
+ prompt_dates_js = json.dumps(prompt_dates)
174
+ prompt_totals_js = json.dumps([prompt_by_date[d]["total"] for d in prompt_dates])
175
+
176
+ CAT_COLORS = {
177
+ "bug-resolution": "#f87171",
178
+ "architecture": "#6366f1",
179
+ "feature": "#34d399",
180
+ "breakthrough": "#f59e0b",
181
+ }
182
+ DEFAULT_COLOR = "#94a3b8"
183
+
184
+ cat_datasets = []
185
+ for cat in all_categories:
186
+ cat_datasets.append({
187
+ "label": cat,
188
+ "data": [prompt_by_date[d]["by_category"].get(cat, 0) for d in prompt_dates],
189
+ "backgroundColor": CAT_COLORS.get(cat, DEFAULT_COLOR),
190
+ "borderRadius": 2,
191
+ })
192
+ cat_datasets_js = json.dumps(cat_datasets)
193
+
194
+ # Category totals for doughnut
195
+ cat_totals = {c: sum(prompt_by_date[d]["by_category"].get(c, 0) for d in prompt_dates)
196
+ for c in all_categories}
197
+ donut_labels_js = json.dumps(list(cat_totals.keys()))
198
+ donut_values_js = json.dumps(list(cat_totals.values()))
199
+ donut_colors_js = json.dumps([CAT_COLORS.get(c, DEFAULT_COLOR) for c in cat_totals])
200
+
201
+ html = f"""<!DOCTYPE html>
202
+ <html lang="en">
203
+ <head>
204
+ <meta charset="UTF-8">
205
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
206
+ <title>Claude Code — {project_name} tracking</title>
207
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
208
+ <style>
209
+ * {{ box-sizing: border-box; margin: 0; padding: 0; }}
210
+ body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
211
+ background: #0f1117; color: #e2e8f0; padding: 24px; }}
212
+ h1 {{ font-size: 1.25rem; font-weight: 600; margin-bottom: 4px; color: #f8fafc; }}
213
+ .subtitle {{ font-size: 0.8rem; color: #64748b; margin-bottom: 24px; }}
214
+ .stats {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
215
+ gap: 12px; margin-bottom: 28px; }}
216
+ .stat {{ background: #1e2330; border: 1px solid #2d3748; border-radius: 10px;
217
+ padding: 14px 16px; }}
218
+ .stat-label {{ font-size: 0.7rem; color: #64748b; text-transform: uppercase;
219
+ letter-spacing: 0.05em; margin-bottom: 4px; }}
220
+ .stat-value {{ font-size: 1.4rem; font-weight: 700; color: #f8fafc; }}
221
+ .stat-sub {{ font-size: 0.7rem; color: #94a3b8; margin-top: 2px; }}
222
+ .grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }}
223
+ .card {{ background: #1e2330; border: 1px solid #2d3748; border-radius: 10px;
224
+ padding: 16px; }}
225
+ .card.wide {{ grid-column: 1 / -1; }}
226
+ .card h2 {{ font-size: 0.8rem; font-weight: 600; color: #94a3b8;
227
+ text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 14px; }}
228
+ canvas {{ max-height: 220px; }}
229
+ .wide canvas {{ max-height: 180px; }}
230
+ .notice {{ font-size: 0.78rem; color: #94a3b8; background: #1e2330;
231
+ border: 1px solid #3b4a6b; border-left: 3px solid #6366f1;
232
+ border-radius: 6px; padding: 10px 14px; margin-bottom: 20px; }}
233
+ .notice strong {{ color: #e2e8f0; }}
234
+ @media (max-width: 700px) {{ .grid {{ grid-template-columns: 1fr; }} }}
235
+ </style>
236
+ </head>
237
+ <body>
238
+ <h1>Claude Code — {project_name}</h1>
239
+ <p class="subtitle">Updated after every session &mdash; open in browser to view</p>
240
+ <p class="notice">&#9432; Cost figures are <strong>API list-price equivalents</strong> (what pay-as-you-go API customers would be charged). If you are on a Max subscription, these are <em>not</em> amounts billed to you.</p>
241
+
242
+ <div class="stats">
243
+ <div class="stat">
244
+ <div class="stat-label">API list-price equivalent</div>
245
+ <div class="stat-value">${total_cost:.2f}</div>
246
+ <div class="stat-sub">across {len(dates)} day{"s" if len(dates) != 1 else ""} (not billed)</div>
247
+ </div>
248
+ <div class="stat">
249
+ <div class="stat-label">Sessions</div>
250
+ <div class="stat-value">{total_sessions}</div>
251
+ <div class="stat-sub">{sessions_with_data} with token data</div>
252
+ </div>
253
+ <div class="stat">
254
+ <div class="stat-label">Output tokens</div>
255
+ <div class="stat-value">{total_output:,}</div>
256
+ <div class="stat-sub">&nbsp;</div>
257
+ </div>
258
+ <div class="stat">
259
+ <div class="stat-label">Cache read share</div>
260
+ <div class="stat-value">{cache_pct}%</div>
261
+ <div class="stat-sub">of all tokens</div>
262
+ </div>
263
+ <div class="stat">
264
+ <div class="stat-label">Key prompts captured</div>
265
+ <div class="stat-value">{total_prompts}</div>
266
+ <div class="stat-sub">of {total_human_msgs:,} total prompts</div>
267
+ </div>
268
+ <div class="stat">
269
+ <div class="stat-label">Prompt efficiency</div>
270
+ <div class="stat-value">{overall_efficiency}%</div>
271
+ <div class="stat-sub">key / total (higher = better)</div>
272
+ </div>
273
+ </div>
274
+
275
+ <div class="grid">
276
+
277
+ <div class="card wide">
278
+ <h2>Cumulative cost over sessions</h2>
279
+ <canvas id="cumul"></canvas>
280
+ </div>
281
+
282
+ <div class="card">
283
+ <h2>Cost per day</h2>
284
+ <canvas id="costDay"></canvas>
285
+ </div>
286
+
287
+ <div class="card">
288
+ <h2>Sessions per day</h2>
289
+ <canvas id="sessDay"></canvas>
290
+ </div>
291
+
292
+ <div class="card">
293
+ <h2>Cost by model (stacked per day)</h2>
294
+ <canvas id="modelStack"></canvas>
295
+ </div>
296
+
297
+ <div class="card">
298
+ <h2>Output tokens per day</h2>
299
+ <canvas id="outputDay"></canvas>
300
+ </div>
301
+
302
+ </div>
303
+
304
+ <h2 style="font-size:0.85rem;font-weight:600;color:#94a3b8;text-transform:uppercase;
305
+ letter-spacing:0.05em;margin:32px 0 16px">Key Prompts</h2>
306
+
307
+ <div class="grid">
308
+
309
+ <div class="card wide">
310
+ <h2>Total prompts vs key prompts per day</h2>
311
+ <canvas id="promptsVsTotal"></canvas>
312
+ </div>
313
+
314
+ <div class="card">
315
+ <h2>Prompt efficiency per day (%)</h2>
316
+ <canvas id="promptEfficiency"></canvas>
317
+ </div>
318
+
319
+ <div class="card">
320
+ <h2>Category breakdown (all time)</h2>
321
+ <canvas id="promptDonut"></canvas>
322
+ </div>
323
+
324
+ <div class="card wide">
325
+ <h2>Category breakdown per day (stacked)</h2>
326
+ <canvas id="promptStack"></canvas>
327
+ </div>
328
+
329
+ </div>
330
+
331
+ <script>
332
+ const DATES = {dates_js};
333
+ const COST_BY_DATE = {cost_by_date_js};
334
+ const SESSIONS_BY_DATE = {sessions_by_date_js};
335
+ const OUTPUT_BY_DATE = {output_by_date_js};
336
+ const OPUS_BY_DATE = {opus_by_date_js};
337
+ const SONNET_BY_DATE = {sonnet_by_date_js};
338
+ const CUMUL_LABELS = {cumul_labels_js};
339
+ const CUMUL_VALUES = {cumul_values_js};
340
+ const MODEL_LABELS = {model_labels_js};
341
+ const MODEL_COSTS = {model_costs_js};
342
+ const MODEL_SESSIONS = {model_sessions_js};
343
+ const PROMPT_DATES = {prompt_dates_js};
344
+ const PROMPT_TOTALS = {prompt_totals_js};
345
+ const PROMPT_CAT_DATASETS = {cat_datasets_js};
346
+ const DONUT_LABELS = {donut_labels_js};
347
+ const DONUT_VALUES = {donut_values_js};
348
+ const DONUT_COLORS = {donut_colors_js};
349
+ const ALL_PROMPT_DATES = {all_prompt_dates_js};
350
+ const TOTAL_MSGS_BY_DATE = {total_msgs_by_date_js};
351
+ const KEY_PROMPTS_BY_DATE = {key_prompts_by_date_js};
352
+ const EFFICIENCY_BY_DATE = {efficiency_by_date_js};
353
+
354
+ const GRID = '#2d3748';
355
+ const TEXT = '#94a3b8';
356
+ const baseOpts = {{
357
+ responsive: true,
358
+ maintainAspectRatio: true,
359
+ plugins: {{ legend: {{ labels: {{ color: TEXT, boxWidth: 12, font: {{ size: 11 }} }} }} }},
360
+ scales: {{
361
+ x: {{ ticks: {{ color: TEXT, font: {{ size: 10 }} }}, grid: {{ color: GRID }} }},
362
+ y: {{ ticks: {{ color: TEXT, font: {{ size: 10 }} }}, grid: {{ color: GRID }} }}
363
+ }}
364
+ }};
365
+
366
+ // Cumulative cost line
367
+ new Chart(document.getElementById('cumul'), {{
368
+ type: 'line',
369
+ data: {{
370
+ labels: CUMUL_LABELS,
371
+ datasets: [{{ label: 'Cumulative cost ($)', data: CUMUL_VALUES,
372
+ borderColor: '#6366f1', backgroundColor: 'rgba(99,102,241,0.15)',
373
+ fill: true, tension: 0.3, pointRadius: 2 }}]
374
+ }},
375
+ options: {{ ...baseOpts, plugins: {{ ...baseOpts.plugins,
376
+ tooltip: {{ callbacks: {{ label: ctx => ' $' + ctx.parsed.y.toFixed(2) }} }} }} }}
377
+ }});
378
+
379
+ // Cost per day bar
380
+ new Chart(document.getElementById('costDay'), {{
381
+ type: 'bar',
382
+ data: {{
383
+ labels: DATES,
384
+ datasets: [{{ label: 'Cost ($)', data: COST_BY_DATE,
385
+ backgroundColor: '#6366f1', borderRadius: 4 }}]
386
+ }},
387
+ options: {{ ...baseOpts, plugins: {{ ...baseOpts.plugins,
388
+ tooltip: {{ callbacks: {{ label: ctx => ' $' + ctx.parsed.y.toFixed(2) }} }} }} }}
389
+ }});
390
+
391
+ // Sessions per day
392
+ new Chart(document.getElementById('sessDay'), {{
393
+ type: 'bar',
394
+ data: {{
395
+ labels: DATES,
396
+ datasets: [{{ label: 'Sessions', data: SESSIONS_BY_DATE,
397
+ backgroundColor: '#22d3ee', borderRadius: 4 }}]
398
+ }},
399
+ options: baseOpts
400
+ }});
401
+
402
+ // Model stacked per day
403
+ new Chart(document.getElementById('modelStack'), {{
404
+ type: 'bar',
405
+ data: {{
406
+ labels: DATES,
407
+ datasets: [
408
+ {{ label: 'Opus', data: OPUS_BY_DATE, backgroundColor: '#f59e0b', borderRadius: 2 }},
409
+ {{ label: 'Sonnet', data: SONNET_BY_DATE, backgroundColor: '#6366f1', borderRadius: 2 }}
410
+ ]
411
+ }},
412
+ options: {{ ...baseOpts, scales: {{ ...baseOpts.scales, x: {{ ...baseOpts.scales.x, stacked: true }},
413
+ y: {{ ...baseOpts.scales.y, stacked: true }} }},
414
+ plugins: {{ ...baseOpts.plugins,
415
+ tooltip: {{ callbacks: {{ label: ctx => ' $' + ctx.parsed.y.toFixed(2) }} }} }} }}
416
+ }});
417
+
418
+ // Output tokens per day
419
+ new Chart(document.getElementById('outputDay'), {{
420
+ type: 'bar',
421
+ data: {{
422
+ labels: DATES,
423
+ datasets: [{{ label: 'Output tokens', data: OUTPUT_BY_DATE,
424
+ backgroundColor: '#34d399', borderRadius: 4 }}]
425
+ }},
426
+ options: baseOpts
427
+ }});
428
+
429
+ // Total vs key prompts per day
430
+ new Chart(document.getElementById('promptsVsTotal'), {{
431
+ type: 'bar',
432
+ data: {{
433
+ labels: ALL_PROMPT_DATES,
434
+ datasets: [
435
+ {{ label: 'Total prompts', data: TOTAL_MSGS_BY_DATE,
436
+ backgroundColor: 'rgba(148,163,184,0.35)', borderRadius: 4 }},
437
+ {{ label: 'Key prompts', data: KEY_PROMPTS_BY_DATE,
438
+ backgroundColor: '#a78bfa', borderRadius: 4 }}
439
+ ]
440
+ }},
441
+ options: baseOpts
442
+ }});
443
+
444
+ // Efficiency % per day
445
+ new Chart(document.getElementById('promptEfficiency'), {{
446
+ type: 'line',
447
+ data: {{
448
+ labels: ALL_PROMPT_DATES,
449
+ datasets: [{{ label: 'Efficiency (%)', data: EFFICIENCY_BY_DATE,
450
+ borderColor: '#f59e0b', backgroundColor: 'rgba(245,158,11,0.15)',
451
+ fill: true, tension: 0.3, pointRadius: 3, spanGaps: true }}]
452
+ }},
453
+ options: {{ ...baseOpts, plugins: {{ ...baseOpts.plugins,
454
+ tooltip: {{ callbacks: {{ label: ctx => ' ' + ctx.parsed.y + '%' }} }} }},
455
+ scales: {{ ...baseOpts.scales,
456
+ y: {{ ...baseOpts.scales.y, min: 0, max: 100,
457
+ ticks: {{ ...baseOpts.scales.y.ticks, callback: v => v + '%' }} }} }} }}
458
+ }});
459
+
460
+ // Category doughnut
461
+ new Chart(document.getElementById('promptDonut'), {{
462
+ type: 'doughnut',
463
+ data: {{
464
+ labels: DONUT_LABELS,
465
+ datasets: [{{ data: DONUT_VALUES, backgroundColor: DONUT_COLORS,
466
+ borderWidth: 2, borderColor: '#1e2330' }}]
467
+ }},
468
+ options: {{
469
+ responsive: true,
470
+ maintainAspectRatio: true,
471
+ plugins: {{
472
+ legend: {{ position: 'right', labels: {{ color: TEXT, boxWidth: 12, font: {{ size: 11 }} }} }}
473
+ }}
474
+ }}
475
+ }});
476
+
477
+ // Category stacked per day
478
+ new Chart(document.getElementById('promptStack'), {{
479
+ type: 'bar',
480
+ data: {{
481
+ labels: PROMPT_DATES,
482
+ datasets: PROMPT_CAT_DATASETS
483
+ }},
484
+ options: {{ ...baseOpts,
485
+ scales: {{
486
+ x: {{ ...baseOpts.scales.x, stacked: true }},
487
+ y: {{ ...baseOpts.scales.y, stacked: true }}
488
+ }}
489
+ }}
490
+ }});
491
+ </script>
492
+ </body>
493
+ </html>
494
+ """
495
+
496
+ with open(output_file, "w") as f:
497
+ f.write(html)
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ TRACKING_DIR="$1"
4
+ mkdir -p "$TRACKING_DIR"
5
+ mkdir -p "$TRACKING_DIR/key-prompts"
6
+
7
+ cat > "$TRACKING_DIR/tokens.json" <<'EOF'
8
+ []
9
+ EOF
10
+
11
+ cat > "$TRACKING_DIR/key-prompts.md" <<'EOF'
12
+ # Prompt Journal
13
+
14
+ High-signal prompts organized by day.
15
+
16
+ | File | Entries | Highlights |
17
+ |------|---------|------------|
18
+
19
+ **Total**: 0 entries
20
+
21
+ ---
22
+
23
+ New entries go in `key-prompts/YYYY-MM-DD.md` for today's date. Create the file if it doesn't exist — use the same header format as existing files.
24
+ EOF
25
+
26
+ cat > "$TRACKING_DIR/sessions.md" <<'EOF'
27
+ # Session Log
28
+
29
+ ---
30
+ EOF
31
+
32
+ cat > "$TRACKING_DIR/cost-analysis.md" <<'EOF'
33
+ # AI Cost Analysis
34
+
35
+ ## Development Costs
36
+
37
+ | Date | Session Summary | Input | Cache Write | Cache Read | Output | Cost (USD) |
38
+ |------|----------------|-------|-------------|------------|--------|------------|
39
+ | | **Total** | | | | | **$0.00** |
40
+
41
+ *Token counts include prompt caching. Pricing: Sonnet 4.5 -- input $3/M, cache write $3.75/M, cache read $0.30/M, output $15/M. Opus 4.6 -- input $15/M, cache write $18.75/M, cache read $1.50/M, output $75/M.*
42
+
43
+ ---
44
+
45
+ ## Anthropic Pricing Reference
46
+
47
+ | Model | Input (per M) | Output (per M) | Cache Write | Cache Read |
48
+ |-------|--------------|----------------|-------------|------------|
49
+ | Claude Opus 4.6 | $15.00 | $75.00 | $18.75 | $1.50 |
50
+ | Claude Sonnet 4.5 | $3.00 | $15.00 | $3.75 | $0.30 |
51
+ | Claude Haiku 4.5 | $0.80 | $4.00 | $1.00 | $0.08 |
52
+ EOF
53
+
54
+ cat > "$TRACKING_DIR/ai-dev-log.md" <<'EOF'
55
+ # AI Development Log
56
+
57
+ **Period**: Started $(date +%Y-%m-%d)
58
+ **Primary AI Tool**: Claude Code
59
+
60
+ ---
61
+
62
+ ## Tools & Workflow
63
+
64
+ *Document your AI workflow here.*
65
+
66
+ ---
67
+
68
+ ## Effective Prompts
69
+
70
+ See `key-prompts.md` for the full journal with context and analysis.
71
+
72
+ ---
73
+
74
+ ## Code Analysis
75
+
76
+ *Document AI contribution estimates here.*
77
+
78
+ ---
79
+
80
+ ## Key Learnings
81
+
82
+ *Document key learnings as you go.*
83
+ EOF
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+ INPUT="$(cat)"
6
+
7
+ # Prevent loops
8
+ STOP_ACTIVE="$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('stop_hook_active', False))" 2>/dev/null || echo "False")"
9
+ if [[ "$STOP_ACTIVE" == "True" ]]; then exit 0; fi
10
+
11
+ # Extract fields
12
+ CWD="$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('cwd',''))" 2>/dev/null || true)"
13
+ TRANSCRIPT="$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('transcript_path',''))" 2>/dev/null || true)"
14
+ SESSION_ID="$(echo "$INPUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('session_id',''))" 2>/dev/null || true)"
15
+
16
+ if [[ -z "$CWD" || -z "$TRANSCRIPT" || ! -f "$TRANSCRIPT" ]]; then exit 0; fi
17
+
18
+ # Find project root (walk up to .git)
19
+ PROJECT_ROOT="$CWD"
20
+ while [[ "$PROJECT_ROOT" != "/" ]]; do
21
+ [[ -d "$PROJECT_ROOT/.git" ]] && break
22
+ PROJECT_ROOT="$(dirname "$PROJECT_ROOT")"
23
+ done
24
+ if [[ "$PROJECT_ROOT" == "/" ]]; then exit 0; fi
25
+
26
+ TRACKING_DIR="$PROJECT_ROOT/.claude/tracking"
27
+
28
+ # Auto-initialize if missing
29
+ if [[ ! -d "$TRACKING_DIR" ]]; then
30
+ bash "$SCRIPT_DIR/init-templates.sh" "$TRACKING_DIR"
31
+ fi
32
+
33
+ # Parse token usage from JSONL and update tokens.json
34
+ python3 - "$TRANSCRIPT" "$TRACKING_DIR/tokens.json" "$SESSION_ID" "$(basename "$PROJECT_ROOT")" <<'PYEOF'
35
+ import sys, json, os
36
+ from datetime import date
37
+
38
+ transcript_path = sys.argv[1]
39
+ tokens_file = sys.argv[2]
40
+ session_id = sys.argv[3]
41
+ project_name = sys.argv[4]
42
+ today = date.today().isoformat()
43
+
44
+ # Sum all token usage from assistant messages in this session
45
+ inp = out = cache_create = cache_read = 0
46
+ model = "unknown"
47
+ with open(transcript_path) as f:
48
+ for line in f:
49
+ try:
50
+ obj = json.loads(line)
51
+ msg = obj.get('message', {})
52
+ if isinstance(msg, dict) and msg.get('role') == 'assistant':
53
+ usage = msg.get('usage', {})
54
+ if usage:
55
+ inp += usage.get('input_tokens', 0)
56
+ out += usage.get('output_tokens', 0)
57
+ cache_create += usage.get('cache_creation_input_tokens', 0)
58
+ cache_read += usage.get('cache_read_input_tokens', 0)
59
+ m = msg.get('model', '')
60
+ if m:
61
+ model = m
62
+ except:
63
+ pass
64
+
65
+ total = inp + cache_create + cache_read + out
66
+ if 'opus' in model:
67
+ cost = inp * 15 / 1e6 + cache_create * 18.75 / 1e6 + cache_read * 1.50 / 1e6 + out * 75 / 1e6
68
+ else:
69
+ cost = inp * 3 / 1e6 + cache_create * 3.75 / 1e6 + cache_read * 0.30 / 1e6 + out * 15 / 1e6
70
+
71
+ # Load or create tokens.json
72
+ data = []
73
+ if os.path.exists(tokens_file):
74
+ try:
75
+ with open(tokens_file) as f:
76
+ data = json.load(f)
77
+ except:
78
+ data = []
79
+
80
+ # Build entry
81
+ entry = {
82
+ "date": today,
83
+ "project": project_name,
84
+ "session_id": session_id,
85
+ "input_tokens": inp,
86
+ "cache_creation_tokens": cache_create,
87
+ "cache_read_tokens": cache_read,
88
+ "output_tokens": out,
89
+ "total_tokens": total,
90
+ "estimated_cost_usd": round(cost, 4),
91
+ "model": model
92
+ }
93
+
94
+ # Update existing or append new
95
+ found = False
96
+ for i, e in enumerate(data):
97
+ if e.get('session_id') == session_id:
98
+ data[i] = entry
99
+ found = True
100
+ break
101
+ if not found:
102
+ data.append(entry)
103
+
104
+ with open(tokens_file, 'w') as f:
105
+ json.dump(data, f, indent=2)
106
+ f.write('\n')
107
+ PYEOF
108
+
109
+ # Regenerate charts
110
+ python3 "$SCRIPT_DIR/generate-charts.py" "$TRACKING_DIR/tokens.json" "$TRACKING_DIR/charts.html" 2>/dev/null || true
111
+
112
+ # Regenerate key-prompts index
113
+ python3 "$SCRIPT_DIR/update-prompts-index.py" "$TRACKING_DIR" 2>/dev/null || true
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Regenerates <tracking_dir>/key-prompts.md index from files in key-prompts/ folder.
4
+ Called by stop-hook.sh after each session.
5
+
6
+ Usage: python3 update-prompts-index.py <tracking_dir>
7
+ """
8
+ import sys
9
+ import os
10
+ import re
11
+ import glob
12
+
13
+ tracking_dir = sys.argv[1]
14
+ prompts_dir = os.path.join(tracking_dir, "key-prompts")
15
+ index_file = os.path.join(tracking_dir, "key-prompts.md")
16
+
17
+ if not os.path.isdir(prompts_dir):
18
+ sys.exit(0)
19
+
20
+ files = sorted(glob.glob(os.path.join(prompts_dir, "????-??-??.md")))
21
+ if not files:
22
+ sys.exit(0)
23
+
24
+ rows = []
25
+ total_entries = 0
26
+
27
+ for f in files:
28
+ date = os.path.splitext(os.path.basename(f))[0]
29
+ with open(f) as fh:
30
+ content = fh.read()
31
+
32
+ # Count entries (## headings that are not the title line)
33
+ entries = len(re.findall(r'^## (?!Key Prompts)', content, re.MULTILINE))
34
+
35
+ # Extract first 3 entry titles for highlights
36
+ titles = re.findall(r'^## (.+)', content, re.MULTILINE)
37
+ # Skip the file title (first line if it matches "Key Prompts — ...")
38
+ titles = [t for t in titles if not t.startswith("Key Prompts")]
39
+ highlights = ", ".join(titles[:3])
40
+ if len(titles) > 3:
41
+ highlights += "..."
42
+
43
+ rows.append((date, entries, highlights))
44
+ total_entries += entries
45
+
46
+ lines = ["# Prompt Journal\n",
47
+ "\nHigh-signal prompts organized by day.\n",
48
+ "\n| File | Entries | Highlights |\n",
49
+ "|------|---------|------------|\n"]
50
+
51
+ for date, entries, highlights in rows:
52
+ lines.append(f"| [{date}](key-prompts/{date}.md) | {entries} | {highlights} |\n")
53
+
54
+ lines.append(f"\n**Total**: {total_entries} entries across {len(rows)} day{'s' if len(rows) != 1 else ''}\n")
55
+ lines.append("\n---\n")
56
+ lines.append("\nNew entries go in `key-prompts/YYYY-MM-DD.md` for today's date. "
57
+ "Create the file if it doesn't exist — use the same header format as existing files.\n")
58
+
59
+ with open(index_file, "w") as f:
60
+ f.writelines(lines)
package/uninstall.sh ADDED
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ INSTALL_DIR="$HOME/.claude/tracking"
5
+ SETTINGS="$HOME/.claude/settings.json"
6
+ HOOK_CMD="$INSTALL_DIR/stop-hook.sh"
7
+
8
+ echo "Uninstalling claude-code-tracker..."
9
+
10
+ # Remove scripts
11
+ if [[ -d "$INSTALL_DIR" ]]; then
12
+ rm -f "$INSTALL_DIR/"*.sh "$INSTALL_DIR/"*.py
13
+ echo "Scripts removed from $INSTALL_DIR"
14
+ else
15
+ echo "Nothing to remove at $INSTALL_DIR"
16
+ fi
17
+
18
+ # Remove hook entry from settings.json
19
+ if [[ -f "$SETTINGS" ]]; then
20
+ python3 - "$SETTINGS" "$HOOK_CMD" <<'PYEOF'
21
+ import sys, json, os
22
+
23
+ settings_file = sys.argv[1]
24
+ hook_cmd = sys.argv[2]
25
+
26
+ try:
27
+ with open(settings_file) as f:
28
+ data = json.load(f)
29
+ except Exception:
30
+ sys.exit(0)
31
+
32
+ hooks = data.get("hooks", {})
33
+ stop_hooks = hooks.get("Stop", [])
34
+
35
+ new_stop_hooks = []
36
+ removed = False
37
+ for group in stop_hooks:
38
+ new_group_hooks = [h for h in group.get("hooks", []) if h.get("command") != hook_cmd]
39
+ if len(new_group_hooks) < len(group.get("hooks", [])):
40
+ removed = True
41
+ if new_group_hooks:
42
+ new_stop_hooks.append({"hooks": new_group_hooks})
43
+ elif not removed:
44
+ new_stop_hooks.append(group)
45
+
46
+ if removed:
47
+ hooks["Stop"] = new_stop_hooks
48
+ if not hooks["Stop"]:
49
+ del hooks["Stop"]
50
+ if not hooks:
51
+ del data["hooks"]
52
+ with open(settings_file, 'w') as f:
53
+ json.dump(data, f, indent=2)
54
+ f.write('\n')
55
+ print("Hook removed from", settings_file)
56
+ else:
57
+ print("Hook not found in", settings_file)
58
+ PYEOF
59
+ fi
60
+
61
+ echo "claude-code-tracker uninstalled."