@aisa-one/cli 0.3.0 → 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 +143 -1
  2. package/LICENSE +21 -0
  3. package/README.md +151 -162
  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 +59 -0
  38. package/dist/commands/mcp.js +122 -44
  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 +51 -38
  65. package/dist/constants.js +53 -109
  66. package/dist/index.js +152 -355
  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,822 @@
1
+ import * as readline from "node:readline/promises";
2
+ import chalk from "chalk";
3
+ import { AGENT_BADGE, AGENT_NOTES, BACKUP_COPY, CONFIRM, FILE_MODEL_FALLBACK, FILE_MODEL_NOTE, STEP_AGENT, STEP_CAPS, STEP_MODELS, STEP_TITLES, STEP_WELCOME, agentRank, fill, t, } from "./flow.js";
4
+ import { INSTALLERS } from "./install.js";
5
+ import { defaultModelsFor } from "./llm-config.js";
6
+ import { httpFetch } from "../utils/http.js";
7
+ import { cells } from "../utils/width.js";
8
+ import { pick, interactive, restoreTerminal } from "./prompt.js";
9
+ /** Same rule the page applies, so both offer the same rows. */
10
+ export function flowClients(clients, canInstall) {
11
+ return clients.map((c) => ({
12
+ ...c,
13
+ installable: !c.detected && Boolean(INSTALLERS[c.id]) && canInstall,
14
+ command: INSTALLERS[c.id]?.command,
15
+ }));
16
+ }
17
+ /**
18
+ * The model options for an agent, by the rule the page already applies.
19
+ *
20
+ * There are three shapes over there and the terminal offered one, so half
21
+ * the agents were asked a question the page does not ask them. A CLI agent
22
+ * yet to be installed chooses between switching and not; an installed one
23
+ * can also keep its own model and put AIsa beside it. VS Code cannot switch:
24
+ * Copilot's models can be joined, not replaced. An app configured through a
25
+ * file is not asked at all — there is nothing here to point at a model, so
26
+ * it gets the note the page shows instead of a menu.
27
+ */
28
+ function modelChoices(client) {
29
+ if (client.kind === "cli") {
30
+ return client.detected ? ["backup", "switch", "skip"] : ["switch", "skip"];
31
+ }
32
+ return client.id === "vscode" ? ["backup", "skip"] : [];
33
+ }
34
+ const dim = chalk.gray;
35
+ const bold = chalk.bold;
36
+ /** A framed step header, so the terminal has the page's sense of place. */
37
+ function header(n, lang) {
38
+ const step = STEP_TITLES.find((s) => s.n === n);
39
+ // After the number, before the words: the number says where you are, the
40
+ // glyph says what this one is, and a reader scrolling back finds the glyph
41
+ // first. Terminal only — see the note on STEP_TITLES.
42
+ const title = `${n}/6 ${step.icon} ${t(step.title, lang)}`;
43
+ return `\n${bold.cyan("┌─ " + title)} ${dim("· " + t(step.sub, lang))}`;
44
+ }
45
+ /**
46
+ * Ask for a line, on an interface that exists only for this question.
47
+ *
48
+ * A readline kept open across the whole flow does not survive the arrow-key
49
+ * pickers beside it: those put stdin in raw mode, and the interface that was
50
+ * listening before is left attached to a stdin whose mode changed underneath
51
+ * it. The result was a prompt that echoed nothing and accepted nothing, with
52
+ * no way out but killing the terminal.
53
+ *
54
+ * One question, one interface, closed before anything else touches stdin.
55
+ */
56
+ async function askLine(prompt) {
57
+ // Cooked mode first: a picker may have just been open, and readline on a raw
58
+ // stdin accepts nothing.
59
+ restoreTerminal();
60
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
61
+ try {
62
+ return (await rl.question(prompt)).trim();
63
+ }
64
+ finally {
65
+ rl.close();
66
+ // Whatever happened, the terminal goes back cooked. A flow that threw
67
+ // while a picker was open used to leave it raw.
68
+ restoreTerminal();
69
+ }
70
+ }
71
+ /** Strip the markup the page needs and the terminal cannot show. */
72
+ export function plain(html) {
73
+ return html
74
+ .replace(/<br\s*\/?>/gi, " ")
75
+ .replace(/<[^>]+>/g, "")
76
+ .replace(/&lt;/g, "<")
77
+ .replace(/&gt;/g, ">")
78
+ .replace(/&amp;/g, "&")
79
+ .replace(/\s+/g, " ")
80
+ .trim();
81
+ }
82
+ /** Two columns wide in a terminal: CJK, and the punctuation that comes with it. */
83
+ /** Kept as a name because wrap() reads better with it; the maths is shared. */
84
+ export const displayWidth = cells;
85
+ /**
86
+ * Wrap to the terminal width.
87
+ *
88
+ * Splitting on spaces alone is wrong for Chinese, which has none: a whole
89
+ * sentence arrives as one "word" and the line runs far past the margin —
90
+ * observed on the welcome step, where a paragraph overflowed by half a line.
91
+ * So a run that is itself too wide is broken between characters, and width is
92
+ * measured in cells rather than characters because CJK glyphs take two.
93
+ */
94
+ export function wrap(text, width = Math.min(process.stdout.columns || 80, 84)) {
95
+ const out = [];
96
+ let line = "";
97
+ const flush = () => { if (line) {
98
+ out.push(line);
99
+ line = "";
100
+ } };
101
+ const breakWide = (word) => {
102
+ let chunk = "";
103
+ for (const ch of word) {
104
+ if (displayWidth(chunk + ch) > width) {
105
+ out.push(chunk);
106
+ chunk = "";
107
+ }
108
+ chunk += ch;
109
+ }
110
+ return chunk;
111
+ };
112
+ for (const word of text.split(" ")) {
113
+ const sep = line ? 1 : 0;
114
+ if (line && displayWidth(line) + sep + displayWidth(word) > width)
115
+ flush();
116
+ if (displayWidth(word) > width) {
117
+ flush();
118
+ line = breakWide(word);
119
+ continue;
120
+ }
121
+ line = line ? `${line} ${word}` : word;
122
+ }
123
+ flush();
124
+ return out;
125
+ }
126
+ function say(text, indent = "│ ") {
127
+ for (const line of wrap(plain(text)))
128
+ console.log(dim(indent) + line);
129
+ }
130
+ /**
131
+ * Thrown by a step when the run has to resume from an earlier one.
132
+ *
133
+ * The steps are written straight down the page, which reads well and cannot
134
+ * jump. Going back is rare enough that unwinding to the loop and starting
135
+ * again from a named step is clearer than turning the whole thing into a
136
+ * state machine for the sake of it.
137
+ */
138
+ class Rewind {
139
+ to;
140
+ echo;
141
+ constructor(to,
142
+ /** False when the page already knows — see Answer's `back`. */
143
+ echo = true) {
144
+ this.to = to;
145
+ this.echo = echo;
146
+ }
147
+ }
148
+ /**
149
+ * Where the terminal should stand when the page is on `pageStep`.
150
+ *
151
+ * Step 1 is the welcome, which this side prints on its way past rather than
152
+ * stopping on — so a click on it lands here at the first question.
153
+ */
154
+ function terminalStepFor(pageStep) {
155
+ return Math.max(2, pageStep);
156
+ }
157
+ /**
158
+ * Ask one question, while watching for the page to answer it instead.
159
+ *
160
+ * Waiting on readline alone is what made the terminal look dead: a person who
161
+ * ticked a card in the browser saw nothing happen here, because this side
162
+ * only looked at the shared draft between questions. So the prompt races
163
+ * against a poll, and whichever resolves first wins the step.
164
+ *
165
+ * The abandoned readline promise cannot be cancelled — it stays pending on a
166
+ * closed interface, which is harmless — but the caller must not ask again on
167
+ * the same interface after the page wins, so each step reads at most once.
168
+ */
169
+ async function askOrWatch(o, count, fallback, seenRev,
170
+ /** The step being asked; moving past it is what answers it. */
171
+ step) {
172
+ let stop = false;
173
+ const typed = (async () => {
174
+ for (;;) {
175
+ const answer = await askLine(bold.cyan("│ > "));
176
+ if (stop)
177
+ return { by: "user", index: fallback };
178
+ if (answer === "")
179
+ return { by: "user", index: fallback };
180
+ const n = Number(answer);
181
+ if (Number.isInteger(n) && n >= 1 && n <= count)
182
+ return { by: "user", index: n - 1 };
183
+ console.log(dim("│ ") + chalk.yellow(`1–${count}`));
184
+ }
185
+ })();
186
+ const watched = (async () => {
187
+ let misses = 0;
188
+ for (;;) {
189
+ await new Promise((r) => setTimeout(r, 900));
190
+ if (stop)
191
+ return { by: "user", index: fallback };
192
+ const s = await pull(o);
193
+ if (!s) {
194
+ // Never resolves once it gives up, which is right: the typed answer
195
+ // is still coming and this side was never waiting on the page.
196
+ if (++misses >= WATCH_GIVE_UP)
197
+ return await new Promise(() => { });
198
+ continue;
199
+ }
200
+ misses = 0;
201
+ if (s.rev === seenRev || !s.draft)
202
+ continue;
203
+ // Moving past the step is the only thing that answers it. Treating a
204
+ // changed value as the answer meant picking an agent in the page —
205
+ // without pressing Next — carried both sides straight to the next step.
206
+ if ((s.currentStep ?? 0) > step) {
207
+ return { by: "page", draft: s.draft };
208
+ }
209
+ // And going back is an answer too: clicking an earlier step in the rail
210
+ // means "I want to choose that again", which is not something this side
211
+ // can honour by staying where it is.
212
+ // Compared after translating, not before. The page's step 1 is this
213
+ // side's step 2, so a page sitting on the welcome reads as "behind"
214
+ // a terminal that is already standing exactly where it should — and
215
+ // the question got reprinted every time somebody opened the page.
216
+ if (s.currentStep && terminalStepFor(s.currentStep) < step) {
217
+ return { by: "back", to: terminalStepFor(s.currentStep), from: "page" };
218
+ }
219
+ }
220
+ })();
221
+ const winner = await Promise.race([typed, watched]);
222
+ stop = true;
223
+ return winner;
224
+ }
225
+ /**
226
+ * The arrow-key picker, racing the same watch the typed prompt races.
227
+ *
228
+ * Returns the same Answer shape either way, so a step does not care which
229
+ * input the terminal was able to offer.
230
+ */
231
+ async function pickOrWatch(o, seenRev, step, choices, multi, initial, hint,
232
+ /** Where Escape lands. Without it Escape is ignored, as on step 2. */
233
+ backTo,
234
+ /**
235
+ * Mirror a checklist instead of ending on its first change.
236
+ *
237
+ * A single choice is answered the moment the page makes it, so those steps
238
+ * leave this out and end on `changed`. A checklist is not: ticking a box
239
+ * over there closed this picker and moved the run on, which is why the ✓
240
+ * marks here never followed the page. With `live` the two sides show the
241
+ * same ticks and the step ends only when someone presses Next.
242
+ */
243
+ live) {
244
+ // The two sides write to the same draft, so a poll that overtakes our own
245
+ // write would paint the state from before it — the box you just ticked
246
+ // un-ticks itself half a second later. Ignore anything older than our last
247
+ // write, and anything at all while one is in flight.
248
+ let floor = seenRev;
249
+ let shown = initial.join(",");
250
+ /** Set by the watcher when the page moved to an earlier step. */
251
+ let back;
252
+ // Holding an arrow key down produces a keystroke every few milliseconds,
253
+ // and one request each would put the page behind the terminal rather than
254
+ // beside it. Only the latest selection is worth sending, so a move made
255
+ // while a write is in flight replaces the one waiting for it.
256
+ let pending;
257
+ let flushing = false;
258
+ const flush = async () => {
259
+ if (flushing || !live?.write)
260
+ return;
261
+ flushing = true;
262
+ try {
263
+ while (pending) {
264
+ const next = pending;
265
+ pending = undefined;
266
+ floor = Math.max(floor, await live.write(next));
267
+ shown = next.join(",");
268
+ }
269
+ }
270
+ catch {
271
+ /* the page falls behind; this terminal still finishes the run */
272
+ }
273
+ finally {
274
+ flushing = false;
275
+ }
276
+ };
277
+ const r = await pick({
278
+ title: "",
279
+ choices,
280
+ multi,
281
+ initial,
282
+ hint,
283
+ onToggle: live?.write
284
+ ? (indexes) => {
285
+ pending = indexes;
286
+ void flush();
287
+ }
288
+ : undefined,
289
+ watch: async (signal, apply) => {
290
+ let misses = 0;
291
+ while (!signal.aborted) {
292
+ await new Promise((x) => setTimeout(x, 900));
293
+ if (signal.aborted)
294
+ return undefined;
295
+ if (flushing || pending)
296
+ continue;
297
+ const s = await pull(o);
298
+ if (!s) {
299
+ // Stop watching, keep the picker. The keyboard still answers this
300
+ // question — that is the whole point of the two sides being
301
+ // independent — so nothing here should end because a page went.
302
+ if (++misses >= WATCH_GIVE_UP)
303
+ return undefined;
304
+ continue;
305
+ }
306
+ misses = 0;
307
+ if (s.rev === seenRev || !s.draft)
308
+ continue;
309
+ if ((s.currentStep ?? 0) > step)
310
+ return s.draft;
311
+ if (s.currentStep && terminalStepFor(s.currentStep) < step) {
312
+ back = terminalStepFor(s.currentStep);
313
+ return s.draft; // ends the picker; the caller reads `back`
314
+ }
315
+ if (!live || s.rev < floor)
316
+ continue;
317
+ const indexes = live.read(s.draft);
318
+ if (!indexes)
319
+ continue;
320
+ const next = indexes.join(",");
321
+ if (next === shown)
322
+ continue;
323
+ shown = next;
324
+ apply(indexes);
325
+ }
326
+ return undefined;
327
+ },
328
+ });
329
+ if (r.interrupted) {
330
+ if (back !== undefined)
331
+ return { by: "back", to: back, from: "page" };
332
+ return { by: "page", draft: r.interrupted };
333
+ }
334
+ if (r.escaped) {
335
+ if (backTo === undefined)
336
+ return { by: "user", index: initial[0] ?? 0, picked: initial };
337
+ return { by: "back", to: backTo, from: "here" };
338
+ }
339
+ return { by: "user", index: r.picked?.[0] ?? initial[0] ?? 0, picked: r.picked };
340
+ }
341
+ /** Push a change to the shared draft. Failure is not fatal: the terminal can
342
+ * still finish the run, it just stops mirroring into a page nobody may have
343
+ * open. */
344
+ async function push(o, rev, patch) {
345
+ try {
346
+ const res = await httpFetch(`${o.baseUrl}/select?token=${o.token}`, {
347
+ method: "POST",
348
+ headers: { "content-type": "application/json" },
349
+ body: JSON.stringify({ rev, ...patch }),
350
+ timeoutMs: 5_000,
351
+ });
352
+ const body = (await res.json());
353
+ // A stale rev means someone wrote while this was being typed — but they
354
+ // wrote a different field, and dropping this write silently is worse than
355
+ // the conflict it was meant to catch. Retry once against what is now
356
+ // current. Without this the terminal's step never landed: the page
357
+ // publishes its step on load, and every later terminal write was refused.
358
+ if (body.ok === false) {
359
+ const retry = await httpFetch(`${o.baseUrl}/select?token=${o.token}`, {
360
+ method: "POST",
361
+ headers: { "content-type": "application/json" },
362
+ body: JSON.stringify({ rev: body.rev, ...patch }),
363
+ timeoutMs: 5_000,
364
+ });
365
+ return (await retry.json());
366
+ }
367
+ return body;
368
+ }
369
+ catch {
370
+ return { rev };
371
+ }
372
+ }
373
+ /**
374
+ * How many polls in a row may fail before the terminal stops watching.
375
+ *
376
+ * Not an error: the page is optional, and this side can finish the whole run
377
+ * on its own. But a watcher that keeps asking a port nothing is listening on,
378
+ * every 900ms for as long as the question is open, is spending the user's
379
+ * machine on a conversation that ended.
380
+ */
381
+ const WATCH_GIVE_UP = 8;
382
+ /** Read the shared draft, to pick up whatever the page did. */
383
+ async function pull(o) {
384
+ try {
385
+ const res = await httpFetch(`${o.baseUrl}/status?token=${o.token}`, {
386
+ idempotent: true,
387
+ maxAttempts: 1,
388
+ timeoutMs: 3_000,
389
+ });
390
+ const s = (await res.json());
391
+ return { rev: s.rev ?? 0, draft: s.draft, currentStep: s.currentStep };
392
+ }
393
+ catch {
394
+ return undefined;
395
+ }
396
+ }
397
+ /**
398
+ * Walk the flow in the terminal and return what was chosen.
399
+ *
400
+ * Returns undefined when the user abandons it, which the caller treats the
401
+ * same way as closing the page.
402
+ */
403
+ export async function runTerminalFlow(o) {
404
+ let rev = 0;
405
+ const draft = { servers: [], clients: [], install: [], llmMode: "backup" };
406
+ // Start from whatever the shared draft already holds — a page may have been
407
+ // open first, and its ticks are the starting point here rather than being
408
+ // silently discarded.
409
+ //
410
+ // Its *position* is part of that state, not just its answers. Adopting the
411
+ // ticks while always restarting at step 2 meant a page sitting on step 4 got
412
+ // asked its first two questions again by a terminal that already knew both
413
+ // answers — and, now that the page follows this side backwards, got dragged
414
+ // back to 2 to watch. Resuming where the run actually stands is the whole
415
+ // difference between two views of one run and two runs that happen to share
416
+ // a draft.
417
+ const seed = await pull(o);
418
+ let resumeAt = 2;
419
+ if (seed) {
420
+ rev = seed.rev;
421
+ if (seed.draft)
422
+ Object.assign(draft, seed.draft);
423
+ // Cap at the confirmation: 6 is the finished view, and there is nothing
424
+ // for this side to ask once a run has been applied.
425
+ if (seed.currentStep)
426
+ resumeAt = Math.min(terminalStepFor(seed.currentStep), 5);
427
+ }
428
+ try {
429
+ // Steps 2–5 are one loop, because "n" at the confirmation means go back
430
+ // and change something — not abandon the run. Answering no used to end
431
+ // the process, which is the opposite of what the word offers.
432
+ // ── step 1: welcome ──
433
+ // The page opens on this and the terminal used to skip straight past it,
434
+ // which left the one surface that cannot be scrolled back through with no
435
+ // answer to "what am I about to get". Same four points, without the tiles.
436
+ console.log(header(1, o.lang));
437
+ say(t(STEP_WELCOME.h1, o.lang));
438
+ console.log(dim("│"));
439
+ say(t(STEP_WELCOME.lede, o.lang));
440
+ console.log(dim("│"));
441
+ for (const tile of STEP_WELCOME.tiles) {
442
+ console.log(dim("│ ") + chalk.bold("· " + plain(t(tile.h3, o.lang))));
443
+ for (const line of wrap(plain(t(tile.p, o.lang)), 74)) {
444
+ console.log(dim("│ ") + dim(line));
445
+ }
446
+ }
447
+ console.log(dim("└─"));
448
+ // Steps 2 through the confirmation, repeatable — and resumable from any
449
+ // of them. Answering n at the end comes back here, and so does clicking
450
+ // an earlier step in the page's rail: both say "I want to choose that
451
+ // again", and the difference is only which step they land on.
452
+ //
453
+ // What a later step needs from an earlier one lives out here, so
454
+ // resuming at 3 still knows which agent step 2 settled on.
455
+ let confirmed = "again";
456
+ let from = resumeAt;
457
+ let client;
458
+ let modeLabel = "";
459
+ for (;;) {
460
+ try {
461
+ // ── step 2: your agent ──
462
+ if (from <= 2) {
463
+ console.log(header(2, o.lang));
464
+ say(t(STEP_AGENT.question, o.lang));
465
+ console.log(dim("│"));
466
+ const shown = o.clients
467
+ .filter((c) => c.detected || c.installable)
468
+ .sort((a, b) => agentRank(a.id) - agentRank(b.id));
469
+ if (shown.length === 0)
470
+ return undefined;
471
+ shown.forEach((c, i) => {
472
+ const badge = c.detected
473
+ ? chalk.green(t(AGENT_BADGE.detected, o.lang))
474
+ : chalk.yellow(t(AGENT_BADGE.absent, o.lang));
475
+ console.log(`${dim("│")} ${bold(String(i + 1) + ")")} ${c.label.padEnd(16)} ${badge} ${dim(c.detail ?? "")}`);
476
+ });
477
+ console.log(dim("│"));
478
+ const preferred = Math.max(0, shown.findIndex((c) => draft.clients[0] === c.id));
479
+ const a1 = interactive()
480
+ ? await pickOrWatch(o, rev, 2, shown.map((c) => ({
481
+ label: c.label,
482
+ meta: c.detected ? t(AGENT_BADGE.detected, o.lang) : t(AGENT_BADGE.absent, o.lang),
483
+ })), false, [preferred], o.lang === "zh" ? "↑↓ 选择 · 回车确认" : "↑↓ move · enter to confirm", undefined, // step 2 has nothing behind it
484
+ {
485
+ read: (d) => {
486
+ const i = shown.findIndex((c) => c.id === d.clients[0]);
487
+ return i < 0 ? undefined : [i];
488
+ },
489
+ write: async ([i]) => {
490
+ const c = shown[i];
491
+ ({ rev } = await push(o, rev, {
492
+ draft: { clients: [c.id], install: c.detected ? [] : [c.id] },
493
+ }));
494
+ return rev;
495
+ },
496
+ })
497
+ : await askOrWatch(o, shown.length, preferred, rev, 2);
498
+ if (a1.by === "back")
499
+ throw new Rewind(a1.to, a1.from === "here");
500
+ if (a1.by === "page") {
501
+ // Answered in the browser. Say so rather than redrawing silently —
502
+ // seeing why the prompt moved on is the whole point.
503
+ client = shown.find((c) => c.id === a1.draft.clients[0]) ?? shown[preferred];
504
+ Object.assign(draft, a1.draft);
505
+ const s = await pull(o);
506
+ if (s)
507
+ rev = s.rev;
508
+ console.log("\n" + dim("│ ") + chalk.magenta(o.lang === "zh" ? "↩ 已在页面中选择" : "↩ chosen in the page"));
509
+ }
510
+ else {
511
+ client = shown[a1.index];
512
+ }
513
+ draft.clients = [client.id];
514
+ draft.install = client.detected ? [] : [client.id];
515
+ console.log(dim("└─ ") + chalk.green(client.label) + " ✓");
516
+ if (AGENT_NOTES[client.id])
517
+ say(t(AGENT_NOTES[client.id], o.lang), " ");
518
+ ({ rev } = await push(o, rev, { step: 3, draft: { clients: draft.clients, install: draft.install } }));
519
+ }
520
+ // Resuming at 3 or later: the agent is whatever step 2 settled on, or
521
+ // whatever the shared draft says if this side never ran that step.
522
+ if (!client) {
523
+ client = o.clients.find((c) => c.id === draft.clients[0]) ?? o.clients.find((c) => c.detected);
524
+ if (!client)
525
+ return undefined;
526
+ }
527
+ // ── step 3: models ──
528
+ if (from <= 3) {
529
+ console.log(header(3, o.lang));
530
+ say(`${t(STEP_MODELS.h2Prefix, o.lang)}${client.label}${t(STEP_MODELS.h2Suffix, o.lang)}`);
531
+ console.log(dim("│"));
532
+ // The copy carries {model}; the page swaps in an element its script
533
+ // fills, and here the name goes straight in.
534
+ const modelName = defaultModelsFor(client.id).model;
535
+ const brief = (x) => plain(t(x, o.lang)).split("{model}").join(modelName);
536
+ const unpicked = !client.detected;
537
+ const copy = {
538
+ backup: {
539
+ label: t(STEP_MODELS.backup.name, o.lang),
540
+ // The page fills this tile with the agent's own backup wording;
541
+ // leaving it blank here described the recommended option in no
542
+ // words at all.
543
+ brief: BACKUP_COPY[client.id] ? brief(BACKUP_COPY[client.id]) : "",
544
+ },
545
+ switch: unpicked
546
+ ? { label: t(STEP_MODELS.freshSwitch.name, o.lang), brief: brief(STEP_MODELS.freshSwitch.brief) }
547
+ : { label: t(STEP_MODELS.switchIt.name, o.lang), brief: brief(STEP_MODELS.switchIt.brief) },
548
+ skip: {
549
+ label: t(STEP_MODELS.notNow.name, o.lang),
550
+ brief: brief(unpicked ? STEP_MODELS.notNow.briefFresh : STEP_MODELS.notNow.briefDetected),
551
+ },
552
+ };
553
+ const modes = modelChoices(client).map((id) => ({ id, ...copy[id] }));
554
+ let mode = modes[0] ?? { id: "backup", ...copy.backup };
555
+ if (modes.length === 0) {
556
+ // Nothing to choose. Say why in the page's own words and move on,
557
+ // rather than offering a menu it does not offer.
558
+ for (const l of wrap(brief(FILE_MODEL_NOTE[client.id] ?? FILE_MODEL_FALLBACK), 68)) {
559
+ console.log(dim("│ ") + dim(l));
560
+ }
561
+ console.log(dim("└─ ") + dim(o.lang === "zh" ? "这一步没有要选的" : "nothing to choose here"));
562
+ }
563
+ else {
564
+ modes.forEach((m, i) => {
565
+ const rec = i === 0 ? dim(` (${t(STEP_MODELS.recommended, o.lang)})`) : "";
566
+ console.log(`${dim("│")} ${bold(String(i + 1) + ")")} ${m.label}${rec}`);
567
+ if (m.brief)
568
+ for (const l of wrap(m.brief, 68))
569
+ console.log(dim("│ ") + dim(l));
570
+ });
571
+ console.log(dim("│"));
572
+ const a2 = interactive()
573
+ ? await pickOrWatch(o, rev, 3, modes
574
+ .map((m, i) => ({
575
+ label: m.label + (i === 0 ? dim(` (${t(STEP_MODELS.recommended, o.lang)})`) : ""),
576
+ }))
577
+ .concat([{ label: dim(t(CONFIRM.goBack, o.lang)) }]), false, [0], o.lang === "zh" ? "↑↓ 选择 · 回车确认 · esc 返回" : "↑↓ move · enter to confirm · esc to go back", 2, {
578
+ read: (d) => {
579
+ const i = modes.findIndex((m) => m.id === d.llmMode);
580
+ return i < 0 ? undefined : [i];
581
+ },
582
+ write: async ([i]) => {
583
+ ({ rev } = await push(o, rev, { draft: { llmMode: modes[i].id } }));
584
+ return rev;
585
+ },
586
+ })
587
+ : await askOrWatch(o, modes.length, 0, rev, 3);
588
+ if (a2.by === "back")
589
+ throw new Rewind(a2.to, a2.from === "here");
590
+ // The extra row is the way back. Steps 3 and 4 are the two where a
591
+ // person can realise they picked the wrong thing a moment ago; 2 has
592
+ // nothing behind it and 5 already asks.
593
+ if (a2.by === "user" && a2.index === modes.length)
594
+ throw new Rewind(2);
595
+ if (a2.by === "page") {
596
+ mode = modes.find((m) => m.id === a2.draft.llmMode) ?? modes[0];
597
+ Object.assign(draft, a2.draft);
598
+ const s = await pull(o);
599
+ if (s)
600
+ rev = s.rev;
601
+ console.log("\n" + dim("│ ") + chalk.magenta(o.lang === "zh" ? "↩ 已在页面中选择" : "↩ chosen in the page"));
602
+ }
603
+ else {
604
+ mode = modes[a2.index];
605
+ }
606
+ console.log(dim("└─ ") + chalk.green(mode.label) + " ✓");
607
+ }
608
+ draft.llmMode = mode.id;
609
+ modeLabel = mode.label;
610
+ ({ rev } = await push(o, rev, { step: 4, draft: { llmMode: draft.llmMode } }));
611
+ }
612
+ // ── step 4: capabilities ──
613
+ if (from <= 4) {
614
+ console.log(header(4, o.lang));
615
+ const totalTools = o.servers.reduce((n, s) => n + s.toolCount, 0);
616
+ const cats = [...new Set(o.servers.map((s) => s.category))];
617
+ say(fill(STEP_CAPS.counts, o.lang, {
618
+ areas: cats.length, servers: o.servers.length, tools: totalTools,
619
+ }));
620
+ console.log(dim("│"));
621
+ // Pull once more: the capability list is long, and a page open beside
622
+ // this one is the likelier place to have ticked through it.
623
+ const fresh = await pull(o);
624
+ if (fresh?.draft?.servers?.length)
625
+ draft.servers = fresh.draft.servers;
626
+ if (fresh)
627
+ rev = fresh.rev;
628
+ const chosen = new Set(draft.servers);
629
+ if (interactive()) {
630
+ // Sorted by area, with the area named once where it changes.
631
+ //
632
+ // Twenty-five servers over eight areas — and five of those areas hold
633
+ // exactly one server, so a heading per area produced "heading, row,
634
+ // heading, row", which is a hat on every line rather than a grouping.
635
+ // The name on the first row of each run says the same thing and costs
636
+ // no rows: what follows inherits it from the blank space beside it.
637
+ const ordered = [...o.servers].sort((a, b) => a.category.localeCompare(b.category) || a.slug.localeCompare(b.slug));
638
+ const rowFor = (slug) => ordered.findIndex((s) => s.slug === slug);
639
+ const initial = [...chosen].map(rowFor).filter((i) => i >= 0);
640
+ const a3 = await pickOrWatch(o, rev, 4, ordered.map((srv) => ({
641
+ label: srv.slug,
642
+ meta: `${srv.toolCount} ${t(STEP_CAPS.toolsWord, o.lang)}`,
643
+ tag: srv.category,
644
+ // Straight from the host, the same sentence the page shows. A
645
+ // hand-written table here would be a second source of truth for
646
+ // something that ships with the server.
647
+ detail: srv.description,
648
+ })), true, initial, o.lang === "zh"
649
+ ? "↑↓ 移动 · 空格勾选 · a 全选/全不选 · 回车确认 · esc 返回上一步"
650
+ : "↑↓ move · space to tick · a for all · enter to confirm · esc to go back", 3, {
651
+ read: (d) => d.servers === undefined ? undefined : d.servers.map(rowFor).filter((i) => i >= 0),
652
+ write: async (indexes) => {
653
+ const next = indexes.map((i) => ordered[i]?.slug).filter((x) => Boolean(x));
654
+ ({ rev } = await push(o, rev, { draft: { servers: next } }));
655
+ return rev;
656
+ },
657
+ });
658
+ // In a checklist the way back cannot be another row — a row is a
659
+ // thing you tick — so Escape is the gesture, and the hint says so.
660
+ if (a3.by === "back")
661
+ throw new Rewind(a3.to, a3.from === "here");
662
+ if (a3.by === "page") {
663
+ chosen.clear();
664
+ for (const slug of a3.draft.servers ?? [])
665
+ chosen.add(slug);
666
+ const s2 = await pull(o);
667
+ if (s2)
668
+ rev = s2.rev;
669
+ console.log("\n" + dim("│ ") + chalk.magenta(o.lang === "zh" ? "↩ 已在页面中选择" : "↩ chosen in the page"));
670
+ }
671
+ else {
672
+ chosen.clear();
673
+ for (const i of a3.picked ?? []) {
674
+ const srv = ordered[i];
675
+ if (srv)
676
+ chosen.add(srv.slug);
677
+ }
678
+ }
679
+ }
680
+ else {
681
+ o.servers.forEach((s, i) => {
682
+ const mark = chosen.has(s.slug) ? chalk.green("[x]") : dim("[ ]");
683
+ console.log(`${dim("│")} ${mark} ${bold(String(i + 1).padStart(2))}) ${s.slug.padEnd(24)} ${dim(String(s.toolCount) + " " + t(STEP_CAPS.toolsWord, o.lang))}`);
684
+ });
685
+ console.log(dim("│"));
686
+ say(o.lang === "zh"
687
+ ? "输入编号切换选中(空格分隔),直接回车确认。"
688
+ : "Type numbers to toggle (space-separated), or press enter to confirm.");
689
+ for (;;) {
690
+ const answer = await askLine(bold.cyan("│ > "));
691
+ if (answer === "")
692
+ break;
693
+ for (const tok of answer.split(/[\s,]+/)) {
694
+ const n = Number(tok);
695
+ if (!Number.isInteger(n) || n < 1 || n > o.servers.length)
696
+ continue;
697
+ const slug = o.servers[n - 1].slug;
698
+ if (chosen.has(slug))
699
+ chosen.delete(slug);
700
+ else
701
+ chosen.add(slug);
702
+ }
703
+ console.log(dim("│ ") + chalk.green(`${chosen.size} selected: `) + dim([...chosen].join(", ")));
704
+ ({ rev } = await push(o, rev, { draft: { servers: [...chosen] } }));
705
+ }
706
+ }
707
+ draft.servers = [...chosen];
708
+ if (draft.servers.length === 0) {
709
+ console.log(dim("└─ ") + chalk.yellow(o.lang === "zh" ? "至少选一个" : "Pick at least one"));
710
+ return undefined;
711
+ }
712
+ console.log(dim("└─ ") + chalk.green(`${draft.servers.length} ✓`));
713
+ // Publish the choice but NOT the step: arriving at step 5 is what makes
714
+ // the page start the run by itself, and the user has not confirmed yet.
715
+ // Announcing the step here applied everything without being asked.
716
+ ({ rev } = await push(o, rev, { draft: { servers: draft.servers } }));
717
+ }
718
+ // ── step 5: confirm ──
719
+ // Everything above was browsing and could be undone by closing the
720
+ // window. This is where the machine changes, so it asks for a word rather
721
+ // than a keystroke — enter alone is too easy to hit on the way past.
722
+ console.log(header(5, o.lang));
723
+ say(t(CONFIRM.heading, o.lang));
724
+ console.log(dim("│"));
725
+ if (!modeLabel)
726
+ modeLabel = draft.llmMode;
727
+ console.log(`${dim("│")} ${t(CONFIRM.agent, o.lang)}: ${chalk.bold(client.label)}`);
728
+ console.log(`${dim("│")} ${t(CONFIRM.models, o.lang)}: ${chalk.bold(modeLabel)}`);
729
+ console.log(`${dim("│")} ${t(CONFIRM.capabilities, o.lang)}: ${chalk.bold(String(draft.servers.length))} ${dim(draft.servers.join(", "))}`);
730
+ console.log(dim("│"));
731
+ if (interactive()) {
732
+ const r = await pick({
733
+ title: "",
734
+ // Applying is first and where the cursor starts. It was the other
735
+ // way round, to keep a stray return from writing anything — but by
736
+ // this point the summary above says exactly what will happen, and
737
+ // making the common answer cost two keystrokes is its own kind of
738
+ // wrong. Escape still leaves without applying.
739
+ choices: [
740
+ { label: chalk.green.bold(t(CONFIRM.apply, o.lang)) },
741
+ // Dim, like the way-back row on the models step. A way out of a
742
+ // screen is not one of the things the screen offers, and rendering
743
+ // it as brightly as the action it declines reads as a second offer.
744
+ { label: dim(t(CONFIRM.goBack, o.lang)) },
745
+ ],
746
+ multi: false,
747
+ initial: [0],
748
+ // Escape does the same as picking the way-back row - `r.aborted`
749
+ // falls through to "again" below - so the hint says so. Steps 3
750
+ // and 4 both advertise it and this one did not, which made the
751
+ // key look like it did nothing here.
752
+ hint: o.lang === "zh" ? "↑↓ 选择 · 回车确认 · esc 返回修改"
753
+ : "↑↓ move · enter to confirm · esc to go back",
754
+ });
755
+ confirmed = !r.aborted && r.picked?.[0] === 0 ? "go" : "again";
756
+ console.log(dim("└─ ") + (confirmed === "go"
757
+ ? chalk.green(t(CONFIRM.apply, o.lang) + " ✓")
758
+ : chalk.yellow(t(CONFIRM.backToEdit, o.lang))));
759
+ }
760
+ else {
761
+ say(t(CONFIRM.ask, o.lang) + " — ok / n");
762
+ // Bounded, and empty means no. A closed pipe returns "" forever, and
763
+ // asking again each time printed the prompt down the screen until it
764
+ // ran into whatever came next. Three tries is a person mistyping; more
765
+ // than that is not a person.
766
+ let tries = 0;
767
+ for (;;) {
768
+ const said = (await askLine(bold.cyan("│ > "))).toLowerCase();
769
+ if (["ok", "yes", "y", "是", "好"].includes(said)) {
770
+ confirmed = "go";
771
+ break;
772
+ }
773
+ // Empty means no here, unlike the picker above where enter on the
774
+ // highlighted row means yes. Deliberately different: with no
775
+ // terminal, an empty line is usually a closed pipe rather than
776
+ // someone agreeing, and EOF must not write to a machine.
777
+ if (said === "" || ["n", "no", "否"].includes(said)) {
778
+ confirmed = "again";
779
+ break;
780
+ }
781
+ if (++tries >= 3) {
782
+ confirmed = "again";
783
+ break;
784
+ }
785
+ console.log(dim("│ ") + chalk.yellow("ok / n"));
786
+ }
787
+ console.log(dim("└─ ") + (confirmed === "go"
788
+ ? chalk.green("ok ✓")
789
+ : chalk.yellow(t(CONFIRM.backToEdit, o.lang))));
790
+ }
791
+ }
792
+ catch (e) {
793
+ if (!(e instanceof Rewind))
794
+ throw e;
795
+ // Somebody asked for an earlier step — the page's rail, or a way-back
796
+ // row here. Start again from there with everything else still in hand.
797
+ from = e.to;
798
+ console.log("\n" + dim("│ ") + chalk.magenta(o.lang === "zh" ? `↩ 回到第 ${from} 步` : `↩ back to step ${from}`));
799
+ if (e.echo)
800
+ ({ rev } = await push(o, rev, { step: from }));
801
+ continue;
802
+ }
803
+ // "Go back and change something" at the confirmation lands on the model
804
+ // step, not the agent: an agent you already picked is rarely the thing
805
+ // you came back to change, and making you pick it again to reach the
806
+ // rest is a toll rather than a choice.
807
+ if (confirmed === "again") {
808
+ from = 3;
809
+ ({ rev } = await push(o, rev, { step: 3 }));
810
+ continue;
811
+ }
812
+ from = 2;
813
+ break;
814
+ }
815
+ return draft;
816
+ }
817
+ finally {
818
+ // Whatever happened, the terminal goes back cooked. A flow that threw
819
+ // while a picker was open used to leave it raw.
820
+ restoreTerminal();
821
+ }
822
+ }