@juho0719/cckit 0.2.4 → 0.2.5

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,334 @@
1
+ const crypto = require('crypto');
2
+ const http = require('http');
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // ========== WebSocket Protocol (RFC 6455) ==========
7
+
8
+ const OPCODES = { TEXT: 0x01, CLOSE: 0x08, PING: 0x09, PONG: 0x0A };
9
+ const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
10
+
11
+ function computeAcceptKey(clientKey) {
12
+ return crypto.createHash('sha1').update(clientKey + WS_MAGIC).digest('base64');
13
+ }
14
+
15
+ function encodeFrame(opcode, payload) {
16
+ const fin = 0x80;
17
+ const len = payload.length;
18
+ let header;
19
+
20
+ if (len < 126) {
21
+ header = Buffer.alloc(2);
22
+ header[0] = fin | opcode;
23
+ header[1] = len;
24
+ } else if (len < 65536) {
25
+ header = Buffer.alloc(4);
26
+ header[0] = fin | opcode;
27
+ header[1] = 126;
28
+ header.writeUInt16BE(len, 2);
29
+ } else {
30
+ header = Buffer.alloc(10);
31
+ header[0] = fin | opcode;
32
+ header[1] = 127;
33
+ header.writeBigUInt64BE(BigInt(len), 2);
34
+ }
35
+
36
+ return Buffer.concat([header, payload]);
37
+ }
38
+
39
+ function decodeFrame(buffer) {
40
+ if (buffer.length < 2) return null;
41
+
42
+ const secondByte = buffer[1];
43
+ const opcode = buffer[0] & 0x0F;
44
+ const masked = (secondByte & 0x80) !== 0;
45
+ let payloadLen = secondByte & 0x7F;
46
+ let offset = 2;
47
+
48
+ if (!masked) throw new Error('Client frames must be masked');
49
+
50
+ if (payloadLen === 126) {
51
+ if (buffer.length < 4) return null;
52
+ payloadLen = buffer.readUInt16BE(2);
53
+ offset = 4;
54
+ } else if (payloadLen === 127) {
55
+ if (buffer.length < 10) return null;
56
+ payloadLen = Number(buffer.readBigUInt64BE(2));
57
+ offset = 10;
58
+ }
59
+
60
+ const maskOffset = offset;
61
+ const dataOffset = offset + 4;
62
+ const totalLen = dataOffset + payloadLen;
63
+ if (buffer.length < totalLen) return null;
64
+
65
+ const mask = buffer.slice(maskOffset, dataOffset);
66
+ const data = Buffer.alloc(payloadLen);
67
+ for (let i = 0; i < payloadLen; i++) {
68
+ data[i] = buffer[dataOffset + i] ^ mask[i % 4];
69
+ }
70
+
71
+ return { opcode, payload: data, bytesConsumed: totalLen };
72
+ }
73
+
74
+ // ========== Configuration ==========
75
+
76
+ const PORT = process.env.PLAN_VIEWER_PORT || (49152 + Math.floor(Math.random() * 16383));
77
+ const HOST = process.env.PLAN_VIEWER_HOST || '127.0.0.1';
78
+ const URL_HOST = process.env.PLAN_VIEWER_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST);
79
+ const SCREEN_DIR = process.env.PLAN_VIEWER_DIR || '/tmp/plan-viewer';
80
+ const OWNER_PID = process.env.PLAN_VIEWER_OWNER_PID ? Number(process.env.PLAN_VIEWER_OWNER_PID) : null;
81
+
82
+ const MIME_TYPES = {
83
+ '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
84
+ '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
85
+ '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml'
86
+ };
87
+
88
+ // ========== Templates and Constants ==========
89
+
90
+ const WAITING_PAGE = `<!DOCTYPE html>
91
+ <html>
92
+ <head><meta charset="utf-8"><title>Plan Viewer</title>
93
+ <style>body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
94
+ h1 { color: #333; } p { color: #666; }</style>
95
+ </head>
96
+ <body><h1>Plan Viewer</h1>
97
+ <p>Waiting for content...</p></body></html>`;
98
+
99
+ const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
100
+ const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
101
+ const helperInjection = '<script>\n' + helperScript + '\n</script>';
102
+
103
+ // ========== Helper Functions ==========
104
+
105
+ function isFullDocument(html) {
106
+ const trimmed = html.trimStart().toLowerCase();
107
+ return trimmed.startsWith('<!doctype') || trimmed.startsWith('<html');
108
+ }
109
+
110
+ function wrapInFrame(content) {
111
+ return frameTemplate.replace('<!-- CONTENT -->', content);
112
+ }
113
+
114
+ function getNewestScreen() {
115
+ const files = fs.readdirSync(SCREEN_DIR)
116
+ .filter(f => f.endsWith('.html'))
117
+ .map(f => {
118
+ const fp = path.join(SCREEN_DIR, f);
119
+ return { path: fp, mtime: fs.statSync(fp).mtime.getTime() };
120
+ })
121
+ .sort((a, b) => b.mtime - a.mtime);
122
+ return files.length > 0 ? files[0].path : null;
123
+ }
124
+
125
+ // ========== HTTP Request Handler ==========
126
+
127
+ function handleRequest(req, res) {
128
+ touchActivity();
129
+ if (req.method === 'GET' && req.url === '/') {
130
+ const screenFile = getNewestScreen();
131
+ let html = screenFile
132
+ ? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))
133
+ : WAITING_PAGE;
134
+
135
+ if (html.includes('</body>')) {
136
+ html = html.replace('</body>', helperInjection + '\n</body>');
137
+ } else {
138
+ html += helperInjection;
139
+ }
140
+
141
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
142
+ res.end(html);
143
+ } else if (req.method === 'GET' && req.url.startsWith('/files/')) {
144
+ const fileName = req.url.slice(7);
145
+ const filePath = path.join(SCREEN_DIR, path.basename(fileName));
146
+ if (!fs.existsSync(filePath)) {
147
+ res.writeHead(404);
148
+ res.end('Not found');
149
+ return;
150
+ }
151
+ const ext = path.extname(filePath).toLowerCase();
152
+ const contentType = MIME_TYPES[ext] || 'application/octet-stream';
153
+ res.writeHead(200, { 'Content-Type': contentType });
154
+ res.end(fs.readFileSync(filePath));
155
+ } else {
156
+ res.writeHead(404);
157
+ res.end('Not found');
158
+ }
159
+ }
160
+
161
+ // ========== WebSocket Connection Handling ==========
162
+
163
+ const clients = new Set();
164
+
165
+ function handleUpgrade(req, socket) {
166
+ const key = req.headers['sec-websocket-key'];
167
+ if (!key) { socket.destroy(); return; }
168
+
169
+ const accept = computeAcceptKey(key);
170
+ socket.write(
171
+ 'HTTP/1.1 101 Switching Protocols\r\n' +
172
+ 'Upgrade: websocket\r\n' +
173
+ 'Connection: Upgrade\r\n' +
174
+ 'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n'
175
+ );
176
+
177
+ let buffer = Buffer.alloc(0);
178
+ clients.add(socket);
179
+
180
+ socket.on('data', (chunk) => {
181
+ buffer = Buffer.concat([buffer, chunk]);
182
+ while (buffer.length > 0) {
183
+ let result;
184
+ try {
185
+ result = decodeFrame(buffer);
186
+ } catch (e) {
187
+ socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));
188
+ clients.delete(socket);
189
+ return;
190
+ }
191
+ if (!result) break;
192
+ buffer = buffer.slice(result.bytesConsumed);
193
+
194
+ switch (result.opcode) {
195
+ case OPCODES.TEXT:
196
+ handleMessage(result.payload.toString());
197
+ break;
198
+ case OPCODES.CLOSE:
199
+ socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));
200
+ clients.delete(socket);
201
+ return;
202
+ case OPCODES.PING:
203
+ socket.write(encodeFrame(OPCODES.PONG, result.payload));
204
+ break;
205
+ case OPCODES.PONG:
206
+ break;
207
+ default: {
208
+ const closeBuf = Buffer.alloc(2);
209
+ closeBuf.writeUInt16BE(1003);
210
+ socket.end(encodeFrame(OPCODES.CLOSE, closeBuf));
211
+ clients.delete(socket);
212
+ return;
213
+ }
214
+ }
215
+ }
216
+ });
217
+
218
+ socket.on('close', () => clients.delete(socket));
219
+ socket.on('error', () => clients.delete(socket));
220
+ }
221
+
222
+ function handleMessage(text) {
223
+ let event;
224
+ try {
225
+ event = JSON.parse(text);
226
+ } catch (e) {
227
+ console.error('Failed to parse WebSocket message:', e.message);
228
+ return;
229
+ }
230
+ touchActivity();
231
+ console.log(JSON.stringify({ source: 'user-event', ...event }));
232
+ if (event.choice) {
233
+ const eventsFile = path.join(SCREEN_DIR, '.events');
234
+ fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n');
235
+ }
236
+ }
237
+
238
+ function broadcast(msg) {
239
+ const frame = encodeFrame(OPCODES.TEXT, Buffer.from(JSON.stringify(msg)));
240
+ for (const socket of clients) {
241
+ try { socket.write(frame); } catch (e) { clients.delete(socket); }
242
+ }
243
+ }
244
+
245
+ // ========== Activity Tracking ==========
246
+
247
+ const IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
248
+ let lastActivity = Date.now();
249
+
250
+ function touchActivity() {
251
+ lastActivity = Date.now();
252
+ }
253
+
254
+ // ========== File Watching ==========
255
+
256
+ const debounceTimers = new Map();
257
+
258
+ // ========== Server Startup ==========
259
+
260
+ function startServer() {
261
+ if (!fs.existsSync(SCREEN_DIR)) fs.mkdirSync(SCREEN_DIR, { recursive: true });
262
+
263
+ const knownFiles = new Set(
264
+ fs.readdirSync(SCREEN_DIR).filter(f => f.endsWith('.html'))
265
+ );
266
+
267
+ const server = http.createServer(handleRequest);
268
+ server.on('upgrade', handleUpgrade);
269
+
270
+ const watcher = fs.watch(SCREEN_DIR, (eventType, filename) => {
271
+ if (!filename || !filename.endsWith('.html')) return;
272
+
273
+ if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename));
274
+ debounceTimers.set(filename, setTimeout(() => {
275
+ debounceTimers.delete(filename);
276
+ const filePath = path.join(SCREEN_DIR, filename);
277
+
278
+ if (!fs.existsSync(filePath)) return;
279
+ touchActivity();
280
+
281
+ if (!knownFiles.has(filename)) {
282
+ knownFiles.add(filename);
283
+ const eventsFile = path.join(SCREEN_DIR, '.events');
284
+ if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
285
+ console.log(JSON.stringify({ type: 'screen-added', file: filePath }));
286
+ } else {
287
+ console.log(JSON.stringify({ type: 'screen-updated', file: filePath }));
288
+ }
289
+
290
+ broadcast({ type: 'reload' });
291
+ }, 100));
292
+ });
293
+ watcher.on('error', (err) => console.error('fs.watch error:', err.message));
294
+
295
+ function shutdown(reason) {
296
+ console.log(JSON.stringify({ type: 'server-stopped', reason }));
297
+ const infoFile = path.join(SCREEN_DIR, '.server-info');
298
+ if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile);
299
+ fs.writeFileSync(
300
+ path.join(SCREEN_DIR, '.server-stopped'),
301
+ JSON.stringify({ reason, timestamp: Date.now() }) + '\n'
302
+ );
303
+ watcher.close();
304
+ clearInterval(lifecycleCheck);
305
+ server.close(() => process.exit(0));
306
+ }
307
+
308
+ function ownerAlive() {
309
+ if (!OWNER_PID) return true;
310
+ try { process.kill(OWNER_PID, 0); return true; } catch (e) { return false; }
311
+ }
312
+
313
+ const lifecycleCheck = setInterval(() => {
314
+ if (!ownerAlive()) shutdown('owner process exited');
315
+ else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) shutdown('idle timeout');
316
+ }, 60 * 1000);
317
+ lifecycleCheck.unref();
318
+
319
+ server.listen(PORT, HOST, () => {
320
+ const info = JSON.stringify({
321
+ type: 'server-started', port: Number(PORT), host: HOST,
322
+ url_host: URL_HOST, url: 'http://' + URL_HOST + ':' + PORT,
323
+ screen_dir: SCREEN_DIR
324
+ });
325
+ console.log(info);
326
+ fs.writeFileSync(path.join(SCREEN_DIR, '.server-info'), info + '\n');
327
+ });
328
+ }
329
+
330
+ if (require.main === module) {
331
+ startServer();
332
+ }
333
+
334
+ module.exports = { computeAcceptKey, encodeFrame, decodeFrame, OPCODES };
@@ -0,0 +1,122 @@
1
+ #!/bin/bash
2
+ # Start the plan-viewer server and output connection info
3
+ # Usage: start-server.sh [--project-dir <path>] [--host <bind-host>] [--url-host <display-host>] [--foreground]
4
+ #
5
+ # Starts server on a random high port, outputs JSON with URL.
6
+ # Each session gets its own directory under .plan-viewer/sessions/
7
+
8
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
9
+
10
+ # Parse arguments
11
+ PROJECT_DIR=""
12
+ FOREGROUND="false"
13
+ FORCE_BACKGROUND="false"
14
+ BIND_HOST="127.0.0.1"
15
+ URL_HOST=""
16
+ while [[ $# -gt 0 ]]; do
17
+ case "$1" in
18
+ --project-dir)
19
+ PROJECT_DIR="$2"
20
+ shift 2
21
+ ;;
22
+ --host)
23
+ BIND_HOST="$2"
24
+ shift 2
25
+ ;;
26
+ --url-host)
27
+ URL_HOST="$2"
28
+ shift 2
29
+ ;;
30
+ --foreground|--no-daemon)
31
+ FOREGROUND="true"
32
+ shift
33
+ ;;
34
+ --background|--daemon)
35
+ FORCE_BACKGROUND="true"
36
+ shift
37
+ ;;
38
+ *)
39
+ echo "{\"error\": \"Unknown argument: $1\"}"
40
+ exit 1
41
+ ;;
42
+ esac
43
+ done
44
+
45
+ if [[ -z "$URL_HOST" ]]; then
46
+ if [[ "$BIND_HOST" == "127.0.0.1" || "$BIND_HOST" == "localhost" ]]; then
47
+ URL_HOST="localhost"
48
+ else
49
+ URL_HOST="$BIND_HOST"
50
+ fi
51
+ fi
52
+
53
+ if [[ -n "${CODEX_CI:-}" && "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then
54
+ FOREGROUND="true"
55
+ fi
56
+
57
+ # Generate unique session directory
58
+ SESSION_ID="$$-$(date +%s)"
59
+
60
+ if [[ -n "$PROJECT_DIR" ]]; then
61
+ SCREEN_DIR="${PROJECT_DIR}/.plan-viewer/sessions/${SESSION_ID}"
62
+ else
63
+ SCREEN_DIR="/tmp/plan-viewer-${SESSION_ID}"
64
+ fi
65
+
66
+ PID_FILE="${SCREEN_DIR}/.server.pid"
67
+ LOG_FILE="${SCREEN_DIR}/.server.log"
68
+
69
+ # Create fresh session directory
70
+ mkdir -p "$SCREEN_DIR"
71
+
72
+ # Kill any existing server
73
+ if [[ -f "$PID_FILE" ]]; then
74
+ old_pid=$(cat "$PID_FILE")
75
+ kill "$old_pid" 2>/dev/null
76
+ rm -f "$PID_FILE"
77
+ fi
78
+
79
+ cd "$SCRIPT_DIR"
80
+
81
+ # Resolve the harness PID
82
+ OWNER_PID="$(ps -o ppid= -p "$PPID" 2>/dev/null | tr -d ' ')"
83
+ if [[ -z "$OWNER_PID" || "$OWNER_PID" == "1" ]]; then
84
+ OWNER_PID="$PPID"
85
+ fi
86
+
87
+ # Foreground mode
88
+ if [[ "$FOREGROUND" == "true" ]]; then
89
+ echo "$$" > "$PID_FILE"
90
+ env PLAN_VIEWER_DIR="$SCREEN_DIR" PLAN_VIEWER_HOST="$BIND_HOST" PLAN_VIEWER_URL_HOST="$URL_HOST" PLAN_VIEWER_OWNER_PID="$OWNER_PID" node server.js
91
+ exit $?
92
+ fi
93
+
94
+ # Background mode
95
+ nohup env PLAN_VIEWER_DIR="$SCREEN_DIR" PLAN_VIEWER_HOST="$BIND_HOST" PLAN_VIEWER_URL_HOST="$URL_HOST" PLAN_VIEWER_OWNER_PID="$OWNER_PID" node server.js > "$LOG_FILE" 2>&1 &
96
+ SERVER_PID=$!
97
+ disown "$SERVER_PID" 2>/dev/null
98
+ echo "$SERVER_PID" > "$PID_FILE"
99
+
100
+ # Wait for server-started message
101
+ for i in {1..50}; do
102
+ if grep -q "server-started" "$LOG_FILE" 2>/dev/null; then
103
+ alive="true"
104
+ for _ in {1..20}; do
105
+ if ! kill -0 "$SERVER_PID" 2>/dev/null; then
106
+ alive="false"
107
+ break
108
+ fi
109
+ sleep 0.1
110
+ done
111
+ if [[ "$alive" != "true" ]]; then
112
+ echo "{\"error\": \"Server started but was killed. Retry with: $SCRIPT_DIR/start-server.sh${PROJECT_DIR:+ --project-dir $PROJECT_DIR} --host $BIND_HOST --url-host $URL_HOST --foreground\"}"
113
+ exit 1
114
+ fi
115
+ grep "server-started" "$LOG_FILE" | head -1
116
+ exit 0
117
+ fi
118
+ sleep 0.1
119
+ done
120
+
121
+ echo '{"error": "Server failed to start within 5 seconds"}'
122
+ exit 1
@@ -0,0 +1,27 @@
1
+ #!/bin/bash
2
+ # Stop the plan-viewer server and clean up
3
+ # Usage: stop-server.sh <screen_dir>
4
+
5
+ SCREEN_DIR="$1"
6
+
7
+ if [[ -z "$SCREEN_DIR" ]]; then
8
+ echo '{"error": "Usage: stop-server.sh <screen_dir>"}'
9
+ exit 1
10
+ fi
11
+
12
+ PID_FILE="${SCREEN_DIR}/.server.pid"
13
+
14
+ if [[ -f "$PID_FILE" ]]; then
15
+ pid=$(cat "$PID_FILE")
16
+ kill "$pid" 2>/dev/null
17
+ rm -f "$PID_FILE" "${SCREEN_DIR}/.server.log"
18
+
19
+ # Only delete ephemeral /tmp directories
20
+ if [[ "$SCREEN_DIR" == /tmp/* ]]; then
21
+ rm -rf "$SCREEN_DIR"
22
+ fi
23
+
24
+ echo '{"status": "stopped"}'
25
+ else
26
+ echo '{"status": "not_running"}'
27
+ fi
@@ -0,0 +1,47 @@
1
+ # Spec Document Reviewer Prompt Template
2
+
3
+ 스펙 문서 작성 후 검증을 위한 서브에이전트 프롬프트 템플릿.
4
+
5
+ **용도:** 스펙이 완전하고 일관적이며 구현 계획 작성 준비가 되었는지 검증.
6
+
7
+ **실행 시점:** 스펙 문서가 docs/specs/에 작성된 후.
8
+
9
+ ```
10
+ Agent tool (general-purpose):
11
+ description: "Review spec document"
12
+ prompt: |
13
+ 스펙 문서 리뷰어. 이 스펙이 완전하고 구현 계획 작성 준비가 되었는지 검증하라.
14
+
15
+ **검토할 스펙:** [SPEC_FILE_PATH]
16
+
17
+ ## 검토 항목
18
+
19
+ | 카테고리 | 확인 사항 |
20
+ |----------|-----------|
21
+ | 완전성 | TODO, 플레이스홀더, "TBD", 미완성 섹션 |
22
+ | 커버리지 | 에러 처리, 엣지 케이스, 통합 포인트 누락 |
23
+ | 일관성 | 내부 모순, 충돌하는 요구사항 |
24
+ | 명확성 | 모호한 요구사항 |
25
+ | YAGNI | 요청하지 않은 기능, 과도한 설계 |
26
+ | 범위 | 단일 계획으로 충분한 범위인지 |
27
+ | 아키텍처 | 명확한 경계, 잘 정의된 인터페이스, 독립적으로 이해/테스트 가능 |
28
+
29
+ ## 특히 주의
30
+
31
+ - TODO 마커 또는 플레이스홀더 텍스트
32
+ - "추후 정의" 또는 "X 완료 후 스펙 작성" 등의 문구
33
+ - 다른 섹션에 비해 눈에 띄게 상세도가 낮은 섹션
34
+ - 명확한 경계나 인터페이스가 없는 유닛
35
+
36
+ ## 출력 형식
37
+
38
+ ## Spec Review
39
+
40
+ **Status:** ✅ Approved | ❌ Issues Found
41
+
42
+ **Issues (있는 경우):**
43
+ - [섹션 X]: [구체적 문제] - [중요한 이유]
44
+
45
+ **Recommendations (권고):**
46
+ - [승인을 차단하지 않는 제안사항]
47
+ ```