UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

155 lines 6.42 kB
/** * Shared interactive-prompt utilities. * * Every readline-based prompt in the CLI MUST go through these helpers so * the CLI can never hang indefinitely on a detached or unresponsive terminal. * All helpers apply a hard timeout (default 60s, overridable via * AIWG_PROMPT_TIMEOUT_MS) after which the prompt resolves to the supplied * fallback and a visible warning is emitted. * * The underlying setTimeout is `.unref()`'d so a user pressing Ctrl-C mid-prompt * does not keep the event loop alive for the remaining wait window. * * Phase 1 of the CLI Stabilization Epic (#918). Phase 3 (#920) will migrate * these helpers to @clack/prompts with AbortSignal support; this file is the * interim abstraction that makes both call-site cleanup and that future * migration mechanical. */ import readline from 'readline'; import * as ui from './ui.js'; /** * Resolve the prompt timeout from env. Falsy / non-numeric / non-positive * values fall back to 60 seconds. */ export function promptTimeoutMs() { const raw = process.env['AIWG_PROMPT_TIMEOUT_MS']; const n = raw ? parseInt(raw, 10) : NaN; return Number.isFinite(n) && n > 0 ? n : 60_000; } /** * Create a readline interface bound to process stdio. Callers must always * `rl.close()` when done. */ export function createPromptInterface() { return readline.createInterface({ input: process.stdin, output: process.stdout, }); } /** * Ask a single question with a hard timeout. On timeout, resolves to `fallback` * and emits a warn-level UI message describing what default was used. * * The timeout timer is `.unref()`'d so the event loop can exit cleanly when * the caller closes the readline interface (e.g. on Ctrl-C). * * Do NOT call this from multiple sites on the same readline interface * concurrently — each readline interface should own exactly one in-flight * prompt at a time. */ export function askWithTimeout(rl, prompt, parse, fallback, fallbackLabel, timeoutMs = promptTimeoutMs(), signal) { return new Promise((resolve, reject) => { let settled = false; const timer = setTimeout(() => { if (settled) return; settled = true; ui.warn(` No input within ${Math.round(timeoutMs / 1000)}s — using default: ${fallbackLabel}`); cleanup(); resolve(fallback); }, timeoutMs); // Ensure the timer does not block the event loop if the readline interface // is closed (e.g. user hits Ctrl-C). The callback only fires if the timer // is still live and the event loop has other work. timer.unref?.(); // Integrate with the HandlerContext.signal plumbed through in Phase 3 // (#920). When Ctrl-C aborts the top-level AbortController, the prompt // rejects cleanly, closes the readline, and clears the timeout — no // orphaned input reader, no pending timer. const onAbort = () => { if (settled) return; settled = true; cleanup(); try { rl.close(); } catch { /* already closed */ } reject(signal.reason ?? new Error('Aborted')); }; const cleanup = () => { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); }; if (signal?.aborted) { // Fire synchronously so the caller's try/catch sees the reject without // awaiting a tick. onAbort(); return; } signal?.addEventListener('abort', onAbort, { once: true }); rl.question(prompt, (answer) => { if (settled) return; settled = true; cleanup(); resolve(parse(answer)); }); }); } /** * Prompt for a trimmed string with timeout and default. Honors `signal` from * the HandlerContext (Phase 3 #920) so Ctrl-C cancels the prompt cleanly. */ export async function askString(rl, prompt, fallback = '', signal) { return askWithTimeout(rl, prompt, (a) => a.trim(), fallback, fallback || '(empty)', promptTimeoutMs(), signal); } /** * Prompt for a yes/no answer with timeout and default. Answers starting with * 'y' (case-insensitive) are considered yes; everything else is no. On * timeout, returns `defaultValue`. */ export async function askYesNo(rl, question, defaultValue = false, signal) { return askWithTimeout(rl, question, (a) => a.trim().toLowerCase().startsWith('y'), defaultValue, defaultValue ? 'yes' : 'no', promptTimeoutMs(), signal); } /** * Prompt for a numeric selection from a list. Returns the selected item, or * `fallback` on timeout / invalid input. If `fallback` is undefined, returns * `options[0]`. */ export async function askChoice(rl, prompt, options, fallback, signal) { const pick = fallback ?? options[0]; const label = fallback !== undefined ? String(fallback) : String(pick); return askWithTimeout(rl, prompt, (answer) => { const idx = parseInt(answer.trim(), 10) - 1; if (!isNaN(idx) && idx >= 0 && idx < options.length) return options[idx]; return pick; }, pick, label, promptTimeoutMs(), signal); } /** * Prompt for a labeled selection from a list of options. Renders each option * as a numbered line before asking. Users can respond by number (1-based) or * by matching the exact `label`. On timeout or invalid input, returns * `fallback`. * * This replaces the hand-rolled number-or-name logic sprinkled across * `init.ts` (provider selection) and `use.ts` (topology profile picker). * POC for the prompt-library spike (#926). */ export async function listSelect(rl, prompt, options, fallback, signal) { if (options.length === 0) return fallback; options.forEach((opt, i) => console.log(` ${i + 1}. ${opt.label}`)); return askWithTimeout(rl, prompt, (answer) => { const trimmed = answer.trim(); if (!trimmed) return fallback; const idx = parseInt(trimmed, 10) - 1; if (!isNaN(idx) && idx >= 0 && idx < options.length) return options[idx].value; const match = options.find(o => o.label === trimmed); return match?.value ?? fallback; }, fallback, String(fallback), promptTimeoutMs(), signal); } //# sourceMappingURL=prompt-utils.js.map