ccakashic 0.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,51 @@
1
+ # cctape
2
+
3
+ A CLI tool to browse Claude Code session logs (`~/.claude/projects/`) as beautiful HTML in your browser.
4
+
5
+ ## Usage
6
+
7
+ ### npx
8
+
9
+ ```bash
10
+ npx cctape
11
+ ```
12
+
13
+ ### Run from source
14
+
15
+ ```bash
16
+ git clone git@github.com:ashimon83/cctape.git
17
+ cd cctape
18
+ npm start
19
+ ```
20
+
21
+ A local HTTP server starts and your browser opens automatically.
22
+
23
+ ## Features
24
+
25
+ - **Fully browser-based** — Project list → Session list → Conversation detail
26
+ - **Chat-style layout** — User / assistant messages in bubbles
27
+ - **Collapsible tool calls** — Bash, Read, Edit, and other tool invocations are collapsed into `<details>` blocks
28
+ - **Diff view** — File edits shown with red/green line highlights
29
+ - **Date navigation** — Side nav and sticky headers to jump between dates
30
+ - **Token stats** — Per-session input/output tokens, cache hit rate, and duration
31
+ - **Inline subagent conversations** — Subagent dialogues nested inside the Agent tool_use that spawned them
32
+ - **Permalinks** — Click any message timestamp to get a shareable URL
33
+ - **Dark mode** — Follows `prefers-color-scheme` automatically
34
+ - **Filter search** — Incremental filtering on list pages
35
+ - **Keyboard navigation** — `j` / `k` to move between messages
36
+ - **Zero dependencies** — Node.js built-in modules only
37
+
38
+ ## Options
39
+
40
+ ```bash
41
+ # Custom port (default: 3333)
42
+ CCTAPE_PORT=3000 npx cctape
43
+ ```
44
+
45
+ ## Requirements
46
+
47
+ - Node.js >= 18
48
+
49
+ ## License
50
+
51
+ MIT
package/bin/cctape.js ADDED
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const http = require('http');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { exec } = require('child_process');
8
+ const { listProjects, listSessions } = require('../lib/discover');
9
+ const { parseSession } = require('../lib/parser');
10
+ const { generate } = require('../lib/html-generator');
11
+ const { generateIndex, generateSessionList } = require('../lib/pages');
12
+
13
+ function openInBrowser(url) {
14
+ const cmd = process.platform === 'darwin' ? 'open'
15
+ : process.platform === 'win32' ? 'start'
16
+ : 'xdg-open';
17
+ exec(`${cmd} "${url}"`);
18
+ }
19
+
20
+ const PORT = parseInt(process.env.CCTAPE_PORT) || 3333;
21
+
22
+ const server = http.createServer(async (req, res) => {
23
+ try {
24
+ const url = new URL(req.url, `http://localhost`);
25
+ const pathname = url.pathname;
26
+
27
+ if (pathname === '/' || pathname === '') {
28
+ // Project index
29
+ const projects = listProjects();
30
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
31
+ res.end(generateIndex(projects));
32
+ return;
33
+ }
34
+
35
+ const projectMatch = pathname.match(/^\/project\/(.+)$/);
36
+ if (projectMatch && !pathname.includes('/session/')) {
37
+ // Session list for a project
38
+ const rawName = decodeURIComponent(projectMatch[1]);
39
+ const projects = listProjects();
40
+ const project = projects.find(p => p.rawName === rawName);
41
+ if (!project) {
42
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
43
+ res.end('Project not found');
44
+ return;
45
+ }
46
+ const sessions = await listSessions(project.dir);
47
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
48
+ res.end(generateSessionList(project, sessions));
49
+ return;
50
+ }
51
+
52
+ const sessionMatch = pathname.match(/^\/project\/(.+)\/session\/(.+)$/);
53
+ if (sessionMatch) {
54
+ // Render a specific session
55
+ const rawName = decodeURIComponent(sessionMatch[1]);
56
+ const sessionId = decodeURIComponent(sessionMatch[2]);
57
+ const projects = listProjects();
58
+ const project = projects.find(p => p.rawName === rawName);
59
+ if (!project) {
60
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
61
+ res.end('Project not found');
62
+ return;
63
+ }
64
+ const sessionPath = path.join(project.dir, `${sessionId}.jsonl`);
65
+ if (!fs.existsSync(sessionPath)) {
66
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
67
+ res.end('Session not found');
68
+ return;
69
+ }
70
+ const sessions = await listSessions(project.dir);
71
+ const session = sessions.find(s => s.id === sessionId) || { id: sessionId, path: sessionPath };
72
+ const parsed = await parseSession(sessionPath);
73
+ const html = generate(parsed, { projectName: project.name, session, backUrl: `/project/${encodeURIComponent(rawName)}` });
74
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
75
+ res.end(html);
76
+ return;
77
+ }
78
+
79
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
80
+ res.end('Not found');
81
+ } catch (err) {
82
+ console.error(err);
83
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
84
+ res.end('Internal server error');
85
+ }
86
+ });
87
+
88
+ server.listen(PORT, '127.0.0.1', () => {
89
+ const addr = server.address();
90
+ const url = `http://127.0.0.1:${addr.port}`;
91
+ console.log(`cctape running at ${url}`);
92
+ console.log('Press Ctrl+C to stop');
93
+ openInBrowser(url);
94
+ });
@@ -0,0 +1,153 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const readline = require('readline');
7
+
8
+ const CLAUDE_DIR = path.join(os.homedir(), '.claude', 'projects');
9
+
10
+ function decodeDirName(dirName) {
11
+ // Directory names encode paths: /Users/foo/bar → -Users-foo-bar
12
+ // This is lossy (dots become dashes too), but good enough for display
13
+ if (dirName.startsWith('-')) {
14
+ return '/' + dirName.slice(1).replace(/-/g, '/');
15
+ }
16
+ return dirName;
17
+ }
18
+
19
+ function listProjects() {
20
+ if (!fs.existsSync(CLAUDE_DIR)) {
21
+ return [];
22
+ }
23
+
24
+ const entries = fs.readdirSync(CLAUDE_DIR, { withFileTypes: true });
25
+ const projects = [];
26
+
27
+ for (const entry of entries) {
28
+ if (!entry.isDirectory()) continue;
29
+
30
+ const projectDir = path.join(CLAUDE_DIR, entry.name);
31
+ const jsonlFiles = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
32
+
33
+ if (jsonlFiles.length === 0) continue;
34
+
35
+ // Get most recent modification time
36
+ let lastModified = 0;
37
+ for (const f of jsonlFiles) {
38
+ const stat = fs.statSync(path.join(projectDir, f));
39
+ if (stat.mtimeMs > lastModified) lastModified = stat.mtimeMs;
40
+ }
41
+
42
+ projects.push({
43
+ name: decodeDirName(entry.name),
44
+ rawName: entry.name,
45
+ dir: projectDir,
46
+ sessionCount: jsonlFiles.length,
47
+ lastModified: new Date(lastModified),
48
+ });
49
+ }
50
+
51
+ projects.sort((a, b) => b.lastModified - a.lastModified);
52
+ return projects;
53
+ }
54
+
55
+ async function getSessionPreview(filePath) {
56
+ return new Promise((resolve) => {
57
+ const result = {
58
+ id: path.basename(filePath, '.jsonl'),
59
+ path: filePath,
60
+ timestamp: null,
61
+ lastModified: fs.statSync(filePath).mtimeMs,
62
+ preview: '',
63
+ gitBranch: null,
64
+ slug: null,
65
+ model: null,
66
+ hasSubagents: false,
67
+ totalTokens: 0,
68
+ outputTokens: 0,
69
+ };
70
+
71
+ // Check for subagents directory
72
+ const sessionDir = path.join(path.dirname(filePath), result.id);
73
+ if (fs.existsSync(path.join(sessionDir, 'subagents'))) {
74
+ result.hasSubagents = true;
75
+ }
76
+
77
+ const rl = readline.createInterface({
78
+ input: fs.createReadStream(filePath, { encoding: 'utf-8' }),
79
+ crlfDelay: Infinity,
80
+ });
81
+
82
+ let foundPreview = false;
83
+
84
+ rl.on('line', (line) => {
85
+ try {
86
+ const obj = JSON.parse(line);
87
+
88
+ if (!result.timestamp && obj.timestamp) {
89
+ result.timestamp = obj.timestamp;
90
+ }
91
+ if (!result.gitBranch && obj.gitBranch) {
92
+ result.gitBranch = obj.gitBranch;
93
+ }
94
+ if (!result.slug && obj.slug) {
95
+ result.slug = obj.slug;
96
+ }
97
+
98
+ // Extract model from first assistant message
99
+ if (!result.model && obj.type === 'assistant' && obj.message?.model) {
100
+ result.model = obj.message.model;
101
+ }
102
+
103
+ // Aggregate token usage
104
+ if (obj.type === 'assistant' && obj.message?.usage) {
105
+ const u = obj.message.usage;
106
+ result.totalTokens += (u.input_tokens || 0) + (u.output_tokens || 0)
107
+ + (u.cache_creation_input_tokens || 0) + (u.cache_read_input_tokens || 0);
108
+ result.outputTokens += u.output_tokens || 0;
109
+ }
110
+
111
+ // Extract first user message as preview
112
+ if (!foundPreview && obj.type === 'user' && obj.message) {
113
+ const content = obj.message.content;
114
+ let text = '';
115
+ if (typeof content === 'string') {
116
+ text = content;
117
+ } else if (Array.isArray(content)) {
118
+ for (const block of content) {
119
+ if (block.type === 'text' && block.text) {
120
+ text = block.text;
121
+ break;
122
+ }
123
+ }
124
+ }
125
+ if (text) {
126
+ result.preview = text.replace(/\n/g, ' ').slice(0, 100);
127
+ foundPreview = true;
128
+ }
129
+ }
130
+ } catch {
131
+ // skip malformed lines
132
+ }
133
+ });
134
+
135
+ rl.on('close', () => resolve(result));
136
+ rl.on('error', () => resolve(result));
137
+ });
138
+ }
139
+
140
+ async function listSessions(projectDir) {
141
+ const jsonlFiles = fs.readdirSync(projectDir)
142
+ .filter(f => f.endsWith('.jsonl'))
143
+ .map(f => path.join(projectDir, f));
144
+
145
+ const sessions = await Promise.all(jsonlFiles.map(getSessionPreview));
146
+
147
+ // Sort by file modification time descending (most recently active first)
148
+ sessions.sort((a, b) => b.lastModified - a.lastModified);
149
+
150
+ return sessions;
151
+ }
152
+
153
+ module.exports = { listProjects, listSessions, decodeDirName, CLAUDE_DIR };