ccanalyzer 1.0.0 → 1.1.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,47 @@
1
+ # ccanalyzer
2
+
3
+ A web-based dashboard for analyzing your [Claude Code](https://claude.ai/code) sessions — costs, token usage, agent activity, and conversation timelines.
4
+
5
+ ![Node.js >= 18](https://img.shields.io/badge/node-%3E%3D18-brightgreen)
6
+ [![npm](https://img.shields.io/npm/v/ccanalyzer)](https://www.npmjs.com/package/ccanalyzer)
7
+
8
+ ## Usage
9
+
10
+ ```bash
11
+ npx -y ccanalyzer@latest
12
+ ```
13
+
14
+ Opens a local dashboard at **http://localhost:3737** in your browser.
15
+
16
+ ## Features
17
+
18
+ - **Dashboard** — all projects with token counts, costs, and last activity
19
+ - **Session browser** — list sessions per project sorted by recency
20
+ - **Session detail** — full message thread with token/cost breakdown per exchange
21
+ - **Gantt timeline** — visual timeline of user turns, AI responses, and spawned agents
22
+ - Click a **message bar** → see the exchange inline
23
+ - Click an **agent bar** → open the full agent conversation in a popup
24
+ - **Agent popup** — complete agent conversation with stats (input/output tokens, cost)
25
+
26
+ ## Custom config directory
27
+
28
+ By default, ccanalyzer reads `~/.claude`. To analyze a different Claude config directory (e.g. a work profile or a custom `CLAUDE_CONFIG_DIR`):
29
+
30
+ ```bash
31
+ CLAUDE_CONFIG_DIR=/path/to/your/.claude npx -y ccanalyzer@latest
32
+ ```
33
+
34
+ Example — analyze a secondary profile:
35
+
36
+ ```bash
37
+ CLAUDE_CONFIG_DIR=~/.claude-work npx -y ccanalyzer@latest
38
+ ```
39
+
40
+ ## Requirements
41
+
42
+ - Node.js >= 18
43
+ - Claude Code sessions in `~/.claude/projects/` (or your custom `CLAUDE_CONFIG_DIR`)
44
+
45
+ ## License
46
+
47
+ MIT
package/bin/index.js CHANGED
@@ -5,7 +5,7 @@ const { startServer } = require('../src/server');
5
5
  const port = parseInt(process.env.PORT || '3737', 10);
6
6
 
7
7
  startServer(port).then(({ url }) => {
8
- console.log(`\n ccanalyser running at ${url}\n`);
8
+ console.log(`\n ccanalyzer running at ${url}\n`);
9
9
  try {
10
10
  // Try to open browser
11
11
  import('open').then(m => m.default(url)).catch(() => {});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccanalyzer",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Web-based analyzer for Claude Code sessions",
5
5
  "bin": {
6
6
  "ccanalyzer": "./bin/index.js"
@@ -20,7 +20,13 @@
20
20
  "engines": {
21
21
  "node": ">=18"
22
22
  },
23
- "keywords": ["claude", "claude-code", "analytics", "sessions", "ccanalyzer"],
23
+ "keywords": [
24
+ "claude",
25
+ "claude-code",
26
+ "analytics",
27
+ "sessions",
28
+ "ccanalyzer"
29
+ ],
24
30
  "license": "MIT",
25
31
  "repository": {
26
32
  "type": "git",
package/src/public/app.js CHANGED
@@ -62,6 +62,27 @@ function showView(name) {
62
62
  });
63
63
  }
64
64
 
65
+ function pushUrl(params) {
66
+ const qs = new URLSearchParams(params).toString();
67
+ history.pushState(params, '', qs ? '?' + qs : location.pathname);
68
+ }
69
+
70
+ function restoreFromUrl() {
71
+ const p = new URLSearchParams(location.search);
72
+ const project = p.get('project');
73
+ const session = p.get('session');
74
+ if (project && session) return loadSessionDetail(encodeURIComponent(project), encodeURIComponent(session));
75
+ if (project) return loadSessions(encodeURIComponent(project));
76
+ loadDashboard();
77
+ }
78
+
79
+ window.addEventListener('popstate', e => {
80
+ const d = e.state || {};
81
+ if (d.session && d.project) loadSessionDetail(encodeURIComponent(d.project), encodeURIComponent(d.session));
82
+ else if (d.project) loadSessions(encodeURIComponent(d.project));
83
+ else loadDashboard();
84
+ });
85
+
65
86
  function setBreadcrumb(parts) {
66
87
  $('breadcrumb').innerHTML = parts.map(p => `<div style="color:var(--text3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escHtml(p)}</div>`).join('');
67
88
  }
@@ -75,6 +96,7 @@ async function api(path) {
75
96
 
76
97
  /* ── Dashboard ── */
77
98
  async function loadDashboard() {
99
+ pushUrl({});
78
100
  showView('dashboard');
79
101
  setBreadcrumb(['Dashboard']);
80
102
  const container = $('view-dashboard');
@@ -178,6 +200,7 @@ function initActivityChart() {
178
200
  /* ── Sessions list ── */
179
201
  async function loadSessions(dirNameEncoded) {
180
202
  const dirName = decodeURIComponent(dirNameEncoded);
203
+ pushUrl({ project: dirName });
181
204
  showView('sessions');
182
205
 
183
206
  if (!state.projects) {
@@ -230,6 +253,7 @@ async function loadSessions(dirNameEncoded) {
230
253
  async function loadSessionDetail(dirNameEncoded, fileEncoded) {
231
254
  const dirName = decodeURIComponent(dirNameEncoded);
232
255
  const file = decodeURIComponent(fileEncoded);
256
+ pushUrl({ project: dirName, session: file });
233
257
  showView('session-detail');
234
258
  const container = $('view-session-detail');
235
259
  container.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text3)">Chargement...</div>';
@@ -264,6 +288,30 @@ function renderSessionDetail(session, dirName, file) {
264
288
  ? new Date(lastTimestamp) - new Date(firstTimestamp) : null;
265
289
  const hasAgents = agents && agents.length > 0;
266
290
 
291
+ // Aggregate MCPs and skills across all messages
292
+ const sessionMcps = new Set();
293
+ const sessionSkills = new Set();
294
+ for (const msg of session.messages) {
295
+ if (msg.type !== 'assistant') continue;
296
+ const content = Array.isArray(msg.content) ? msg.content : [];
297
+ for (const block of content) {
298
+ if (!block || block.type !== 'tool_use') continue;
299
+ if (block.name.startsWith('mcp__')) {
300
+ const parts = block.name.split('__');
301
+ const server = (parts[1] || '').replace(/^claude_ai_/, '').replace(/_/g, ' ');
302
+ if (server) sessionMcps.add(server);
303
+ } else if (block.name === 'Skill' && block.input?.skill) {
304
+ sessionSkills.add(block.input.skill);
305
+ }
306
+ }
307
+ }
308
+ const mcpBar = sessionMcps.size > 0
309
+ ? `<div class="session-tools-bar">${[...sessionMcps].map(s => `<span class="usage-chip"><span class="tag-mcp">mcp</span>${escHtml(s)}</span>`).join('')}</div>`
310
+ : '';
311
+ const skillBar = sessionSkills.size > 0
312
+ ? `<div class="session-tools-bar">${[...sessionSkills].map(s => `<span class="usage-chip"><span class="tag-skill">skill</span>${escHtml(s)}</span>`).join('')}</div>`
313
+ : '';
314
+
267
315
  container.innerHTML = `
268
316
  <button class="back-btn" onclick="loadSessions('${encodeURIComponent(dirName)}')">← Sessions</button>
269
317
  <div class="page-header">
@@ -278,6 +326,7 @@ function renderSessionDetail(session, dirName, file) {
278
326
  ${cwd ? `<div class="meta-item"><div class="meta-label">Répertoire</div><div class="meta-value mono">${escHtml(cwd.replace('/home/olivier-j/', '~/'))}</div></div>` : ''}
279
327
  ${gitBranch && gitBranch !== 'HEAD' ? `<div class="meta-item"><div class="meta-label">Branche</div><div class="meta-value mono" style="color:var(--green)">${escHtml(gitBranch)}</div></div>` : ''}
280
328
  </div>
329
+ ${mcpBar}${skillBar}
281
330
 
282
331
  <div class="token-bar">
283
332
  <div class="token-item"><div class="token-label">Input</div><div class="token-value input">${fmt(totalUsage.input)}</div></div>
@@ -821,15 +870,84 @@ function renderMessages(messages, ctx) {
821
870
  return true;
822
871
  });
823
872
  if (!filtered.length) return '<div class="empty"><p>Aucun message</p></div>';
824
- return filtered.map((m, i) => renderMessage(m, i, ctx)).join('');
873
+
874
+ // Track active skill/agent context across messages
875
+ let activeAgent = null;
876
+ return filtered.map((m, i) => {
877
+ if (m.type === 'assistant') {
878
+ const content = Array.isArray(m.content) ? m.content : [];
879
+ for (const block of content) {
880
+ if (!block || block.type !== 'tool_use') continue;
881
+ if (block.name === 'Skill') {
882
+ activeAgent = { kind: 'skill', name: block.input?.skill || '?' };
883
+ } else if (block.name === 'Agent') {
884
+ const name = block.input?.description || block.input?.subagent_type || 'agent';
885
+ activeAgent = { kind: 'agent', name: name.slice(0, 40) };
886
+ }
887
+ }
888
+ }
889
+ return renderMessage(m, i, ctx, activeAgent);
890
+ }).join('');
825
891
  }
826
892
 
827
- function renderMessage(m, i, ctx) {
893
+ function renderMessage(m, i, ctx, activeAgent) {
828
894
  const isUser = m.type === 'user';
829
895
  const isAgent = m.isSidechain;
830
896
  const collapseByDefault = !isUser && i > 0;
831
897
  const pfx = ctx === 'modal' ? 'modal-' : '';
832
898
 
899
+ // Extract tool info for assistant messages (used in header hint + usage chips)
900
+ const msgToolNames = [];
901
+ const mcpServers = [];
902
+ const skillsUsed = [];
903
+ if (!isUser) {
904
+ const contentArr = Array.isArray(m.content) ? m.content : [];
905
+ for (const block of contentArr) {
906
+ if (!block || block.type !== 'tool_use') continue;
907
+ msgToolNames.push(block.name);
908
+ if (block.name.startsWith('mcp__')) {
909
+ const parts = block.name.split('__');
910
+ const server = (parts[1] || '').replace(/^claude_ai_/, '').replace(/_/g, ' ');
911
+ if (server && !mcpServers.includes(server)) mcpServers.push(server);
912
+ } else if (block.name === 'Skill' && block.input?.skill) {
913
+ const sn = block.input.skill;
914
+ if (!skillsUsed.includes(sn)) skillsUsed.push(sn);
915
+ }
916
+ }
917
+ }
918
+ // Build a human-readable hint for the collapsed header
919
+ let toolHint = '';
920
+ if (!isUser && msgToolNames.length > 0) {
921
+ const hintParts = [];
922
+ // Agent spawns: show description
923
+ const contentArr2 = Array.isArray(m.content) ? m.content : [];
924
+ for (const block of contentArr2) {
925
+ if (!block || block.type !== 'tool_use') continue;
926
+ if (block.name === 'Agent') {
927
+ const desc = block.input?.description || block.input?.subagent_type || 'agent';
928
+ hintParts.push(`⬡ ${desc.slice(0, 40)}`);
929
+ } else if (block.name === 'Skill') {
930
+ hintParts.push(`skill:${block.input?.skill || '?'}`);
931
+ } else if (block.name.startsWith('mcp__')) {
932
+ const parts = block.name.split('__');
933
+ const server = (parts[1] || '').replace(/^claude_ai_/, '');
934
+ const tool = (parts[2] || '').replace(/^[a-z]+-/, '');
935
+ if (!hintParts.some(p => p.startsWith(`mcp:${server}`)))
936
+ hintParts.push(`mcp:${server}/${tool}`);
937
+ } else if (!['Read','Write','Edit','Bash','WebSearch','WebFetch','ToolSearch'].includes(block.name)) {
938
+ hintParts.push(block.name);
939
+ }
940
+ }
941
+ // Fallback to common tools if nothing notable
942
+ if (!hintParts.length) {
943
+ const common = msgToolNames.slice(0, 3);
944
+ hintParts.push(...common);
945
+ if (msgToolNames.length > 3) hintParts.push(`+${msgToolNames.length - 3}`);
946
+ }
947
+ toolHint = hintParts.slice(0, 3).join(' ');
948
+ if (hintParts.length > 3) toolHint += ` +${hintParts.length - 3}`;
949
+ }
950
+
833
951
  let bodyHtml = '';
834
952
  if (isUser) {
835
953
  if (typeof m.content === 'string') {
@@ -880,24 +998,36 @@ function renderMessage(m, i, ctx) {
880
998
  ${u.cache_creation_input_tokens ? `<span class="usage-chip">cache↑: <span class="cw">${fmt(u.cache_creation_input_tokens)}</span></span>` : ''}
881
999
  ${u.cache_read_input_tokens ? `<span class="usage-chip">cache↓: <span class="cr">${fmt(u.cache_read_input_tokens)}</span></span>` : ''}
882
1000
  ${m.model ? `<span class="usage-chip" style="color:var(--accent)">${escHtml(modelShort(m.model))}</span>` : ''}
1001
+ ${mcpServers.map(s => `<span class="usage-chip"><span class="tag-mcp">mcp</span>${escHtml(s)}</span>`).join('')}
1002
+ ${skillsUsed.map(s => `<span class="usage-chip"><span class="tag-skill">skill</span>${escHtml(s)}</span>`).join('')}
883
1003
  </div>`;
884
1004
  }
885
1005
 
886
1006
  const ts = m.timestamp ? fmtTime(m.timestamp) : '';
887
1007
  const msgId = `${pfx}msg-${i}`;
888
1008
 
1009
+ let agentBadge = '';
1010
+ if (!isUser && activeAgent) {
1011
+ const icon = activeAgent.kind === 'skill' ? '⚡' : '⬡';
1012
+ const col = activeAgent.kind === 'skill' ? 'var(--purple)' : 'var(--teal)';
1013
+ agentBadge = `<span class="msg-agent-badge" style="color:${col}">${icon} ${escHtml(activeAgent.name)}</span>`;
1014
+ }
1015
+
889
1016
  return `
890
1017
  <div class="message ${isAgent ? 'sidechain' : ''} ${collapseByDefault ? 'collapsed' : ''}" id="${msgId}" data-uuid="${escHtml(m.uuid || '')}">
891
1018
  <div class="message-header" onclick="this.parentElement.classList.toggle('collapsed')">
892
1019
  <span class="msg-role ${isUser ? 'user' : 'assistant'}">${isUser ? '👤 User' : '🤖 AI'}</span>
1020
+ ${agentBadge}
893
1021
  ${isAgent ? '<span class="badge badge-sidechain">agent</span>' : ''}
1022
+ ${toolHint ? `<span class="msg-tool-hint">${escHtml(toolHint)}</span>` : ''}
894
1023
  <div class="msg-meta">
895
1024
  ${ts ? `<span>${ts}</span>` : ''}
896
1025
  ${m.stopReason ? `<span style="color:var(--text3)">${escHtml(m.stopReason)}</span>` : ''}
897
1026
  </div>
898
1027
  <span class="msg-chevron">▼</span>
899
1028
  </div>
900
- <div class="message-body">${bodyHtml}${usageHtml}</div>
1029
+ <div class="message-body">${bodyHtml}</div>
1030
+ ${usageHtml ? `<div class="message-stats">${usageHtml}</div>` : ''}
901
1031
  </div>`;
902
1032
  }
903
1033
 
@@ -920,4 +1050,4 @@ window.setMsgFilter = setMsgFilter;
920
1050
  window.toggleTimeline = toggleTimeline;
921
1051
  window.closeAgentModal = closeAgentModal;
922
1052
 
923
- loadDashboard();
1053
+ restoreFromUrl();
@@ -3,8 +3,8 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>ccanalyser</title>
7
- <link rel="stylesheet" href="style.css" />
6
+ <title>ccanalyzer</title>
7
+ <link rel="stylesheet" href="style.css?v=6" />
8
8
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
9
9
  </head>
10
10
  <body>
@@ -12,7 +12,7 @@
12
12
  <nav id="sidebar">
13
13
  <div class="logo">
14
14
  <span class="logo-icon">◈</span>
15
- <span class="logo-text">ccanalyser</span>
15
+ <span class="logo-text">ccanalyzer</span>
16
16
  </div>
17
17
  <div id="nav-menu">
18
18
  <a href="#" class="nav-item active" data-view="dashboard">
@@ -33,6 +33,6 @@
33
33
  <div class="spinner"></div>
34
34
  </div>
35
35
 
36
- <script src="app.js"></script>
36
+ <script src="app.js?v=6"></script>
37
37
  </body>
38
38
  </html>
@@ -241,7 +241,7 @@ tr:hover td { background: var(--bg3); cursor: pointer; }
241
241
  display: flex;
242
242
  flex-wrap: wrap;
243
243
  gap: 12px;
244
- margin-bottom: 24px;
244
+ margin-bottom: 14px;
245
245
  padding: 16px 20px;
246
246
  background: var(--bg2);
247
247
  border: 1px solid var(--border);
@@ -262,7 +262,7 @@ tr:hover td { background: var(--bg3); cursor: pointer; }
262
262
  background: var(--bg2);
263
263
  border: 1px solid var(--border);
264
264
  border-radius: var(--radius2);
265
- margin-bottom: 24px;
265
+ margin-bottom: 14px;
266
266
  flex-wrap: wrap;
267
267
  }
268
268
  .token-item { display: flex; flex-direction: column; gap: 3px; }
@@ -335,6 +335,31 @@ tr:hover td { background: var(--bg3); cursor: pointer; }
335
335
  }
336
336
  .message.collapsed .message-body { display: none; }
337
337
 
338
+ .message-stats {
339
+ padding: 8px 16px 10px;
340
+ background: var(--bg2);
341
+ border-top: 1px solid var(--border);
342
+ }
343
+ .message-stats .usage-inline {
344
+ margin-top: 0;
345
+ padding-top: 0;
346
+ border-top: none;
347
+ }
348
+
349
+ .msg-agent-badge {
350
+ font-family: var(--font);
351
+ font-size: 10px;
352
+ font-weight: 600;
353
+ padding: 1px 6px;
354
+ border-radius: 4px;
355
+ background: #1a1e28;
356
+ flex-shrink: 0;
357
+ white-space: nowrap;
358
+ overflow: hidden;
359
+ text-overflow: ellipsis;
360
+ max-width: 200px;
361
+ }
362
+
338
363
  .msg-text {
339
364
  color: var(--text);
340
365
  line-height: 1.65;
@@ -405,6 +430,41 @@ tr:hover td { background: var(--bg3); cursor: pointer; }
405
430
  .usage-chip .cw { color: var(--yellow); }
406
431
  .usage-chip .cr { color: var(--teal); }
407
432
 
433
+ .tag-mcp, .tag-skill {
434
+ display: inline-block;
435
+ font-size: 9px;
436
+ font-weight: 700;
437
+ text-transform: uppercase;
438
+ letter-spacing: 0.5px;
439
+ padding: 1px 4px;
440
+ border-radius: 3px;
441
+ margin-right: 4px;
442
+ }
443
+ .tag-mcp { background: #1a2e2a; color: var(--teal); }
444
+ .tag-skill { background: #221a30; color: var(--purple); }
445
+
446
+ .msg-tool-hint {
447
+ font-family: var(--font);
448
+ font-size: 11px;
449
+ color: var(--text3);
450
+ overflow: hidden;
451
+ text-overflow: ellipsis;
452
+ white-space: nowrap;
453
+ max-width: 320px;
454
+ flex-shrink: 1;
455
+ }
456
+
457
+ .session-tools-bar {
458
+ display: flex;
459
+ flex-wrap: wrap;
460
+ gap: 8px;
461
+ padding: 10px 16px;
462
+ background: var(--bg2);
463
+ border: 1px solid var(--border);
464
+ border-radius: var(--radius);
465
+ margin-bottom: 14px;
466
+ }
467
+
408
468
  /* ── Back button ── */
409
469
  .back-btn {
410
470
  display: inline-flex;