@oharness/server 0.11.0 → 0.12.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.
@@ -557,10 +557,18 @@ let offerLoaded = false;
557
557
  let offer = { connectors: [], plugins: [], skills: [], agents: [] };
558
558
  /** The connectors on offer rather than the ones already here. */
559
559
  let market = [];
560
+ /** Servers found in Claude Code's and Codex's config, offered beside them. */
561
+ let foreignMarket = [];
560
562
  /** The connector whose sign-in is open in a browser tab, if any. */
561
563
  let authorizing = null;
562
564
  /** The connector being added right now, so its row can say so. */
563
565
  let adding = null;
566
+ /** The plugins on offer, from our own list; fetched when that screen opens. */
567
+ let pluginMarket = [];
568
+ /** The plugin whose install was asked for once and is waiting to be confirmed. */
569
+ let confirming = null;
570
+ /** The plugin downloading right now. */
571
+ let installing = null;
564
572
  let attachQuery = "";
565
573
  let attachCursor = 0;
566
574
  /** Guards against a slow answer to an old keystroke landing after a new one. */
@@ -603,7 +611,7 @@ function attachRows() {
603
611
  if (attachView === "skills" || attachView === "plugins" || attachView === "connectors") {
604
612
  const kind = attachView;
605
613
  const items =
606
- kind === "skills" ? offer.skills || []
614
+ kind === "skills" ? usableSkills()
607
615
  : kind === "plugins" ? offer.plugins || []
608
616
  : offer.connectors || [];
609
617
  const row = (entry) => ({
@@ -641,11 +649,11 @@ function attachRows() {
641
649
  ...(!items.length ? [{ type: "hint", label: t(empty[kind]) }] : []),
642
650
  ...(hidden > 0 ? [{ type: "hint", label: t("add.andMore", { n: hidden }) }] : []),
643
651
  // Browse is only a different question where there is somewhere to browse:
644
- // connectors have a registry of ones not installed, skills and plugins
645
- // are whatever is on this machine already. "More" only when the menu
646
- // is holding some back; with everything in view it is the catalogue.
647
- ...(kind === "connectors"
648
- ? [{ type: "market", kind, icon: "search", label: t(hidden > 0 ? "add.browse" : "add.browseAll") }]
652
+ // connectors have a registry of ones not installed and plugins a market,
653
+ // skills are whatever is on this machine already. "More" only when the
654
+ // menu is holding some back; with everything in view it is the catalogue.
655
+ ...(kind === "connectors" || kind === "plugins"
656
+ ? [{ type: "market", kind, icon: "search", label: t(hidden > 0 ? "add.browse" : "add.browseAll." + kind) }]
649
657
  : []),
650
658
  { type: "manage", kind, icon: "settings", label: t("add.manage") },
651
659
  ];
@@ -680,7 +688,7 @@ function attachRows() {
680
688
  * under the name is the one a person would think of. Someone after the
681
689
  * video tools types "video", and only the description contains it.
682
690
  */
683
- const skills = (offer.skills || [])
691
+ const skills = usableSkills()
684
692
  .filter((skill) =>
685
693
  !query ||
686
694
  skill.name.toLowerCase().includes(query) ||
@@ -779,7 +787,7 @@ function attachRows() {
779
787
 
780
788
  const connectors = (offer.connectors || []).length;
781
789
  const plugins = (offer.plugins || []).length;
782
- const skills = (offer.skills || []).length;
790
+ const skills = usableSkills().length;
783
791
  return [
784
792
  // What goes with the message: from this machine, first. Two rows for
785
793
  // files and folders, because the dialogs behind them are two: only macOS
@@ -834,6 +842,21 @@ function attachRows() {
834
842
  sub: skills ? String(skills) : "",
835
843
  into: true,
836
844
  },
845
+ // What this person already set up in Claude Code or Codex and has not
846
+ // brought over yet. At the top level, one click from the +: a section
847
+ // two screens in is a section nobody goes looking for. Gone once there is
848
+ // nothing left to add.
849
+ ...(foreignWaiting() > 0
850
+ ? [{
851
+ type: "market",
852
+ kind: "imports",
853
+ icon: "import",
854
+ // Each tool's own mark: the row is about where these come from.
855
+ marks: ["claude", "codex"],
856
+ label: t("add.importFrom"),
857
+ sub: String(foreignWaiting()),
858
+ }]
859
+ : []),
837
860
  ];
838
861
  }
839
862
 
@@ -842,7 +865,45 @@ function notChoice(row) {
842
865
  return row.type === "group" || row.type === "sep";
843
866
  }
844
867
 
868
+ /**
869
+ * Repaint the menu, gliding from the height it had to the one it needs.
870
+ *
871
+ * Every repaint goes through here — a new screen, a list that arrived from
872
+ * the server after the menu opened, a filter narrowing it — because any one
873
+ * of them that changed the height in a single frame was the jolt. A glide
874
+ * already under way is interrupted from wherever it got to, not restarted
875
+ * from where it began.
876
+ */
877
+ let attachResize = null;
845
878
  function paintAttachMenu() {
879
+ const visible = !attachMenu.hidden;
880
+ const before = visible ? attachMenu.offsetHeight : 0;
881
+ if (attachResize) {
882
+ attachResize.cancel();
883
+ attachResize = null;
884
+ }
885
+ paintAttachRows();
886
+ if (!visible || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
887
+ const after = attachMenu.offsetHeight;
888
+ if (Math.abs(after - before) < 2) return;
889
+ attachMenu.classList.add("resizing");
890
+ const glide = attachMenu.animate([{ height: before + "px" }, { height: after + "px" }], {
891
+ duration: 200,
892
+ easing: "cubic-bezier(.2,.8,.2,1)",
893
+ });
894
+ attachResize = glide;
895
+ const done = () => {
896
+ if (attachResize !== glide) return;
897
+ attachResize = null;
898
+ attachMenu.classList.remove("resizing");
899
+ };
900
+ glide.onfinish = done;
901
+ glide.oncancel = () => {
902
+ if (attachResize === glide || !attachResize) attachMenu.classList.remove("resizing");
903
+ };
904
+ }
905
+
906
+ function paintAttachRows() {
846
907
  const rows = attachRows();
847
908
  if (attachCursor >= rows.length) attachCursor = Math.max(0, rows.length - 1);
848
909
  // The list opens with the cursor at 0, and 0 is a heading whenever the list
@@ -870,7 +931,13 @@ function paintAttachMenu() {
870
931
  "menu-item" + ((row.ref && referenced(row.ref)) || row.on ? " on" : "") + (i === attachCursor ? " cursor" : ""),
871
932
  );
872
933
  item.setAttribute("role", "button");
873
- item.appendChild(row.mark ? connectorMark(row.mark, row.label) : svgIcon(row.icon));
934
+ if (row.marks) {
935
+ const pair = el("span", "mark-pair");
936
+ for (const source of row.marks) pair.appendChild(vendorMark(source));
937
+ item.appendChild(pair);
938
+ } else {
939
+ item.appendChild(row.mark ? connectorMark(row.mark, row.label) : svgIcon(row.icon));
940
+ }
874
941
  if (row.note) {
875
942
  item.classList.add("stacked");
876
943
  const stack = el("span", "stack");
@@ -1049,15 +1116,27 @@ async function withPreview(file) {
1049
1116
  }
1050
1117
 
1051
1118
  function showAttachView(view) {
1119
+ const from = attachView;
1120
+ const before = attachMenu.hidden ? 0 : attachMenu.offsetHeight;
1052
1121
  attachView = view;
1053
1122
  // No row is under the keyboard until a key moves there. Opening onto a
1054
1123
  // highlighted first row, with the pointer highlighting a second, made the
1055
1124
  // menu look like it had two selections and no rhythm.
1056
1125
  attachCursor = -1;
1057
1126
  paintAttachMenu();
1127
+ // Into a list slides in from the right, back out from the left, so the
1128
+ // menu reads as a stack of screens rather than one that changed its mind.
1129
+ if (before && from !== view) slideAttachBody(view === "root" ? "slide-back" : "slide-in");
1058
1130
  attachMenu.focus();
1059
1131
  }
1060
1132
 
1133
+ function slideAttachBody(name) {
1134
+ attachBody.classList.remove("slide-in", "slide-back");
1135
+ void attachBody.offsetWidth; // restart the animation
1136
+ attachBody.classList.add(name);
1137
+ }
1138
+
1139
+
1061
1140
  // --- browsing connectors and plugins ---------------------------------------
1062
1141
  /**
1063
1142
  * What is on offer, in a dialog rather than in the menu.
@@ -1076,37 +1155,100 @@ let marketKind = "connectors";
1076
1155
  const marketSearch = document.getElementById("mk-search");
1077
1156
  marketSearch.addEventListener("input", () => paintMarket());
1078
1157
 
1158
+ /**
1159
+ * Which list the import tab sits beside. Bringing things in from Claude Code
1160
+ * and Codex is its own tab, not a section of the catalogue: those rows are
1161
+ * this person's own setup, and a shelf of them between "in this session" and
1162
+ * seventy vendors read as one more vendor list.
1163
+ */
1164
+ let marketHome = "connectors";
1165
+ const marketTabs = document.getElementById("mk-tabs");
1166
+
1167
+ function paintMarketTabs() {
1168
+ const tabbed = marketKind === "connectors" || marketKind === "plugins" || marketKind === "imports";
1169
+ marketTabs.hidden = !tabbed;
1170
+ if (!tabbed) return;
1171
+ marketTabs.replaceChildren();
1172
+ const tab = (kind, build) => {
1173
+ const button = el("button", "mk-tab" + (marketKind === kind ? " on" : ""));
1174
+ button.type = "button";
1175
+ button.setAttribute("role", "tab");
1176
+ button.setAttribute("aria-selected", String(marketKind === kind));
1177
+ build(button);
1178
+ button.addEventListener("click", () => {
1179
+ if (marketKind !== kind) void openMarket(kind);
1180
+ });
1181
+ marketTabs.appendChild(button);
1182
+ };
1183
+ tab(marketHome, (button) => button.appendChild(document.createTextNode(t(marketHome === "plugins" ? "add.plugins" : "add.connectors"))));
1184
+ tab("imports", (button) => {
1185
+ button.appendChild(vendorMark("claude"));
1186
+ button.appendChild(vendorMark("codex"));
1187
+ button.appendChild(document.createTextNode(t("market.tab.import")));
1188
+ const waiting = foreignWaiting();
1189
+ if (waiting) button.appendChild(el("span", "mk-tab-count", String(waiting)));
1190
+ });
1191
+ }
1192
+
1079
1193
  async function openMarket(kind) {
1194
+ if (kind === "connectors" || kind === "plugins") marketHome = kind;
1080
1195
  marketKind = kind;
1081
1196
  // Cleared on open: a query left over from the last screen would hide most of
1082
1197
  // this one and look like an empty catalogue.
1083
1198
  marketSearch.value = "";
1084
1199
  marketSearch.placeholder = t("menu.search");
1085
- const titles = { plugins: "add.plugins", skills: "add.skills", connectors: "add.connectors" };
1086
- const notes = { plugins: "market.pluginsNote", skills: "market.skillsNote", connectors: "market.connectorsNote" };
1087
- document.getElementById("mk-title").textContent = t(titles[kind] || titles.connectors);
1200
+ const titles = { plugins: "add.plugins", skills: "add.skills", connectors: "add.connectors", imports: "add.importFrom" };
1201
+ const notes = { plugins: "market.pluginsNote", skills: "market.skillsNote", connectors: "market.connectorsNote", imports: "market.importsNote" };
1202
+ // The import tab belongs to the list it sits beside, so the dialog keeps
1203
+ // that list's name and only the sentence under it changes.
1204
+ document.getElementById("mk-title").textContent = t(titles[kind === "imports" ? marketHome : kind] || titles.connectors);
1205
+ paintMarketTabs();
1088
1206
  document.getElementById("mk-note").textContent = t(notes[kind] || notes.connectors);
1089
- marketList.replaceChildren(el("div", "mk-desc", t("market.loading")));
1090
- marketDialog.showModal();
1207
+ // A tab switch keeps the old list up until the new one is ready: a
1208
+ // "Loading…" line in between collapses the dialog for a frame.
1209
+ const switching = marketDialog.open;
1210
+ if (!switching) marketList.replaceChildren(el("div", "mk-desc", t("market.loading")));
1211
+ marketDialog.classList.remove("closing");
1212
+ // Rows play their entrance once per opening, not on every repaint.
1213
+ marketDialog.classList.add("fresh");
1214
+ // Already open when one list links to another — the plugins screen
1215
+ // pointing at the import screen — and showModal on an open dialog throws.
1216
+ if (!marketDialog.open) marketDialog.showModal();
1091
1217
 
1092
1218
  const context = await getJson("/context");
1093
1219
  if (!context.error) offer = context;
1094
- if (kind === "connectors") {
1220
+ if (kind === "connectors" || kind === "imports" || kind === "plugins") {
1095
1221
  const body = await getJson("/registry");
1096
1222
  market = body.error ? [] : body.connectors || [];
1223
+ foreignMarket = body.error ? [] : body.imports || [];
1224
+ }
1225
+ if (kind === "plugins") {
1226
+ confirming = null;
1227
+ const body = await getJson("/plugin-market");
1228
+ pluginMarket = body.error ? [] : body.plugins || [];
1097
1229
  }
1230
+ paintMarketTabs();
1098
1231
  paintMarket();
1232
+ if (switching) {
1233
+ marketList.classList.remove("swap");
1234
+ void marketList.offsetWidth;
1235
+ marketList.classList.add("swap");
1236
+ marketList.scrollTop = 0;
1237
+ }
1238
+ setTimeout(() => marketDialog.classList.remove("fresh"), 400);
1099
1239
  }
1100
1240
 
1101
1241
  function paintMarket() {
1102
1242
  marketList.replaceChildren();
1103
1243
  const query = (marketSearch.value || "").trim().toLowerCase();
1244
+ if (marketKind === "imports") return paintImports(query);
1104
1245
  const hit = (entry) =>
1105
1246
  !query ||
1106
1247
  (entry.name || "").toLowerCase().includes(query) ||
1107
1248
  (entry.title || "").toLowerCase().includes(query) ||
1108
1249
  (entry.description || "").toLowerCase().includes(query) ||
1109
- (entry.label || "").toLowerCase().includes(query);
1250
+ (entry.label || "").toLowerCase().includes(query) ||
1251
+ (entry.publisher || "").toLowerCase().includes(query);
1110
1252
  const installed = (
1111
1253
  marketKind === "plugins" ? offer.plugins || []
1112
1254
  : marketKind === "skills" ? offer.skills || []
@@ -1120,10 +1262,28 @@ function paintMarket() {
1120
1262
  marketList.appendChild(grid);
1121
1263
  }
1122
1264
 
1123
- // Only connectors have somewhere to come from yet. Saying so beats an
1124
- // "Available" heading over an empty space that reads as a failed load.
1125
- const available = marketKind === "connectors" ? market.filter((one) => !one.installed && hit(one)) : [];
1126
- if (available.length) {
1265
+
1266
+ // Skills have nowhere to come from yet. Saying so beats an "Available"
1267
+ // heading over an empty space that reads as a failed load.
1268
+ const available =
1269
+ marketKind === "connectors" ? market.filter((one) => !one.installed && hit(one))
1270
+ : marketKind === "plugins" ? pluginMarket.filter((one) => !one.installed && hit(one))
1271
+ : [];
1272
+ if ((marketKind === "plugins" || marketKind === "connectors") && available.length) {
1273
+ // Seventy rows under one heading is a wall; under seven it is a shelf.
1274
+ // Connectors share the plugins' shelves, so a vendor sits on the same one
1275
+ // in both dialogs, plus one of their own for the servers run locally.
1276
+ const shelves = marketKind === "connectors" ? ["local", ...PLUGIN_CATEGORIES] : PLUGIN_CATEGORIES;
1277
+ const paint = marketKind === "connectors" ? offerRow : pluginOfferRow;
1278
+ for (const category of shelves) {
1279
+ const rows = available.filter((one) => (shelves.includes(one.category) ? one.category : "other") === category);
1280
+ if (!rows.length) continue;
1281
+ marketList.appendChild(el("div", "mk-group", t("market.cat." + category)));
1282
+ const grid = el("div", "mk-grid");
1283
+ for (const entry of rows) grid.appendChild(paint(entry));
1284
+ marketList.appendChild(grid);
1285
+ }
1286
+ } else if (available.length) {
1127
1287
  marketList.appendChild(el("div", "mk-group", t("market.available")));
1128
1288
  const grid = el("div", "mk-grid");
1129
1289
  for (const entry of available) grid.appendChild(offerRow(entry));
@@ -1141,14 +1301,16 @@ function paintMarket() {
1141
1301
  * browser's own placeholder, which is uglier than the fallback and arrives at
1142
1302
  * a different moment in every browser.
1143
1303
  */
1144
- function connectorMark(id, label) {
1304
+ function connectorMark(id, label, route = "/connector-icon", fallback) {
1145
1305
  const letter = (label || id || "?").trim().charAt(0).toUpperCase();
1146
1306
  // A stable hue per connector, so the tile is the same colour every time
1147
1307
  // rather than a different one each render.
1148
1308
  let hash = 0;
1149
1309
  for (let i = 0; i < id.length; i++) hash = (hash * 31 + id.charCodeAt(i)) % 360;
1150
- const mono = el("span", "mk-mono", letter);
1151
- mono.style.background = "hsl(" + hash + " 42% 42%)";
1310
+ // A caller with a better placeholder than a letter — a plugin with no site
1311
+ // keeps the plugin glyph it always had — passes it in.
1312
+ const mono = fallback || el("span", "mk-mono", letter);
1313
+ if (!fallback) mono.style.background = "hsl(" + hash + " 42% 42%)";
1152
1314
 
1153
1315
  const img = new Image();
1154
1316
  img.className = "mk-logo";
@@ -1157,7 +1319,7 @@ function connectorMark(id, label) {
1157
1319
  if (isDarkFlatGlyph(img)) img.classList.add("invert-dark");
1158
1320
  mono.replaceWith(img);
1159
1321
  });
1160
- img.src = q("/connector-icon?id=" + encodeURIComponent(id));
1322
+ img.src = q(route + "?id=" + encodeURIComponent(id));
1161
1323
  return mono;
1162
1324
  }
1163
1325
 
@@ -1218,7 +1380,11 @@ function installedRow(entry) {
1218
1380
  const row = el("div", "mk-item");
1219
1381
  const isPlugin = marketKind === "plugins";
1220
1382
  const isSkill = marketKind === "skills";
1221
- row.appendChild(isPlugin ? svgIcon("plugin") : isSkill ? svgIcon("skill") : connectorMark(entry.name, entry.name));
1383
+ row.appendChild(
1384
+ isPlugin ? connectorMark(entry.name, entry.title || entry.name, "/plugin-icon", svgIcon("plugin"))
1385
+ : isSkill ? svgIcon("skill")
1386
+ : connectorMark(entry.name, entry.name),
1387
+ );
1222
1388
 
1223
1389
  const naming = el("div", "mk-naming");
1224
1390
  // A plugin may carry a display title over its id; the id is still what a
@@ -1236,11 +1402,18 @@ function installedRow(entry) {
1236
1402
  // A runtime plugin says so in its source; a skill carries its own flag. Reading
1237
1403
  // only one of them is how the two that ship here — memory and the media
1238
1404
  // plugin — ended up as the only rows in the panel with no provenance at all.
1405
+ // Claude Code's and Codex's are named for the tool that put them there:
1406
+ // that is where one gets fixed, and where removing it would take it from.
1239
1407
  const tag =
1240
1408
  entry.builtin || entry.source === "builtin" ? "built-in"
1241
1409
  : entry.source === "runtime" ? "runtime"
1242
1410
  : "";
1243
1411
  if (tag) heading.appendChild(el("span", "mk-tag", tag));
1412
+ if (entry.source === "claude" || entry.source === "codex") heading.appendChild(vendorChip(entry.source));
1413
+ if (isSkill && entry.disabled) {
1414
+ row.classList.add("off");
1415
+ heading.appendChild(el("span", "mk-tag", t("market.off")));
1416
+ }
1244
1417
  naming.appendChild(heading);
1245
1418
  // A skill has nothing to connect to and nothing to be offline from, so the
1246
1419
  // slot that carries a connector's state carries what the skill is for —
@@ -1254,7 +1427,11 @@ function installedRow(entry) {
1254
1427
  : entry.needsAuth
1255
1428
  ? t("add.needsAuth")
1256
1429
  : entry.error || t("add.offline");
1257
- if (detail) naming.appendChild(el("div", "mk-desc", detail));
1430
+ if (detail) {
1431
+ const line = el("div", "mk-desc", detail);
1432
+ line.title = detail;
1433
+ naming.appendChild(line);
1434
+ }
1258
1435
  row.appendChild(naming);
1259
1436
 
1260
1437
  const acts = el("div", "mk-acts");
@@ -1262,22 +1439,42 @@ function installedRow(entry) {
1262
1439
  // and repeating it on every row distinguishes nothing. What is worth saying
1263
1440
  // per row is the part that would otherwise surprise someone: this plugin
1264
1441
  // declares servers, and they are not running.
1265
- const idle = isPlugin && entry.source === "claude" ? (entry.servers || []).length : 0;
1442
+ const here = new Set((offer.connectors || []).map((one) => one.name));
1443
+ const idle = isPlugin && (entry.source === "claude" || entry.source === "codex")
1444
+ ? (entry.servers || []).filter((name) => !here.has(name)).length
1445
+ : 0;
1266
1446
  // Two keys rather than a plural engine: this is the only place in the page
1267
1447
  // where the count is usually one, and "1 servers" is worth the extra string.
1448
+ // A button, because the next question is always "then how do I start it".
1268
1449
  if (idle) {
1269
1450
  const said = idle === 1 ? t("market.serverIdle") : t("market.serversIdle", { n: idle });
1270
- acts.appendChild(el("span", "mk-state", said));
1451
+ const go = el("button", "mk-btn", said);
1452
+ go.type = "button";
1453
+ go.title = t("add.importFrom");
1454
+ go.addEventListener("click", () => void openMarket("imports"));
1455
+ acts.appendChild(go);
1271
1456
  }
1272
1457
  const ref = { kind: isPlugin ? "plugin" : isSkill ? "skill" : "connector", value: entry.name, label: entry.name };
1273
- const attach = el("button", "mk-icon-btn", referenced(ref) ? "✓" : "+");
1274
- attach.type = "button";
1275
- attach.title = t("market.attach");
1276
- attach.addEventListener("click", () => {
1277
- toggleRef(ref);
1278
- attach.textContent = referenced(ref) ? "✓" : "+";
1279
- });
1280
- acts.appendChild(attach);
1458
+ // A skill that is off cannot be loaded, so it cannot be attached either.
1459
+ if (!(isSkill && entry.disabled)) {
1460
+ const attach = el("button", "mk-icon-btn", referenced(ref) ? "✓" : "+");
1461
+ attach.type = "button";
1462
+ attach.title = t("market.attach");
1463
+ attach.addEventListener("click", () => {
1464
+ toggleRef(ref);
1465
+ attach.textContent = referenced(ref) ? "✓" : "+";
1466
+ });
1467
+ acts.appendChild(attach);
1468
+ }
1469
+
1470
+ // Off rather than removed: most of these are files another tool put on
1471
+ // disk, and deleting one to hide it here would take it from that tool too.
1472
+ if (isSkill) {
1473
+ const toggle = el("button", "mk-btn", t(entry.disabled ? "market.enable" : "market.disable"));
1474
+ toggle.type = "button";
1475
+ toggle.addEventListener("click", () => void setSkill(entry.name, Boolean(entry.disabled)));
1476
+ acts.appendChild(toggle);
1477
+ }
1281
1478
 
1282
1479
  // A connector that asked to be signed in to gets the one button that moves
1283
1480
  // it forward, in front of the one that removes it.
@@ -1293,8 +1490,17 @@ function installedRow(entry) {
1293
1490
  acts.appendChild(go);
1294
1491
  }
1295
1492
 
1296
- // Removal is a connector's alone: a plugin is a directory on disk and a
1297
- // skill is a file in one, and neither is undone from a composer menu.
1493
+ // A plugin this menu installed can be taken away by it. Any other plugin is
1494
+ // a directory somebody put on disk, and a skill is a file in one; neither is
1495
+ // undone from a composer menu.
1496
+ if (isPlugin && entry.market) {
1497
+ const drop = el("button", "mk-icon-btn drop", "×");
1498
+ drop.type = "button";
1499
+ drop.title = t("add.remove");
1500
+ drop.disabled = Boolean(installing);
1501
+ drop.addEventListener("click", () => void installPlugin(entry.market, true));
1502
+ acts.appendChild(drop);
1503
+ }
1298
1504
  if (!isPlugin && !isSkill) {
1299
1505
  const drop = el("button", "mk-icon-btn drop", "×");
1300
1506
  drop.type = "button";
@@ -1330,6 +1536,250 @@ function offerRow(entry) {
1330
1536
  return row;
1331
1537
  }
1332
1538
 
1539
+ /** Claude Code's or Codex's app icon; see tool-icons.ts. */
1540
+ function vendorMark(source, size) {
1541
+ const mark = el("span", "vendor-mark");
1542
+ mark.setAttribute("aria-hidden", "true");
1543
+ if (size) mark.style.width = mark.style.height = size + "px";
1544
+ const img = document.createElement("img");
1545
+ img.alt = "";
1546
+ img.decoding = "async";
1547
+ img.src = q("/tool-icon?id=" + (source === "codex" ? "codex" : "claude"));
1548
+ mark.appendChild(img);
1549
+ return mark;
1550
+ }
1551
+
1552
+ /** "Claude Code" or "Codex" with its mark, where a row says whose it is. */
1553
+ function vendorChip(source) {
1554
+ const chip = el("span", "vendor-chip");
1555
+ chip.appendChild(vendorMark(source));
1556
+ chip.appendChild(document.createTextNode(source === "codex" ? "Codex" : "Claude Code"));
1557
+ return chip;
1558
+ }
1559
+
1560
+ /**
1561
+ * One row per server name. The same server set up in both tools — or twice
1562
+ * in one — is one thing to add, so it is listed once, under both tools'
1563
+ * marks, and the declaration that would work is the one the button adds.
1564
+ */
1565
+ function mergeForeign(list) {
1566
+ const usable = (one) => !one.reason && !(one.missingEnv || []).length;
1567
+ const byName = new Map();
1568
+ for (const one of list) {
1569
+ const key = one.scope === "plugin" ? "plugin:" + one.plugin + ":" + one.name : one.name;
1570
+ const group = byName.get(key) || [];
1571
+ group.push(one);
1572
+ byName.set(key, group);
1573
+ }
1574
+ return [...byName.values()].map((group) => {
1575
+ const best = group.find(usable) || group[0];
1576
+ return { ...best, sources: [...new Set(group.map((one) => one.source))] };
1577
+ });
1578
+ }
1579
+
1580
+ /**
1581
+ * Everything worth bringing over from Claude Code and Codex, on one screen.
1582
+ *
1583
+ * Skills, agents and plugins are read where they lie and need nothing done,
1584
+ * so the only rows are servers: those in the two tools' own config files
1585
+ * first, then those inside their plugins, under the plugin that brought each.
1586
+ */
1587
+ function paintImports(query) {
1588
+ const hit = (one) =>
1589
+ !query ||
1590
+ one.name.toLowerCase().includes(query) ||
1591
+ (one.pluginTitle || one.plugin || "").toLowerCase().includes(query) ||
1592
+ (one.target || "").toLowerCase().includes(query);
1593
+ const here = new Set((offer.connectors || []).map((one) => one.name));
1594
+ const waiting = mergeForeign(foreignMarket.filter((one) => !one.installed && !here.has(one.name) && hit(one)));
1595
+
1596
+ const files = waiting.filter((one) => one.scope !== "plugin");
1597
+ if (files.length) {
1598
+ marketList.appendChild(el("div", "mk-group", t("market.fromConfig")));
1599
+ const grid = el("div", "mk-grid");
1600
+ for (const entry of files) grid.appendChild(foreignRow(entry));
1601
+ marketList.appendChild(grid);
1602
+ }
1603
+ const bundled = waiting.filter((one) => one.scope === "plugin");
1604
+ if (bundled.length) {
1605
+ marketList.appendChild(el("div", "mk-group", t("market.fromPlugins")));
1606
+ const grid = el("div", "mk-grid");
1607
+ for (const entry of bundled) grid.appendChild(foreignRow(entry));
1608
+ marketList.appendChild(grid);
1609
+ }
1610
+ if (!files.length && !bundled.length) {
1611
+ marketList.appendChild(el("div", "mk-desc mk-empty", t(query ? "market.empty" : "market.allImported")));
1612
+ }
1613
+ }
1614
+
1615
+ /**
1616
+ * One server from Claude Code's or Codex's config.
1617
+ *
1618
+ * Offered, never started on its own: it is a process, and often a key, the
1619
+ * person set up for another program. The row says where it was found and
1620
+ * what it reaches, and — when the file has a key written out in it — that
1621
+ * adding it copies that key, because that is the one consequence of the
1622
+ * click a reader would not guess.
1623
+ */
1624
+ function foreignRow(entry) {
1625
+ const row = el("div", "mk-item");
1626
+ row.appendChild(connectorMark(entry.name, entry.name));
1627
+
1628
+ const naming = el("div", "mk-naming");
1629
+ const heading = el("div", "mk-name", entry.name);
1630
+ for (const source of entry.sources || [entry.source]) heading.appendChild(vendorChip(source));
1631
+ naming.appendChild(heading);
1632
+ const missing = entry.missingEnv || [];
1633
+ const problem = entry.reason
1634
+ ? t("market.reason." + entry.reason)
1635
+ : missing.length
1636
+ ? t("market.needsEnv", { names: missing.join(", ") })
1637
+ : "";
1638
+ const where = [
1639
+ entry.scope === "plugin" ? t("market.scope.inPlugin", { name: entry.pluginTitle || entry.plugin }) : t("market.scope." + entry.scope),
1640
+ entry.target,
1641
+ ].filter(Boolean).join(" · ");
1642
+ naming.appendChild(el("div", problem ? "mk-desc warn" : "mk-desc", problem || where));
1643
+ if (!problem && entry.inlineSecret) naming.appendChild(el("div", "mk-desc", t("market.inlineSecret")));
1644
+ row.title = entry.file;
1645
+ row.appendChild(naming);
1646
+
1647
+ const acts = el("div", "mk-acts");
1648
+ const key = foreignKey(entry);
1649
+ // A row that cannot be added says why in its text; the button steps back
1650
+ // rather than sitting there as a bright block that does nothing.
1651
+ const add = el("button", problem ? "mk-btn" : "mk-btn go", t(key === adding ? "market.importing" : "market.import"));
1652
+ add.type = "button";
1653
+ add.disabled = Boolean(adding) || Boolean(problem);
1654
+ add.addEventListener("click", () => void importConnector(entry));
1655
+ acts.appendChild(add);
1656
+ row.appendChild(acts);
1657
+ return row;
1658
+ }
1659
+
1660
+ /** Add one found in Claude Code's or Codex's config, by where it was found. */
1661
+ function foreignKey(entry) {
1662
+ return [entry.source, entry.scope, entry.plugin || "", entry.name].join(":");
1663
+ }
1664
+
1665
+ async function importConnector(entry) {
1666
+ if (adding) return;
1667
+ adding = foreignKey(entry);
1668
+ paintMarket();
1669
+ const body = await api(
1670
+ "/connector",
1671
+ { import: { source: entry.source, scope: entry.scope, name: entry.name, ...(entry.plugin ? { plugin: entry.plugin } : {}) } },
1672
+ 3 * 60_000,
1673
+ );
1674
+ adding = null;
1675
+ if (body.error) {
1676
+ addNotice(body.error, "err");
1677
+ return void paintMarket();
1678
+ }
1679
+ if (body.connectors) offer = { connectors: body.connectors, plugins: body.plugins || [], skills: body.skills || [], agents: body.agents || [] };
1680
+ // By name: the same server is often in both tools, and once one is here
1681
+ // the other would be a second copy of it.
1682
+ for (const one of foreignMarket) if (one.name === entry.name) one.installed = true;
1683
+ for (const one of market) if (one.id === entry.name) one.installed = true;
1684
+ paintMarketTabs();
1685
+ paintMarket();
1686
+ }
1687
+
1688
+ /** Switch a skill off or back on. */
1689
+ async function setSkill(name, enabled) {
1690
+ const body = await api("/skill", { name: name, enabled: enabled });
1691
+ if (body.error) return void addNotice(body.error, "err");
1692
+ if (body.skills) offer = { connectors: body.connectors || [], plugins: body.plugins || [], skills: body.skills, agents: body.agents || [] };
1693
+ paintMarket();
1694
+ }
1695
+
1696
+ /** The shelves the plugin list is cut into, in the order they are shown. */
1697
+ const PLUGIN_CATEGORIES = ["build", "data", "monitor", "dev", "web", "design", "work", "other"];
1698
+
1699
+ /**
1700
+ * One plugin on offer.
1701
+ *
1702
+ * Installing downloads somebody else's repository and, for a plugin with
1703
+ * servers, connects to them — so the first click says exactly that, and only
1704
+ * the second one does it. A dialog would say the same thing further from the
1705
+ * row it is about.
1706
+ */
1707
+ function pluginOfferRow(entry) {
1708
+ const row = el("div", "mk-item");
1709
+ row.appendChild(connectorMark(entry.id, entry.label, "/plugin-icon", svgIcon("plugin")));
1710
+
1711
+ const naming = el("div", "mk-naming");
1712
+ const heading = el("div", "mk-name", entry.label);
1713
+ if (entry.publisher && entry.publisher !== entry.label) heading.appendChild(el("span", "mk-tag", entry.publisher));
1714
+ naming.appendChild(heading);
1715
+ const asking = confirming === entry.id;
1716
+ naming.appendChild(el("div", asking ? "mk-desc warn" : "mk-desc", asking ? consent(entry) : entry.description));
1717
+ if (!asking) naming.appendChild(el("div", "mk-desc", provides(entry.provides || {})));
1718
+ row.appendChild(naming);
1719
+
1720
+ const acts = el("div", "mk-acts");
1721
+ const label = installing === entry.id ? "market.installing" : asking ? "market.confirm" : "market.install";
1722
+ const add = el("button", "mk-btn go", t(label));
1723
+ add.type = "button";
1724
+ add.disabled = Boolean(installing);
1725
+ add.addEventListener("click", () => {
1726
+ if (!asking) {
1727
+ confirming = entry.id;
1728
+ return void paintMarket();
1729
+ }
1730
+ void installPlugin(entry.id, false);
1731
+ });
1732
+ acts.appendChild(add);
1733
+ if (asking && !installing) {
1734
+ const cancel = el("button", "mk-btn", t("market.cancel"));
1735
+ cancel.type = "button";
1736
+ cancel.addEventListener("click", () => {
1737
+ confirming = null;
1738
+ paintMarket();
1739
+ });
1740
+ acts.appendChild(cancel);
1741
+ }
1742
+ row.appendChild(acts);
1743
+ return row;
1744
+ }
1745
+
1746
+ /** "37 skills · 3 agents · 1 connector", from what the list says the plugin holds. */
1747
+ function provides(what) {
1748
+ const parts = [];
1749
+ if (what.skills) parts.push(t(what.skills === 1 ? "market.oneSkill" : "market.skills", { n: what.skills }));
1750
+ if (what.agents) parts.push(t(what.agents === 1 ? "market.oneAgent" : "market.agents", { n: what.agents }));
1751
+ const servers = (what.servers || []).length;
1752
+ if (servers) parts.push(t(servers === 1 ? "market.oneServer" : "market.servers", { n: servers }));
1753
+ return parts.join(" · ");
1754
+ }
1755
+
1756
+ /** What the second click agrees to, said before it is made. */
1757
+ function consent(entry) {
1758
+ const servers = (entry.provides && entry.provides.servers) || [];
1759
+ return t(servers.length ? "market.consentServers" : "market.consent", {
1760
+ publisher: entry.publisher || entry.label,
1761
+ servers: servers.join(", "),
1762
+ });
1763
+ }
1764
+
1765
+ /** Install a plugin from the list, or remove one it installed. */
1766
+ async function installPlugin(id, remove) {
1767
+ if (installing) return;
1768
+ installing = remove ? null : id;
1769
+ confirming = null;
1770
+ paintMarket();
1771
+ const body = await api("/plugin-market", { id: id, remove: remove === true }, 5 * 60_000);
1772
+ installing = null;
1773
+ if (body.error) {
1774
+ addNotice(body.error, "err");
1775
+ return void paintMarket();
1776
+ }
1777
+ if (body.plugins) offer = { connectors: body.connectors || [], plugins: body.plugins, skills: body.skills || [], agents: body.agents || [] };
1778
+ for (const entry of pluginMarket) if (entry.id === id) entry.installed = remove !== true;
1779
+ if (!remove) addNotice(t("market.installedNotice", { name: (pluginMarket.find((one) => one.id === id) || { label: id }).label }));
1780
+ paintMarket();
1781
+ }
1782
+
1333
1783
  /**
1334
1784
  * Add a connector, or take one away.
1335
1785
  *
@@ -1378,7 +1828,23 @@ async function authorizeConnector(id) {
1378
1828
 
1379
1829
  document.getElementById("mk-close").appendChild(svgIcon("close"));
1380
1830
  document.getElementById("mk-close").setAttribute("aria-label", t("market.close"));
1381
- document.getElementById("mk-close").addEventListener("click", () => marketDialog.close());
1831
+ document.getElementById("mk-close").addEventListener("click", () => closeMarket());
1832
+ // Escape closes it too, and should leave the same way the button does.
1833
+ marketDialog.addEventListener("cancel", (e) => {
1834
+ e.preventDefault();
1835
+ closeMarket();
1836
+ });
1837
+
1838
+ /** Fade the dialog out before it goes, rather than vanishing mid-frame. */
1839
+ function closeMarket() {
1840
+ if (!marketDialog.open || marketDialog.classList.contains("closing")) return;
1841
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return void marketDialog.close();
1842
+ marketDialog.classList.add("closing");
1843
+ setTimeout(() => {
1844
+ marketDialog.classList.remove("closing");
1845
+ marketDialog.close();
1846
+ }, 140);
1847
+ }
1382
1848
 
1383
1849
  async function loadMentionFiles() {
1384
1850
  const seq = ++attachSeq;
@@ -1437,6 +1903,22 @@ function openAttachMenu() {
1437
1903
  offer = body;
1438
1904
  paintAttachMenu();
1439
1905
  });
1906
+ // For the import row's count; the dialog fetches its own. Kept between
1907
+ // opens, so after the first the row is there from the first frame.
1908
+ void getJson("/registry").then((body) => {
1909
+ if (body.error) return;
1910
+ foreignMarket = body.imports || [];
1911
+ if (!attachMenu.hidden && attachView === "root") paintAttachMenu();
1912
+ });
1913
+ }
1914
+
1915
+ /**
1916
+ * How many servers from Claude Code and Codex could be added now: not here
1917
+ * already, usable, and counted once when both tools declare the same one.
1918
+ */
1919
+ function foreignWaiting() {
1920
+ const here = new Set((offer.connectors || []).map((one) => one.name));
1921
+ return new Set(foreignMarket.filter((one) => !one.installed && !one.reason && !here.has(one.name)).map((one) => one.name)).size;
1440
1922
  }
1441
1923
 
1442
1924
  /**
@@ -1496,10 +1978,18 @@ function moveAttachCursor(e) {
1496
1978
 
1497
1979
  const inputMirror = document.getElementById("input-mirror");
1498
1980
 
1981
+ /**
1982
+ * The skills the model can load. The offer also carries the ones switched
1983
+ * off, for the list that switches them back on; nothing else should see them.
1984
+ */
1985
+ function usableSkills() {
1986
+ return (offer.skills || []).filter((skill) => !skill.disabled);
1987
+ }
1988
+
1499
1989
  /** Every name an @ can stand for. */
1500
1990
  function mentionNames() {
1501
1991
  return new Map([
1502
- ...(offer.skills || []).map((skill) => [skill.name, "skill"]),
1992
+ ...usableSkills().map((skill) => [skill.name, "skill"]),
1503
1993
  ...(offer.plugins || []).map((plugin) => [plugin.name, "plugin"]),
1504
1994
  ]);
1505
1995
  }