@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,3181 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomBytes } from "node:crypto";
3
+ import { createServer } from "node:http";
4
+ import { existsSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { dirname, join } from "node:path";
7
+ import chalk from "chalk";
8
+ import { error } from "../utils/display.js";
9
+ import { expandHome } from "../utils/file.js";
10
+ import { MCP_CONFIGS, MCP_DEFAULT_SLUGS, AISA_PROVIDER_ID } from "../constants.js";
11
+ import { getApiKey, getConfig, setConfig } from "../config.js";
12
+ import { fetchLiveServers, writeClientConfig, buildEntry, stripped } from "./mcp.js";
13
+ import { INSTALLERS, installAgent, isInstalled, supported } from "./install.js";
14
+ import { writeCodexLLM, writeClaudeCodeLLM, writeOpencodeLLM, writeCodexAisaProfile, writeOpencodeAisaBackup, defaultModelsFor, patchCodexMCPAuth, DEFAULT_MODELS, } from "./llm-config.js";
15
+ import { writeClaudeAisaSettings, installWrappers } from "./wrappers.js";
16
+ import { mintCliKey } from "./oauth-login.js";
17
+ import { canOpenBrowser } from "../utils/browser.js";
18
+ import { vscodeDetected, vscodeUserDir, writeVSCodeLLM, writeVSCodeMCP, installVSCodeExtension, launchVSCode, VSCODE_MODELS } from "./vscode.js";
19
+ import { formatMicrosUSD } from "./account.js";
20
+ import { apiRequest } from "../api.js";
21
+ import { run, runSync } from "../utils/exec.js";
22
+ import { httpFetch } from "../utils/http.js";
23
+ import { Journal } from "../utils/journal.js";
24
+ import { checkForUpdate, markUpdateAnnounced } from "../utils/update-check.js";
25
+ import { resolveLang, LANGS, LAUNCH, SURFACE, RETURN_PAGE, t } from "./flow.js";
26
+ import { renderReturnPage } from "./signin-page.js";
27
+ import { VERSION } from "../constants.js";
28
+ import { readFileSync, writeFileSync, unlinkSync, mkdirSync, mkdtempSync } from "node:fs";
29
+ import { tmpdir } from "node:os";
30
+ import { RED, RED_CTA, INK, PAPER, I, LOGO, CATEGORY_ICON, EXAMPLES, FILE_CLIENT_LABELS, CLIENT_LOGOS, CODEX_FACE, CLAUDE_BOT, OPENCODE_MARK, } from "./connect-shared.js";
31
+ import { renderT2Page } from "./connect-t2.js";
32
+ import { runTerminalFlow, flowClients } from "./connect-terminal.js";
33
+ import { pick } from "./prompt.js";
34
+ import { handOverResults, closeStaleResults, SUPERSEDE_GRACE_MS } from "./serve-results.js";
35
+ import { restoreTerminal, handOffStdin } from "./prompt.js";
36
+ import { printBanner } from "../utils/banner.js";
37
+ import { verifyKey } from "../utils/key-check.js";
38
+ /**
39
+ * Page templates. T1 is the original two-page flow (selection + live
40
+ * progress, then a separate success page); T2 is the guided six-step flow.
41
+ * Both run on the same server endpoints, so switching is a render choice:
42
+ * `--template t1` or AISA_CONNECT_TEMPLATE=t1. More templates slot in here.
43
+ */
44
+ export const CONNECT_TEMPLATES = ["t1", "t2"];
45
+ export const DEFAULT_TEMPLATE = "t2";
46
+ export function resolveTemplate(flag) {
47
+ const raw = (flag ?? process.env.AISA_CONNECT_TEMPLATE ?? DEFAULT_TEMPLATE).toLowerCase();
48
+ return CONNECT_TEMPLATES.includes(raw) ? raw : DEFAULT_TEMPLATE;
49
+ }
50
+ /**
51
+ * `aisa connect` — a one-shot local web page that wires AIsa's MCP servers
52
+ * into the coding agents installed on this machine.
53
+ *
54
+ * The shape is deliberate, learned from studying how others solve "install an
55
+ * MCP from a web page":
56
+ *
57
+ * - A remote page cannot touch the local machine, and Claude Code has no
58
+ * install deeplink (Cursor and VS Code do). The only bridge that works for
59
+ * every client is a local process serving a page on 127.0.0.1 — so that is
60
+ * all this is.
61
+ * - It is a *visitor*, not a resident: pick servers, pick clients, apply,
62
+ * sign in, exit. No daemon, no terminal takeover, no prompt or skill
63
+ * injection into the user's agent. The user stays in their own Claude Code.
64
+ * - Sign-in is one OAuth round for everything: with no key stored, the run
65
+ * starts with the same browser approval `aisa login` uses, which mints the
66
+ * durable "aisa cli" key (POST /v1/keys/mint). Every MCP entry is then
67
+ * written as a bearer and the model provider gets the same key — zero
68
+ * per-server authorization popups. Only if that sign-in fails do we fall
69
+ * back to each client's own OAuth machinery (`claude mcp login <name>` per
70
+ * server; `codex mcp add` runs its own flow), which still works but costs
71
+ * one browser round per server. A key configured beforehand skips the
72
+ * sign-in entirely.
73
+ * - The page reports the whole journey live (GET /status polling), and a
74
+ * dedicated success page opens at the end — spawned by this process via
75
+ * the OS browser command, so no popup blocker is involved — because users
76
+ * who tabbed away to the authorization rarely come back to the first tab.
77
+ *
78
+ * Page style matches the AIsa Console sign-in (auth.aisa.one, tokens read
79
+ * off the live page 2026-08-20): warm #f9f8f6 dot-grid background, black
80
+ * #0d0d0b top bar, Inter with 800-weight headlines, #e5322d headline red,
81
+ * #cc2b26 CTA red at 6px radius. Capability groups follow the pattern of
82
+ * Sentry's OAuth approval screen: checkbox + name + tool-count badge + a
83
+ * real description of what the capability gives the agent.
84
+ */
85
+ /** How long the page may sit untouched before we give up and exit. */
86
+ const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
87
+ /** How long the success page stays served before the process exits. Copy
88
+ * buttons are client-side, so the page keeps working after exit. */
89
+ const LINGER_AFTER_DONE_MS = 30 * 60 * 1000;
90
+ /** How long to wait for the T2 page to finish playing its checklist before
91
+ * the success tab opens anyway (the tab may have been closed). */
92
+ const PAGE_SEEN_TIMEOUT_MS = 90 * 1000;
93
+ export function detectClients() {
94
+ const clients = [];
95
+ // Claude Code: presence means the `claude` binary answers on PATH. Its MCP
96
+ // entries live in user scope managed by the binary itself, not in a config
97
+ // file we own — hence kind "cli".
98
+ const probe = runSync("claude", ["--version"], { timeout: 5_000 });
99
+ const claudeVersion = probe.status === 0 ? probe.stdout.trim() : "";
100
+ clients.push({
101
+ id: "claude-code",
102
+ label: "Claude Code",
103
+ kind: "cli",
104
+ detected: probe.status === 0,
105
+ detail: claudeVersion || "claude not found on PATH",
106
+ });
107
+ // Codex keeps its MCP servers in ~/.codex/config.toml, so like Claude Code
108
+ // it is detected by asking the binary rather than by looking for a file we
109
+ // own.
110
+ const codex = runSync("codex", ["--version"], { timeout: 5_000 });
111
+ clients.push({
112
+ id: "codex",
113
+ label: "Codex",
114
+ kind: "cli",
115
+ detected: codex.status === 0,
116
+ detail: codex.status === 0 ? codex.stdout.trim() : "codex not found on PATH",
117
+ });
118
+ // opencode: `opencode mcp add` (1.18+) writes the MCP entries; models go
119
+ // into its one JSON config. Detection stays the same discipline: actually
120
+ // run the binary.
121
+ const oc = runSync("opencode", ["--version"], { timeout: 5_000 });
122
+ clients.push({
123
+ id: "opencode",
124
+ label: "opencode",
125
+ kind: "cli",
126
+ detected: oc.status === 0,
127
+ detail: oc.status === 0 ? `opencode ${oc.stdout.trim()}` : "opencode not found on PATH",
128
+ });
129
+ // VS Code: its chat models and MCP servers are both files in the user
130
+ // profile directory, written without the UI. Detected when that
131
+ // directory exists, i.e. VS Code has run here at least once.
132
+ clients.push({
133
+ id: "vscode",
134
+ label: "VS Code",
135
+ kind: "file",
136
+ detected: vscodeDetected(),
137
+ detail: vscodeDetected() ? vscodeUserDir().replace(homedir(), "~") : "VS Code has not been run on this machine",
138
+ });
139
+ for (const [id, cfg] of Object.entries(MCP_CONFIGS)) {
140
+ // "Installed" here means the client's config directory exists. Coarse,
141
+ // but the false-positive cost is one harmless config file.
142
+ const detected = existsSync(dirname(expandHome(cfg.path)));
143
+ clients.push({
144
+ id,
145
+ label: FILE_CLIENT_LABELS[id] ?? id,
146
+ kind: "file",
147
+ detected,
148
+ detail: cfg.path,
149
+ });
150
+ }
151
+ return clients;
152
+ }
153
+ /**
154
+ * Configure one server entry in Claude Code's user scope. Remove-then-add
155
+ * because `claude mcp add` refuses to overwrite an existing name; removing a
156
+ * name that is not there fails too, which is exactly why that failure is
157
+ * ignored. The result is idempotent either way.
158
+ */
159
+ async function claudeCodeAdd(name, endpoint, key) {
160
+ await run("claude", ["mcp", "remove", "-s", "user", name], { timeout: 15_000 }).catch(() => { });
161
+ const args = ["mcp", "add", "-s", "user", "--transport", "http", name, endpoint];
162
+ if (key)
163
+ args.push("--header", `Authorization: Bearer ${key}`);
164
+ await run("claude", args, { timeout: 15_000 });
165
+ }
166
+ /**
167
+ * Add one server to Codex, letting Codex do the authorising.
168
+ *
169
+ * `codex mcp add --url` detects that the endpoint speaks OAuth and starts the
170
+ * flow itself — one command instead of the add-then-login pair Claude Code
171
+ * needs. Writing config.toml directly, as an earlier version did, skips that
172
+ * detection entirely and leaves entries that list as "Not logged in": present,
173
+ * enabled, and 401 on first use.
174
+ *
175
+ * stdio is inherited because the flow prints an authorisation URL and waits.
176
+ */
177
+ function codexAdd(name, endpoint, key,
178
+ /** Collects the tool's own output when it is not being shown live. */
179
+ said) {
180
+ return new Promise((resolve) => {
181
+ const args = ["mcp", "add", name, "--url", endpoint];
182
+ // Codex takes the *name* of an environment variable, never the token
183
+ // itself — so a key never reaches the process table or a shell history.
184
+ // With one configured we point every server at the same variable; without
185
+ // one, add detects OAuth support and authorises instead.
186
+ if (key)
187
+ args.push("--bearer-token-env-var", CODEX_KEY_ENV_VAR);
188
+ // Inherited only when this add might have to talk to the user: with no
189
+ // key it detects OAuth and prints a URL to visit, and a prompt captured
190
+ // into a buffer is a hang. With a key it says one line per server —
191
+ // "Added global MCP server 'aisa-web-search'." — and inheriting put that
192
+ // line straight onto the terminal, unindented and unmarked, in the middle
193
+ // of a column of • rows and ahead of the very line that summarises it.
194
+ // Captured, it becomes what it is: detail belonging to that summary.
195
+ const capture = Boolean(key) && Boolean(said);
196
+ // Inherited means it may prompt, which means it needs the keyboard to
197
+ // itself. See handOffStdin.
198
+ if (!capture)
199
+ handOffStdin();
200
+ const child = spawn("codex", args, capture ? { stdio: ["ignore", "pipe", "pipe"] } : { stdio: "inherit" });
201
+ if (capture) {
202
+ const take = (buf) => {
203
+ for (const raw of buf.toString().split("\n")) {
204
+ const line = raw.trim();
205
+ if (line)
206
+ said.push(line);
207
+ }
208
+ };
209
+ child.stdout?.on("data", take);
210
+ child.stderr?.on("data", take);
211
+ }
212
+ const timer = setTimeout(() => child.kill("SIGTERM"), 180_000);
213
+ child.once("close", (code) => {
214
+ clearTimeout(timer);
215
+ resolve(code === 0);
216
+ });
217
+ child.once("error", () => {
218
+ clearTimeout(timer);
219
+ resolve(false);
220
+ });
221
+ });
222
+ }
223
+ /**
224
+ * Drive Claude Code's own OAuth for one server: `claude mcp login` opens the
225
+ * browser authorization and stores the tokens in Claude Code's own store,
226
+ * where Claude Code also refreshes them. stdio is inherited on purpose — the
227
+ * login needs the user's real terminal (it prompts on stdin as a headless
228
+ * fallback), and its progress lines belong in front of the user.
229
+ */
230
+ function claudeCodeLogin(name) {
231
+ return new Promise((resolve) => {
232
+ handOffStdin();
233
+ const child = spawn("claude", ["mcp", "login", name], { stdio: "inherit" });
234
+ const timer = setTimeout(() => {
235
+ child.kill("SIGTERM");
236
+ }, 180_000);
237
+ child.once("close", (code) => {
238
+ clearTimeout(timer);
239
+ resolve(code === 0);
240
+ });
241
+ child.once("error", () => {
242
+ clearTimeout(timer);
243
+ resolve(false);
244
+ });
245
+ });
246
+ }
247
+ /**
248
+ * Cursor's install deeplink: `cursor://anysphere.cursor-deeplink/mcp/install`
249
+ * with the server name and its config (the same shape Cursor keeps in
250
+ * mcp.json) base64-encoded. Cursor opens a confirmation showing the config,
251
+ * then writes the entry itself — the one-click path Cursor documents.
252
+ */
253
+ export function cursorDeeplink(name, endpoint, key) {
254
+ const config = Buffer.from(JSON.stringify(buildEntry("url", endpoint, key)), "utf8").toString("base64");
255
+ return `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(name)}&config=${encodeURIComponent(config)}`;
256
+ }
257
+ async function applySelection(clientIds, chosen, key, dryRun, state) {
258
+ const results = [];
259
+ for (const id of clientIds) {
260
+ if (id === "cursor" && state?.deeplinks) {
261
+ // T2: hand Cursor its own deeplinks instead of writing mcp.json — the
262
+ // user confirms each inside Cursor, which then owns the entry.
263
+ state.deeplinks = chosen.map((s) => ({
264
+ slug: s.slug,
265
+ name: `aisa-${s.slug}`,
266
+ url: cursorDeeplink(`aisa-${s.slug}`, s.endpoint, key),
267
+ }));
268
+ results.push({
269
+ client: id,
270
+ ok: true,
271
+ message: `${chosen.length} install link${chosen.length === 1 ? "" : "s"} ready — open them in Cursor`,
272
+ });
273
+ }
274
+ else if (id === "claude-code") {
275
+ if (dryRun) {
276
+ results.push({ client: id, ok: true, message: `would run claude mcp add for ${chosen.length} servers` });
277
+ continue;
278
+ }
279
+ try {
280
+ for (const s of chosen) {
281
+ await claudeCodeAdd(`aisa-${s.slug}`, s.endpoint, key);
282
+ }
283
+ results.push({
284
+ client: id,
285
+ ok: true,
286
+ message: `${chosen.length} servers added (user scope)${key ? "" : " — browser authorization starts next"}`,
287
+ });
288
+ }
289
+ catch (e) {
290
+ results.push({ client: id, ok: false, message: e.message });
291
+ }
292
+ }
293
+ else if (id === "codex") {
294
+ if (dryRun) {
295
+ results.push({ client: id, ok: true, message: `would run codex mcp add for ${chosen.length} servers` });
296
+ continue;
297
+ }
298
+ let added = 0;
299
+ const said = [];
300
+ for (const s of chosen) {
301
+ const name = `aisa-${s.slug}`;
302
+ // Remove first: codex mcp add refuses an existing name, and removing
303
+ // one that is absent is a no-op we do not care about either way.
304
+ await run("codex", ["mcp", "remove", name], { timeout: 15_000 }).catch(() => { });
305
+ if (!(await codexAdd(name, s.endpoint, key, said)))
306
+ continue;
307
+ // The add stored only the env-var NAME; swap it for the literal
308
+ // header so the entry works in every terminal, exported or not.
309
+ if (key && !patchCodexMCPAuth(name, key).ok)
310
+ continue;
311
+ added++;
312
+ }
313
+ results.push(added === chosen.length
314
+ ? {
315
+ client: id,
316
+ ok: true,
317
+ said,
318
+ message: key
319
+ ? `${added} servers added with your key`
320
+ : `${added} servers added and authorized`,
321
+ }
322
+ : { client: id, ok: false, said, message: `only ${added} of ${chosen.length} servers were added` });
323
+ }
324
+ else if (id === "opencode") {
325
+ // The official command (1.18+): non-interactive, idempotent on the
326
+ // name, writes opencode.jsonc in the exact schema shape. Same doctrine
327
+ // as the other CLI agents — never hand-write what the vendor's own
328
+ // command can write.
329
+ if (dryRun) {
330
+ results.push({ client: id, ok: true, message: `would run opencode mcp add for ${chosen.length} servers` });
331
+ continue;
332
+ }
333
+ let added = 0;
334
+ for (const s of chosen) {
335
+ const args = ["mcp", "add", `aisa-${s.slug}`, "--url", s.endpoint];
336
+ if (key)
337
+ args.push("--header", `Authorization=Bearer ${key}`);
338
+ try {
339
+ await run("opencode", args, { timeout: 30_000 });
340
+ added++;
341
+ }
342
+ catch {
343
+ /* counted below */
344
+ }
345
+ }
346
+ results.push(added === chosen.length
347
+ ? {
348
+ client: id,
349
+ ok: true,
350
+ message: key
351
+ ? `${added} servers added with your key`
352
+ : `${added} servers added — opencode runs its own OAuth on first start`,
353
+ }
354
+ : { client: id, ok: false, message: `only ${added} of ${chosen.length} servers were added` });
355
+ }
356
+ else if (id === "vscode") {
357
+ if (dryRun) {
358
+ results.push({ client: id, ok: true, message: `would write ${chosen.length} servers to VS Code's mcp.json` });
359
+ continue;
360
+ }
361
+ const r = writeVSCodeMCP(chosen, key);
362
+ results.push(r.ok
363
+ ? { client: id, ok: true, message: `${r.written} servers → ${r.path.replace(homedir(), "~")}${key ? "" : " — VS Code signs in on first use"}` }
364
+ : { client: id, ok: false, message: r.reason });
365
+ }
366
+ else if (id === "claude-ai") {
367
+ // Nothing on this machine to write: claude.ai takes remote MCP servers
368
+ // as Connectors pasted in by the user, with its own OAuth. The page
369
+ // hands over the URLs; this step only confirms they are ready.
370
+ results.push({
371
+ client: id,
372
+ ok: true,
373
+ message: `${chosen.length} connector URL${chosen.length === 1 ? "" : "s"} ready — add them in claude.ai`,
374
+ });
375
+ }
376
+ else if (MCP_CONFIGS[id]) {
377
+ if (dryRun) {
378
+ results.push({ client: id, ok: true, message: `would write ${chosen.length + 1} entries to ${MCP_CONFIGS[id].path}` });
379
+ continue;
380
+ }
381
+ const r = writeClientConfig(id, chosen, key);
382
+ results.push(r.ok
383
+ ? { client: id, ok: true, message: `${r.written} servers → ${MCP_CONFIGS[id].path}` }
384
+ : { client: id, ok: false, message: r.reason });
385
+ }
386
+ else {
387
+ results.push({ client: id, ok: false, message: "unknown client" });
388
+ }
389
+ }
390
+ return results;
391
+ }
392
+ /** Long enough to read one sentence before the screen changes under you.
393
+ * Every handoff to the browser or to a slow command gets one. */
394
+ const BEFORE_HANDOFF_MS = 3000;
395
+ /** Seconds of visible warning before a sign-in tab opens. Long enough to
396
+ * survive the page's own checklist pacing, which can be several seconds
397
+ * behind the run at exactly this point. */
398
+ const SIGNIN_COUNTDOWN_S = 4;
399
+ /** How long to wait for the page to say the warning is up, before giving up
400
+ * on it and counting down anyway. */
401
+ const SIGNIN_ACK_WAIT_MS = 15_000;
402
+ /** How long a run waits for an approval that opened in a second tab. */
403
+ const SIGNIN_CATCH_TIMEOUT_MS = 10 * 60 * 1000;
404
+ /** Passed to `codex mcp add --bearer-token-env-var` so the add does not start
405
+ * its own OAuth flow; the entry is then patched to carry the literal header
406
+ * (see applySelection), because nothing guarantees a shell exports this.
407
+ * Matches the CLI's own variable so an exported key also just works. */
408
+ const CODEX_KEY_ENV_VAR = "AISA_API_KEY";
409
+ /** Below this the balance step lingers and nudges towards a top-up. */
410
+ const LOW_BALANCE_MICROS = 5_000_000;
411
+ /** The manual fallback when the inline sign-in cannot mint a key. */
412
+ const CONSOLE_KEYS_URL = "https://console.aisa.one/api-keys";
413
+ const pause = (ms) => new Promise((r) => setTimeout(r, ms));
414
+ /** Mutate one step in place; the page picks it up on its next poll. */
415
+ function setStep(state, id, patch) {
416
+ const step = state.steps.find((s) => s.id === id);
417
+ if (step)
418
+ Object.assign(step, patch);
419
+ }
420
+ /**
421
+ * The plan the page renders before anything runs.
422
+ *
423
+ * Order matters and mirrors what a person would do by hand: get the agent on
424
+ * the machine, prove who you are, see whether you can pay for anything, then
425
+ * wire the capabilities up. A later step that depends on an earlier one is
426
+ * marked skipped rather than failed when its prerequisite did not happen —
427
+ * "skipped" is information, "failed" is alarm.
428
+ */
429
+ function buildPlan(input) {
430
+ const steps = [];
431
+ for (const id of input.install) {
432
+ const label = INSTALLERS[id]?.label ?? id;
433
+ steps.push({
434
+ id: `install:${id}`,
435
+ label: `Install ${label}`,
436
+ state: "pending",
437
+ detail: INSTALLERS[id]?.command,
438
+ });
439
+ }
440
+ // The aisa command itself, so the npx visitor leaves with the full
441
+ // toolbox (balance, topup, login/key rotation) — shown as its own step,
442
+ // never a silent side effect. Always listed, so the plan reads the same on
443
+ // every machine; when the command is already there the step simply says so.
444
+ steps.push({
445
+ id: "install:aisa-cli",
446
+ label: "Install the AIsa CLI",
447
+ state: "pending",
448
+ detail: isInstalled("aisa")
449
+ ? "the aisa command is already on this machine"
450
+ : "npm install -g @aisa-one/cli — the aisa command for balance, top-up and key rotation",
451
+ });
452
+ // One sign-in, before anything that wants a credential: the browser
453
+ // approval mints the durable CLI key, and with it every MCP entry is a
454
+ // bearer and the model provider can be written — no per-server popups.
455
+ // If it fails at run time the per-server OAuth rounds come back as a
456
+ // fallback (added to the plan then, not promised now).
457
+ // Listed in a dry run too, so the rehearsal shows the real sequence.
458
+ if (!input.keyed) {
459
+ steps.push({
460
+ id: "signin",
461
+ label: "Sign in to AIsa",
462
+ state: "pending",
463
+ // Two different situations, and the difference matters to the reader:
464
+ // a first sign-in is expected, while a key that has stopped working is
465
+ // news — and without saying so the step looks like the setup forgot
466
+ // they had signed in before.
467
+ detail: input.keyRejected
468
+ ? "your stored key is no longer accepted — one browser approval replaces it"
469
+ : "one browser approval — it mints your CLI key",
470
+ });
471
+ }
472
+ const web = input.clients[0] === "claude-ai";
473
+ const cursorLinks = input.clients[0] === "cursor" && input.deeplink;
474
+ steps.push({
475
+ id: "mcp",
476
+ label: web
477
+ ? `Prepare ${input.servers.length} connector URL${input.servers.length === 1 ? "" : "s"}`
478
+ : cursorLinks
479
+ ? `Prepare ${input.servers.length} Cursor install link${input.servers.length === 1 ? "" : "s"}`
480
+ : `Add ${input.servers.length} MCP server${input.servers.length === 1 ? "" : "s"}`,
481
+ state: "pending",
482
+ detail: input.clients.join(", "),
483
+ });
484
+ if (input.llmMode === "switch") {
485
+ steps.push({
486
+ id: "llm",
487
+ label: "Point its models at AIsa",
488
+ state: "pending",
489
+ detail: "writes the agent's own provider settings; reversible",
490
+ });
491
+ }
492
+ else if (input.llmMode === "backup") {
493
+ const target = input.clients[0];
494
+ steps.push({
495
+ id: "llm-backup",
496
+ label: target === "claude-code"
497
+ ? "Install the claude-aisa command"
498
+ : target === "codex"
499
+ ? "Add the aisa profile and codex-aisa command"
500
+ : target === "vscode"
501
+ ? `Add ${VSCODE_MODELS.length} AIsa models to VS Code chat`
502
+ : "Add AIsa as a backup provider",
503
+ state: "pending",
504
+ detail: "your current models and settings stay untouched",
505
+ });
506
+ }
507
+ steps.push({
508
+ id: "balance",
509
+ label: "Check your AIsa balance",
510
+ state: "pending",
511
+ detail: "so an empty account is not a surprise at the first call",
512
+ });
513
+ return steps;
514
+ }
515
+ /**
516
+ * Run the plan, updating state as each step settles. Returns how many failed.
517
+ *
518
+ * Nothing here throws: one broken step must not cost the user the rest of the
519
+ * run, so every failure is recorded on its own row and the plan continues.
520
+ */
521
+ async function runPlan(state, input, log) {
522
+ let failures = 0;
523
+ const label = (id) => state.steps.find((s) => s.id === id)?.label ?? id;
524
+ const fail = (id, detail) => {
525
+ failures++;
526
+ setStep(state, id, { state: "fail", detail });
527
+ log.line("fail", label(id), detail);
528
+ };
529
+ const ok = (id, detail) => {
530
+ setStep(state, id, { state: "ok", detail });
531
+ log.line("ok", label(id), detail);
532
+ };
533
+ /**
534
+ * Already done, in an earlier pass.
535
+ *
536
+ * A failed sign-in stops the run and the page offers to try again, which
537
+ * re-enters here — so every block that writes something has to be able to
538
+ * recognise its own work and step over it. Installing twice is only
539
+ * wasteful; adding the same MCP entry twice has been a real bug.
540
+ */
541
+ const settled = (id) => state.steps.find((s) => s.id === id)?.state === "ok";
542
+ // Only the two blocks that run *before* the sign-in need this. Everything
543
+ // after it was skipped when the run stopped, so a retry finds it pending
544
+ // and does it once.
545
+ // ── install ──
546
+ for (const id of input.install) {
547
+ const stepId = `install:${id}`;
548
+ if (settled(stepId))
549
+ continue;
550
+ const label = INSTALLERS[id]?.label ?? id;
551
+ setStep(state, stepId, {
552
+ state: "running",
553
+ detail: `about to run ${INSTALLERS[id]?.command}`,
554
+ });
555
+ log.line("step", `Installing ${label}`, INSTALLERS[id]?.command);
556
+ await pause(BEFORE_HANDOFF_MS);
557
+ setStep(state, stepId, {
558
+ state: "running",
559
+ detail: `installing — this usually takes under a minute`,
560
+ });
561
+ if (input.dryRun) {
562
+ ok(stepId, "dry run — nothing installed");
563
+ continue;
564
+ }
565
+ const outcome = await installAgent(id);
566
+ if (outcome.ok) {
567
+ ok(stepId, outcome.alreadyInstalled ? "already installed" : "installed");
568
+ }
569
+ else {
570
+ // Not a hard failure: the user can run the command themselves and the
571
+ // rest of the plan still applies to whatever they already have.
572
+ setStep(state, stepId, {
573
+ state: "fail",
574
+ detail: `${outcome.detail} — run: ${outcome.command}`,
575
+ });
576
+ failures++;
577
+ log.line("fail", `Install ${label}`, outcome.detail);
578
+ log.note("run this yourself, then re-run connect:");
579
+ log.command(outcome.command);
580
+ }
581
+ }
582
+ // ── the CLI itself, so the npx visitor keeps the toolbox ──
583
+ if (state.steps.some((s) => s.id === "install:aisa-cli") && !settled("install:aisa-cli")) {
584
+ setStep(state, "install:aisa-cli", { state: "running", detail: "npm install -g @aisa-one/cli" });
585
+ const outcome = input.dryRun
586
+ ? { ok: true, alreadyInstalled: isInstalled("aisa"), detail: "", command: "" }
587
+ : await installAgent("aisa-cli");
588
+ if (outcome.ok) {
589
+ ok("install:aisa-cli", outcome.alreadyInstalled ? "already installed" : "installed — try `aisa balance` anytime");
590
+ }
591
+ else {
592
+ // Not fatal: everything else still applies; the user just keeps npx.
593
+ failures++;
594
+ setStep(state, "install:aisa-cli", {
595
+ state: "fail",
596
+ detail: `${outcome.detail} — run: ${outcome.command}`,
597
+ });
598
+ }
599
+ }
600
+ // ── sign in once, before anything that wants a credential ──
601
+ let key = input.key;
602
+ if (state.steps.some((s) => s.id === "signin")) {
603
+ // Counted down, not merely announced.
604
+ //
605
+ // The first version set a line saying a tab was about to open, waited
606
+ // three seconds and opened it — and the tab still arrived unannounced,
607
+ // because the page paces its checklist: rows are revealed one at a time
608
+ // with a dwell each, so the sign-in row often reached the screen at the
609
+ // very moment the three seconds ran out. The warning was displayed for
610
+ // nobody.
611
+ //
612
+ // A number that changes cannot be missed the same way. Whatever the page
613
+ // is behind by, the reader catches the tail of it and a ticking number
614
+ // reads as "something is about to happen" with no instructions needed.
615
+ // A machine with no browser gets none of this. The catcher exists only
616
+ // where a loopback redirect can be caught, which is the same condition as
617
+ // "a browser can be opened here" — so its absence is the signal, and
618
+ // every sentence below has to stop promising a tab that is not coming.
619
+ // Said on a server, "opening the sign-in in a new browser tab" is simply
620
+ // untrue, and the reader is left waiting for something that never
621
+ // appears while the instructions they actually need scroll up past it.
622
+ const willOpenTab = Boolean(input.catcher);
623
+ log.line("step", "You need an AIsa account", willOpenTab ? "opening the sign-in in a new browser tab" : "sign in from a machine that has a browser");
624
+ setStep(state, "signin", {
625
+ state: "running",
626
+ detail: willOpenTab
627
+ ? "AIsa needs an account — the sign-in opens in a new tab in a moment…"
628
+ : "AIsa needs an account — this machine has no browser, so the terminal will show you what to open",
629
+ });
630
+ // Wait to be told the line above is actually on screen. Measured, the
631
+ // page can be seven seconds behind the run at this row, so counting down
632
+ // from here counted down to nobody. Bounded: a page that was never opened
633
+ // never answers, and a terminal-only run must not stall on it.
634
+ if (willOpenTab && input.signinShown) {
635
+ await Promise.race([input.signinShown, pause(SIGNIN_ACK_WAIT_MS)]);
636
+ }
637
+ // The countdown warns about a tab that is about to steal the screen.
638
+ // Where no tab is coming there is nothing to warn about, and counting
639
+ // down to nothing just delays the instructions.
640
+ if (willOpenTab) {
641
+ for (let left = SIGNIN_COUNTDOWN_S; left > 0; left--) {
642
+ setStep(state, "signin", {
643
+ state: "running",
644
+ // Spelled out, and no trailing ellipsis. "in 2…" was read as a
645
+ // sentence that had been cut off rather than a countdown — the one
646
+ // word that says what the number means was the word missing.
647
+ detail: `AIsa needs an account — opening the sign-in in a new tab in ${left} second${left === 1 ? "" : "s"}`,
648
+ });
649
+ await pause(1000);
650
+ }
651
+ }
652
+ // With a deadline in it. Ten minutes of a turning spinner and no stated
653
+ // end is indistinguishable from a hang — and this is the one step where
654
+ // the run genuinely cannot proceed on its own, so it is the one place a
655
+ // reader most needs to know that waiting is bounded.
656
+ const until = new Date(Date.now() + SIGNIN_CATCH_TIMEOUT_MS);
657
+ const hhmm = `${String(until.getHours()).padStart(2, "0")}:${String(until.getMinutes()).padStart(2, "0")}`;
658
+ setStep(state, "signin", {
659
+ state: "running",
660
+ detail: willOpenTab
661
+ ? `sign in or create your account in the new tab — this setup waits until ${hhmm}`
662
+ : `follow the steps in the terminal — this setup waits until ${hhmm}`,
663
+ });
664
+ try {
665
+ if (input.dryRun) {
666
+ ok("signin", "dry run — the browser approval would open here");
667
+ }
668
+ else {
669
+ key = await mintCliKey({ lang: input.lang, catcher: input.catcher });
670
+ // Two facts, in the order they matter to the person who just left
671
+ // the page to go and do this: it worked, and here is what it got
672
+ // them. The old line led with the artifact — "your CLI key is
673
+ // stored" — which reports a file being written and leaves the
674
+ // question they actually went away to answer unanswered.
675
+ setStep(state, "signin", {
676
+ state: "ok",
677
+ detail: "signed in successfully — your AIsa key is now stored on this machine",
678
+ });
679
+ // Its own line rather than ok()'s label-plus-detail, which would read
680
+ // "Sign in to AIsa — signed in successfully — …".
681
+ log.line("ok", "Signed in successfully", "your AIsa key is stored on this machine");
682
+ }
683
+ }
684
+ catch (e) {
685
+ // The run stops here.
686
+ //
687
+ // It used to carry on: the per-server browser authorisation still
688
+ // exists, so "no key" was treated as a slower path rather than a wall.
689
+ // Two things were wrong with that. Every entry written from here is
690
+ // half a configuration — an endpoint with no credential — and the
691
+ // fallback itself waits on a terminal that a page-driven run does not
692
+ // have, so the checklist sat spinning on a step that was never going
693
+ // to finish. A person who could not sign in wants to sign in again,
694
+ // and that is now one button rather than a rerun.
695
+ failures++;
696
+ // First line only. These messages carry a follow-up under a newline —
697
+ // the authorisation URL, for a machine with no browser to open it —
698
+ // which is exactly right in a terminal and a wall of query string in a
699
+ // step's one-line detail, where the newline is collapsed away. The log
700
+ // keeps the whole thing.
701
+ const why = e.message.split("\n")[0].trim();
702
+ setStep(state, "signin", {
703
+ state: "fail",
704
+ detail: `${why} — nothing was written; sign in to continue`,
705
+ });
706
+ log.line("fail", "Sign in to AIsa", e.message);
707
+ for (const step of state.steps) {
708
+ if (step.state === "pending") {
709
+ setStep(state, step.id, { state: "skip", detail: "waiting for the sign-in" });
710
+ }
711
+ }
712
+ // Read by the page to offer the one action that unblocks this.
713
+ state.needsSignIn = true;
714
+ return failures;
715
+ }
716
+ }
717
+ // Reached the far side of the sign-in, so whatever it was is over.
718
+ state.needsSignIn = false;
719
+ // ── MCP entries ──
720
+ const willAuthorize = !key && input.clients[0] === "codex";
721
+ setStep(state, "mcp", {
722
+ state: "running",
723
+ detail: willAuthorize
724
+ ? "adding each server — your browser will open to authorize them"
725
+ : "writing client configuration",
726
+ });
727
+ if (willAuthorize)
728
+ await pause(BEFORE_HANDOFF_MS);
729
+ const results = await applySelection(input.clients, input.servers, key, input.dryRun, state);
730
+ state.results = results;
731
+ for (const r of results) {
732
+ log.line(r.ok ? "ok" : "fail", r.client, r.message);
733
+ // Under the summary, not ahead of it, and set in from it — this is what
734
+ // the tool said while doing the thing the line above just reported.
735
+ for (const line of r.said ?? [])
736
+ log.sub(line);
737
+ }
738
+ const mcpOk = results.length > 0 && results.every((r) => r.ok);
739
+ if (mcpOk) {
740
+ setStep(state, "mcp", { state: "ok", detail: results.map((r) => r.client).join(", ") });
741
+ }
742
+ else {
743
+ failures++;
744
+ setStep(state, "mcp", {
745
+ state: "fail",
746
+ detail: results.filter((r) => !r.ok).map((r) => `${r.client}: ${r.message}`).join("; "),
747
+ });
748
+ }
749
+ // ── LLM backup: AIsa beside the user's own setup, nothing switched ──
750
+ if (state.steps.some((s) => s.id === "llm-backup")) {
751
+ const target = input.clients[0];
752
+ setStep(state, "llm-backup", { state: "running", detail: "adding AIsa without touching your setup" });
753
+ if (input.dryRun) {
754
+ ok("llm-backup", "dry run — nothing written");
755
+ }
756
+ else if (!key) {
757
+ setStep(state, "llm-backup", { state: "skip", detail: "needs a key — run 'aisa login', then 'aisa connect' again" });
758
+ }
759
+ else if (target === "claude-code") {
760
+ writeClaudeAisaSettings(key, DEFAULT_MODELS);
761
+ const w = installWrappers(["claude-aisa"]);
762
+ if (w.ok) {
763
+ ok("llm-backup", `claude-aisa → ${w.dir}${w.pathHint ? " (add it to PATH: " + w.pathHint + ")" : ""}`);
764
+ }
765
+ else
766
+ fail("llm-backup", `could not write the wrapper into ${w.dir}`);
767
+ }
768
+ else if (target === "codex") {
769
+ const r = writeCodexAisaProfile(key);
770
+ const w = r.ok ? installWrappers(["codex-aisa"]) : { ok: false, dir: "", wrote: [] };
771
+ if (r.ok && w.ok) {
772
+ ok("llm-backup", `codex-aisa → ${w.dir}${"pathHint" in w && w.pathHint ? " (add it to PATH: " + w.pathHint + ")" : ""}`);
773
+ }
774
+ else
775
+ fail("llm-backup", r.ok ? `could not write the wrapper` : r.reason);
776
+ }
777
+ else if (target === "opencode") {
778
+ const r = writeOpencodeAisaBackup(key);
779
+ if (r.ok)
780
+ ok("llm-backup", `aisa provider added — pick aisa/${DEFAULT_MODELS.model} in opencode`);
781
+ else
782
+ fail("llm-backup", r.reason);
783
+ }
784
+ else if (target === "vscode") {
785
+ // Preferred: the AIsa extension provisions key and models through VS
786
+ // Code's own command. Fallback: write the models file and let the
787
+ // user paste the key once (the results page explains how).
788
+ setStep(state, "llm-backup", { state: "running", detail: "installing the AIsa extension so VS Code stores your key itself" });
789
+ const ext = installVSCodeExtension();
790
+ if (ext.ok) {
791
+ ok("llm-backup", `${VSCODE_MODELS.length} models under "AIsa" in the chat model picker — key provisioned by the AIsa extension`);
792
+ }
793
+ else {
794
+ const r = writeVSCodeLLM();
795
+ if (r.ok)
796
+ ok("llm-backup", `${VSCODE_MODELS.length} models under "AIsa" in the chat model picker — paste your key once there (extension: ${ext.reason})`);
797
+ else
798
+ fail("llm-backup", r.reason);
799
+ }
800
+ }
801
+ else {
802
+ setStep(state, "llm-backup", { state: "skip", detail: "not available for this client" });
803
+ }
804
+ }
805
+ // ── LLM provider ──
806
+ if (state.steps.some((s) => s.id === "llm")) {
807
+ setStep(state, "llm", { state: "running", detail: "writing provider settings" });
808
+ if (input.dryRun) {
809
+ ok("llm", "dry run — nothing written");
810
+ }
811
+ else if (!key) {
812
+ // Only reachable when the sign-in above failed (or was declined): the
813
+ // normal path mints a key before this step runs. A provider entry has
814
+ // nowhere to put an OAuth token, so without a key the fallback is the
815
+ // console page that hands them out, with exact instructions.
816
+ setStep(state, "llm", {
817
+ state: "running",
818
+ detail: "opening console.aisa.one/api-keys — copy a key from there…",
819
+ });
820
+ await pause(BEFORE_HANDOFF_MS);
821
+ if (withBrowserEarly())
822
+ openBrowser(CONSOLE_KEYS_URL);
823
+ setStep(state, "llm", {
824
+ state: "skip",
825
+ detail: "waiting on a key — run 'aisa login --key <key>' with the one you just copied, then 'aisa connect' again",
826
+ });
827
+ log.line("warn", "No API key yet", "the model provider was not written");
828
+ log.note(`copy a key from ${CONSOLE_KEYS_URL}, then:`);
829
+ log.command("aisa login --key <key>");
830
+ log.command("aisa connect");
831
+ }
832
+ else {
833
+ const target = input.clients[0];
834
+ const models = defaultModelsFor(target);
835
+ const res = target === "codex"
836
+ ? writeCodexLLM(key, models)
837
+ : target === "opencode"
838
+ ? writeOpencodeLLM(key, models)
839
+ : writeClaudeCodeLLM(key, models);
840
+ if (res.ok) {
841
+ ok("llm", `${models.model} via ${res.path}`);
842
+ log.line("write", "Wrote model settings", res.path);
843
+ if (target === "codex") {
844
+ // A freshly installed Codex offers to sign in to OpenAI on first
845
+ // run. Nothing here needs that account, and picking one of those
846
+ // options sends the user down a path that ignores this config.
847
+ log.note("start it in a new terminal — skip any OpenAI sign-in prompt, it is not needed:");
848
+ log.command("codex");
849
+ }
850
+ else {
851
+ log.note("start it in a new terminal to pick up the new models:");
852
+ log.command(target === "opencode" ? "opencode" : "claude");
853
+ }
854
+ }
855
+ else
856
+ fail("llm", res.reason);
857
+ }
858
+ }
859
+ // ── authorization, one browser round per server ──
860
+ const authSteps = state.steps.filter((s) => s.id.startsWith("auth:"));
861
+ if (authSteps.length > 0) {
862
+ if (!mcpOk) {
863
+ for (const step of authSteps) {
864
+ setStep(state, step.id, { state: "skip", detail: "the entries were not added" });
865
+ }
866
+ }
867
+ else {
868
+ state.phase = "authorizing";
869
+ log.line("step", "Authorizing each server in your browser");
870
+ for (const step of authSteps) {
871
+ const slug = step.id.slice("auth:".length);
872
+ const name = `aisa-${slug}`;
873
+ // Say what is about to happen, then pause long enough to read it.
874
+ // A browser tab that appears with no warning reads as something going
875
+ // wrong; a sentence and a beat make it an expected step.
876
+ setStep(state, step.id, {
877
+ state: "running",
878
+ detail: "opening the AIsa sign-in in your browser — approve it there…",
879
+ });
880
+ await pause(BEFORE_HANDOFF_MS);
881
+ setStep(state, step.id, {
882
+ state: "running",
883
+ detail: "waiting for you to approve it in the browser tab",
884
+ });
885
+ state.auth[name] = "authorizing";
886
+ const authorized = await claudeCodeLogin(name);
887
+ state.auth[name] = authorized ? "ok" : "fail";
888
+ if (authorized)
889
+ ok(step.id, "authorized");
890
+ else
891
+ fail(step.id, `not authorized — retry with: claude mcp login ${name}`);
892
+ }
893
+ }
894
+ }
895
+ // ── balance, the last thing before the success page ──
896
+ setStep(state, "balance", { state: "running", detail: "reading your account" });
897
+ const balance = await readBalance(key);
898
+ state.balanceMicros = balance;
899
+ if (balance === null) {
900
+ // Not a failure: an unknown balance costs nothing.
901
+ setStep(state, "balance", {
902
+ state: "skip",
903
+ detail: key
904
+ ? "could not read the balance right now"
905
+ : "needs a key — run 'aisa login', then 'aisa balance'",
906
+ });
907
+ }
908
+ else if (balance <= 0) {
909
+ setStep(state, "balance", {
910
+ state: "ok",
911
+ detail: "no credit yet — add some with 'aisa topup' before your first call",
912
+ });
913
+ log.line("warn", "No credit yet", "your first call will fail without it");
914
+ log.command("aisa topup", "add credit");
915
+ }
916
+ else if (balance <= LOW_BALANCE_MICROS) {
917
+ setStep(state, "balance", {
918
+ state: "ok",
919
+ detail: `${formatMicrosUSD(balance)} available — running low; top up on the results page so nothing stops mid-task`,
920
+ });
921
+ }
922
+ else {
923
+ setStep(state, "balance", { state: "ok", detail: `${formatMicrosUSD(balance)} available` });
924
+ }
925
+ return failures;
926
+ }
927
+ /** Account balance in micros, or null when it cannot be read. */
928
+ async function readBalance(key) {
929
+ if (!key)
930
+ return null;
931
+ try {
932
+ const res = await apiRequest(key, "credits/balance");
933
+ if (!res.success || !res.data)
934
+ return null;
935
+ return Number(res.data.account_balance_micros_usd);
936
+ }
937
+ catch {
938
+ return null;
939
+ }
940
+ }
941
+ // ── shared page shell (brand: auth.aisa.one) ────────────────────────────────
942
+ function shell(title, body) {
943
+ return `<!doctype html>
944
+ <html><head><meta charset="utf-8"><title>${title}</title>
945
+ <meta name="viewport" content="width=device-width,initial-scale=1">
946
+ <!-- The address of this page contains the run token; see connect-t2.ts. -->
947
+ <meta name="referrer" content="no-referrer">
948
+ <style>
949
+ :root {
950
+ --paper: ${PAPER}; --ink: #1c1b1a; --muted: #6d6a66; --line: #e7e4df;
951
+ --card: #ffffff; --red: ${RED}; --red-cta: ${RED_CTA}; --bar: ${INK};
952
+ --tint: #fdf1ef; --ok: #2e7d43;
953
+ }
954
+ @media (prefers-color-scheme: dark) {
955
+ :root { --paper: #141312; --ink: #f0eeeb; --muted: #9b9792; --line: #2c2a27;
956
+ --card: #1d1c1a; --tint: #2a1917; --ok: #57b06f; }
957
+ }
958
+ * { box-sizing: border-box; margin: 0; }
959
+ body { background: var(--paper); color: var(--ink);
960
+ font: 16px/1.6 Inter, "Inter Fallback", "PingFang SC", ui-sans-serif, system-ui, sans-serif;
961
+ background-image: radial-gradient(color-mix(in srgb, var(--muted) 22%, transparent) 1px, transparent 1px);
962
+ background-size: 22px 22px; }
963
+ .bar { background: var(--bar); color: #fff; display: flex; align-items: center;
964
+ gap: .55rem; padding: .8rem 1.4rem; font-weight: 600; }
965
+ .bar .tag { margin-left: .4rem; font-weight: 400; opacity: .55; font-size: .85rem; }
966
+ .bar .local { margin-left: auto; font-weight: 400; font-size: .78rem; opacity: .5; }
967
+ main { padding: 1.7rem 12% 4rem; }
968
+ .cols { display: grid; grid-template-columns: minmax(0, 1fr) 552px; gap: 2.6rem;
969
+ align-items: start; margin-top: 1rem; }
970
+ .rail { position: sticky; top: 1.4rem; }
971
+ .rail h2:first-child { margin-top: .4rem; }
972
+ @media (max-width: 1180px) {
973
+ main { padding: 2.4rem 6% 4rem; }
974
+ .cols { grid-template-columns: 1fr; }
975
+ .rail { position: static; }
976
+ }
977
+ .eyebrow { display: flex; align-items: center; gap: .55rem; color: var(--muted);
978
+ font-size: .74rem; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; }
979
+ .eyebrow::before { content: ""; width: 26px; height: 3px; background: var(--red); }
980
+ h1 { font-size: 2.05rem; font-weight: 800; letter-spacing: -.02em; margin: .4rem 0 .35rem; }
981
+ h1 em { font-style: normal; color: var(--red); }
982
+ .lede { color: var(--muted); max-width: none; font-size: .98rem; }
983
+ h2 { display: flex; align-items: center; gap: .55rem; font-size: 1.08rem; font-weight: 700;
984
+ margin: 2rem 0 .9rem; }
985
+ h2 .n { display: inline-flex; align-items: center; justify-content: center; width: 24px;
986
+ height: 24px; border-radius: 50%; background: var(--red); color: #fff; font-size: .8rem; }
987
+ .cat { display: flex; align-items: center; gap: .45rem; color: var(--muted);
988
+ font-size: .8rem; font-weight: 600; letter-spacing: .1em; text-transform: uppercase;
989
+ margin: 1.2rem 0 .5rem; }
990
+ .cat svg { width: 15px; height: 15px; }
991
+ .card { display: flex; gap: .9rem; align-items: flex-start; background: var(--card);
992
+ border: 1px solid var(--line); border-left: 3px solid var(--line); border-radius: 8px;
993
+ padding: 1rem 1.1rem; margin-bottom: .6rem; cursor: pointer; transition: border-color .15s; }
994
+ .card:hover { border-color: color-mix(in srgb, var(--red) 45%, var(--line)); }
995
+ .card.on { border-left-color: var(--red); background: color-mix(in srgb, var(--tint) 55%, var(--card)); }
996
+ .card.off { opacity: .55; cursor: default; }
997
+ .card input { width: 20px; height: 20px; margin-top: .2rem; accent-color: var(--red-cta); flex: none; }
998
+ .card .body { min-width: 0; }
999
+ .card .head { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap; }
1000
+ .card .name { font-weight: 700; }
1001
+ .clogo { display: inline-flex; align-items: center; margin-right: .55rem; flex: none; }
1002
+ .clogo svg { display: block; }
1003
+ .badge { font-size: .74rem; font-weight: 700; padding: .18rem .62rem; border-radius: 99px;
1004
+ border: 1px solid var(--line); color: var(--muted); white-space: nowrap; }
1005
+ .badge.ok { background: var(--ok); border-color: var(--ok); color: #fff; }
1006
+ .badge.todo { background: #f59e0b; border-color: #f59e0b; color: #fff; }
1007
+ /* The no-model warning: the one place on the page allowed to shout. A
1008
+ freshly installed agent with no backend is a broken first-run. */
1009
+ .modelwarn { margin-top: .7rem; border: 2px solid #f59e0b; border-radius: 10px;
1010
+ background: color-mix(in srgb, #f59e0b 12%, var(--card)); padding: .9rem 1rem; }
1011
+ .modelwarn .mw-head { font-weight: 800; font-size: 1rem; color: color-mix(in srgb, #b45309 60%, var(--ink));
1012
+ margin-bottom: .35rem; }
1013
+ .modelwarn .mw-body { font-size: .92rem; color: color-mix(in srgb, #92400e 45%, var(--ink)); line-height: 1.5; }
1014
+ .modelwarn .mw-fix { margin-top: .7rem; background: var(--cta); color: #fff;
1015
+ border: 0; border-radius: 6px; padding: .55rem 1.3rem; font-weight: 700;
1016
+ font-size: .95rem; cursor: pointer; }
1017
+ .modelwarn .mw-fix:hover { filter: brightness(1.08); }
1018
+ @keyframes mwshake { 0%,100% { transform: translateX(0); }
1019
+ 25% { transform: translateX(-5px); } 75% { transform: translateX(5px); } }
1020
+ /* The loud state a card jumps to the moment its install step succeeds. */
1021
+ .badge.installed { background: var(--ok); border-color: var(--ok); color: #fff; }
1022
+ .card.freshly-installed { border-color: var(--ok);
1023
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--ok) 25%, transparent); }
1024
+ .card .brief { color: var(--muted); font-size: .93rem; }
1025
+ .card .desc { color: var(--muted); font-size: .93rem; margin-top: .25rem;
1026
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
1027
+ .card.open .desc { display: block; -webkit-line-clamp: unset; }
1028
+ .card .more { display: inline-block; margin-top: .15rem; color: var(--red); font-weight: 600;
1029
+ font-size: .82rem; cursor: pointer; }
1030
+ .chips { color: var(--muted); font-size: .82rem; margin: .2rem 0 .4rem; line-height: 1.9; }
1031
+ .chips b { color: var(--ink); font-weight: 600; }
1032
+ .authnote { display: flex; gap: .7rem; align-items: flex-start; background: var(--card);
1033
+ border: 1px solid var(--line); border-radius: 8px; padding: 1rem 1.1rem; color: var(--muted);
1034
+ font-size: .93rem; }
1035
+ .authnote svg { flex: none; margin-top: .1rem; color: var(--red); }
1036
+ .authnote b { color: var(--ink); }
1037
+ .cta { display: flex; width: 100%; align-items: center; justify-content: center; gap: .6rem;
1038
+ margin-top: 1.6rem; background: var(--red-cta); color: #fff; border: none; border-radius: 6px;
1039
+ font: inherit; font-weight: 600; font-size: 1.12rem; padding: .95rem 2.2rem; cursor: pointer; }
1040
+ .cta:hover { background: color-mix(in srgb, var(--red-cta) 88%, black); }
1041
+ .cta:disabled { opacity: .55; cursor: default; }
1042
+ a.cta { text-decoration: none; margin-top: .9rem; }
1043
+ .fine { color: var(--muted); font-size: .84rem; margin-top: .8rem; }
1044
+ #progress { margin-top: 1.6rem; display: none; }
1045
+ .step { display: flex; align-items: flex-start; gap: .7rem; padding: .6rem .2rem;
1046
+ border-bottom: 1px dashed var(--line); font-size: .95rem;
1047
+ opacity: .5; transition: opacity .3s; }
1048
+ .step.running, .step.ok, .step.fail { opacity: 1; }
1049
+ .step .body { min-width: 0; }
1050
+ .step .lbl { display: block; font-weight: 500; }
1051
+ .step .det { display: block; color: var(--muted); font-size: .84rem; margin-top: .15rem; }
1052
+ .step .st { margin-left: auto; font-size: .8rem; font-weight: 600; color: var(--muted);
1053
+ white-space: nowrap; padding-left: .6rem; }
1054
+ .step.ok .st { color: var(--ok); } .step.fail .st { color: var(--red); }
1055
+ /* The marker carries the state: an empty ring waiting, a spinner working,
1056
+ a tick or cross when settled. Position is fixed so rows never jump. */
1057
+ .step .mark { flex: none; width: 16px; height: 16px; margin-top: .15rem;
1058
+ border-radius: 50%; border: 2px solid var(--line); display: flex;
1059
+ align-items: center; justify-content: center; font-size: 11px; font-weight: 700;
1060
+ color: #fff; transition: background .25s, border-color .25s; }
1061
+ .step.running .mark { border-color: var(--red); border-top-color: transparent;
1062
+ animation: r .8s linear infinite; }
1063
+ .step.ok .mark { background: var(--ok); border-color: var(--ok); }
1064
+ .step.ok .mark::after { content: "\\2713"; }
1065
+ .step.fail .mark { background: var(--red); border-color: var(--red); }
1066
+ .step.fail .mark::after { content: "\\2715"; }
1067
+ .step.skip .mark { border-style: dotted; }
1068
+ .spin { display: inline-block; width: 12px; height: 12px; border: 2px solid var(--line);
1069
+ border-top-color: var(--red); border-radius: 50%; animation: r 1s linear infinite; }
1070
+ @keyframes r { to { transform: rotate(360deg); } }
1071
+ /* Overall progress: one bar so a long run reads at a glance. */
1072
+ .bar-wrap { height: 4px; background: var(--line); border-radius: 99px; overflow: hidden;
1073
+ margin: .9rem 0 .3rem; }
1074
+ .bar-fill { height: 100%; width: 0; background: var(--red); border-radius: 99px;
1075
+ transition: width .4s ease; }
1076
+ .bar-note { color: var(--muted); font-size: .8rem; }
1077
+ .bigcheck { width: 64px; height: 64px; border-radius: 50%; background: var(--red);
1078
+ color: #fff; display: flex; align-items: center; justify-content: center; margin-bottom: 1.2rem; }
1079
+ .bigcheck svg { width: 34px; height: 34px; }
1080
+ .examples { display: grid; grid-template-columns: 1fr; gap: .8rem; max-width: 62rem; }
1081
+ .example { background: var(--card); border: 1px solid var(--line); border-radius: 8px;
1082
+ padding: 1rem 1.1rem; display: flex; gap: .9rem; align-items: flex-start; }
1083
+ .example .txt { font-size: .95rem; }
1084
+ .example .srv { color: var(--red); font-weight: 600; font-size: .74rem; letter-spacing: .06em;
1085
+ text-transform: uppercase; display: block; margin-bottom: .25rem; }
1086
+ .example button { margin-left: auto; flex: none; display: inline-flex; align-items: center;
1087
+ gap: .35rem; font: inherit; font-size: .8rem; font-weight: 600; color: var(--ink);
1088
+ background: transparent; border: 1px solid var(--line); border-radius: 6px;
1089
+ padding: .35rem .7rem; cursor: pointer; }
1090
+ .example button:hover { border-color: var(--red); color: var(--red); }
1091
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85em;
1092
+ background: color-mix(in srgb, var(--muted) 12%, transparent); padding: .1em .35em;
1093
+ border-radius: 4px; }
1094
+ </style></head><body>
1095
+ <div class="bar">${LOGO}
1096
+ <span class="tag">MCP Connect</span><span class="local">local · 127.0.0.1</span></div>
1097
+ <main>${body}</main>
1098
+ </body></html>`;
1099
+ }
1100
+ // ── page A: selection + live progress ───────────────────────────────────────
1101
+ function renderPage(servers, clients, token, keyed, canInstall) {
1102
+ const byCategory = new Map();
1103
+ for (const s of servers) {
1104
+ const list = byCategory.get(s.category) ?? [];
1105
+ list.push(s);
1106
+ byCategory.set(s.category, list);
1107
+ }
1108
+ const serverGroups = [...byCategory.entries()]
1109
+ .map(([cat, list]) => {
1110
+ const rows = list
1111
+ .map((s) => {
1112
+ const checked = MCP_DEFAULT_SLUGS.includes(s.slug) ? "checked" : "";
1113
+ // The full manifest description, CSS-clamped to two lines; "more"
1114
+ // expands it and is hidden by the page script when the text already
1115
+ // fits — so short cards carry no dead link and long ones stay tidy.
1116
+ return `<label class="card ${checked ? "on" : ""}" data-kind="server">
1117
+ <input type="checkbox" name="server" value="${s.slug}" ${checked}>
1118
+ <span class="body"><span class="head"><span class="name">${stripped(s.name)}</span>
1119
+ <span class="badge">${s.toolCount} tools</span></span>
1120
+ <span class="desc">${s.description}</span>
1121
+ <span class="more" data-more>more</span></span></label>`;
1122
+ })
1123
+ .join("\n");
1124
+ return `<div class="cat">${CATEGORY_ICON[cat] ?? I.sparkles}${cat}</div>\n${rows}`;
1125
+ })
1126
+ .join("\n");
1127
+ const usable = clients.filter((c) => c.detected);
1128
+ const installable = clients.filter((c) => !c.detected && INSTALLERS[c.id] && canInstall);
1129
+ const rest = clients.filter((c) => !c.detected && !installable.some((i) => i.id === c.id));
1130
+ // One target per run, on purpose. Each client has its own install, config
1131
+ // format and authorisation dance; doing several at once turns one failure
1132
+ // into a puzzle about which of them failed and what state the rest are in.
1133
+ const clientRows = usable
1134
+ .map((c, i) => `<label class="card${i === 0 ? " on" : ""}" data-kind="client">
1135
+ <input type="radio" name="client" value="${c.id}"${i === 0 ? " checked" : ""}>
1136
+ <span class="body"><span class="head">${CLIENT_LOGOS[c.id] ? `<span class="clogo">${CLIENT_LOGOS[c.id]}</span>` : ""}<span class="name">${c.label}</span>
1137
+ <span class="badge ok">✓ detected</span></span>
1138
+ <span class="brief">${c.detail}</span></span></label>`)
1139
+ .join("\n") +
1140
+ installable
1141
+ .map((c) => `<label class="card" data-kind="client" data-cid="${c.id}">
1142
+ <input type="radio" name="client" value="${c.id}" data-install="1">
1143
+ <span class="body"><span class="head">${CLIENT_LOGOS[c.id] ? `<span class="clogo">${CLIENT_LOGOS[c.id]}</span>` : ""}<span class="name">${c.label}</span>
1144
+ <span class="badge todo" data-badge>not installed</span></span>
1145
+ <span class="brief" data-brief>Install <b>and</b> connect it \u2014 <code>${INSTALLERS[c.id].command}</code></span></span></label>`)
1146
+ .join("\n") +
1147
+ (rest.length
1148
+ ? `<div class="chips">${rest.map((c) => `${c.label} <i>· not found</i>`).join(" &nbsp;&nbsp; ")}</div>`
1149
+ : "");
1150
+ const totalTools = servers.reduce((n, s) => n + s.toolCount, 0);
1151
+ // Named per client so the page can say which model a pick actually gets.
1152
+ const modelByClient = Object.fromEntries(clients.map((c) => [c.id, defaultModelsFor(c.id).model]));
1153
+ const authCopy = keyed
1154
+ ? `Your configured AIsa API key is written into each entry — <b>no sign-in needed</b>.`
1155
+ : `<b>One sign-in, nothing to paste.</b> Your browser opens the AIsa approval
1156
+ <b>once</b>; it issues a long-lived key for this machine, and every server
1157
+ and model below is configured with it — no further popups.`;
1158
+ const body = `
1159
+ <div class="eyebrow">Connect</div>
1160
+ <h1><em>AIsa MCP</em> — powerful real-world reach for your agent</h1>
1161
+ <p class="lede">One connection puts <b>${totalTools} live tools</b> — web search &amp; research,
1162
+ X/Twitter, Reddit, Instagram, stocks, crypto, prediction markets and B2B data — inside the
1163
+ coding agent you already use. Pick what you need, press Connect, approve in the browser. Done.</p>
1164
+
1165
+ <div class="cols">
1166
+ <div class="left">
1167
+ <h2><span class="n">1</span>Choose capabilities</h2>
1168
+ ${serverGroups}
1169
+ </div>
1170
+
1171
+ <aside class="rail">
1172
+ <h2><span class="n">2</span>Install into</h2>
1173
+ ${clientRows}
1174
+
1175
+ <button class="cta" id="apply">Connect ${I.arrow}</button>
1176
+
1177
+ <h2 style="margin-top:1.6rem"><span class="n">3</span>Models</h2>
1178
+ <label class="card" data-kind="llm" id="llmcard">
1179
+ <input type="checkbox" id="llm">
1180
+ <span class="body"><span class="head"><span class="name">Run it on AIsa models</span></span>
1181
+ <span class="brief" id="llmbrief">Points the agent's model traffic at AIsa.
1182
+ Reversible \u2014 it writes the agent's own provider settings and nothing else.</span></span></label>
1183
+
1184
+ <div id="backupmodes" style="display:none">
1185
+ <label class="card on" data-kind="lmode">
1186
+ <input type="radio" name="lmode" value="backup" checked>
1187
+ <span class="body"><span class="head"><span class="name">Add AIsa as a backup — recommended</span></span>
1188
+ <span class="brief" id="backupbrief"></span></span></label>
1189
+ <label class="card" data-kind="lmode">
1190
+ <input type="radio" name="lmode" value="switch">
1191
+ <span class="body"><span class="head"><span class="name">Switch its models to AIsa</span></span>
1192
+ <span class="brief">Points this agent's model traffic at AIsa. Reversible — it writes the
1193
+ agent's own provider settings and nothing else.</span></span></label>
1194
+ <label class="card" data-kind="lmode">
1195
+ <input type="radio" name="lmode" value="skip">
1196
+ <span class="body"><span class="head"><span class="name">Not now</span></span>
1197
+ <span class="brief">Leave models exactly as they are.</span></span></label>
1198
+ </div>
1199
+
1200
+ <div id="modelwarn" class="modelwarn" style="display:none">
1201
+ <div class="mw-head">\u26a0\ufe0e Installing without a model backend</div>
1202
+ <div class="mw-body">A fresh install <b>cannot answer a single prompt</b> until you
1203
+ configure a model provider by hand. Turn on AIsa models and it leaves here ready to
1204
+ work: <b>Claude, GPT, Gemini, DeepSeek, Kimi, GLM</b> and the rest, one key,
1205
+ prices well below going direct.</div>
1206
+ <button type="button" class="mw-fix" id="modelfix">Use AIsa models \u2192</button>
1207
+ </div>
1208
+
1209
+ <h2 style="margin-top:1.6rem"><span class="n">4</span>Authorize</h2>
1210
+ <div class="authnote">${I.shield}<div>${authCopy}</div></div>
1211
+ <p class="fine">Served by the local <code>aisa connect</code> process — nothing leaves your
1212
+ machine except the OAuth you approve. The process exits when everything is connected.</p>
1213
+
1214
+ <div id="progress"></div>
1215
+ <div id="result" class="fine"></div>
1216
+ </aside>
1217
+ </div>
1218
+
1219
+ <script>
1220
+ (function () {
1221
+ var TOKEN = ${JSON.stringify(token)};
1222
+ var btn = document.getElementById("apply");
1223
+ var ARROW = btn.innerHTML.replace(/^[^<]*/, "");
1224
+ var progress = document.getElementById("progress");
1225
+ var result = document.getElementById("result");
1226
+
1227
+ // The button says what pressing it will do: installing is slower and more
1228
+ // invasive than writing config, so it should never be a surprise.
1229
+ var llmBox = document.getElementById("llm");
1230
+ var llmBrief = document.getElementById("llmbrief");
1231
+ var LLM_BRIEF = llmBrief.innerHTML;
1232
+ var lastClient = null;
1233
+ var MODEL_FOR = ${JSON.stringify(modelByClient)};
1234
+
1235
+ function syncButton() {
1236
+ if (btn.disabled) return;
1237
+ var chosen = document.querySelector('input[name="client"]:checked');
1238
+ var installing = chosen && chosen.dataset.install === "1";
1239
+ btn.innerHTML = (installing ? "Install &amp; connect " : "Connect ") + ARROW;
1240
+
1241
+ // An agent being installed right now has no model backend at all, so this
1242
+ // is on by default there and off for one already in use — changing a
1243
+ // working setup should be the user's decision, not ours. Only re-applied
1244
+ // when the target changes, so a deliberate tick is never undone.
1245
+ if (chosen && chosen.value !== lastClient) {
1246
+ lastClient = chosen.value;
1247
+ llmBox.checked = Boolean(installing);
1248
+ llmBox.closest(".card").classList.toggle("on", llmBox.checked);
1249
+ var model = MODEL_FOR[chosen.value] || "";
1250
+ llmBrief.innerHTML = (installing
1251
+ ? "<b>Recommended \\u2014 a fresh install has no model backend yet.</b> "
1252
+ : "") + (model ? "Runs it on <b>" + model + "</b> through AIsa. " : "") + LLM_BRIEF;
1253
+ // The warning is derived from this checkbox, so it has to be recomputed
1254
+ // here rather than left to the client-radio listener. A client radio
1255
+ // matches both '.card input' and 'input[name="client"]', so both
1256
+ // listeners fire, in registration order: the one that recomputes the
1257
+ // warning runs before this one flips the box. Without this line the
1258
+ // warning is decided from the previous target's state, and picking an
1259
+ // install target left it showing next to an already-ticked box.
1260
+ updateModelWarn();
1261
+ }
1262
+ }
1263
+
1264
+ // Informed consent is the whole point of backup mode: say exactly what
1265
+ // gets installed, that nothing of theirs changes, how to use it, how to
1266
+ // remove it. Copy varies per client because the mechanism does.
1267
+ var BACKUP_COPY = {
1268
+ "claude-code": "Installs one small command, <b><code>claude-aisa</code></b>, next to your other tools. Your original <b><code>claude</code></b> command keeps its login, models and settings <b>exactly as they are</b>. Run <b><code>claude-aisa</code></b> whenever you want <b>Claude, GPT, Gemini, DeepSeek, Kimi, GLM</b> and the rest, at lower prices. Remove it anytime by deleting that one file.",
1269
+ codex: "Adds an <b>aisa profile</b> inside codex's own config and a <b>codex-aisa</b> command. Your default codex setup is <b>untouched</b> — <code>codex-aisa</code> (or <code>codex --profile aisa</code>) uses AIsa for that session only. Remove anytime.",
1270
+ opencode: "Adds AIsa as an <b>extra provider</b> in opencode's config. Your default model is <b>untouched</b> — pick <code>aisa/\u2026</code> from opencode's model list whenever you want it."
1271
+ };
1272
+ var backupModes = document.getElementById("backupmodes");
1273
+ var llmCard = document.getElementById("llmcard");
1274
+ function updateModelsUI() {
1275
+ var chosen = document.querySelector('input[name="client"]:checked');
1276
+ var detectedCli = chosen && chosen.dataset.install !== "1" && BACKUP_COPY[chosen.value];
1277
+ backupModes.style.display = detectedCli ? "block" : "none";
1278
+ llmCard.style.display = detectedCli ? "none" : "";
1279
+ if (detectedCli) document.getElementById("backupbrief").innerHTML = BACKUP_COPY[chosen.value];
1280
+ }
1281
+ document.querySelectorAll('input[name="lmode"]').forEach(function (r) {
1282
+ r.addEventListener("change", function () {
1283
+ document.querySelectorAll('[data-kind="lmode"]').forEach(function (c) {
1284
+ c.classList.toggle("on", c.querySelector("input").checked);
1285
+ });
1286
+ });
1287
+ });
1288
+
1289
+ var modelWarn = document.getElementById("modelwarn");
1290
+ var armed = false;
1291
+ function updateModelWarn() {
1292
+ var chosen = document.querySelector('input[name="client"]:checked');
1293
+ var need = chosen && chosen.dataset.install === "1" && !llmBox.checked;
1294
+ modelWarn.style.display = need ? "block" : "none";
1295
+ if (!need) armed = false;
1296
+ }
1297
+ document.getElementById("modelfix").addEventListener("click", function () {
1298
+ llmBox.checked = true;
1299
+ llmBox.closest(".card").classList.add("on");
1300
+ updateModelWarn();
1301
+ });
1302
+ document.querySelectorAll('input[name="client"]').forEach(function (r) {
1303
+ r.addEventListener("change", function () { updateModelWarn(); updateModelsUI(); });
1304
+ });
1305
+ updateModelsUI();
1306
+
1307
+ llmBox.addEventListener("change", function () {
1308
+ updateModelWarn();
1309
+ llmBox.closest(".card").classList.toggle("on", llmBox.checked);
1310
+ });
1311
+
1312
+ document.querySelectorAll(".card input").forEach(function (cb) {
1313
+ cb.addEventListener("change", function () {
1314
+ if (cb.type === "radio") {
1315
+ // A radio unchecks its siblings without firing their events, so the
1316
+ // whole group is repainted rather than just this row.
1317
+ document.querySelectorAll('input[name="' + cb.name + '"]').forEach(function (r) {
1318
+ r.closest(".card").classList.toggle("on", r.checked);
1319
+ });
1320
+ } else {
1321
+ cb.closest(".card").classList.toggle("on", cb.checked);
1322
+ }
1323
+ syncButton();
1324
+ });
1325
+ });
1326
+
1327
+ document.querySelectorAll("[data-more]").forEach(function (m) {
1328
+ var d = m.previousElementSibling;
1329
+ if (d && d.scrollHeight <= d.clientHeight + 8) { m.style.display = "none"; return; }
1330
+ m.addEventListener("click", function (ev) {
1331
+ ev.preventDefault(); ev.stopPropagation();
1332
+ var card = m.closest(".card");
1333
+ var open = card.classList.toggle("open");
1334
+ m.textContent = open ? "less" : "more";
1335
+ });
1336
+ });
1337
+
1338
+ function picked(name) {
1339
+ return Array.prototype.map.call(
1340
+ document.querySelectorAll('input[name="' + name + '"]:checked'),
1341
+ function (i) { return i.value; });
1342
+ }
1343
+
1344
+ var STATE_WORD = { pending: "waiting", running: "working\\u2026", ok: "done",
1345
+ fail: "failed", skip: "skipped" };
1346
+
1347
+ // The moment an install step succeeds, its client card flips to a loud
1348
+ // installed state — the card is where the user read "not installed", so
1349
+ // the card is where the success must land.
1350
+ function syncClientCards(steps) {
1351
+ steps.forEach(function (s) {
1352
+ if (s.id.indexOf("install:") !== 0 || s.state !== "ok") return;
1353
+ var card = document.querySelector('[data-cid="' + s.id.slice(8) + '"]');
1354
+ if (!card || card.className.indexOf("freshly-installed") !== -1) return;
1355
+ card.className += " freshly-installed";
1356
+ var badge = card.querySelector("[data-badge]");
1357
+ if (badge) { badge.className = "badge installed"; badge.textContent = "\\u2713 installed"; }
1358
+ var brief = card.querySelector("[data-brief]");
1359
+ if (brief) brief.innerHTML = "<b>Installed successfully</b> \\u2014 " + (s.detail || "ready to use");
1360
+ });
1361
+ }
1362
+
1363
+ function renderSteps(steps) {
1364
+ if (!steps || !steps.length) return;
1365
+ syncClientCards(steps);
1366
+ progress.style.display = "block";
1367
+ var settled = steps.filter(function (s) {
1368
+ return s.state === "ok" || s.state === "skip";
1369
+ }).length;
1370
+ var failed = steps.filter(function (s) { return s.state === "fail"; }).length;
1371
+ var pct = Math.round(((settled + failed) / steps.length) * 100);
1372
+ var running = steps.filter(function (s) { return s.state === "running"; })[0];
1373
+
1374
+ var rows = steps.map(function (s) {
1375
+ return "<div class='step " + s.state + "'>" +
1376
+ "<span class='mark'></span>" +
1377
+ "<span class='body'><span class='lbl'>" + s.label + "</span>" +
1378
+ (s.detail ? "<span class='det'>" + s.detail + "</span>" : "") +
1379
+ "</span><span class='st'>" + (STATE_WORD[s.state] || s.state) + "</span></div>";
1380
+ }).join("");
1381
+
1382
+ progress.innerHTML = "<h2><span class='n'>5</span>Setting things up</h2>" +
1383
+ "<div class='bar-wrap'><div class='bar-fill' style='width:" + pct + "%'></div></div>" +
1384
+ "<div class='bar-note'>" + (settled + failed) + " of " + steps.length + " \\u00b7 " +
1385
+ (running ? running.label : (pct === 100 ? "finished" : "starting\\u2026")) + "</div>" +
1386
+ rows;
1387
+
1388
+ // The button narrates the phase it is actually in — "Connecting…" over a
1389
+ // minute-long install reads as a hang, or worse, as a lie.
1390
+ if (running) {
1391
+ var BTN_WORD = { install: "Installing\\u2026", signin: "Signing in\\u2026",
1392
+ mcp: "Connecting\\u2026", llm: "Configuring models\\u2026",
1393
+ auth: "Authorizing\\u2026", balance: "Finishing\\u2026" };
1394
+ btn.textContent = BTN_WORD[running.id.split(":")[0]] || "Working\\u2026";
1395
+ }
1396
+
1397
+ // The list grows as steps settle; keep the live edge in view, but only
1398
+ // when progress actually advanced — re-scrolling on every poll would
1399
+ // fight the user's own scrolling. While running, center the active row;
1400
+ // once everything settled, go all the way down so the final ticks and
1401
+ // the completion note are fully visible before the summary tab opens.
1402
+ var sig = steps.map(function (s) { return s.state; }).join(",");
1403
+ if (sig !== lastSig) {
1404
+ lastSig = sig;
1405
+ if (running) {
1406
+ var edge = progress.querySelector(".step.running");
1407
+ if (edge && edge.scrollIntoView) edge.scrollIntoView({ block: "center", behavior: "smooth" });
1408
+ } else {
1409
+ window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
1410
+ }
1411
+ }
1412
+ }
1413
+ var lastSig = "";
1414
+
1415
+ function poll() {
1416
+ fetch("/status?token=" + TOKEN).then(function (r) { return r.json(); }).then(function (s) {
1417
+ renderSteps(s.steps);
1418
+ if (s.phase === "done") {
1419
+ document.title = "\\u2713 AIsa Connected";
1420
+ // The big CTA is the bridge between the two pages: the summary tab
1421
+ // opened itself, but this is where the user is looking.
1422
+ var link = s.doneUrl
1423
+ ? "<a class='cta' href='" + s.doneUrl + "'>See how to use it \\u2192</a>"
1424
+ : "";
1425
+ result.innerHTML = "<b>All connected.</b> A summary page with try-it-now examples just opened in a new tab." + link;
1426
+ btn.textContent = "Connected";
1427
+ window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
1428
+ return;
1429
+ }
1430
+ if (s.phase === "failed") {
1431
+ result.innerHTML = "Some steps did not complete — see the list above; the summary page has the details and how to retry.";
1432
+ return;
1433
+ }
1434
+ setTimeout(poll, 1000);
1435
+ }).catch(function () { setTimeout(poll, 1500); });
1436
+ }
1437
+
1438
+ btn.addEventListener("click", function () {
1439
+ var servers = picked("server");
1440
+ var chosen = document.querySelector('input[name="client"]:checked');
1441
+ if (!servers.length || !chosen) {
1442
+ result.textContent = "Pick at least one capability and one client."; return;
1443
+ }
1444
+ var clients = [chosen.value];
1445
+ var install = chosen.dataset.install === "1" ? [chosen.value] : [];
1446
+ // Soft block: a fresh install with no model is almost certainly a
1447
+ // mistake. First press stops here and points at the warning; a second
1448
+ // press is an informed choice and goes through.
1449
+ if (install.length && !llmBox.checked && !armed) {
1450
+ armed = true;
1451
+ updateModelWarn();
1452
+ modelWarn.scrollIntoView({ block: "center", behavior: "smooth" });
1453
+ modelWarn.style.animation = "none"; void modelWarn.offsetWidth;
1454
+ modelWarn.style.animation = "mwshake .45s";
1455
+ btn.textContent = "Install anyway, without a model \u2192";
1456
+ return;
1457
+ }
1458
+ btn.disabled = true;
1459
+ btn.textContent = install.length ? "Installing\\u2026" : "Connecting\\u2026";
1460
+ fetch("/apply", { method: "POST",
1461
+ headers: { "content-type": "application/json", "x-connect-token": TOKEN },
1462
+ body: JSON.stringify({ servers: servers, clients: clients, install: install,
1463
+ llmMode: install.length || backupModes.style.display === "none"
1464
+ ? (llmBox.checked ? "switch" : "skip")
1465
+ : (document.querySelector('input[name="lmode"]:checked') || {}).value || "skip" })
1466
+ }).then(function (r) { return r.json(); }).then(function (data) {
1467
+ renderSteps(data.steps);
1468
+ poll();
1469
+ });
1470
+ });
1471
+ })();
1472
+ </script>`;
1473
+ return shell("AIsa Connect", body);
1474
+ }
1475
+ // ── page C: success + try-it-now examples ───────────────────────────────────
1476
+ export function renderDone(chosen, clientIds, steps, allServers, balanceMicros, llmMode) {
1477
+ const clientNames = clientIds
1478
+ .map((id) => (id === "claude-code" ? "Claude Code" : FILE_CLIENT_LABELS[id] ?? id))
1479
+ .join(", ");
1480
+ // One selected server gets two prompts so the page never feels thin; two or
1481
+ // more get one prompt each (capped at four cards).
1482
+ const withEx = chosen.filter((s) => EXAMPLES[s.slug]);
1483
+ const cards = withEx.length === 1
1484
+ ? EXAMPLES[withEx[0].slug].slice(0, 2).map((text) => ({ slug: withEx[0].slug, text }))
1485
+ : withEx.slice(0, 4).map((s) => ({ slug: s.slug, text: EXAMPLES[s.slug][0] }));
1486
+ const examples = cards
1487
+ .map((c) => `<div class="example"><div><span class="srv">aisa-${c.slug}</span>
1488
+ <div class="txt">${c.text}</div></div>
1489
+ <button data-copy="${c.text.replace(/"/g, "&quot;")}">${I.copy} Copy</button></div>`)
1490
+ .join("\n");
1491
+ // The page tells the truth about the run: a failed MCP step means no tools
1492
+ // were added, and a page that congratulates anyway (as an earlier version
1493
+ // did, over a 0-of-1 add) teaches the user to distrust every later success.
1494
+ const failed = steps.filter((s) => s.state === "fail");
1495
+ const mcpFailed = failed.some((s) => s.id === "mcp");
1496
+ const failBlock = failed.length
1497
+ ? `<div class="authnote" style="margin-bottom:1.2rem">${I.shield}<div>
1498
+ <b>${failed.length} step${failed.length > 1 ? "s" : ""} did not complete:</b>
1499
+ <ul style="margin:.4rem 0 0 1.1rem">${failed
1500
+ .map((s) => `<li><b>${s.label}</b> — ${s.detail ?? "failed"}</li>`)
1501
+ .join("")}</ul>
1502
+ Fix the above, then run <code>npx @aisa-one/cli connect</code> again — it is
1503
+ safe to re-run and picks up where things stand.</div></div>`
1504
+ : "";
1505
+ const toolCount = chosen.reduce((n, s) => n + s.toolCount, 0);
1506
+ const remaining = allServers.length - chosen.length;
1507
+ const remainingTools = allServers.reduce((n, s) => n + s.toolCount, 0) - toolCount;
1508
+ // A run that installed an agent is a much bigger deal than one that only
1509
+ // added tools, and the headline must say so: the user walked in with
1510
+ // nothing and walked out with a working coding agent on AIsa models.
1511
+ const installedAgents = steps
1512
+ .filter((s) => s.id.startsWith("install:") && s.state === "ok")
1513
+ .map((s) => INSTALLERS[s.id.slice("install:".length)]?.label ?? s.id.slice("install:".length));
1514
+ const llmOk = steps.some((s) => s.id === "llm" && s.state === "ok");
1515
+ const model = defaultModelsFor(clientIds[0] ?? "");
1516
+ const successHeadline = installedAgents.length
1517
+ ? `<h1>Congratulations — <em>${installedAgents.join(" & ")}</em> is installed and armed
1518
+ with <em>${toolCount} powerful tool${toolCount > 1 ? "s" : ""}</em></h1>
1519
+ <p class="lede"><b>${installedAgents.join(" & ")}</b> is now on this machine, signed in to
1520
+ AIsa${llmOk ? `, running on <b>${model.model}</b> through AIsa,` : ","} with
1521
+ ${chosen.length} MCP server${chosen.length > 1 ? "s" : ""} wired in — a complete AI setup,
1522
+ nothing else to configure.</p>`
1523
+ : `<h1>Congratulations — your agent just got <em>${toolCount} powerful new tool${toolCount > 1 ? "s" : ""}</em></h1>
1524
+ <p class="lede">${chosen.length} AIsa MCP server${chosen.length > 1 ? "s are" : " is"} now
1525
+ installed and signed in for <b>${clientNames}</b> — nothing else to configure.</p>`;
1526
+ // Launchable only for the CLI agents, and only while the local process is
1527
+ // still alive (it lingers a few minutes) — the button degrades to a
1528
+ // "run it yourself" hint once the process has exited.
1529
+ //
1530
+ // The card mimics each agent's real first screen — Codex's ASCII face
1531
+ // (transcribed from its TUI; the binary stores it as fragments) and Claude
1532
+ // Code's pixel robot — so the terminal the button opens looks exactly like
1533
+ // what the user just previewed. Familiarity is the whole point.
1534
+ const backup = llmMode === "backup";
1535
+ const launchBin = clientIds[0] === "codex" ? (backup ? "codex-aisa" : "codex")
1536
+ : clientIds[0] === "claude-code" ? (backup ? "claude-aisa" : "claude")
1537
+ : clientIds[0] === "opencode" ? "opencode" : null;
1538
+ // Backup mode's one-line contract, repeated where the user will act on it.
1539
+ const backupNote = backup
1540
+ ? clientIds[0] === "opencode"
1541
+ ? `<p class="fine" style="margin:0 0 1.2rem"><b>Your usual setup is untouched.</b> Pick
1542
+ <code>aisa/\u2026</code> from opencode's model list whenever you want AIsa.</p>`
1543
+ : `<p class="fine" style="margin:0 0 1.2rem"><b>Your usual <code>${clientIds[0] === "codex" ? "codex" : "claude"}</code> is untouched.</b> Run
1544
+ <code>${launchBin}</code> whenever you want AIsa's models; delete that one file to remove it.</p>`
1545
+ : "";
1546
+ const cwd = process.cwd();
1547
+ const termPreview = launchBin === "opencode"
1548
+ ? `<pre class="termlogo oc">${OPENCODE_MARK}</pre>
1549
+ <div class="termline">Welcome to <b>opencode</b></div>
1550
+ <div class="termline dim">model: <b>${AISA_PROVIDER_ID}/${model.model}</b> · via AIsa</div>
1551
+ <div class="termline dim">config: ~/.config/opencode/opencode.json</div>`
1552
+ : launchBin === "codex"
1553
+ ? `<pre class="termlogo codex">${CODEX_FACE}</pre>
1554
+ <div class="termline">Welcome to <b>Codex</b>, OpenAI's command-line coding agent</div>
1555
+ <div class="termline dim">model: <b>${model.model}</b> · via AIsa</div>`
1556
+ : `<pre class="termlogo claude">${CLAUDE_BOT}</pre>
1557
+ <div class="termline"><b class="ccname">Claude Code</b></div>
1558
+ <div class="termline dim">${model.model} · via AIsa</div>
1559
+ <div class="termline dim">${cwd}</div>
1560
+ <div class="termline accent">Using ${model.model} (from .claude/settings.json)</div>`;
1561
+ const launchBlock = !mcpFailed && launchBin
1562
+ ? `<div class="termcard">
1563
+ <div class="termwin">
1564
+ <div class="termbar"><span class="tdot r"></span><span class="tdot y"></span><span class="tdot g"></span></div>
1565
+ <div class="termbody">${termPreview}</div>
1566
+ </div>
1567
+ <div class="termside">
1568
+ <button class="cta act" id="launch">Launch ${launchBin} →</button>
1569
+ <span class="fine" id="launchnote"></span>
1570
+ </div>
1571
+ </div>`
1572
+ : "";
1573
+ const headline = mcpFailed
1574
+ ? `<div class="eyebrow">Almost there</div>
1575
+ <h1>Your agent is <em>not connected yet</em></h1>
1576
+ <p class="lede">The MCP entries could not be added to <b>${clientNames}</b> — details below.</p>`
1577
+ : `<div class="bigcheck">${I.check}</div>
1578
+ <div class="eyebrow">Connected</div>
1579
+ ${successHeadline}`;
1580
+ // ── the recap: the same journey the first page showed live, replayed as a
1581
+ // settled checklist, so the fresh tab connects visually to what the user
1582
+ // just watched happen. Every step, including the failed and skipped ones.
1583
+ const STEP_ICON = {
1584
+ ok: "✓",
1585
+ fail: "✕",
1586
+ skip: "–",
1587
+ pending: "·",
1588
+ running: "·",
1589
+ };
1590
+ // Check mark at the END of the row: the row reads as a capability gained
1591
+ // ("Install Codex — installed ✓"), not as a checklist item to do.
1592
+ const recapRows = steps
1593
+ .map((s) => `<div class="rstep ${s.state}"><span class="rl">${s.label}</span>
1594
+ <span class="rd">${s.detail ?? ""}</span><span class="ri">${STEP_ICON[s.state]}</span></div>`)
1595
+ .join("\n");
1596
+ const recap = `
1597
+ <h2>Everything you just gained</h2>
1598
+ <div class="recap">
1599
+ <div class="rsum">${chosen.length} capabilit${chosen.length === 1 ? "y" : "ies"} · ${toolCount} tools · ${clientNames}</div>
1600
+ ${recapRows}
1601
+ </div>`;
1602
+ // ── the balance card: always shown, always with the top-up button — money
1603
+ // is the one thing a user should never have to hunt for. Below $5 the card
1604
+ // turns warm and suggests (never demands) a top-up.
1605
+ const TOPUP_URL = "https://console.aisa.one/billing?source=aisa_cli";
1606
+ const low = balanceMicros !== null && balanceMicros < 5_000_000;
1607
+ const balanceCard = `
1608
+ <div class="balcard${low ? " low" : ""}">
1609
+ <div><div class="balnum">${balanceMicros === null ? "—" : formatMicrosUSD(balanceMicros)}</div>
1610
+ <div class="ballbl">AIsa balance</div></div>
1611
+ <div class="balright">${low
1612
+ ? `<div class="lownote">Running a little low — a small top-up now keeps your first calls flowing smoothly.</div>`
1613
+ : balanceMicros === null
1614
+ ? `<div class="lownote">Could not read it just now — <code>aisa balance</code> will.</div>`
1615
+ : ""}
1616
+ <a class="cta topup" href="${TOPUP_URL}" target="_blank" rel="noopener noreferrer">Top up now →</a></div>
1617
+ </div>`;
1618
+ const body = `
1619
+ <style>
1620
+ h1 { font-size: 2.5rem; }
1621
+ .lede { font-size: 1.08rem; }
1622
+ .example .txt { font-size: 1.02rem; }
1623
+ .example .srv { font-size: .8rem; }
1624
+ h2 { font-size: 1.2rem; }
1625
+ .fine { font-size: .9rem; }
1626
+ .recap { border: 1px solid var(--line); border-radius: 12px; background: var(--card);
1627
+ padding: 1rem 1.2rem; margin: 0 0 1.4rem; max-width: 62rem; }
1628
+ .rsum { font-weight: 600; padding-bottom: .55rem; border-bottom: 1px dashed var(--line);
1629
+ margin-bottom: .55rem; }
1630
+ .rstep { display: flex; gap: .7rem; align-items: baseline; padding: .4rem 0;
1631
+ font-size: 1.06rem; border-bottom: 1px solid var(--line); }
1632
+ .rstep:last-child { border-bottom: 0; }
1633
+ .rstep .ri { font-weight: 800; font-size: 1.15rem; flex: none; margin-left: auto; }
1634
+ .rstep.ok .ri { color: var(--ok); }
1635
+ .rstep.fail .ri { color: #dc2626; }
1636
+ .rstep.skip .ri, .rstep.pending .ri { color: #9ca3af; }
1637
+ .rstep .rl { font-weight: 700; flex: none; }
1638
+ .rstep .rd { color: var(--muted); font-size: .95rem; overflow-wrap: anywhere; }
1639
+ .balcard { display: flex; justify-content: space-between; align-items: center;
1640
+ gap: 1rem; border: 1px solid var(--line); border-radius: 12px; background: var(--card);
1641
+ padding: 1rem 1.2rem; margin: 0 0 1.8rem; max-width: 62rem; flex-wrap: wrap; }
1642
+ .balcard.low { border-color: #f59e0b; background: color-mix(in srgb, #f59e0b 12%, var(--card)); }
1643
+ .balnum { font-size: 1.6rem; font-weight: 800; }
1644
+ .ballbl { font-size: .8rem; color: var(--muted); }
1645
+ .balright { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
1646
+ .lownote { font-size: .92rem; color: color-mix(in srgb, #b45309 70%, var(--ink)); max-width: 26rem; }
1647
+ .cta.topup, .cta.act { display: inline-block; width: auto; padding: .7rem 1.8rem; }
1648
+ /* The launch card: a believable little terminal, agent branding intact,
1649
+ with the launch button on the right edge — same edge as Top up above. */
1650
+ .termcard { display: flex; justify-content: space-between; align-items: center;
1651
+ gap: 1.2rem; border: 1px solid var(--line); border-radius: 12px; background: var(--card);
1652
+ padding: 1rem 1.2rem; margin: 0 0 1.8rem; max-width: 62rem; flex-wrap: wrap; }
1653
+ .termwin { background: #0d0d0b; border-radius: 10px; overflow: hidden;
1654
+ flex: 1 1 24rem; min-width: 0; box-shadow: inset 0 0 0 1px #262622; }
1655
+ .termbar { display: flex; gap: .38rem; padding: .5rem .7rem; background: #1a1a17; }
1656
+ .tdot { width: .62rem; height: .62rem; border-radius: 50%; }
1657
+ .tdot.r { background: #ff5f57; } .tdot.y { background: #febc2e; } .tdot.g { background: #28c840; }
1658
+ .termbody { padding: .8rem 1rem 1rem; font-family: ui-monospace, SFMono-Regular,
1659
+ Menlo, monospace; font-size: .78rem; line-height: 1.35; }
1660
+ .termlogo { margin: 0 0 .6rem; font-size: .35rem; line-height: 1.15; overflow-x: auto; }
1661
+ .termlogo.codex { color: #33d17a; }
1662
+ .termlogo.oc { color: #fafafa; font-size: .62rem; line-height: 1.2; }
1663
+ .termlogo.claude { color: #e07b54; font-size: .56rem; line-height: 1.05; }
1664
+ .termline { color: #d8d8d2; padding: .08rem 0; overflow-wrap: anywhere; }
1665
+ .termline b { color: #fff; }
1666
+ .termline.dim { color: #8a8a82; }
1667
+ .termline.accent { color: #d8d8d2; border-left: 2px solid #33d17a; padding-left: .5rem; }
1668
+ .termline .prompt { color: #33d17a; font-weight: 700; }
1669
+ .ccname { color: #33d17a !important; }
1670
+ .termside { display: flex; flex-direction: column; align-items: flex-end;
1671
+ gap: .5rem; flex: none; }
1672
+ </style>
1673
+ ${headline}
1674
+ ${mcpFailed
1675
+ ? failBlock + recap + balanceCard
1676
+ : `<p class="lede" style="margin-top:.6rem">You are now connected to <b>AIsa</b> — a powerful capability layer for agents: one account for
1677
+ <b>Claude, GPT, Gemini, DeepSeek, Kimi, GLM</b> and the live data behind them: <b>Twitter, SimilarWeb, CoinGecko, Apollo, Polymarket</b> and more.${remaining > 0
1678
+ ? ` ${remaining} more MCP server${remaining > 1 ? "s" : ""} (${remainingTools} tools) are one
1679
+ <code>npx @aisa-one/cli connect</code> away.`
1680
+ : ""}
1681
+ Explore the platform at <a href="https://aisa.one" target="_blank" rel="noopener noreferrer">aisa.one</a> ·
1682
+ usage &amp; billing at <a href="https://console.aisa.one" target="_blank" rel="noopener noreferrer">console.aisa.one</a>.</p>
1683
+ ${failBlock}
1684
+ ${recap}
1685
+ ${balanceCard}
1686
+ ${backupNote}
1687
+ ${launchBlock}
1688
+ <h2>${I.sparkles} Try it now — paste one of these into ${clientNames.split(",")[0]}</h2>
1689
+ <div class="examples">
1690
+ ${examples || '<p class="fine">Ask your agent to use any of the aisa-* MCP tools.</p>'}
1691
+ </div>
1692
+ <p class="fine">These first-run prompts mention <b>AIsa</b> once so the demo reliably lands on
1693
+ your new tools. After that, plain natural language is enough — your agent reaches for AIsa on
1694
+ its own whenever a task needs live data. Verify anytime with <code>/mcp</code> inside
1695
+ Claude Code — the entries should show <b>Connected</b>.</p>`}
1696
+ <p class="fine">This page keeps working after the local process exits.</p>
1697
+ <script>
1698
+ document.querySelectorAll("[data-copy]").forEach(function (b) {
1699
+ b.addEventListener("click", function () {
1700
+ navigator.clipboard.writeText(b.getAttribute("data-copy")).then(function () {
1701
+ b.textContent = "Copied \\u2713"; setTimeout(function () { b.innerHTML = ${JSON.stringify(I.copy + " Copy")}; }, 1600);
1702
+ });
1703
+ });
1704
+ });
1705
+ var launch = document.getElementById("launch");
1706
+ if (launch) launch.addEventListener("click", function () {
1707
+ var token = new URLSearchParams(location.search).get("token");
1708
+ var note = document.getElementById("launchnote");
1709
+ launch.disabled = true;
1710
+ fetch("/launch?token=" + token, { method: "POST" })
1711
+ .then(function (r) { if (!r.ok) throw 0; return r.json(); })
1712
+ .then(function () {
1713
+ launch.textContent = "\\u2713 Opened in Terminal";
1714
+ note.textContent = "";
1715
+ })
1716
+ .catch(function () {
1717
+ // Process exited, or no terminal emulator could be started (headless
1718
+ // Linux) — the agent is installed either way, one command starts it.
1719
+ launch.style.display = "none";
1720
+ note.innerHTML = "Could not open a terminal automatically \\u2014 just run <code>${launchBin ?? ""}</code> in any terminal.";
1721
+ });
1722
+ });
1723
+ </script>`;
1724
+ return shell(mcpFailed ? "AIsa \u2014 almost connected" : "\u2713 AIsa Connected", body);
1725
+ }
1726
+ /**
1727
+ * Every body this server accepts is a few hundred bytes of JSON — a choice,
1728
+ * a step number, a list of slugs. There is no request here for which
1729
+ * megabytes are a legitimate shape, and accumulating without a ceiling means
1730
+ * anything that can reach the port can grow this process until it dies.
1731
+ */
1732
+ const MAX_BODY_BYTES = 64 * 1024;
1733
+ function readBody(req) {
1734
+ return new Promise((resolve, reject) => {
1735
+ let data = "";
1736
+ let size = 0;
1737
+ req.on("data", (c) => {
1738
+ size += typeof c === "string" ? Buffer.byteLength(c) : c.length;
1739
+ if (size > MAX_BODY_BYTES) {
1740
+ req.destroy();
1741
+ reject(new Error("request body too large"));
1742
+ return;
1743
+ }
1744
+ data += c;
1745
+ });
1746
+ req.on("end", () => resolve(data));
1747
+ req.on("error", reject);
1748
+ });
1749
+ }
1750
+ /**
1751
+ * Open a real terminal window running the freshly connected agent. The
1752
+ * command is a fixed whitelist entry — nothing from the request reaches the
1753
+ * shell — and the working directory is where connect was launched, which is
1754
+ * almost always the project the user wants the agent in.
1755
+ */
1756
+ function launchAgentTerminal(binary) {
1757
+ if (process.platform === "darwin") {
1758
+ // osascript ships with macOS — no dependency of ours. First use may show
1759
+ // a one-time automation permission prompt; Terminal.app starts a fresh
1760
+ // shell, so the cd is explicit.
1761
+ const dir = process.cwd().replace(/'/g, "'\\''");
1762
+ return run("osascript", [
1763
+ "-e",
1764
+ `tell application "Terminal" to do script "cd '${dir}' && ${binary}"`,
1765
+ "-e",
1766
+ 'tell application "Terminal" to activate',
1767
+ ], { timeout: 30_000 }).then(() => true, () => false);
1768
+ }
1769
+ // Linux: only meaningful in a graphical session, and there is no single
1770
+ // terminal — walk the common ones until one starts. The spawned terminal
1771
+ // inherits our cwd, so the agent opens in the right directory without a cd.
1772
+ if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY)
1773
+ return Promise.resolve(false);
1774
+ const candidates = [
1775
+ ["x-terminal-emulator", ["-e", binary]],
1776
+ ["gnome-terminal", ["--", binary]],
1777
+ ["konsole", ["-e", binary]],
1778
+ ["xfce4-terminal", ["-e", binary]],
1779
+ ["xterm", ["-e", binary]],
1780
+ ];
1781
+ return candidates.reduce((chain, [cmd, args]) => chain.then((ok) => ok || run(cmd, args, { timeout: 30_000 }).then(() => true, () => false)), Promise.resolve(false));
1782
+ }
1783
+ /** Quit and reopen Claude Desktop so it loads the fresh MCP config. The
1784
+ * quit is polite (AppleScript / SIGTERM to the app, never -9); a Claude
1785
+ * Desktop that was not running simply starts. */
1786
+ function restartClaudeDesktop() {
1787
+ if (process.platform === "darwin") {
1788
+ runSync("osascript", ["-e", 'tell application "Claude" to quit'], { timeout: 15_000 });
1789
+ const r = runSync("open", ["-a", "Claude"], { timeout: 30_000 });
1790
+ return r.status === 0;
1791
+ }
1792
+ return false;
1793
+ }
1794
+ /** Open Cursor on a folder: the bundled `cursor` command on macOS (the
1795
+ * app may not be on PATH), `cursor` elsewhere. */
1796
+ function launchCursor(dir) {
1797
+ const candidates = process.platform === "darwin"
1798
+ ? ["cursor", "/Applications/Cursor.app/Contents/Resources/app/bin/cursor"]
1799
+ : ["cursor", "/usr/bin/cursor", "/usr/share/cursor/bin/cursor"];
1800
+ for (const c of candidates) {
1801
+ const r = runSync(c, [dir], { timeout: 30_000 });
1802
+ if (r.status === 0)
1803
+ return true;
1804
+ }
1805
+ if (process.platform === "darwin") {
1806
+ const r = runSync("open", ["-a", "Cursor", dir], { timeout: 30_000 });
1807
+ return r.status === 0;
1808
+ }
1809
+ return false;
1810
+ }
1811
+ /** The same question the run asks itself, for the places that ask early. */
1812
+ function withBrowserEarly() {
1813
+ return canOpenBrowser();
1814
+ }
1815
+ function openBrowser(url) {
1816
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
1817
+ void run(cmd, [url], { timeout: 30_000 }).catch(() => { });
1818
+ }
1819
+ /**
1820
+ * Where a live run leaves its address, so a second `aisa connect` can offer
1821
+ * the first one's page instead of starting a rival server. Ephemeral by
1822
+ * design: it lives in the temp directory, carries the pid, and a stale file
1823
+ * (crashed run, killed terminal) is detected and replaced rather than
1824
+ * trusted — the price of a wrong guess is one dead link, so the check is
1825
+ * "is that pid still a connect process", not just "does the file exist".
1826
+ */
1827
+ function runLockPath() {
1828
+ return join(tmpdir(), `aisa-connect-${process.getuid?.() ?? 0}.json`);
1829
+ }
1830
+ function readRunLock() {
1831
+ try {
1832
+ const lock = JSON.parse(readFileSync(runLockPath(), "utf-8"));
1833
+ if (!lock?.pid || !lock.url)
1834
+ return null;
1835
+ // Signal 0 only asks "may I signal it"; it never touches the process.
1836
+ process.kill(lock.pid, 0);
1837
+ return lock;
1838
+ }
1839
+ catch {
1840
+ return null;
1841
+ }
1842
+ }
1843
+ function writeRunLock(url) {
1844
+ try {
1845
+ mkdirSync(tmpdir(), { recursive: true });
1846
+ writeFileSync(runLockPath(), JSON.stringify({ pid: process.pid, url, started: Date.now() }), "utf-8");
1847
+ }
1848
+ catch {
1849
+ /* a lock we cannot write is a feature we simply do not get */
1850
+ }
1851
+ }
1852
+ function clearRunLock() {
1853
+ try {
1854
+ const lock = JSON.parse(readFileSync(runLockPath(), "utf-8"));
1855
+ if (lock.pid === process.pid)
1856
+ unlinkSync(runLockPath());
1857
+ }
1858
+ catch {
1859
+ /* nothing to clear */
1860
+ }
1861
+ }
1862
+ /**
1863
+ * Where a run drops its state when a terminal asks for it back.
1864
+ *
1865
+ * One path per user, because there is one run per user — the same reason the
1866
+ * lock file has that shape.
1867
+ */
1868
+ function returnPath() {
1869
+ return join(tmpdir(), `aisa-connect-return-${process.getuid?.() ?? 0}.json`);
1870
+ }
1871
+ function readHandover(file) {
1872
+ try {
1873
+ const h = JSON.parse(readFileSync(file, "utf-8"));
1874
+ try {
1875
+ unlinkSync(file);
1876
+ }
1877
+ catch {
1878
+ /* it served its purpose either way */
1879
+ }
1880
+ return h.port && h.token ? h : null;
1881
+ }
1882
+ catch {
1883
+ return null;
1884
+ }
1885
+ }
1886
+ /**
1887
+ * Move the live run to a process the shell is not waiting on.
1888
+ *
1889
+ * The port has to be released before the child reaches for it, so the caller
1890
+ * closes the server first; the child retries while the socket finishes
1891
+ * closing. Returns false if the child could not be started, which the caller
1892
+ * must treat as "the page is going away" rather than promising otherwise.
1893
+ */
1894
+ function handOverRun(h, closeParent) {
1895
+ let file;
1896
+ try {
1897
+ file = join(mkdtempSync(join(tmpdir(), "aisa-run-")), "handover.json");
1898
+ writeFileSync(file, JSON.stringify(h), { mode: 0o600 });
1899
+ }
1900
+ catch {
1901
+ return false;
1902
+ }
1903
+ closeParent();
1904
+ try {
1905
+ const child = spawn(process.execPath, [process.argv[1], "connect", "--resume", file], {
1906
+ detached: true,
1907
+ stdio: "ignore",
1908
+ env: process.env,
1909
+ });
1910
+ child.unref();
1911
+ return true;
1912
+ }
1913
+ catch {
1914
+ try {
1915
+ unlinkSync(file);
1916
+ }
1917
+ catch {
1918
+ /* nothing to clean up */
1919
+ }
1920
+ return false;
1921
+ }
1922
+ }
1923
+ /**
1924
+ * Ask the recorded address whether our run is really there.
1925
+ *
1926
+ * A pid in a file is not proof: pids are reused, and this one is about to be
1927
+ * sent SIGUSR1 or SIGUSR2 — both of which terminate a process that has no
1928
+ * handler for them. Signalling on the strength of a stale lock is how a tool
1929
+ * kills something that has nothing to do with it. An answer on that port,
1930
+ * with that token, is proof; nothing else is.
1931
+ */
1932
+ async function probeRun(url) {
1933
+ try {
1934
+ const u = new URL(url);
1935
+ const token = u.searchParams.get("token") ?? "";
1936
+ if (!token)
1937
+ return { ours: false };
1938
+ const res = await fetch(`${u.origin}/status?token=${token}`, { signal: AbortSignal.timeout(2_000) });
1939
+ if (!res.ok)
1940
+ return { ours: false };
1941
+ const data = (await res.json());
1942
+ return typeof data.phase === "string" ? { ours: true, phase: data.phase } : { ours: false };
1943
+ }
1944
+ catch {
1945
+ return { ours: false };
1946
+ }
1947
+ }
1948
+ /** Signal a run, but only once it has proved it is the run we recorded. */
1949
+ async function askRunTo(lock, signal) {
1950
+ if (!(await probeRun(lock.url)).ours)
1951
+ return false;
1952
+ try {
1953
+ process.kill(lock.pid, signal);
1954
+ return true;
1955
+ }
1956
+ catch {
1957
+ return false;
1958
+ }
1959
+ }
1960
+ /**
1961
+ * What "run it" means for this client: a backup pair (the wrapper first,
1962
+ * since that is the one thing that changed; the original second, for
1963
+ * comparison) or a single command. Empty for clients with no terminal
1964
+ * command of their own (VS Code, Cursor, Claude Desktop, Claude.ai) — those
1965
+ * get an Open/Restart button on the results page instead, not a shell to
1966
+ * hand over.
1967
+ */
1968
+ function launchChoices(clientId, llmMode) {
1969
+ if (!["claude-code", "codex", "opencode"].includes(clientId))
1970
+ return [];
1971
+ const original = clientId === "claude-code" ? "claude" : clientId;
1972
+ if (llmMode === "backup" && (clientId === "claude-code" || clientId === "codex")) {
1973
+ const wrapper = clientId === "claude-code" ? "claude-aisa" : "codex-aisa";
1974
+ const model = defaultModelsFor(clientId).model;
1975
+ return [
1976
+ { cmd: wrapper, desc: `${model} via AIsa — your usual ${original} is untouched` },
1977
+ { cmd: original, desc: "your original setup, unchanged" },
1978
+ ];
1979
+ }
1980
+ const model = defaultModelsFor(clientId).model;
1981
+ return [
1982
+ { cmd: original, desc: llmMode === "switch" ? `now running on AIsa (${model})` : "your new tools are ready" },
1983
+ ];
1984
+ }
1985
+ /**
1986
+ * The last step of a run, when there is a real terminal command to hand
1987
+ * over: let the user pick one and start it right here, rather than making
1988
+ * them copy a line from the log and open a fresh terminal. Only offered
1989
+ * when stdin/stdout are a real TTY — piped or non-interactive invocations
1990
+ * (scripts, CI) fall straight through to the linger period, same as before
1991
+ * this existed.
1992
+ */
1993
+ /**
1994
+ * True once the child has visibly been a real agent session rather than an
1995
+ * instant failure — a clean exit, or simply having stuck around for a few
1996
+ * seconds (covers Ctrl-C mid-conversation, which often exits non-zero).
1997
+ * Getting this wrong the generous way (calling a real session "not real")
1998
+ * is the harmless direction; the costly one is the reverse.
1999
+ */
2000
+ function looksLikeARealSession(code, ranForMs) {
2001
+ return code !== null && (code === 0 || ranForMs > 2_000);
2002
+ }
2003
+ async function offerLaunch(log, clientId, llmMode, lang) {
2004
+ const choices = launchChoices(clientId, llmMode);
2005
+ if (choices.length === 0 || !process.stdin.isTTY || !process.stdout.isTTY)
2006
+ return "nothing-to-offer";
2007
+ // Arrow keys, like every other choice in this flow. Typing a number here
2008
+ // while the six steps before it were driven with arrows is a change of
2009
+ // input method at the last screen, and it did not reliably take the number
2010
+ // anyway.
2011
+ console.log();
2012
+ console.log(chalk.bold(t(LAUNCH.question, lang)));
2013
+ const rows = [
2014
+ ...choices.map((c) => ({ label: chalk.bold(c.cmd), meta: c.desc })),
2015
+ { label: chalk.bold(t(LAUNCH.exit, lang)), meta: t(LAUNCH.exitNote, lang) },
2016
+ ];
2017
+ const result = await pick({
2018
+ title: "",
2019
+ choices: rows,
2020
+ multi: false,
2021
+ initial: [0],
2022
+ hint: lang === "zh" ? "↑↓ 选择 · 回车确认" : "↑↓ move · enter to confirm",
2023
+ });
2024
+ const idx = result.aborted ? rows.length - 1 : (result.picked?.[0] ?? rows.length - 1);
2025
+ const picked = choices[idx];
2026
+ if (!picked) {
2027
+ log.record("asked what to run next — chose to exit");
2028
+ return "chose-exit";
2029
+ }
2030
+ log.record(`asked what to run next — chose ${picked.cmd}`);
2031
+ console.log(chalk.gray(`\nStarting ${picked.cmd}… exit it normally to come back here.\n`));
2032
+ const startedAt = Date.now();
2033
+ const code = await new Promise((resolve) => {
2034
+ // The agent owns the keyboard from here; this process must stop reading
2035
+ // the same descriptor or the two of them share the user's keystrokes.
2036
+ handOffStdin();
2037
+ const child = spawn(picked.cmd, [], { stdio: "inherit", cwd: process.cwd() });
2038
+ child.once("exit", (code) => resolve(code));
2039
+ child.once("error", () => resolve(null));
2040
+ });
2041
+ if (!looksLikeARealSession(code, Date.now() - startedAt)) {
2042
+ log.line("warn", `${picked.cmd} exited right away`, code === null ? "could not start it" : `exit code ${code}`);
2043
+ return "nothing-to-offer";
2044
+ }
2045
+ // A real session happened. Say so, then repeat the one thing worth
2046
+ // repeating — everything printed before this got scrolled away by
2047
+ // whatever just filled the screen.
2048
+ // Ruled and spaced, because whatever just ran filled the screen and this
2049
+ // line has to be findable in what it left behind.
2050
+ log.rule();
2051
+ console.log(chalk.bold.green(`🎉 That was ${picked.cmd}, running on AIsa. Nice.`));
2052
+ log.encore([
2053
+ { cmd: picked.cmd, why: picked.desc },
2054
+ { cmd: "aisa connect", why: "run this any time — configure servers, switch models, connect another agent" },
2055
+ ]);
2056
+ return "ran-agent";
2057
+ }
2058
+ /**
2059
+ * The closing block of a run: what changed on this machine, which commands
2060
+ * the user now has, and what to run next. It is the part someone reads
2061
+ * after the browser tab is gone — and the part that teaches them to do this
2062
+ * by hand next time, so it names files and commands rather than describing
2063
+ * them.
2064
+ */
2065
+ function summarise(log, r) {
2066
+ const done = (id) => r.steps.some((s) => s.id === id && s.state === "ok");
2067
+ log.section("What changed on this machine");
2068
+ const mcpMsg = r.steps.find((s) => s.id === "mcp");
2069
+ if (mcpMsg?.state === "ok") {
2070
+ const where = r.clientId === "claude-code" ? "Claude Code's user scope (claude mcp list)"
2071
+ : r.clientId === "codex" ? "~/.codex/config.toml"
2072
+ : r.clientId === "opencode" ? "~/.config/opencode/opencode.json"
2073
+ : r.clientId === "vscode" ? "VS Code's mcp.json"
2074
+ : r.clientId === "claude-desktop" ? "claude_desktop_config.json (as mcp-remote bridges)"
2075
+ : r.clientId === "cursor" ? "install links you confirm inside Cursor"
2076
+ : mcpMsg.detail ?? "";
2077
+ log.line("write", `${r.servers.length} MCP server${r.servers.length > 1 ? "s" : ""} → ${where}`);
2078
+ for (const srv of r.servers)
2079
+ log.record(`aisa-${srv.slug} → ${srv.endpoint}`);
2080
+ }
2081
+ if (done("llm")) {
2082
+ log.line("write", "Model provider set to AIsa", r.steps.find((s) => s.id === "llm")?.detail);
2083
+ }
2084
+ if (done("llm-backup")) {
2085
+ log.line("write", "AIsa added beside your setup", r.steps.find((s) => s.id === "llm-backup")?.detail);
2086
+ }
2087
+ if (done("install:aisa-cli"))
2088
+ log.line("write", "AIsa CLI available as `aisa`");
2089
+ log.record(`credential: ~/.aisa/key (0600)`);
2090
+ log.section("Commands you now have");
2091
+ for (const c of launchChoices(r.clientId, r.llmMode))
2092
+ log.command(c.cmd, c.desc);
2093
+ log.command("aisa balance", "check your credit");
2094
+ log.command("aisa topup", "add credit");
2095
+ log.command("aisa connect", "configure servers, switch models, connect another agent");
2096
+ log.section("Good to know");
2097
+ if (r.clientId === "claude-desktop")
2098
+ log.line("info", "Restart Claude Desktop", "the servers load on start");
2099
+ if (r.clientId === "vscode")
2100
+ log.line("info", "VS Code needs no reload", "the servers and models are already there");
2101
+ if (r.clientId === "cursor")
2102
+ log.line("info", "Press Install in Cursor", "one confirmation per server");
2103
+ log.line("info", "Nothing runs in the background", "this process exits when the results page expires");
2104
+ if (r.balance !== null && r.balance <= LOW_BALANCE_MICROS) {
2105
+ log.line("warn", `Balance is ${formatMicrosUSD(r.balance)}`, "top up so your agent never stops mid-task");
2106
+ }
2107
+ if (r.newerVersion) {
2108
+ log.line("info", `A newer CLI is out: v${r.newerVersion}`, `you're on v${VERSION} — run \`aisa update\``);
2109
+ }
2110
+ if (log.path)
2111
+ log.line("info", "This run was logged to", log.path.replace(homedir(), "~"));
2112
+ }
2113
+ export async function connectAction(options) {
2114
+ // A run that was handed over at the terminal. Everything it needs was
2115
+ // written down by the process that had it, so this one asks nobody: no
2116
+ // manifest fetch, no client probe, no browser, no terminal flow — it
2117
+ // exists only to keep answering the page that is already open.
2118
+ let resumed = options.resume ? readHandover(options.resume) : null;
2119
+ if (options.resume && !resumed)
2120
+ return;
2121
+ // Started by a run handing itself over: no terminal, no browser, no stdout
2122
+ // anyone will read. A run taken back from one is inherited the same way but
2123
+ // is very much in the foreground, so the two are not the same question.
2124
+ const detached = Boolean(options.resume);
2125
+ let template = resumed ? resumed.template : resolveTemplate(options.template);
2126
+ // One language for both surfaces. Reading it here rather than in the page
2127
+ // is the point: a language the browser chose alone would leave the terminal
2128
+ // in English while the page is in Chinese.
2129
+ let lang = resumed ? resumed.lang : resolveLang(options.lang, getConfig("lang"));
2130
+ // One run at a time. A second one would serve a second page against the
2131
+ // same machine — two plans writing the same config files — so point the
2132
+ // user at the page that is already open instead. --force overrides for the
2133
+ // rare case where the first run is wedged.
2134
+ const log = new Journal(detached);
2135
+ if (!detached)
2136
+ printBanner();
2137
+ log.section(`AIsa connect · v${VERSION}`);
2138
+ log.line("info", "Machine", `${process.platform} ${process.arch} · node ${process.versions.node}`);
2139
+ // Fired now so it has the whole run — often minutes, while the user picks
2140
+ // servers in the browser — to resolve. Cached and throttled (see
2141
+ // update-check.ts), so this is a no-op network call on most invocations.
2142
+ // connect says this itself, in the run's own voice and among the rest of
2143
+ // what it reports — so the generic line at the end of every command stands
2144
+ // down rather than saying it twice.
2145
+ markUpdateAnnounced();
2146
+ const updateCheckP = detached ? Promise.resolve(undefined) : checkForUpdate().catch(() => undefined);
2147
+ // A page left over from a previous run describes a machine that has since
2148
+ // changed. Close it before this run starts writing a new answer. A resumed
2149
+ // run is that same page carrying on, so it closes nothing.
2150
+ if (!detached)
2151
+ await closeStaleResults();
2152
+ // --force means "start over", not "run two of these". The page that was
2153
+ // open is about to describe a machine someone else is changing, so it is
2154
+ // told to stand down the same way a finished one is.
2155
+ if (options.force && !resumed) {
2156
+ const previous = readRunLock();
2157
+ if (previous)
2158
+ await askRunTo(previous, "SIGUSR1");
2159
+ }
2160
+ let live = options.force || resumed ? null : readRunLock();
2161
+ if (live) {
2162
+ // A lock whose address does not answer is a lock left behind — by a
2163
+ // crash, a kill, a machine that slept through its own timers. Nothing is
2164
+ // there to reopen or to signal, so it is not a live run.
2165
+ const probe = await probeRun(live.url);
2166
+ if (!probe.ours) {
2167
+ log.record(`stale run lock for pid ${live.pid} — its address does not answer`);
2168
+ live = null;
2169
+ }
2170
+ else if (probe.phase === "done" || probe.phase === "failed") {
2171
+ live = { ...live, settled: true };
2172
+ }
2173
+ }
2174
+ if (live && live.settled) {
2175
+ // It finished; its results page is just lingering. Nothing more will be
2176
+ // written there, so there is nothing to protect against — proceed as if
2177
+ // no lock existed. This process's own writeRunLock (below) takes over
2178
+ // the file once its server is up.
2179
+ const mins = Math.max(1, Math.round((Date.now() - live.started) / 60_000));
2180
+ log.line("info", `An earlier run finished ${mins} minute${mins === 1 ? "" : "s"} ago`, "starting a new one");
2181
+ // Its page is still up and still describes this machine as it was. Ask it
2182
+ // to tell its reader that a newer run has started, rather than letting
2183
+ // two pages disagree about what is configured here.
2184
+ await askRunTo(live, "SIGUSR1");
2185
+ live = null;
2186
+ }
2187
+ if (live) {
2188
+ const mins = Math.max(1, Math.round((Date.now() - live.started) / 60_000));
2189
+ log.line("info", `A run from ${mins} minute${mins === 1 ? "" : "s"} ago is still open`, `pid ${live.pid}`);
2190
+ if (withBrowserEarly())
2191
+ console.log(` ${chalk.cyan(live.url)}`);
2192
+ // Ask for it back rather than driving it from here. A run started in one
2193
+ // terminal and continued in another still prints everything after "start"
2194
+ // from the process that owns it — which, after an interrupt, has no
2195
+ // stdout at all. Posting to it left this terminal watching nothing happen
2196
+ // while the page finished by itself.
2197
+ let took = null;
2198
+ try {
2199
+ unlinkSync(returnPath());
2200
+ }
2201
+ catch {
2202
+ /* nothing left over */
2203
+ }
2204
+ if (await askRunTo(live, "SIGUSR2")) {
2205
+ // Bounded: it either writes the file within a moment or it is busy,
2206
+ // and busy is an answer too.
2207
+ for (let i = 0; i < 30 && !took; i++) {
2208
+ await new Promise((r) => setTimeout(r, 100));
2209
+ took = readHandover(returnPath());
2210
+ }
2211
+ }
2212
+ if (took) {
2213
+ log.note("picking up where that run left off");
2214
+ resumed = took;
2215
+ template = took.template;
2216
+ lang = took.lang;
2217
+ live = null;
2218
+ }
2219
+ else {
2220
+ // Still working — mid-install, or waiting on an OAuth round. That work
2221
+ // cannot move, so point at the page that is doing it and stay out.
2222
+ log.note("reopening that page — it is still working");
2223
+ log.command("aisa connect --force", "start over instead");
2224
+ if (withBrowserEarly())
2225
+ openBrowser(live.url);
2226
+ return;
2227
+ }
2228
+ }
2229
+ let servers;
2230
+ try {
2231
+ servers = resumed ? resumed.servers : await fetchLiveServers();
2232
+ }
2233
+ catch (e) {
2234
+ log.line("fail", "Could not read the MCP manifest", e.message);
2235
+ log.note("check your network and try again");
2236
+ process.exitCode = 1;
2237
+ return;
2238
+ }
2239
+ const clients = resumed ? resumed.clients : detectClients();
2240
+ // Rehearsal aid: in a dry run, pretend the listed clients are present so a
2241
+ // page can be reviewed for a client this machine does not have. Never
2242
+ // honoured outside --dry-run, where it could only mislead.
2243
+ if (options.dryRun && process.env.AISA_CONNECT_PRETEND_DETECTED) {
2244
+ const pretend = new Set(process.env.AISA_CONNECT_PRETEND_DETECTED.split(",").map((x) => x.trim()));
2245
+ for (const c of clients)
2246
+ if (pretend.has(c.id)) {
2247
+ c.detected = true;
2248
+ c.detail = `${c.detail} (pretend)`;
2249
+ }
2250
+ }
2251
+ const detected = clients.filter((c) => c.detected);
2252
+ if (detected.length === 0) {
2253
+ log.line("fail", "No supported client found", "Claude Code, Codex, opencode, VS Code, Cursor, Claude Desktop");
2254
+ log.note("install one, or write a config anyway:");
2255
+ log.command("aisa mcp setup --agent <client>");
2256
+ process.exitCode = 1;
2257
+ return;
2258
+ }
2259
+ const key = getApiKey();
2260
+ // A key that exists is not a key that works. One revoked from the console,
2261
+ // or belonging to a deleted account, sits in ~/.aisa/key looking exactly
2262
+ // like a good one — and the run then skipped the sign-in, wrote the dead
2263
+ // key into every MCP entry and into the agent's provider settings, and the
2264
+ // first the user heard of it was their agent failing to authenticate after
2265
+ // the setup had declared itself finished.
2266
+ //
2267
+ // Started here and never awaited: the user is about to spend half a minute
2268
+ // choosing servers, so the answer is always in hand before the plan is
2269
+ // built. Optimistic until it says otherwise, because "cannot tell" must not
2270
+ // push someone on a flaky connection through a sign-in they do not need.
2271
+ let keyVerdict = key ? "unreachable" : "invalid";
2272
+ const keyCheckP = key
2273
+ ? verifyKey(key).then((v) => (keyVerdict = v))
2274
+ : Promise.resolve("invalid");
2275
+ void keyCheckP;
2276
+ /** The stored key, or nothing when the gateway has rejected it. */
2277
+ const liveKey = () => (keyVerdict === "invalid" ? undefined : key);
2278
+ // One random token per run: the page and every endpoint require it, so
2279
+ // another local process cannot drive this server blind.
2280
+ const token = resumed ? resumed.token : randomBytes(16).toString("hex");
2281
+ // Rendered per request, not once at startup: the language picker changes
2282
+ // `lang` and reloads, and a page built before that would come back in the
2283
+ // language the user just switched away from.
2284
+ const page = () => template === "t2"
2285
+ ? renderT2Page(servers, clients, token, Boolean(liveKey()), supported(), !isInstalled("aisa"), "start", lang)
2286
+ : renderPage(servers, clients, token, Boolean(liveKey()), supported());
2287
+ const state = resumed ? resumed.state : {
2288
+ phase: "selecting",
2289
+ results: [],
2290
+ auth: {},
2291
+ steps: [],
2292
+ rev: 0,
2293
+ currentStep: 1,
2294
+ // Seeded with what the page would have ticked on its own, so a terminal
2295
+ // that never opens a browser starts from the same defaults rather than
2296
+ // from nothing.
2297
+ draft: {
2298
+ servers: servers.filter((s) => MCP_DEFAULT_SLUGS.includes(s.slug)).map((s) => s.slug),
2299
+ clients: [],
2300
+ install: [],
2301
+ llmMode: "backup",
2302
+ },
2303
+ };
2304
+ let chosenServers = [];
2305
+ let chosenClients = [];
2306
+ let port = 0;
2307
+ // T2 tells us when its step-5 animation has finished playing; the success
2308
+ // tab waits for that so the user is never yanked away mid-checklist. A
2309
+ // closed tab would never say so, hence the bounded wait below.
2310
+ let pageSeen = () => { };
2311
+ const pageSeenP = new Promise((r) => (pageSeen = r));
2312
+ /**
2313
+ * The page saying it has the sign-in warning on screen.
2314
+ *
2315
+ * A countdown run by this process was invisible: the page paces its
2316
+ * checklist, and measured here the sign-in row reached the screen seven
2317
+ * seconds after the run got to it — by which point the countdown was on its
2318
+ * last number. Warning somebody after the fact is not warning them.
2319
+ *
2320
+ * So the run waits to be told. Bounded, because a page that was never
2321
+ * opened will never say anything and a terminal-only run must not stall on
2322
+ * a browser that does not exist.
2323
+ */
2324
+ let signinShown = () => { };
2325
+ const signinShownP = new Promise((r) => (signinShown = r));
2326
+ /** Set when /apply came from the terminal rather than the page. */
2327
+ let drivenByTerminal = false;
2328
+ /**
2329
+ * Set the first time the page itself is served.
2330
+ *
2331
+ * Whether to keep the page alive past the terminal is not a question about
2332
+ * this machine — it is a question about whether anyone is looking at it.
2333
+ * Guessing from the environment gets both halves wrong: it would keep a
2334
+ * page alive on a desktop where the tab was never opened, and throw one
2335
+ * away on a server whose port someone had forwarded and was reading.
2336
+ */
2337
+ let pageOpened = false;
2338
+ let settled = false;
2339
+ // When this process intends to stop answering, and why. Both travel with
2340
+ // /status so the page can say so before the tab goes quiet on its own.
2341
+ let closesAt = resumed ? resumed.until : Date.now() + IDLE_TIMEOUT_MS;
2342
+ let supersededUntil;
2343
+ // A handed-over run answers to nobody but the page, and the page was
2344
+ // promised a window rather than ten quiet minutes — so it runs out its
2345
+ // clock instead, silently, because nothing is watching this stdout.
2346
+ const idle = setTimeout(() => {
2347
+ if (settled)
2348
+ return;
2349
+ if (!detached)
2350
+ error("No response from the browser in 10 minutes — giving up.");
2351
+ process.exit(detached ? 0 : 1);
2352
+ }, detached ? Math.max(1_000, resumed.until - Date.now()) : IDLE_TIMEOUT_MS);
2353
+ /**
2354
+ * The sign-in that happens inside a run, caught on the run's own port.
2355
+ *
2356
+ * Standalone `aisa login` opens a random loopback port for the redirect,
2357
+ * which is right when the sign-in is the whole errand. Here it is not: the
2358
+ * approval opens in a second tab, and a random port lands that tab on a
2359
+ * page with no idea a setup is in progress, leaving the reader to find
2360
+ * their own way back to the first tab. Catching it here means the tab comes
2361
+ * home to the address the run already lives at, and can return them to it.
2362
+ *
2363
+ * Only ever one at a time — a run signs in once — so a single slot is
2364
+ * enough, and `state` is what proves the response belongs to it. The route
2365
+ * cannot require the run token: the authorization server redirects with
2366
+ * `code` and `state` and nothing else, which is exactly what `state` is for.
2367
+ */
2368
+ let pendingSignIn;
2369
+ /** True only while the run is parked on a browser sign-in. */
2370
+ let awaitingSignIn = false;
2371
+ /** Set once the plan has run; the page's "Log in again" calls it. */
2372
+ let retrySignIn;
2373
+ /** One at a time. */
2374
+ let retrying = false;
2375
+ /**
2376
+ * Give the page a working "Log in again", for a given plan.
2377
+ *
2378
+ * Called twice over a run's life: once when the plan is first built, and
2379
+ * again by a process that inherited a run whose terminal went away mid
2380
+ * sign-in. The second is the point — a handed-over run keeps its page, and
2381
+ * a page whose only useful button does nothing is not much of a page.
2382
+ */
2383
+ const armRetry = (planInput) => {
2384
+ retrySignIn = async () => {
2385
+ if (!state.needsSignIn || retrying)
2386
+ return false;
2387
+ retrying = true;
2388
+ state.needsSignIn = false;
2389
+ state.phase = "applying";
2390
+ // Everything the stop left behind goes back to pending; the sign-in
2391
+ // itself is the step being retried.
2392
+ for (const step of state.steps) {
2393
+ if (step.state === "skip" || step.id === "signin") {
2394
+ setStep(state, step.id, { state: "pending", detail: "" });
2395
+ }
2396
+ }
2397
+ log.line("step", "Trying the sign-in again", "at your request, from the page");
2398
+ try {
2399
+ const again = await runPlan(state, planInput, log);
2400
+ state.phase = again > 0 ? "failed" : "done";
2401
+ }
2402
+ finally {
2403
+ retrying = false;
2404
+ }
2405
+ return true;
2406
+ };
2407
+ };
2408
+ /**
2409
+ * Hand the run's own address to the sign-in.
2410
+ *
2411
+ * Only where a browser can actually be opened here. On a server the
2412
+ * redirect would come back to a loopback port nobody can reach, so the
2413
+ * sign-in keeps its hosted-redirect-and-paste path — the port would be a
2414
+ * dead end dressed up as an improvement.
2415
+ */
2416
+ const signInCatcher = canOpenBrowser()
2417
+ ? {
2418
+ // A getter, not a value: the port is assigned when the server binds,
2419
+ // which is after this object exists and long before it is read.
2420
+ get redirectUri() {
2421
+ return `http://127.0.0.1:${port}/callback`;
2422
+ },
2423
+ wait(expected) {
2424
+ awaitingSignIn = true;
2425
+ return new Promise((resolve, reject) => {
2426
+ // The same deadline the standalone sign-in uses. A tab that is
2427
+ // never approved must not hold a run open for ever.
2428
+ const giveUp = setTimeout(() => {
2429
+ if (pendingSignIn?.state !== expected)
2430
+ return;
2431
+ pendingSignIn = undefined;
2432
+ reject(new Error("no response from the browser — retry later with 'aisa login'"));
2433
+ }, SIGNIN_CATCH_TIMEOUT_MS);
2434
+ giveUp.unref?.();
2435
+ pendingSignIn = {
2436
+ state: expected,
2437
+ resolve: (code) => {
2438
+ clearTimeout(giveUp);
2439
+ awaitingSignIn = false;
2440
+ resolve(code);
2441
+ },
2442
+ reject: (e) => {
2443
+ clearTimeout(giveUp);
2444
+ awaitingSignIn = false;
2445
+ reject(e);
2446
+ },
2447
+ };
2448
+ });
2449
+ },
2450
+ }
2451
+ : undefined;
2452
+ const returnPage = (outcome, back) => renderReturnPage(outcome, back, {
2453
+ title: t(RETURN_PAGE[outcome].title, lang),
2454
+ body: t(RETURN_PAGE[outcome].body, lang),
2455
+ link: t(RETURN_PAGE.link, lang),
2456
+ });
2457
+ const srv = createServer(async (req, res) => {
2458
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
2459
+ const tokenOk = url.searchParams.get("token") === token || req.headers["x-connect-token"] === token;
2460
+ if (req.method === "GET" && url.pathname === "/callback") {
2461
+ const code = url.searchParams.get("code");
2462
+ const got = url.searchParams.get("state");
2463
+ const mine = pendingSignIn && got === pendingSignIn.state;
2464
+ // `state` is the only credential this route can ask for — the
2465
+ // authorization server redirects with `code` and `state` and nothing
2466
+ // else — so everything that turns on it is decided here.
2467
+ //
2468
+ // Two things follow from a state that does not match. The run is not
2469
+ // cancelled: any local process could otherwise abort a sign-in by
2470
+ // fetching this path once. And the page it gets back carries no link
2471
+ // home, because that link contains the run token — the one thing
2472
+ // keeping other processes on this machine from driving the run.
2473
+ if (!mine) {
2474
+ res
2475
+ .writeHead(pendingSignIn ? 400 : 200, { "content-type": "text/html; charset=utf-8" })
2476
+ .end(returnPage("stale", ""));
2477
+ return;
2478
+ }
2479
+ // Marked, so the page it lands on can say what just happened rather
2480
+ // than looking like a setup that reloaded for no reason.
2481
+ const back = `http://127.0.0.1:${port}/?token=${token}&signedin=1`;
2482
+ const p = pendingSignIn;
2483
+ pendingSignIn = undefined;
2484
+ if (!code) {
2485
+ // Right state, no code: the authorization server said no, or the
2486
+ // user declined. That is this run's answer and it does end it.
2487
+ res
2488
+ .writeHead(400, { "content-type": "text/html; charset=utf-8" })
2489
+ .end(returnPage("failed", back));
2490
+ p.reject(new Error("authorization was denied or the response was malformed"));
2491
+ return;
2492
+ }
2493
+ res
2494
+ .writeHead(200, { "content-type": "text/html; charset=utf-8" })
2495
+ .end(returnPage("ok", back));
2496
+ p.resolve(code);
2497
+ return;
2498
+ }
2499
+ if (req.method === "GET" && url.pathname === "/") {
2500
+ if (!tokenOk) {
2501
+ res.writeHead(403).end("forbidden");
2502
+ return;
2503
+ }
2504
+ pageOpened = true;
2505
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(page());
2506
+ return;
2507
+ }
2508
+ if (req.method === "GET" && url.pathname === "/status") {
2509
+ if (!tokenOk) {
2510
+ res.writeHead(403).end();
2511
+ return;
2512
+ }
2513
+ // Same two fields the handed-over results server publishes, so the
2514
+ // page's countdown does not care which process is answering it.
2515
+ const closingAt = supersededUntil ?? closesAt;
2516
+ res
2517
+ .writeHead(200, { "content-type": "application/json" })
2518
+ .end(JSON.stringify({ ...state, closingAt, superseded: Boolean(supersededUntil) }));
2519
+ return;
2520
+ }
2521
+ if (req.method === "GET" && url.pathname === "/done") {
2522
+ if (!tokenOk) {
2523
+ res.writeHead(403).end("forbidden");
2524
+ return;
2525
+ }
2526
+ // T2's done view is the same page parked on its last step; T1 has a
2527
+ // dedicated success page.
2528
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(template === "t2"
2529
+ ? renderT2Page(servers, clients, token, Boolean(key), supported(), !isInstalled("aisa"), "done", lang)
2530
+ : renderDone(chosenServers, chosenClients, state.steps, servers, state.balanceMicros ?? null, state.llmMode));
2531
+ return;
2532
+ }
2533
+ if (req.method === "GET" && url.pathname === "/key") {
2534
+ // For the one client that cannot take the key from a file (VS Code):
2535
+ // the results page copies it to the clipboard for pasting into the
2536
+ // app's own key prompt. Token-gated, loopback only, like everything.
2537
+ if (!tokenOk) {
2538
+ res.writeHead(403).end();
2539
+ return;
2540
+ }
2541
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ key: key ?? null }));
2542
+ return;
2543
+ }
2544
+ if (req.method === "POST" && url.pathname === "/lang") {
2545
+ if (!tokenOk) {
2546
+ res.writeHead(403).end();
2547
+ return;
2548
+ }
2549
+ // Stored by the CLI rather than the browser, so the terminal running
2550
+ // beside this page switches too and the next run opens the same way.
2551
+ let body;
2552
+ try {
2553
+ body = JSON.parse(await readBody(req));
2554
+ }
2555
+ catch {
2556
+ res.writeHead(400).end();
2557
+ return;
2558
+ }
2559
+ const picked = LANGS.includes(body.lang) ? body.lang : undefined;
2560
+ if (!picked) {
2561
+ res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ ok: false }));
2562
+ return;
2563
+ }
2564
+ setConfig("lang", picked);
2565
+ lang = picked;
2566
+ log.line("info", "Language", picked === "zh" ? "切换为中文" : "switched to English");
2567
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true, lang: picked }));
2568
+ return;
2569
+ }
2570
+ if (req.method === "POST" && url.pathname === "/select") {
2571
+ if (!tokenOk) {
2572
+ res.writeHead(403).end();
2573
+ return;
2574
+ }
2575
+ // The shared draft. Both the page and the terminal write here, so the
2576
+ // reply always carries the state that won — a writer whose rev is stale
2577
+ // gets told what it missed instead of having its copy accepted.
2578
+ let body;
2579
+ try {
2580
+ body = JSON.parse(await readBody(req));
2581
+ }
2582
+ catch {
2583
+ res.writeHead(400).end();
2584
+ return;
2585
+ }
2586
+ const current = state.rev ?? 0;
2587
+ const stale = typeof body.rev === "number" && body.rev !== current;
2588
+ if (!stale) {
2589
+ if (body.draft)
2590
+ state.draft = { ...state.draft, ...body.draft };
2591
+ if (typeof body.step === "number")
2592
+ state.currentStep = body.step;
2593
+ state.rev = current + 1;
2594
+ }
2595
+ res.writeHead(stale ? 409 : 200, { "content-type": "application/json" }).end(JSON.stringify({ ok: !stale, rev: state.rev, draft: state.draft, currentStep: state.currentStep }));
2596
+ return;
2597
+ }
2598
+ if (req.method === "POST" && url.pathname === "/retry-signin") {
2599
+ if (!tokenOk) {
2600
+ res.writeHead(403).end();
2601
+ return;
2602
+ }
2603
+ // Answer before the work: the retry opens a browser tab and then waits
2604
+ // up to ten minutes, and a request held open for that long is a
2605
+ // request the page has already given up on.
2606
+ // Also refuses while one is already under way: the button is clickable
2607
+ // again the moment the checklist repaints, and a second attempt would
2608
+ // race the first for the same sign-in.
2609
+ const can = Boolean(retrySignIn) && Boolean(state.needsSignIn) && !retrying;
2610
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: can }));
2611
+ if (can)
2612
+ void retrySignIn();
2613
+ return;
2614
+ }
2615
+ if (req.method === "POST" && url.pathname === "/signin-shown") {
2616
+ if (!tokenOk) {
2617
+ res.writeHead(403).end();
2618
+ return;
2619
+ }
2620
+ signinShown();
2621
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true }));
2622
+ return;
2623
+ }
2624
+ if (req.method === "POST" && url.pathname === "/seen") {
2625
+ if (!tokenOk) {
2626
+ res.writeHead(403).end();
2627
+ return;
2628
+ }
2629
+ pageSeen();
2630
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true }));
2631
+ return;
2632
+ }
2633
+ if (req.method === "POST" && url.pathname === "/launch") {
2634
+ if (!tokenOk) {
2635
+ res.writeHead(403).end();
2636
+ return;
2637
+ }
2638
+ // Whitelist only — the client name selects a fixed binary, and no part
2639
+ // of the request ever reaches a shell.
2640
+ const backup = state.llmMode === "backup";
2641
+ if (chosenClients[0] === "vscode" || chosenClients[0] === "cursor" || chosenClients[0] === "claude-desktop") {
2642
+ // Not terminal agents: open (or for Claude Desktop, restart) the app.
2643
+ const launched = chosenClients[0] === "vscode" ? launchVSCode(process.cwd())
2644
+ : chosenClients[0] === "cursor" ? launchCursor(process.cwd())
2645
+ : restartClaudeDesktop();
2646
+ res.writeHead(launched ? 200 : 500, { "content-type": "application/json" }).end(JSON.stringify({ ok: launched }));
2647
+ return;
2648
+ }
2649
+ const bin = chosenClients[0] === "codex" ? (backup ? "codex-aisa" : "codex")
2650
+ : chosenClients[0] === "claude-code" ? (backup ? "claude-aisa" : "claude")
2651
+ : chosenClients[0] === "opencode" ? "opencode" : null;
2652
+ if (!bin) {
2653
+ res.writeHead(400, { "content-type": "application/json" }).end(JSON.stringify({ ok: false }));
2654
+ return;
2655
+ }
2656
+ // Wait for the real outcome: a 200 over a failed launch would leave the
2657
+ // page claiming success it never checked (headless Linux, no terminal
2658
+ // emulator found). The page's catch shows the run-it-yourself hint.
2659
+ const launched = await launchAgentTerminal(bin);
2660
+ res
2661
+ .writeHead(launched ? 200 : 500, { "content-type": "application/json" })
2662
+ .end(JSON.stringify({ ok: launched }));
2663
+ return;
2664
+ }
2665
+ if (req.method === "POST" && url.pathname === "/apply") {
2666
+ if (!tokenOk) {
2667
+ res.writeHead(403).end();
2668
+ return;
2669
+ }
2670
+ let body;
2671
+ try {
2672
+ body = JSON.parse(await readBody(req));
2673
+ }
2674
+ catch {
2675
+ res.writeHead(400).end();
2676
+ return;
2677
+ }
2678
+ // Once only. Both surfaces reach step 5, and the page starts by itself
2679
+ // on arriving there — so the terminal confirming and the page adopting
2680
+ // that step can both call this within the same second. Running it twice
2681
+ // means installing twice and writing the same config twice, which the
2682
+ // timing alone was keeping rare rather than impossible.
2683
+ if (state.phase !== "selecting") {
2684
+ res
2685
+ .writeHead(200, { "content-type": "application/json" })
2686
+ .end(JSON.stringify({ started: false, already: true, steps: state.steps }));
2687
+ return;
2688
+ }
2689
+ // Who started this decides whether there is an animation to wait for.
2690
+ drivenByTerminal = req.headers["x-connect-source"] === "terminal";
2691
+ chosenServers = servers.filter((s) => body.servers?.includes(s.slug));
2692
+ chosenClients = body.clients ?? [];
2693
+ const wantInstall = new Set(body.install ?? []);
2694
+ // Ticking "install" means "and connect it": nobody installs an agent
2695
+ // here except to put AIsa servers in it, and asking twice for the same
2696
+ // intention is how a user ends up with an empty Codex.
2697
+ for (const id of wantInstall) {
2698
+ if (!chosenClients.includes(id))
2699
+ chosenClients.push(id);
2700
+ }
2701
+ state.phase = "applying";
2702
+ const llmMode = body.llmMode === "switch" || body.llmMode === "backup" || body.llmMode === "skip"
2703
+ ? body.llmMode
2704
+ : body.llm
2705
+ ? "switch"
2706
+ : "skip";
2707
+ state.llmMode = llmMode;
2708
+ state.selection = {
2709
+ servers: chosenServers.map((s) => s.slug),
2710
+ clients: chosenClients,
2711
+ install: [...wantInstall],
2712
+ llmMode,
2713
+ };
2714
+ // The whole plan, in order, before any of it runs — the page renders it
2715
+ // greyed out so a long install reads as progress rather than a hang.
2716
+ // T2 connects Cursor through its install deeplinks; an empty array here
2717
+ // is the signal applySelection fills in.
2718
+ if (template === "t2" && chosenClients[0] === "cursor")
2719
+ state.deeplinks = [];
2720
+ state.steps = buildPlan({
2721
+ install: [...wantInstall],
2722
+ clients: chosenClients,
2723
+ servers: chosenServers,
2724
+ keyed: Boolean(liveKey()),
2725
+ keyRejected: Boolean(key) && keyVerdict === "invalid",
2726
+ dryRun: Boolean(options.dryRun),
2727
+ llmMode,
2728
+ deeplink: template === "t2" && chosenClients[0] === "cursor",
2729
+ });
2730
+ res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ started: true, steps: state.steps }));
2731
+ if (settled)
2732
+ return;
2733
+ settled = true;
2734
+ clearTimeout(idle);
2735
+ // Replay the browser's choices in the terminal: the page is transient,
2736
+ // the scrollback is not, and a reader who only has this should still
2737
+ // know what was decided on their behalf.
2738
+ log.section("What you chose");
2739
+ const clientLabel = clients.find((c) => c.id === chosenClients[0])?.label ?? chosenClients[0];
2740
+ log.line("choice", "Agent", wantInstall.size ? `${clientLabel} (install it first)` : clientLabel);
2741
+ log.line("choice", "Models", llmMode === "switch"
2742
+ ? `point ${clientLabel} at AIsa (${defaultModelsFor(chosenClients[0]).model})`
2743
+ : llmMode === "backup"
2744
+ ? "add AIsa beside your current setup, nothing replaced"
2745
+ : "leave models as they are");
2746
+ log.line("choice", `Capabilities (${chosenServers.length})`, chosenServers.map((s) => `aisa-${s.slug}`).join(", "));
2747
+ log.section("Setting things up");
2748
+ /**
2749
+ * A failed sign-in stops the run rather than writing half a
2750
+ * configuration, and the page offers to try again. That retry has to
2751
+ * re-enter the plan with exactly the inputs the first pass had — the
2752
+ * agent, the servers, the model choice — so they are kept rather than
2753
+ * recomputed from a page that may have been reloaded since.
2754
+ */
2755
+ const planInput = {
2756
+ install: [...wantInstall],
2757
+ clients: chosenClients,
2758
+ servers: chosenServers,
2759
+ // Not `key`: a key the gateway has turned down must not be written
2760
+ // into MCP entries or provider settings on its way to failing.
2761
+ key: liveKey(),
2762
+ catcher: signInCatcher,
2763
+ signinShown: signinShownP,
2764
+ dryRun: Boolean(options.dryRun),
2765
+ llmMode,
2766
+ lang,
2767
+ };
2768
+ armRetry(planInput);
2769
+ const failures = await runPlan(state, planInput, log);
2770
+ const results = state.results;
2771
+ // Long since settled by now (fired at the top of connectAction) — this
2772
+ // just reads the resolved value, it does not add a wait.
2773
+ const newerVersion = await updateCheckP;
2774
+ {
2775
+ // The backend is genuinely done the moment runPlan resolves — but
2776
+ // T2's own page is still deliberately pacing through its checklist
2777
+ // animation for a few more seconds, and its results tab has not
2778
+ // opened yet. Declaring "All set" here, before that, reads as the
2779
+ // terminal getting ahead of what the user is watching on screen. So
2780
+ // state.phase (which the page's poll loop needs right away) is set
2781
+ // immediately, but the celebration itself waits for the same moment
2782
+ // the page does: after pageSeenP, right as the results tab opens.
2783
+ state.phase = failures > 0 ? "failed" : "done";
2784
+ const celebrate = () => {
2785
+ // A run stopped for want of a key connected nothing, and counting
2786
+ // the capabilities the user *chose* as though they had arrived is
2787
+ // the summary congratulating them on a setup that did not happen.
2788
+ if (state.needsSignIn) {
2789
+ log.section("Stopped — waiting on your sign-in");
2790
+ log.line("warn", "Nothing was written", "the rest of the setup runs once you are signed in");
2791
+ log.command("aisa connect", "start again when you are ready");
2792
+ return;
2793
+ }
2794
+ log.section(failures > 0 ? "Finished, with issues" : "🎉 All set");
2795
+ if (failures > 0) {
2796
+ log.line("warn", `${failures} step${failures > 1 ? "s" : ""} did not complete`, "details above");
2797
+ }
2798
+ log.line("ok", options.dryRun
2799
+ ? "Dry run complete — nothing was written"
2800
+ : `${chosenServers.length} capabilit${chosenServers.length === 1 ? "y" : "ies"} connected to ${clientLabel}`);
2801
+ };
2802
+ // A run halted for want of a key has no results to show, and the
2803
+ // page is offering to try the sign-in again — opening a second tab
2804
+ // over that, describing a setup that did not happen, would take the
2805
+ // reader away from the one button that helps.
2806
+ if (!options.dryRun && !state.needsSignIn) {
2807
+ // The success page opens as a fresh tab from this process (an OS
2808
+ // browser launch, so no popup blocker applies) — users who tabbed
2809
+ // away to the authorization rarely come back to the first tab.
2810
+ const doneUrl = `http://127.0.0.1:${port}/done?token=${token}`;
2811
+ state.doneUrl = doneUrl;
2812
+ // Printed before the wait, so it has to be true before the wait.
2813
+ // "Finishing up…" with a tick would be a receipt for something that
2814
+ // has not happened yet; what *has* happened is everything on this
2815
+ // machine — runPlan has returned and every file is written. The
2816
+ // only thing outstanding is an animation in a browser.
2817
+ log.line("ok", "Everything on this machine is done", "waiting for the page to catch up");
2818
+ // Only a page that ran the apply itself has a checklist to finish.
2819
+ // Waiting on one that never started meant the terminal sat here for
2820
+ // the full ninety seconds after everything was already done.
2821
+ if (template === "t2" && !drivenByTerminal) {
2822
+ await Promise.race([pageSeenP, pause(PAGE_SEEN_TIMEOUT_MS)]);
2823
+ }
2824
+ await pause(BEFORE_HANDOFF_MS);
2825
+ if (withBrowserEarly())
2826
+ openBrowser(doneUrl);
2827
+ celebrate();
2828
+ if (withBrowserEarly()) {
2829
+ const until = new Date(Date.now() + LINGER_AFTER_DONE_MS);
2830
+ log.note(`the page stays up until ${until.getHours()}:${String(until.getMinutes()).padStart(2, "0")} — Ctrl-C to finish now`);
2831
+ }
2832
+ // Last, so it is the line still on screen: everything else is what
2833
+ // happened, this is what to do next.
2834
+ summarise(log, {
2835
+ clientId: chosenClients[0],
2836
+ clientLabel,
2837
+ servers: chosenServers,
2838
+ llmMode,
2839
+ steps: state.steps,
2840
+ balance: state.balanceMicros ?? null,
2841
+ newerVersion,
2842
+ });
2843
+ const primary = launchChoices(chosenClients[0], llmMode)[0];
2844
+ log.encore([
2845
+ ...(primary ? [{ cmd: primary.cmd, why: primary.desc }] : []),
2846
+ { cmd: "aisa connect", why: "run this any time — configure servers, switch models, connect another agent" },
2847
+ ]);
2848
+ const outcome = await offerLaunch(log, chosenClients[0], llmMode, lang);
2849
+ if (outcome === "ran-agent" || outcome === "chose-exit") {
2850
+ // Either they just came out of a real session, or they asked for
2851
+ // the terminal back. "Exit" says it hands the terminal back; it
2852
+ // used to fall into the linger below and hold it for half an
2853
+ // hour, which is the one thing the word promises not to do.
2854
+ if (outcome === "chose-exit") {
2855
+ // Give the terminal back without taking the page with it: a
2856
+ // detached copy takes over the same port and serves the results
2857
+ // for what is left of the linger. The browser tab does not
2858
+ // notice; the shell gets its prompt.
2859
+ const handed = handOverResults({
2860
+ port,
2861
+ token,
2862
+ template,
2863
+ lang,
2864
+ keyed: Boolean(liveKey()),
2865
+ canInstall: supported(),
2866
+ servers,
2867
+ clients,
2868
+ chosenServers,
2869
+ chosenClients,
2870
+ state,
2871
+ until: Date.now() + LINGER_AFTER_DONE_MS,
2872
+ }, () => srv.close());
2873
+ log.note(handed
2874
+ ? lang === "zh"
2875
+ ? "结果页继续开着 30 分钟 —— 再跑 aisa connect 会重新开始"
2876
+ : "the results page stays up for 30 minutes — aisa connect starts fresh"
2877
+ : lang === "zh"
2878
+ ? "结果页已关闭 —— 随时可以再跑 aisa connect"
2879
+ : "the results page is closing — run aisa connect any time");
2880
+ process.exit(failures > 0 ? 1 : 0);
2881
+ }
2882
+ // Coming out of an agent session is not a reason to close the
2883
+ // page: the line above promised it until a stated time, and a
2884
+ // reader who goes back to that tab should find it, not a dead
2885
+ // port. Same handover as Exit.
2886
+ handOverResults({
2887
+ port, token, template, lang,
2888
+ keyed: Boolean(liveKey()),
2889
+ canInstall: supported(),
2890
+ servers, clients, chosenServers, chosenClients, state,
2891
+ until: Date.now() + LINGER_AFTER_DONE_MS,
2892
+ }, () => srv.close());
2893
+ process.exit(failures > 0 ? 1 : 0);
2894
+ }
2895
+ else {
2896
+ // Nothing to offer, or it would not start: leave the page up for
2897
+ // someone who is still reading it.
2898
+ closesAt = Date.now() + LINGER_AFTER_DONE_MS;
2899
+ setTimeout(() => {
2900
+ srv.close();
2901
+ process.exit(failures > 0 ? 1 : 0);
2902
+ }, LINGER_AFTER_DONE_MS);
2903
+ }
2904
+ }
2905
+ else {
2906
+ // Dry run: nothing is animating and no tab is opening, so there is
2907
+ // nothing to wait for — the celebration can fire right away.
2908
+ celebrate();
2909
+ summarise(log, {
2910
+ clientId: chosenClients[0],
2911
+ clientLabel,
2912
+ servers: chosenServers,
2913
+ llmMode,
2914
+ steps: state.steps,
2915
+ balance: state.balanceMicros ?? null,
2916
+ newerVersion,
2917
+ });
2918
+ log.encore([
2919
+ { cmd: "aisa connect", why: "run this any time — configure servers, switch models, connect another agent" },
2920
+ ]);
2921
+ // Dry run: nothing was actually configured, so there is no real
2922
+ // command to hand the terminal over to — no launch prompt here.
2923
+ // The page (and T2's done step) stays reachable for a minute so a
2924
+ // rehearsal can be read.
2925
+ setTimeout(() => {
2926
+ srv.close();
2927
+ process.exit(0);
2928
+ }, 60_000);
2929
+ }
2930
+ }
2931
+ return;
2932
+ }
2933
+ res.writeHead(404).end();
2934
+ });
2935
+ await new Promise((resolve) => {
2936
+ if (!resumed) {
2937
+ srv.listen(options.port ? Number(options.port) : 0, "127.0.0.1", resolve);
2938
+ return;
2939
+ }
2940
+ // The process we came from has just closed its listener; a socket takes
2941
+ // a moment to let go, so keep asking rather than giving the page a dead
2942
+ // port on the strength of one failed attempt.
2943
+ let attempt = 0;
2944
+ const tryListen = () => {
2945
+ const onError = () => {
2946
+ if (++attempt >= 20) {
2947
+ process.exit(1);
2948
+ }
2949
+ setTimeout(tryListen, 150);
2950
+ };
2951
+ srv.once("error", onError);
2952
+ srv.listen(resumed.port, "127.0.0.1", () => {
2953
+ srv.off("error", onError);
2954
+ resolve();
2955
+ });
2956
+ };
2957
+ tryListen();
2958
+ });
2959
+ const addr = srv.address();
2960
+ port = typeof addr === "object" && addr ? addr.port : 0;
2961
+ // A run inherited mid sign-in arrives with its plan already decided and its
2962
+ // only useful button pointing at a function this process has not built yet.
2963
+ // A resumed run knows what was chosen — it travelled in the state — but the
2964
+ // variables the routes read are filled in by /apply, which this process
2965
+ // never runs. Left empty, /launch could not name a binary and answered 400,
2966
+ // and the page reported it as "could not open a terminal": two unrelated
2967
+ // causes behind one sentence, and the one that was true said nothing about
2968
+ // the real problem.
2969
+ if (resumed && state.selection) {
2970
+ chosenClients = [...state.selection.clients];
2971
+ chosenServers = servers.filter((x) => state.selection.servers.includes(x.slug));
2972
+ // llmMode is not a local here — the routes read state.llmMode, which
2973
+ // travels with the run.
2974
+ }
2975
+ // The selection is in the state it inherited, so rebuild the plan from that
2976
+ // rather than asking a page that may since have been reloaded.
2977
+ if (resumed && state.needsSignIn && state.selection) {
2978
+ const sel = state.selection;
2979
+ armRetry({
2980
+ install: [...sel.install],
2981
+ clients: [...sel.clients],
2982
+ servers: servers.filter((x) => sel.servers.includes(x.slug)),
2983
+ key: liveKey(),
2984
+ catcher: signInCatcher,
2985
+ signinShown: signinShownP,
2986
+ dryRun: Boolean(options.dryRun),
2987
+ llmMode: sel.llmMode,
2988
+ lang,
2989
+ });
2990
+ }
2991
+ const pageUrl = `http://127.0.0.1:${port}/?token=${token}`;
2992
+ // Publish the address for a second invocation, and take it back on every
2993
+ // way out: normal exit, Ctrl-C, or an unhandled crash.
2994
+ let handedOver = false;
2995
+ writeRunLock(pageUrl);
2996
+ // A newer run asks this one to stand down. Killing it outright would leave
2997
+ // whoever is reading that tab with a page that simply stopped answering,
2998
+ // which is the thing the countdown exists to prevent — so it is a request,
2999
+ // not a kill, and the page has a minute to say what happened.
3000
+ // A terminal has come back for this run. Driving it from there over HTTP
3001
+ // was the obvious thing and the wrong one: everything after "start" — the
3002
+ // progress, the results, the launch menu — is printed by whichever process
3003
+ // owns the run, and this one may have no stdout to print to. So it gives
3004
+ // the run up instead, and the terminal picks it up on the same port.
3005
+ process.on("SIGUSR2", () => {
3006
+ if (state.phase !== "selecting")
3007
+ return;
3008
+ try {
3009
+ writeFileSync(returnPath(), JSON.stringify({
3010
+ port, token, lang, template,
3011
+ dryRun: Boolean(options.dryRun),
3012
+ servers, clients, state,
3013
+ until: Date.now() + LINGER_AFTER_DONE_MS,
3014
+ }), { mode: 0o600 });
3015
+ }
3016
+ catch {
3017
+ return; // could not write it down, so keep serving rather than vanish
3018
+ }
3019
+ handedOver = true;
3020
+ srv.close();
3021
+ process.exit(0);
3022
+ });
3023
+ process.on("SIGUSR1", () => {
3024
+ if (supersededUntil)
3025
+ return;
3026
+ supersededUntil = Date.now() + SUPERSEDE_GRACE_MS;
3027
+ setTimeout(() => {
3028
+ srv.close();
3029
+ process.exit(0);
3030
+ }, SUPERSEDE_GRACE_MS).unref?.();
3031
+ setTimeout(() => process.exit(0), SUPERSEDE_GRACE_MS + 2_000);
3032
+ });
3033
+ process.once("exit", () => {
3034
+ // The lock now belongs to whoever is still serving that page.
3035
+ if (!handedOver)
3036
+ clearRunLock();
3037
+ });
3038
+ for (const sig of ["SIGINT", "SIGTERM"]) {
3039
+ process.once(sig, () => {
3040
+ // This handler is registered before the picker's, and only one of them
3041
+ // gets to exit — so put the terminal back before leaving, or the shell
3042
+ // that asked for control back gets it with no echo.
3043
+ restoreTerminal();
3044
+ // Ctrl-C asks for the shell back. It does not ask for the browser tab
3045
+ // to close, and taking it down was the one thing the page could not
3046
+ // recover from. Anything already being written is a different matter:
3047
+ // that work lives in this process and cannot be moved mid-flight.
3048
+ //
3049
+ // Waiting for a sign-in is not that. Nothing is being written — the run
3050
+ // is parked on a promise that only a browser can resolve — so the
3051
+ // "cannot be moved" reasoning does not reach it, and the page has every
3052
+ // reason to outlive the terminal here: the reader is in another tab
3053
+ // signing in. The wait itself cannot be carried across, so it is ended
3054
+ // honestly and the page is handed over offering to start it again.
3055
+ const wasAwaiting = awaitingSignIn;
3056
+ if (wasAwaiting) {
3057
+ awaitingSignIn = false;
3058
+ // Marked here rather than left to runPlan's catch: rejecting the
3059
+ // promise settles on a microtask, and this handler decides what to
3060
+ // hand over on the line below. The state the child inherits has to be
3061
+ // the true one — a sign-in that ended, not one still turning.
3062
+ setStep(state, "signin", {
3063
+ state: "fail",
3064
+ detail: "the terminal was closed while the sign-in was open — nothing was written",
3065
+ });
3066
+ for (const step of state.steps) {
3067
+ if (step.state === "pending") {
3068
+ setStep(state, step.id, { state: "skip", detail: "waiting for the sign-in" });
3069
+ }
3070
+ }
3071
+ state.needsSignIn = true;
3072
+ state.phase = "failed";
3073
+ if (pendingSignIn) {
3074
+ const p = pendingSignIn;
3075
+ pendingSignIn = undefined;
3076
+ p.reject(new Error("the terminal was closed while the sign-in was open"));
3077
+ }
3078
+ }
3079
+ if ((state.phase === "selecting" || wasAwaiting) && pageOpened) {
3080
+ const until = Date.now() + LINGER_AFTER_DONE_MS;
3081
+ if (handOverRun({ port, token, lang, template, dryRun: Boolean(options.dryRun), servers, clients, state, until }, () => srv.close())) {
3082
+ handedOver = true;
3083
+ console.log("");
3084
+ log.line("info", "Terminal released", wasAwaiting
3085
+ ? "the sign-in was not finished — the page offers to start it again"
3086
+ : "the page carries on without it");
3087
+ console.log(` ${chalk.cyan(pageUrl)}`);
3088
+ log.note(`open for another ${Math.round(LINGER_AFTER_DONE_MS / 60_000)} minutes`);
3089
+ log.command("aisa connect", "come back to it");
3090
+ console.log("");
3091
+ process.exit(0);
3092
+ }
3093
+ }
3094
+ clearRunLock();
3095
+ process.exit(130);
3096
+ });
3097
+ }
3098
+ log.line("ok", "Found on this machine", detected.map((c) => c.label).join(", "));
3099
+ const missing = clients.filter((c) => !c.detected).map((c) => c.label);
3100
+ if (missing.length)
3101
+ log.record(`not found: ${missing.join(", ")}`);
3102
+ log.line("info", `${servers.length} AIsa MCP servers are live`, "pick what you need");
3103
+ // The terminal walks the same six steps from the same definition. It is not
3104
+ // a fallback: on a machine with no browser, printing a localhost URL is a
3105
+ // dead end, and both surfaces share one draft so either can be used —
3106
+ // together, or on its own.
3107
+ // Whether a browser opens is a property of the machine, not a mode the
3108
+ // user should have to name. On a box that has one it opens; on a box that
3109
+ // does not, the URL is printed for whoever can reach it and the terminal
3110
+ // carries the whole run. Neither case needs telling in advance.
3111
+ const withBrowser = options.open !== false && canOpenBrowser();
3112
+ if (!options.headless && !detached) {
3113
+ // Two surfaces, introduced in whichever order this machine can offer
3114
+ // them. A box with a browser is shown the page first; a box without one
3115
+ // is told what it is doing instead — by name, as a mode, not as a
3116
+ // shortfall — and the address stays available underneath for anyone who
3117
+ // would rather forward a port and click.
3118
+ if (withBrowser || options.open === false) {
3119
+ log.section(t(SURFACE.pageTitle, lang));
3120
+ console.log(` ${chalk.cyan(pageUrl)}`);
3121
+ if (withBrowser) {
3122
+ log.note(t(SURFACE.opening, lang));
3123
+ openBrowser(pageUrl);
3124
+ }
3125
+ else {
3126
+ log.note(t(SURFACE.urlOnly, lang));
3127
+ }
3128
+ }
3129
+ else {
3130
+ // No address printed here on purpose. It is a 127.0.0.1 URL for a
3131
+ // machine the reader is not sitting at: copied to their own computer
3132
+ // it opens nothing, or worse, opens whatever they happen to be running
3133
+ // on that port. Reaching it needs port forwarding, which is not
3134
+ // something to put in front of someone who did not ask for it — and
3135
+ // anyone who would do it already knows the port from the flag they
3136
+ // passed. So the terminal simply gets on with the questions.
3137
+ log.section(t(SURFACE.consoleTitle, lang));
3138
+ log.line("info", t(SURFACE.consoleBody, lang));
3139
+ }
3140
+ }
3141
+ if (!detached && (options.headless || (process.stdin.isTTY && process.stdout.isTTY))) {
3142
+ const picked = await runTerminalFlow({
3143
+ baseUrl: `http://127.0.0.1:${port}`,
3144
+ token,
3145
+ lang,
3146
+ servers,
3147
+ clients: flowClients(clients, supported()),
3148
+ headless: Boolean(options.headless),
3149
+ });
3150
+ if (picked) {
3151
+ // Hand the terminal's answers to the same endpoint the page posts to,
3152
+ // so there is one apply path and not a second one that can drift.
3153
+ try {
3154
+ const res = await httpFetch(`http://127.0.0.1:${port}/apply?token=${token}`, {
3155
+ method: "POST",
3156
+ headers: { "content-type": "application/json", "x-connect-source": "terminal" },
3157
+ body: JSON.stringify({
3158
+ servers: picked.servers,
3159
+ clients: picked.clients,
3160
+ install: picked.install,
3161
+ llmMode: picked.llmMode,
3162
+ }),
3163
+ timeoutMs: 15_000,
3164
+ });
3165
+ const body = (await res.json());
3166
+ if (body.already) {
3167
+ // The page got there first — its run is the one that counts, and
3168
+ // it is already on screen. Nothing to do but say so.
3169
+ log.line("info", "Already being configured", "the page started this run");
3170
+ }
3171
+ }
3172
+ catch (e) {
3173
+ // Swallowing this printed nothing at all and returned the prompt as
3174
+ // if the work had been done.
3175
+ log.line("fail", "Could not start the configuration", e.message);
3176
+ log.note("nothing was written — run aisa connect again");
3177
+ process.exitCode = 1;
3178
+ }
3179
+ }
3180
+ }
3181
+ }