lazyclaw 5.4.3 → 6.0.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.
Files changed (46) hide show
  1. package/channels/handoff.mjs +36 -0
  2. package/cli.mjs +73 -7399
  3. package/daemon.mjs +23 -2085
  4. package/mas/agent_turn.mjs +2 -1
  5. package/mas/index_db.mjs +82 -0
  6. package/mas/learning.mjs +17 -1
  7. package/mas/mention_router.mjs +38 -10
  8. package/mas/provider_adapters.mjs +28 -4
  9. package/mas/scrub_env.mjs +34 -0
  10. package/mas/tool_runner.mjs +23 -7
  11. package/mas/tools/bash.mjs +10 -5
  12. package/mas/tools/browser.mjs +18 -0
  13. package/mas/tools/learning.mjs +24 -14
  14. package/mas/tools/recall.mjs +5 -1
  15. package/mas/tools/web.mjs +47 -11
  16. package/mas/trajectory_store.mjs +7 -4
  17. package/package.json +3 -2
  18. package/providers/auth_store.mjs +22 -0
  19. package/providers/claude_cli.mjs +28 -2
  20. package/providers/claude_cli_detect.mjs +46 -0
  21. package/providers/custom_provider.mjs +70 -0
  22. package/providers/model_catalogue.mjs +86 -0
  23. package/providers/orchestrator.mjs +30 -9
  24. package/providers/rates.mjs +12 -2
  25. package/providers/registry.mjs +10 -7
  26. package/sandbox/confiners/landlock.mjs +14 -8
  27. package/sandbox/confiners/seatbelt.mjs +18 -2
  28. package/scripts/loop-worker.mjs +18 -7
  29. package/scripts/migrate-v5.mjs +5 -61
  30. package/sessions.mjs +0 -0
  31. package/tui/editor.mjs +44 -0
  32. package/tui/modal_filter.mjs +59 -0
  33. package/tui/modal_picker.mjs +12 -37
  34. package/tui/pickers.mjs +917 -0
  35. package/tui/provider_families.mjs +41 -0
  36. package/tui/repl.mjs +67 -36
  37. package/tui/slash_commands.mjs +8 -7
  38. package/tui/slash_dispatcher.mjs +923 -118
  39. package/tui/splash.mjs +5 -12
  40. package/tui/subcommands.mjs +17 -0
  41. package/tui/terminal_approve.mjs +37 -0
  42. package/web/dashboard.css +275 -0
  43. package/web/dashboard.html +2 -1685
  44. package/web/dashboard.js +1406 -0
  45. package/workflow/persistent.mjs +13 -6
  46. package/mas/tools/skill_view.mjs +0 -43
@@ -0,0 +1,917 @@
1
+ // Interactive TUI helpers — readline pickers, banner/mascot renderers,
2
+ // arrow-key menu, provider/model selection, and the _quickPrompt line reader.
3
+ // Extracted from cli.mjs in Phase D4. Lives in tui/ so banner asset imports
4
+ // are siblings (./banner.generated.mjs, ./wordmark.mjs).
5
+ import { readConfig, writeConfig, _resolveAuthKey } from '../lib/config.mjs';
6
+ import { ensureRegistry, getRegistry } from '../lib/registry_boot.mjs';
7
+ import { bucketProviders as _bucketProviders } from './provider_families.mjs';
8
+ import { addCustomProvider } from '../providers/custom_provider.mjs';
9
+ import {
10
+ modelCatalogueFor as _modelCatalogueResolve,
11
+ fetchModelsForProvider as _fetchModelsResolve,
12
+ supportsLiveFetch as _supportsLiveFetch,
13
+ } from '../providers/model_catalogue.mjs';
14
+
15
+ export function _attachGhostAutocomplete(rl) {
16
+ // Returns `{ dispose, suspend, resume }`. Dispose detaches the
17
+ // keypress + rl 'line' listeners (failure to do so leaks the
18
+ // event-loop ref, which is exactly the slow-exit bug v3.92
19
+ // fixed). Suspend / resume gate the keypress handler so the
20
+ // streaming chat output isn't interleaved with `\x1b[s\x1b[K\x1b[u`
21
+ // ghost-render escapes — that interleaving is what surfaces as
22
+ // visible gaps between Korean characters in long replies.
23
+ const noop = () => {};
24
+ if (!process.stdout.isTTY) return { dispose: noop, suspend: noop, resume: noop };
25
+ const cmds = SLASH_COMMANDS.map((c) => c.cmd);
26
+ let lastGhost = '';
27
+ let suspended = false;
28
+ // Find the longest match for the current input. Returns '' when
29
+ // nothing matches or when the input already equals a command.
30
+ const findMatch = () => {
31
+ const buf = rl.line || '';
32
+ if (!buf.startsWith('/')) return '';
33
+ const exact = cmds.find((c) => c === buf);
34
+ if (exact) return '';
35
+ const hits = cmds.filter((c) => c.startsWith(buf) && c.length > buf.length);
36
+ if (!hits.length) return '';
37
+ return hits[0]; // first match is the shortest matching command
38
+ };
39
+ // Render the ghost after the user's cursor. We use ANSI save/restore
40
+ // (\x1b[s / \x1b[u) so writing the suggestion doesn't move readline's
41
+ // notion of where the cursor is; we just paint the dim text and snap
42
+ // back. \x1b[K clears any leftover ghost from the previous keystroke.
43
+ const render = () => {
44
+ if (!process.stdout.isTTY) return;
45
+ const match = findMatch();
46
+ const buf = rl.line || '';
47
+ // Always clear leftover ghost first.
48
+ process.stdout.write('\x1b[s\x1b[K');
49
+ if (match && match.length > buf.length) {
50
+ const tail = match.slice(buf.length);
51
+ process.stdout.write(`\x1b[2m${tail}\x1b[0m`);
52
+ lastGhost = match;
53
+ } else {
54
+ lastGhost = '';
55
+ }
56
+ process.stdout.write('\x1b[u');
57
+ };
58
+ // Intercept Right-arrow at end-of-line to accept the suggestion.
59
+ // We attach as a prependListener so we run before readline's own
60
+ // handler — when we accept, we mutate rl.line ourselves and call
61
+ // _refreshLine, then return without forwarding the keypress.
62
+ const onKeypress = (_str, key) => {
63
+ if (!key) return;
64
+ // While a streaming response is being printed, do nothing —
65
+ // any ANSI cursor save / restore we emit would tear the wide-
66
+ // character (CJK) output apart on the visible terminal.
67
+ if (suspended) return;
68
+ if (key.name === 'right' && lastGhost && rl.line === rl.line.trim() &&
69
+ rl.cursor === (rl.line || '').length && (rl.line || '').length < lastGhost.length) {
70
+ const accepted = lastGhost;
71
+ // Clear the dim ghost before redrawing the line (otherwise the
72
+ // residue overlaps the new line content).
73
+ process.stdout.write('\x1b[s\x1b[K\x1b[u');
74
+ rl.line = accepted;
75
+ rl.cursor = accepted.length;
76
+ // _refreshLine is private but stable across Node 18+ readline
77
+ // implementations. Falls back to manual redraw if it ever changes.
78
+ if (typeof rl._refreshLine === 'function') rl._refreshLine();
79
+ else { process.stdout.write('\r\x1b[K' + (rl._prompt || '') + accepted); }
80
+ lastGhost = '';
81
+ return;
82
+ }
83
+ // For any other key, schedule the ghost re-render after readline
84
+ // has updated rl.line. setImmediate runs after readline's keypress
85
+ // handler completes.
86
+ setImmediate(render);
87
+ };
88
+ process.stdin.on('keypress', onKeypress);
89
+ // Clear ghost on each new prompt so a stale dim hint doesn't carry
90
+ // over between turns.
91
+ const onLine = () => { lastGhost = ''; };
92
+ rl.on('line', onLine);
93
+ const dispose = () => {
94
+ try { process.stdin.removeListener('keypress', onKeypress); } catch (_) {}
95
+ try { rl.removeListener('line', onLine); } catch (_) {}
96
+ // Wipe any leftover ghost on screen so the user's terminal doesn't
97
+ // keep a dim suffix after we exit.
98
+ try { process.stdout.write('\x1b[s\x1b[K\x1b[u'); } catch (_) {}
99
+ };
100
+ return {
101
+ dispose,
102
+ suspend: () => {
103
+ suspended = true;
104
+ // Wipe any half-rendered ghost before streaming starts so the
105
+ // first chunk lands at the same column as the prompt.
106
+ try { process.stdout.write('\x1b[s\x1b[K\x1b[u'); } catch (_) {}
107
+ },
108
+ resume: () => { suspended = false; },
109
+ };
110
+ }
111
+
112
+ // LazyClaw banner — printed once at the top of every interactive chat
113
+ // session so users see the active provider/model before they start
114
+ // typing. Plain ANSI; auto-skipped when stdout isn't a TTY (so piped
115
+ // invocations stay clean for tests/scripts).
116
+ // Single source of truth for the LazyClaw banner — used by the chat
117
+ // REPL header, the no-arg launcher, and the first-run welcome panel.
118
+ // Returns an array of pre-formatted lines (with ANSI colour) so the
119
+ // caller can splice in additional rows without re-implementing the
120
+ // alignment.
121
+ //
122
+ // Width-management rule: every inner line is forced through
123
+ // `.padEnd(W)` so a stray width miscount can't punch the right
124
+ // border off the box (which is exactly the bug v3.99.5 shipped:
125
+ // v4.2.2 — boxed figlet "lazy" wordmark, single-colour orange. The
126
+ // previous mixed-colour banner (helmet-red letter art + ink-beige
127
+ // caption) read as "two banners glued together" because the colour
128
+ // changed mid-box. We use one warm orange (#F08246) for everything —
129
+ // border, letter art, caption — so the eye reads it as one badge.
130
+ //
131
+ // Letter art is figlet "standard" (6 rows) rather than the v3.99.11
132
+ // "small" (4 rows), because small renders as a pixel mush in most
133
+ // terminal fonts. Standard's strokes are wide enough that the
134
+ // letters read as `l a z y` even at small terminal sizes.
135
+ //
136
+ // Layout invariant: every inner row is exactly INNER_W visible cells
137
+ // (no double-width glyphs, all chars are 1 cell in any monospace
138
+ // font), so the right edge `│` always lands in the same column.
139
+ //
140
+ // _renderMascot / _renderMascotTiny are kept as stubs so any leftover
141
+ // caller doesn't crash; no state-coloured art is produced any more.
142
+
143
+ const _ORANGE_RGB = '241;130;70'; // #F18246
144
+ export function _orange(s) { return `\x1b[38;2;${_ORANGE_RGB}m${s}\x1b[0m`; }
145
+
146
+ export function _renderMascot() {
147
+ return ['lazyclaw'];
148
+ }
149
+
150
+ export function _renderMascotTiny() {
151
+ return 'lazyclaw';
152
+ }
153
+
154
+ // figlet "standard" "lazy", trimmed of leading blank line. Each row
155
+ // is left-padded by two spaces inside the box, and every row is then
156
+ // padded to INNER_W cells.
157
+ const _LAZY_STANDARD = [
158
+ ' _ ',
159
+ '| | __ _ _____ _ ',
160
+ '| |/ _` |_ / | | | ',
161
+ '| | (_| |/ /| |_| | ',
162
+ '|_|\\__,_/___|\\__, | ',
163
+ ' |___/ ',
164
+ ];
165
+
166
+ const _INNER_W = 32; // 2 left pad + 20 letter art + caption headroom
167
+
168
+ export function _renderBanner(version) {
169
+ const v = String(version || '?.?.?');
170
+ const cap = ` LazyClaw v${v}`;
171
+ const padInner = (s) => ' ' + s.padEnd(_INNER_W - 2, ' ');
172
+ const wrap = (inner) => _orange('│') + _orange(inner) + _orange('│');
173
+ const top = _orange('╭' + '─'.repeat(_INNER_W) + '╮');
174
+ const bot = _orange('╰' + '─'.repeat(_INNER_W) + '╯');
175
+ return [
176
+ top,
177
+ ..._LAZY_STANDARD.map((row) => wrap(padInner(row))),
178
+ wrap(padInner(cap)),
179
+ bot,
180
+ ];
181
+ }
182
+
183
+ // v5 hero banner — ANSI Shadow LAZYCLAW wordmark stacked on top of the
184
+ // braille sloth (tui/banner.generated.mjs + tui/wordmark.mjs). Left-aligned
185
+ // with a 2-cell margin so wide terminals don't push the art to the right.
186
+ // Opt out with LAZYCLAW_LEGACY_MENU=1 to fall back to the v4 figlet box.
187
+ let _bannerAssetsCache = null;
188
+ export async function _loadBannerAssets() {
189
+ if (_bannerAssetsCache !== null) return _bannerAssetsCache;
190
+ try {
191
+ const { banner } = await import('./banner.generated.mjs');
192
+ const { wordmark } = await import('./wordmark.mjs');
193
+ _bannerAssetsCache = { banner, wordmark };
194
+ } catch {
195
+ _bannerAssetsCache = null;
196
+ }
197
+ return _bannerAssetsCache;
198
+ }
199
+
200
+ export async function _renderV5Banner(version) {
201
+ const a = await _loadBannerAssets();
202
+ if (!a) return _renderBanner(version); // missing tarball asset → v4 figlet
203
+ const v = String(version || '?.?.?');
204
+ const rows = [];
205
+ const palette = a.wordmark.palette || [];
206
+ const gradient = a.wordmark.gradient || [];
207
+ function tint(idx, s) {
208
+ const hex = palette[idx] || '#FFB347';
209
+ const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
210
+ if (!m) return _orange(s);
211
+ const [, r, g, b] = m;
212
+ const R = parseInt(r, 16), G = parseInt(g, 16), B = parseInt(b, 16);
213
+ return `\x1b[38;2;${R};${G};${B}m${s}\x1b[0m`;
214
+ }
215
+ a.wordmark.rows.forEach((r, i) => rows.push(tint(gradient[i] ?? 1, ' ' + r)));
216
+ rows.push('');
217
+ for (const r of a.banner.rows) rows.push(_orange(' ' + r));
218
+ rows.push(_orange(' ' + `lazyclaw v${v}`));
219
+ return rows;
220
+ }
221
+
222
+ export function _printChatBanner(activeProvName, activeModel, version) {
223
+ if (!process.stdout.isTTY) return;
224
+ // Single-hue header: labels dim-orange, values/emphasis full-orange, so the
225
+ // four caption rows below the box read as part of the same warm badge.
226
+ const dimOrange = (s) => `\x1b[2m\x1b[38;2;${_ORANGE_RGB}m${s}\x1b[0m`;
227
+ const orange = _orange;
228
+ const lines = [
229
+ '',
230
+ ..._renderBanner(version),
231
+ '',
232
+ ` ${dimOrange('provider ·')} ${orange(activeProvName)}`,
233
+ ` ${dimOrange('model ·')} ${orange(activeModel || '(default)')}`,
234
+ ` ${dimOrange('slash ·')} ${orange('/help · /model · /provider · /exit')}`,
235
+ ` ${dimOrange('hint ·')} ${orange('→')} ${dimOrange('to accept the suggested command,')} ${orange('Tab')} ${dimOrange('to cycle')}`,
236
+ '',
237
+ ];
238
+ process.stdout.write(lines.join('\n') + '\n');
239
+ }
240
+
241
+
242
+ // Interactive provider/model picker. Used on first run (no config) or
243
+ // when the user passes --pick. Falls back to plain stdin reads when
244
+ // stdout isn't a TTY (CI/script callers should pass --non-interactive
245
+ // equivalents instead).
246
+ // Generic arrow-key menu used by the multi-step provider/model
247
+ // picker below. Returns the picked item, or one of the sentinel
248
+ // strings 'BACK' (Esc — caller should retry the previous step) or
249
+ // 'CANCEL' (q — caller should bail entirely). Ctrl-C exits the
250
+ // process directly, matching every other interactive prompt in the
251
+ // CLI.
252
+ //
253
+ // `items` is an array of { id, label, desc, tag }. `tag` is an
254
+ // optional pre-coloured pill (e.g. "[api key]") that lands on the
255
+ // right side of the row. `defaultIdx` lets the caller pin where the
256
+ // cursor lands; default 0.
257
+ export async function _arrowMenu({ title, subtitle, footer, items, defaultIdx = 0, searchable = false }) {
258
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
259
+ // Non-TTY fallback: print the labels on stderr and read a single
260
+ // line of stdin. Used when somebody pipes input to `lazyclaw
261
+ // setup` — the wizard still works, just without arrows.
262
+ process.stderr.write(`${title}\n`);
263
+ items.forEach((it, i) => process.stderr.write(` ${i + 1}. ${it.label}${it.desc ? ' — ' + it.desc : ''}\n`));
264
+ process.stderr.write('pick (number / id, blank for first): ');
265
+ const ans = await new Promise((resolve) => {
266
+ let buf = '';
267
+ const onData = (chunk) => {
268
+ buf += chunk.toString();
269
+ if (buf.includes('\n')) { process.stdin.off('data', onData); resolve(buf.trim()); }
270
+ };
271
+ process.stdin.on('data', onData);
272
+ });
273
+ if (!ans) return items[0];
274
+ const byNum = parseInt(ans, 10);
275
+ if (Number.isFinite(byNum) && byNum >= 1 && byNum <= items.length) return items[byNum - 1];
276
+ const byId = items.find((it) => it.id === ans || it.label === ans);
277
+ return byId || items[0];
278
+ }
279
+
280
+ const readline = await import('node:readline');
281
+ readline.emitKeypressEvents(process.stdin);
282
+ if (process.stdin.setRawMode) process.stdin.setRawMode(true);
283
+ // A previous `readline.createInterface(...).close()` (e.g. from
284
+ // `_quickPrompt`) leaves stdin paused — the keypress listener we
285
+ // attach below would never fire and the menu would appear frozen
286
+ // instead of responding to arrow keys. Resume + ref defensively
287
+ // before drawing so the picker always receives the first keypress.
288
+ process.stdin.resume();
289
+ if (process.stdin.ref) process.stdin.ref();
290
+ const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
291
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
292
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
293
+
294
+ // Typeahead state. `query` accumulates printable chars when searchable
295
+ // is on; the visible item slice is recomputed on every keystroke. We
296
+ // keep `defaultIdx` semantics by mapping it to the unfiltered list and
297
+ // tracking selection inside the filtered view via the item identity.
298
+ let query = '';
299
+ const matchScore = (it, q) => {
300
+ if (!q) return 0;
301
+ const hay = `${it.label || ''} ${it.desc || ''} ${it.id || ''}`.toLowerCase();
302
+ const needle = q.toLowerCase();
303
+ if (hay.includes(needle)) return hay.indexOf(needle) === 0 ? 2 : 1;
304
+ // simple subsequence fallback so "g4o" matches "gpt-4o".
305
+ let i = 0; let matched = 0;
306
+ for (const ch of hay) {
307
+ if (ch === needle[matched]) { matched++; if (matched === needle.length) break; }
308
+ i++;
309
+ }
310
+ return matched === needle.length ? 0.5 : 0;
311
+ };
312
+ const filterItems = () => {
313
+ if (!searchable || !query) return items.slice();
314
+ const scored = items
315
+ .map((it) => ({ it, s: matchScore(it, query) }))
316
+ .filter((x) => x.s > 0)
317
+ .sort((a, b) => b.s - a.s);
318
+ return scored.map((x) => x.it);
319
+ };
320
+ let view = filterItems();
321
+ let idx = Math.max(0, Math.min(view.length - 1, defaultIdx));
322
+ if (idx < 0) idx = 0;
323
+
324
+ const draw = () => {
325
+ process.stdout.write('\x1b[?25l\x1b[2J\x1b[H');
326
+ process.stdout.write(accent(title) + '\n');
327
+ if (subtitle) process.stdout.write(dim(subtitle) + '\n');
328
+ const help = searchable
329
+ ? '↑/↓ to move · Enter to confirm · type to search · Esc to back · Ctrl+U to clear · q to quit'
330
+ : '↑/↓ to move · Enter to confirm · Esc to back · q to quit';
331
+ process.stdout.write(dim(help) + '\n');
332
+ if (searchable) {
333
+ const q = query ? bold(query) : dim('(type to filter)');
334
+ process.stdout.write(dim(' search: ') + q + dim(` ${view.length}/${items.length} match`) + '\n\n');
335
+ } else {
336
+ process.stdout.write('\n');
337
+ }
338
+ if (view.length === 0) {
339
+ process.stdout.write(' ' + dim('(no matches — backspace or Ctrl+U to clear the filter)') + '\n');
340
+ if (footer) process.stdout.write('\n' + dim(footer) + '\n');
341
+ return;
342
+ }
343
+ const headerLines = subtitle ? 4 : 3;
344
+ const rows = Math.max(6, (process.stdout.rows || 24) - (headerLines + (searchable ? 3 : 4)));
345
+ let from = Math.max(0, idx - Math.floor(rows / 2));
346
+ if (from + rows > view.length) from = Math.max(0, view.length - rows);
347
+ const to = Math.min(view.length, from + rows);
348
+ // Pre-compute label width so descriptions line up across rows.
349
+ const labelW = view.reduce((w, it) => Math.max(w, (it.label || '').length), 12);
350
+ for (let i = from; i < to; i++) {
351
+ const it = view[i];
352
+ const marker = i === idx ? accent('❯ ') : ' ';
353
+ const lbl = (it.label || '').padEnd(labelW);
354
+ const lblOut = i === idx ? bold(lbl) : lbl;
355
+ const desc = it.desc ? ' ' + dim(it.desc) : '';
356
+ const tag = it.tag ? ' ' + it.tag : '';
357
+ process.stdout.write(`${marker}${lblOut}${desc}${tag}\n`);
358
+ }
359
+ if (to < view.length) {
360
+ process.stdout.write(`${dim(` …(${view.length - to} more)`)}\n`);
361
+ }
362
+ if (footer) process.stdout.write('\n' + dim(footer) + '\n');
363
+ };
364
+
365
+ draw();
366
+ return await new Promise((resolve) => {
367
+ const recompute = () => {
368
+ view = filterItems();
369
+ if (idx >= view.length) idx = Math.max(0, view.length - 1);
370
+ draw();
371
+ };
372
+ const onKey = (str, key) => {
373
+ if (!key) return;
374
+ if (key.name === 'up') { if (view.length) { idx = (idx - 1 + view.length) % view.length; draw(); } }
375
+ else if (key.name === 'down') { if (view.length) { idx = (idx + 1) % view.length; draw(); } }
376
+ else if (key.name === 'pageup') { idx = Math.max(0, idx - 10); draw(); }
377
+ else if (key.name === 'pagedown') { idx = Math.min(view.length - 1, idx + 10); draw(); }
378
+ else if (key.name === 'home') { idx = 0; draw(); }
379
+ else if (key.name === 'end') { idx = view.length - 1; draw(); }
380
+ else if (key.name === 'return') {
381
+ if (view.length === 0) return;
382
+ cleanup();
383
+ resolve(view[idx]);
384
+ }
385
+ else if (key.ctrl && key.name === 'c') { cleanup(); process.exit(130); }
386
+ else if (key.ctrl && key.name === 'u') { if (searchable) { query = ''; recompute(); } }
387
+ else if (key.name === 'escape') {
388
+ if (searchable && query) { query = ''; recompute(); return; }
389
+ cleanup(); resolve('BACK');
390
+ }
391
+ else if (key.name === 'backspace') {
392
+ if (searchable && query.length > 0) { query = query.slice(0, -1); recompute(); }
393
+ }
394
+ else if (searchable && str && str.length === 1 && str >= ' ' && str !== '\x7f' && !key.ctrl && !key.meta) {
395
+ // Printable char → append to filter buffer. We deliberately do not
396
+ // intercept 'q' as a shortcut when searchable is on, because the
397
+ // user might be typing a model id that contains 'q'. Use Esc / Ctrl+C
398
+ // to bail out instead.
399
+ query += str;
400
+ recompute();
401
+ }
402
+ else if (!searchable && key.name === 'q') { cleanup(); resolve('CANCEL'); }
403
+ };
404
+ const cleanup = () => {
405
+ process.stdin.off('keypress', onKey);
406
+ if (process.stdin.setRawMode) process.stdin.setRawMode(false);
407
+ process.stdout.write('\x1b[?25h\x1b[2J\x1b[H');
408
+ };
409
+ process.stdin.on('keypress', onKey);
410
+ });
411
+ }
412
+
413
+ // Bucket every registered provider into one of three auth-method
414
+ // families. The picker's first step asks the user which family
415
+ // they want before drilling into specific providers — much less
416
+ // overwhelming than a flat 40-row list. Bucket assignment lives
417
+ // here (rather than registry.mjs) because it's a UX concept, not
418
+ // an intrinsic provider attribute.
419
+ export function _providerFamilies() {
420
+ // Membership (api/cli/mock, orchestrator excluded) is shared with the Ink
421
+ // picker via tui/provider_families.mjs so both paths bucket identically.
422
+ // The ANSI tags below are readline-specific, so they're applied here.
423
+ const b = _bucketProviders(getRegistry());
424
+ return {
425
+ api: { label: 'API key', desc: 'paste an sk-... key during setup', tag: '\x1b[38;5;245m[needs key]\x1b[0m', members: b.api },
426
+ cli: { label: 'CLI / Local', desc: 'keyless — uses an existing CLI login or a local daemon', tag: '\x1b[38;5;208m[no key]\x1b[0m', members: b.cli },
427
+ mock: { label: 'Mock', desc: 'offline echo, only useful for testing', tag: '\x1b[38;5;245m[test]\x1b[0m', members: b.mock },
428
+ };
429
+ }
430
+
431
+ // Multi-step provider / model picker — replaces the flat 40-row
432
+ // list of v3.99.5 with a drill-in:
433
+ //
434
+ // Step 1 — auth family (API key / CLI-Local / Mock)
435
+ // Step 2 — provider in that family (gemini / openai / claude-cli / …)
436
+ // Step 3 — model in that provider's suggestedModels
437
+ //
438
+ // Esc at any step goes back one. q or Ctrl-C cancels entirely.
439
+ // Steps that have only one option auto-advance so the user doesn't
440
+ // stare at a single-row menu (e.g. the Mock family has just `mock`).
441
+ export async function _pickProviderInteractive() {
442
+ const providers = Object.keys(getRegistry().PROVIDERS);
443
+ if (!providers.length) return { provider: 'mock', model: null };
444
+ const info = getRegistry().PROVIDER_INFO || {};
445
+ const families = _providerFamilies();
446
+
447
+ // Non-TTY fallback — single-prompt picker, identical to before.
448
+ if (!process.stdout.isTTY || !process.stdin.isTTY) {
449
+ process.stdout.write(`provider [${providers.join('|')}]: `);
450
+ const ans = await new Promise((resolve) => {
451
+ let buf = '';
452
+ const onData = (chunk) => {
453
+ buf += chunk.toString();
454
+ if (buf.includes('\n')) { process.stdin.off('data', onData); resolve(buf.trim()); }
455
+ };
456
+ process.stdin.on('data', onData);
457
+ });
458
+ // v5.3.2 — non-TTY fallback used to be `providers[0]`, which was
459
+ // whatever happened to be first in the registry (currently
460
+ // anthropic). Pin to claude-cli to match the interactive onboard
461
+ // hint at cmdOnboard (the keyless subscription path).
462
+ return { provider: ans || 'claude-cli', model: null };
463
+ }
464
+
465
+ // ── Step 1 — auth family ──────────────────────────────────────
466
+ let family = null;
467
+ while (!family) {
468
+ const familyItems = Object.entries(families)
469
+ .filter(([, b]) => b.members.length > 0)
470
+ .map(([id, b]) => {
471
+ // Show member count + a few names instead of the full list — the
472
+ // API-key family alone now has 12 vendors and joining all of them
473
+ // produced an unreadable line.
474
+ const preview = b.members.slice(0, 3).join(' / ');
475
+ const more = b.members.length > 3 ? ` … (+${b.members.length - 3} more)` : '';
476
+ return {
477
+ id,
478
+ label: b.label,
479
+ desc: `${b.desc} · ${preview}${more}`,
480
+ tag: b.tag,
481
+ };
482
+ });
483
+ const picked = await _arrowMenu({
484
+ title: 'LazyClaw setup — Step 1 of 3: pick how you want to auth',
485
+ subtitle: 'API: bring your own key · CLI/Local: use what\'s already on this machine · Mock: offline test',
486
+ items: familyItems,
487
+ });
488
+ if (picked === 'CANCEL' || picked === 'BACK') return null;
489
+ family = picked;
490
+ }
491
+
492
+ // ── Step 2 — provider in that family ──────────────────────────
493
+ let provider = null;
494
+ while (!provider) {
495
+ const memberNames = families[family.id].members;
496
+ const provItems = memberNames.map((name) => {
497
+ const meta = info[name] || {};
498
+ const isCustom = !!meta.custom;
499
+ const isBuiltinCompat = !!meta.builtinOpenAICompat;
500
+ // Step-2 desc used to preview four suggested model ids per provider.
501
+ // That made the row read like "gemini · models: gemini-2.5-pro ·
502
+ // gemini-2.5-flash · gemini-2.0-flash · gemini-2.0-flash-thinking-exp",
503
+ // which is too dense and partly redundant — step 3 already shows the
504
+ // full curated list. Keep the row to a vendor label + endpoint hint.
505
+ let desc = '';
506
+ if (isCustom) desc = `custom · ${meta.baseUrl || ''}`;
507
+ else if (isBuiltinCompat) desc = meta.label || meta.baseUrl || '';
508
+ else if (meta.label && meta.label !== name) desc = meta.label;
509
+ return {
510
+ id: name,
511
+ label: name,
512
+ desc,
513
+ tag: isCustom
514
+ ? '\x1b[38;5;213m[custom]\x1b[0m'
515
+ : (meta.requiresApiKey ? '\x1b[38;5;245m[api key]\x1b[0m' : '\x1b[38;5;208m[no key]\x1b[0m'),
516
+ };
517
+ });
518
+ // Surface a "+ Add a new custom endpoint…" entry inside the API-key
519
+ // family. NIM, OpenRouter, vLLM, LM Studio, Together, Groq, etc. all
520
+ // speak the OpenAI Chat-Completions wire format — this single hook
521
+ // covers every one of them without shipping a per-vendor provider.
522
+ if (family.id === 'api') {
523
+ provItems.push({
524
+ id: '__add_custom__',
525
+ label: '+ Add a custom OpenAI-compatible endpoint…',
526
+ desc: 'NVIDIA NIM · OpenRouter · Together · Groq · vLLM · LM Studio · …',
527
+ tag: '\x1b[38;5;213m[new]\x1b[0m',
528
+ });
529
+ }
530
+ if (memberNames.length === 1 && family.id !== 'api') {
531
+ // Auto-advance — no point making the user pick from a single row,
532
+ // unless we just appended the "+ Add custom" entry above.
533
+ provider = { id: memberNames[0] };
534
+ break;
535
+ }
536
+ const picked = await _arrowMenu({
537
+ title: `LazyClaw setup — Step 2 of 3: pick a ${family.label} provider`,
538
+ subtitle: `Showing ${provItems.length} ${family.label.toLowerCase()} option(s). Type to filter.`,
539
+ items: provItems,
540
+ searchable: true,
541
+ });
542
+ if (picked === 'CANCEL') return null;
543
+ if (picked === 'BACK') { family = null; return _pickProviderInteractive(); }
544
+ if (picked && picked.id === '__add_custom__') {
545
+ const added = await _addCustomProviderInteractive();
546
+ if (!added) continue; // back to provider list
547
+ // Force the registry to pick up the new entry and recompute the
548
+ // family bucket for the next loop iteration.
549
+ await ensureRegistry();
550
+ Object.assign(families, _providerFamilies());
551
+ provider = { id: added.name };
552
+ break;
553
+ }
554
+ provider = picked;
555
+ }
556
+
557
+ // ── Step 3 — model picker ────────────────────────────────────────
558
+ // v5.3.2 — the setup wizard no longer surfaces composite providers
559
+ // (orchestrator is filtered out of _providerFamilies above), so this
560
+ // step is just the regular model picker. The orchestrator wizard
561
+ // (_setupOrchestratorInteractive) stays reachable via the dedicated
562
+ // `lazyclaw orchestrator …` subcommand and an explicit
563
+ // `--provider orchestrator` invocation.
564
+ const picked = await _pickModelInteractive(provider.id, {
565
+ titlePrefix: 'LazyClaw setup — Step 3 of 3:',
566
+ onBack: 'restart',
567
+ });
568
+ if (picked === 'CANCEL') return null;
569
+ if (picked === 'BACK') return _pickProviderInteractive();
570
+ return { provider: provider.id, model: picked };
571
+ }
572
+
573
+ // Step-3 alternative for composite providers (currently only the
574
+ // orchestrator). Builds `cfg.orchestrator = { planner, workers,
575
+ // maxSubtasks }` interactively and persists it before returning.
576
+ //
577
+ // planner: single picker over registered non-composite providers.
578
+ // workers: multi-select with a running list + add/remove/done loop.
579
+ // maxSubtasks: typed integer, default 5.
580
+ export async function _setupOrchestratorInteractive() {
581
+ const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
582
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
583
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
584
+ const ok = (s) => `\x1b[32m${s}\x1b[0m`;
585
+ const info = getRegistry().PROVIDER_INFO || {};
586
+ const eligibleNames = Object.keys(getRegistry().PROVIDERS).filter((n) => n !== 'orchestrator' && n !== 'mock');
587
+ if (eligibleNames.length === 0) {
588
+ process.stdout.write('\n' + accent('orchestrator setup') + ': no eligible workers — register a real provider first.\n');
589
+ await _quickPrompt(' press Enter to continue ');
590
+ return 'CANCEL';
591
+ }
592
+ const cfg = readConfig();
593
+ const existing = cfg.orchestrator && typeof cfg.orchestrator === 'object' ? cfg.orchestrator : {};
594
+
595
+ // ── Pick planner ─────────────────────────────────────────────────
596
+ const plannerItems = eligibleNames.map((name) => {
597
+ const m = info[name] || {};
598
+ const defaultModel = m.defaultModel || '';
599
+ return {
600
+ id: `${name}${defaultModel ? ':' + defaultModel : ''}`,
601
+ label: m.label && m.label !== name ? `${name} — ${m.label}` : name,
602
+ desc: defaultModel ? `default model: ${defaultModel}` : '',
603
+ };
604
+ });
605
+ const plannerPick = await _arrowMenu({
606
+ title: 'LazyClaw setup — Step 3 of 3: orchestrator — pick the planner',
607
+ subtitle: 'The planner decomposes the user request into subtasks and writes the final synthesis. Strong reasoning models work best here.',
608
+ items: plannerItems,
609
+ searchable: true,
610
+ defaultIdx: Math.max(0, plannerItems.findIndex((p) => p.id === existing.planner)),
611
+ });
612
+ if (plannerPick === 'CANCEL') return 'CANCEL';
613
+ if (plannerPick === 'BACK') return 'BACK';
614
+ const planner = plannerPick.id;
615
+
616
+ // ── Pick workers (iterative add/remove) ──────────────────────────
617
+ const workers = Array.isArray(existing.workers) ? existing.workers.slice() : [];
618
+ while (true) {
619
+ process.stdout.write('\x1b[2J\x1b[H');
620
+ process.stdout.write(accent('Orchestrator workers') + '\n');
621
+ process.stdout.write(dim('Subtasks are dispatched round-robin across this list.') + '\n\n');
622
+ if (workers.length === 0) {
623
+ process.stdout.write(' ' + dim('(none yet — add at least one)') + '\n\n');
624
+ } else {
625
+ workers.forEach((w, i) => {
626
+ process.stdout.write(` ${i + 1}. ${ok(w)}\n`);
627
+ });
628
+ process.stdout.write('\n');
629
+ }
630
+ const items = [
631
+ { id: '__add__', label: '+ Add a worker', desc: 'pick from registered providers' },
632
+ { id: '__remove__', label: '- Remove a worker', desc: workers.length ? 'pick which entry to drop' : '(nothing to remove)' },
633
+ { id: '__done__', label: `Done${workers.length ? ` (${workers.length} worker${workers.length === 1 ? '' : 's'})` : ' — at least one worker required'}`, desc: workers.length ? 'save cfg.orchestrator and finish' : 'add one worker first' },
634
+ ];
635
+ const action = await _arrowMenu({
636
+ title: 'LazyClaw setup — orchestrator workers',
637
+ subtitle: `Planner: ${planner}`,
638
+ items,
639
+ });
640
+ if (action === 'CANCEL') return 'CANCEL';
641
+ if (action === 'BACK') return 'BACK';
642
+ if (action.id === '__add__') {
643
+ const wPick = await _arrowMenu({
644
+ title: 'Add worker',
645
+ subtitle: 'Picked entries are appended to the workers list.',
646
+ items: plannerItems.filter((p) => !workers.includes(p.id)),
647
+ searchable: true,
648
+ });
649
+ if (wPick === 'CANCEL' || wPick === 'BACK') continue;
650
+ workers.push(wPick.id);
651
+ continue;
652
+ }
653
+ if (action.id === '__remove__') {
654
+ if (!workers.length) continue;
655
+ const rPick = await _arrowMenu({
656
+ title: 'Remove worker',
657
+ subtitle: 'Highlighted entry is removed from the list.',
658
+ items: workers.map((w) => ({ id: w, label: w })),
659
+ });
660
+ if (rPick === 'CANCEL' || rPick === 'BACK') continue;
661
+ const idx = workers.indexOf(rPick.id);
662
+ if (idx >= 0) workers.splice(idx, 1);
663
+ continue;
664
+ }
665
+ if (action.id === '__done__') {
666
+ if (workers.length === 0) continue;
667
+ break;
668
+ }
669
+ }
670
+
671
+ // ── maxSubtasks ──────────────────────────────────────────────────
672
+ const defaultMax = Number.isFinite(existing.maxSubtasks) && existing.maxSubtasks > 0
673
+ ? Math.min(10, existing.maxSubtasks)
674
+ : 5;
675
+ const rawMax = (await _quickPrompt(` ${bold('maxSubtasks')} ${dim(`(2..10, blank → ${defaultMax}):`)} `)).trim();
676
+ let maxSubtasks = defaultMax;
677
+ if (rawMax) {
678
+ const n = parseInt(rawMax, 10);
679
+ if (Number.isFinite(n) && n >= 1) maxSubtasks = Math.min(10, Math.max(1, n));
680
+ }
681
+
682
+ // ── Persist ──────────────────────────────────────────────────────
683
+ cfg.orchestrator = { planner, workers, maxSubtasks };
684
+ writeConfig(cfg);
685
+ process.stdout.write('\n');
686
+ process.stdout.write(` ${ok('✓ orchestrator saved')} ${dim('→')} ` +
687
+ `planner ${ok(planner)} · ${workers.length} worker${workers.length === 1 ? '' : 's'} · maxSubtasks ${maxSubtasks}\n`);
688
+ await _quickPrompt(' press Enter to continue ');
689
+ return { ok: true };
690
+ }
691
+
692
+ // Pause the chat REPL's readline + ghost-autocomplete while a sub-picker
693
+ // (provider / model arrow menu) takes over the terminal. The sub-picker
694
+ // installs its own `keypress` listener and toggles raw mode; the chat's
695
+ // readline would race it for stdin if we left it active. After `body`
696
+ // returns we re-emit keypress events, restore raw mode, and re-prompt
697
+ // so the chat resumes cleanly. `body` is awaited — exceptions propagate.
698
+ export async function _pauseChatForSubMenu(rl, ghost, body) {
699
+ if (ghost && typeof ghost.suspend === 'function') ghost.suspend();
700
+ try { rl.pause(); } catch (_) {}
701
+ // Drop the readline keypress hook so the picker's own listener has
702
+ // sole ownership while it's open. We re-arm it on the way out.
703
+ if (process.stdin.setRawMode) {
704
+ try { process.stdin.setRawMode(false); } catch (_) {}
705
+ }
706
+ try {
707
+ await body();
708
+ } finally {
709
+ const readline = await import('node:readline');
710
+ try { readline.emitKeypressEvents(process.stdin); } catch (_) {}
711
+ if (process.stdin.setRawMode && process.stdin.isTTY) {
712
+ try { process.stdin.setRawMode(false); } catch (_) {}
713
+ }
714
+ process.stdin.resume();
715
+ if (process.stdin.ref) process.stdin.ref();
716
+ if (ghost && typeof ghost.resume === 'function') ghost.resume();
717
+ try { rl.resume(); } catch (_) {}
718
+ try { rl.prompt(); } catch (_) {}
719
+ }
720
+ }
721
+
722
+ // Standalone model picker for the chat REPL's `/model` slash. Returns
723
+ // the chosen model id (string), 'BACK', or 'CANCEL'. Falls through to
724
+ // null when the provider has no curated models and no live-fetch surface
725
+ // (mock) — the caller should treat that as "use the provider default".
726
+ export async function _pickModelInteractive(providerId, opts = {}) {
727
+ const info = getRegistry().PROVIDER_INFO || {};
728
+ const meta = info[providerId] || {};
729
+ const baseModels = Array.isArray(meta.suggestedModels) ? meta.suggestedModels.slice() : [];
730
+ const isCustom = !!meta.custom;
731
+ const isBuiltinCompat = !!meta.builtinOpenAICompat;
732
+ const supportsLiveFetch = _supportsLiveFetch(meta, providerId);
733
+
734
+ if (!baseModels.length && !supportsLiveFetch) return null;
735
+
736
+ let dynamicModels = [];
737
+ while (true) {
738
+ const allModels = Array.from(new Set([...baseModels, ...dynamicModels]));
739
+ const modelItems = allModels.map((m) => ({ id: m, label: m, desc: '' }));
740
+ if (supportsLiveFetch) {
741
+ modelItems.unshift({
742
+ id: '__fetch_models__',
743
+ label: '↻ Fetch live model list from /v1/models',
744
+ desc: isCustom || isBuiltinCompat ? `GET ${meta.baseUrl}/models` : 'pulls the up-to-date catalogue from the provider',
745
+ tag: '\x1b[38;5;245m[live]\x1b[0m',
746
+ });
747
+ }
748
+ modelItems.push({
749
+ id: '__custom_model__',
750
+ label: '… type a custom model id',
751
+ desc: 'use any model id supported by this provider, even if not listed above',
752
+ tag: '\x1b[38;5;245m[free]\x1b[0m',
753
+ });
754
+
755
+ const defaultIdx = supportsLiveFetch
756
+ ? Math.max(0, 1 + allModels.indexOf(meta.defaultModel || allModels[0]))
757
+ : Math.max(0, allModels.indexOf(meta.defaultModel || allModels[0]));
758
+ const titlePrefix = opts.titlePrefix ? `${opts.titlePrefix} ` : '';
759
+ const picked = await _arrowMenu({
760
+ title: `${titlePrefix}pick a model for ${providerId}`,
761
+ subtitle: `Type to filter ${allModels.length} model(s). Enter to confirm. Backspace clears one char, Ctrl+U clears the filter.`,
762
+ items: modelItems,
763
+ defaultIdx,
764
+ searchable: true,
765
+ });
766
+ if (picked === 'CANCEL') return 'CANCEL';
767
+ if (picked === 'BACK') return 'BACK';
768
+ if (picked.id === '__custom_model__') {
769
+ const typed = (await _quickPrompt(` model id for ${providerId}: `)).trim();
770
+ if (!typed) continue;
771
+ return typed;
772
+ }
773
+ if (picked.id === '__fetch_models__') {
774
+ try {
775
+ process.stdout.write(`\n fetching ${providerId} model list…\n`);
776
+ const fetched = await _fetchModelsForProvider(providerId);
777
+ if (!fetched.length) {
778
+ process.stdout.write(` ${'\x1b[33m'}no models returned${'\x1b[0m'} — falling back to the suggested list.\n`);
779
+ await _quickPrompt(' press Enter to continue ');
780
+ } else {
781
+ dynamicModels = fetched;
782
+ process.stdout.write(` fetched ${fetched.length} model(s).\n`);
783
+ await _quickPrompt(' press Enter to pick one ');
784
+ }
785
+ } catch (e) {
786
+ process.stdout.write(`\n ${'\x1b[33m'}fetch failed:${'\x1b[0m'} ${e?.message || e}\n`);
787
+ await _quickPrompt(' press Enter to continue ');
788
+ }
789
+ continue;
790
+ }
791
+ return picked.id;
792
+ }
793
+ }
794
+
795
+ // Resolve {baseUrl, apiKey} for a provider so we can call /v1/models on
796
+ // its behalf. Returns null when the provider doesn't expose an OpenAI-
797
+ // compatible model catalogue (e.g. anthropic, gemini, claude-cli).
798
+ export function _modelCatalogueFor(providerId) {
799
+ const cfg = readConfig();
800
+ return _modelCatalogueResolve({
801
+ cfg,
802
+ registryMod: getRegistry(),
803
+ resolveAuthKey: (id) => _resolveAuthKey(cfg, id),
804
+ providerId,
805
+ });
806
+ }
807
+
808
+ export async function _fetchModelsForProvider(providerId) {
809
+ const cfg = readConfig();
810
+ return _fetchModelsResolve({
811
+ cfg,
812
+ registryMod: getRegistry(),
813
+ resolveAuthKey: (id) => _resolveAuthKey(cfg, id),
814
+ providerId,
815
+ });
816
+ }
817
+
818
+ // Walk the user through registering a new OpenAI-compatible custom
819
+ // provider (NIM, OpenRouter, vLLM, LM Studio, Together, Groq, …).
820
+ // Persists into cfg.customProviders[] and returns { name } on success,
821
+ // or null when the user backs out.
822
+ export async function _addCustomProviderInteractive() {
823
+ const accent = (s) => `\x1b[38;5;208m${s}\x1b[0m`;
824
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
825
+ const bold = (s) => `\x1b[1m${s}\x1b[0m`;
826
+ const ok = (s) => `\x1b[32m${s}\x1b[0m`;
827
+
828
+ process.stdout.write('\x1b[2J\x1b[H');
829
+ process.stdout.write(accent('Add a custom OpenAI-compatible endpoint') + '\n');
830
+ process.stdout.write(dim('Works with any service that speaks the OpenAI v1 wire format.') + '\n');
831
+ process.stdout.write(dim('Examples:') + '\n');
832
+ process.stdout.write(dim(' · NVIDIA NIM https://integrate.api.nvidia.com/v1') + '\n');
833
+ process.stdout.write(dim(' · OpenRouter https://openrouter.ai/api/v1') + '\n');
834
+ process.stdout.write(dim(' · Together AI https://api.together.xyz/v1') + '\n');
835
+ process.stdout.write(dim(' · Groq https://api.groq.com/openai/v1') + '\n');
836
+ process.stdout.write(dim(' · vLLM / LM Studio http://localhost:8000/v1') + '\n\n');
837
+
838
+ const { validateCustomProviderName, registerCustomProviders, fetchOpenAICompatModels, isBuiltinOpenAICompatName } = getRegistry();
839
+ let name;
840
+ while (true) {
841
+ const raw = (await _quickPrompt(` ${bold('name')} ${dim('(short id, e.g. "nim", "openrouter"):')} `)).trim();
842
+ if (!raw) {
843
+ process.stdout.write(dim(' cancelled — back to the picker.\n'));
844
+ return null;
845
+ }
846
+ try { name = validateCustomProviderName(raw); }
847
+ catch (e) {
848
+ process.stdout.write(` \x1b[33m${e.message}\x1b[0m — try again.\n`);
849
+ continue;
850
+ }
851
+ // OpenAI-compat builtins (nim / openrouter / groq / …) can be overridden
852
+ // by a custom entry of the same name — both go through
853
+ // makeOpenAICompatProvider, so the wire format is identical and the
854
+ // user is just pointing the same alias at a different URL/key. Surface
855
+ // the override so it isn't a silent surprise.
856
+ if (typeof isBuiltinOpenAICompatName === 'function' && isBuiltinOpenAICompatName(name)) {
857
+ process.stdout.write(
858
+ ` \x1b[2mNote: "${name}" is a built-in OpenAI-compatible provider; ` +
859
+ `your custom entry will override the built-in baseUrl/api-key for this install. ` +
860
+ `Remove with: lazyclaw providers remove ${name}\x1b[0m\n`
861
+ );
862
+ }
863
+ break;
864
+ }
865
+ const baseUrlRaw = (await _quickPrompt(` ${bold('baseUrl')} ${dim('(must end in /v1, no trailing slash needed):')} `)).trim();
866
+ if (!baseUrlRaw) { process.stdout.write(dim(' cancelled — baseUrl is required.\n')); return null; }
867
+ if (!/^https?:\/\//i.test(baseUrlRaw)) {
868
+ process.stdout.write(' \x1b[33mbaseUrl must start with http:// or https://\x1b[0m — cancelled.\n');
869
+ return null;
870
+ }
871
+ const apiKey = (await _quickPrompt(` ${bold('api-key')} ${dim('(blank if the endpoint is auth-less, e.g. local vLLM):')} `)).trim();
872
+
873
+ // Persist + hot-register + best-effort probe via the shared, unit-tested
874
+ // core (providers/custom_provider.mjs) so this readline wizard and the Ink
875
+ // /provider add flow can't drift. The interactive prompts above already
876
+ // validated name + baseUrl; addCustomProvider re-validates harmlessly.
877
+ const result = await addCustomProvider({
878
+ registry: getRegistry(),
879
+ readConfig,
880
+ writeConfig,
881
+ name,
882
+ baseUrl: baseUrlRaw,
883
+ apiKey,
884
+ });
885
+ const entry = { name: result.name, baseUrl: result.baseUrl };
886
+ let probeMsg;
887
+ if (result.probe.ok && result.probe.count > 0) {
888
+ probeMsg = ` ${ok('✓')} reachable — ${result.probe.count} model(s) advertised at ${entry.baseUrl}/models\n`;
889
+ } else if (result.probe.ok) {
890
+ probeMsg = ` ${ok('✓')} registered — /v1/models returned no entries (will rely on free-text model id).\n`;
891
+ } else {
892
+ probeMsg = ` \x1b[33m!\x1b[0m registered, but /v1/models probe failed: ${result.probe.error}\n`;
893
+ }
894
+ process.stdout.write('\n');
895
+ process.stdout.write(` ${ok(bold('✓ custom provider saved:'))} ${entry.name} ${dim('→')} ${entry.baseUrl}\n`);
896
+ process.stdout.write(probeMsg);
897
+ process.stdout.write(dim(` Removable any time via: lazyclaw providers remove ${name}\n`));
898
+ await _quickPrompt(' press Enter to continue ');
899
+ return { name };
900
+ }
901
+ export async function _quickPrompt(label) {
902
+ const readline = await import('node:readline');
903
+ process.stdout.write('\n');
904
+ // Make sure stdin is in cooked / line-buffered mode for the
905
+ // duration of the prompt — a prior `_arrowMenu` may have left raw
906
+ // mode on, in which case readline.question() never fires its
907
+ // line-event because each byte is delivered as a keypress instead.
908
+ if (process.stdin.isTTY && process.stdin.setRawMode) {
909
+ try { process.stdin.setRawMode(false); } catch (_) {}
910
+ }
911
+ process.stdin.resume();
912
+ if (process.stdin.ref) process.stdin.ref();
913
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
914
+ const ans = await new Promise((resolve) => rl.question(label, resolve));
915
+ rl.close();
916
+ return ans.trim();
917
+ }