@aisa-one/cli 0.2.4 → 0.5.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 (102) hide show
  1. package/CHANGELOG.md +172 -1
  2. package/LICENSE +21 -0
  3. package/README.md +163 -169
  4. package/dist/api.d.ts +10 -0
  5. package/dist/api.js +14 -3
  6. package/dist/catalog.d.ts +1 -18
  7. package/dist/catalog.js +1 -24
  8. package/dist/cli-error.d.ts +17 -0
  9. package/dist/cli-error.js +25 -0
  10. package/dist/cli-guidance.d.ts +14 -0
  11. package/dist/cli-guidance.js +55 -0
  12. package/dist/commands/account.d.ts +17 -0
  13. package/dist/commands/account.js +45 -1
  14. package/dist/commands/api.d.ts +0 -11
  15. package/dist/commands/api.js +6 -175
  16. package/dist/commands/auth.d.ts +2 -1
  17. package/dist/commands/auth.js +43 -9
  18. package/dist/commands/brand-logos.d.ts +6 -0
  19. package/dist/commands/brand-logos.js +21 -0
  20. package/dist/commands/configCmd.js +8 -3
  21. package/dist/commands/connect-shared.d.ts +136 -0
  22. package/dist/commands/connect-shared.js +143 -0
  23. package/dist/commands/connect-t2.d.ts +18 -0
  24. package/dist/commands/connect-t2.js +1599 -0
  25. package/dist/commands/connect-terminal.d.ts +65 -0
  26. package/dist/commands/connect-terminal.js +822 -0
  27. package/dist/commands/connect.d.ts +31 -0
  28. package/dist/commands/connect.js +3181 -0
  29. package/dist/commands/flow.d.ts +685 -0
  30. package/dist/commands/flow.js +697 -0
  31. package/dist/commands/install.d.ts +91 -0
  32. package/dist/commands/install.js +258 -0
  33. package/dist/commands/llm-config.d.ts +103 -0
  34. package/dist/commands/llm-config.js +452 -0
  35. package/dist/commands/manifest.d.ts +68 -0
  36. package/dist/commands/manifest.js +90 -0
  37. package/dist/commands/mcp.d.ts +79 -2
  38. package/dist/commands/mcp.js +262 -48
  39. package/dist/commands/oauth-login.d.ts +33 -0
  40. package/dist/commands/oauth-login.js +333 -0
  41. package/dist/commands/prompt.d.ts +96 -0
  42. package/dist/commands/prompt.js +397 -0
  43. package/dist/commands/serve-results.d.ts +61 -0
  44. package/dist/commands/serve-results.js +245 -0
  45. package/dist/commands/serve-signin.d.ts +28 -0
  46. package/dist/commands/serve-signin.js +90 -0
  47. package/dist/commands/signin-page.d.ts +49 -0
  48. package/dist/commands/signin-page.js +250 -0
  49. package/dist/commands/skills.js +87 -50
  50. package/dist/commands/tool-help.d.ts +69 -0
  51. package/dist/commands/tool-help.js +189 -0
  52. package/dist/commands/tool-input.d.ts +22 -0
  53. package/dist/commands/tool-input.js +220 -0
  54. package/dist/commands/tools.d.ts +12 -0
  55. package/dist/commands/tools.js +307 -0
  56. package/dist/commands/update.d.ts +11 -0
  57. package/dist/commands/update.js +92 -0
  58. package/dist/commands/vscode.d.ts +65 -0
  59. package/dist/commands/vscode.js +173 -0
  60. package/dist/commands/wrappers.d.ts +23 -0
  61. package/dist/commands/wrappers.js +102 -0
  62. package/dist/completion.js +3 -80
  63. package/dist/config.js +76 -2
  64. package/dist/constants.d.ts +78 -38
  65. package/dist/constants.js +80 -102
  66. package/dist/index.js +155 -357
  67. package/dist/json-text.d.ts +12 -0
  68. package/dist/json-text.js +60 -0
  69. package/dist/router.d.ts +39 -0
  70. package/dist/router.js +59 -0
  71. package/dist/skills-registry.js +12 -3
  72. package/dist/utils/banner.d.ts +9 -0
  73. package/dist/utils/banner.js +41 -0
  74. package/dist/utils/browser.d.ts +19 -0
  75. package/dist/utils/browser.js +31 -0
  76. package/dist/utils/exec.d.ts +43 -0
  77. package/dist/utils/exec.js +61 -0
  78. package/dist/utils/http.d.ts +53 -0
  79. package/dist/utils/http.js +124 -0
  80. package/dist/utils/install-method.d.ts +36 -0
  81. package/dist/utils/install-method.js +48 -0
  82. package/dist/utils/journal.d.ts +57 -0
  83. package/dist/utils/journal.js +188 -0
  84. package/dist/utils/key-check.d.ts +20 -0
  85. package/dist/utils/key-check.js +24 -0
  86. package/dist/utils/update-check.d.ts +23 -0
  87. package/dist/utils/update-check.js +116 -0
  88. package/dist/utils/width.d.ts +32 -0
  89. package/dist/utils/width.js +73 -0
  90. package/package.json +8 -4
  91. package/vscode-extension/README.md +6 -0
  92. package/vscode-extension/aisa-vscode.vsix +0 -0
  93. package/dist/commands/finance.d.ts +0 -17
  94. package/dist/commands/finance.js +0 -342
  95. package/dist/commands/run.d.ts +0 -28
  96. package/dist/commands/run.js +0 -182
  97. package/dist/commands/search.d.ts +0 -9
  98. package/dist/commands/search.js +0 -81
  99. package/dist/commands/twitter.d.ts +0 -138
  100. package/dist/commands/twitter.js +0 -374
  101. package/dist/commands/video.d.ts +0 -49
  102. package/dist/commands/video.js +0 -311
@@ -0,0 +1,333 @@
1
+ import chalk from "chalk";
2
+ import { run } from "../utils/exec.js";
3
+ import { createHash, randomBytes } from "node:crypto";
4
+ import { createServer } from "node:http";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { error, hint, info, success } from "../utils/display.js";
7
+ import { setApiKey } from "../config.js";
8
+ import { maskKey } from "../config.js";
9
+ import { httpFetch } from "../utils/http.js";
10
+ import { canOpenBrowser } from "../utils/browser.js";
11
+ import { SIGNIN, t } from "./flow.js";
12
+ import { renderSignInPage } from "./signin-page.js";
13
+ import { handOverSignInPage, SIGNIN_PAGE_TTL_MS } from "./serve-signin.js";
14
+ /**
15
+ * `aisa login` without a key: sign in once in a browser, come back with the
16
+ * CLI's long-lived key.
17
+ *
18
+ * The flow is the standard one every CLI converges on (gh, flyctl, claude):
19
+ *
20
+ * 1. register a public OAuth client (Clerk supports dynamic registration)
21
+ * 2. authorization-code + PKCE, redirecting to a loopback port
22
+ * 3. exchange the code for an access token
23
+ * 4. trade that token for the durable "aisa cli" key at /v1/keys/mint,
24
+ * and store the key — the token itself is then dropped. One secret on
25
+ * disk, and it is the one that does not expire in a day.
26
+ *
27
+ * A machine with no browser of its own takes the paste-back variant, and it
28
+ * is chosen for the user rather than asked for: the URL is printed, the user
29
+ * authorizes wherever they are sitting, the redirect to 127.0.0.1 fails to
30
+ * load there — and that is fine, because the code is in the URL, which they
31
+ * paste back here. PKCE needs the verifier, not a reachable callback, and
32
+ * the verifier never left this machine. What travels through the clipboard
33
+ * is a one-time code, not a key.
34
+ */
35
+ const AUTH_SERVER = "https://clerk.aisa.one";
36
+ /**
37
+ * Where the sign-in lands when the browser is on a different machine.
38
+ *
39
+ * A page that does one thing: show the one-time code so it can be carried
40
+ * back by hand. The loopback redirect below is better whenever it can be
41
+ * used — the CLI catches the code itself and nobody copies anything — but it
42
+ * only works when the browser and this process share a machine.
43
+ *
44
+ * This address is a published contract. Every released CLI that names it
45
+ * keeps sending users there, so it is not to be changed; a new one would
46
+ * have to be added alongside.
47
+ */
48
+ const HOSTED_REDIRECT = "https://aisa.one/cli/auth";
49
+ const MINT_URL = "https://api.aisa.one/v1/keys/mint";
50
+ const b64url = (buf) => buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
51
+ async function registerClient(redirectUri) {
52
+ const res = await httpFetch(`${AUTH_SERVER}/oauth/register`, {
53
+ method: "POST",
54
+ headers: { "Content-Type": "application/json" },
55
+ body: JSON.stringify({
56
+ client_name: "AIsa CLI",
57
+ redirect_uris: [redirectUri],
58
+ grant_types: ["authorization_code"],
59
+ response_types: ["code"],
60
+ token_endpoint_auth_method: "none",
61
+ scope: "openid profile email",
62
+ }),
63
+ signal: AbortSignal.timeout(15_000),
64
+ });
65
+ const body = (await res.json());
66
+ if (!body.client_id)
67
+ throw new Error(`client registration failed (HTTP ${res.status})`);
68
+ return body.client_id;
69
+ }
70
+ /** Wait for the authorization code on the loopback port. */
71
+ /**
72
+ * How long to hold a loopback callback open before giving up on it.
73
+ *
74
+ * Five minutes looked generous until a sign-in took longer than that and the
75
+ * approval landed on a closed port: the browser said it could not connect,
76
+ * which reads as "I broke something" rather than "that took too long". A
77
+ * person who is signing in normally is done inside a minute; the ones who are
78
+ * not are switching machines, reading the consent screen properly, or being
79
+ * interrupted. Fifteen minutes costs nothing and covers all of them.
80
+ */
81
+ const CALLBACK_TIMEOUT_MS = 15 * 60 * 1000;
82
+ /**
83
+ * And once it does expire, keep answering for a little longer.
84
+ *
85
+ * The wait is over and the terminal has said so, but the socket costs nothing
86
+ * to hold and an approval arriving late deserves a sentence rather than a
87
+ * connection error. It stops the process from lingering more than this.
88
+ */
89
+ const AFTER_TIMEOUT_GRACE_MS = 90 * 1000;
90
+ function waitForCallback(port, expectedState, lang) {
91
+ let expired = false;
92
+ return new Promise((resolve, reject) => {
93
+ // A browser we opened may never come back — it was never opened at all,
94
+ // the tab was closed, the machine has no screen. Waiting for that with
95
+ // no deadline is a hang, and a hang is the one failure that tells the
96
+ // user nothing.
97
+ const giveUp = setTimeout(() => {
98
+ // Deliberately not closing the server: an approval that arrives now has
99
+ // to land somewhere that can explain itself. It closes on its own a
100
+ // little later, and the process does not outlive it.
101
+ expired = true;
102
+ const close = setTimeout(() => {
103
+ srv.close();
104
+ process.exit(1);
105
+ }, AFTER_TIMEOUT_GRACE_MS);
106
+ close.unref?.();
107
+ reject(new Error(`no response from the browser after ${CALLBACK_TIMEOUT_MS / 60_000} minutes — run 'aisa login' again`));
108
+ }, CALLBACK_TIMEOUT_MS);
109
+ giveUp.unref?.();
110
+ const srv = createServer((req, res) => {
111
+ clearTimeout(giveUp);
112
+ const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
113
+ if (url.pathname !== "/callback") {
114
+ res.writeHead(404).end();
115
+ return;
116
+ }
117
+ if (expired) {
118
+ // They approved it; we had already stopped listening for an answer.
119
+ // Saying so is the difference between "that took too long" and the
120
+ // browser's own "could not connect", which reads like a broken tool.
121
+ res
122
+ .writeHead(408, { "content-type": "text/html; charset=utf-8" })
123
+ .end(renderSignInPage("expired"));
124
+ return;
125
+ }
126
+ const code = url.searchParams.get("code");
127
+ const state = url.searchParams.get("state");
128
+ if (!code || state !== expectedState) {
129
+ res
130
+ .writeHead(400, { "content-type": "text/html; charset=utf-8" })
131
+ .end(renderSignInPage("failed"));
132
+ srv.close();
133
+ reject(new Error("authorization was denied or the response was malformed"));
134
+ return;
135
+ }
136
+ // charset matters more than it looks: without it a browser in a Chinese
137
+ // locale decodes this as GBK and the em dash arrives as mojibake, which
138
+ // is what the last version of this line actually did.
139
+ res
140
+ .writeHead(200, { "content-type": "text/html; charset=utf-8" })
141
+ .end(renderSignInPage("ok", Date.now() + SIGNIN_PAGE_TTL_MS));
142
+ // Not srv.close(): a page that cannot survive a refresh is a page that
143
+ // says "this site can't be reached" to someone checking their sign-in
144
+ // really worked. The port goes to a detached copy that serves this one
145
+ // document for a few minutes, and the terminal comes back now.
146
+ res.on("finish", () => {
147
+ clearTimeout(giveUp);
148
+ handOverSignInPage(port, () => srv.close());
149
+ });
150
+ resolve(code);
151
+ });
152
+ srv.listen(port, "127.0.0.1");
153
+ srv.on("error", (e) => {
154
+ clearTimeout(giveUp);
155
+ reject(e);
156
+ });
157
+ });
158
+ }
159
+ /** The paste-back path: read the redirect URL (or bare code) from stdin. */
160
+ /**
161
+ * Read the redirect back from whoever has the browser.
162
+ *
163
+ * Takes the whole address, which is what a person copying from an address
164
+ * bar actually has — but also takes a bare code, because someone who reads
165
+ * the query string will pull out the interesting part instead, and refusing
166
+ * that would be pedantry.
167
+ */
168
+ /**
169
+ * Read the pasted redirect, and give the reader more than one go at it.
170
+ *
171
+ * It used to ask once and reject on anything it could not parse. Pasting a
172
+ * long code into a terminal is exactly where people fumble — the clipboard
173
+ * did not take, enter went in early, the wrong window was in front — and one
174
+ * fumble failed the whole sign-in. In `aisa login` that costs the command and
175
+ * the authorize URL together: the page they just opened is stale, so they
176
+ * start over from the beginning for a mistyped paste.
177
+ *
178
+ * Three tries, each saying what was wrong with the last one. An empty line is
179
+ * not a mistake, it is "stop asking" — the same meaning it has in every other
180
+ * prompt in this flow.
181
+ */
182
+ const PASTE_TRIES = 3;
183
+ function parsePaste(raw) {
184
+ const text = raw.trim().replace(/^["']|["']$/g, "");
185
+ try {
186
+ const url = new URL(text);
187
+ return { code: url.searchParams.get("code"), state: url.searchParams.get("state") };
188
+ }
189
+ catch {
190
+ // Not a URL. A query fragment, or the code on its own.
191
+ const params = new URLSearchParams(text.replace(/^\?/, ""));
192
+ return {
193
+ code: params.get("code") ?? (/^[A-Za-z0-9._~-]{8,}$/.test(text) ? text : null),
194
+ state: params.get("state"),
195
+ };
196
+ }
197
+ }
198
+ async function waitForPaste(expectedState, lang) {
199
+ for (let attempt = 1; attempt <= PASTE_TRIES; attempt++) {
200
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
201
+ const answer = await rl.question(t(SIGNIN.prompt, lang));
202
+ rl.close();
203
+ if (answer.trim() === "") {
204
+ throw new Error("sign-in cancelled — run it again when you have the code");
205
+ }
206
+ const { code, state } = parsePaste(answer);
207
+ let wrong;
208
+ if (!code)
209
+ wrong = t(SIGNIN.badPaste, lang);
210
+ // A pasted bare code carries no state to check; a pasted URL does, and a
211
+ // mismatched one is an older sign-in that would fail confusingly at the
212
+ // token endpoint instead of here.
213
+ else if (state !== null && state !== expectedState) {
214
+ wrong = "that is from an older sign-in — paste the address from the page you just opened";
215
+ }
216
+ if (!wrong)
217
+ return code;
218
+ if (attempt === PASTE_TRIES)
219
+ throw new Error(wrong);
220
+ console.log(` ${chalk.yellow(wrong)}`);
221
+ console.log(` ${chalk.gray(`try again — ${PASTE_TRIES - attempt} left, or press enter to stop`)}\n`);
222
+ }
223
+ /* c8 ignore next */
224
+ throw new Error(t(SIGNIN.badPaste, lang));
225
+ }
226
+ function openBrowser(url) {
227
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
228
+ void run(cmd, [url], { timeout: 30_000 }).catch(() => { });
229
+ }
230
+ export async function mintCliKey(options = {}) {
231
+ const lang = options.lang ?? "en";
232
+ // Unset means "work it out": a server has no browser to open and waiting
233
+ // for a click on a machine with no screen is the worst way to find out.
234
+ // A caller that brought its own catcher has already decided.
235
+ const useBrowser = options.catcher ? true : (options.open ?? canOpenBrowser());
236
+ // The redirect has to be registered before the browser opens, and Clerk
237
+ // matches it exactly — so which of the three it is has to be settled here,
238
+ // before anything else happens. A loopback port is only worth taking when
239
+ // this process is the one that will catch the redirect.
240
+ const port = useBrowser && !options.catcher ? 10000 + Math.floor(Math.random() * 50_000) : 0;
241
+ const redirectUri = options.catcher
242
+ ? options.catcher.redirectUri
243
+ : useBrowser
244
+ ? `http://127.0.0.1:${port}/callback`
245
+ : HOSTED_REDIRECT;
246
+ info(t(SIGNIN.start, lang));
247
+ const clientId = await registerClient(redirectUri);
248
+ const verifier = b64url(randomBytes(32));
249
+ const challenge = b64url(createHash("sha256").update(verifier).digest());
250
+ const state = b64url(randomBytes(16));
251
+ const authUrl = new URL(`${AUTH_SERVER}/oauth/authorize`);
252
+ authUrl.search = new URLSearchParams({
253
+ response_type: "code",
254
+ client_id: clientId,
255
+ redirect_uri: redirectUri,
256
+ scope: "openid profile email",
257
+ state,
258
+ code_challenge: challenge,
259
+ code_challenge_method: "S256",
260
+ }).toString();
261
+ let code;
262
+ if (options.catcher) {
263
+ // The run's own server is already listening; nothing to start here.
264
+ info(t(SIGNIN.willOpen, lang));
265
+ console.log(` ${t(SIGNIN.ifNot, lang)}\n ${authUrl.toString()}\n`);
266
+ openBrowser(authUrl.toString());
267
+ code = await options.catcher.wait(state);
268
+ }
269
+ else if (useBrowser) {
270
+ info(t(SIGNIN.willOpen, lang));
271
+ console.log(` ${t(SIGNIN.ifNot, lang)}\n ${authUrl.toString()}\n`);
272
+ openBrowser(authUrl.toString());
273
+ code = await waitForCallback(port, state, lang);
274
+ }
275
+ else {
276
+ if (!process.stdin.isTTY) {
277
+ // Nothing to open and nowhere to paste. Say so now rather than sitting
278
+ // on a callback that cannot arrive.
279
+ throw new Error(`no browser here and no terminal to paste into — sign in on a machine that has one, or run: aisa login --key <key>\n ${authUrl.toString()}`);
280
+ }
281
+ console.log(` ${t(SIGNIN.here, lang)}\n`);
282
+ console.log(` ${t(SIGNIN.step1, lang)}\n ${authUrl.toString()}\n`);
283
+ console.log(` ${t(SIGNIN.step2, lang)}\n`);
284
+ console.log(` ${t(SIGNIN.step3, lang)}\n`);
285
+ code = await waitForPaste(state, lang);
286
+ }
287
+ const tokenRes = await httpFetch(`${AUTH_SERVER}/oauth/token`, {
288
+ method: "POST",
289
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
290
+ body: new URLSearchParams({
291
+ grant_type: "authorization_code",
292
+ code,
293
+ redirect_uri: redirectUri,
294
+ client_id: clientId,
295
+ code_verifier: verifier,
296
+ }),
297
+ signal: AbortSignal.timeout(20_000),
298
+ });
299
+ const tokens = (await tokenRes.json());
300
+ if (!tokens.access_token) {
301
+ throw new Error(`token exchange failed: ${tokens.error_description ?? tokens.error ?? tokenRes.status}`);
302
+ }
303
+ // The token is a day-long credential; the key is the durable one. Trade up
304
+ // and keep only the key.
305
+ const mintRes = await httpFetch(MINT_URL, {
306
+ method: "POST",
307
+ headers: { Authorization: `Bearer ${tokens.access_token}` },
308
+ signal: AbortSignal.timeout(20_000),
309
+ });
310
+ if (mintRes.status === 404) {
311
+ // A deployment without the mint endpoint. The sign-in still proved the
312
+ // account works; the key just has to travel by hand once.
313
+ throw new Error("this deployment cannot issue CLI keys — copy one from https://console.aisa.one/api-keys and run: aisa login --key <key>");
314
+ }
315
+ const minted = (await mintRes.json());
316
+ if (!mintRes.ok || !minted.key) {
317
+ throw new Error(`could not issue a key: ${minted.error ?? `HTTP ${mintRes.status}`}`);
318
+ }
319
+ setApiKey(minted.key);
320
+ return minted.key;
321
+ }
322
+ export async function oauthLogin(options = {}) {
323
+ if (options.open === false && !process.stdin.isTTY) {
324
+ error("--no-browser needs an interactive terminal to paste the redirect URL into.");
325
+ hint("In scripts, use: aisa login --key <key>");
326
+ process.exitCode = 1;
327
+ return;
328
+ }
329
+ const key = await mintCliKey(options);
330
+ // The balance follows from the caller, so no "try aisa balance" here: being
331
+ // told to go and check is worse than being shown.
332
+ success(`Signed in — CLI key ${maskKey(key)} stored`);
333
+ }
@@ -0,0 +1,96 @@
1
+ /** Put the terminal back the way a shell expects it. Safe to call twice. */
2
+ export declare function restoreTerminal(): void;
3
+ /**
4
+ * Let a child process own the keyboard.
5
+ *
6
+ * leaveRaw resumes stdin, which is right when this process is about to read
7
+ * it again and wrong when something else is. A resumed stream with no `data`
8
+ * listener still pulls bytes off the file descriptor and drops them — so with
9
+ * stdio inherited, every keystroke was a race between this process and the
10
+ * agent it had just started. The user saw it as characters going missing and
11
+ * lines submitting themselves: enter is the keystroke where losing the race
12
+ * has consequences.
13
+ *
14
+ * Paused, this process reads nothing and the child gets all of it. Anything
15
+ * here that wants stdin back takes it — the pickers call enterRaw and the
16
+ * prompts build a fresh readline, and both resume.
17
+ */
18
+ export declare function handOffStdin(): void;
19
+ /** Can this terminal deliver arrow keys? */
20
+ export declare function interactive(): boolean;
21
+ export interface Choice {
22
+ /**
23
+ * A short label on the right, said once where a run of rows begins.
24
+ *
25
+ * Eight areas over twenty-five servers, and five of the areas hold exactly
26
+ * one server — so heading rows produced "heading, row, heading, row", which
27
+ * is not a grouping, it is a hat on every line. Sorting by area and naming
28
+ * it only where it changes says the same thing in no extra rows at all: the
29
+ * rows below inherit it from the blank space, which is what a run is.
30
+ *
31
+ * Set it on every row; this decides where to print it. Naming only the
32
+ * first row of a run put the name off-screen for most of a long one — the
33
+ * twelve SEO servers do not fit a window, so scrolling into them lost the
34
+ * word that said what they were. It is printed where the run changes and
35
+ * again at the top of the window, which is the other place a run can begin
36
+ * as far as the reader is concerned.
37
+ */
38
+ tag?: string;
39
+ /** Left column, already coloured. */
40
+ label: string;
41
+ /** Right column, dimmed. Cut before it can wrap. */
42
+ meta?: string;
43
+ /** Extra lines under the cursor's row only. */
44
+ detail?: string;
45
+ }
46
+ /**
47
+ * Watch for a change made somewhere else while the picker is open.
48
+ *
49
+ * Given a signal that fires the moment the picker is done, because a watcher
50
+ * that outlives it never stops: each step left one behind, polling every 900ms
51
+ * for the life of the process — three steps, three pollers, still running long
52
+ * after the run had finished.
53
+ */
54
+ export type Interrupt<T> = (signal: AbortSignal,
55
+ /**
56
+ * Repaint with a selection made elsewhere, without ending the picker.
57
+ *
58
+ * A checklist is not answered by its first tick. Treating every change in
59
+ * the page as the answer closed this picker on the first box ticked over
60
+ * there and moved the run on; a person ticking their way down twenty-five
61
+ * servers should see the ✓ marks arrive here and keep going.
62
+ */
63
+ apply: (indexes: number[]) => void) => Promise<T | undefined>;
64
+ export interface PickResult<T> {
65
+ /** Indexes chosen here, or undefined when something else answered. */
66
+ picked?: number[];
67
+ /** Whatever the interrupt resolved with. */
68
+ interrupted?: T;
69
+ aborted?: boolean;
70
+ /** Escape: the question was left rather than answered. */
71
+ escaped?: boolean;
72
+ }
73
+ /**
74
+ * Draw a picker until the user commits, or `watch` resolves.
75
+ *
76
+ * `multi` decides whether space toggles and enter confirms a set, or the
77
+ * cursor itself is the answer.
78
+ */
79
+ export declare function pick<T>(opts: {
80
+ title: string;
81
+ choices: Choice[];
82
+ multi: boolean;
83
+ initial?: number[];
84
+ cursor?: number;
85
+ hint: string;
86
+ watch?: Interrupt<T>;
87
+ /**
88
+ * Called whenever the local selection changes, so the other side can follow.
89
+ *
90
+ * On a checklist that is a tick; on a single choice it is the cursor
91
+ * itself, which is the only way this terminal has of saying "this one" —
92
+ * it is painted as chosen, so treating the move as a hover left the page
93
+ * showing nothing while the terminal plainly showed something.
94
+ */
95
+ onToggle?: (indexes: number[]) => void;
96
+ }): Promise<PickResult<T>>;