@idl3/claude-control 0.1.20 → 0.1.22

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 CHANGED
@@ -20,6 +20,16 @@ npm install -g @idl3/claude-control # or run once: npx @idl3/claude-control
20
20
 
21
21
  **Prerequisites:** Node ≥20 and **tmux** on your `PATH` (`brew install tmux` · `sudo apt install tmux`). Optional: **ttyd** for the in-browser raw terminal (`brew install ttyd` · `sudo apt install ttyd`) — set `CLAUDE_CONTROL_TTYD` to override its path. The web UI ships prebuilt — no build step on install.
22
22
 
23
+ **Optional local AI (no API key):**
24
+
25
+ - **Voice → text** — `brew install ffmpeg whisper-cpp` and drop a model at `~/.claude-control/models/ggml-base.en.bin`. The mic in the composer records audio and transcribes it locally.
26
+ - **Prompt enhancer (✨)** — defaults to a **local MLX model** on Apple Silicon. One-time setup:
27
+ ```bash
28
+ python3 -m venv ~/.claude-control/mlx-venv
29
+ ~/.claude-control/mlx-venv/bin/pip install mlx-lm
30
+ ```
31
+ claude-control lazily starts `mlx_lm.server` on first use, keeps it warm, and shuts it down when idle. The model (default `mlx-community/Llama-3.2-3B-Instruct-4bit`, ~1.8 GB) auto-downloads on first run. Pick the backend + model in **Settings** (`mlx` → `claude -p` → rules fallback). Without the venv (or on non-Apple hardware) the enhancer falls back to `claude -p`, then a deterministic rules optimiser. Env overrides: `CLAUDE_CONTROL_MLX_PYTHON`, `CLAUDE_CONTROL_MLX_PORT`.
32
+
23
33
  ```bash
24
34
  claude-control # start the server (prints the URL)
25
35
  claude-control --help # config + subcommands
package/lib/answer.js CHANGED
@@ -4,7 +4,8 @@
4
4
  // footer: "Enter to select · ↑/↓ to navigate · n to add notes · Tab to switch
5
5
  // questions · Esc to cancel"
6
6
  // spec: single-select = ['Down'*index, 'Enter'];
7
- // multi-select = navigate Down to each chosen index, press Space, then Enter.
7
+ // multi-select = Space-toggle each chosen index, then Down to the
8
+ // per-question action row ("Next"/"Submit") + Enter.
8
9
  //
9
10
  // - Each question lists its options vertically; a cursor starts on the FIRST
10
11
  // option (index 0) and moves with Up/Down. There are NO number shortcuts —
@@ -12,11 +13,15 @@
12
13
  // this UI (the cause of "answer sent but nothing happened").
13
14
  // - SINGLE-select: navigate Down to the chosen option, then press Enter. Enter
14
15
  // commits the answer and advances to the next question (or submits on the last).
15
- // - MULTI-select: navigate Down to each chosen option (top-to-bottom, so a
16
- // monotonic run of Downs) pressing Space to toggle it, then press Enter to
17
- // confirm the question and advance/submit.
18
- // - There is no separate "Submit" step: the final question's Enter submits the
19
- // whole picker. (The old `Right`-to-Submit-tab + `'1'` model was stale.)
16
+ // - MULTI-select: Space toggles a checkbox; Enter on a checkbox ONLY toggles it
17
+ // (footer reads "Enter to select") and does NOT advance. So: Space-toggle each
18
+ // chosen option, then navigate Down to the action row — "Next" (non-final) or
19
+ // "Submit" (final) at navigable index options.length + 1 (after the real
20
+ // options and the always-present "Type something" free-text row), then Enter.
21
+ // Enter on "Next" advances to the next question (cursor resets to 0); Enter on
22
+ // "Submit" submits the whole picker. (Pressing Enter on the last toggled
23
+ // option — the old model — left the second question unanswered + never
24
+ // submitted: the exact reported bug.)
20
25
  //
21
26
  // We deliberately avoid the `n` (add notes) key: it opens a free-text input that
22
27
  // would swallow every subsequent keystroke. Navigation is arrows + Space/Enter only.
@@ -24,6 +29,309 @@
24
29
  // Keys are sent one at a time with a delay (see tmux.sendRawKeysSequenced) so the
25
30
  // picker's re-render settles between keys and none are dropped.
26
31
 
32
+ // ---------------------------------------------------------------------------
33
+ // Picker capture parser — capture-driven answerer
34
+ // ---------------------------------------------------------------------------
35
+ //
36
+ // Empirical picker model (reverse-engineered from live renders):
37
+ //
38
+ // Navigable rows in order:
39
+ // 1. Each real option: "N. [ ]Label" or "N. [x]Label" or "N. [✓]Label"
40
+ // Below each option there may be DIMMED DESCRIPTION lines — these are
41
+ // NOT navigable and Up/Down skip them.
42
+ // 2. "Type something" — always present free-text row.
43
+ // 3. Action row — literal "Next" (non-final) or "Submit" (final).
44
+ // 4. "Chat about this" — always present last row.
45
+ //
46
+ // Cursor: row is marked at line start with "›" or "❯" (possibly with
47
+ // leading whitespace / ANSI stripped text before it).
48
+ //
49
+ // Review screen (multi-question only, appears after final Submit):
50
+ // "Review your answers … Ready to submit your answers?"
51
+ // "› 1. Submit answers"
52
+ // "2. Cancel"
53
+ //
54
+ // The parser strips ANSI escape sequences before analysis so it works on both
55
+ // plain and escape-laden captures.
56
+
57
+ /**
58
+ * @typedef {{
59
+ * kind: 'option'|'type-something'|'action'|'chat'|'review-submit'|'review-cancel',
60
+ * label: string,
61
+ * checked?: boolean,
62
+ * cursor: boolean
63
+ * }} PickerRow
64
+ *
65
+ * @typedef {{
66
+ * rows: PickerRow[],
67
+ * actionLabel: 'Next'|'Submit'|null,
68
+ * isReview: boolean,
69
+ * confidence: 'ok'|'low'
70
+ * }} ParsedPicker
71
+ */
72
+
73
+ // Strip ANSI escape sequences from a string.
74
+ function stripAnsi(str) {
75
+ // eslint-disable-next-line no-control-regex
76
+ return str.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').replace(/\x1b\][^\x07]*\x07/g, '');
77
+ }
78
+
79
+ // Detect cursor marker at the start of a (stripped, trimmed) line.
80
+ function hasCursor(line) {
81
+ return /^[›❯]/.test(line.trim());
82
+ }
83
+
84
+ // Remove the cursor marker from a line and trim.
85
+ function removeCursor(line) {
86
+ return line.trim().replace(/^[›❯]\s*/, '');
87
+ }
88
+
89
+ // Detect an option line: "N. [ ] Label" / "N. [x] Label" / "N. [✓] Label"
90
+ // Also handles "N. [✓]Label" without space after bracket.
91
+ const OPTION_RE = /^\d+\.\s+\[([✓x✗ ])\]\s*(.*)/;
92
+
93
+ /**
94
+ * Parse the visible content of a tmux pane into a structured picker model.
95
+ *
96
+ * Returns a low-confidence result (confidence:'low', rows:[]) rather than
97
+ * throwing when the capture doesn't look like a picker at all.
98
+ *
99
+ * @param {string} capture Raw text from tmux capture-pane.
100
+ * @returns {ParsedPicker}
101
+ */
102
+ export function parsePicker(capture) {
103
+ const EMPTY = { rows: [], actionLabel: null, isReview: false, confidence: 'low' };
104
+
105
+ if (!capture || typeof capture !== 'string') return EMPTY;
106
+
107
+ const raw = stripAnsi(capture);
108
+ const lines = raw.split('\n');
109
+
110
+ // Detect review screen first — it's structurally different.
111
+ const hasReviewHeader = lines.some((l) => /Review your answers/i.test(l));
112
+ const hasReadyLine = lines.some((l) => /Ready to submit your answers/i.test(l));
113
+
114
+ if (hasReviewHeader && hasReadyLine) {
115
+ // Parse the two review options.
116
+ const rows = [];
117
+ for (const rawLine of lines) {
118
+ const stripped = stripAnsi(rawLine);
119
+ const cursor = hasCursor(stripped);
120
+ const line = removeCursor(stripped);
121
+ if (/1\.\s+Submit answers/i.test(line)) {
122
+ rows.push({ kind: 'review-submit', label: 'Submit answers', cursor });
123
+ } else if (/2\.\s+Cancel/i.test(line)) {
124
+ rows.push({ kind: 'review-cancel', label: 'Cancel', cursor });
125
+ }
126
+ }
127
+ if (rows.length === 0) return EMPTY;
128
+ return { rows, actionLabel: null, isReview: true, confidence: 'ok' };
129
+ }
130
+
131
+ // Normal question screen.
132
+ // Strategy: scan lines, classify each as option / description / special.
133
+ // Description lines follow an option and do NOT match the option pattern,
134
+ // are not cursor-marked, and don't match any other special marker.
135
+
136
+ /** @type {PickerRow[]} */
137
+ const rows = [];
138
+ /** @type {'Next'|'Submit'|null} */
139
+ let actionLabel = null;
140
+
141
+ // Track whether we've seen at least one option (to know if we're past options).
142
+ let seenOption = false;
143
+ // Track whether the most-recently-seen navigable row was an option, so the
144
+ // next plain text line can be classified as a description.
145
+ let lastNavWasOption = false;
146
+
147
+ for (const rawLine of lines) {
148
+ const stripped = stripAnsi(rawLine);
149
+ const cursor = hasCursor(stripped);
150
+ const line = removeCursor(stripped);
151
+ const trimmed = line.trim();
152
+
153
+ if (!trimmed) continue;
154
+
155
+ // Option line: "N. [x] Label" — match first, then check if it's a special
156
+ // known row (Type something, Chat about this) that happens to be numbered.
157
+ const optMatch = trimmed.match(OPTION_RE);
158
+ if (optMatch) {
159
+ const checkChar = optMatch[1]; // ' ', 'x', '✓', '✗'
160
+ const label = optMatch[2].trim();
161
+
162
+ // "Type something" can appear as a numbered checkbox row.
163
+ if (/^Type something$/i.test(label)) {
164
+ rows.push({ kind: 'type-something', label: 'Type something', cursor });
165
+ lastNavWasOption = false;
166
+ continue;
167
+ }
168
+
169
+ // "Chat about this" can appear as a numbered checkbox row.
170
+ if (/^Chat about this$/i.test(label)) {
171
+ rows.push({ kind: 'chat', label: 'Chat about this', cursor });
172
+ lastNavWasOption = false;
173
+ continue;
174
+ }
175
+
176
+ const checked = checkChar !== ' ';
177
+ rows.push({ kind: 'option', label, checked, cursor });
178
+ seenOption = true;
179
+ lastNavWasOption = true;
180
+ continue;
181
+ }
182
+
183
+ // "Type something" row — the free-text row when not in option format.
184
+ if (/^Type something/i.test(trimmed)) {
185
+ rows.push({ kind: 'type-something', label: 'Type something', cursor });
186
+ lastNavWasOption = false;
187
+ continue;
188
+ }
189
+
190
+ // Action row — "Next" or "Submit" (bare word on its own line, appears AFTER
191
+ // "Type something"). Must appear at start of line content (after cursor strip).
192
+ if (/^Next$/i.test(trimmed)) {
193
+ rows.push({ kind: 'action', label: 'Next', cursor });
194
+ actionLabel = 'Next';
195
+ lastNavWasOption = false;
196
+ continue;
197
+ }
198
+ if (/^Submit$/i.test(trimmed)) {
199
+ rows.push({ kind: 'action', label: 'Submit', cursor });
200
+ actionLabel = 'Submit';
201
+ lastNavWasOption = false;
202
+ continue;
203
+ }
204
+
205
+ // "Chat about this" row — may appear bare or as a numbered line "N. Chat about this".
206
+ {
207
+ const bareLabel = trimmed.replace(/^\d+\.\s+/, '');
208
+ if (/^Chat about this/i.test(bareLabel) || /^Chat about this/i.test(trimmed)) {
209
+ rows.push({ kind: 'chat', label: 'Chat about this', cursor });
210
+ lastNavWasOption = false;
211
+ continue;
212
+ }
213
+ }
214
+
215
+ // Footer line — skip (keyboard hint at the bottom).
216
+ if (/Enter to select|↑.↓ to navigate|Esc to cancel/i.test(trimmed)) continue;
217
+
218
+ // Tab-bar line (e.g. "← ⊠ Fruits □ Colors ✔ Submit →") — skip.
219
+ if (/←.*→/.test(trimmed)) continue;
220
+
221
+ // Question text / header — skip if we haven't seen any option yet.
222
+ if (!seenOption) continue;
223
+
224
+ // Otherwise: if the previous navigable row was an option, this is a
225
+ // description line below it — skip (not navigable).
226
+ if (lastNavWasOption) continue;
227
+
228
+ // Anything else after options: skip (question text bleed, etc.).
229
+ }
230
+
231
+ // Need at least one option or "Type something" to call it a picker.
232
+ if (rows.filter((r) => r.kind === 'option' || r.kind === 'type-something').length === 0) {
233
+ return EMPTY;
234
+ }
235
+
236
+ return { rows, actionLabel, isReview: false, confidence: 'ok' };
237
+ }
238
+
239
+ /**
240
+ * Given a parsed picker for ONE question and the desired selections, compute
241
+ * the keystroke sequence to toggle the right options and press the action row.
242
+ *
243
+ * Returns null when confidence is insufficient (unknown option label, no action
244
+ * row found, etc.) — caller must fall back to the static model.
245
+ *
246
+ * @param {ParsedPicker} parsed
247
+ * @param {{ multiSelect?: boolean, options: {label:string}[] }} question
248
+ * @param {string[]} selectedLabels
249
+ * @returns {string[]|null}
250
+ */
251
+ export function planStep(parsed, question, selectedLabels) {
252
+ if (!parsed || parsed.confidence !== 'ok' || parsed.isReview) return null;
253
+
254
+ const { rows, actionLabel } = parsed;
255
+
256
+ // Navigable rows are everything EXCEPT descriptions (which we already excluded
257
+ // in parsePicker). All rows in the list are navigable.
258
+ const navRows = rows;
259
+
260
+ if (navRows.length === 0) return null;
261
+
262
+ if (!question.multiSelect) {
263
+ // Single-select: find the target option by label, Down to it, Enter.
264
+ if (!selectedLabels || selectedLabels.length === 0) return null;
265
+ const targetLabel = selectedLabels[0];
266
+ const targetIdx = navRows.findIndex(
267
+ (r) => r.kind === 'option' && r.label === targetLabel,
268
+ );
269
+ if (targetIdx < 0) return null;
270
+
271
+ // Cursor position from the parsed state.
272
+ const cursorIdx = navRows.findIndex((r) => r.cursor);
273
+ const fromIdx = cursorIdx >= 0 ? cursorIdx : 0;
274
+
275
+ const keys = [];
276
+ const delta = targetIdx - fromIdx;
277
+ if (delta > 0) {
278
+ for (let i = 0; i < delta; i += 1) keys.push('Down');
279
+ } else if (delta < 0) {
280
+ for (let i = 0; i < -delta; i += 1) keys.push('Up');
281
+ }
282
+ keys.push('Enter');
283
+ return keys;
284
+ }
285
+
286
+ // Multi-select: for each label, verify it exists, then compute toggle plan.
287
+ if (!selectedLabels || selectedLabels.length === 0) return null;
288
+
289
+ // Resolve label → navigable index for all targets.
290
+ const targetIndices = selectedLabels.map((label) =>
291
+ navRows.findIndex((r) => r.kind === 'option' && r.label === label),
292
+ );
293
+ if (targetIndices.some((i) => i < 0)) return null; // unknown label — bail
294
+
295
+ // Sort ascending for top-to-bottom navigation.
296
+ targetIndices.sort((a, b) => a - b);
297
+
298
+ // Find action row index.
299
+ const actionIdx = navRows.findIndex((r) => r.kind === 'action');
300
+ if (actionIdx < 0) return null; // no action row visible — bail
301
+
302
+ const cursorIdx = navRows.findIndex((r) => r.cursor);
303
+ let cursor = cursorIdx >= 0 ? cursorIdx : 0;
304
+
305
+ const keys = [];
306
+
307
+ for (const target of targetIndices) {
308
+ // Navigate to the target option.
309
+ const delta = target - cursor;
310
+ if (delta > 0) {
311
+ for (let i = 0; i < delta; i += 1) keys.push('Down');
312
+ } else if (delta < 0) {
313
+ for (let i = 0; i < -delta; i += 1) keys.push('Up');
314
+ }
315
+ // Toggle: only Space if the current checked state ≠ desired (checked).
316
+ // The picker starts with all unchecked; we always want to check the targets.
317
+ // If it's already checked (pre-ticked), Space would UN-check — skip it.
318
+ const row = navRows[target];
319
+ if (!row.checked) keys.push('Space');
320
+ cursor = target;
321
+ }
322
+
323
+ // Navigate to the action row and Enter.
324
+ const actionDelta = actionIdx - cursor;
325
+ if (actionDelta > 0) {
326
+ for (let i = 0; i < actionDelta; i += 1) keys.push('Down');
327
+ } else if (actionDelta < 0) {
328
+ for (let i = 0; i < -actionDelta; i += 1) keys.push('Up');
329
+ }
330
+ keys.push('Enter');
331
+
332
+ return keys;
333
+ }
334
+
27
335
  /**
28
336
  * Resolve the selected labels to option indices, in top-to-bottom order.
29
337
  * @param {{options: {label:string}[]}} question
@@ -62,15 +370,26 @@ export function buildAnswerKeys(question, selectedLabels) {
62
370
  return keys;
63
371
  }
64
372
 
65
- // Multi-select: walk down through the chosen options in order, toggling each
66
- // with Space; the cursor starts at option 0, so move only the delta between
67
- // successive targets. A trailing Enter confirms the question.
373
+ // Multi-select: toggle each chosen option with Space (cursor starts at option
374
+ // 0; move only the delta between successive targets). Then navigate DOWN to the
375
+ // per-question action row "Next" on a non-final question, "Submit" on the
376
+ // final one — and press Enter to activate it.
377
+ //
378
+ // CRITICAL (verified empirically against the live picker): the footer is
379
+ // "Enter to select", so Enter on a CHECKBOX only toggles it — it does NOT
380
+ // advance/submit. The action row sits at navigable index options.length + 1:
381
+ // the real options [0..N-1], then the always-present "Type something" free-text
382
+ // row [N], then "Next"/"Submit" [N+1] (then "Chat about this" [N+2]). The OLD
383
+ // model pressed Enter while still on the last option, so it never advanced —
384
+ // the second question went unanswered and the picker never submitted.
68
385
  let cursor = 0;
69
386
  for (const target of indices) {
70
387
  for (let i = cursor; i < target; i += 1) keys.push('Down');
71
388
  keys.push('Space');
72
389
  cursor = target;
73
390
  }
391
+ const actionRow = (question.options?.length ?? 0) + 1;
392
+ for (let i = cursor; i < actionRow; i += 1) keys.push('Down');
74
393
  keys.push('Enter');
75
394
  return keys;
76
395
  }
@@ -91,5 +410,12 @@ export function buildAnswerProgram(pending, selections) {
91
410
  for (let i = 0; i < questions.length; i += 1) {
92
411
  program.push(...buildAnswerKeys(questions[i], selections?.[i] || []));
93
412
  }
413
+ // Multi-question pickers carry a final "Submit" tab: after the last question's
414
+ // action-row Enter, the picker lands on a "Review your answers · Submit answers /
415
+ // Cancel" screen with "Submit answers" highlighted. One more Enter confirms +
416
+ // closes it. (Verified live: without this, every question was answered correctly
417
+ // but the picker sat on the review screen, unsubmitted.) Single-question pickers
418
+ // have no review step — the question's own Enter submits.
419
+ if (questions.length > 1) program.push('Enter');
94
420
  return program;
95
421
  }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * lib/claude-cli.js — LLM backend that spawns the host Claude CLI.
3
+ *
4
+ * No API key required. Uses the same `claude` binary that the operator already
5
+ * has installed. Lean flags cut cost ~28x by disabling MCP and tools.
6
+ *
7
+ * Exports:
8
+ * - resolveClaudeBin() → string | null (abs path or null; re-reads config each call)
9
+ * - parseResult(stdout) → string (pure; throws on bad envelope)
10
+ * - complete(prompt, { model }) → Promise<string>
11
+ */
12
+
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+ import { execFileSync, spawn } from 'node:child_process';
17
+
18
+ import { readConfig } from './config.js';
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Empty MCP config: written once at module init to a stable temp path so the
22
+ // --mcp-config flag always points at a valid (empty) file.
23
+ // ---------------------------------------------------------------------------
24
+ const EMPTY_MCP_PATH = path.join(os.tmpdir(), 'claude-control-empty-mcp.json');
25
+
26
+ function ensureEmptyMcpConfig() {
27
+ if (!fs.existsSync(EMPTY_MCP_PATH)) {
28
+ fs.writeFileSync(EMPTY_MCP_PATH, '{"mcpServers":{}}', { mode: 0o600 });
29
+ }
30
+ }
31
+
32
+ try {
33
+ ensureEmptyMcpConfig();
34
+ } catch {
35
+ // Non-fatal: complete() will fail if the path is missing, which is fine.
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Binary resolution — re-reads config each call so tests can control it via
40
+ // writeConfig({ claudeBin: ... }) without module-level memoization.
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /**
44
+ * Resolve the absolute path of the claude CLI binary.
45
+ * Resolution order:
46
+ * 1. config.claudeBin if set and exists
47
+ * 2. `which claude` result if exists
48
+ * 3. Common installation paths, first that exists
49
+ *
50
+ * Re-reads config each call (cheap; avoids memoization that breaks tests).
51
+ *
52
+ * @returns {string | null}
53
+ */
54
+ export function resolveClaudeBin() {
55
+ const config = readConfig();
56
+
57
+ // 1. Explicit config override
58
+ if (config.claudeBin && typeof config.claudeBin === 'string' && config.claudeBin.trim()) {
59
+ const p = config.claudeBin.trim();
60
+ if (fs.existsSync(p)) return p;
61
+ }
62
+
63
+ // 2. `which claude`
64
+ try {
65
+ const found = execFileSync('which', ['claude'], { encoding: 'utf8' }).trim();
66
+ if (found && fs.existsSync(found)) return found;
67
+ } catch {
68
+ // not on PATH
69
+ }
70
+
71
+ // 3. Common paths
72
+ const candidates = [
73
+ path.join(os.homedir(), '.local', 'bin', 'claude'),
74
+ '/opt/homebrew/bin/claude',
75
+ '/usr/local/bin/claude',
76
+ '/usr/bin/claude',
77
+ ];
78
+ for (const p of candidates) {
79
+ if (fs.existsSync(p)) return p;
80
+ }
81
+
82
+ return null;
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------
86
+ // Envelope parser — pure, no I/O, fully testable without spawning.
87
+ // ---------------------------------------------------------------------------
88
+
89
+ /**
90
+ * Parse the JSON stdout envelope from `claude -p ... --output-format json`.
91
+ * Throws on malformed JSON, is_error:true, or missing .result.
92
+ *
93
+ * Expected envelope:
94
+ * { type: 'result', subtype: 'success', is_error: false, result: string, ... }
95
+ *
96
+ * @param {string} stdout
97
+ * @returns {string}
98
+ */
99
+ export function parseResult(stdout) {
100
+ let parsed;
101
+ try {
102
+ parsed = JSON.parse(stdout);
103
+ } catch (err) {
104
+ throw new Error(`claude CLI: invalid JSON in stdout: ${err.message}`);
105
+ }
106
+ if (parsed && parsed.is_error === true) {
107
+ throw new Error(`claude CLI: is_error=true: ${parsed.result ?? '(no message)'}`);
108
+ }
109
+ if (!parsed || typeof parsed.result !== 'string') {
110
+ throw new Error('claude CLI: missing .result in envelope');
111
+ }
112
+ return parsed.result;
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // complete — spawn the CLI and return the result string.
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Run a prompt through the Claude CLI and return the text result.
121
+ *
122
+ * @param {string} prompt
123
+ * @param {{ model?: string }} [opts]
124
+ * @returns {Promise<string>}
125
+ */
126
+ export function complete(prompt, { model } = {}) {
127
+ return new Promise((resolve, reject) => {
128
+ const bin = resolveClaudeBin();
129
+ if (!bin) {
130
+ return reject(new Error('claude CLI not found'));
131
+ }
132
+
133
+ ensureEmptyMcpConfig();
134
+
135
+ const resolvedModel = model ?? readConfig().optimizeModel ?? 'claude-haiku-4-5';
136
+
137
+ // Lean flags: -p (print mode), --output-format json, no tools, empty MCP.
138
+ // Prompt is passed as a direct argv element — never shell-interpolated.
139
+ const args = [
140
+ '-p', prompt,
141
+ '--model', resolvedModel,
142
+ '--output-format', 'json',
143
+ '--strict-mcp-config',
144
+ '--mcp-config', EMPTY_MCP_PATH,
145
+ '--allowed-tools', '',
146
+ ];
147
+
148
+ const child = spawn(bin, args, { stdio: ['ignore', 'pipe', 'pipe'] });
149
+ const stdoutChunks = [];
150
+ const stderrChunks = [];
151
+
152
+ child.stdout.on('data', (chunk) => stdoutChunks.push(chunk));
153
+ child.stderr.on('data', (chunk) => stderrChunks.push(chunk));
154
+
155
+ child.on('close', (code) => {
156
+ if (code !== 0) {
157
+ const stderrText = Buffer.concat(stderrChunks).toString('utf8').slice(0, 300);
158
+ return reject(new Error(`claude CLI exited ${code}: ${stderrText}`));
159
+ }
160
+ const stdout = Buffer.concat(stdoutChunks).toString('utf8');
161
+ try {
162
+ resolve(parseResult(stdout));
163
+ } catch (err) {
164
+ reject(err);
165
+ }
166
+ });
167
+
168
+ child.on('error', (err) => reject(err));
169
+ });
170
+ }