@teddysc/claude-run 0.3.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 +21 -0
- package/README.md +96 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +596 -0
- package/dist/web/assets/index-C4bjh4sC.js +289 -0
- package/dist/web/assets/index-qn9mqiTt.css +1 -0
- package/dist/web/index.html +13 -0
- package/package.json +74 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kamran Ahmed
|
|
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,96 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# Claude Run
|
|
4
|
+
|
|
5
|
+
Browse your Claude Code conversation history in a beautiful web UI
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/claude-run)
|
|
8
|
+
[](https://opensource.org/licenses/MIT)
|
|
9
|
+
|
|
10
|
+
<img src=".github/claude-run.gif" alt="Claude Run Demo" width="800" />
|
|
11
|
+
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<br />
|
|
15
|
+
|
|
16
|
+
Run the project simply by executing
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx claude-run
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The browser will open automatically at http://localhost:12001.
|
|
23
|
+
|
|
24
|
+
## Features
|
|
25
|
+
|
|
26
|
+
- **Real-time streaming** - Watch conversations update live as Claude responds
|
|
27
|
+
- **Search** - Find sessions by prompt text or project name
|
|
28
|
+
- **Filter by project** - Focus on specific projects
|
|
29
|
+
- **Resume sessions** - Copy the resume command to continue any conversation in your terminal
|
|
30
|
+
- **Collapsible sidebar** - Maximize your viewing area
|
|
31
|
+
- **Dark mode** - Easy on the eyes
|
|
32
|
+
- **Clean UI** - Familiar chat interface with collapsible tool calls
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
Install globally via npm:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
npm install -g claude-run
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Then run it from any directory:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
claude-run
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The browser will open automatically at http://localhost:12001, showing all your Claude Code conversations.
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
claude-run [options]
|
|
52
|
+
|
|
53
|
+
Options:
|
|
54
|
+
-V, --version Show version number
|
|
55
|
+
-p, --port <number> Port to listen on (default: 12001)
|
|
56
|
+
-d, --dir <path> Claude directory (default: ~/.claude)
|
|
57
|
+
--no-open Do not open browser automatically
|
|
58
|
+
-h, --help Show help
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## How It Works
|
|
62
|
+
|
|
63
|
+
Claude Code stores conversation history in `~/.claude/`. This tool reads that data and presents it in a web interface with:
|
|
64
|
+
|
|
65
|
+
- **Session list** - All your conversations, sorted by recency
|
|
66
|
+
- **Project filter** - Focus on a specific project
|
|
67
|
+
- **Conversation view** - Full message history with tool calls
|
|
68
|
+
- **Session header** - Shows conversation title, project name, and timestamp
|
|
69
|
+
- **Resume command** - Copies the command to resume the conversation
|
|
70
|
+
- **Real-time updates** - SSE streaming for live conversations
|
|
71
|
+
|
|
72
|
+
## Requirements
|
|
73
|
+
|
|
74
|
+
- Node.js 20+
|
|
75
|
+
- Claude Code installed and used at least once
|
|
76
|
+
|
|
77
|
+
## Development
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
# Clone the repo
|
|
81
|
+
git clone https://github.com/kamranahmedse/claude-run.git
|
|
82
|
+
cd claude-run
|
|
83
|
+
|
|
84
|
+
# Install dependencies
|
|
85
|
+
pnpm install
|
|
86
|
+
|
|
87
|
+
# Start development servers
|
|
88
|
+
pnpm dev
|
|
89
|
+
|
|
90
|
+
# Build for production
|
|
91
|
+
pnpm build
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT © Kamran Ahmed
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// api/index.ts
|
|
4
|
+
import { program } from "commander";
|
|
5
|
+
|
|
6
|
+
// api/server.ts
|
|
7
|
+
import { Hono } from "hono";
|
|
8
|
+
import { cors } from "hono/cors";
|
|
9
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
10
|
+
import { streamSSE } from "hono/streaming";
|
|
11
|
+
import { serve } from "@hono/node-server";
|
|
12
|
+
|
|
13
|
+
// api/storage.ts
|
|
14
|
+
import { readdir, readFile, stat, open } from "fs/promises";
|
|
15
|
+
import { join, basename } from "path";
|
|
16
|
+
import { homedir } from "os";
|
|
17
|
+
import { createInterface } from "readline";
|
|
18
|
+
var claudeDir = join(homedir(), ".claude");
|
|
19
|
+
var projectsDir = join(claudeDir, "projects");
|
|
20
|
+
var fileIndex = /* @__PURE__ */ new Map();
|
|
21
|
+
var historyCache = null;
|
|
22
|
+
var pendingRequests = /* @__PURE__ */ new Map();
|
|
23
|
+
function initStorage(dir) {
|
|
24
|
+
claudeDir = dir ?? join(homedir(), ".claude");
|
|
25
|
+
projectsDir = join(claudeDir, "projects");
|
|
26
|
+
}
|
|
27
|
+
function getClaudeDir() {
|
|
28
|
+
return claudeDir;
|
|
29
|
+
}
|
|
30
|
+
function invalidateHistoryCache() {
|
|
31
|
+
historyCache = null;
|
|
32
|
+
}
|
|
33
|
+
function addToFileIndex(sessionId, filePath) {
|
|
34
|
+
fileIndex.set(sessionId, filePath);
|
|
35
|
+
}
|
|
36
|
+
function encodeProjectPath(path) {
|
|
37
|
+
return path.replace(/[/.]/g, "-");
|
|
38
|
+
}
|
|
39
|
+
function getProjectName(projectPath) {
|
|
40
|
+
const parts = projectPath.split("/").filter(Boolean);
|
|
41
|
+
return parts[parts.length - 1] || projectPath;
|
|
42
|
+
}
|
|
43
|
+
async function buildFileIndex() {
|
|
44
|
+
try {
|
|
45
|
+
const projectDirs = await readdir(projectsDir, { withFileTypes: true });
|
|
46
|
+
const directories = projectDirs.filter((d) => d.isDirectory());
|
|
47
|
+
await Promise.all(
|
|
48
|
+
directories.map(async (dir) => {
|
|
49
|
+
try {
|
|
50
|
+
const projectPath = join(projectsDir, dir.name);
|
|
51
|
+
const files = await readdir(projectPath);
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
if (file.endsWith(".jsonl")) {
|
|
54
|
+
const sessionId = basename(file, ".jsonl");
|
|
55
|
+
fileIndex.set(sessionId, join(projectPath, file));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch {
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
);
|
|
62
|
+
} catch {
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function loadHistoryCache() {
|
|
66
|
+
try {
|
|
67
|
+
const historyPath = join(claudeDir, "history.jsonl");
|
|
68
|
+
const content = await readFile(historyPath, "utf-8");
|
|
69
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
70
|
+
const entries = [];
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
try {
|
|
73
|
+
entries.push(JSON.parse(line));
|
|
74
|
+
} catch {
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
historyCache = entries;
|
|
78
|
+
return entries;
|
|
79
|
+
} catch {
|
|
80
|
+
historyCache = [];
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function dedupe(key, fn) {
|
|
85
|
+
const existing = pendingRequests.get(key);
|
|
86
|
+
if (existing) {
|
|
87
|
+
return existing;
|
|
88
|
+
}
|
|
89
|
+
const promise = fn().finally(() => {
|
|
90
|
+
pendingRequests.delete(key);
|
|
91
|
+
});
|
|
92
|
+
pendingRequests.set(key, promise);
|
|
93
|
+
return promise;
|
|
94
|
+
}
|
|
95
|
+
async function findSessionByTimestamp(encodedProject, timestamp) {
|
|
96
|
+
try {
|
|
97
|
+
const projectPath = join(projectsDir, encodedProject);
|
|
98
|
+
const files = await readdir(projectPath);
|
|
99
|
+
const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
|
|
100
|
+
const fileStats = await Promise.all(
|
|
101
|
+
jsonlFiles.map(async (file) => {
|
|
102
|
+
const filePath = join(projectPath, file);
|
|
103
|
+
const fileStat = await stat(filePath);
|
|
104
|
+
return { file, mtime: fileStat.mtimeMs };
|
|
105
|
+
})
|
|
106
|
+
);
|
|
107
|
+
let closestFile = null;
|
|
108
|
+
let closestTimeDiff = Infinity;
|
|
109
|
+
for (const { file, mtime } of fileStats) {
|
|
110
|
+
const timeDiff = Math.abs(mtime - timestamp);
|
|
111
|
+
if (timeDiff < closestTimeDiff) {
|
|
112
|
+
closestTimeDiff = timeDiff;
|
|
113
|
+
closestFile = file;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (closestFile) {
|
|
117
|
+
return basename(closestFile, ".jsonl");
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
}
|
|
121
|
+
return void 0;
|
|
122
|
+
}
|
|
123
|
+
async function findSessionFile(sessionId) {
|
|
124
|
+
if (fileIndex.has(sessionId)) {
|
|
125
|
+
return fileIndex.get(sessionId);
|
|
126
|
+
}
|
|
127
|
+
const targetFile = `${sessionId}.jsonl`;
|
|
128
|
+
try {
|
|
129
|
+
const projectDirs = await readdir(projectsDir, { withFileTypes: true });
|
|
130
|
+
const directories = projectDirs.filter((d) => d.isDirectory());
|
|
131
|
+
const results = await Promise.all(
|
|
132
|
+
directories.map(async (dir) => {
|
|
133
|
+
try {
|
|
134
|
+
const projectPath = join(projectsDir, dir.name);
|
|
135
|
+
const files = await readdir(projectPath);
|
|
136
|
+
if (files.includes(targetFile)) {
|
|
137
|
+
return join(projectPath, targetFile);
|
|
138
|
+
}
|
|
139
|
+
} catch {
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
})
|
|
143
|
+
);
|
|
144
|
+
const filePath = results.find((r) => r !== null);
|
|
145
|
+
if (filePath) {
|
|
146
|
+
fileIndex.set(sessionId, filePath);
|
|
147
|
+
return filePath;
|
|
148
|
+
}
|
|
149
|
+
} catch (err) {
|
|
150
|
+
console.error("Error finding session file:", err);
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
async function loadStorage() {
|
|
155
|
+
await Promise.all([buildFileIndex(), loadHistoryCache()]);
|
|
156
|
+
}
|
|
157
|
+
async function getSessions() {
|
|
158
|
+
return dedupe("getSessions", async () => {
|
|
159
|
+
const entries = historyCache ?? await loadHistoryCache();
|
|
160
|
+
const sessions = [];
|
|
161
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
162
|
+
for (const entry of entries) {
|
|
163
|
+
let sessionId = entry.sessionId;
|
|
164
|
+
if (!sessionId) {
|
|
165
|
+
const encodedProject = encodeProjectPath(entry.project);
|
|
166
|
+
sessionId = await findSessionByTimestamp(encodedProject, entry.timestamp);
|
|
167
|
+
}
|
|
168
|
+
if (!sessionId || seenIds.has(sessionId)) {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
seenIds.add(sessionId);
|
|
172
|
+
sessions.push({
|
|
173
|
+
id: sessionId,
|
|
174
|
+
display: entry.display,
|
|
175
|
+
timestamp: entry.timestamp,
|
|
176
|
+
project: entry.project,
|
|
177
|
+
projectName: getProjectName(entry.project)
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return sessions.sort((a, b) => b.timestamp - a.timestamp);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
async function getProjects() {
|
|
184
|
+
const entries = historyCache ?? await loadHistoryCache();
|
|
185
|
+
const projects = /* @__PURE__ */ new Set();
|
|
186
|
+
for (const entry of entries) {
|
|
187
|
+
if (entry.project) {
|
|
188
|
+
projects.add(entry.project);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return [...projects].sort();
|
|
192
|
+
}
|
|
193
|
+
async function getConversation(sessionId) {
|
|
194
|
+
return dedupe(`getConversation:${sessionId}`, async () => {
|
|
195
|
+
const filePath = await findSessionFile(sessionId);
|
|
196
|
+
if (!filePath) {
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
const messages = [];
|
|
200
|
+
try {
|
|
201
|
+
const content = await readFile(filePath, "utf-8");
|
|
202
|
+
const lines = content.trim().split("\n").filter(Boolean);
|
|
203
|
+
for (const line of lines) {
|
|
204
|
+
try {
|
|
205
|
+
const msg = JSON.parse(line);
|
|
206
|
+
if (msg.type === "user" || msg.type === "assistant") {
|
|
207
|
+
messages.push(msg);
|
|
208
|
+
} else if (msg.type === "summary") {
|
|
209
|
+
messages.unshift(msg);
|
|
210
|
+
}
|
|
211
|
+
} catch {
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
} catch (err) {
|
|
215
|
+
console.error("Error reading conversation:", err);
|
|
216
|
+
}
|
|
217
|
+
return messages;
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
async function getConversationStream(sessionId, fromOffset = 0) {
|
|
221
|
+
const filePath = await findSessionFile(sessionId);
|
|
222
|
+
if (!filePath) {
|
|
223
|
+
return { messages: [], nextOffset: 0 };
|
|
224
|
+
}
|
|
225
|
+
const messages = [];
|
|
226
|
+
let fileHandle;
|
|
227
|
+
try {
|
|
228
|
+
const fileStat = await stat(filePath);
|
|
229
|
+
const fileSize = fileStat.size;
|
|
230
|
+
if (fromOffset >= fileSize) {
|
|
231
|
+
return { messages: [], nextOffset: fromOffset };
|
|
232
|
+
}
|
|
233
|
+
fileHandle = await open(filePath, "r");
|
|
234
|
+
const stream = fileHandle.createReadStream({
|
|
235
|
+
start: fromOffset,
|
|
236
|
+
encoding: "utf-8"
|
|
237
|
+
});
|
|
238
|
+
const rl = createInterface({
|
|
239
|
+
input: stream,
|
|
240
|
+
crlfDelay: Infinity
|
|
241
|
+
});
|
|
242
|
+
let bytesConsumed = 0;
|
|
243
|
+
for await (const line of rl) {
|
|
244
|
+
const lineBytes = Buffer.byteLength(line, "utf-8") + 1;
|
|
245
|
+
if (line.trim()) {
|
|
246
|
+
try {
|
|
247
|
+
const msg = JSON.parse(line);
|
|
248
|
+
if (msg.type === "user" || msg.type === "assistant") {
|
|
249
|
+
messages.push(msg);
|
|
250
|
+
}
|
|
251
|
+
bytesConsumed += lineBytes;
|
|
252
|
+
} catch {
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
bytesConsumed += lineBytes;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const actualOffset = fromOffset + bytesConsumed;
|
|
260
|
+
const nextOffset = actualOffset > fileSize ? fileSize : actualOffset;
|
|
261
|
+
return { messages, nextOffset };
|
|
262
|
+
} catch (err) {
|
|
263
|
+
console.error("Error reading conversation stream:", err);
|
|
264
|
+
return { messages: [], nextOffset: fromOffset };
|
|
265
|
+
} finally {
|
|
266
|
+
if (fileHandle) {
|
|
267
|
+
await fileHandle.close();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// api/watcher.ts
|
|
273
|
+
import { watch } from "chokidar";
|
|
274
|
+
import { basename as basename2, join as join2 } from "path";
|
|
275
|
+
var watcher = null;
|
|
276
|
+
var claudeDir2 = "";
|
|
277
|
+
var debounceTimers = /* @__PURE__ */ new Map();
|
|
278
|
+
var debounceMs = 20;
|
|
279
|
+
var historyChangeListeners = /* @__PURE__ */ new Set();
|
|
280
|
+
var sessionChangeListeners = /* @__PURE__ */ new Set();
|
|
281
|
+
function initWatcher(dir) {
|
|
282
|
+
claudeDir2 = dir;
|
|
283
|
+
}
|
|
284
|
+
function emitChange(filePath) {
|
|
285
|
+
if (filePath.endsWith("history.jsonl")) {
|
|
286
|
+
for (const callback of historyChangeListeners) {
|
|
287
|
+
callback();
|
|
288
|
+
}
|
|
289
|
+
} else if (filePath.endsWith(".jsonl")) {
|
|
290
|
+
const sessionId = basename2(filePath, ".jsonl");
|
|
291
|
+
for (const callback of sessionChangeListeners) {
|
|
292
|
+
callback(sessionId, filePath);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function handleChange(path) {
|
|
297
|
+
const existing = debounceTimers.get(path);
|
|
298
|
+
if (existing) {
|
|
299
|
+
clearTimeout(existing);
|
|
300
|
+
}
|
|
301
|
+
const timer = setTimeout(() => {
|
|
302
|
+
debounceTimers.delete(path);
|
|
303
|
+
emitChange(path);
|
|
304
|
+
}, debounceMs);
|
|
305
|
+
debounceTimers.set(path, timer);
|
|
306
|
+
}
|
|
307
|
+
function startWatcher() {
|
|
308
|
+
if (watcher) {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const historyPath = join2(claudeDir2, "history.jsonl");
|
|
312
|
+
const projectsDir2 = join2(claudeDir2, "projects");
|
|
313
|
+
const usePolling = process.env.CLAUDE_RUN_USE_POLLING === "1";
|
|
314
|
+
watcher = watch([historyPath, projectsDir2], {
|
|
315
|
+
persistent: true,
|
|
316
|
+
ignoreInitial: true,
|
|
317
|
+
usePolling,
|
|
318
|
+
...usePolling && { interval: 100 },
|
|
319
|
+
depth: 2
|
|
320
|
+
});
|
|
321
|
+
watcher.on("change", handleChange);
|
|
322
|
+
watcher.on("add", handleChange);
|
|
323
|
+
watcher.on("error", (error) => {
|
|
324
|
+
console.error("Watcher error:", error);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
function stopWatcher() {
|
|
328
|
+
if (watcher) {
|
|
329
|
+
watcher.close();
|
|
330
|
+
watcher = null;
|
|
331
|
+
}
|
|
332
|
+
for (const timer of debounceTimers.values()) {
|
|
333
|
+
clearTimeout(timer);
|
|
334
|
+
}
|
|
335
|
+
debounceTimers.clear();
|
|
336
|
+
}
|
|
337
|
+
function onHistoryChange(callback) {
|
|
338
|
+
historyChangeListeners.add(callback);
|
|
339
|
+
}
|
|
340
|
+
function offHistoryChange(callback) {
|
|
341
|
+
historyChangeListeners.delete(callback);
|
|
342
|
+
}
|
|
343
|
+
function onSessionChange(callback) {
|
|
344
|
+
sessionChangeListeners.add(callback);
|
|
345
|
+
}
|
|
346
|
+
function offSessionChange(callback) {
|
|
347
|
+
sessionChangeListeners.delete(callback);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// api/server.ts
|
|
351
|
+
import { join as join3, dirname } from "path";
|
|
352
|
+
import { fileURLToPath } from "url";
|
|
353
|
+
import { readFileSync, existsSync } from "fs";
|
|
354
|
+
import open2 from "open";
|
|
355
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
356
|
+
var __dirname = dirname(__filename);
|
|
357
|
+
function getWebDistPath() {
|
|
358
|
+
const prodPath = join3(__dirname, "web");
|
|
359
|
+
if (existsSync(prodPath)) {
|
|
360
|
+
return prodPath;
|
|
361
|
+
}
|
|
362
|
+
return join3(__dirname, "..", "dist", "web");
|
|
363
|
+
}
|
|
364
|
+
function createServer(options) {
|
|
365
|
+
const {
|
|
366
|
+
port,
|
|
367
|
+
host = "127.0.0.1",
|
|
368
|
+
claudeDir: claudeDir3,
|
|
369
|
+
dev = false,
|
|
370
|
+
open: shouldOpen = true
|
|
371
|
+
} = options;
|
|
372
|
+
initStorage(claudeDir3);
|
|
373
|
+
initWatcher(getClaudeDir());
|
|
374
|
+
const app = new Hono();
|
|
375
|
+
if (dev) {
|
|
376
|
+
app.use(
|
|
377
|
+
"*",
|
|
378
|
+
cors({
|
|
379
|
+
origin: ["http://localhost:12000"],
|
|
380
|
+
allowMethods: ["GET", "POST", "OPTIONS"],
|
|
381
|
+
allowHeaders: ["Content-Type"]
|
|
382
|
+
})
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
app.get("/api/sessions", async (c) => {
|
|
386
|
+
const sessions = await getSessions();
|
|
387
|
+
return c.json(sessions);
|
|
388
|
+
});
|
|
389
|
+
app.get("/api/projects", async (c) => {
|
|
390
|
+
const projects = await getProjects();
|
|
391
|
+
return c.json(projects);
|
|
392
|
+
});
|
|
393
|
+
app.get("/api/sessions/stream", async (c) => {
|
|
394
|
+
return streamSSE(c, async (stream) => {
|
|
395
|
+
let isConnected = true;
|
|
396
|
+
const knownSessions = /* @__PURE__ */ new Map();
|
|
397
|
+
const cleanup = () => {
|
|
398
|
+
isConnected = false;
|
|
399
|
+
offHistoryChange(handleHistoryChange);
|
|
400
|
+
};
|
|
401
|
+
const handleHistoryChange = async () => {
|
|
402
|
+
if (!isConnected) {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
try {
|
|
406
|
+
const sessions = await getSessions();
|
|
407
|
+
const newOrUpdated = sessions.filter((s) => {
|
|
408
|
+
const known = knownSessions.get(s.id);
|
|
409
|
+
return known === void 0 || known !== s.timestamp;
|
|
410
|
+
});
|
|
411
|
+
for (const s of sessions) {
|
|
412
|
+
knownSessions.set(s.id, s.timestamp);
|
|
413
|
+
}
|
|
414
|
+
if (newOrUpdated.length > 0) {
|
|
415
|
+
await stream.writeSSE({
|
|
416
|
+
event: "sessionsUpdate",
|
|
417
|
+
data: JSON.stringify(newOrUpdated)
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
} catch {
|
|
421
|
+
cleanup();
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
onHistoryChange(handleHistoryChange);
|
|
425
|
+
c.req.raw.signal.addEventListener("abort", cleanup);
|
|
426
|
+
try {
|
|
427
|
+
const sessions = await getSessions();
|
|
428
|
+
for (const s of sessions) {
|
|
429
|
+
knownSessions.set(s.id, s.timestamp);
|
|
430
|
+
}
|
|
431
|
+
await stream.writeSSE({
|
|
432
|
+
event: "sessions",
|
|
433
|
+
data: JSON.stringify(sessions)
|
|
434
|
+
});
|
|
435
|
+
while (isConnected) {
|
|
436
|
+
await stream.writeSSE({
|
|
437
|
+
event: "heartbeat",
|
|
438
|
+
data: JSON.stringify({ timestamp: Date.now() })
|
|
439
|
+
});
|
|
440
|
+
await stream.sleep(3e4);
|
|
441
|
+
}
|
|
442
|
+
} catch {
|
|
443
|
+
} finally {
|
|
444
|
+
cleanup();
|
|
445
|
+
}
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
app.get("/api/conversation/:id", async (c) => {
|
|
449
|
+
const sessionId = c.req.param("id");
|
|
450
|
+
const messages = await getConversation(sessionId);
|
|
451
|
+
return c.json(messages);
|
|
452
|
+
});
|
|
453
|
+
app.get("/api/conversation/:id/stream", async (c) => {
|
|
454
|
+
const sessionId = c.req.param("id");
|
|
455
|
+
const offsetParam = c.req.query("offset");
|
|
456
|
+
let offset = offsetParam ? parseInt(offsetParam, 10) : 0;
|
|
457
|
+
return streamSSE(c, async (stream) => {
|
|
458
|
+
let isConnected = true;
|
|
459
|
+
const cleanup = () => {
|
|
460
|
+
isConnected = false;
|
|
461
|
+
offSessionChange(handleSessionChange);
|
|
462
|
+
};
|
|
463
|
+
const handleSessionChange = async (changedSessionId) => {
|
|
464
|
+
if (changedSessionId !== sessionId || !isConnected) {
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const { messages: newMessages, nextOffset: newOffset } = await getConversationStream(sessionId, offset);
|
|
468
|
+
offset = newOffset;
|
|
469
|
+
if (newMessages.length > 0) {
|
|
470
|
+
try {
|
|
471
|
+
await stream.writeSSE({
|
|
472
|
+
event: "messages",
|
|
473
|
+
data: JSON.stringify(newMessages)
|
|
474
|
+
});
|
|
475
|
+
} catch {
|
|
476
|
+
cleanup();
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
};
|
|
480
|
+
onSessionChange(handleSessionChange);
|
|
481
|
+
c.req.raw.signal.addEventListener("abort", cleanup);
|
|
482
|
+
try {
|
|
483
|
+
const { messages, nextOffset } = await getConversationStream(
|
|
484
|
+
sessionId,
|
|
485
|
+
offset
|
|
486
|
+
);
|
|
487
|
+
offset = nextOffset;
|
|
488
|
+
await stream.writeSSE({
|
|
489
|
+
event: "messages",
|
|
490
|
+
data: JSON.stringify(messages)
|
|
491
|
+
});
|
|
492
|
+
while (isConnected) {
|
|
493
|
+
await stream.writeSSE({
|
|
494
|
+
event: "heartbeat",
|
|
495
|
+
data: JSON.stringify({ timestamp: Date.now() })
|
|
496
|
+
});
|
|
497
|
+
await stream.sleep(3e4);
|
|
498
|
+
}
|
|
499
|
+
} catch {
|
|
500
|
+
} finally {
|
|
501
|
+
cleanup();
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
const webDistPath = getWebDistPath();
|
|
506
|
+
app.use("/*", serveStatic({ root: webDistPath }));
|
|
507
|
+
app.get("/*", async (c) => {
|
|
508
|
+
const indexPath = join3(webDistPath, "index.html");
|
|
509
|
+
try {
|
|
510
|
+
const html = readFileSync(indexPath, "utf-8");
|
|
511
|
+
return c.html(html);
|
|
512
|
+
} catch {
|
|
513
|
+
return c.text("UI not found. Run 'pnpm build' first.", 404);
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
onHistoryChange(() => {
|
|
517
|
+
invalidateHistoryCache();
|
|
518
|
+
});
|
|
519
|
+
onSessionChange((sessionId, filePath) => {
|
|
520
|
+
addToFileIndex(sessionId, filePath);
|
|
521
|
+
});
|
|
522
|
+
startWatcher();
|
|
523
|
+
let httpServer = null;
|
|
524
|
+
return {
|
|
525
|
+
app,
|
|
526
|
+
port,
|
|
527
|
+
start: async () => {
|
|
528
|
+
await loadStorage();
|
|
529
|
+
const openUrl = `http://${host}:${dev ? 12e3 : port}/`;
|
|
530
|
+
console.log(`
|
|
531
|
+
claude-run is running at ${openUrl}
|
|
532
|
+
`);
|
|
533
|
+
if (!dev && shouldOpen) {
|
|
534
|
+
open2(openUrl).catch(console.error);
|
|
535
|
+
}
|
|
536
|
+
httpServer = serve({
|
|
537
|
+
fetch: app.fetch,
|
|
538
|
+
port,
|
|
539
|
+
hostname: host
|
|
540
|
+
});
|
|
541
|
+
return httpServer;
|
|
542
|
+
},
|
|
543
|
+
stop: () => {
|
|
544
|
+
stopWatcher();
|
|
545
|
+
if (httpServer) {
|
|
546
|
+
httpServer.close();
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// api/index.ts
|
|
553
|
+
import { homedir as homedir2 } from "os";
|
|
554
|
+
import { join as join4 } from "path";
|
|
555
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
556
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
557
|
+
import { dirname as dirname2 } from "path";
|
|
558
|
+
var __filename2 = fileURLToPath2(import.meta.url);
|
|
559
|
+
var __dirname2 = dirname2(__filename2);
|
|
560
|
+
function getVersion() {
|
|
561
|
+
try {
|
|
562
|
+
const pkgPath = join4(__dirname2, "..", "package.json");
|
|
563
|
+
const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
|
|
564
|
+
return pkg.version;
|
|
565
|
+
} catch {
|
|
566
|
+
return "0.1.0";
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
program.name("claude-run").description(
|
|
570
|
+
"A beautiful web UI for browsing Claude Code conversation history"
|
|
571
|
+
).version(getVersion()).option("-p, --port <number>", "Port to listen on", "12001").option("-H, --host <address>", "Host address to listen on", "127.0.0.1").option(
|
|
572
|
+
"-d, --dir <path>",
|
|
573
|
+
"Claude directory path",
|
|
574
|
+
join4(homedir2(), ".claude")
|
|
575
|
+
).option("--dev", "Enable CORS for development").option("--no-open", "Do not open browser automatically").parse();
|
|
576
|
+
var opts = program.opts();
|
|
577
|
+
var server = createServer({
|
|
578
|
+
port: parseInt(opts.port, 10),
|
|
579
|
+
host: opts.host,
|
|
580
|
+
claudeDir: opts.dir,
|
|
581
|
+
dev: opts.dev,
|
|
582
|
+
open: opts.open
|
|
583
|
+
});
|
|
584
|
+
process.on("SIGINT", () => {
|
|
585
|
+
console.log("\nShutting down...");
|
|
586
|
+
server.stop();
|
|
587
|
+
process.exit(0);
|
|
588
|
+
});
|
|
589
|
+
process.on("SIGTERM", () => {
|
|
590
|
+
server.stop();
|
|
591
|
+
process.exit(0);
|
|
592
|
+
});
|
|
593
|
+
server.start().catch((err) => {
|
|
594
|
+
console.error("Failed to start server:", err);
|
|
595
|
+
process.exit(1);
|
|
596
|
+
});
|