@makimoto/cc-log-viewer 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shimpei Makimoto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # @makimoto/cc-log-viewer
2
+
3
+ A local web app for searching and browsing [Claude Code](https://docs.anthropic.com/en/docs/claude-code) session logs with full-text search.
4
+
5
+ Indexes all conversation data stored under `~/.claude/projects/` into SQLite FTS5 and serves a searchable web interface.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ npx @makimoto/cc-log-viewer
11
+ ```
12
+
13
+ Opens at http://127.0.0.1:8899
14
+
15
+ ## Features
16
+
17
+ - Full-text search across all Claude Code sessions using SQLite FTS5
18
+ - Filter by project, git branch, and role (user/assistant)
19
+ - Session list view with metadata (summary, branch, timestamps)
20
+ - Session detail view with full conversation history
21
+ - Copy `claude --resume <session-id>` command to resume sessions from terminal
22
+ - Auto-reindex every 3 minutes while the browser tab is open
23
+ - Incremental indexing (only processes new/changed sessions)
24
+ - Keyboard shortcut: `/` to focus search
25
+
26
+ ## Installation
27
+
28
+ ```bash
29
+ # Run directly (no install needed)
30
+ npx @makimoto/cc-log-viewer
31
+
32
+ # Or install globally
33
+ npm install -g @makimoto/cc-log-viewer
34
+ cc-log-viewer
35
+ ```
36
+
37
+ ## CLI Options
38
+
39
+ ```
40
+ cc-log-viewer [options]
41
+
42
+ Options:
43
+ --port <number> Port to listen on (default: 8899)
44
+ --host <string> Host to bind to (default: 127.0.0.1)
45
+ --reindex Reindex sessions and exit
46
+ --reindex-force Force full reindex and exit
47
+ --stats Show index stats and exit
48
+ ```
49
+
50
+ ## How It Works
51
+
52
+ Claude Code stores session data as JSONL files under `~/.claude/projects/`. This tool:
53
+
54
+ 1. Scans all project directories for session metadata (`sessions-index.json`) and raw JSONL files
55
+ 2. Extracts user and assistant messages, ignoring tool calls and system data
56
+ 3. Stores everything in a SQLite database with FTS5 at `~/.claude/cc-log-viewer.db`
57
+ 4. Serves a web UI for searching and browsing
58
+
59
+ ## Configuration
60
+
61
+ If you use a custom Claude Code config directory via the `CLAUDE_CONFIG_DIR` environment variable, this tool respects it automatically:
62
+
63
+ ```bash
64
+ CLAUDE_CONFIG_DIR=/custom/path/to/claude npx @makimoto/cc-log-viewer
65
+ ```
66
+
67
+ By default, `~/.claude/` is used.
68
+
69
+ ## Requirements
70
+
71
+ - Node.js >= 18
72
+ - Claude Code (session data must exist under the Claude config directory)
73
+
74
+ ## License
75
+
76
+ MIT
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+
3
+ 'use strict';
4
+
5
+ const args = process.argv.slice(2);
6
+
7
+ function getArg(name) {
8
+ const idx = args.indexOf(name);
9
+ if (idx === -1) return undefined;
10
+ return args[idx + 1];
11
+ }
12
+
13
+ function hasFlag(name) {
14
+ return args.includes(name);
15
+ }
16
+
17
+ const port = Number(getArg('--port')) || 8899;
18
+ const host = getArg('--host') || '127.0.0.1';
19
+
20
+ const { buildIndex, getStats } = require('../src/indexer.js');
21
+
22
+ function main() {
23
+ if (hasFlag('--stats')) {
24
+ const stats = getStats();
25
+ for (const [key, value] of Object.entries(stats)) {
26
+ console.log(`${key}: ${value}`);
27
+ }
28
+ process.exit(0);
29
+ }
30
+
31
+ if (hasFlag('--reindex') || hasFlag('--reindex-force')) {
32
+ const force = hasFlag('--reindex-force');
33
+ console.log(force ? 'Force reindexing...' : 'Reindexing...');
34
+ const result = buildIndex(force);
35
+ console.log(`Indexed ${result.sessionsIndexed} sessions, skipped ${result.sessionsSkipped}`);
36
+ process.exit(0);
37
+ }
38
+
39
+ console.log('Building index...');
40
+ const result = buildIndex();
41
+ console.log(`Indexed ${result.sessionsIndexed} sessions, skipped ${result.sessionsSkipped}`);
42
+
43
+ const { start } = require('../src/server.js');
44
+ start(host, port);
45
+ }
46
+
47
+ try {
48
+ main();
49
+ } catch (err) {
50
+ console.error(err);
51
+ process.exit(1);
52
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@makimoto/cc-log-viewer",
3
+ "version": "0.1.0",
4
+ "description": "Search through Claude Code session logs with full-text search",
5
+ "author": "Shimpei Makimoto",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/makimoto/cc-log-viewer.git"
10
+ },
11
+ "homepage": "https://github.com/makimoto/cc-log-viewer",
12
+ "keywords": [
13
+ "claude",
14
+ "claude-code",
15
+ "search",
16
+ "session",
17
+ "logs"
18
+ ],
19
+ "bin": {
20
+ "cc-log-viewer": "./bin/cc-log-viewer.js"
21
+ },
22
+ "files": [
23
+ "bin/",
24
+ "src/"
25
+ ],
26
+ "scripts": {
27
+ "test": "node --test test/*.test.js"
28
+ },
29
+ "dependencies": {
30
+ "better-sqlite3": "^11.0.0",
31
+ "express": "^4.21.0"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "engines": {
37
+ "node": ">=18"
38
+ }
39
+ }
package/src/indexer.js ADDED
@@ -0,0 +1,506 @@
1
+ /**
2
+ * Index Claude Code session data into SQLite FTS5 for full-text search.
3
+ *
4
+ * Reads session metadata and conversation messages from ~/.claude/projects/
5
+ * and stores them in a SQLite database with FTS5 virtual tables.
6
+ */
7
+
8
+ "use strict";
9
+
10
+ const os = require("os");
11
+ const path = require("path");
12
+ const fs = require("fs");
13
+ const Database = require("better-sqlite3");
14
+
15
+ const CLAUDE_DIR = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude");
16
+ const PROJECTS_DIR = path.join(CLAUDE_DIR, "projects");
17
+ const DB_PATH = path.join(CLAUDE_DIR, "cc-log-viewer.db");
18
+
19
+ const SCHEMA_SQL = `
20
+ CREATE TABLE IF NOT EXISTS sessions (
21
+ session_id TEXT PRIMARY KEY,
22
+ project_path TEXT,
23
+ project_name TEXT,
24
+ git_branch TEXT,
25
+ summary TEXT,
26
+ first_prompt TEXT,
27
+ message_count INTEGER,
28
+ created TEXT,
29
+ modified TEXT,
30
+ indexed_at REAL
31
+ );
32
+
33
+ CREATE VIRTUAL TABLE IF NOT EXISTS messages USING fts5(
34
+ session_id,
35
+ role,
36
+ content,
37
+ timestamp,
38
+ message_type,
39
+ tokenize='unicode61'
40
+ );
41
+
42
+ CREATE TABLE IF NOT EXISTS index_meta (
43
+ key TEXT PRIMARY KEY,
44
+ value TEXT
45
+ );
46
+ `;
47
+
48
+ function getDbPath() {
49
+ return DB_PATH;
50
+ }
51
+
52
+ function _connect() {
53
+ const dir = path.dirname(DB_PATH);
54
+ if (!fs.existsSync(dir)) {
55
+ fs.mkdirSync(dir, { recursive: true });
56
+ }
57
+ const db = new Database(DB_PATH);
58
+ db.pragma("journal_mode = WAL");
59
+ return db;
60
+ }
61
+
62
+ function _initDb(db) {
63
+ db.exec(SCHEMA_SQL);
64
+ }
65
+
66
+ /**
67
+ * Extract plain text from a message content field.
68
+ *
69
+ * Content can be either a string or a list of content blocks.
70
+ * For content block arrays, only "text" type blocks are extracted.
71
+ */
72
+ function _extractTextFromContent(content) {
73
+ if (typeof content === "string") {
74
+ return content;
75
+ }
76
+ if (Array.isArray(content)) {
77
+ const parts = [];
78
+ for (const block of content) {
79
+ if (
80
+ block !== null &&
81
+ typeof block === "object" &&
82
+ block.type === "text"
83
+ ) {
84
+ const text = block.text || "";
85
+ if (text) {
86
+ parts.push(text);
87
+ }
88
+ }
89
+ }
90
+ return parts.join("\n");
91
+ }
92
+ return "";
93
+ }
94
+
95
+ /**
96
+ * Parse a session JSONL file and return an array of
97
+ * { role, content, timestamp, messageType } objects.
98
+ *
99
+ * Only user and assistant messages with actual text content are included.
100
+ */
101
+ function _parseJsonlMessages(jsonlPath) {
102
+ const results = [];
103
+ let data;
104
+ try {
105
+ data = fs.readFileSync(jsonlPath, "utf-8");
106
+ } catch (e) {
107
+ console.warn(`Cannot read ${jsonlPath}: ${e.message}`);
108
+ return results;
109
+ }
110
+
111
+ const lines = data.split("\n");
112
+ for (let i = 0; i < lines.length; i++) {
113
+ const line = lines[i].trim();
114
+ if (!line) continue;
115
+
116
+ let obj;
117
+ try {
118
+ obj = JSON.parse(line);
119
+ } catch {
120
+ continue;
121
+ }
122
+
123
+ const msgType = obj.type;
124
+ if (msgType !== "user" && msgType !== "assistant") continue;
125
+
126
+ const message = obj.message;
127
+ if (message === null || typeof message !== "object") continue;
128
+
129
+ const role = message.role || "";
130
+ if (role !== "user" && role !== "assistant") continue;
131
+
132
+ const rawContent = message.content;
133
+ if (rawContent === undefined || rawContent === null) continue;
134
+
135
+ const text = _extractTextFromContent(rawContent);
136
+ if (!text.trim()) continue;
137
+
138
+ const timestamp = obj.timestamp || "";
139
+ results.push({ role, content: text, timestamp, messageType: msgType });
140
+ }
141
+
142
+ return results;
143
+ }
144
+
145
+ /**
146
+ * Extract session metadata by reading the entire JSONL file.
147
+ *
148
+ * Used as a fallback when sessions-index.json is not available.
149
+ */
150
+ function _extractMetadataFromJsonl(jsonlPath) {
151
+ const sessionId = path.basename(jsonlPath, ".jsonl");
152
+ const stat = fs.statSync(jsonlPath);
153
+ const meta = {
154
+ sessionId,
155
+ fullPath: jsonlPath,
156
+ fileMtime: Math.floor(stat.mtimeMs),
157
+ firstPrompt: "",
158
+ summary: "",
159
+ messageCount: 0,
160
+ created: "",
161
+ modified: "",
162
+ gitBranch: "",
163
+ projectPath: "",
164
+ };
165
+
166
+ let msgCount = 0;
167
+ let firstTimestamp = null;
168
+ let lastTimestamp = null;
169
+
170
+ let data;
171
+ try {
172
+ data = fs.readFileSync(jsonlPath, "utf-8");
173
+ } catch (e) {
174
+ console.warn(`Cannot read ${jsonlPath}: ${e.message}`);
175
+ return meta;
176
+ }
177
+
178
+ const lines = data.split("\n");
179
+ for (const rawLine of lines) {
180
+ const line = rawLine.trim();
181
+ if (!line) continue;
182
+
183
+ let obj;
184
+ try {
185
+ obj = JSON.parse(line);
186
+ } catch {
187
+ continue;
188
+ }
189
+
190
+ const msgType = obj.type;
191
+ const ts = obj.timestamp || "";
192
+
193
+ if (typeof ts === "string" && ts) {
194
+ if (firstTimestamp === null) {
195
+ firstTimestamp = ts;
196
+ }
197
+ lastTimestamp = ts;
198
+ }
199
+
200
+ if (!meta.gitBranch && obj.gitBranch) {
201
+ meta.gitBranch = obj.gitBranch;
202
+ }
203
+ if (!meta.projectPath && obj.cwd) {
204
+ meta.projectPath = obj.cwd;
205
+ }
206
+ if (!meta.sessionId && obj.sessionId) {
207
+ meta.sessionId = obj.sessionId;
208
+ }
209
+
210
+ if (msgType === "user" || msgType === "assistant") {
211
+ msgCount++;
212
+ const message = obj.message || {};
213
+ if (msgType === "user" && !meta.firstPrompt) {
214
+ const content = message.content || "";
215
+ if (typeof content === "string") {
216
+ meta.firstPrompt = content.slice(0, 200);
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ meta.messageCount = msgCount;
223
+ if (firstTimestamp) {
224
+ meta.created = firstTimestamp;
225
+ }
226
+ if (lastTimestamp) {
227
+ meta.modified = lastTimestamp;
228
+ }
229
+
230
+ return meta;
231
+ }
232
+
233
+ /**
234
+ * Discover all sessions from ~/.claude/projects/.
235
+ *
236
+ * First reads sessions-index.json if available, then falls back to
237
+ * scanning JSONL files directly for projects without an index.
238
+ * Returns an array of [projectDirName, entryDict] tuples.
239
+ */
240
+ function _discoverSessions() {
241
+ const results = [];
242
+
243
+ if (!fs.existsSync(PROJECTS_DIR) || !fs.statSync(PROJECTS_DIR).isDirectory()) {
244
+ console.warn(`Projects directory not found: ${PROJECTS_DIR}`);
245
+ return results;
246
+ }
247
+
248
+ const projectDirs = fs.readdirSync(PROJECTS_DIR).sort();
249
+
250
+ for (const dirName of projectDirs) {
251
+ const projectDir = path.join(PROJECTS_DIR, dirName);
252
+ let dirStat;
253
+ try {
254
+ dirStat = fs.statSync(projectDir);
255
+ } catch {
256
+ continue;
257
+ }
258
+ if (!dirStat.isDirectory()) continue;
259
+
260
+ const indexFile = path.join(projectDir, "sessions-index.json");
261
+ const indexedSessionIds = new Set();
262
+
263
+ if (fs.existsSync(indexFile)) {
264
+ let data = {};
265
+ try {
266
+ data = JSON.parse(fs.readFileSync(indexFile, "utf-8"));
267
+ } catch (e) {
268
+ console.warn(`Cannot read ${indexFile}: ${e.message}`);
269
+ }
270
+
271
+ const entries = data.entries || [];
272
+ for (const entry of entries) {
273
+ const sid = entry.sessionId || "";
274
+ if (sid) {
275
+ indexedSessionIds.add(sid);
276
+ }
277
+ results.push([dirName, entry]);
278
+ }
279
+ }
280
+
281
+ // Scan for JSONL files not covered by sessions-index.json
282
+ let files;
283
+ try {
284
+ files = fs.readdirSync(projectDir).sort();
285
+ } catch {
286
+ continue;
287
+ }
288
+
289
+ for (const fileName of files) {
290
+ if (!fileName.endsWith(".jsonl")) continue;
291
+ const sessionId = path.basename(fileName, ".jsonl");
292
+ if (indexedSessionIds.has(sessionId)) continue;
293
+
294
+ const jsonlFile = path.join(projectDir, fileName);
295
+ const entry = _extractMetadataFromJsonl(jsonlFile, dirName);
296
+ results.push([dirName, entry]);
297
+ }
298
+ }
299
+
300
+ return results;
301
+ }
302
+
303
+ /**
304
+ * Build the search index from Claude Code session data.
305
+ *
306
+ * @param {boolean} force - If true, drop and rebuild everything.
307
+ * If false, only index sessions whose modified time is newer than
308
+ * the last indexed time.
309
+ * @returns {{ sessionsIndexed: number, messagesIndexed: number, sessionsSkipped: number }}
310
+ */
311
+ function buildIndex(force = false) {
312
+ const db = _connect();
313
+ _initDb(db);
314
+
315
+ if (force) {
316
+ db.exec("DELETE FROM sessions");
317
+ db.exec("DELETE FROM messages");
318
+ }
319
+
320
+ const stats = { sessionsIndexed: 0, messagesIndexed: 0, sessionsSkipped: 0 };
321
+
322
+ const existing = {};
323
+ if (!force) {
324
+ const rows = db.prepare("SELECT session_id, indexed_at FROM sessions").all();
325
+ for (const row of rows) {
326
+ existing[row.session_id] = row.indexed_at;
327
+ }
328
+ }
329
+
330
+ const insertSession = db.prepare(
331
+ `INSERT INTO sessions
332
+ (session_id, project_path, project_name, git_branch, summary,
333
+ first_prompt, message_count, created, modified, indexed_at)
334
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
335
+ );
336
+
337
+ const insertMessages = db.prepare(
338
+ `INSERT INTO messages (session_id, role, content, timestamp, message_type)
339
+ VALUES (?, ?, ?, ?, ?)`
340
+ );
341
+
342
+ const deleteSession = db.prepare("DELETE FROM sessions WHERE session_id = ?");
343
+ const deleteMessages = db.prepare("DELETE FROM messages WHERE session_id = ?");
344
+
345
+ const sessions = _discoverSessions();
346
+
347
+ const insertBatch = db.transaction((batch) => {
348
+ for (const row of batch) {
349
+ insertMessages.run(row[0], row[1], row[2], row[3], row[4]);
350
+ }
351
+ });
352
+
353
+ for (const [projectDirName, entry] of sessions) {
354
+ const sessionId = entry.sessionId || "";
355
+ if (!sessionId) continue;
356
+
357
+ const modified = entry.modified || "";
358
+ const fileMtime = entry.fileMtime || 0;
359
+
360
+ if (!force && sessionId in existing) {
361
+ const indexedAt = existing[sessionId];
362
+ if (indexedAt && fileMtime && indexedAt >= fileMtime / 1000.0) {
363
+ stats.sessionsSkipped++;
364
+ continue;
365
+ }
366
+ }
367
+
368
+ const projectPath = entry.projectPath || "";
369
+ const projectName = projectDirName;
370
+ const gitBranch = entry.gitBranch || "";
371
+ const summary = entry.summary || "";
372
+ const firstPrompt = entry.firstPrompt || "";
373
+ const messageCount = entry.messageCount || 0;
374
+ const created = entry.created || "";
375
+ const now = Date.now() / 1000.0;
376
+
377
+ let jsonlPath = entry.fullPath || "";
378
+ if (!jsonlPath || !fs.existsSync(jsonlPath)) {
379
+ jsonlPath = path.join(PROJECTS_DIR, projectDirName, `${sessionId}.jsonl`);
380
+ }
381
+
382
+ if (sessionId in existing) {
383
+ deleteSession.run(sessionId);
384
+ deleteMessages.run(sessionId);
385
+ }
386
+
387
+ insertSession.run(
388
+ sessionId,
389
+ projectPath,
390
+ projectName,
391
+ gitBranch,
392
+ summary,
393
+ firstPrompt,
394
+ messageCount,
395
+ created,
396
+ modified,
397
+ now
398
+ );
399
+
400
+ let msgCount = 0;
401
+ if (fs.existsSync(jsonlPath)) {
402
+ const messages = _parseJsonlMessages(jsonlPath);
403
+ const batch = [];
404
+ for (const msg of messages) {
405
+ batch.push([sessionId, msg.role, msg.content, msg.timestamp, msg.messageType]);
406
+ msgCount++;
407
+ if (batch.length >= 500) {
408
+ insertBatch(batch);
409
+ batch.length = 0;
410
+ }
411
+ }
412
+ if (batch.length > 0) {
413
+ insertBatch(batch);
414
+ }
415
+ }
416
+
417
+ stats.sessionsIndexed++;
418
+ stats.messagesIndexed += msgCount;
419
+
420
+ if (stats.sessionsIndexed % 50 === 0) {
421
+ // Periodic implicit checkpoint via WAL
422
+ }
423
+ }
424
+
425
+ db.close();
426
+ return stats;
427
+ }
428
+
429
+ /**
430
+ * Return index statistics.
431
+ *
432
+ * @returns {{ sessionCount: number, messageCount: number, dbSizeBytes: number, projectCount: number, dbPath: string }}
433
+ */
434
+ function getStats() {
435
+ if (!fs.existsSync(DB_PATH)) {
436
+ return {
437
+ sessionCount: 0,
438
+ messageCount: 0,
439
+ dbSizeBytes: 0,
440
+ projectCount: 0,
441
+ dbPath: DB_PATH,
442
+ };
443
+ }
444
+
445
+ const db = _connect();
446
+ try {
447
+ const sessionCount = db.prepare("SELECT COUNT(*) AS c FROM sessions").get().c;
448
+ const messageCount = db.prepare("SELECT COUNT(*) AS c FROM messages").get().c;
449
+ const projectCount = db
450
+ .prepare("SELECT COUNT(DISTINCT project_name) AS c FROM sessions")
451
+ .get().c;
452
+
453
+ const dbSizeBytes = fs.statSync(DB_PATH).size;
454
+
455
+ return {
456
+ sessionCount,
457
+ messageCount,
458
+ dbSizeBytes,
459
+ projectCount,
460
+ dbPath: DB_PATH,
461
+ };
462
+ } catch {
463
+ return {
464
+ sessionCount: 0,
465
+ messageCount: 0,
466
+ dbSizeBytes: 0,
467
+ projectCount: 0,
468
+ dbPath: DB_PATH,
469
+ };
470
+ } finally {
471
+ db.close();
472
+ }
473
+ }
474
+
475
+ module.exports = {
476
+ buildIndex,
477
+ getStats,
478
+ getDbPath,
479
+ DB_PATH,
480
+ };
481
+
482
+ // CLI entry point
483
+ if (require.main === module) {
484
+ const args = process.argv.slice(2);
485
+ const forceFlag = args.includes("--force");
486
+ const statsFlag = args.includes("--stats");
487
+
488
+ if (statsFlag) {
489
+ const s = getStats();
490
+ for (const [k, v] of Object.entries(s)) {
491
+ console.log(` ${k}: ${v}`);
492
+ }
493
+ } else {
494
+ const result = buildIndex(forceFlag);
495
+ console.log("Indexing complete:");
496
+ for (const [k, v] of Object.entries(result)) {
497
+ console.log(` ${k}: ${v}`);
498
+ }
499
+ console.log();
500
+ console.log("Stats:");
501
+ const s = getStats();
502
+ for (const [k, v] of Object.entries(s)) {
503
+ console.log(` ${k}: ${v}`);
504
+ }
505
+ }
506
+ }