cc-plan-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.
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ // cc-plan-viewer PostToolUse hook
3
+ // Detects plan file writes and opens the browser-based plan viewer.
4
+ // Also injects review feedback back into Claude Code via additionalContext.
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const os = require('os');
9
+ const http = require('http');
10
+ const { execSync, spawn } = require('child_process');
11
+
12
+ const SERVER_PORT = 3847;
13
+ const PORT_FILE = path.join(os.tmpdir(), 'cc-plan-viewer-port');
14
+ const DEBOUNCE_FILE = path.join(os.tmpdir(), 'cc-plan-viewer-opened.json');
15
+ const DEBOUNCE_MS = 30000; // 30 seconds
16
+
17
+ // Plans directory candidates
18
+ const PLANS_DIRS = [
19
+ path.join(os.homedir(), '.claude-personal', 'plans'),
20
+ path.join(os.homedir(), '.claude', 'plans'),
21
+ ];
22
+
23
+ function getPlansDir() {
24
+ for (const dir of PLANS_DIRS) {
25
+ if (fs.existsSync(dir)) return dir;
26
+ }
27
+ return null;
28
+ }
29
+
30
+ function getServerPort() {
31
+ try {
32
+ return parseInt(fs.readFileSync(PORT_FILE, 'utf8').trim(), 10) || SERVER_PORT;
33
+ } catch {
34
+ return SERVER_PORT;
35
+ }
36
+ }
37
+
38
+ function isPlanFile(filePath) {
39
+ if (!filePath || !filePath.endsWith('.md')) return false;
40
+ const plansDir = getPlansDir();
41
+ if (!plansDir) return false;
42
+ return path.dirname(filePath) === plansDir;
43
+ }
44
+
45
+ function shouldOpenBrowser(filename) {
46
+ try {
47
+ const data = JSON.parse(fs.readFileSync(DEBOUNCE_FILE, 'utf8'));
48
+ const lastOpened = data[filename];
49
+ if (lastOpened && Date.now() - lastOpened < DEBOUNCE_MS) return false;
50
+ } catch {}
51
+ return true;
52
+ }
53
+
54
+ function markBrowserOpened(filename) {
55
+ let data = {};
56
+ try { data = JSON.parse(fs.readFileSync(DEBOUNCE_FILE, 'utf8')); } catch {}
57
+ data[filename] = Date.now();
58
+ fs.writeFileSync(DEBOUNCE_FILE, JSON.stringify(data), 'utf8');
59
+ }
60
+
61
+ function postToServer(port, filePath) {
62
+ return new Promise((resolve) => {
63
+ const payload = JSON.stringify({ filePath });
64
+ const req = http.request(
65
+ {
66
+ hostname: '127.0.0.1',
67
+ port,
68
+ path: '/api/plan-updated',
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
71
+ timeout: 2000,
72
+ },
73
+ (res) => { res.resume(); resolve(true); }
74
+ );
75
+ req.on('error', () => resolve(false));
76
+ req.on('timeout', () => { req.destroy(); resolve(false); });
77
+ req.write(payload);
78
+ req.end();
79
+ });
80
+ }
81
+
82
+ function startServer() {
83
+ // Server entry point: <pkg>/dist/server/server/index.js
84
+ const serverPath = path.join(__dirname, '..', 'dist', 'server', 'server', 'index.js');
85
+ if (!fs.existsSync(serverPath)) return;
86
+ const child = spawn(process.execPath, [serverPath], {
87
+ detached: true,
88
+ stdio: 'ignore',
89
+ env: { ...process.env, PORT: String(SERVER_PORT) },
90
+ });
91
+ child.unref();
92
+ }
93
+
94
+ async function waitForServer(port, maxWaitMs = 3000) {
95
+ const start = Date.now();
96
+ while (Date.now() - start < maxWaitMs) {
97
+ const ok = await new Promise((resolve) => {
98
+ const req = http.request(
99
+ { hostname: '127.0.0.1', port, path: '/health', method: 'GET', timeout: 500 },
100
+ (res) => { res.resume(); resolve(res.statusCode === 200); }
101
+ );
102
+ req.on('error', () => resolve(false));
103
+ req.on('timeout', () => { req.destroy(); resolve(false); });
104
+ req.end();
105
+ });
106
+ if (ok) return true;
107
+ await new Promise(r => setTimeout(r, 200));
108
+ }
109
+ return false;
110
+ }
111
+
112
+ function checkUnconsumedReviews() {
113
+ const plansDir = getPlansDir();
114
+ if (!plansDir) return null;
115
+
116
+ const files = fs.readdirSync(plansDir).filter(f => f.endsWith('.review.json'));
117
+ for (const file of files) {
118
+ try {
119
+ const reviewPath = path.join(plansDir, file);
120
+ const review = JSON.parse(fs.readFileSync(reviewPath, 'utf8'));
121
+ if (review.consumedAt) continue;
122
+
123
+ // Mark as consumed
124
+ review.consumedAt = new Date().toISOString();
125
+ fs.writeFileSync(reviewPath, JSON.stringify(review, null, 2), 'utf8');
126
+
127
+ // Format feedback
128
+ if (review.action === 'feedback') {
129
+ let msg = `PLAN REVIEW FEEDBACK for "${review.planFile}":\n`;
130
+ if (review.inlineComments && review.inlineComments.length > 0) {
131
+ msg += `\n${review.inlineComments.length} inline comment(s):\n\n`;
132
+ for (let i = 0; i < review.inlineComments.length; i++) {
133
+ const c = review.inlineComments[i];
134
+ msg += `--- Comment ${i + 1} ---\n`;
135
+ msg += `Selected text: "${c.sectionId}"\n`;
136
+ msg += `Feedback: ${c.body}\n\n`;
137
+ }
138
+ }
139
+ if (review.overallComment) {
140
+ msg += `--- General comment ---\n`;
141
+ msg += `${review.overallComment}\n`;
142
+ }
143
+ return msg;
144
+ } else {
145
+ return `PLAN REVIEW for "${review.planFile}": User selected "${review.action}"`;
146
+ }
147
+ } catch {}
148
+ }
149
+ return null;
150
+ }
151
+
152
+ // Main
153
+ let input = '';
154
+ process.stdin.setEncoding('utf8');
155
+ process.stdin.on('data', (chunk) => (input += chunk));
156
+ process.stdin.on('end', async () => {
157
+ try {
158
+ const data = JSON.parse(input);
159
+ const toolName = data.tool_name;
160
+ const filePath = data.tool_input?.file_path;
161
+
162
+ // Check for unconsumed reviews on every invocation
163
+ const reviewFeedback = checkUnconsumedReviews();
164
+
165
+ // Check if this is a plan file write
166
+ const isPlan = (toolName === 'Write' || toolName === 'Edit') && isPlanFile(filePath);
167
+
168
+ if (isPlan) {
169
+ const port = getServerPort();
170
+ const serverReachable = await postToServer(port, filePath);
171
+
172
+ if (!serverReachable) {
173
+ startServer();
174
+ await waitForServer(port);
175
+ await postToServer(port, filePath);
176
+ }
177
+
178
+ const filename = path.basename(filePath);
179
+ if (shouldOpenBrowser(filename)) {
180
+ markBrowserOpened(filename);
181
+ try {
182
+ execSync(`open "http://localhost:${port}/?plan=${encodeURIComponent(filename)}"`, { stdio: 'ignore' });
183
+ } catch {}
184
+ }
185
+ }
186
+
187
+ // Output hook response
188
+ if (reviewFeedback) {
189
+ const output = {
190
+ hookSpecificOutput: {
191
+ hookEventName: 'PostToolUse',
192
+ additionalContext: reviewFeedback,
193
+ },
194
+ };
195
+ process.stdout.write(JSON.stringify(output));
196
+ }
197
+ } catch {
198
+ // Silent fail — never block tool execution
199
+ process.exit(0);
200
+ }
201
+ });
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "cc-plan-viewer",
3
+ "version": "0.1.0",
4
+ "description": "Browser-based PR-style review UI for Claude Code plans",
5
+ "type": "module",
6
+ "bin": {
7
+ "cc-plan-viewer": "./dist/server/bin/cc-plan-viewer.js"
8
+ },
9
+ "files": [
10
+ "dist/",
11
+ "hooks/",
12
+ "LICENSE",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "dev": "concurrently \"tsx watch server/index.ts\" \"vite\"",
17
+ "build": "vite build && tsc -p tsconfig.server.json",
18
+ "start": "node dist/server/server/index.js",
19
+ "install-hook": "node dist/server/bin/cc-plan-viewer.js install",
20
+ "prepublishOnly": "npm run build"
21
+ },
22
+ "keywords": ["claude", "claude-code", "plan", "review", "viewer"],
23
+ "license": "MIT",
24
+ "author": "Damià Fuentes Escoté",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/damiafuentes/cc-plan-viewer"
28
+ },
29
+ "engines": {
30
+ "node": ">=18"
31
+ },
32
+ "dependencies": {
33
+ "express": "^5.1.0",
34
+ "ws": "^8.18.0"
35
+ },
36
+ "devDependencies": {
37
+ "@types/express": "^5.0.0",
38
+ "@types/node": "^22.0.0",
39
+ "@types/ws": "^8.5.0",
40
+ "@types/react": "^19.0.0",
41
+ "@types/react-dom": "^19.0.0",
42
+ "@vitejs/plugin-react": "^4.3.0",
43
+ "autoprefixer": "^10.4.0",
44
+ "concurrently": "^9.0.0",
45
+ "postcss": "^8.4.0",
46
+ "react": "^19.0.0",
47
+ "react-dom": "^19.0.0",
48
+ "react-markdown": "^9.0.0",
49
+ "rehype-highlight": "^7.0.0",
50
+ "remark-gfm": "^4.0.0",
51
+ "tailwindcss": "^3.4.0",
52
+ "tsx": "^4.19.0",
53
+ "typescript": "^5.7.0",
54
+ "vite": "^6.0.0"
55
+ }
56
+ }