ccakashic 0.2.4 → 0.2.6

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.
@@ -1,7 +1,9 @@
1
- 'use strict';
2
-
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getCSS = getCSS;
4
+ exports.getAppJS = getAppJS;
3
5
  function getCSS() {
4
- return `
6
+ return `
5
7
  :root {
6
8
  --bg: #fafaf9;
7
9
  --bg-secondary: #f5f5f4;
@@ -568,9 +570,8 @@ body {
568
570
  }
569
571
  `;
570
572
  }
571
-
572
573
  function getAppJS() {
573
- return `
574
+ return `
574
575
  (function() {
575
576
  // Simple markdown renderer (no external dependency)
576
577
  function renderMarkdown(text) {
@@ -671,5 +672,3 @@ function getAppJS() {
671
672
  })();
672
673
  `;
673
674
  }
674
-
675
- module.exports = { getCSS, getAppJS };
package/package.json CHANGED
@@ -1,13 +1,12 @@
1
1
  {
2
2
  "name": "ccakashic",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Browse Claude Code session logs (~/.claude/projects/) as beautiful HTML in your browser — an Akashic Record of your Claude Code sessions",
5
5
  "bin": {
6
- "ccakashic": "bin/ccakashic.js"
6
+ "ccakashic": "dist/bin/ccakashic.js"
7
7
  },
8
8
  "files": [
9
- "bin",
10
- "lib",
9
+ "dist",
11
10
  "vendor",
12
11
  "README.md"
13
12
  ],
@@ -15,9 +14,19 @@
15
14
  "node": ">=18"
16
15
  },
17
16
  "scripts": {
18
- "start": "node bin/ccakashic.js"
17
+ "build": "tsc",
18
+ "start": "npm run build && node dist/bin/ccakashic.js",
19
+ "dev": "tsc --watch",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest",
22
+ "prepack": "npm run build"
19
23
  },
20
24
  "dependencies": {},
25
+ "devDependencies": {
26
+ "@types/node": "^22.0.0",
27
+ "typescript": "^5.6.0",
28
+ "vitest": "^2.1.0"
29
+ },
21
30
  "keywords": [
22
31
  "claude",
23
32
  "claude-code",
package/bin/ccakashic.js DELETED
@@ -1,225 +0,0 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- const http = require('http');
5
- const fs = require('fs');
6
- const os = require('os');
7
- const path = require('path');
8
- const { exec } = require('child_process');
9
- const { listProjects, listSessions, findSessionForCwd } = require('../lib/discover');
10
- const { parseSession } = require('../lib/parser');
11
- const { generate } = require('../lib/html-generator');
12
- const { generateIndex, generateSessionList } = require('../lib/pages');
13
- const pkg = require('../package.json');
14
-
15
- function openInBrowser(url) {
16
- const cmd = process.platform === 'darwin' ? 'open'
17
- : process.platform === 'win32' ? 'start'
18
- : 'xdg-open';
19
- exec(`${cmd} "${url}"`);
20
- }
21
-
22
- const PORT = parseInt(process.env.CCAKASHIC_PORT) || 3333;
23
- const MAX_PORT_TRIES = 20;
24
- const LOCK_FILE = path.join(os.tmpdir(), `ccakashic-${os.userInfo().username || 'user'}.json`);
25
-
26
- const server = http.createServer(async (req, res) => {
27
- try {
28
- const url = new URL(req.url, `http://localhost`);
29
- const pathname = url.pathname;
30
-
31
- // Health/identity endpoint used to detect an already-running ccakashic
32
- if (pathname === '/__ccakashic') {
33
- res.writeHead(200, { 'Content-Type': 'application/json' });
34
- res.end(JSON.stringify({ name: 'ccakashic', version: pkg.version }));
35
- return;
36
- }
37
-
38
- if (pathname === '/' || pathname === '') {
39
- const projects = listProjects();
40
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
41
- res.end(generateIndex(projects));
42
- return;
43
- }
44
-
45
- const projectMatch = pathname.match(/^\/project\/(.+)$/);
46
- if (projectMatch && !pathname.includes('/session/')) {
47
- const rawName = decodeURIComponent(projectMatch[1]);
48
- const projects = listProjects();
49
- const project = projects.find(p => p.rawName === rawName);
50
- if (!project) {
51
- res.writeHead(404, { 'Content-Type': 'text/plain' });
52
- res.end('Project not found');
53
- return;
54
- }
55
- const sessions = await listSessions(project.dir);
56
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
57
- res.end(generateSessionList(project, sessions));
58
- return;
59
- }
60
-
61
- const sessionMatch = pathname.match(/^\/project\/(.+)\/session\/(.+)$/);
62
- if (sessionMatch) {
63
- const rawName = decodeURIComponent(sessionMatch[1]);
64
- const sessionId = decodeURIComponent(sessionMatch[2]);
65
- const projects = listProjects();
66
- const project = projects.find(p => p.rawName === rawName);
67
- if (!project) {
68
- res.writeHead(404, { 'Content-Type': 'text/plain' });
69
- res.end('Project not found');
70
- return;
71
- }
72
- const sessionPath = path.join(project.dir, `${sessionId}.jsonl`);
73
- if (!fs.existsSync(sessionPath)) {
74
- res.writeHead(404, { 'Content-Type': 'text/plain' });
75
- res.end('Session not found');
76
- return;
77
- }
78
- const sessions = await listSessions(project.dir);
79
- const session = sessions.find(s => s.id === sessionId) || { id: sessionId, path: sessionPath };
80
- const parsed = await parseSession(sessionPath);
81
- const html = generate(parsed, { projectName: project.name, session, backUrl: `/project/${encodeURIComponent(rawName)}` });
82
- res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
83
- res.end(html);
84
- return;
85
- }
86
-
87
- res.writeHead(404, { 'Content-Type': 'text/plain' });
88
- res.end('Not found');
89
- } catch (err) {
90
- console.error(err);
91
- res.writeHead(500, { 'Content-Type': 'text/plain' });
92
- res.end('Internal server error');
93
- }
94
- });
95
-
96
- async function buildOpenUrl(baseUrl) {
97
- try {
98
- const match = await findSessionForCwd(process.cwd());
99
- if (match) {
100
- console.log(`Detected session for ${process.cwd()} → opening at bottom`);
101
- return `${baseUrl}/project/${encodeURIComponent(match.projectRawName)}/session/${encodeURIComponent(match.sessionId)}#session-bottom`;
102
- }
103
- } catch (err) {
104
- console.error('Failed to auto-detect session:', err.message);
105
- }
106
- return baseUrl;
107
- }
108
-
109
- function probeCcakashic(port) {
110
- return new Promise((resolve) => {
111
- const req = http.request({
112
- host: '127.0.0.1',
113
- port,
114
- path: '/__ccakashic',
115
- method: 'GET',
116
- timeout: 500,
117
- }, (res) => {
118
- let data = '';
119
- res.on('data', chunk => { data += chunk; });
120
- res.on('end', () => {
121
- try {
122
- const parsed = JSON.parse(data);
123
- resolve(parsed && parsed.name === 'ccakashic');
124
- } catch {
125
- resolve(false);
126
- }
127
- });
128
- });
129
- req.on('error', () => resolve(false));
130
- req.on('timeout', () => { req.destroy(); resolve(false); });
131
- req.end();
132
- });
133
- }
134
-
135
- function readLockPort() {
136
- try {
137
- const data = JSON.parse(fs.readFileSync(LOCK_FILE, 'utf-8'));
138
- return typeof data.port === 'number' ? data.port : null;
139
- } catch {
140
- return null;
141
- }
142
- }
143
-
144
- function writeLockFile(port) {
145
- try {
146
- fs.writeFileSync(LOCK_FILE, JSON.stringify({ port, pid: process.pid, startedAt: Date.now() }));
147
- } catch {
148
- // best-effort
149
- }
150
- }
151
-
152
- function cleanupLockFile() {
153
- try { fs.unlinkSync(LOCK_FILE); } catch {}
154
- }
155
-
156
- function listenOnPort(port) {
157
- return new Promise((resolve, reject) => {
158
- const onError = (err) => { server.off('listening', onListening); reject(err); };
159
- const onListening = () => { server.off('error', onError); resolve(); };
160
- server.once('error', onError);
161
- server.once('listening', onListening);
162
- server.listen(port, '127.0.0.1');
163
- });
164
- }
165
-
166
- async function findExistingCcakashic(startPort) {
167
- const lockPort = readLockPort();
168
- if (lockPort && await probeCcakashic(lockPort)) return lockPort;
169
- if (startPort !== lockPort && await probeCcakashic(startPort)) return startPort;
170
- return null;
171
- }
172
-
173
- async function startServer(startPort) {
174
- for (let i = 0; i < MAX_PORT_TRIES; i++) {
175
- const port = startPort + i;
176
- try {
177
- await listenOnPort(port);
178
- return port;
179
- } catch (err) {
180
- if (err.code !== 'EADDRINUSE') throw err;
181
- // Port is taken by something else; see if it's ccakashic
182
- if (await probeCcakashic(port)) return -port; // negative = reuse signal
183
- }
184
- }
185
- throw new Error(`No available port after ${MAX_PORT_TRIES} tries starting at ${startPort}`);
186
- }
187
-
188
- async function main() {
189
- const existing = await findExistingCcakashic(PORT);
190
- if (existing) {
191
- const url = `http://127.0.0.1:${existing}`;
192
- console.log(`Reusing existing ccakashic at ${url}`);
193
- writeLockFile(existing);
194
- openInBrowser(await buildOpenUrl(url));
195
- return;
196
- }
197
-
198
- const result = await startServer(PORT);
199
- if (result < 0) {
200
- const port = -result;
201
- const url = `http://127.0.0.1:${port}`;
202
- console.log(`Reusing existing ccakashic at ${url}`);
203
- writeLockFile(port);
204
- openInBrowser(await buildOpenUrl(url));
205
- return;
206
- }
207
-
208
- const port = result;
209
- const url = `http://127.0.0.1:${port}`;
210
- console.log(`ccakashic running at ${url}`);
211
- console.log('Press Ctrl+C to stop');
212
- writeLockFile(port);
213
-
214
- const cleanup = () => { cleanupLockFile(); process.exit(0); };
215
- process.on('SIGINT', cleanup);
216
- process.on('SIGTERM', cleanup);
217
- process.on('exit', cleanupLockFile);
218
-
219
- openInBrowser(await buildOpenUrl(url));
220
- }
221
-
222
- main().catch((err) => {
223
- console.error(err);
224
- process.exit(1);
225
- });
package/lib/discover.js DELETED
@@ -1,221 +0,0 @@
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
- function readCwdFromSession(filePath) {
154
- return new Promise((resolve) => {
155
- const rl = readline.createInterface({
156
- input: fs.createReadStream(filePath, { encoding: 'utf-8' }),
157
- crlfDelay: Infinity,
158
- });
159
-
160
- let found = null;
161
- rl.on('line', (line) => {
162
- if (found) return;
163
- try {
164
- const obj = JSON.parse(line);
165
- if (obj.cwd) {
166
- found = obj.cwd;
167
- rl.close();
168
- }
169
- } catch {
170
- // skip
171
- }
172
- });
173
-
174
- rl.on('close', () => resolve(found));
175
- rl.on('error', () => resolve(found));
176
- });
177
- }
178
-
179
- function latestSessionFile(projectDir) {
180
- const files = fs.readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
181
- if (!files.length) return null;
182
- let best = null;
183
- let bestMtime = 0;
184
- for (const f of files) {
185
- const mtime = fs.statSync(path.join(projectDir, f)).mtimeMs;
186
- if (mtime > bestMtime) {
187
- bestMtime = mtime;
188
- best = f;
189
- }
190
- }
191
- return best ? { file: best, mtimeMs: bestMtime } : null;
192
- }
193
-
194
- async function findSessionForCwd(cwd) {
195
- // Pick the project whose cwd is the most specific (longest) match for the
196
- // given cwd. A brand-new session in a specific subdir should win over an
197
- // older, more-active ancestor project.
198
- const projects = listProjects();
199
- let bestMatch = null;
200
- let bestLen = -1;
201
-
202
- for (const project of projects) {
203
- const latest = latestSessionFile(project.dir);
204
- if (!latest) continue;
205
- const projectCwd = await readCwdFromSession(path.join(project.dir, latest.file));
206
- if (!projectCwd) continue;
207
-
208
- const isMatch = cwd === projectCwd || cwd.startsWith(projectCwd + path.sep);
209
- if (isMatch && projectCwd.length > bestLen) {
210
- bestMatch = {
211
- projectRawName: project.rawName,
212
- sessionId: path.basename(latest.file, '.jsonl'),
213
- };
214
- bestLen = projectCwd.length;
215
- }
216
- }
217
-
218
- return bestMatch;
219
- }
220
-
221
- module.exports = { listProjects, listSessions, decodeDirName, findSessionForCwd, CLAUDE_DIR };