@akms/mcp-ssh 0.3.1 → 0.3.2

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to `@akms/mcp-ssh`. Dates are `YYYY-MM-DD`.
4
4
 
5
+ ## [0.3.2] - 2026-10-26
6
+
7
+ ### Changed
8
+
9
+ - Point Sync review instructions to its current menu and name the services retaining old connection entries, grouping only identical retained lists.
10
+ - Separate runtime waits from failed changes in Register and Sync. Group retained connection names, count saved connections separately from service entries, and show semantic colors only on interactive terminals without NO_COLOR.
11
+ - Offer unimported connections directly from Sync and compare both saved and external settings before resolving an individual entry during session migration. Wide terminals show versions side by side; narrow terminals keep wrapped values.
12
+ - Preserve existing terminal symbols and keep status words visible when colors are disabled.
13
+ - Document the upgrade path from 0.2.x, including pending runtimes, differing imported settings, reconnecting clients, rollback and registry migration.
14
+
5
15
  ## [0.3.1] - 2026-10-26
6
16
 
7
17
  ### Verification
package/README.md CHANGED
@@ -18,6 +18,14 @@ akms-mcp-ssh --help
18
18
  akms-mcp-ssh --version # show the installed version
19
19
  ```
20
20
 
21
+ ## Upgrading from 0.2.x
22
+
23
+ Open the current setup with `npx -y --prefer-online @akms/mcp@latest`, install this runtime from **Runtime**, then use **Register > Apply registration** to import existing entries. Compare differing descriptions when prompted, or use **Import existing connections** later.
24
+
25
+ If you register before installing the runtime, existing entries are kept in a normal pending state. Install the runtime, run **Sync**, then restart or reconnect your client and check `ssh_catalog_status`.
26
+
27
+ See the launcher's [full upgrade guide](https://github.com/juncha9/npm.akms.mcp/blob/main/README.md#upgrading-from-02x) for the comparison steps, Codex thread cleanup, rollback and **Migrate registry** for older development catalogs.
28
+
21
29
  ## Minimum working configuration
22
30
 
23
31
  Install the server from **Runtime** before using these manual examples. On Windows its script is `<AKMS_MCP_HOME or ~/.akms/mcp>/runtime/node_modules/@akms/mcp-ssh/lib/cli.js`; on macOS/Linux insert `lib/` before `node_modules`. Replace the placeholder below with that absolute path. Use the Node executable from `node -p "process.execPath"` as `command`. The `config` command writes both paths for you.
@@ -62,7 +70,7 @@ Managed connections start Node directly using a dedicated installation at `<AKMS
62
70
 
63
71
  Before Add or a standard-launch Edit, setup installs a missing runtime with `npm install --global --prefix "<AKMS runtime directory>" --prefer-online --no-fund --no-audit @akms/mcp-ssh@latest`. If the installed version is older than this setup package, it offers an update (default Yes); declining continues with the installed version. Linked development packages skip this prompt. Host-key saves for standard launches also check the runtime before writing. Custom launch commands keep their existing command and require no managed-runtime installation. If an update fails but an installed runtime is still usable, setup reports the error and continues with that version. Servers do not invoke npm or query updates when AI clients connect.
64
72
 
65
- To migrate existing entries, open `akms-mcp-ssh config`, choose **Runtime**, install the runtime if needed, then choose **Use for existing connections**. Both legacy npx entries and direct paths in ordinary global installations can be converted. Review and apply the list, then **restart or reconnect the AI clients** to replace running processes. Connection values and credentials stay intact; custom commands and external client edits are preserved for individual review. Register/import legacy entries first if they are missing from the shared list. Version-pinned npx entries switch to the installed version shown in the preview.
73
+ For an existing catalog that still uses individual entries, open `akms-mcp-ssh config`, choose **Runtime**, install the runtime if needed, then choose **Use for existing connections**. Both legacy npx entries and direct paths in ordinary global installations can be converted. Review and apply the list, then **restart or reconnect the AI clients** to replace running processes. Connection values and credentials stay intact; custom commands and external client edits are preserved for individual review. Register/import legacy entries first if they are missing from the shared list. Version-pinned npx entries switch to the installed version shown in the preview.
66
74
 
67
75
  **Runtime > Update runtime installation** explicitly installs the latest release into this same directory. The Node executable keeps its existing alias path, allowing nvm-windows to switch Node versions while the package path stays fixed. If the Node command path or `AKMS_MCP_HOME` moves, reapply current paths from Runtime. Removing the runtime directory breaks entries that use it.
68
76
 
package/lib/cli.cjs CHANGED
@@ -35,7 +35,7 @@ var SHUTDOWN_DEADLINE_MS = 5e3;
35
35
  var CWD_MARKER = "__AKMS_SSH_CWD__";
36
36
  var MAX_DIRECTORY_ENTRIES = 1e3;
37
37
  var SERVER_NAME = "akms-mcp-ssh";
38
- var SERVER_VERSION = "0.3.1";
38
+ var SERVER_VERSION = "0.3.2";
39
39
 
40
40
  // src/_defs/command-rules.ts
41
41
  var CRITICAL_PATH_PATTERN = new RegExp(
@@ -968,6 +968,8 @@ function createStyle(enabled) {
968
968
  return {
969
969
  accent: paint(36, 39),
970
970
  success: paint(32, 39),
971
+ warning: paint(33, 39),
972
+ error: paint(31, 39),
971
973
  dim: paint(2, 22),
972
974
  bold: paint(1, 22)
973
975
  };
@@ -1515,7 +1517,7 @@ var fieldName = (field) => {
1515
1517
  const names = { command: "Launch command", args: "Launch arguments", DB_HOST: "Host", SSH_HOST: "Host", DB_PORT: "Port", SSH_PORT: "Port", DB_NAME: "Database", DB_USER: "User", SSH_USER: "User", WSL_USER: "User", WSL_DISTRO: "Linux distribution", S3_BUCKET: "Bucket", S3_REGION: "Region", S3_PREFIX: "Key prefix" };
1516
1518
  return Object.hasOwn(names, key) ? names[key] : plainText(key);
1517
1519
  };
1518
- function writeWrapped(prompter, text, indent2 = "", changed) {
1520
+ function writeWrapped(prompter, text, indent2 = "", changed, style) {
1519
1521
  const characters = Array.from(plainText(text));
1520
1522
  let line = indent2;
1521
1523
  let width = displayWidth(indent2);
@@ -1530,7 +1532,7 @@ function writeWrapped(prompter, text, indent2 = "", changed) {
1530
1532
  const size = displayWidth(character);
1531
1533
  if (width + size > prompter.width) {
1532
1534
  flush();
1533
- prompter.write(line + "\n");
1535
+ prompter.write((style ? style(line) : line) + "\n");
1534
1536
  line = indent2;
1535
1537
  width = displayWidth(indent2);
1536
1538
  }
@@ -1543,7 +1545,7 @@ function writeWrapped(prompter, text, indent2 = "", changed) {
1543
1545
  width += size;
1544
1546
  }
1545
1547
  flush();
1546
- prompter.write(line + "\n");
1548
+ prompter.write((style ? style(line) : line) + "\n");
1547
1549
  }
1548
1550
  function changedRange(values, index) {
1549
1551
  if (values.length !== 2 || values[0] === values[1]) return void 0;
@@ -1565,6 +1567,37 @@ function compareEntries(prompter, versions) {
1565
1567
  const key = field.replace(/^env\./, "");
1566
1568
  return versions.map(({ entry }) => field.startsWith("env.") && visibleField.test(key) ? Object.hasOwn(entry.env, key) ? plainText(entry.env[key]) || "(empty)" : "(not set)" : "(hidden)");
1567
1569
  });
1570
+ if (versions.length === 2 && prompter.width >= 90) {
1571
+ const width = Math.floor((prompter.width - 3) / 2);
1572
+ const wrap = (value, changed) => {
1573
+ const lines = [{ text: "", width: 0 }];
1574
+ for (const [index, character] of Array.from(value).entries()) {
1575
+ const size = displayWidth(character);
1576
+ if (lines[lines.length - 1].width + size > width) lines.push({ text: "", width: 0 });
1577
+ const line = lines[lines.length - 1];
1578
+ line.text += changed && index >= changed[0] && index < changed[1] ? prompter.paint.accent(prompter.paint.bold(character)) : character;
1579
+ line.width += size;
1580
+ }
1581
+ return lines;
1582
+ };
1583
+ const row = (left, right) => {
1584
+ for (let line = 0; line < Math.max(left.length, right.length); line++) {
1585
+ prompter.write(`${left[line]?.text ?? ""}${" ".repeat(width - (left[line]?.width ?? 0))} | ${right[line]?.text ?? ""}
1586
+ `);
1587
+ }
1588
+ };
1589
+ prompter.write("\n");
1590
+ row(wrap(`[1] ${plainText(versions[0].label)}`), wrap(`[2] ${plainText(versions[1].label)}`));
1591
+ for (let field = 0; field < fields.length; field++) {
1592
+ prompter.write(`
1593
+ ${prompter.paint.dim(fieldName(fields[field]))}
1594
+ `);
1595
+ row(wrap(values[field][0], changedRange(values[field], 0)), wrap(values[field][1], changedRange(values[field], 1)));
1596
+ }
1597
+ if (values.some((row2) => row2.includes("(hidden)"))) prompter.write(prompter.paint.dim("\nSecret values are hidden.\n"));
1598
+ prompter.write("\n");
1599
+ return;
1600
+ }
1568
1601
  for (let index = 0; index < versions.length; index++) {
1569
1602
  prompter.write("\n");
1570
1603
  writeWrapped(prompter, `[${index + 1}] ${versions[index].label}`);
@@ -1771,10 +1804,10 @@ async function checkConnections(prompter, entries, check = checkStartup) {
1771
1804
  const missing = prefixes?.filter((prefix) => !covered.has(prefix)) ?? [];
1772
1805
  if (result.ok && missing.length) result = { ...result, ok: false, detail: `Missing connection tools: ${missing.map((prefix) => prefix.slice(0, -2)).join(", ")}. Review catalog status before reconnecting.` };
1773
1806
  const safe = (value) => [entry, ...maskEntries ?? []].reduce((text, source) => redactStartupText(text, source), value);
1774
- writeWrapped(prompter, safe(result.ok ? `OK ${name}: ${result.server}@${result.version}, ${result.tools} tools` : `FAILED ${name}: exit ${result.exitCode ?? "unavailable"}. ${result.detail ?? "Server failed to start."}`));
1807
+ writeWrapped(prompter, safe(result.ok ? `OK ${name}: ${result.server}@${result.version}, ${result.tools} tools` : `FAILED ${name}: exit ${result.exitCode ?? "unavailable"}. ${result.detail ?? "Server failed to start."}`), "", void 0, result.ok ? prompter.paint.success : prompter.paint.error);
1775
1808
  if (!result.ok) {
1776
1809
  failed = true;
1777
- for (const line of result.stderr) writeWrapped(prompter, safe(line), " ");
1810
+ for (const line of result.stderr) writeWrapped(prompter, safe(line), " ", void 0, prompter.paint.dim);
1778
1811
  }
1779
1812
  }
1780
1813
  if (failed) prompter.write("Settings remain saved. Fix failed startup checks before restarting or reconnecting your AI clients.\n");
@@ -2237,11 +2270,26 @@ var Registry = class {
2237
2270
  inspect() {
2238
2271
  return this.reconcile(this.read(), false);
2239
2272
  }
2273
+ formatResults(results, prefix = "", paint, reviewHint) {
2274
+ return formatResults(results, prefix, {
2275
+ paint,
2276
+ reviewHint,
2277
+ connectionCount: Object.values(this.read().connections).filter((item) => item.entry).length
2278
+ });
2279
+ }
2240
2280
  reconcile(state, apply, kinds) {
2241
2281
  const results = [];
2242
2282
  const active = (kind) => Object.entries(state.connections).filter(([, item]) => item.kind === kind && item.entry);
2243
2283
  for (const kind of kinds ?? KINDS) if (this.isSessionMode(kind, state) && active(kind).length && !state.sessions?.[kind]) {
2244
- for (const client of state.clients) results.push({ client, name: sessionName(kind), status: "pending", detail: "Install this kind's runtime from Runtime, then run Sync. Existing client entries are kept." });
2284
+ for (const client of state.clients) results.push({
2285
+ client,
2286
+ name: sessionName(kind),
2287
+ kind,
2288
+ connections: active(kind).map(([name]) => name),
2289
+ status: "pending",
2290
+ waitingFor: "runtime",
2291
+ detail: "Install it from Runtime, then run Sync. Your existing entries keep working."
2292
+ });
2245
2293
  }
2246
2294
  const work = [
2247
2295
  ...Object.entries(state.connections).filter(([, connection]) => !kinds || kinds.includes(connection.kind)).map(([name, connection]) => ({ name, connection, bundle: false })),
@@ -2257,7 +2305,15 @@ var Registry = class {
2257
2305
  const previous = connection.applied[client];
2258
2306
  if (!desired && !previous) continue;
2259
2307
  const target = this.targets.find((candidate) => candidate.id === client);
2260
- const report = (status, detail) => results.push({ client, name, status, detail });
2308
+ const report = (status, detail, waitingFor) => results.push({
2309
+ client,
2310
+ name,
2311
+ status,
2312
+ detail,
2313
+ kind: connection.kind,
2314
+ connections: bundle ? active(connection.kind).map(([name2]) => name2) : [name],
2315
+ ...waitingFor ? { waitingFor } : {}
2316
+ });
2261
2317
  if (!target) {
2262
2318
  report("error", "Service adapter unavailable; use a newer package to retry.");
2263
2319
  continue;
@@ -2275,20 +2331,18 @@ var Registry = class {
2275
2331
  report("removed", "Removed");
2276
2332
  } else if (exists && (!actual || !previous || hash !== previous)) {
2277
2333
  report("conflict", actual && desired ? describeDifferences([desired, actual]) : actual ? "Changed outside AKMS; review before removing this entry." : "This name is used by another server type; choose another name.");
2278
- } else if (!apply) {
2279
- report("pending", desired ? "Needs applying" : "Needs removal");
2280
2334
  } else {
2281
2335
  if (!desired && state.clients.includes(client)) {
2282
2336
  if (!bundle && this.isSessionMode(connection.kind, state) && active(connection.kind).length) {
2283
2337
  const session = state.sessions?.[connection.kind];
2284
2338
  const replacement2 = entries[sessionName(connection.kind)];
2285
2339
  if (!session || !replacement2 || entryHash(replacement2) !== entryHash(session.entry) || session.applied[client] !== entryHash(session.entry)) {
2286
- report("pending", "Connection entry kept until the session server is applied.");
2340
+ report("pending", "Connection entry kept until the session server is applied.", "session");
2287
2341
  continue;
2288
2342
  }
2289
2343
  }
2290
2344
  if (bundle && !this.isSessionMode(connection.kind, state) && active(connection.kind).some(([connectionName, value]) => !entries[connectionName] || entryHash(entries[connectionName]) !== entryHash(value.entry))) {
2291
- report("pending", "Session entry kept until all connection entries are applied.");
2345
+ report("pending", "Session entry kept until all connection entries are applied.", "connections");
2292
2346
  continue;
2293
2347
  }
2294
2348
  }
@@ -2299,7 +2353,11 @@ var Registry = class {
2299
2353
  replacement = state.connections[replacement.replacement];
2300
2354
  }
2301
2355
  if (!desired && state.clients.includes(client) && !this.isSessionMode(connection.kind, state) && replacement && replacement.entry && replacement.applied[client] !== entryHash(replacement.entry)) {
2302
- report("pending", "Old name kept until the new name is applied.");
2356
+ report("pending", "Old name kept until the new name is applied.", "rename");
2357
+ continue;
2358
+ }
2359
+ if (!apply) {
2360
+ report("pending", desired ? "Needs applying" : "Needs removal");
2303
2361
  continue;
2304
2362
  }
2305
2363
  const result = desired ? target.writeEntry(name, desired) : target.removeEntry(name);
@@ -2390,7 +2448,7 @@ var Registry = class {
2390
2448
  const registry = this;
2391
2449
  let snapshot;
2392
2450
  const expected = (name) => snapshot ? own(snapshot, name) ? entryHash(snapshot[name]) : null : void 0;
2393
- const saved = (results) => ({ status: "written", detail: formatResults(results, "Saved in the shared AKMS catalog.") });
2451
+ const saved = (results) => ({ status: "written", detail: registry.formatResults(results, "Saved in the shared AKMS catalog.") });
2394
2452
  const prepare = (name, entry, oldName) => {
2395
2453
  if (!registry.read().clients.length) throw new RegistryError("Register at least one AI service before saving a connection.");
2396
2454
  const prior = snapshot && own(snapshot, oldName ?? name) ? snapshot[oldName ?? name] : void 0;
@@ -2419,30 +2477,58 @@ var Registry = class {
2419
2477
  };
2420
2478
  }
2421
2479
  };
2422
- function formatResults(results, prefix = "") {
2423
- const lines = prefix ? [prefix] : [];
2424
- if (!results.length) return [...lines, " No shared connections need applying."].join("\n");
2425
- const attention = results.filter((item) => !["synced", "removed"].includes(item.status));
2426
- const total = new Set(results.map((item) => item.name)).size;
2427
- const unresolved = new Set(attention.map((item) => item.name)).size;
2428
- lines.push(` Connections: ${total - unresolved} complete \xB7 ${unresolved} need attention`);
2480
+ function formatResults(results, prefix = "", options = {}) {
2481
+ const plain = (text) => text;
2482
+ const paint = options.paint ?? { success: plain, warning: plain, error: plain, dim: plain, bold: plain };
2483
+ const safe = (text) => text.replace(/[\x00-\x1f\x7f]/g, " ");
2484
+ const lines = prefix ? [paint.bold(prefix)] : [];
2485
+ const total = options.connectionCount ?? new Set(results.flatMap((item) => item.connections ?? [item.name])).size;
2486
+ lines.push(` Connections: ${total}`);
2429
2487
  const updated = results.filter((item) => item.status === "synced" && item.detail === "Applied").length;
2430
2488
  const unchanged = results.filter((item) => item.status === "synced" && item.detail !== "Applied").length;
2431
2489
  const removed = results.filter((item) => item.status === "removed").length;
2432
- lines.push(` Service entries: ${updated} updated \xB7 ${unchanged} already up to date${removed ? ` \xB7 ${removed} removed` : ""}`);
2433
- for (const [heading, rows] of [
2434
- ["Needs review (existing settings kept)", attention.filter((item) => item.status === "conflict")],
2435
- ["Could not finish", attention.filter((item) => item.status !== "conflict")]
2490
+ const failed = results.filter((item) => item.status === "error");
2491
+ lines.push(` Service entries: ${paint.success(`${updated} updated \xB7 ${unchanged} already up to date`)}${removed ? ` \xB7 ${paint.success(`${removed} removed`)}` : ""} \xB7 ${(failed.length ? paint.error : paint.dim)(`${failed.length} failed`)}`);
2492
+ if (!results.length) lines.push(paint.dim(" No shared connections need applying."));
2493
+ if (results.length && results.every((item) => item.status === "synced" || item.status === "removed")) lines.push(paint.success(" OK: all service entries are up to date."));
2494
+ const waiting = results.filter((item) => item.status === "pending");
2495
+ for (const kind of KINDS) {
2496
+ const runtime = waiting.filter((item) => item.kind === kind && item.waitingFor === "runtime");
2497
+ const kept = waiting.filter((item) => item.kind === kind && item.waitingFor === "session");
2498
+ if (!runtime.length && !kept.length) continue;
2499
+ lines.push(`
2500
+ ${paint.warning(runtime.length ? `Waiting for runtime - ${kind.toUpperCase()}` : `Waiting for ${sessionName(kind)}`)}`);
2501
+ if (runtime.length) lines.push(" Install it from Runtime, then run Sync. Your existing entries keep working.");
2502
+ else lines.push(" Resolve this session entry in Sync. Existing connection entries stay until it is ready.");
2503
+ const retained = /* @__PURE__ */ new Map();
2504
+ for (const client of new Set(kept.map((item) => item.client))) {
2505
+ const names = [...new Set(kept.filter((item) => item.client === client).map((item) => item.name))];
2506
+ const key = JSON.stringify([...names].sort());
2507
+ const group = retained.get(key);
2508
+ if (group) group.clients.push(client);
2509
+ else retained.set(key, { clients: [client], names });
2510
+ }
2511
+ for (const group of retained.values()) {
2512
+ const clients = group.clients.map((client) => CLIENT_LABELS[client]).join(" \xB7 ");
2513
+ lines.push(paint.dim(` Kept in ${clients} until ${sessionName(kind)} is applied: ${group.names.map(safe).join(", ")}`));
2514
+ }
2515
+ }
2516
+ for (const [heading, rows, color] of [
2517
+ ["Needs review (existing settings kept)", results.filter((item) => item.status === "conflict"), paint.warning],
2518
+ ["Waiting for Sync", waiting.filter((item) => !item.waitingFor || !["runtime", "session"].includes(item.waitingFor)), paint.warning],
2519
+ ["Could not finish", failed, paint.error]
2436
2520
  ]) {
2437
2521
  if (!rows.length) continue;
2438
2522
  lines.push(`
2439
- ${heading}:`);
2523
+ ${color(`${heading}:`)}`);
2440
2524
  for (const name of new Set(rows.map((item) => item.name))) {
2441
- lines.push(` ${name.replace(/[\x00-\x1f\x7f]/g, " ")}`);
2442
- for (const row of rows.filter((item) => item.name === name)) lines.push(` ${CLIENT_LABELS[row.client]}: ${row.detail}`);
2525
+ lines.push(` ${safe(name)}`);
2526
+ for (const row of rows.filter((item) => item.name === name)) lines.push(` ${row.status === "error" ? paint.error("FAILED ") : ""}${CLIENT_LABELS[row.client]}: ${safe(row.detail)}`);
2443
2527
  }
2444
2528
  }
2445
- if (attention.length) lines.push("\nNext: open Sync to compare settings or retry unfinished changes.");
2529
+ if (results.some((item) => item.status === "conflict")) lines.push(paint.warning(`
2530
+ ${options.reviewHint ?? "Next: open Sync to compare settings and choose which version to use."}`));
2531
+ if (failed.length) lines.push(paint.error("\nNext: fix the errors above, then run Sync again."));
2446
2532
  return lines.join("\n");
2447
2533
  }
2448
2534
 
@@ -2511,7 +2597,7 @@ async function runtimeMenu(prompter, registry, requestedKind) {
2511
2597
  prompter.write(`New kinds will use ${mode === "catalog" ? "session servers" : "individual entries"}. Existing kinds keep their settings.
2512
2598
  `);
2513
2599
  if (await prompter.askYesNo("Change the default for new kinds?", false)) {
2514
- prompter.write(`${formatResults(registry.setDefaultMode(mode), "Default for new kinds saved.")}
2600
+ prompter.write(`${registry.formatResults(registry.setDefaultMode(mode), "Default for new kinds saved.", prompter.paint)}
2515
2601
  `);
2516
2602
  await prompter.acknowledge();
2517
2603
  }
@@ -2540,7 +2626,7 @@ async function runtimeMenu(prompter, registry, requestedKind) {
2540
2626
  if (!await prompter.askYesNo("Apply this configuration mode?", false)) continue;
2541
2627
  const results2 = registry.applySession(plan2);
2542
2628
  prompter.clearScreen();
2543
- prompter.write(`${formatResults(results2, "Configuration mode saved.")}
2629
+ prompter.write(`${registry.formatResults(results2, "Configuration mode saved.", prompter.paint)}
2544
2630
  `);
2545
2631
  await registry.checkSaved(prompter, plan2.names);
2546
2632
  prompter.write("Restart or reconnect your AI clients to load the new server layout. Fix startup failures first.\n");
@@ -2576,7 +2662,7 @@ Convert ${plan.names.length} connection(s):
2576
2662
  if (!await prompter.askYesNo("Apply these launch commands to registered services?", true)) continue;
2577
2663
  const results = registry.applyRuntime(plan);
2578
2664
  prompter.clearScreen();
2579
- prompter.write(`${formatResults(results, "Runtime settings saved.")}
2665
+ prompter.write(`${registry.formatResults(results, "Runtime settings saved.", prompter.paint)}
2580
2666
 
2581
2667
  Restart or reconnect AI clients to replace running npx processes.
2582
2668
  `);
@@ -2698,7 +2784,7 @@ async function migrationMenu(prompter, registry) {
2698
2784
  if (!await prompter.askYesNo("Migrate registry and apply these changes?", false)) return;
2699
2785
  const results = registry.applyMigration(plan);
2700
2786
  prompter.clearScreen();
2701
- prompter.write(`${formatResults(results, "Registry migration saved.")}
2787
+ prompter.write(`${registry.formatResults(results, "Registry migration saved.", prompter.paint)}
2702
2788
  `);
2703
2789
  await registry.checkSaved(prompter, plan.kinds.filter((item) => item.status !== "individual").flatMap((item) => item.names));
2704
2790
  prompter.write("Reconnect affected clients after changes to their server layout. Fix startup failures first.\n");
@@ -2713,6 +2799,12 @@ async function migrationMenu(prompter, registry) {
2713
2799
  }
2714
2800
 
2715
2801
  // src/modules/registration/menu.ts
2802
+ function pendingImports(registry, kind) {
2803
+ const state = registry.read();
2804
+ const entries = registry.candidates(kind).entries.filter((candidate) => !Object.hasOwn(state.connections, candidate.name));
2805
+ const names = [...new Set(entries.map((candidate) => candidate.name))];
2806
+ return { names, differing: names.filter((name) => new Set(entries.filter((item) => item.name === name).map((item) => entryHash(item.entry))).size > 1).length };
2807
+ }
2716
2808
  function preview(prompter, entry) {
2717
2809
  if (!entry) {
2718
2810
  prompter.write(" Remove this connection from the service.\n");
@@ -2743,22 +2835,21 @@ var ManagedSession = class {
2743
2835
  }
2744
2836
  writeStatus(prompter) {
2745
2837
  const state = this.registry.read();
2746
- if (this.registry.needsMigration(state)) prompter.write("Registry update available. Choose Migrate registry to review the changes.\n");
2838
+ if (this.registry.needsMigration(state)) prompter.write(prompter.paint.warning("Registry update available. Choose Migrate registry to review the changes.\n"));
2747
2839
  prompter.write(`Services: ${state.clients.map((id) => CLIENT_LABELS[id]).join(" \xB7 ") || "none"}
2748
2840
  `);
2749
- if (!state.clients.length) prompter.write("No AI services registered. Choose Register before adding a connection.\n");
2750
- if (this.registry.isSessionMode(this.options.kind, state)) prompter.write(`Session server: ${sessionName(this.options.kind)}. Reconnect clients after changing connections.
2751
- `);
2752
- const attention = new Set(this.registry.inspect().filter((item) => this.registry.kindForName(item.name, state) === this.options.kind && !["synced", "removed"].includes(item.status)).map((item) => item.name)).size;
2753
- if (attention) prompter.write(`Needs attention: ${attention} connection(s). Open Sync to review.
2754
- `);
2841
+ if (!state.clients.length) prompter.write(prompter.paint.warning("No AI services registered. Choose Register before adding a connection.\n"));
2842
+ if (this.registry.isSessionMode(this.options.kind, state)) prompter.write(prompter.paint.dim(`Session server: ${sessionName(this.options.kind)}. Reconnect clients after changing connections.
2843
+ `));
2844
+ const attention = new Set(this.registry.inspect().filter((item) => this.registry.kindForName(item.name, state) === this.options.kind && !["synced", "removed"].includes(item.status)).flatMap((item) => item.connections ?? [item.name])).size;
2845
+ if (attention) prompter.write(prompter.paint.warning(`Needs attention: ${attention} connection(s). Open Sync to review.
2846
+ `));
2755
2847
  const pending = this.pendingImports();
2756
- if (pending) prompter.write(`Import existing connections: ${pending} connection(s) available. Existing settings are preserved.
2757
- `);
2848
+ if (pending) prompter.write(prompter.paint.warning(`Import available: ${pending} connection(s). Open Import existing connections.
2849
+ `));
2758
2850
  }
2759
2851
  pendingImports() {
2760
- const state = this.registry.read();
2761
- return new Set(this.registry.candidates(this.options.kind).entries.filter((candidate) => !Object.hasOwn(state.connections, candidate.name)).map((candidate) => candidate.name)).size;
2852
+ return pendingImports(this.registry, this.options.kind).names.length;
2762
2853
  }
2763
2854
  items() {
2764
2855
  return [
@@ -2876,8 +2967,8 @@ Remove only AKMS-managed entries from these services; keep the shared catalog.
2876
2967
  const results = registry.register([...selected], initial.clients, plan.imports);
2877
2968
  prompter.clearScreen();
2878
2969
  prompter.write(`
2879
- ${formatResults(results, `Registration saved.
2880
- AI services: ${[...selected].map((id) => CLIENT_LABELS[id]).join(" \xB7 ") || "none"}`)}
2970
+ ${registry.formatResults(results, `Registration saved.
2971
+ AI services: ${[...selected].map((id) => CLIENT_LABELS[id]).join(" \xB7 ") || "none"}`, prompter.paint)}
2881
2972
  `);
2882
2973
  if (importNames.length) prompter.write(`Imported ${importNames.length} existing connection(s) into the shared list.
2883
2974
  `);
@@ -2950,7 +3041,7 @@ Import existing connections:
2950
3041
  if (await prompter.askYesNo("Import matching connections and apply them?", true)) {
2951
3042
  const results2 = registry.register(state.clients, state.clients, matching.imports);
2952
3043
  prompter.clearScreen();
2953
- prompter.write(`${formatResults(results2, "Import complete")}
3044
+ prompter.write(`${registry.formatResults(results2, "Import complete", prompter.paint)}
2954
3045
  `);
2955
3046
  await prompter.acknowledge();
2956
3047
  }
@@ -2978,7 +3069,7 @@ ${plainText(name)} \xB7 Choose the settings to use on all registered services.
2978
3069
  const results = registry.importEntry(destination, chosen, candidates);
2979
3070
  prompter.clearScreen();
2980
3071
  prompter.write(`
2981
- ${formatResults(results, "Import complete")}
3072
+ ${registry.formatResults(results, "Import complete", prompter.paint)}
2982
3073
  `);
2983
3074
  if (destination !== name) prompter.write(" The original entries were kept under their old name.\n");
2984
3075
  await prompter.acknowledge();
@@ -2998,19 +3089,28 @@ async function syncMenu(prompter, registry) {
2998
3089
  const results = pendingResults ?? registry.sync();
2999
3090
  pendingResults = void 0;
3000
3091
  prompter.write(`
3001
- ${formatResults(results, "Sync results")}
3092
+ ${registry.formatResults(results, "Sync results", prompter.paint, "Next: choose an item below to compare settings and pick a version.")}
3002
3093
  `);
3094
+ const imports = pendingImports(registry);
3095
+ if (imports.names.length) prompter.write(prompter.paint.warning(`
3096
+ Not yet imported: ${imports.names.length} connection(s)${imports.differing === imports.names.length ? " with differing settings" : ""} - open Import existing connections.
3097
+ `));
3003
3098
  const conflicts = results.filter((result) => result.status === "conflict");
3004
- if (!conflicts.length) {
3099
+ if (!conflicts.length && !imports.names.length) {
3005
3100
  await prompter.acknowledge();
3006
3101
  return;
3007
3102
  }
3008
3103
  const picked = await prompter.select("Review differences", [
3009
3104
  ...conflicts.map((item, index) => ({ value: String(index), label: `${CLIENT_LABELS[item.client]} \xB7 ${item.name}` })),
3105
+ ...imports.names.length ? [{ value: "import", key: "import", label: "Import existing connections", hint: `${imports.names.length} connection(s) to review` }] : [],
3010
3106
  { value: "back", key: "b", label: "Later \u2014 keep existing settings" }
3011
3107
  ], { cancelValue: "back" });
3012
3108
  if (picked === "back") return;
3013
3109
  try {
3110
+ if (picked === "import") {
3111
+ await importMenu(prompter, registry);
3112
+ continue;
3113
+ }
3014
3114
  prompter.clearScreen();
3015
3115
  const conflict = conflicts[Number(picked)];
3016
3116
  const state = registry.read();
@@ -3044,12 +3144,17 @@ ${formatResults(results, "Sync results")}
3044
3144
  const client = CLIENT_LABELS[conflict.client];
3045
3145
  const registered = state.clients.includes(conflict.client);
3046
3146
  const sessionMode = registry.isSessionMode(connection.kind, state);
3047
- const removing = !connection.entry || !registered || sessionMode;
3147
+ const removing = !connection.entry || !registered;
3148
+ const retiring = sessionMode && !removing;
3048
3149
  const subject = connection.entry && descriptionOnly([connection.entry, actual]) ? "description" : "settings";
3049
3150
  writeWrapped(prompter, `Sync / ${plainText(conflict.name)}`);
3050
3151
  writeWrapped(prompter, `Registered services: ${state.clients.map((id) => CLIENT_LABELS[id]).join(" \xB7 ") || "none"}`);
3051
3152
  prompter.write("\n");
3052
3153
  if (!removing && connection.entry) {
3154
+ if (retiring) {
3155
+ writeWrapped(prompter, `Choose settings for ${sessionName(connection.kind)} before removing the old ${client} entry.`);
3156
+ writeWrapped(prompter, "The old entry stays until the session server is ready.");
3157
+ }
3053
3158
  compareEntries(prompter, [{ label: "Saved in AKMS", entry: connection.entry }, { label: `Currently in ${client}`, entry: actual }]);
3054
3159
  } else {
3055
3160
  writeWrapped(prompter, !registered ? `${client} is no longer registered.` : sessionMode ? `This kind now uses ${sessionName(connection.kind)}. The individual client entry is no longer needed.` : "This connection was removed from AKMS.");
@@ -3061,8 +3166,8 @@ Currently in ${client}
3061
3166
  prompter.write("\n");
3062
3167
  }
3063
3168
  const choices = [
3064
- { value: "shared", label: removing ? `Remove from ${client}` : `Use 1: AKMS -> ${client}`, hint: removing ? "finish the pending removal" : `replace its ${subject} with version 1` },
3065
- ...registered ? [{ value: "client", label: sessionMode ? `Import ${client} settings into the catalog, then remove this entry` : removing ? `Restore from ${client}` : `Use 2: ${client} -> all ${state.clients.length} services`, hint: "save in AKMS and sync registered services" }] : [],
3169
+ { value: "shared", label: removing ? `Remove from ${client}` : retiring ? "Use 1: keep AKMS settings" : `Use 1: AKMS -> ${client}`, hint: removing ? "finish the pending removal" : retiring ? `use version 1 in ${sessionName(connection.kind)}` : `replace its ${subject} with version 1` },
3170
+ ...registered ? [{ value: "client", label: removing ? `Restore from ${client}` : `Use 2: ${client} -> all ${state.clients.length} services`, hint: retiring ? `save version 2 in AKMS for ${sessionName(connection.kind)}` : "save in AKMS and sync registered services" }] : [],
3066
3171
  { value: "back", key: "b", label: "Back without changes" }
3067
3172
  ];
3068
3173
  const choice = await prompter.select(removing ? registered ? "Remove or restore this connection?" : `Remove this connection from ${client}?` : `Which ${subject} should be used?`, choices, { cancelValue: "back" });
@@ -3071,8 +3176,9 @@ Currently in ${client}
3071
3176
  if (choice === "client") {
3072
3177
  writeWrapped(prompter, `Save the ${client} version in AKMS.`);
3073
3178
  writeWrapped(prompter, `Sync to: ${state.clients.map((id) => CLIENT_LABELS[id]).join(" \xB7 ")}`);
3074
- } else writeWrapped(prompter, removing ? `Remove ${plainText(conflict.name)} from ${client}.` : `Copy the saved AKMS ${subject} to ${client}.`);
3075
- const confirmation = choice === "client" ? `Save in AKMS and sync ${state.clients.length} services?` : removing ? `Remove from ${client}?` : `Update ${client} with version 1?`;
3179
+ } else writeWrapped(prompter, removing ? `Remove ${plainText(conflict.name)} from ${client}.` : retiring ? `Keep the saved AKMS ${subject}.` : `Copy the saved AKMS ${subject} to ${client}.`);
3180
+ if (retiring) writeWrapped(prompter, `Keep the old ${client} entry until ${sessionName(connection.kind)} is ready, then remove it.`);
3181
+ const confirmation = choice === "client" ? `Save in AKMS and sync ${state.clients.length} services?` : removing ? `Remove from ${client}?` : retiring ? "Keep version 1 and finish Sync?" : `Update ${client} with version 1?`;
3076
3182
  if (await prompter.askYesNo(confirmation, false)) {
3077
3183
  pendingResults = registry.resolve(conflict.client, conflict.name, actual, choice === "client");
3078
3184
  }