claude-plan-viewer 1.4.1 → 1.5.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 CHANGED
@@ -2,10 +2,13 @@
2
2
 
3
3
  > Browse, search, and read your Claude Code plans in a clean web UI
4
4
 
5
+ [![Website](https://img.shields.io/badge/Website-claudeplans.dev-blue)](https://claudeplans.dev)
5
6
  [![npm version](https://img.shields.io/npm/v/claude-plan-viewer.svg)](https://www.npmjs.com/package/claude-plan-viewer)
6
7
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
7
8
  [![Bun](https://img.shields.io/badge/Bun-%23000000.svg?logo=bun&logoColor=white)](https://bun.sh)
8
9
 
10
+ **[📖 Documentation](https://claudeplans.dev)** | **[🚀 Quick Start](https://claudeplans.dev/getting-started/quickstart)** | **[📚 API Reference](https://claudeplans.dev/reference/api)**
11
+
9
12
  ![screenshot](screenshot.png)
10
13
 
11
14
  ## Features
@@ -66,6 +69,10 @@ claude-plan-viewer
66
69
  # Start on specific port
67
70
  claude-plan-viewer --port 8080
68
71
  claude-plan-viewer -p 8080
72
+
73
+ # Listen on all network interfaces (accessible from other devices)
74
+ claude-plan-viewer --host 0.0.0.0
75
+ claude-plan-viewer -H 0.0.0.0
69
76
  ```
70
77
 
71
78
  The server will automatically find an available port if the requested port is in use.
@@ -75,6 +82,7 @@ The server will automatically find an available port if the requested port is in
75
82
  | Flag | Short | Description |
76
83
  | --------------------- | ----- | --------------------------------------------------------- |
77
84
  | `--port <number>` | `-p` | Port to start the server on (default: 3000) |
85
+ | `--host <address>` | `-H` | Host to bind to (default: localhost) |
78
86
  | `--claude-dir <path>` | `-c` | Path to `.claude` directory (default: `~/.claude`) |
79
87
  | `--json` | `-j` | Export all plans as JSON and exit |
80
88
  | `--output <file>` | `-o` | Output file for JSON export (prints to stdout if omitted) |
package/index.ts CHANGED
@@ -1,11 +1,14 @@
1
1
  #!/usr/bin/env bun
2
2
  import { readdir, stat, watch } from "node:fs/promises";
3
+ import { createReadStream } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import { homedir } from "node:os";
6
+ import { createInterface } from "node:readline";
5
7
  import index from "./src/index.html";
6
8
  import apiDocs from "./src/api-docs.html";
7
9
  import pkg from "./package.json";
8
10
  import openapi from "./openapi.json";
11
+ import getPort, { portNumbers } from "get-port";
9
12
 
10
13
  // Resolved at startup based on --claude-dir flag or CLAUDE_DIR env var
11
14
  let PLANS_DIR: string;
@@ -22,6 +25,7 @@ function initializeDirectories(claudeDir: string): void {
22
25
 
23
26
  interface CliArgs {
24
27
  port?: number;
28
+ host?: string;
25
29
  json?: boolean;
26
30
  output?: string;
27
31
  fromFile?: string;
@@ -43,6 +47,11 @@ function parseCliArgs(): CliArgs {
43
47
  args.port = parseInt(nextArg, 10);
44
48
  i++;
45
49
  }
50
+ } else if (arg === "--host" || arg === "-H") {
51
+ if (nextArg && !nextArg.startsWith("-")) {
52
+ args.host = nextArg;
53
+ i++;
54
+ }
46
55
  } else if (arg === "--json" || arg === "-j") {
47
56
  args.json = true;
48
57
  } else if (arg === "--output" || arg === "-o") {
@@ -78,6 +87,8 @@ Usage: claude-plan-viewer [options]
78
87
 
79
88
  Options:
80
89
  -p, --port <number> Port to start server on (default: 3000)
90
+ -H, --host <address> Host to bind to (default: localhost)
91
+ Use 0.0.0.0 to listen on all interfaces
81
92
  -c, --claude-dir <path> Path to .claude directory (default: ~/.claude)
82
93
  Can also be set via CLAUDE_DIR environment variable
83
94
  -j, --json Export all plans as JSON and exit
@@ -89,6 +100,7 @@ Options:
89
100
  Examples:
90
101
  claude-plan-viewer Start viewer on default port
91
102
  claude-plan-viewer -p 8080 Start on port 8080
103
+ claude-plan-viewer -H 0.0.0.0 Listen on all network interfaces
92
104
  claude-plan-viewer -c /path/to/.claude Use custom .claude directory
93
105
  claude-plan-viewer -j -o plans.json Export plans to file
94
106
  claude-plan-viewer -f plans.json Load plans from exported file
@@ -147,24 +159,7 @@ async function loadPlansFromFile(filepath: string): Promise<PlanMetadata[]> {
147
159
 
148
160
  // Find an available port starting from the requested port
149
161
  async function findAvailablePort(startPort: number = 3000): Promise<number> {
150
- let port = startPort;
151
- const maxAttempts = 100;
152
-
153
- for (let i = 0; i < maxAttempts; i++) {
154
- try {
155
- const testServer = Bun.serve({
156
- port,
157
- fetch: () => new Response(),
158
- });
159
- testServer.stop();
160
- return port;
161
- } catch {
162
- port++;
163
- }
164
- }
165
- throw new Error(
166
- `No available port found in range ${startPort}-${startPort + maxAttempts}`,
167
- );
162
+ return getPort({ port: portNumbers(startPort, startPort + 100) });
168
163
  }
169
164
 
170
165
  // Cross-platform open file in default editor
@@ -192,10 +187,6 @@ interface PlanMetadata {
192
187
  sessionId: string | null;
193
188
  }
194
189
 
195
- interface Plan extends PlanMetadata {
196
- content: string;
197
- }
198
-
199
190
  // Extract project name from a full path (cross-platform)
200
191
  // e.g., "/Users/helge/code/plans-viewer" -> "plans-viewer"
201
192
  // e.g., "C:\Users\name\code\my-app" -> "my-app"
@@ -212,48 +203,27 @@ function extractProjectName(cwd: string): string {
212
203
  return lastSlash === -1 ? trimmed : trimmed.slice(lastSlash + 1);
213
204
  }
214
205
 
215
- // Extract cwd (working directory) from JSONL content
216
- function extractCwdFromJsonl(content: string): string | null {
217
- if (!content) return null;
218
- const match = content.match(/"cwd":"([^"]+)"/);
219
- if (!match || !match[1]) return null;
220
- // Unescape JSON string (convert \\\\ to \\)
221
- return match[1].replace(/\\\\/g, "\\");
222
- }
223
-
224
- // Extract slug -> sessionId mapping from JSONL content
225
- // Each line in JSONL may contain both "slug" and "sessionId" fields
226
- function extractSlugSessionMap(content: string): Map<string, string> {
227
- if (!content) return new Map();
228
- const slugSessionMap = new Map<string, string>();
229
-
230
- // Process each line to find slug and sessionId pairs
231
- const lines = content.split("\n");
232
- for (const line of lines) {
233
- if (!line.trim()) continue;
234
-
235
- const slugMatch = line.match(/"slug":"([\w-]+)"/);
236
- const sessionMatch = line.match(/"sessionId":"([^"]+)"/);
237
-
238
- if (slugMatch && slugMatch[1] && sessionMatch && sessionMatch[1]) {
239
- slugSessionMap.set(slugMatch[1], sessionMatch[1]);
240
- }
241
- }
242
-
243
- return slugSessionMap;
244
- }
206
+ // Stream a JSONL file line-by-line without loading the entire file into memory
207
+ async function processJsonlLineByLine(
208
+ path: string,
209
+ onLine: (line: string) => void
210
+ ): Promise<void> {
211
+ return new Promise((resolve, reject) => {
212
+ const stream = createReadStream(path, {
213
+ encoding: "utf-8",
214
+ highWaterMark: 64 * 1024,
215
+ });
216
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
245
217
 
246
- // Extract unique slugs from JSONL content (for backwards compatibility)
247
- function extractSlugsFromJsonl(content: string): string[] {
248
- if (!content) return [];
249
- const slugs = new Set<string>();
250
- const matches = content.matchAll(/"slug":"([\w-]+)"/g);
251
- for (const match of matches) {
252
- if (match[1]) {
253
- slugs.add(match[1]);
254
- }
255
- }
256
- return Array.from(slugs);
218
+ rl.on("line", (line) => {
219
+ if (line.length > 0) onLine(line);
220
+ });
221
+ rl.on("close", resolve);
222
+ rl.on("error", (err) => {
223
+ rl.close();
224
+ reject(err);
225
+ });
226
+ });
257
227
  }
258
228
 
259
229
  interface SlugMetadata {
@@ -265,74 +235,67 @@ interface ProjectMapping {
265
235
  [slug: string]: SlugMetadata;
266
236
  }
267
237
 
268
- // Build a mapping of plan slugs to project names and session IDs by scanning Claude Code's project metadata
269
- async function buildProjectMapping(): Promise<ProjectMapping> {
238
+ // Build a mapping of plan slugs to project names and session IDs by scanning Claude Code's project metadata.
239
+ // Streams JSONL files line-by-line to avoid loading multi-GB project data into memory.
240
+ // Only tracks slugs that correspond to actual plan files.
241
+ async function buildProjectMapping(
242
+ neededSlugs?: Set<string>
243
+ ): Promise<ProjectMapping> {
270
244
  const mapping: ProjectMapping = {};
271
245
 
272
246
  try {
273
247
  const projectDirs = await readdir(PROJECTS_DIR);
274
248
 
275
- // Process all project directories in parallel
276
- const results = await Promise.all(
277
- projectDirs.map(async (dir) => {
278
- const dirPath = join(PROJECTS_DIR, dir);
279
- try {
280
- const dirStats = await stat(dirPath);
281
- if (!dirStats.isDirectory()) return null;
282
-
283
- // Find JSONL files
284
- const files = await readdir(dirPath);
285
- const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
286
- if (jsonlFiles.length === 0) return null;
287
-
288
- // Read all JSONL files in parallel
289
- const fileContents = await Promise.all(
290
- jsonlFiles.map(async (file) => {
291
- try {
292
- return await Bun.file(join(dirPath, file)).text();
293
- } catch {
294
- return null;
295
- }
296
- })
297
- );
249
+ for (const dir of projectDirs) {
250
+ const dirPath = join(PROJECTS_DIR, dir);
251
+ try {
252
+ const dirStats = await stat(dirPath);
253
+ if (!dirStats.isDirectory()) continue;
298
254
 
299
- let projectName: string | null = null;
300
- const slugSessionMap = new Map<string, string>();
255
+ const files = await readdir(dirPath);
256
+ const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
257
+ if (jsonlFiles.length === 0) continue;
301
258
 
302
- // Process file contents
303
- for (const content of fileContents) {
304
- if (!content) continue;
259
+ let projectName: string | null = null;
260
+ const slugSessionMap = new Map<string, string>();
305
261
 
306
- // Get project name from cwd (only need to find it once)
307
- if (!projectName) {
308
- const cwd = extractCwdFromJsonl(content);
309
- if (cwd) {
310
- projectName = extractProjectName(cwd);
262
+ // Process files sequentially to keep memory bounded
263
+ for (const file of jsonlFiles) {
264
+ try {
265
+ await processJsonlLineByLine(join(dirPath, file), (line) => {
266
+ // Extract project name from cwd (only need first occurrence)
267
+ if (!projectName) {
268
+ const cwdMatch = line.match(/"cwd":"([^"]+)"/);
269
+ if (cwdMatch?.[1]) {
270
+ const cwd = cwdMatch[1].replace(/\\\\/g, "\\");
271
+ projectName = extractProjectName(cwd);
272
+ }
311
273
  }
312
- }
313
274
 
314
- // Collect slug -> sessionId mappings
315
- const fileSlugSessions = extractSlugSessionMap(content);
316
- for (const [slug, sessionId] of fileSlugSessions) {
317
- slugSessionMap.set(slug, sessionId);
318
- }
275
+ // Extract slug-sessionId pairs, filtering to only needed slugs
276
+ const slugMatch = line.match(/"slug":"([\w-]+)"/);
277
+ if (
278
+ slugMatch?.[1] &&
279
+ (!neededSlugs || neededSlugs.has(slugMatch[1]))
280
+ ) {
281
+ const sessionMatch = line.match(/"sessionId":"([^"]+)"/);
282
+ if (sessionMatch?.[1]) {
283
+ slugSessionMap.set(slugMatch[1], sessionMatch[1]);
284
+ }
285
+ }
286
+ });
287
+ } catch {
288
+ // Skip unreadable files
319
289
  }
320
-
321
- return { projectName, slugSessionMap };
322
- } catch {
323
- return null;
324
290
  }
325
- })
326
- );
327
291
 
328
- // Merge results into mapping
329
- for (const result of results) {
330
- if (!result?.projectName) continue;
331
- for (const [slug, sessionId] of result.slugSessionMap) {
332
- mapping[slug] = {
333
- project: result.projectName,
334
- sessionId: sessionId,
335
- };
292
+ if (projectName) {
293
+ for (const [slug, sessionId] of slugSessionMap) {
294
+ mapping[slug] = { project: projectName, sessionId };
295
+ }
296
+ }
297
+ } catch {
298
+ // Skip inaccessible directories
336
299
  }
337
300
  }
338
301
  } catch {
@@ -347,37 +310,39 @@ let cachedProjectMapping: ProjectMapping | null = null;
347
310
  const contentCache = new Map<string, string>();
348
311
 
349
312
  async function loadPlans(): Promise<PlanMetadata[]> {
350
- // Build or use cached project mapping
313
+ const files = await readdir(PLANS_DIR);
314
+ const mdFiles = files.filter((f) => f.endsWith(".md"));
315
+
316
+ // Build or use cached project mapping, passing needed slugs for targeted lookup
351
317
  let projectMapping: ProjectMapping;
352
318
  if (!cachedProjectMapping) {
353
- projectMapping = await buildProjectMapping();
319
+ const neededSlugs = new Set(mdFiles.map((f) => f.replace(".md", "")));
320
+ projectMapping = await buildProjectMapping(neededSlugs);
354
321
  cachedProjectMapping = projectMapping;
355
322
  } else {
356
323
  projectMapping = cachedProjectMapping;
357
324
  }
358
325
 
359
- const files = await readdir(PLANS_DIR);
360
- const mdFiles = files.filter((f) => f.endsWith(".md"));
361
-
362
326
  const plans = await Promise.all(
363
327
  mdFiles.map(async (filename) => {
364
328
  const filepath = join(PLANS_DIR, filename);
365
329
  const file = Bun.file(filepath);
366
330
 
367
- const [content, stats] = await Promise.all([file.text(), stat(filepath)]);
331
+ const [content, stats] = await Promise.all([
332
+ file.text(),
333
+ stat(filepath),
334
+ ]);
368
335
 
369
336
  const titleMatch = content.match(/^#\s+(.+)$/m);
370
337
  const title = titleMatch?.[1]
371
338
  ? titleMatch[1].replace(/^Plan:\s*/i, "")
372
339
  : filename.replace(".md", "");
373
340
 
374
- // Look up project from metadata using plan slug (filename without .md)
375
341
  const slug = filename.replace(".md", "");
376
342
  const lineCount = content.split("\n").length;
377
343
  const wordCount = content.split(/\s+/).filter(Boolean).length;
378
344
 
379
345
  const metadata = projectMapping[slug];
380
- // Cache content separately for search and lazy loading
381
346
  contentCache.set(filename, content);
382
347
 
383
348
  return {
@@ -440,12 +405,11 @@ async function watchPlansDirectory() {
440
405
  }
441
406
 
442
407
  // Main server startup
443
- async function startServer() {
444
- const args = parseCliArgs();
445
- const port = await findAvailablePort(args.port ?? 3000);
408
+ async function startServer(port: number, host?: string) {
446
409
 
447
410
  const server = Bun.serve({
448
411
  port,
412
+ hostname: host,
449
413
  routes: {
450
414
  "/": index,
451
415
  "/api": () => Response.redirect("/api/", 301),
@@ -509,7 +473,10 @@ async function startServer() {
509
473
  "/api/refresh": {
510
474
  POST: async () => {
511
475
  const before = cachedPlans?.length ?? 0;
512
- invalidateAllCaches();
476
+ // Only invalidate plans and content; project mapping is expensive
477
+ // to rebuild (streams all JSONL files) and rarely changes
478
+ invalidatePlansCache();
479
+ invalidateContentCache();
513
480
  await loadPlans();
514
481
  const after = cachedPlans?.length ?? 0;
515
482
  return Response.json({ success: true, before, after });
@@ -542,16 +509,10 @@ async function startServer() {
542
509
  return server;
543
510
  }
544
511
 
545
- // ANSI color codes
546
- const c = {
547
- reset: "\x1b[0m",
548
- bold: "\x1b[1m",
549
- dim: "\x1b[2m",
550
- green: "\x1b[32m",
551
- cyan: "\x1b[36m",
552
- yellow: "\x1b[33m",
553
- magenta: "\x1b[35m",
554
- };
512
+ // OSC 8 hyperlink escape sequence for clickable terminal URLs
513
+ function link(url: string, text?: string): string {
514
+ return `\x1b]8;;${url}\x07${text ?? url}\x1b]8;;\x07`;
515
+ }
555
516
 
556
517
  // Main entry point
557
518
  (async () => {
@@ -576,8 +537,13 @@ const c = {
576
537
  process.exit(0);
577
538
  }
578
539
 
579
- // Pre-load plans from file if --from-file is provided
540
+ // Start server first
541
+ const port = await findAvailablePort(args.port ?? 3000);
542
+ const server = await startServer(port, args.host);
543
+
544
+ // Pre-load plans from file or directory
580
545
  let planCount: number;
546
+ let projectCount = 0;
581
547
  let sourceDisplay: string;
582
548
 
583
549
  if (args.fromFile) {
@@ -585,32 +551,30 @@ const c = {
585
551
  planCount = plans.length;
586
552
  sourceDisplay = args.fromFile;
587
553
  } else {
588
- planCount = (await readdir(PLANS_DIR)).filter((f) =>
589
- f.endsWith(".md"),
590
- ).length;
591
554
  sourceDisplay = PLANS_DIR;
592
555
  // Only watch for file changes when not using --from-file
593
556
  watchPlansDirectory();
557
+
558
+ await loadPlans();
559
+
560
+ const projects = new Set(
561
+ (cachedPlans || []).map((p) => p.project).filter(Boolean)
562
+ );
563
+ projectCount = projects.size;
564
+ planCount = cachedPlans?.length ?? 0;
594
565
  }
595
566
 
596
- const server = await startServer();
567
+ const localUrl = `http://localhost:${server.port}/`;
568
+ const apiUrl = `http://localhost:${server.port}/api/`;
569
+ const dirUrl = `file://${claudeDir}`;
597
570
 
598
- console.log();
599
- console.log(`${c.bold}${c.magenta} 📋 Claude Plan Viewer${c.reset}`);
600
- console.log(`${c.dim} ─────────────────────────────${c.reset}`);
601
- console.log(`${c.green} ✓${c.reset} Server running`);
602
- if (!args.fromFile) {
603
- console.log(`${c.green} ✓${c.reset} Watching for file changes`);
571
+ console.log(`\nclaude-plan-viewer v${pkg.version}\n`);
572
+ console.log(` ➜ Web: ${link(localUrl)}`);
573
+ console.log(` ➜ API: ${link(apiUrl)}`);
574
+ if (args.fromFile) {
575
+ console.log(` ➜ Source: ${sourceDisplay}`);
576
+ } else {
577
+ console.log(` ➜ Dir: ${link(dirUrl)} (${projectCount} projects)`);
604
578
  }
605
- console.log();
606
- console.log(
607
- `${c.dim} Local:${c.reset} ${c.cyan}${c.bold}http://localhost:${server.port}${c.reset}`,
608
- );
609
- console.log(
610
- `${c.dim} API:${c.reset} ${c.cyan}http://localhost:${server.port}/api/${c.reset}`,
611
- );
612
- console.log(
613
- `${c.dim} Plans:${c.reset} ${c.yellow}${planCount} plans${c.reset} in ${c.dim}${sourceDisplay}${c.reset}`,
614
- );
615
- console.log();
579
+ console.log(`\n Serving ${planCount} plans\n`);
616
580
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plan-viewer",
3
- "version": "1.4.1",
3
+ "version": "1.5.0",
4
4
  "description": "A web-based viewer for Claude Code plan files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29,7 +29,10 @@
29
29
  "install:local": "bun scripts/install-local.ts",
30
30
  "uninstall:link": "bun unlink",
31
31
  "uninstall:local": "bun scripts/uninstall-local.ts",
32
- "format": "bunx prettier --write src"
32
+ "format": "bunx prettier --write src",
33
+ "website:dev": "cd website/docs && bun run dev",
34
+ "website:build": "cd website/docs && bun run build",
35
+ "website:preview": "cd website/docs && bun run preview"
33
36
  },
34
37
  "keywords": [
35
38
  "claude",
@@ -47,7 +50,7 @@
47
50
  "bugs": {
48
51
  "url": "https://github.com/HelgeSverre/claude-plan-viewer/issues"
49
52
  },
50
- "homepage": "https://github.com/HelgeSverre/claude-plan-viewer#readme",
53
+ "homepage": "https://claudeplans.dev",
51
54
  "engines": {
52
55
  "bun": ">=1.0.0"
53
56
  },
@@ -63,6 +66,7 @@
63
66
  "typescript": "^5.9.3"
64
67
  },
65
68
  "dependencies": {
69
+ "get-port": "^7.1.0",
66
70
  "react": "^19.2.3",
67
71
  "react-dom": "^19.2.3",
68
72
  "react-markdown": "^10.1.0",
package/src/api-docs.html CHANGED
@@ -9,9 +9,20 @@
9
9
  <div id="app"></div>
10
10
  <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
11
11
  <script>
12
- Scalar.createApiReference("#app", {
13
- url: "/api/openapi.json",
14
- });
12
+ const darkModeQuery = window.matchMedia("(prefers-color-scheme: dark)");
13
+
14
+ function createApiReference() {
15
+ document.getElementById("app").innerHTML = "";
16
+ Scalar.createApiReference("#app", {
17
+ url: "/api/openapi.json",
18
+ darkMode: darkModeQuery.matches,
19
+ hideDarkModeToggle: true,
20
+ hideClientButton: true,
21
+ });
22
+ }
23
+
24
+ createApiReference();
25
+ darkModeQuery.addEventListener("change", createApiReference);
15
26
  </script>
16
27
  </body>
17
28
  </html>
package/src/index.html CHANGED
@@ -1,13 +1,33 @@
1
1
  <!doctype html>
2
2
  <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Claude Plans Viewer</title>
7
- <link rel="stylesheet" href="./styles/styles.css" />
8
- </head>
9
- <body>
10
- <div id="root"></div>
11
- <script type="module" src="./client/index.tsx"></script>
12
- </body>
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Claude Plans Viewer</title>
7
+ <meta
8
+ name="description"
9
+ content="Browse, search, and read your Claude Code plans in a clean web UI"
10
+ />
11
+ <meta name="author" content="Helge Sverre" />
12
+ <meta property="og:title" content="Claude Plans Viewer" />
13
+ <meta
14
+ property="og:description"
15
+ content="Browse, search, and read your Claude Code plans in a clean web UI"
16
+ />
17
+ <meta property="og:type" content="website" />
18
+ <meta property="og:url" content="https://claudeplans.dev" />
19
+ <meta property="og:site_name" content="Claude Plans Viewer" />
20
+ <meta name="twitter:card" content="summary_large_image" />
21
+ <meta name="twitter:title" content="Claude Plans Viewer" />
22
+ <meta
23
+ name="twitter:description"
24
+ content="Browse, search, and read your Claude Code plans in a clean web UI"
25
+ />
26
+ <link rel="canonical" href="https://claudeplans.dev" />
27
+ <link rel="stylesheet" href="./styles/styles.css" />
28
+ </head>
29
+ <body>
30
+ <div id="root"></div>
31
+ <script type="module" src="./client/index.tsx"></script>
32
+ </body>
13
33
  </html>