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.
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env python3
2
+ """Cross-platform replacement for init-templates.sh."""
3
+ import sys
4
+ import os
5
+
6
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
7
+ import storage # noqa: E402
8
+
9
+
10
+ def main():
11
+ if len(sys.argv) < 2:
12
+ print(f"Usage: {sys.argv[0]} <tracking_dir>", file=sys.stderr)
13
+ sys.exit(1)
14
+
15
+ tracking_dir = sys.argv[1]
16
+ os.makedirs(tracking_dir, exist_ok=True)
17
+
18
+ # Initialize SQLite database (replaces tokens.json / agents.json)
19
+ storage.init_db(tracking_dir)
20
+
21
+ # Create key-prompts directory
22
+ key_prompts_dir = os.path.join(tracking_dir, 'key-prompts')
23
+ os.makedirs(key_prompts_dir, exist_ok=True)
24
+
25
+ if __name__ == '__main__':
26
+ main()
@@ -1,12 +1,12 @@
1
1
  #!/usr/bin/env bash
2
2
  set -euo pipefail
3
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
3
4
  TRACKING_DIR="$1"
4
5
  mkdir -p "$TRACKING_DIR"
5
6
  mkdir -p "$TRACKING_DIR/key-prompts"
6
7
 
7
- cat > "$TRACKING_DIR/tokens.json" <<'EOF'
8
- []
9
- EOF
8
+ # Initialize SQLite database (replaces tokens.json / agents.json)
9
+ python3 "$SCRIPT_DIR/storage.py" --init "$TRACKING_DIR"
10
10
 
11
11
  cat > "$TRACKING_DIR/key-prompts.md" <<'EOF'
12
12
  # Prompt Journal
@@ -0,0 +1,286 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Parse friction events from Claude Code JSONL transcripts.
4
+
5
+ Usage:
6
+ python3 parse_friction.py <transcript_path> <friction_file> <session_id> <project> <source> \
7
+ [--agent-type TYPE] [--agent-id ID]
8
+
9
+ Friction categories (priority order, first match wins):
10
+ permission_denied, hook_blocked, cascade_error, command_failed, tool_error, correction, retry
11
+ """
12
+ import sys, json, os, argparse
13
+
14
+
15
+ def parse_friction(transcript_path, session_id, project, source,
16
+ agent_type=None, agent_id=None):
17
+ """Parse a JSONL transcript and return a list of friction event dicts."""
18
+ events = []
19
+ pending_tools = {} # tool_use_id -> {name, turn_index, timestamp}
20
+ last_error_by_tool = {} # tool_name -> tool_use_id of last errored call
21
+ skill_stack = [] # [(tool_use_id, skill_name)]
22
+
23
+ msgs = [] # (role, timestamp, is_sidechain, user_type)
24
+ lines = [] # raw parsed objects
25
+ model = "unknown"
26
+
27
+ with open(transcript_path, encoding='utf-8') as f:
28
+ for line in f:
29
+ try:
30
+ obj = json.loads(line)
31
+ lines.append(obj)
32
+ t = obj.get('type')
33
+ ts = obj.get('timestamp')
34
+ if t == 'user' and ts:
35
+ msgs.append(('user', ts, obj.get('isSidechain', False),
36
+ obj.get('userType')))
37
+ elif t == 'assistant' and ts:
38
+ msgs.append(('assistant', ts, False, None))
39
+ msg = obj.get('message', {})
40
+ if isinstance(msg, dict) and msg.get('role') == 'assistant':
41
+ m = msg.get('model', '')
42
+ if m:
43
+ model = m
44
+ except Exception:
45
+ pass
46
+
47
+ # Build turn boundaries: user (non-sidechain) -> next assistant
48
+ turn_boundaries = [] # [(user_msg_idx, asst_msg_idx)]
49
+ i = 0
50
+ while i < len(msgs):
51
+ if msgs[i][0] == 'user' and not msgs[i][2]:
52
+ j = i + 1
53
+ while j < len(msgs) and msgs[j][0] != 'assistant':
54
+ j += 1
55
+ if j < len(msgs):
56
+ turn_boundaries.append((i, j))
57
+ i = j + 1
58
+ else:
59
+ i += 1
60
+ else:
61
+ i += 1
62
+
63
+ def get_turn_index(timestamp):
64
+ if not timestamp:
65
+ return 0
66
+ for idx, (ui, ai) in enumerate(turn_boundaries):
67
+ user_ts = msgs[ui][1]
68
+ asst_ts = msgs[ai][1]
69
+ if user_ts <= timestamp <= asst_ts:
70
+ return idx
71
+ if idx + 1 < len(turn_boundaries):
72
+ next_user_ts = msgs[turn_boundaries[idx + 1][0]][1]
73
+ if asst_ts <= timestamp < next_user_ts:
74
+ return idx
75
+ return max(0, len(turn_boundaries) - 1)
76
+
77
+ def make_date(timestamp):
78
+ try:
79
+ from datetime import datetime
80
+ return datetime.fromisoformat(
81
+ timestamp.replace('Z', '+00:00')).strftime('%Y-%m-%d')
82
+ except Exception:
83
+ from datetime import date
84
+ return date.today().isoformat()
85
+
86
+ def current_skill():
87
+ return skill_stack[-1][1] if skill_stack else None
88
+
89
+ def make_event(timestamp, turn_index, category, tool_name, detail, resolved=None):
90
+ return {
91
+ 'timestamp': timestamp or '',
92
+ 'date': make_date(timestamp),
93
+ 'session_id': session_id,
94
+ 'turn_index': turn_index,
95
+ 'source': source,
96
+ 'agent_type': agent_type,
97
+ 'agent_id': agent_id,
98
+ 'project': project,
99
+ 'category': category,
100
+ 'tool_name': tool_name,
101
+ 'skill': current_skill(),
102
+ 'model': model,
103
+ 'detail': (detail or '')[:500],
104
+ 'resolved': resolved,
105
+ }
106
+
107
+ # Second pass: detect friction
108
+ for obj in lines:
109
+ ts = obj.get('timestamp', '')
110
+ turn_idx = get_turn_index(ts)
111
+ msg = obj.get('message', {})
112
+ if not isinstance(msg, dict):
113
+ continue
114
+ content_blocks = msg.get('content', [])
115
+
116
+ if isinstance(content_blocks, list):
117
+ for block in content_blocks:
118
+ if not isinstance(block, dict):
119
+ continue
120
+ btype = block.get('type')
121
+
122
+ if btype == 'tool_use':
123
+ tool_id = block.get('id', '')
124
+ tool_name = block.get('name', '')
125
+ pending_tools[tool_id] = {
126
+ 'name': tool_name,
127
+ 'turn_index': turn_idx,
128
+ 'timestamp': ts,
129
+ }
130
+ if tool_name == 'Skill':
131
+ skill_name = block.get('input', {}).get('skill', '')
132
+ if skill_name:
133
+ skill_stack.append((tool_id, skill_name))
134
+
135
+ elif btype == 'tool_result':
136
+ tool_id = block.get('tool_use_id', '')
137
+ is_error = block.get('is_error', False)
138
+ content = ''
139
+ raw_content = block.get('content', '')
140
+ if isinstance(raw_content, str):
141
+ content = raw_content
142
+ elif isinstance(raw_content, list):
143
+ content = ' '.join(
144
+ c.get('text', '') for c in raw_content
145
+ if isinstance(c, dict) and c.get('type') == 'text')
146
+
147
+ tool_info = pending_tools.get(tool_id, {})
148
+ tool_name = tool_info.get('name', '')
149
+
150
+ # Pop skill stack if this is a Skill tool result
151
+ if skill_stack and skill_stack[-1][0] == tool_id:
152
+ skill_stack.pop()
153
+
154
+ content_lower = content.lower()
155
+
156
+ # Check if this is a retry of a previously errored tool
157
+ if tool_name and tool_name in last_error_by_tool:
158
+ if not is_error:
159
+ events.append(make_event(ts, turn_idx, 'retry',
160
+ tool_name, 'Retry succeeded', True))
161
+ del last_error_by_tool[tool_name]
162
+ continue
163
+ else:
164
+ events.append(make_event(ts, turn_idx, 'retry',
165
+ tool_name, 'Retry failed', False))
166
+ continue
167
+
168
+ if not is_error:
169
+ continue
170
+
171
+ # Priority 1: permission_denied
172
+ if ("user doesn't want to proceed" in content_lower or
173
+ "tool use was rejected" in content_lower):
174
+ events.append(make_event(ts, turn_idx, 'permission_denied',
175
+ tool_name, content))
176
+ last_error_by_tool[tool_name] = tool_id
177
+ continue
178
+
179
+ # Priority 2: hook_blocked
180
+ if 'pretooluse:' in content_lower and 'blocked' in content_lower:
181
+ events.append(make_event(ts, turn_idx, 'hook_blocked',
182
+ tool_name, content))
183
+ last_error_by_tool[tool_name] = tool_id
184
+ continue
185
+
186
+ # Priority 3: cascade_error
187
+ if 'sibling tool call errored' in content_lower:
188
+ events.append(make_event(ts, turn_idx, 'cascade_error',
189
+ tool_name, content))
190
+ continue
191
+
192
+ # Priority 4: command_failed
193
+ if tool_name == 'Bash' and content.startswith('Exit code '):
194
+ events.append(make_event(ts, turn_idx, 'command_failed',
195
+ tool_name, content))
196
+ last_error_by_tool[tool_name] = tool_id
197
+ continue
198
+
199
+ # Priority 5: tool_error (catch-all for is_error=true)
200
+ events.append(make_event(ts, turn_idx, 'tool_error',
201
+ tool_name, content))
202
+ last_error_by_tool[tool_name] = tool_id
203
+
204
+ # Check for corrections: user messages
205
+ obj_type = obj.get('type')
206
+ user_type = obj.get('userType')
207
+ is_sidechain = obj.get('isSidechain', False)
208
+ if obj_type == 'user' and user_type == 'human' and not is_sidechain:
209
+ text = ''
210
+ if isinstance(content_blocks, list):
211
+ texts = [c.get('text', '') for c in content_blocks
212
+ if isinstance(c, dict) and c.get('type') == 'text']
213
+ text = ' '.join(texts).strip()
214
+ elif isinstance(msg.get('content'), str):
215
+ text = msg['content'].strip()
216
+
217
+ if text:
218
+ text_lower = text.lower()
219
+ first_100 = text_lower[:100]
220
+ is_correction = False
221
+
222
+ for prefix in ('no,', 'no ', 'wrong', 'stop', 'wait', 'actually,'):
223
+ if text_lower.startswith(prefix):
224
+ is_correction = True
225
+ break
226
+
227
+ if not is_correction:
228
+ for phrase in ("that's wrong", "not what i", "i said", "i meant"):
229
+ if phrase in first_100:
230
+ is_correction = True
231
+ break
232
+
233
+ if is_correction:
234
+ events.append(make_event(ts, turn_idx, 'correction',
235
+ None, text[:200]))
236
+
237
+ return events
238
+
239
+
240
+ def upsert_friction(friction_file, session_id, new_events):
241
+ """Load existing friction.json, remove events for session_id, add new, sort, write."""
242
+ data = []
243
+ if os.path.exists(friction_file):
244
+ try:
245
+ with open(friction_file, encoding='utf-8') as f:
246
+ data = json.load(f)
247
+ except Exception:
248
+ data = []
249
+
250
+ data = [e for e in data if e.get('session_id') != session_id]
251
+ data.extend(new_events)
252
+
253
+ data.sort(key=lambda x: (x.get('date', ''), x.get('session_id', ''),
254
+ x.get('turn_index', 0)))
255
+
256
+ os.makedirs(os.path.dirname(os.path.abspath(friction_file)), exist_ok=True)
257
+ with open(friction_file, 'w', encoding='utf-8') as f:
258
+ json.dump(data, f, indent=2)
259
+ f.write('\n')
260
+
261
+ return data
262
+
263
+
264
+ def main():
265
+ parser = argparse.ArgumentParser(description='Parse friction events from JSONL transcript')
266
+ parser.add_argument('transcript_path')
267
+ parser.add_argument('friction_file')
268
+ parser.add_argument('session_id')
269
+ parser.add_argument('project')
270
+ parser.add_argument('source')
271
+ parser.add_argument('--agent-type', default=None)
272
+ parser.add_argument('--agent-id', default=None)
273
+ args = parser.parse_args()
274
+
275
+ events = parse_friction(args.transcript_path, args.session_id, args.project,
276
+ args.source, args.agent_type, args.agent_id)
277
+
278
+ if events:
279
+ upsert_friction(args.friction_file, args.session_id, events)
280
+ print(f"{len(events)} friction event(s) recorded.")
281
+ else:
282
+ upsert_friction(args.friction_file, args.session_id, [])
283
+
284
+
285
+ if __name__ == '__main__':
286
+ main()
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Parse Skill tool invocations from Claude Code JSONL transcripts.
4
+
5
+ Usage:
6
+ python3 parse_skills.py <transcript_path> <tracking_dir> <session_id> <project>
7
+ """
8
+ import json
9
+ import os
10
+ import sys
11
+ from datetime import date, datetime
12
+
13
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
14
+ sys.path.insert(0, SCRIPT_DIR)
15
+ import storage
16
+
17
+
18
+ def make_date(timestamp):
19
+ try:
20
+ return datetime.fromisoformat(
21
+ timestamp.replace('Z', '+00:00')).strftime('%Y-%m-%d')
22
+ except Exception:
23
+ return date.today().isoformat()
24
+
25
+
26
+ def parse_skills(transcript_path, session_id, project):
27
+ """Parse JSONL transcript for Skill tool_use blocks.
28
+ Returns list of dicts ready for storage.replace_session_skills()."""
29
+ lines = []
30
+ with open(transcript_path, encoding='utf-8') as f:
31
+ for raw in f:
32
+ try:
33
+ obj = json.loads(raw)
34
+ lines.append(obj)
35
+ except Exception:
36
+ pass
37
+
38
+ pending = {} # tool_use_id -> {skill_name, args, tool_use_id, timestamp}
39
+ entries = []
40
+
41
+ for obj in lines:
42
+ ts = obj.get('timestamp', '')
43
+ msg = obj.get('message', {})
44
+ if not isinstance(msg, dict):
45
+ continue
46
+ content_blocks = msg.get('content', [])
47
+ if not isinstance(content_blocks, list):
48
+ continue
49
+
50
+ for block in content_blocks:
51
+ if not isinstance(block, dict):
52
+ continue
53
+ btype = block.get('type')
54
+
55
+ if btype == 'tool_use' and block.get('name') == 'Skill':
56
+ tool_use_id = block.get('id', '')
57
+ inp = block.get('input', {})
58
+ pending[tool_use_id] = {
59
+ 'skill_name': inp.get('skill', 'unknown'),
60
+ 'args': inp.get('args'),
61
+ 'tool_use_id': tool_use_id,
62
+ 'timestamp': ts,
63
+ }
64
+
65
+ elif btype == 'tool_result':
66
+ tool_use_id = block.get('tool_use_id', '')
67
+ if tool_use_id not in pending:
68
+ continue
69
+
70
+ info = pending.pop(tool_use_id)
71
+ is_error = block.get('is_error', False)
72
+
73
+ error_content = None
74
+ if is_error:
75
+ raw_content = block.get('content', '')
76
+ if isinstance(raw_content, str):
77
+ error_content = raw_content
78
+ elif isinstance(raw_content, list):
79
+ error_content = ' '.join(
80
+ c.get('text', '') for c in raw_content
81
+ if isinstance(c, dict) and c.get('type') == 'text')
82
+
83
+ duration = 0
84
+ if info['timestamp'] and ts:
85
+ try:
86
+ use_ts = datetime.fromisoformat(
87
+ info['timestamp'].replace('Z', '+00:00'))
88
+ result_ts = datetime.fromisoformat(
89
+ ts.replace('Z', '+00:00'))
90
+ duration = max(0, int((result_ts - use_ts).total_seconds()))
91
+ except Exception:
92
+ pass
93
+
94
+ entries.append({
95
+ 'session_id': session_id,
96
+ 'date': make_date(info['timestamp']),
97
+ 'project': project,
98
+ 'skill_name': info['skill_name'],
99
+ 'args': info['args'],
100
+ 'tool_use_id': info['tool_use_id'],
101
+ 'timestamp': info['timestamp'],
102
+ 'duration_seconds': duration,
103
+ 'success': 0 if is_error else 1,
104
+ 'error_message': error_content if is_error else None,
105
+ })
106
+
107
+ # Flush unmatched pending skills
108
+ for tool_use_id, info in pending.items():
109
+ entries.append({
110
+ 'session_id': session_id,
111
+ 'date': make_date(info['timestamp']),
112
+ 'project': project,
113
+ 'skill_name': info['skill_name'],
114
+ 'args': info['args'],
115
+ 'tool_use_id': info['tool_use_id'],
116
+ 'timestamp': info['timestamp'],
117
+ 'duration_seconds': 0,
118
+ 'success': 1,
119
+ 'error_message': None,
120
+ })
121
+
122
+ return entries
123
+
124
+
125
+ if __name__ == '__main__':
126
+ if len(sys.argv) != 5:
127
+ print(f"Usage: {sys.argv[0]} <transcript_path> <tracking_dir> <session_id> <project>",
128
+ file=sys.stderr)
129
+ sys.exit(1)
130
+
131
+ transcript_path, tracking_dir, session_id, project = sys.argv[1:5]
132
+ entries = parse_skills(transcript_path, session_id, project)
133
+ storage.replace_session_skills(tracking_dir, session_id, entries)
@@ -1,24 +1,25 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- Patch duration_seconds for per-turn entries that have duration 0,
4
- and migrate old single-entry-per-session entries to per-turn format.
3
+ Patch duration_seconds for per-turn entries that have duration 0.
5
4
 
6
5
  Usage:
7
6
  python3 patch-durations.py <project_root>
8
7
  """
9
- import sys, json, os, glob
8
+ import sys, json, os
9
+
10
+ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
11
+ sys.path.insert(0, SCRIPT_DIR)
12
+ import storage
13
+
10
14
  from datetime import datetime
11
15
 
12
16
  project_root = os.path.abspath(sys.argv[1])
13
17
  tracking_dir = os.path.join(project_root, ".claude", "tracking")
14
- tokens_file = os.path.join(tracking_dir, "tokens.json")
15
18
 
16
19
  slug = project_root.replace("/", "-")
17
20
  transcripts_dir = os.path.expanduser("~/.claude/projects/" + slug)
18
- project_name = os.path.basename(project_root)
19
21
 
20
- with open(tokens_file) as f:
21
- data = json.load(f)
22
+ data = storage.get_all_turns(tracking_dir)
22
23
 
23
24
  def parse_transcript(jf):
24
25
  msgs = []
@@ -26,7 +27,7 @@ def parse_transcript(jf):
26
27
  model = "unknown"
27
28
  first_ts = None
28
29
  try:
29
- with open(jf) as f:
30
+ with open(jf, encoding='utf-8') as f:
30
31
  for line in f:
31
32
  try:
32
33
  obj = json.loads(line)
@@ -52,15 +53,9 @@ def parse_transcript(jf):
52
53
  pass
53
54
  return msgs, usages, model, first_ts
54
55
 
55
- # Separate old-format (no turn_index) from new-format entries
56
- old_entries = [e for e in data if "turn_index" not in e]
57
- new_entries = [e for e in data if "turn_index" in e]
58
-
59
- # For new-format entries with duration 0, patch from transcript
60
- existing_keys = {(e.get("session_id"), e.get("turn_index")): i for i, e in enumerate(new_entries)}
61
56
  patched = 0
62
57
 
63
- for entry in new_entries:
58
+ for entry in data:
64
59
  if entry.get("duration_seconds", 0) > 0:
65
60
  continue
66
61
  sid = entry.get("session_id")
@@ -85,7 +80,7 @@ for entry in new_entries:
85
80
  t1 = datetime.fromisoformat(msgs[j][1].replace("Z", "+00:00"))
86
81
  duration = max(0, int((t1 - t0).total_seconds()))
87
82
  if duration > 0:
88
- entry["duration_seconds"] = duration
83
+ storage.patch_turn_duration(tracking_dir, sid, turn_index, duration)
89
84
  patched += 1
90
85
  print(f" patched {sid[:8]}#{turn_index} {duration}s")
91
86
  except Exception:
@@ -99,103 +94,8 @@ for entry in new_entries:
99
94
  else:
100
95
  i += 1
101
96
 
102
- # Migrate old-format entries to per-turn
103
- migrated_sessions = 0
104
- new_turn_entries = []
105
- for old_entry in old_entries:
106
- sid = old_entry.get("session_id")
107
- if not sid:
108
- continue
109
- jf = os.path.join(transcripts_dir, sid + ".jsonl")
110
- if not os.path.exists(jf):
111
- # Keep old entry as-is if we can't reprocess
112
- new_entries.append(old_entry)
113
- continue
114
-
115
- msgs, usages, model, first_ts = parse_transcript(jf)
116
-
117
- turn_index = 0
118
- usage_index = 0
119
- i = 0
120
- session_date = old_entry.get("date")
121
-
122
- while i < len(msgs):
123
- if msgs[i][0] == "user":
124
- user_ts = msgs[i][1]
125
- j = i + 1
126
- while j < len(msgs) and msgs[j][0] != "assistant":
127
- j += 1
128
- if j < len(msgs):
129
- asst_ts = msgs[j][1]
130
- usage = {}
131
- if usage_index < len(usages):
132
- usage = usages[usage_index]
133
- usage_index += 1
134
-
135
- inp = usage.get("input_tokens", 0)
136
- out = usage.get("output_tokens", 0)
137
- cache_create = usage.get("cache_creation_input_tokens", 0)
138
- cache_read = usage.get("cache_read_input_tokens", 0)
139
- total = inp + cache_create + cache_read + out
140
-
141
- if total > 0:
142
- duration = 0
143
- try:
144
- t0 = datetime.fromisoformat(user_ts.replace("Z", "+00:00"))
145
- t1 = datetime.fromisoformat(asst_ts.replace("Z", "+00:00"))
146
- duration = max(0, int((t1 - t0).total_seconds()))
147
- except Exception:
148
- pass
149
-
150
- if "opus" in model:
151
- cost = inp * 15 / 1e6 + cache_create * 18.75 / 1e6 + cache_read * 1.50 / 1e6 + out * 75 / 1e6
152
- else:
153
- cost = inp * 3 / 1e6 + cache_create * 3.75 / 1e6 + cache_read * 0.30 / 1e6 + out * 15 / 1e6
154
-
155
- try:
156
- turn_ts = datetime.fromisoformat(user_ts.replace("Z", "+00:00")).strftime("%Y-%m-%dT%H:%M:%SZ")
157
- turn_date = datetime.fromisoformat(user_ts.replace("Z", "+00:00")).strftime("%Y-%m-%d")
158
- except Exception:
159
- turn_ts = user_ts
160
- turn_date = session_date
161
-
162
- new_turn_entries.append({
163
- "date": turn_date,
164
- "project": project_name,
165
- "session_id": sid,
166
- "turn_index": turn_index,
167
- "turn_timestamp": turn_ts,
168
- "input_tokens": inp,
169
- "cache_creation_tokens": cache_create,
170
- "cache_read_tokens": cache_read,
171
- "output_tokens": out,
172
- "total_tokens": total,
173
- "estimated_cost_usd": round(cost, 4),
174
- "model": model,
175
- "duration_seconds": duration,
176
- })
177
- turn_index += 1
178
- i = j + 1
179
- else:
180
- i += 1
181
- else:
182
- i += 1
183
-
184
- if turn_index > 0:
185
- migrated_sessions += 1
186
- print(f" migrated {sid[:8]} {turn_index} turn(s)")
187
- else:
188
- new_entries.append(old_entry)
189
-
190
- new_entries.extend(new_turn_entries)
191
- new_entries.sort(key=lambda x: (x.get("date", ""), x.get("session_id", ""), x.get("turn_index", 0)))
192
-
193
- if patched > 0 or migrated_sessions > 0:
194
- with open(tokens_file, "w") as f:
195
- json.dump(new_entries, f, indent=2)
196
- f.write("\n")
197
- script_dir = os.path.dirname(os.path.abspath(__file__))
97
+ if patched > 0:
198
98
  charts_html = os.path.join(tracking_dir, "charts.html")
199
- os.system(f'python3 "{script_dir}/generate-charts.py" "{tokens_file}" "{charts_html}" 2>/dev/null')
99
+ os.system(f'python3 "{SCRIPT_DIR}/generate-charts.py" "{tracking_dir}" "{charts_html}" 2>/dev/null')
200
100
 
201
- print(f"{patched} turn(s) patched, {migrated_sessions} session(s) migrated to per-turn format.")
101
+ print(f"{patched} turn(s) patched.")
@@ -0,0 +1,36 @@
1
+ """Cross-platform utility helpers for claude-tracker."""
2
+ import os
3
+ import sys
4
+ import subprocess
5
+
6
+ def get_transcripts_dir():
7
+ """Return the Claude transcripts directory for the current platform."""
8
+ if sys.platform == 'win32':
9
+ appdata = os.environ.get('APPDATA', '')
10
+ return os.path.join(appdata, 'Claude', 'claude_code', 'transcripts')
11
+ elif sys.platform == 'darwin':
12
+ home = os.path.expanduser('~')
13
+ return os.path.join(home, 'Library', 'Application Support', 'Claude', 'claude_code', 'transcripts')
14
+ else:
15
+ home = os.path.expanduser('~')
16
+ return os.path.join(home, '.config', 'Claude', 'claude_code', 'transcripts')
17
+
18
+ def slugify_path(path):
19
+ """Convert an absolute path to a slug suitable for use as a directory name.
20
+ Handles Windows drive letters and backslashes."""
21
+ # Normalize separators
22
+ slug = path.replace('\\', '/').replace('/', '-')
23
+ # Remove drive letter colon on Windows (e.g. C: -> C)
24
+ slug = slug.replace(':', '')
25
+ # Strip leading/trailing dashes
26
+ slug = slug.strip('-')
27
+ return slug
28
+
29
+ def open_file(path):
30
+ """Open a file with the default system application."""
31
+ if sys.platform == 'win32':
32
+ os.startfile(path)
33
+ elif sys.platform == 'darwin':
34
+ subprocess.run(['open', path])
35
+ else:
36
+ subprocess.run(['xdg-open', path])