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
JavaScript
/**
* 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