@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.
- package/dist/i18n.js +90 -4
- package/dist/i18n.js.map +1 -1
- package/dist/icons.d.ts +3 -0
- package/dist/icons.js +68 -3
- package/dist/icons.js.map +1 -1
- package/dist/page/client/base.js +2 -0
- package/dist/page/client/base.js.map +1 -1
- package/dist/page/client/composer.js +530 -40
- package/dist/page/client/composer.js.map +1 -1
- package/dist/page/style.js +92 -2
- package/dist/page/style.js.map +1 -1
- package/dist/page.js +1 -0
- package/dist/page.js.map +1 -1
- package/dist/server.js +245 -3
- package/dist/server.js.map +1 -1
- package/dist/terminal.d.ts +7 -0
- package/dist/terminal.js +15 -1
- package/dist/terminal.js.map +1 -1
- package/dist/tool-icons.d.ts +12 -0
- package/dist/tool-icons.js +16 -0
- package/dist/tool-icons.js.map +1 -0
- package/package.json +2 -2
|
@@ -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" ?
|
|
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
|
|
645
|
-
// are whatever is on this machine already. "More" only when the
|
|
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 = (
|
|
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 = (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1090
|
-
|
|
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
|
-
|
|
1124
|
-
//
|
|
1125
|
-
|
|
1126
|
-
|
|
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
|
-
|
|
1151
|
-
|
|
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("
|
|
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(
|
|
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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
attach.
|
|
1279
|
-
|
|
1280
|
-
|
|
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
|
-
//
|
|
1297
|
-
//
|
|
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", () =>
|
|
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
|
-
...(
|
|
1992
|
+
...usableSkills().map((skill) => [skill.name, "skill"]),
|
|
1503
1993
|
...(offer.plugins || []).map((plugin) => [plugin.name, "plugin"]),
|
|
1504
1994
|
]);
|
|
1505
1995
|
}
|