dotdotdot-cli 1.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.
package/lib/llm.js ADDED
@@ -0,0 +1,471 @@
1
+ 'use strict';
2
+
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // llm.js — Multi-provider LLM integration with two prompt modes:
5
+ // 1) Quick mode — single command generation
6
+ // 2) Task mode — multi-step plan generation
7
+ // ─────────────────────────────────────────────────────────────────────────────
8
+
9
+ const https = require('https');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { PROVIDERS, DEBUG_DIR, ensureDirs } = require('./config');
13
+ const { getHistory } = require('./session');
14
+
15
+ // ─── Debug logging ──────────────────────────────────────────────────────────
16
+
17
+ function debugLog(provider, input, response) {
18
+ try {
19
+ ensureDirs();
20
+ const ts = new Date().toISOString().replace(/[:.]/g, '-');
21
+ const file = path.join(DEBUG_DIR, `${ts}.json`);
22
+ fs.writeFileSync(file, JSON.stringify({ provider, input, response, ts }, null, 2));
23
+
24
+ // Cleanup old logs (keep 20)
25
+ const logs = fs.readdirSync(DEBUG_DIR).sort();
26
+ while (logs.length > 20) {
27
+ fs.unlinkSync(path.join(DEBUG_DIR, logs.shift()));
28
+ }
29
+ return file;
30
+ } catch { return null; }
31
+ }
32
+
33
+ // ─── System Prompts ─────────────────────────────────────────────────────────
34
+
35
+ const IDENTITY = `You are dotdotdot (...), an LLM that lives in the terminal. You are an expert in shell commands across every OS and shell environment. You think in commands, not paragraphs. You are concise, precise, and never verbose. You always follow the rules below — no exceptions, no improvising outside the JSON format.`;
36
+
37
+ const QUICK_SYSTEM_PROMPT = `${IDENTITY}
38
+
39
+ Given a request and JSON context, output a single shell command.
40
+
41
+ Output ONLY valid JSON: {"command":"...","explanation":"...","warning":null}
42
+
43
+ Rules:
44
+ 1. "sh" is the execution shell. Commands run DIRECTLY in it. NEVER wrap with "powershell -Command", "bash -c", or "cmd /c". Write raw shell code only.
45
+ 2. Match the EXACT shell syntax. Check the [PowerShell]/[Bash]/[CMD] tag in "sh". Never mix syntaxes.
46
+ - PowerShell: use foreach, Where-Object, Select-Object, Test-Connection, Invoke-RestMethod. NOT for/in, grep, ping, curl (use curl.exe if needed).
47
+ - Bash: use for/in, grep, ping, curl. NOT PowerShell cmdlets.
48
+ - CMD: use for /f, ping, findstr. NOT PowerShell or Bash syntax.
49
+ 3. "os" tells you the platform. Bash on Windows (Git Bash/MINGW) uses Windows executables: use "ping -n" not "ping -c", "ipconfig" not "hostname -I", "curl.exe" not "curl" if conflicts exist.
50
+ 4. Only use tools confirmed in the "tools" array.
51
+ 5. Set "warning" to a non-null string for destructive commands (delete, format, kill, overwrite, chmod 777).
52
+ 6. For questions or info requests: if answerable from context, use echo/Write-Host to display the answer. Do NOT run a command that will fail.
53
+ 7. Use "hist" (session history) for follow-ups like "do the same for X" or "undo that".
54
+ 8. Never reference files not in "dir" unless the user specifies a path.
55
+ 9. Prefer concise one-liners. Use shell-appropriate syntax for pipes and chaining.
56
+ 10. If "no_git" is true, do NOT suggest git commands. Instead, output a command that uses echo/Write-Host to tell the user this is not a git repo. You MUST still output valid JSON format.
57
+ 11. If history shows a failed command, learn from the error. Never repeat the same failing command — suggest a fix or explain why it failed.
58
+
59
+ IMPORTANT: ALWAYS output valid JSON regardless of the situation. Never output plain text, markdown, or explanations outside of JSON. Even for errors, wrap the message in the JSON format.
60
+
61
+ NEVER do these:
62
+ - Write to files (no Out-File, Export-Csv, > file.txt, tee, etc.) unless the user explicitly asks to save to a file.
63
+ - Use emojis or unicode symbols in commands. Terminals may not render them.
64
+ - Use Linux-only commands on Windows (hostname -I, ifconfig, etc.) even in Bash — Git Bash on Windows still runs Windows executables for networking.
65
+ - Use associative arrays (declare -A) or complex bash features that may fail in minimal shells.
66
+ - Use ping -c on Windows or ping -n on Linux.`;
67
+
68
+ const TASK_SYSTEM_PROMPT = `${IDENTITY}
69
+
70
+ Break a complex request into 2-5 sequential shell commands.
71
+
72
+ Output ONLY valid JSON: {"steps":[{"description":"...","command":"...","risk":"low|medium|high","needsApproval":true|false,"captureOutput":true|false}],"summary":"one sentence"}
73
+
74
+ Rules:
75
+ 1. "sh" is the execution shell. Commands run DIRECTLY in it. NEVER wrap with "powershell -Command", "bash -c", or "cmd /c". Write raw shell code only.
76
+ 2. Match the EXACT shell syntax. Check the [PowerShell]/[Bash]/[CMD] tag in "sh". Never mix syntaxes.
77
+ - PowerShell: use foreach, Where-Object, Select-Object, Test-Connection, Invoke-RestMethod. NOT for/in, grep, ping, curl (use curl.exe if needed).
78
+ - Bash: use for/in, grep, ping, curl. NOT PowerShell cmdlets.
79
+ - CMD: use for /f, ping, findstr. NOT PowerShell or Bash syntax.
80
+ 3. "os" tells you the platform. Bash on Windows (Git Bash/MINGW) uses Windows executables: use "ping -n" not "ping -c", "ipconfig" not "hostname -I".
81
+ 4. CRITICAL: Each step runs in a SEPARATE process. Variables and state do NOT carry over. If step 2 needs data from step 1, COMBINE them into ONE step.
82
+ 5. Keep steps to 2-5. Prefer FEWER, self-contained steps over many dependent ones. Descriptions must be under 10 words.
83
+ 6. "needsApproval": true ONLY for steps that delete, move, or overwrite files.
84
+ 7. "captureOutput": true ONLY if a later step depends on this step's output. The LAST step must ALWAYS have "captureOutput": false so the user sees the result.
85
+ 8. "risk": "low" = read-only, "medium" = creates/modifies, "high" = deletes/destroys.
86
+ 9. Only use tools confirmed in the "tools" array.
87
+ 10. For "propose" or "suggest" tasks: output a clear list/table using real data from context. Do NOT hardcode.
88
+ 11. Never reference paths not in "dir" unless the user specifies them.
89
+ 12. If "no_git" is true, do NOT plan git commands. Instead, create a single step that uses echo/Write-Host to tell the user this is not a git repo. You MUST still output valid JSON format.
90
+ 13. If history shows a failed command, do not repeat it. Fix or work around the failure.
91
+
92
+ IMPORTANT: ALWAYS output valid JSON regardless of the situation. Never output plain text, markdown, or explanations outside of JSON. Even for errors or refusals, wrap the message in the JSON format with an echo/Write-Host step.
93
+
94
+ NEVER do these:
95
+ - Write to files (no Out-File, Export-Csv, > file.txt, tee, /tmp/anything) unless the user explicitly asks to save. All output goes to the terminal.
96
+ - Use emojis or unicode symbols in commands. Terminals may not render them.
97
+ - Use Linux-only commands on Windows (hostname -I, ifconfig, etc.) even in Bash — Git Bash on Windows still runs Windows executables for networking.
98
+ - Use associative arrays (declare -A) or complex bash features that may fail in minimal shells.
99
+ - Share variables between steps — each step is isolated. Combine dependent operations into one step.
100
+ - Use ping -c on Windows or ping -n on Linux.`;
101
+
102
+ // ─── Build user message ─────────────────────────────────────────────────────
103
+
104
+ function buildUserMessage(userInput, context, mode = 'quick') {
105
+ const { system, shell, cwd, dirListing, gitInfo, tools, projectInfo, environment } = context;
106
+
107
+ // Build compact JSON context
108
+ // Shell type tag helps LLMs distinguish syntax (PowerShell foreach vs bash for)
109
+ const shellTag = shell.isPowerShell ? ' [PowerShell]' : shell.isCmd ? ' [CMD]' : shell.isBash ? ' [Bash]' : '';
110
+ // OS tag: critical for "Bash on Windows" (Git Bash) which uses Windows networking tools
111
+ const osTag = system.platform === 'win32' ? 'windows' : system.platform === 'darwin' ? 'macos' : 'linux';
112
+ const ctx = {
113
+ req: userInput,
114
+ os: osTag,
115
+ sh: shell.name + (shell.version ? ' ' + shell.version.split('\n')[0].replace(/^.*?(\d+\.\d+[\.\d]*).*$/, '$1') : '') + shellTag,
116
+ cwd,
117
+ };
118
+
119
+ // Session history (compact)
120
+ const history = getHistory();
121
+ if (history) ctx.hist = history;
122
+
123
+ // Dir listing (compact — just names, limit 30)
124
+ if (dirListing) ctx.dir = dirListing;
125
+
126
+ // Project info (compact)
127
+ if (projectInfo?.name) {
128
+ ctx.proj = projectInfo.name;
129
+ if (projectInfo.type) ctx.proj_type = projectInfo.type;
130
+ if (projectInfo.scripts?.length) ctx.scripts = projectInfo.scripts.slice(0, 10);
131
+ if (projectInfo.deps?.deps?.length) ctx.deps = projectInfo.deps.deps.slice(0, 10);
132
+ if (projectInfo.files?.length) ctx.proj_files = projectInfo.files;
133
+ }
134
+
135
+ // Git (compact)
136
+ if (gitInfo) {
137
+ ctx.git = { br: gitInfo.branch, dirty: gitInfo.isDirty };
138
+ if (gitInfo.status) ctx.git.st = gitInfo.status;
139
+ } else {
140
+ ctx.no_git = true;
141
+ }
142
+
143
+ // Tools (just names — versions already known to be installed)
144
+ if (tools?.length) ctx.tools = tools.map(t => t.name);
145
+
146
+ // Environment flags (only if present)
147
+ if (environment?.virtualEnv) ctx.venv = environment.virtualEnv;
148
+ if (environment?.isWSL) ctx.wsl = true;
149
+ if (environment?.isDocker) ctx.docker = true;
150
+
151
+ return JSON.stringify(ctx);
152
+ }
153
+
154
+ // ─── JSON extraction ────────────────────────────────────────────────────────
155
+
156
+ function extractJSON(text) {
157
+ // Strip markdown code fences
158
+ let cleaned = text.replace(/```json?\s*/gi, '').replace(/```/g, '').trim();
159
+
160
+ // Find first { and match balanced braces
161
+ const start = cleaned.indexOf('{');
162
+ if (start === -1) return null;
163
+
164
+ let depth = 0;
165
+ for (let i = start; i < cleaned.length; i++) {
166
+ if (cleaned[i] === '{') depth++;
167
+ else if (cleaned[i] === '}') {
168
+ depth--;
169
+ if (depth === 0) {
170
+ try {
171
+ return JSON.parse(cleaned.slice(start, i + 1));
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+ }
177
+ }
178
+ return null;
179
+ }
180
+
181
+ // ─── Provider-specific request builders ─────────────────────────────────────
182
+
183
+ function buildAnthropicRequest(systemPrompt, userMessage, config, maxTokens = 512) {
184
+ const url = new URL(config.apiUrl);
185
+ const body = JSON.stringify({
186
+ model: config.model,
187
+ max_tokens: maxTokens,
188
+ system: systemPrompt,
189
+ messages: [{ role: 'user', content: userMessage }],
190
+ });
191
+
192
+ return {
193
+ body,
194
+ options: {
195
+ hostname: url.hostname,
196
+ port: 443,
197
+ path: url.pathname,
198
+ method: 'POST',
199
+ headers: {
200
+ 'Content-Type': 'application/json',
201
+ 'x-api-key': config.apiKey,
202
+ 'anthropic-version': '2023-06-01',
203
+ 'Content-Length': Buffer.byteLength(body),
204
+ },
205
+ },
206
+ parseResponse: (data) => {
207
+ const json = JSON.parse(data);
208
+ const text = json.content?.[0]?.text || '';
209
+ const usage = json.usage ? {
210
+ inputTokens: json.usage.input_tokens || 0,
211
+ outputTokens: json.usage.output_tokens || 0,
212
+ totalTokens: (json.usage.input_tokens || 0) + (json.usage.output_tokens || 0),
213
+ } : null;
214
+ return { text, usage };
215
+ },
216
+ };
217
+ }
218
+
219
+ function buildOpenAIRequest(systemPrompt, userMessage, config, maxTokens = 512) {
220
+ const url = new URL(config.apiUrl);
221
+ const body = JSON.stringify({
222
+ model: config.model,
223
+ max_tokens: maxTokens,
224
+ messages: [
225
+ { role: 'system', content: systemPrompt },
226
+ { role: 'user', content: userMessage },
227
+ ],
228
+ temperature: 0.1,
229
+ });
230
+
231
+ return {
232
+ body,
233
+ options: {
234
+ hostname: url.hostname,
235
+ port: 443,
236
+ path: url.pathname,
237
+ method: 'POST',
238
+ headers: {
239
+ 'Content-Type': 'application/json',
240
+ 'Authorization': `Bearer ${config.apiKey}`,
241
+ 'Content-Length': Buffer.byteLength(body),
242
+ },
243
+ },
244
+ parseResponse: (data) => {
245
+ const json = JSON.parse(data);
246
+ const text = json.choices?.[0]?.message?.content || '';
247
+ const usage = json.usage ? {
248
+ inputTokens: json.usage.prompt_tokens || 0,
249
+ outputTokens: json.usage.completion_tokens || 0,
250
+ totalTokens: json.usage.total_tokens || (json.usage.prompt_tokens || 0) + (json.usage.completion_tokens || 0),
251
+ } : null;
252
+ return { text, usage };
253
+ },
254
+ };
255
+ }
256
+
257
+ function buildOpenRouterRequest(systemPrompt, userMessage, config, maxTokens = 512) {
258
+ const url = new URL(config.apiUrl);
259
+ const body = JSON.stringify({
260
+ model: config.model,
261
+ max_tokens: maxTokens,
262
+ messages: [
263
+ { role: 'system', content: systemPrompt },
264
+ { role: 'user', content: userMessage },
265
+ ],
266
+ temperature: 0.1,
267
+ });
268
+
269
+ return {
270
+ body,
271
+ options: {
272
+ hostname: url.hostname,
273
+ port: 443,
274
+ path: url.pathname,
275
+ method: 'POST',
276
+ headers: {
277
+ 'Content-Type': 'application/json',
278
+ 'Authorization': `Bearer ${config.apiKey}`,
279
+ 'HTTP-Referer': 'https://github.com/Shell3Dots/dotdotdot',
280
+ 'X-Title': 'dotdotdot',
281
+ 'Content-Length': Buffer.byteLength(body),
282
+ },
283
+ },
284
+ parseResponse: (data) => {
285
+ const json = JSON.parse(data);
286
+ const text = json.choices?.[0]?.message?.content || '';
287
+ const usage = json.usage ? {
288
+ inputTokens: json.usage.prompt_tokens || 0,
289
+ outputTokens: json.usage.completion_tokens || 0,
290
+ totalTokens: json.usage.total_tokens || (json.usage.prompt_tokens || 0) + (json.usage.completion_tokens || 0),
291
+ } : null;
292
+ return { text, usage };
293
+ },
294
+ };
295
+ }
296
+
297
+ function buildGoogleRequest(systemPrompt, userMessage, config, maxTokens = 512) {
298
+ const url = new URL(`${config.apiUrl}/${config.model}:generateContent?key=${config.apiKey}`);
299
+ const body = JSON.stringify({
300
+ system_instruction: { parts: [{ text: systemPrompt }] },
301
+ contents: [{ parts: [{ text: userMessage }] }],
302
+ generationConfig: { maxOutputTokens: maxTokens, temperature: 0.1 },
303
+ });
304
+
305
+ return {
306
+ body,
307
+ options: {
308
+ hostname: url.hostname,
309
+ port: 443,
310
+ path: url.pathname + url.search,
311
+ method: 'POST',
312
+ headers: {
313
+ 'Content-Type': 'application/json',
314
+ 'Content-Length': Buffer.byteLength(body),
315
+ },
316
+ },
317
+ parseResponse: (data) => {
318
+ const json = JSON.parse(data);
319
+ const text = json.candidates?.[0]?.content?.parts?.[0]?.text || '';
320
+ const meta = json.usageMetadata;
321
+ const usage = meta ? {
322
+ inputTokens: meta.promptTokenCount || 0,
323
+ outputTokens: meta.candidatesTokenCount || 0,
324
+ totalTokens: meta.totalTokenCount || (meta.promptTokenCount || 0) + (meta.candidatesTokenCount || 0),
325
+ } : null;
326
+ return { text, usage };
327
+ },
328
+ };
329
+ }
330
+
331
+ // ─── Friendly error messages ────────────────────────────────────────────────
332
+
333
+ function friendlyError(statusCode, provider) {
334
+ const providerName = PROVIDERS[provider]?.name || provider;
335
+ switch (statusCode) {
336
+ case 401: case 403:
337
+ return `Invalid API key for ${providerName}. Run: ... -c`;
338
+ case 429:
339
+ return `Rate limited by ${providerName}. Wait a moment and try again.`;
340
+ case 404:
341
+ return `Model not found on ${providerName}. Check your model setting.`;
342
+ case 500: case 502: case 503:
343
+ return `${providerName} is having issues. Try again in a moment.`;
344
+ default:
345
+ return `${providerName} returned HTTP ${statusCode}.`;
346
+ }
347
+ }
348
+
349
+ // ─── HTTP request helper ────────────────────────────────────────────────────
350
+
351
+ function makeRequest(options, body, timeout = 30000) {
352
+ return new Promise((resolve, reject) => {
353
+ const req = https.request(options, (res) => {
354
+ let data = '';
355
+ res.on('data', (chunk) => { data += chunk; });
356
+ res.on('end', () => resolve({ statusCode: res.statusCode, data }));
357
+ });
358
+
359
+ req.on('error', (err) => reject(err));
360
+ req.setTimeout(timeout, () => {
361
+ req.destroy();
362
+ reject(new Error('Request timed out'));
363
+ });
364
+
365
+ req.write(body);
366
+ req.end();
367
+ });
368
+ }
369
+
370
+ // ─── Main query function ────────────────────────────────────────────────────
371
+
372
+ async function queryLLM(userInput, context, config, mode = 'quick') {
373
+ const systemPrompt = mode === 'task' ? TASK_SYSTEM_PROMPT : QUICK_SYSTEM_PROMPT;
374
+ const userMessage = buildUserMessage(userInput, context, mode);
375
+ const provider = config.provider;
376
+ const maxTokens = mode === 'task' ? 1024 : 512;
377
+
378
+ // Select builder (custom uses OpenAI-compatible format)
379
+ let builder;
380
+ switch (provider) {
381
+ case 'anthropic': builder = buildAnthropicRequest; break;
382
+ case 'openai': builder = buildOpenAIRequest; break;
383
+ case 'openrouter': builder = buildOpenRouterRequest; break;
384
+ case 'google': builder = buildGoogleRequest; break;
385
+ case 'custom': builder = buildOpenAIRequest; break;
386
+ default:
387
+ throw new Error(`Unknown provider: ${provider}. Run: ... -c`);
388
+ }
389
+
390
+ const { body, options, parseResponse } = builder(systemPrompt, userMessage, config, maxTokens);
391
+
392
+ try {
393
+ const { statusCode, data } = await makeRequest(options, body);
394
+
395
+ if (statusCode !== 200) {
396
+ debugLog(provider, userMessage, { statusCode, data });
397
+ throw new Error(friendlyError(statusCode, provider));
398
+ }
399
+
400
+ const { text, usage } = parseResponse(data);
401
+ const debugFile = debugLog(provider, userMessage, { text, usage });
402
+
403
+ const result = extractJSON(text);
404
+ if (!result) {
405
+ const err = new Error('Failed to parse LLM response. The model may need a different prompt format.');
406
+ err.debugLog = debugFile;
407
+ throw err;
408
+ }
409
+
410
+ // Estimate input tokens from message size if not provided by API
411
+ const estimatedInput = Math.ceil((systemPrompt.length + userMessage.length) / 4);
412
+
413
+ // Attach token usage to result
414
+ result._tokenUsage = usage || {
415
+ inputTokens: estimatedInput,
416
+ outputTokens: Math.ceil(text.length / 4),
417
+ totalTokens: estimatedInput + Math.ceil(text.length / 4),
418
+ estimated: true,
419
+ };
420
+
421
+ // Attach debug log path
422
+ if (debugFile) result._debugLog = debugFile;
423
+
424
+ return result;
425
+ } catch (err) {
426
+ if (err.code === 'ENOTFOUND') {
427
+ throw new Error('Network error. Check your internet connection.');
428
+ }
429
+ if (err.message === 'Request timed out') {
430
+ throw new Error('Request timed out. The LLM provider may be slow. Try again.');
431
+ }
432
+ throw err;
433
+ }
434
+ }
435
+
436
+ // ─── Detect if input needs task mode ────────────────────────────────────────
437
+
438
+ function detectMode(input) {
439
+ const lower = input.toLowerCase();
440
+
441
+ // Task mode indicators: multiple verbs, "then", "and then", commas separating actions,
442
+ // words like "organize", "deploy", "setup", "migrate", "refactor"
443
+ const taskPatterns = [
444
+ /\bthen\b/,
445
+ /\band\s+(then\s+)?(?:run|start|serve|deploy|move|copy|delete|create|build|install|open|read|write|find|list|locate|organize|setup|migrate|refactor)\b/i,
446
+ /,\s*(then\s+)?(?:run|start|serve|deploy|move|copy|delete|create|build|install|open|read|write|find|list|locate|organize|setup|migrate|refactor)\b/i,
447
+ /\bstep\s*\d/i,
448
+ /\bfirst\b.*\bthen\b/i,
449
+ /\blocate\b.*\bread\b/i,
450
+ /\bfind\b.*\b(delete|remove|move|copy|organize)\b/i,
451
+ /\bpropose\b/i,
452
+ /\breorganize\b/i,
453
+ /\bsetup\b.*\b(and|then|,)\b/i,
454
+ /\bdeploy\b.*\b(to|at|on|via)\b/i,
455
+ ];
456
+
457
+ for (const pattern of taskPatterns) {
458
+ if (pattern.test(lower)) return 'task';
459
+ }
460
+
461
+ return 'quick';
462
+ }
463
+
464
+ module.exports = {
465
+ queryLLM,
466
+ buildUserMessage,
467
+ extractJSON,
468
+ detectMode,
469
+ QUICK_SYSTEM_PROMPT,
470
+ TASK_SYSTEM_PROMPT,
471
+ };
package/lib/menu.js ADDED
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+
3
+ // ─────────────────────────────────────────────────────────────────────────────
4
+ // menu.js — Compact keyboard-driven menus
5
+ // ─────────────────────────────────────────────────────────────────────────────
6
+
7
+ const { bold, dim, cyan, gray, green, yellow, brightWhite, symbols, c256 } = require('./colors');
8
+
9
+ const accent = c256(39);
10
+ const subtle = c256(240);
11
+
12
+ // ─── Select menu ────────────────────────────────────────────────────────────
13
+
14
+ function selectMenu(options) {
15
+ return new Promise((resolve) => {
16
+ if (!process.stdin.isTTY) {
17
+ const first = options.find(o => !o.disabled);
18
+ return resolve(first ? first.key : null);
19
+ }
20
+
21
+ let sel = options.findIndex(o => !o.disabled);
22
+ if (sel === -1) sel = 0;
23
+
24
+ const draw = () => options.map((o, i) => {
25
+ const active = i === sel;
26
+ const pre = active ? accent('\u276F') : ' ';
27
+ const key = subtle(o.key);
28
+ if (o.disabled) return ` ${pre} ${subtle(o.label)} ${subtle('blocked')}`;
29
+ return active ? ` ${pre} ${bold(brightWhite(o.label))} ${key}` : ` ${pre} ${subtle(o.label)} ${key}`;
30
+ }).join('\n');
31
+
32
+ process.stdout.write('\n' + draw() + '\n');
33
+ const lc = options.length + 1;
34
+
35
+ process.stdin.setRawMode(true);
36
+ process.stdin.resume();
37
+
38
+ const done = () => {
39
+ try { process.stdin.setRawMode(false); } catch { /* ignore */ }
40
+ process.stdin.pause();
41
+ process.stdin.removeListener('data', onKey);
42
+ };
43
+ const redraw = () => { process.stdout.write(`\x1b[${lc}A\x1b[0J\n${draw()}\n`); };
44
+
45
+ const onKey = (buf) => {
46
+ const k = buf.toString();
47
+ if (k === '\x03') { done(); process.stdout.write('\n'); return resolve(null); }
48
+
49
+ if (k === '\r' || k === '\n') {
50
+ const o = options[sel];
51
+ if (o && !o.disabled) { done(); process.stdout.write(`\x1b[${lc}A\x1b[0J ${green(symbols.check)} ${subtle(o.label)}\n`); return resolve(o.key); }
52
+ return;
53
+ }
54
+
55
+ if (k === '\x1b[A' || k === 'k') { let n = sel - 1; while (n >= 0 && options[n].disabled) n--; if (n >= 0) { sel = n; redraw(); } }
56
+ else if (k === '\x1b[B' || k === 'j') { let n = sel + 1; while (n < options.length && options[n].disabled) n++; if (n < options.length) { sel = n; redraw(); } }
57
+
58
+ const sc = options.find(o => o.key === k && !o.disabled);
59
+ if (sc) { done(); process.stdout.write(`\x1b[${lc}A\x1b[0J ${green(symbols.check)} ${subtle(sc.label)}\n`); return resolve(sc.key); }
60
+ };
61
+
62
+ process.stdin.on('data', onKey);
63
+ });
64
+ }
65
+
66
+ // ─── Confirm ────────────────────────────────────────────────────────────────
67
+
68
+ function confirm(message, defaultYes = false) {
69
+ return new Promise((resolve) => {
70
+ if (!process.stdin.isTTY) return resolve(defaultYes);
71
+ const hint = defaultYes ? 'Y/n' : 'y/N';
72
+ process.stdout.write(` ${accent('\u276F')} ${message} ${subtle(hint)} `);
73
+ process.stdin.setRawMode(true);
74
+ process.stdin.resume();
75
+ const onKey = (buf) => {
76
+ const k = buf.toString().toLowerCase();
77
+ try { process.stdin.setRawMode(false); } catch { /* ignore */ }
78
+ process.stdin.pause(); process.stdin.removeListener('data', onKey);
79
+ if (k === '\x03') { process.stdout.write('\n'); return resolve(false); }
80
+ if (k === '\r' || k === '\n') { process.stdout.write(defaultYes ? 'y\n' : 'n\n'); return resolve(defaultYes); }
81
+ process.stdout.write(k === 'y' ? 'y\n' : 'n\n');
82
+ resolve(k === 'y');
83
+ };
84
+ process.stdin.on('data', onKey);
85
+ });
86
+ }
87
+
88
+ // ─── Text input ─────────────────────────────────────────────────────────────
89
+
90
+ function textInput(message, defaultValue = '', opts = {}) {
91
+ const readline = require('readline');
92
+ const displayDefault = opts.displayDefault !== undefined ? opts.displayDefault : defaultValue;
93
+ return new Promise((resolve) => {
94
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
95
+ const prompt = ` ${accent('\u276F')} ${message}${displayDefault ? subtle(` (${displayDefault})`) : ''}: `;
96
+ rl.question(prompt, (ans) => { rl.close(); resolve(ans.trim() || defaultValue); });
97
+ });
98
+ }
99
+
100
+ module.exports = { selectMenu, confirm, textInput };