claude-plan-viewer 1.1.1 → 1.4.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 +84 -4
- package/index.ts +436 -86
- package/package.json +33 -13
- package/src/api-docs.html +17 -0
- package/src/client/App.tsx +218 -0
- package/src/client/components/DetailOverlay.tsx +145 -0
- package/src/client/components/DetailPanel.tsx +136 -0
- package/src/client/components/Header.tsx +74 -0
- package/src/client/components/HelpModal.tsx +81 -0
- package/src/client/components/Markdown.tsx +46 -0
- package/src/client/components/PlanRow.tsx +64 -0
- package/src/client/components/PlansTable.tsx +94 -0
- package/src/client/components/ProjectFilter.tsx +175 -0
- package/src/client/components/SearchInput.tsx +48 -0
- package/src/client/components/index.ts +9 -0
- package/src/client/hooks/index.ts +3 -0
- package/src/client/hooks/useDebounce.ts +17 -0
- package/src/client/hooks/useFilters.ts +78 -0
- package/src/client/hooks/useFocusTrap.ts +70 -0
- package/src/client/hooks/useKeyboard.ts +114 -0
- package/src/client/hooks/usePlans.ts +160 -0
- package/src/client/hooks/useProjects.ts +34 -0
- package/src/client/index.tsx +11 -0
- package/src/client/types.ts +25 -0
- package/src/client/utils/api.ts +47 -0
- package/src/client/utils/formatters.ts +69 -0
- package/src/client/utils/index.ts +3 -0
- package/src/client/utils/strings.ts +18 -0
- package/src/index.html +13 -0
- package/{styles.css → src/styles/styles.css} +439 -178
- package/frontend.ts +0 -843
- package/index.html +0 -14
- package/prism.bundle.js +0 -35
package/index.ts
CHANGED
|
@@ -1,23 +1,148 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { readdir, stat, watch } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
|
-
import index from "./index.html";
|
|
6
|
-
import
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
5
|
+
import index from "./src/index.html";
|
|
6
|
+
import apiDocs from "./src/api-docs.html";
|
|
7
|
+
import pkg from "./package.json";
|
|
8
|
+
import openapi from "./openapi.json";
|
|
9
|
+
|
|
10
|
+
// Resolved at startup based on --claude-dir flag or CLAUDE_DIR env var
|
|
11
|
+
let PLANS_DIR: string;
|
|
12
|
+
let PROJECTS_DIR: string;
|
|
13
|
+
|
|
14
|
+
function resolveClaudeDir(cliArg?: string): string {
|
|
15
|
+
return cliArg || process.env.CLAUDE_DIR || join(homedir(), ".claude");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function initializeDirectories(claudeDir: string): void {
|
|
19
|
+
PLANS_DIR = join(claudeDir, "plans");
|
|
20
|
+
PROJECTS_DIR = join(claudeDir, "projects");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface CliArgs {
|
|
24
|
+
port?: number;
|
|
25
|
+
json?: boolean;
|
|
26
|
+
output?: string;
|
|
27
|
+
fromFile?: string;
|
|
28
|
+
claudeDir?: string;
|
|
29
|
+
version?: boolean;
|
|
30
|
+
help?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseCliArgs(): CliArgs {
|
|
34
|
+
const args: CliArgs = {};
|
|
35
|
+
const argv = process.argv.slice(2);
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < argv.length; i++) {
|
|
38
|
+
const arg = argv[i];
|
|
39
|
+
const nextArg = argv[i + 1];
|
|
40
|
+
|
|
41
|
+
if (arg === "--port" || arg === "-p") {
|
|
42
|
+
if (nextArg && !nextArg.startsWith("-")) {
|
|
43
|
+
args.port = parseInt(nextArg, 10);
|
|
44
|
+
i++;
|
|
45
|
+
}
|
|
46
|
+
} else if (arg === "--json" || arg === "-j") {
|
|
47
|
+
args.json = true;
|
|
48
|
+
} else if (arg === "--output" || arg === "-o") {
|
|
49
|
+
if (nextArg && !nextArg.startsWith("-")) {
|
|
50
|
+
args.output = nextArg;
|
|
51
|
+
i++;
|
|
52
|
+
}
|
|
53
|
+
} else if (arg === "--from-file" || arg === "-f") {
|
|
54
|
+
if (nextArg && !nextArg.startsWith("-")) {
|
|
55
|
+
args.fromFile = nextArg;
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
} else if (arg === "--claude-dir" || arg === "-c") {
|
|
59
|
+
if (nextArg && !nextArg.startsWith("-")) {
|
|
60
|
+
args.claudeDir = nextArg;
|
|
61
|
+
i++;
|
|
62
|
+
}
|
|
63
|
+
} else if (arg === "--version" || arg === "-v") {
|
|
64
|
+
args.version = true;
|
|
65
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
66
|
+
args.help = true;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return args;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function printHelp(): void {
|
|
74
|
+
console.log(`
|
|
75
|
+
claude-plan-viewer - Browse and search Claude Code plans
|
|
76
|
+
|
|
77
|
+
Usage: claude-plan-viewer [options]
|
|
78
|
+
|
|
79
|
+
Options:
|
|
80
|
+
-p, --port <number> Port to start server on (default: 3000)
|
|
81
|
+
-c, --claude-dir <path> Path to .claude directory (default: ~/.claude)
|
|
82
|
+
Can also be set via CLAUDE_DIR environment variable
|
|
83
|
+
-j, --json Export all plans as JSON and exit
|
|
84
|
+
-o, --output <file> Output file for JSON export (stdout if omitted)
|
|
85
|
+
-f, --from-file <file> Load plans from JSON file instead of ~/.claude/plans
|
|
86
|
+
-v, --version Show version number
|
|
87
|
+
-h, --help Show this help message
|
|
88
|
+
|
|
89
|
+
Examples:
|
|
90
|
+
claude-plan-viewer Start viewer on default port
|
|
91
|
+
claude-plan-viewer -p 8080 Start on port 8080
|
|
92
|
+
claude-plan-viewer -c /path/to/.claude Use custom .claude directory
|
|
93
|
+
claude-plan-viewer -j -o plans.json Export plans to file
|
|
94
|
+
claude-plan-viewer -f plans.json Load plans from exported file
|
|
95
|
+
`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function exportPlansAsJson(outputPath?: string): Promise<void> {
|
|
99
|
+
const plans = await loadPlans();
|
|
100
|
+
|
|
101
|
+
const plansWithContent = plans.map((plan) => ({
|
|
102
|
+
...plan,
|
|
103
|
+
content: contentCache.get(plan.filename) || "",
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
const jsonOutput = JSON.stringify(plansWithContent, null, 2);
|
|
107
|
+
|
|
108
|
+
if (outputPath) {
|
|
109
|
+
await Bun.write(outputPath, jsonOutput);
|
|
110
|
+
console.log(`Exported ${plans.length} plans to ${outputPath}`);
|
|
111
|
+
} else {
|
|
112
|
+
console.log(jsonOutput);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function loadPlansFromFile(filepath: string): Promise<PlanMetadata[]> {
|
|
117
|
+
const file = Bun.file(filepath);
|
|
118
|
+
const exists = await file.exists();
|
|
119
|
+
|
|
120
|
+
if (!exists) {
|
|
121
|
+
console.error(`File not found: ${filepath}`);
|
|
122
|
+
process.exit(1);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const data = await file.json();
|
|
126
|
+
const plans: PlanMetadata[] = [];
|
|
127
|
+
|
|
128
|
+
for (const plan of data) {
|
|
129
|
+
contentCache.set(plan.filename, plan.content || "");
|
|
130
|
+
plans.push({
|
|
131
|
+
filename: plan.filename,
|
|
132
|
+
filepath: plan.filepath,
|
|
133
|
+
title: plan.title,
|
|
134
|
+
size: plan.size,
|
|
135
|
+
modified: plan.modified,
|
|
136
|
+
created: plan.created,
|
|
137
|
+
lineCount: plan.lineCount,
|
|
138
|
+
wordCount: plan.wordCount,
|
|
139
|
+
project: plan.project,
|
|
140
|
+
sessionId: plan.sessionId,
|
|
141
|
+
});
|
|
19
142
|
}
|
|
20
|
-
|
|
143
|
+
|
|
144
|
+
cachedPlans = plans;
|
|
145
|
+
return plans;
|
|
21
146
|
}
|
|
22
147
|
|
|
23
148
|
// Find an available port starting from the requested port
|
|
@@ -37,7 +162,9 @@ async function findAvailablePort(startPort: number = 3000): Promise<number> {
|
|
|
37
162
|
port++;
|
|
38
163
|
}
|
|
39
164
|
}
|
|
40
|
-
throw new Error(
|
|
165
|
+
throw new Error(
|
|
166
|
+
`No available port found in range ${startPort}-${startPort + maxAttempts}`,
|
|
167
|
+
);
|
|
41
168
|
}
|
|
42
169
|
|
|
43
170
|
// Cross-platform open file in default editor
|
|
@@ -52,17 +179,21 @@ async function openInEditor(filepath: string): Promise<void> {
|
|
|
52
179
|
}
|
|
53
180
|
}
|
|
54
181
|
|
|
55
|
-
interface
|
|
182
|
+
interface PlanMetadata {
|
|
56
183
|
filename: string;
|
|
57
184
|
filepath: string;
|
|
58
185
|
title: string;
|
|
59
|
-
content: string;
|
|
60
186
|
size: number;
|
|
61
187
|
modified: string;
|
|
62
188
|
created: string;
|
|
63
189
|
lineCount: number;
|
|
64
190
|
wordCount: number;
|
|
65
191
|
project: string | null;
|
|
192
|
+
sessionId: string | null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
interface Plan extends PlanMetadata {
|
|
196
|
+
content: string;
|
|
66
197
|
}
|
|
67
198
|
|
|
68
199
|
// Extract project name from a full path (cross-platform)
|
|
@@ -73,7 +204,9 @@ function extractProjectName(cwd: string): string {
|
|
|
73
204
|
// Normalize: handle both / and \ separators
|
|
74
205
|
const normalized = cwd.replace(/\\/g, "/");
|
|
75
206
|
// Remove trailing slash
|
|
76
|
-
const trimmed = normalized.endsWith("/")
|
|
207
|
+
const trimmed = normalized.endsWith("/")
|
|
208
|
+
? normalized.slice(0, -1)
|
|
209
|
+
: normalized;
|
|
77
210
|
// Get last segment
|
|
78
211
|
const lastSlash = trimmed.lastIndexOf("/");
|
|
79
212
|
return lastSlash === -1 ? trimmed : trimmed.slice(lastSlash + 1);
|
|
@@ -83,70 +216,123 @@ function extractProjectName(cwd: string): string {
|
|
|
83
216
|
function extractCwdFromJsonl(content: string): string | null {
|
|
84
217
|
if (!content) return null;
|
|
85
218
|
const match = content.match(/"cwd":"([^"]+)"/);
|
|
86
|
-
if (!match) return null;
|
|
219
|
+
if (!match || !match[1]) return null;
|
|
87
220
|
// Unescape JSON string (convert \\\\ to \\)
|
|
88
221
|
return match[1].replace(/\\\\/g, "\\");
|
|
89
222
|
}
|
|
90
223
|
|
|
91
|
-
// Extract
|
|
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
|
+
}
|
|
245
|
+
|
|
246
|
+
// Extract unique slugs from JSONL content (for backwards compatibility)
|
|
92
247
|
function extractSlugsFromJsonl(content: string): string[] {
|
|
93
248
|
if (!content) return [];
|
|
94
249
|
const slugs = new Set<string>();
|
|
95
250
|
const matches = content.matchAll(/"slug":"([\w-]+)"/g);
|
|
96
251
|
for (const match of matches) {
|
|
97
|
-
|
|
252
|
+
if (match[1]) {
|
|
253
|
+
slugs.add(match[1]);
|
|
254
|
+
}
|
|
98
255
|
}
|
|
99
256
|
return Array.from(slugs);
|
|
100
257
|
}
|
|
101
258
|
|
|
259
|
+
interface SlugMetadata {
|
|
260
|
+
project: string;
|
|
261
|
+
sessionId: string | null;
|
|
262
|
+
}
|
|
263
|
+
|
|
102
264
|
interface ProjectMapping {
|
|
103
|
-
[slug: string]:
|
|
265
|
+
[slug: string]: SlugMetadata;
|
|
104
266
|
}
|
|
105
267
|
|
|
106
|
-
// Build a mapping of plan slugs to project names by scanning Claude Code's project metadata
|
|
268
|
+
// Build a mapping of plan slugs to project names and session IDs by scanning Claude Code's project metadata
|
|
107
269
|
async function buildProjectMapping(): Promise<ProjectMapping> {
|
|
108
270
|
const mapping: ProjectMapping = {};
|
|
109
271
|
|
|
110
272
|
try {
|
|
111
273
|
const projectDirs = await readdir(PROJECTS_DIR);
|
|
112
274
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
// Find JSONL files and extract cwd + slugs
|
|
119
|
-
const files = await readdir(dirPath);
|
|
120
|
-
const jsonlFiles = files.filter((f) => f.endsWith(".jsonl"));
|
|
121
|
-
|
|
122
|
-
let projectName: string | null = null;
|
|
123
|
-
const allSlugs: string[] = [];
|
|
124
|
-
|
|
125
|
-
for (const file of jsonlFiles) {
|
|
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);
|
|
126
279
|
try {
|
|
127
|
-
const
|
|
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
|
+
);
|
|
298
|
+
|
|
299
|
+
let projectName: string | null = null;
|
|
300
|
+
const slugSessionMap = new Map<string, string>();
|
|
301
|
+
|
|
302
|
+
// Process file contents
|
|
303
|
+
for (const content of fileContents) {
|
|
304
|
+
if (!content) continue;
|
|
305
|
+
|
|
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);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
128
313
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
projectName = extractProjectName(cwd);
|
|
314
|
+
// Collect slug -> sessionId mappings
|
|
315
|
+
const fileSlugSessions = extractSlugSessionMap(content);
|
|
316
|
+
for (const [slug, sessionId] of fileSlugSessions) {
|
|
317
|
+
slugSessionMap.set(slug, sessionId);
|
|
134
318
|
}
|
|
135
319
|
}
|
|
136
320
|
|
|
137
|
-
|
|
138
|
-
const slugs = extractSlugsFromJsonl(content);
|
|
139
|
-
allSlugs.push(...slugs);
|
|
321
|
+
return { projectName, slugSessionMap };
|
|
140
322
|
} catch {
|
|
141
|
-
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Map all slugs to this project
|
|
146
|
-
if (projectName) {
|
|
147
|
-
for (const slug of allSlugs) {
|
|
148
|
-
mapping[slug] = projectName;
|
|
323
|
+
return null;
|
|
149
324
|
}
|
|
325
|
+
})
|
|
326
|
+
);
|
|
327
|
+
|
|
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
|
+
};
|
|
150
336
|
}
|
|
151
337
|
}
|
|
152
338
|
} catch {
|
|
@@ -156,9 +342,19 @@ async function buildProjectMapping(): Promise<ProjectMapping> {
|
|
|
156
342
|
return mapping;
|
|
157
343
|
}
|
|
158
344
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
345
|
+
let cachedPlans: PlanMetadata[] | null = null;
|
|
346
|
+
let cachedProjectMapping: ProjectMapping | null = null;
|
|
347
|
+
const contentCache = new Map<string, string>();
|
|
348
|
+
|
|
349
|
+
async function loadPlans(): Promise<PlanMetadata[]> {
|
|
350
|
+
// Build or use cached project mapping
|
|
351
|
+
let projectMapping: ProjectMapping;
|
|
352
|
+
if (!cachedProjectMapping) {
|
|
353
|
+
projectMapping = await buildProjectMapping();
|
|
354
|
+
cachedProjectMapping = projectMapping;
|
|
355
|
+
} else {
|
|
356
|
+
projectMapping = cachedProjectMapping;
|
|
357
|
+
}
|
|
162
358
|
|
|
163
359
|
const files = await readdir(PLANS_DIR);
|
|
164
360
|
const mdFiles = files.filter((f) => f.endsWith(".md"));
|
|
@@ -167,10 +363,8 @@ async function loadPlans(): Promise<Plan[]> {
|
|
|
167
363
|
mdFiles.map(async (filename) => {
|
|
168
364
|
const filepath = join(PLANS_DIR, filename);
|
|
169
365
|
const file = Bun.file(filepath);
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
stat(filepath),
|
|
173
|
-
]);
|
|
366
|
+
|
|
367
|
+
const [content, stats] = await Promise.all([file.text(), stat(filepath)]);
|
|
174
368
|
|
|
175
369
|
const titleMatch = content.match(/^#\s+(.+)$/m);
|
|
176
370
|
const title = titleMatch?.[1]
|
|
@@ -179,42 +373,147 @@ async function loadPlans(): Promise<Plan[]> {
|
|
|
179
373
|
|
|
180
374
|
// Look up project from metadata using plan slug (filename without .md)
|
|
181
375
|
const slug = filename.replace(".md", "");
|
|
376
|
+
const lineCount = content.split("\n").length;
|
|
377
|
+
const wordCount = content.split(/\s+/).filter(Boolean).length;
|
|
378
|
+
|
|
379
|
+
const metadata = projectMapping[slug];
|
|
380
|
+
// Cache content separately for search and lazy loading
|
|
381
|
+
contentCache.set(filename, content);
|
|
182
382
|
|
|
183
383
|
return {
|
|
184
384
|
filename,
|
|
185
385
|
filepath,
|
|
186
386
|
title,
|
|
187
|
-
content,
|
|
188
387
|
size: stats.size,
|
|
189
388
|
modified: stats.mtime.toISOString(),
|
|
190
389
|
created: stats.birthtime.toISOString(),
|
|
191
|
-
lineCount
|
|
192
|
-
wordCount
|
|
193
|
-
project:
|
|
390
|
+
lineCount,
|
|
391
|
+
wordCount,
|
|
392
|
+
project: metadata?.project || null,
|
|
393
|
+
sessionId: metadata?.sessionId || null,
|
|
194
394
|
};
|
|
195
|
-
})
|
|
395
|
+
}),
|
|
196
396
|
);
|
|
197
397
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
398
|
+
cachedPlans = plans;
|
|
399
|
+
return plans;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Granular cache invalidation
|
|
403
|
+
function invalidatePlansCache() {
|
|
404
|
+
cachedPlans = null;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function invalidateProjectMapping() {
|
|
408
|
+
cachedProjectMapping = null;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function invalidateContentCache(filename?: string) {
|
|
412
|
+
if (filename) {
|
|
413
|
+
contentCache.delete(filename);
|
|
414
|
+
} else {
|
|
415
|
+
contentCache.clear();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function invalidateAllCaches() {
|
|
420
|
+
invalidatePlansCache();
|
|
421
|
+
invalidateProjectMapping();
|
|
422
|
+
invalidateContentCache();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Watch plans directory for changes and invalidate cache
|
|
426
|
+
async function watchPlansDirectory() {
|
|
427
|
+
try {
|
|
428
|
+
const watcher = watch(PLANS_DIR);
|
|
429
|
+
for await (const event of watcher) {
|
|
430
|
+
if (event.filename?.endsWith(".md")) {
|
|
431
|
+
// Only invalidate plans metadata and the specific file's content
|
|
432
|
+
// Project mapping rarely changes, keep it cached
|
|
433
|
+
invalidatePlansCache();
|
|
434
|
+
invalidateContentCache(event.filename);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
} catch {
|
|
438
|
+
// Directory may not exist or watching may not be supported
|
|
439
|
+
}
|
|
201
440
|
}
|
|
202
441
|
|
|
203
442
|
// Main server startup
|
|
204
443
|
async function startServer() {
|
|
205
|
-
const
|
|
206
|
-
const port = await findAvailablePort(
|
|
444
|
+
const args = parseCliArgs();
|
|
445
|
+
const port = await findAvailablePort(args.port ?? 3000);
|
|
207
446
|
|
|
208
447
|
const server = Bun.serve({
|
|
209
448
|
port,
|
|
210
|
-
static: {
|
|
211
|
-
"/prism.bundle.js": Bun.file(prismBundlePath),
|
|
212
|
-
},
|
|
213
449
|
routes: {
|
|
214
450
|
"/": index,
|
|
215
|
-
"/api
|
|
216
|
-
|
|
217
|
-
|
|
451
|
+
"/api": () => Response.redirect("/api/", 301),
|
|
452
|
+
"/api/": apiDocs,
|
|
453
|
+
"/api/openapi.json": () => Response.json(openapi),
|
|
454
|
+
"/api/projects": async () => {
|
|
455
|
+
// Lazy load cache on first request
|
|
456
|
+
if (!cachedPlans) {
|
|
457
|
+
await loadPlans();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const plans = cachedPlans || [];
|
|
461
|
+
const projects = [
|
|
462
|
+
...new Set(plans.map((p) => p.project).filter(Boolean)),
|
|
463
|
+
] as string[];
|
|
464
|
+
projects.sort((a, b) => a.localeCompare(b));
|
|
465
|
+
|
|
466
|
+
return Response.json({ projects });
|
|
467
|
+
},
|
|
468
|
+
"/api/plans": async (req) => {
|
|
469
|
+
// Lazy load cache on first request
|
|
470
|
+
if (!cachedPlans) {
|
|
471
|
+
await loadPlans();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const plans = cachedPlans || [];
|
|
475
|
+
|
|
476
|
+
// Strip content from response - will be fetched separately via /api/plans/{id}/content
|
|
477
|
+
const plansWithoutContent = plans.map((p) => ({
|
|
478
|
+
filename: p.filename,
|
|
479
|
+
filepath: p.filepath,
|
|
480
|
+
title: p.title,
|
|
481
|
+
size: p.size,
|
|
482
|
+
modified: p.modified,
|
|
483
|
+
created: p.created,
|
|
484
|
+
lineCount: p.lineCount,
|
|
485
|
+
wordCount: p.wordCount,
|
|
486
|
+
project: p.project,
|
|
487
|
+
sessionId: p.sessionId,
|
|
488
|
+
}));
|
|
489
|
+
|
|
490
|
+
return Response.json({
|
|
491
|
+
plans: plansWithoutContent,
|
|
492
|
+
});
|
|
493
|
+
},
|
|
494
|
+
"/api/plans/:filename/content": async (req) => {
|
|
495
|
+
// Lazy load cache on first request
|
|
496
|
+
if (!cachedPlans) {
|
|
497
|
+
await loadPlans();
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const filename = req.params.filename as string;
|
|
501
|
+
const content = contentCache.get(filename);
|
|
502
|
+
|
|
503
|
+
if (content === undefined) {
|
|
504
|
+
return new Response("Plan not found", { status: 404 });
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return Response.json({ content });
|
|
508
|
+
},
|
|
509
|
+
"/api/refresh": {
|
|
510
|
+
POST: async () => {
|
|
511
|
+
const before = cachedPlans?.length ?? 0;
|
|
512
|
+
invalidateAllCaches();
|
|
513
|
+
await loadPlans();
|
|
514
|
+
const after = cachedPlans?.length ?? 0;
|
|
515
|
+
return Response.json({ success: true, before, after });
|
|
516
|
+
},
|
|
218
517
|
},
|
|
219
518
|
"/api/open": {
|
|
220
519
|
POST: async (req) => {
|
|
@@ -231,10 +530,13 @@ async function startServer() {
|
|
|
231
530
|
},
|
|
232
531
|
},
|
|
233
532
|
},
|
|
234
|
-
development:
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
533
|
+
development:
|
|
534
|
+
process.env.NODE_ENV !== "production"
|
|
535
|
+
? {
|
|
536
|
+
hmr: true,
|
|
537
|
+
console: true,
|
|
538
|
+
}
|
|
539
|
+
: undefined,
|
|
238
540
|
});
|
|
239
541
|
|
|
240
542
|
return server;
|
|
@@ -253,14 +555,62 @@ const c = {
|
|
|
253
555
|
|
|
254
556
|
// Main entry point
|
|
255
557
|
(async () => {
|
|
558
|
+
const args = parseCliArgs();
|
|
559
|
+
|
|
560
|
+
if (args.version) {
|
|
561
|
+
console.log(`claude-plan-viewer v${pkg.version}`);
|
|
562
|
+
process.exit(0);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (args.help) {
|
|
566
|
+
printHelp();
|
|
567
|
+
process.exit(0);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Initialize directory paths based on --claude-dir flag or CLAUDE_DIR env var
|
|
571
|
+
const claudeDir = resolveClaudeDir(args.claudeDir);
|
|
572
|
+
initializeDirectories(claudeDir);
|
|
573
|
+
|
|
574
|
+
if (args.json) {
|
|
575
|
+
await exportPlansAsJson(args.output);
|
|
576
|
+
process.exit(0);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Pre-load plans from file if --from-file is provided
|
|
580
|
+
let planCount: number;
|
|
581
|
+
let sourceDisplay: string;
|
|
582
|
+
|
|
583
|
+
if (args.fromFile) {
|
|
584
|
+
const plans = await loadPlansFromFile(args.fromFile);
|
|
585
|
+
planCount = plans.length;
|
|
586
|
+
sourceDisplay = args.fromFile;
|
|
587
|
+
} else {
|
|
588
|
+
planCount = (await readdir(PLANS_DIR)).filter((f) =>
|
|
589
|
+
f.endsWith(".md"),
|
|
590
|
+
).length;
|
|
591
|
+
sourceDisplay = PLANS_DIR;
|
|
592
|
+
// Only watch for file changes when not using --from-file
|
|
593
|
+
watchPlansDirectory();
|
|
594
|
+
}
|
|
595
|
+
|
|
256
596
|
const server = await startServer();
|
|
257
|
-
|
|
597
|
+
|
|
258
598
|
console.log();
|
|
259
|
-
console.log(`${c.bold}${c.magenta} 📋
|
|
599
|
+
console.log(`${c.bold}${c.magenta} 📋 Claude Plan Viewer${c.reset}`);
|
|
260
600
|
console.log(`${c.dim} ─────────────────────────────${c.reset}`);
|
|
261
601
|
console.log(`${c.green} ✓${c.reset} Server running`);
|
|
602
|
+
if (!args.fromFile) {
|
|
603
|
+
console.log(`${c.green} ✓${c.reset} Watching for file changes`);
|
|
604
|
+
}
|
|
262
605
|
console.log();
|
|
263
|
-
console.log(
|
|
264
|
-
|
|
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
|
+
);
|
|
265
615
|
console.log();
|
|
266
616
|
})();
|