@16pxh/cli-bridge 1.0.4 → 1.0.7
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/lib/claude.mjs +85 -8
- package/lib/crypto.mjs +44 -0
- package/lib/server.mjs +113 -1
- package/package.json +1 -1
package/lib/claude.mjs
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { spawn, execFile } from 'node:child_process';
|
|
2
2
|
import { promisify } from 'node:util';
|
|
3
|
+
import { mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
3
6
|
|
|
4
7
|
const execFileAsync = promisify(execFile);
|
|
5
8
|
|
|
@@ -22,10 +25,10 @@ export async function checkClaudeInstalled() {
|
|
|
22
25
|
/**
|
|
23
26
|
* Spawn `claude -p <prompt> --output-format json`, resolve with parsed result.
|
|
24
27
|
* @param {string} prompt
|
|
25
|
-
* @param {{
|
|
28
|
+
* @param {{ sessionId?: string }} [options]
|
|
26
29
|
* @returns {Promise<{ result: string, session_id: string, duration_ms: number }>}
|
|
27
30
|
*/
|
|
28
|
-
export function runPrompt(prompt, {
|
|
31
|
+
export function runPrompt(prompt, { sessionId } = {}) {
|
|
29
32
|
return new Promise((resolve, reject) => {
|
|
30
33
|
const args = ['-p', prompt, '--output-format', 'json'];
|
|
31
34
|
if (sessionId) args.push('--resume', sessionId);
|
|
@@ -39,13 +42,7 @@ export function runPrompt(prompt, { timeoutMs = 60000, sessionId } = {}) {
|
|
|
39
42
|
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
40
43
|
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
41
44
|
|
|
42
|
-
const timer = setTimeout(() => {
|
|
43
|
-
child.kill('SIGKILL');
|
|
44
|
-
reject(new Error(`Claude CLI timed out after ${timeoutMs}ms`));
|
|
45
|
-
}, timeoutMs);
|
|
46
|
-
|
|
47
45
|
child.on('close', (code) => {
|
|
48
|
-
clearTimeout(timer);
|
|
49
46
|
if (activeProcess === child) activeProcess = null;
|
|
50
47
|
|
|
51
48
|
if (code !== 0) {
|
|
@@ -74,6 +71,86 @@ export function runPrompt(prompt, { timeoutMs = 60000, sessionId } = {}) {
|
|
|
74
71
|
});
|
|
75
72
|
}
|
|
76
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Write files to temp dir, run Claude CLI with edit permission, read back modified files.
|
|
76
|
+
* @param {string} prompt
|
|
77
|
+
* @param {{ html: string, css: string, js: string }} files
|
|
78
|
+
* @param {{ sessionId?: string }} [options]
|
|
79
|
+
* @returns {Promise<{ html: string, css: string, js: string, result: string, session_id: string, duration_ms: number }>}
|
|
80
|
+
*/
|
|
81
|
+
export function runEditFiles(prompt, files, { sessionId } = {}) {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
// Create temp dir with files
|
|
84
|
+
const tempDir = join(tmpdir(), `16pxh-edit-${Date.now()}`);
|
|
85
|
+
mkdirSync(tempDir, { recursive: true });
|
|
86
|
+
writeFileSync(join(tempDir, 'index.html'), files.html, 'utf-8');
|
|
87
|
+
writeFileSync(join(tempDir, 'style.css'), files.css, 'utf-8');
|
|
88
|
+
writeFileSync(join(tempDir, 'script.js'), files.js, 'utf-8');
|
|
89
|
+
|
|
90
|
+
const args = ['-p', prompt, '--output-format', 'json', '--allowedTools', 'Edit'];
|
|
91
|
+
if (sessionId) args.push('--resume', sessionId);
|
|
92
|
+
|
|
93
|
+
const child = spawn('claude', args, {
|
|
94
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
95
|
+
cwd: tempDir,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
activeProcess = child;
|
|
99
|
+
|
|
100
|
+
let stdout = '';
|
|
101
|
+
let stderr = '';
|
|
102
|
+
|
|
103
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
104
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
105
|
+
|
|
106
|
+
child.on('close', (code) => {
|
|
107
|
+
if (activeProcess === child) activeProcess = null;
|
|
108
|
+
|
|
109
|
+
if (code !== 0) {
|
|
110
|
+
cleanup();
|
|
111
|
+
return reject(new Error(`Claude CLI exited with code ${code}: ${stderr.trim()}`));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let parsed;
|
|
115
|
+
try {
|
|
116
|
+
parsed = JSON.parse(stdout.trim());
|
|
117
|
+
} catch (err) {
|
|
118
|
+
cleanup();
|
|
119
|
+
return reject(new Error(`Failed to parse output: ${err.message}`));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Read back modified files
|
|
123
|
+
try {
|
|
124
|
+
const html = readFileSync(join(tempDir, 'index.html'), 'utf-8');
|
|
125
|
+
const css = readFileSync(join(tempDir, 'style.css'), 'utf-8');
|
|
126
|
+
const js = readFileSync(join(tempDir, 'script.js'), 'utf-8');
|
|
127
|
+
cleanup();
|
|
128
|
+
resolve({
|
|
129
|
+
html,
|
|
130
|
+
css,
|
|
131
|
+
js,
|
|
132
|
+
result: parsed.result ?? '',
|
|
133
|
+
session_id: parsed.session_id ?? '',
|
|
134
|
+
duration_ms: parsed.duration_ms ?? 0,
|
|
135
|
+
});
|
|
136
|
+
} catch (err) {
|
|
137
|
+
cleanup();
|
|
138
|
+
reject(new Error(`Failed to read edited files: ${err.message}`));
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
child.on('error', (err) => {
|
|
143
|
+
if (activeProcess === child) activeProcess = null;
|
|
144
|
+
cleanup();
|
|
145
|
+
reject(err);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
function cleanup() {
|
|
149
|
+
try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
77
154
|
/**
|
|
78
155
|
* Kill the currently running claude child process, if any.
|
|
79
156
|
*/
|
package/lib/crypto.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createHash, createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const ALGORITHM = 'aes-256-cbc';
|
|
4
|
+
const CACHE_TTL = 5 * 60 * 1000;
|
|
5
|
+
|
|
6
|
+
let cachedSecret = null;
|
|
7
|
+
let cachedAt = 0;
|
|
8
|
+
|
|
9
|
+
export async function getBridgeSecret() {
|
|
10
|
+
if (cachedSecret && Date.now() - cachedAt < CACHE_TTL) return cachedSecret;
|
|
11
|
+
const url = process.env.BRIDGE_SECRET_URL;
|
|
12
|
+
if (!url) return null;
|
|
13
|
+
try {
|
|
14
|
+
const res = await fetch(url);
|
|
15
|
+
if (!res.ok) return null;
|
|
16
|
+
cachedSecret = (await res.text()).trim();
|
|
17
|
+
cachedAt = Date.now();
|
|
18
|
+
return cachedSecret;
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function deriveKey(secret) {
|
|
25
|
+
return createHash('sha256').update(secret).digest();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function encryptSummary(text) {
|
|
29
|
+
const secret = await getBridgeSecret();
|
|
30
|
+
if (!secret) throw new Error('Bridge secret not available');
|
|
31
|
+
const key = deriveKey(secret);
|
|
32
|
+
const iv = randomBytes(16);
|
|
33
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
34
|
+
return iv.toString('hex') + ':' + cipher.update(text, 'utf8', 'hex') + cipher.final('hex');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function decryptSummary(encrypted) {
|
|
38
|
+
const secret = await getBridgeSecret();
|
|
39
|
+
if (!secret) throw new Error('Bridge secret not available');
|
|
40
|
+
const key = deriveKey(secret);
|
|
41
|
+
const [ivHex, data] = encrypted.split(':');
|
|
42
|
+
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, 'hex'));
|
|
43
|
+
return decipher.update(data, 'hex', 'utf8') + decipher.final('utf8');
|
|
44
|
+
}
|
package/lib/server.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
2
|
import { verifyToken, hasToken } from './auth.mjs';
|
|
3
|
-
import { checkClaudeInstalled, runPrompt } from './claude.mjs';
|
|
3
|
+
import { checkClaudeInstalled, runPrompt, runEditFiles } from './claude.mjs';
|
|
4
|
+
import { decryptSummary, encryptSummary } from './crypto.mjs';
|
|
4
5
|
|
|
5
6
|
const PORT = 1676;
|
|
6
7
|
const HOST = '127.0.0.1';
|
|
@@ -173,6 +174,117 @@ export function startServer() {
|
|
|
173
174
|
return;
|
|
174
175
|
}
|
|
175
176
|
|
|
177
|
+
// POST /edit-files — write html/css/js to temp files, let Claude edit, read back
|
|
178
|
+
if (req.method === 'POST' && url.pathname === '/edit-files') {
|
|
179
|
+
if (!authenticate(req, res)) {
|
|
180
|
+
logRequest('POST', '/edit-files', 403);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let body;
|
|
185
|
+
try {
|
|
186
|
+
const raw = await readBody(req);
|
|
187
|
+
body = JSON.parse(raw);
|
|
188
|
+
} catch {
|
|
189
|
+
logRequest('POST', '/edit-files', 400, `${c.red}invalid JSON${c.reset}`);
|
|
190
|
+
json(res, 400, { error: 'Invalid JSON body' });
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const { prompt, html, css, js, session_id } = body;
|
|
195
|
+
if (!prompt) {
|
|
196
|
+
json(res, 400, { error: 'Missing prompt' });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const promptPreview = truncateWords(prompt, 12);
|
|
201
|
+
console.log(`${c.dim}${timestamp()}${c.reset} ${c.magenta}→ edit${c.reset} ${c.dim}${promptPreview}${c.reset}`);
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const result = await runEditFiles(
|
|
205
|
+
prompt,
|
|
206
|
+
{ html: html ?? '', css: css ?? '', js: js ?? '' },
|
|
207
|
+
{ sessionId: session_id }
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
logRequest('POST', '/edit-files', 200, `${c.dim}${result.duration_ms}ms${c.reset}`);
|
|
211
|
+
console.log(`${c.dim}${timestamp()}${c.reset} ${c.green}← edit done${c.reset}`);
|
|
212
|
+
|
|
213
|
+
json(res, 200, {
|
|
214
|
+
success: true,
|
|
215
|
+
html: result.html,
|
|
216
|
+
css: result.css,
|
|
217
|
+
js: result.js,
|
|
218
|
+
result: result.result,
|
|
219
|
+
session_id: result.session_id,
|
|
220
|
+
duration_ms: result.duration_ms,
|
|
221
|
+
});
|
|
222
|
+
} catch (err) {
|
|
223
|
+
logRequest('POST', '/edit-files', 500, `${c.red}${err.message}${c.reset}`);
|
|
224
|
+
json(res, 500, { error: err.message });
|
|
225
|
+
}
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// POST /summary — decrypt summary, run AI prompt, encrypt new summary
|
|
230
|
+
if (req.method === 'POST' && url.pathname === '/summary') {
|
|
231
|
+
if (!authenticate(req, res)) {
|
|
232
|
+
logRequest('POST', '/summary', 403);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
let body;
|
|
237
|
+
try {
|
|
238
|
+
const raw = await readBody(req);
|
|
239
|
+
body = JSON.parse(raw);
|
|
240
|
+
} catch {
|
|
241
|
+
logRequest('POST', '/summary', 400, `${c.red}invalid JSON${c.reset}`);
|
|
242
|
+
json(res, 400, { error: 'Invalid JSON body' });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const { prompt, encrypted_summary } = body;
|
|
247
|
+
if (!prompt) {
|
|
248
|
+
json(res, 400, { error: 'Missing prompt' });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
try {
|
|
253
|
+
let currentSummary = '';
|
|
254
|
+
if (encrypted_summary) {
|
|
255
|
+
currentSummary = await decryptSummary(encrypted_summary);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const fullPrompt = prompt.replace('{current_summary}', currentSummary || '(empty — this is the first activity)');
|
|
259
|
+
|
|
260
|
+
const promptPreview = truncateWords(fullPrompt, 12);
|
|
261
|
+
console.log(`${c.dim}${timestamp()}${c.reset} ${c.magenta}→ summary${c.reset} ${c.dim}${promptPreview}${c.reset}`);
|
|
262
|
+
|
|
263
|
+
const { result, duration_ms } = await runPrompt(fullPrompt);
|
|
264
|
+
|
|
265
|
+
const jsonMatch = result.match(/\{[\s\S]*\}/);
|
|
266
|
+
if (!jsonMatch) throw new Error('No JSON in response');
|
|
267
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
268
|
+
|
|
269
|
+
const encryptedNewSummary = await encryptSummary(parsed.summary || '');
|
|
270
|
+
|
|
271
|
+
const resultPreview = truncateChars(parsed.writing_style || '', 64);
|
|
272
|
+
logRequest('POST', '/summary', 200, `${c.dim}${duration_ms}ms${c.reset}`);
|
|
273
|
+
console.log(`${c.dim}${timestamp()}${c.reset} ${c.green}← style:${c.reset} ${c.dim}${resultPreview}${c.reset}`);
|
|
274
|
+
|
|
275
|
+
json(res, 200, {
|
|
276
|
+
success: true,
|
|
277
|
+
encrypted_summary: encryptedNewSummary,
|
|
278
|
+
writing_style: parsed.writing_style || '',
|
|
279
|
+
duration_ms,
|
|
280
|
+
});
|
|
281
|
+
} catch (err) {
|
|
282
|
+
logRequest('POST', '/summary', 500, `${c.red}${err.message}${c.reset}`);
|
|
283
|
+
json(res, 500, { error: err.message });
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
176
288
|
// 404 fallback
|
|
177
289
|
json(res, 404, { error: 'Not found' });
|
|
178
290
|
});
|