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 +51 -0
- package/bin/cctape.js +94 -0
- package/lib/discover.js +153 -0
- package/lib/html-generator.js +496 -0
- package/lib/pages.js +393 -0
- package/lib/parser.js +348 -0
- package/lib/template-assets.js +593 -0
- package/package.json +41 -0
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
|
+
});
|
package/lib/discover.js
ADDED
|
@@ -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 };
|