@16pxh/cli-bridge 1.0.6 → 1.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.
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
 
@@ -42,6 +45,12 @@ export function runPrompt(prompt, { sessionId } = {}) {
42
45
  child.on('close', (code) => {
43
46
  if (activeProcess === child) activeProcess = null;
44
47
 
48
+ // Retry without session if session expired
49
+ if (code !== 0 && sessionId && stderr.includes('No conversation found')) {
50
+ resolve(runPrompt(prompt, {}));
51
+ return;
52
+ }
53
+
45
54
  if (code !== 0) {
46
55
  return reject(new Error(`Claude CLI exited with code ${code}: ${stderr.trim()}`));
47
56
  }
@@ -57,6 +66,8 @@ export function runPrompt(prompt, { sessionId } = {}) {
57
66
  result: parsed.result ?? parsed.content ?? '',
58
67
  session_id: parsed.session_id ?? '',
59
68
  duration_ms: parsed.duration_ms ?? 0,
69
+ usage: parsed.usage ?? null,
70
+ cost_usd: parsed.total_cost_usd ?? 0,
60
71
  });
61
72
  });
62
73
 
@@ -68,6 +79,95 @@ export function runPrompt(prompt, { sessionId } = {}) {
68
79
  });
69
80
  }
70
81
 
82
+ /**
83
+ * Write files to temp dir, run Claude CLI with edit permission, read back modified files.
84
+ * @param {string} prompt
85
+ * @param {{ html: string, css: string, js: string }} files
86
+ * @param {{ sessionId?: string }} [options]
87
+ * @returns {Promise<{ html: string, css: string, js: string, result: string, session_id: string, duration_ms: number }>}
88
+ */
89
+ export function runEditFiles(prompt, files, { sessionId } = {}) {
90
+ return new Promise((resolve, reject) => {
91
+ // Create temp dir with files
92
+ const tempDir = join(tmpdir(), `16pxh-edit-${Date.now()}`);
93
+ mkdirSync(tempDir, { recursive: true });
94
+ writeFileSync(join(tempDir, 'index.html'), files.html, 'utf-8');
95
+ writeFileSync(join(tempDir, 'style.css'), files.css, 'utf-8');
96
+ writeFileSync(join(tempDir, 'script.js'), files.js, 'utf-8');
97
+
98
+ const args = ['-p', prompt, '--output-format', 'json', '--allowedTools', 'Edit'];
99
+ if (sessionId) args.push('--resume', sessionId);
100
+
101
+ const child = spawn('claude', args, {
102
+ stdio: ['ignore', 'pipe', 'pipe'],
103
+ cwd: tempDir,
104
+ });
105
+
106
+ activeProcess = child;
107
+
108
+ let stdout = '';
109
+ let stderr = '';
110
+
111
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
112
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
113
+
114
+ child.on('close', (code) => {
115
+ if (activeProcess === child) activeProcess = null;
116
+
117
+ // Retry without session if session expired
118
+ if (code !== 0 && sessionId && stderr.includes('No conversation found')) {
119
+ cleanup();
120
+ resolve(runEditFiles(prompt, files, {}));
121
+ return;
122
+ }
123
+
124
+ if (code !== 0) {
125
+ cleanup();
126
+ return reject(new Error(`Claude CLI exited with code ${code}: ${stderr.trim()}`));
127
+ }
128
+
129
+ let parsed;
130
+ try {
131
+ parsed = JSON.parse(stdout.trim());
132
+ } catch (err) {
133
+ cleanup();
134
+ return reject(new Error(`Failed to parse output: ${err.message}`));
135
+ }
136
+
137
+ // Read back modified files
138
+ try {
139
+ const html = readFileSync(join(tempDir, 'index.html'), 'utf-8');
140
+ const css = readFileSync(join(tempDir, 'style.css'), 'utf-8');
141
+ const js = readFileSync(join(tempDir, 'script.js'), 'utf-8');
142
+ cleanup();
143
+ resolve({
144
+ html,
145
+ css,
146
+ js,
147
+ result: parsed.result ?? '',
148
+ session_id: parsed.session_id ?? '',
149
+ duration_ms: parsed.duration_ms ?? 0,
150
+ usage: parsed.usage ?? null,
151
+ cost_usd: parsed.total_cost_usd ?? 0,
152
+ });
153
+ } catch (err) {
154
+ cleanup();
155
+ reject(new Error(`Failed to read edited files: ${err.message}`));
156
+ }
157
+ });
158
+
159
+ child.on('error', (err) => {
160
+ if (activeProcess === child) activeProcess = null;
161
+ cleanup();
162
+ reject(err);
163
+ });
164
+
165
+ function cleanup() {
166
+ try { rmSync(tempDir, { recursive: true, force: true }); } catch {}
167
+ }
168
+ });
169
+ }
170
+
71
171
  /**
72
172
  * Kill the currently running claude child process, if any.
73
173
  */
package/lib/server.mjs CHANGED
@@ -1,6 +1,6 @@
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
4
  import { decryptSummary, encryptSummary } from './crypto.mjs';
5
5
 
6
6
  const PORT = 1676;
@@ -40,6 +40,18 @@ function logRequest(method, path, status, extra = '') {
40
40
  );
41
41
  }
42
42
 
43
+ function logTokens(usage, costUsd) {
44
+ if (!usage) return;
45
+ const input = usage.input_tokens ?? 0;
46
+ const output = usage.output_tokens ?? 0;
47
+ const cacheRead = usage.cache_read_input_tokens ?? 0;
48
+ const cacheCreate = usage.cache_creation_input_tokens ?? 0;
49
+ const cost = typeof costUsd === 'number' ? `$${costUsd.toFixed(4)}` : '';
50
+ console.log(
51
+ `${c.dim}${timestamp()}${c.reset} ${c.dim}tokens: in=${input} out=${output} cache_read=${cacheRead} cache_create=${cacheCreate} ${cost}${c.reset}`
52
+ );
53
+ }
54
+
43
55
  const CORS_HEADERS = {
44
56
  'Access-Control-Allow-Origin': '*',
45
57
  'Access-Control-Allow-Headers': 'x-bridge-token, content-type',
@@ -162,9 +174,10 @@ export function startServer() {
162
174
  }
163
175
 
164
176
  try {
165
- const { result, session_id: responseSessionId, duration_ms } = await runPrompt(fullPrompt, { sessionId: session_id });
177
+ const { result, session_id: responseSessionId, duration_ms, usage, cost_usd } = await runPrompt(fullPrompt, { sessionId: session_id });
166
178
  const resultPreview = truncateChars(result, 64);
167
179
  logRequest('POST', '/prompt', 200, `${c.dim}${duration_ms}ms${c.reset}`);
180
+ logTokens(usage, cost_usd);
168
181
  console.log(`${c.dim}${timestamp()}${c.reset} ${c.green}←${c.reset} ${c.dim}${resultPreview}${c.reset}`);
169
182
  json(res, 200, { success: true, result, session_id: responseSessionId, duration_ms });
170
183
  } catch (err) {
@@ -174,6 +187,59 @@ export function startServer() {
174
187
  return;
175
188
  }
176
189
 
190
+ // POST /edit-files — write html/css/js to temp files, let Claude edit, read back
191
+ if (req.method === 'POST' && url.pathname === '/edit-files') {
192
+ if (!authenticate(req, res)) {
193
+ logRequest('POST', '/edit-files', 403);
194
+ return;
195
+ }
196
+
197
+ let body;
198
+ try {
199
+ const raw = await readBody(req);
200
+ body = JSON.parse(raw);
201
+ } catch {
202
+ logRequest('POST', '/edit-files', 400, `${c.red}invalid JSON${c.reset}`);
203
+ json(res, 400, { error: 'Invalid JSON body' });
204
+ return;
205
+ }
206
+
207
+ const { prompt, html, css, js, session_id } = body;
208
+ if (!prompt) {
209
+ json(res, 400, { error: 'Missing prompt' });
210
+ return;
211
+ }
212
+
213
+ const promptPreview = truncateWords(prompt, 12);
214
+ console.log(`${c.dim}${timestamp()}${c.reset} ${c.magenta}→ edit${c.reset} ${c.dim}${promptPreview}${c.reset}`);
215
+
216
+ try {
217
+ const result = await runEditFiles(
218
+ prompt,
219
+ { html: html ?? '', css: css ?? '', js: js ?? '' },
220
+ { sessionId: session_id }
221
+ );
222
+
223
+ logRequest('POST', '/edit-files', 200, `${c.dim}${result.duration_ms}ms${c.reset}`);
224
+ logTokens(result.usage, result.cost_usd);
225
+ console.log(`${c.dim}${timestamp()}${c.reset} ${c.green}← edit done${c.reset}`);
226
+
227
+ json(res, 200, {
228
+ success: true,
229
+ html: result.html,
230
+ css: result.css,
231
+ js: result.js,
232
+ result: result.result,
233
+ session_id: result.session_id,
234
+ duration_ms: result.duration_ms,
235
+ });
236
+ } catch (err) {
237
+ logRequest('POST', '/edit-files', 500, `${c.red}${err.message}${c.reset}`);
238
+ json(res, 500, { error: err.message });
239
+ }
240
+ return;
241
+ }
242
+
177
243
  // POST /summary — decrypt summary, run AI prompt, encrypt new summary
178
244
  if (req.method === 'POST' && url.pathname === '/summary') {
179
245
  if (!authenticate(req, res)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@16pxh/cli-bridge",
3
- "version": "1.0.6",
3
+ "version": "1.1.0",
4
4
  "description": "Bridge local Claude CLI to 16pxh AI features",
5
5
  "type": "module",
6
6
  "bin": {