1582 lines
74 KiB
JavaScript
1582 lines
74 KiB
JavaScript
// Library: one set of instructions, MCP servers and skills, written into
|
||
// every agent that should have them (#29). magpie keeps the library in its
|
||
// own folder and writes each agent's files from it; what the user has in
|
||
// those files besides stays theirs, and every file is kept aside before
|
||
// magpie writes it. Everything shown here comes from /api/library.
|
||
(() => {
|
||
const page = $("#view-library");
|
||
if (!page) return;
|
||
|
||
let lib = null; // the page, as /api/library gives it
|
||
let tab = "instructions";
|
||
let shown = ""; // the tab the page last drew, which fades in only when it changes
|
||
let fits = []; // textareas to fit before the page is painted
|
||
try { tab = localStorage.getItem("magpie.libTab") || tab; } catch {}
|
||
const texts = {}; // set → its shared instructions as typed, while not saved
|
||
let openSet = null; // the set of instructions opened to edit
|
||
let addingSet = false; // a new set being named
|
||
let removingSet = null; // a set whose Remove was clicked once
|
||
const extras = {}; // agent → its own additions as typed, while not saved
|
||
const open = new Set(); // agents whose instructions row is open
|
||
let probe = null; // skills found at a source: { source, candidates, pick:Set, agents:Set }
|
||
let probing = false;
|
||
let modal = null; // what the library has open in the dialog
|
||
|
||
const GLYPH = {
|
||
cmd: "M3 4.5h10v7H3z M5.5 7l1.5 1.2-1.5 1.3 M8.5 9.5h2",
|
||
web: "M8 2.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11z M2.5 8h11 M8 2.5c1.6 1.7 2.3 3.5 2.3 5.5S9.6 11.8 8 13.5 M8 2.5C6.4 4.2 5.7 6 5.7 8s.7 3.8 2.3 5.5",
|
||
skill: "M4 2.5h6.5L12 4v9.5H4z M6 6h4 M6 8.5h4 M6 11h2.5",
|
||
doc: "M4 2.5h5.5L12 5v8.5H4z M9.5 2.5V5H12",
|
||
folder: "M2.5 4.5h4l1.2 1.3h5.8v6.7h-11z",
|
||
up: "M8 12.5v-9 M4.5 7 8 3.5 11.5 7",
|
||
down: "M8 3.5v9 M4.5 9 8 12.5 11.5 9",
|
||
trash: "M3.5 4.5h9 M6.5 4.5V3h3v1.5 M4.5 4.5l.6 8.5h5.8l.6-8.5",
|
||
search: "M7 3a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M10 10l3 3",
|
||
out: "M9.5 3.5h3v3 M12.5 3.5 7.5 8.5 M11 9.5v3H3.5V5h3",
|
||
key: "M10 2.8a3.2 3.2 0 1 0 0 6.4 3.2 3.2 0 0 0 0-6.4z M7.8 8.2 3 13 M4.5 11.5 6 13",
|
||
person: "M8 3a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5z M3.5 13.5c.6-2.4 2.3-3.6 4.5-3.6s3.9 1.2 4.5 3.6",
|
||
back: "m9.5 4.5-3 3.5 3 3.5",
|
||
};
|
||
|
||
const tilde = (p) => (lib?.home && p?.startsWith(lib.home) ? "~" + p.slice(lib.home.length) : p || "");
|
||
const agentOf = (id) => lib.agents.find((a) => a.id === id);
|
||
const nameOf = (id) => agentOf(id)?.name || id;
|
||
// An agent with no MCP of its own (Pi) reads its servers through an
|
||
// extension, which each of its chips says.
|
||
// An agent hidden on the Agents page is left out here too (#71): what it
|
||
// already has stays with it, and a new server or skill isn't given to it.
|
||
const shownAgents = () => lib.agents.filter((a) => !isHidden(a));
|
||
const mcpAgents = () => shownAgents().filter((a) => a.mcp)
|
||
.map((a) => a.mcpVia ? { ...a, aside: t("{agent} reads MCP servers through the {ext} extension", { agent: a.name, ext: a.mcpVia }) } : a);
|
||
const skillAgents = () => shownAgents().filter((a) => a.skills);
|
||
|
||
function glyph(d, cls = "lib-glyph") {
|
||
const g = el("span", cls);
|
||
g.append(svg(d, 16, 1.3));
|
||
return g;
|
||
}
|
||
// A row's mark: the thing's own icon where the market knows it, else a
|
||
// glyph for what it is — never a made-up picture.
|
||
function mark(url, d) {
|
||
if (!url) return glyph(d);
|
||
const box = logo(url, "");
|
||
box.classList.add("lib-logo");
|
||
box.querySelector("img").onerror = () => box.replaceWith(glyph(d));
|
||
return box;
|
||
}
|
||
function button(text, cls, onclick) {
|
||
const b = el("button", "text " + (cls || ""), text);
|
||
b.onclick = (e) => { e.stopPropagation(); onclick(e, b); };
|
||
return b;
|
||
}
|
||
// a path shown on the page: a click shows it in the file manager
|
||
function pathLink(p) {
|
||
const b = el("button", "lib-path", tilde(p));
|
||
b.title = t("Show in {fm}", { fm: fileManager() });
|
||
b.onclick = (e) => { e.stopPropagation(); reveal(p); };
|
||
return b;
|
||
}
|
||
function fileManager() { return /^Mac/.test(navigator.platform) ? "Finder" : /^Win/.test(navigator.platform) ? "Explorer" : t("the file manager"); }
|
||
// the app's webview opens no new windows: a link goes to the system browser
|
||
function browse(u) { api("open", { url: u }).catch((e) => status(e.message, "err")); }
|
||
function reveal(p) { api("library/reveal", { path: p }).catch((e) => status(e.message, "err")); }
|
||
|
||
// A switch: on or off, nothing between.
|
||
function toggle(on, label, onChange) {
|
||
const s = el("button", "lib-switch" + (on ? " on" : ""));
|
||
s.setAttribute("role", "switch");
|
||
s.setAttribute("aria-checked", on ? "true" : "false");
|
||
s.setAttribute("aria-label", label);
|
||
s.append(el("i"));
|
||
s.onclick = (e) => { e.stopPropagation(); onChange(!on); };
|
||
return s;
|
||
}
|
||
|
||
// Agent chips for a server or a skill: each agent that could have it, lit
|
||
// when it does. A chip whose agent couldn't be given it says why.
|
||
function agentChips(all, on, onChange, opts = {}) {
|
||
on ||= [];
|
||
const box = el("div", "lib-agents");
|
||
for (const a of all) {
|
||
const has = on.includes(a.id);
|
||
const c = el("button", "lib-ag" + (has ? " on" : ""));
|
||
c.dataset.agent = a.id;
|
||
c.append(icon(a.icon));
|
||
if (opts.names) c.append(el("span", "n", a.name));
|
||
const problem = opts.problems?.[a.id];
|
||
const blocked = opts.blocked?.(a);
|
||
const via = !has && opts.via?.(a);
|
||
let tip = has ? t("{agent} has it — click to take it away", { agent: a.name }) : t("Give it to {agent}", { agent: a.name });
|
||
if (via) { c.classList.add("via"); tip = t("{agent} reads it through {other} — click to give it its own", { agent: a.name, other: via }); }
|
||
if (problem) { c.classList.add("warn"); tip = a.name + ": " + problem; }
|
||
if (blocked) { c.classList.add("blocked"); c.disabled = true; tip = blocked; }
|
||
c.title = a.aside && !problem && !blocked ? tip + "\n" + a.aside : tip;
|
||
c.setAttribute("aria-pressed", has ? "true" : "false");
|
||
c.onclick = (e) => {
|
||
e.stopPropagation();
|
||
const next = has ? on.filter((x) => x !== a.id) : [...on, a.id];
|
||
onChange(next, c);
|
||
};
|
||
box.append(c);
|
||
}
|
||
return box;
|
||
}
|
||
|
||
// A row's chips switched in place: the page isn't drawn again, which
|
||
// lost the chips' hover (they folded back together and spread again under
|
||
// the pointer) and blinked their icons (#69). The chip shows the click at
|
||
// once; what magpie wrote is then painted onto the same buttons, and the
|
||
// page is drawn again only when more than this row changed.
|
||
function chipsChange(path, name, list, rowOf) {
|
||
return async (next, c) => {
|
||
const box = c.parentElement;
|
||
c.classList.toggle("on", next.includes(c.dataset.agent));
|
||
c.classList.remove("via");
|
||
const before = rest(list);
|
||
try {
|
||
const v = await api("library/" + path, { name, agents: next });
|
||
lib = v;
|
||
report(v.result);
|
||
} catch (e) {
|
||
status(e.message, "err", 6000);
|
||
}
|
||
const x = lib[list].find((y) => y.name === name);
|
||
const fresh = x && rowOf(x).querySelector(":scope > .lib-agents");
|
||
if (!fresh || rest(list) !== before || !box.isConnected || !morphChips(box, fresh)) render();
|
||
};
|
||
}
|
||
// what the page shows besides a row's chips
|
||
const rest = (list) => JSON.stringify([lib[list].map((y) => y.name), lib.foundServers, lib.foundSkills, lib.instructions.agents.map((a) => a.on)]);
|
||
function morphChips(box, fresh) {
|
||
const was = [...box.children], now = [...fresh.children];
|
||
if (was.length !== now.length || was.some((c, i) => c.dataset.agent !== now[i].dataset.agent)) return false;
|
||
was.forEach((c, i) => {
|
||
const n = now[i];
|
||
c.className = n.className;
|
||
c.title = n.title;
|
||
c.disabled = n.disabled;
|
||
c.setAttribute("aria-pressed", n.getAttribute("aria-pressed"));
|
||
c.onclick = n.onclick;
|
||
});
|
||
return true;
|
||
}
|
||
|
||
// ---------- loading and changing ----------
|
||
|
||
async function load() {
|
||
if (!lib) renderLoading();
|
||
rtk = null; // asked again when its tab is drawn: an agent may have been installed since
|
||
try {
|
||
lib = await api("library");
|
||
render();
|
||
} catch (e) {
|
||
status(e.message, "err");
|
||
}
|
||
}
|
||
window.loadLibrary = load;
|
||
|
||
// Every change answers with the page as it is after it, and what it did.
|
||
async function change(path, body, done) {
|
||
try {
|
||
const v = await api("library/" + path, body);
|
||
lib = v;
|
||
report(v.result, done);
|
||
render();
|
||
return true;
|
||
} catch (e) {
|
||
status(e.message, "err", 6000);
|
||
return false;
|
||
}
|
||
}
|
||
function report(res, done) {
|
||
if (!res) return;
|
||
if (res.problems?.length) {
|
||
const p = res.problems[0];
|
||
status(t("{agent}: {error}", { agent: nameOf(p.agent), error: p.error }) + (res.problems.length > 1 ? " " + t("(and {n} more)", { n: res.problems.length - 1 }) : ""), "warn", 8000);
|
||
return;
|
||
}
|
||
const n = res.changed?.length || 0;
|
||
if (done) status(done, "ok");
|
||
else if (n) status(n === 1 ? t("Written to {agent}", { agent: nameOf(res.changed[0]) }) : t("Written to {n} agents", { n }), "ok");
|
||
else status(t("Saved — the agents already had it"), "ok");
|
||
}
|
||
|
||
function renderLoading() {
|
||
page.replaceChildren();
|
||
shown = "";
|
||
const l = el("div", "list lib-skel");
|
||
for (let i = 0; i < 4; i++) l.append(el("div", "row skeleton"));
|
||
page.append(l);
|
||
}
|
||
|
||
// ---------- the page ----------
|
||
|
||
function render() {
|
||
if (!lib) return;
|
||
const top = page.scrollTop;
|
||
const focus = document.activeElement?.dataset?.lib; // keep the caret where the reader is typing
|
||
const caret = focus ? [document.activeElement.selectionStart, document.activeElement.selectionEnd] : null;
|
||
page.replaceChildren();
|
||
fits = [];
|
||
const head = el("div", "lib-head");
|
||
const counts = {
|
||
instructions: lib.instructions.agents.filter((a) => a.on).length,
|
||
mcp: lib.servers.length,
|
||
skills: lib.skills.length,
|
||
};
|
||
const tabs = segs([
|
||
["instructions", t("Instructions")],
|
||
["mcp", t("MCP servers") + (counts.mcp ? " · " + counts.mcp : "")],
|
||
["skills", t("Skills") + (counts.skills ? " · " + counts.skills : "")],
|
||
["rtk", "RTK"],
|
||
], tab, (id) => { tab = id; try { localStorage.setItem("magpie.libTab", id); } catch {} render(); page.scrollTop = 0; });
|
||
tabs.classList.add("lib-tabs");
|
||
head.append(tabs, el("span", "grow"));
|
||
const more = button("", "lib-more", () => reveal(lib.dir));
|
||
more.append(glyph(GLYPH.folder, "lib-mini"), el("span", "", t("Library folder")));
|
||
more.title = tilde(lib.dir);
|
||
head.append(more);
|
||
page.append(head);
|
||
head.classList.toggle("stuck", top > 0);
|
||
if (!lib.agents.length) {
|
||
page.append(empty(t("No agents to write to"), t("Install Claude Code, Codex, Gemini CLI, OpenCode… and the library can give them the same instructions, MCP servers and skills.")));
|
||
return;
|
||
}
|
||
// what changed in a tab is drawn in place: only another tab (or the
|
||
// first one after the skeleton) fades in
|
||
const body = el("div", "lib-body" + (shown !== tab ? " enter" : ""));
|
||
shown = tab;
|
||
if (tab === "instructions") renderInstructions(body);
|
||
else if (tab === "mcp") renderServers(body);
|
||
else if (tab === "rtk") renderRTK(body);
|
||
else renderSkills(body);
|
||
page.append(body);
|
||
for (const n of body.querySelectorAll(".row-head .note")) n.title = n.textContent;
|
||
const foot = el("p", "lib-foot");
|
||
foot.append(el("span", "", t("magpie keeps a copy of every file before it writes it.") + " "));
|
||
const b = el("button", "lib-link", t("Backups"));
|
||
b.onclick = () => reveal(lib.backups);
|
||
foot.append(b);
|
||
page.append(foot);
|
||
for (const f of fits.splice(0)) f();
|
||
page.scrollTop = top;
|
||
if (focus) {
|
||
const e = page.querySelector(`[data-lib="${CSS.escape(focus)}"]`);
|
||
if (e) { e.focus({ preventScroll: true }); if (caret && e.setSelectionRange) e.setSelectionRange(caret[0], caret[1]); }
|
||
}
|
||
}
|
||
|
||
function empty(title, text, action) {
|
||
const box = el("div", "lib-empty");
|
||
box.append(el("b", "", title), el("p", "", text));
|
||
if (action) box.append(action);
|
||
return box;
|
||
}
|
||
|
||
function intro(text) { return el("p", "lib-intro", text); }
|
||
|
||
// ---------- RTK ----------
|
||
|
||
// RTK (rtk-ai.app) cuts down what the shell commands an agent runs print,
|
||
// so their output costs fewer tokens. magpie runs rtk's own installer for
|
||
// each agent switched on, takes out what it wrote for one switched off
|
||
// (with or without rtk), and reads the agents' files for which have it.
|
||
let rtk = null; // /api/library/rtk
|
||
const rtkBusy = new Set(); // agents being switched
|
||
let rtkLoading = false;
|
||
async function loadRTK() {
|
||
if (rtkLoading) return;
|
||
rtkLoading = true;
|
||
try {
|
||
rtk = await api("library/rtk");
|
||
} catch (e) {
|
||
status(e.message, "err");
|
||
}
|
||
rtkLoading = false;
|
||
if (tab === "rtk" && rtk) render();
|
||
}
|
||
function renderRTK(body) {
|
||
body.append(intro(t("RTK rewrites the shell commands an agent runs — git status, cargo test, ls… — to print only what the model needs, so they cost fewer tokens. Switch it on for an agent and magpie runs RTK's own installer for it.")));
|
||
if (!rtk) {
|
||
body.append(el("div", "row skeleton"));
|
||
loadRTK();
|
||
return;
|
||
}
|
||
const card = el("div", "list lib-card");
|
||
const ttl = el("div", "lib-cardhead");
|
||
// RTK's own mark (rtk-ai.app), drawn in the text's colour
|
||
const logo = el("span", "lib-glyph lib-rtk-logo");
|
||
const m = el("span", "mask");
|
||
m.style.setProperty("--i", "url(icons/rtk.png)");
|
||
logo.append(m);
|
||
ttl.append(logo, el("b", "", "RTK"), el("span", "grow"));
|
||
if (rtk.path) {
|
||
ttl.append(el("span", "note mono", rtk.version ? "v" + rtk.version : tilde(rtk.path)));
|
||
card.append(ttl);
|
||
const g = rtk.gain;
|
||
card.append(el("p", "lib-rtk-gain", g
|
||
? t("{saved} tokens saved over {n} commands — {pct}% on average", { saved: tokens(g.saved), n: g.commands.toLocaleString(), pct: Math.round(g.pct) })
|
||
: t("Nothing saved yet: the agents' commands go through RTK once it's switched on and the agent is restarted.")));
|
||
} else {
|
||
card.append(ttl);
|
||
card.append(el("p", "lib-rtk-gain", rtkInstalling
|
||
? t("Installing RTK… this can take a minute.")
|
||
: t("RTK isn't installed. magpie can install it for you, or get it from its site.")));
|
||
if (rtk.install) card.append(el("p", "lib-rtk-cmd", rtk.install));
|
||
const acts = el("div", "lib-acts");
|
||
if (rtk.install) {
|
||
const ib = button(rtkInstalling ? t("Installing…") : t("Install RTK"), "action", installRTK);
|
||
ib.disabled = rtkInstalling;
|
||
acts.append(ib);
|
||
}
|
||
acts.append(button(t("Get RTK"), rtk.install ? "" : "action", () => browse(rtk.url)), button(t("Check again"), "", () => { rtk = null; render(); }));
|
||
card.append(acts);
|
||
}
|
||
body.append(card);
|
||
const rh = el("div", "row-head");
|
||
rh.append(el("span", "label", t("Agents")), el("span", "grow"), el("span", "note", t("restart an agent after switching it")));
|
||
body.append(rh);
|
||
const list = el("div", "list lib-list");
|
||
const rows = rtk.agents.filter((a) => !isHidden(a));
|
||
for (const a of rows) {
|
||
const row = el("div", "row lib-row");
|
||
const who = el("div", "who");
|
||
who.append(el("div", "name", a.name));
|
||
row.append(icon(a.icon), who, el("span", "grow"));
|
||
// its hook calls an rtk that isn't there: switching it off still works
|
||
if (a.on && !rtk.path) row.append(tag(t("RTK missing"), "warn", t("{agent}'s hook calls rtk, which isn't installed, so its shell commands fail. Install RTK, or switch this off.", { agent: a.name })));
|
||
const sw = toggle(a.on, t("{agent} runs its commands through RTK", { agent: a.name }), (on) => setRTK(a, on));
|
||
if ((!rtk.path && !a.on) || rtkBusy.has(a.id) || rtkInstalling) sw.disabled = true;
|
||
row.append(sw);
|
||
list.append(row);
|
||
}
|
||
if (!rows.length) list.append(el("div", "lib-none", t("None of your agents is one RTK has a hook for.")));
|
||
body.append(list);
|
||
}
|
||
async function setRTK(a, on) {
|
||
rtkBusy.add(a.id);
|
||
render();
|
||
try {
|
||
rtk = await api("library/rtk", { agent: a.id, on });
|
||
const names = (rtk.restart || [a.id]).map((id) => rtk.agents.find((x) => x.id === id)?.name || id).join(", ");
|
||
status(on ? t("RTK is on for {agent} — restart it to use it", { agent: names }) : t("RTK is off for {agent} — restart it to see it", { agent: names }), "ok", 6000);
|
||
} catch (e) {
|
||
status(e.message, "err", 8000);
|
||
}
|
||
rtkBusy.delete(a.id);
|
||
render();
|
||
}
|
||
let rtkInstalling = false;
|
||
async function installRTK() {
|
||
rtkInstalling = true;
|
||
render();
|
||
try {
|
||
rtk = await api("library/rtk/install", {});
|
||
status(t("RTK is installed — switch it on for your agents below"), "ok", 6000);
|
||
} catch (e) {
|
||
status(e.message, "err", 10000);
|
||
}
|
||
rtkInstalling = false;
|
||
render();
|
||
}
|
||
function tokens(n) {
|
||
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M";
|
||
if (n >= 1e3) return (n / 1e3).toFixed(1) + "k";
|
||
return String(n);
|
||
}
|
||
|
||
// ---------- instructions ----------
|
||
|
||
function autosize(ta, min) {
|
||
const fit = () => { ta.style.height = "auto"; ta.style.height = Math.max(min, ta.scrollHeight + 2) + "px"; };
|
||
ta.addEventListener("input", fit);
|
||
// fitted once it's on the page, before it's painted, so what's under it
|
||
// doesn't move a frame later
|
||
fits.push(fit);
|
||
requestAnimationFrame(fit); // and one drawn outside render()
|
||
}
|
||
|
||
function iv() { return lib.instructions; }
|
||
const setOf = (id) => lib.instructions.sets.find((x) => x.id === id);
|
||
const setName = (x) => x.name || t("Default");
|
||
function unsavedTexts() {
|
||
const out = {};
|
||
for (const [id, v] of Object.entries(texts)) if (setOf(id) && v !== setOf(id).text) out[id] = v;
|
||
return out;
|
||
}
|
||
function dirty() {
|
||
if (Object.keys(unsavedTexts()).length) return true;
|
||
return Object.entries(extras).some(([id, v]) => v !== (lib.instructions.agents.find((a) => a.agent === id)?.extra || ""));
|
||
}
|
||
|
||
async function saveInstructions() {
|
||
const c = {};
|
||
const tx = unsavedTexts();
|
||
if (Object.keys(tx).length) c.texts = tx;
|
||
const ex = {};
|
||
for (const [id, v] of Object.entries(extras)) if (v !== (lib.instructions.agents.find((a) => a.agent === id)?.extra || "")) ex[id] = v;
|
||
if (Object.keys(ex).length) c.extra = ex;
|
||
if (!Object.keys(c).length) return;
|
||
if (await change("instructions/save", c)) {
|
||
for (const k of Object.keys(texts)) delete texts[k];
|
||
for (const k of Object.keys(extras)) delete extras[k];
|
||
render();
|
||
}
|
||
}
|
||
function discard() {
|
||
for (const k of Object.keys(texts)) delete texts[k];
|
||
for (const k of Object.keys(extras)) delete extras[k];
|
||
render();
|
||
}
|
||
|
||
function renderInstructions(body) {
|
||
const iv = lib.instructions;
|
||
body.append(intro(t("Write it once: every agent switched on below reads it before each conversation, in its own instructions file.")));
|
||
// the sets of shared instructions: one is on, the one the agents read;
|
||
// a set opens to be edited (#106)
|
||
const card = el("div", "list lib-list lib-sets");
|
||
const ttl = el("div", "lib-cardhead");
|
||
ttl.append(glyph(GLYPH.doc), el("b", "", t("Shared instructions")), el("span", "grow"), el("span", "note", "Markdown · " + (/^Mac/.test(navigator.platform) ? "⌘S" : "Ctrl+S")));
|
||
card.append(ttl);
|
||
for (const x of iv.sets) card.append(...setRow(x));
|
||
if (addingSet) card.append(newSetRow());
|
||
else {
|
||
const add = el("div", "lib-acts lib-setadd");
|
||
add.append(button("+ " + t("New set"), "action", () => { addingSet = true; render(); }));
|
||
card.append(add);
|
||
}
|
||
const bar = el("div", "lib-savebar");
|
||
bar.append(el("span", "note", t("Not saved yet")), el("span", "grow"), button(t("Discard"), "", discard), button(t("Save"), "primary", saveInstructions));
|
||
bar.hidden = !dirty();
|
||
body.append(card);
|
||
|
||
const rh = el("div", "row-head");
|
||
rh.append(el("span", "label", t("Agents")), el("span", "grow"), el("span", "note", t("magpie writes its part between two marker lines — the rest of each file stays yours")));
|
||
body.append(rh);
|
||
const list = el("div", "list lib-list");
|
||
const rows = iv.agents.filter((a) => !isHidden({ id: a.agent }));
|
||
for (const a of rows) list.append(...instructionsRow(a));
|
||
if (!rows.length) list.append(el("div", "lib-none", t("None of your agents reads a user-wide instructions file magpie knows.")));
|
||
body.append(list);
|
||
const skip = shownAgents().filter((a) => !a.instructions);
|
||
if (skip.length) body.append(el("p", "lib-aside", t(skip.length > 1 ? "{agents} keep no user-wide instructions file." : "{agents} keeps no user-wide instructions file.", { agents: skip.map((a) => a.name).join(", ") })));
|
||
body.append(bar);
|
||
}
|
||
|
||
function setRow(x) {
|
||
const isOpen = openSet === x.id;
|
||
const text = texts[x.id] ?? x.text;
|
||
const row = el("div", "row lib-row click lib-set" + (isOpen ? " open" : "") + (x.active ? " on" : ""));
|
||
const pick = el("button", "lib-radio" + (x.active ? " on" : ""));
|
||
pick.setAttribute("role", "radio");
|
||
pick.setAttribute("aria-checked", x.active ? "true" : "false");
|
||
pick.title = x.active ? t("The agents switched on below read this set") : t("Switch the agents to this set");
|
||
pick.onclick = (e) => {
|
||
e.stopPropagation();
|
||
if (x.active) return;
|
||
const c = { activate: x.id };
|
||
const tx = unsavedTexts();
|
||
if (Object.keys(tx).length) c.texts = tx;
|
||
change("instructions/save", c, t("The agents read {name} now", { name: setName(x) })).then((ok) => { if (ok) for (const k of Object.keys(tx)) delete texts[k]; });
|
||
};
|
||
const who = el("div", "who");
|
||
who.append(el("div", "name", setName(x)));
|
||
const first = text.split("\n").find((l) => l.trim()) || "";
|
||
who.append(el("div", "sub", first ? first.replace(/^#+\s*/, "") : t("Empty")));
|
||
const tags = el("div", "lib-tags");
|
||
if (x.active) tags.append(tag(t("In use"), "", t("The agents switched on below read this set")));
|
||
if (text !== x.text) tags.append(tag(t("Not saved yet"), "warn"));
|
||
const chev = el("span", "chev");
|
||
chev.append(svg(CHEV_R, 11, 1.7));
|
||
row.append(pick, who, tags, chev);
|
||
row.onclick = () => { openSet = isOpen ? null : x.id; removingSet = null; render(); };
|
||
if (!isOpen) return [row];
|
||
|
||
const det = el("div", "lib-detail");
|
||
const nm = input(setName(x), t("Name"));
|
||
nm.dataset.lib = "setname:" + x.id;
|
||
nm.className = "lib-setname";
|
||
const rename = () => {
|
||
const v = nm.value.trim();
|
||
if (!v || v === setName(x)) { nm.value = setName(x); return; }
|
||
change("instructions/save", { rename: { id: x.id, name: v } }, t("Renamed to {name}", { name: v }));
|
||
};
|
||
nm.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") nm.blur(); else if (e.key === "Escape") { nm.value = setName(x); nm.blur(); } };
|
||
nm.onblur = rename;
|
||
const ta = el("textarea", "lib-text small");
|
||
ta.dataset.lib = "set:" + x.id;
|
||
ta.spellcheck = false;
|
||
ta.value = text;
|
||
ta.placeholder = t("What every agent should know — how you like code written, the stack you work in, the commands that run your tests…");
|
||
ta.oninput = () => { texts[x.id] = ta.value; renderSaveBars(); };
|
||
ta.onkeydown = (e) => { e.stopPropagation(); if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); saveInstructions(); } };
|
||
autosize(ta, 150);
|
||
det.append(nm, ta);
|
||
if (x.id !== "default") {
|
||
const acts = el("div", "lib-acts");
|
||
// its text goes with it, so a second click says so and does it
|
||
const sure = removingSet === x.id;
|
||
const rm = button(sure ? t("Remove {name} and its text", { name: setName(x) }) : t("Remove"), sure ? "primary danger-fill" : "action danger", () => {
|
||
if (!sure) { removingSet = x.id; render(); return; }
|
||
removingSet = null;
|
||
delete texts[x.id];
|
||
openSet = null;
|
||
change("instructions/save", { remove: x.id }, t("{name} removed", { name: setName(x) }));
|
||
});
|
||
if (x.active) { rm.disabled = true; rm.title = t("Switch the agents to another set before removing this one"); }
|
||
acts.append(rm);
|
||
if (sure) acts.append(button(t("Cancel"), "", () => { removingSet = null; render(); }));
|
||
det.append(acts);
|
||
}
|
||
det.onclick = (e) => e.stopPropagation();
|
||
return [row, det];
|
||
}
|
||
|
||
function newSetRow() {
|
||
const row = el("div", "row lib-row lib-set");
|
||
const nm = input("", t("Name, e.g. Work"));
|
||
nm.dataset.lib = "newset";
|
||
nm.className = "lib-setname grow";
|
||
const create = async () => {
|
||
const name = nm.value.trim();
|
||
if (!name) return;
|
||
const id = "s" + Date.now().toString(36);
|
||
if (await change("instructions/save", { create: { id, name } }, t("{name} added — switch the agents to it when it's ready", { name }))) {
|
||
addingSet = false;
|
||
openSet = id;
|
||
render();
|
||
}
|
||
};
|
||
const cancel = () => { addingSet = false; render(); };
|
||
nm.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") create(); else if (e.key === "Escape") cancel(); };
|
||
queueMicrotask(() => nm.focus());
|
||
row.append(nm, button(t("Cancel"), "", cancel), button(t("Add"), "primary", create));
|
||
return row;
|
||
}
|
||
|
||
function instructionsRow(a) {
|
||
const isOpen = open.has(a.agent);
|
||
const row = el("div", "row lib-row click" + (isOpen ? " open" : ""));
|
||
const who = el("div", "who");
|
||
const name = el("div", "name", a.name);
|
||
who.append(name);
|
||
const sub = el("div", "sub");
|
||
sub.append(pathLink(a.path));
|
||
who.append(sub);
|
||
const tags = el("div", "lib-tags");
|
||
const extra = extras[a.agent] ?? a.extra;
|
||
if (a.on && extra) tags.append(tag(t("+ its own"), "", t("{agent} gets something of its own after the shared text", { agent: a.name })));
|
||
if (a.edited) tags.append(tag(t("edited in the file"), "warn", t("magpie's part of this file was changed there; magpie leaves it until the library's text changes")));
|
||
if (a.own) tags.append(tag(a.own === 1 ? t("1 line of its own") : t("{n} lines of its own", { n: a.own }), "", t("The file has instructions besides magpie's part — they stay, and the agent reads both")));
|
||
const chev = el("span", "chev");
|
||
chev.append(svg(CHEV_R, 11, 1.7));
|
||
row.append(icon(a.icon), who, tags, toggle(a.on, t("{agent} reads the shared instructions", { agent: a.name }), (on) => {
|
||
const agents = lib.instructions.agents.filter((x) => (x.agent === a.agent ? on : x.on)).map((x) => x.agent);
|
||
let done = t("Taken out of {agent}'s file", { agent: a.name });
|
||
if (on && dirty()) done = t("{agent} is on — save to write the new text into its file", { agent: a.name });
|
||
else if (on && !iv().shared.trim() && !(a.extra || "").trim()) done = t("{agent} is on — it gets the shared instructions once there are some", { agent: a.name });
|
||
else if (on) done = t("{agent} reads the shared instructions now", { agent: a.name });
|
||
change("instructions/save", { agents }, done);
|
||
}), chev);
|
||
row.onclick = () => { if (isOpen) open.delete(a.agent); else open.add(a.agent); render(); };
|
||
if (!isOpen) return [row];
|
||
|
||
const det = el("div", "lib-detail");
|
||
if (a.override) {
|
||
const w = el("div", "lib-warn");
|
||
w.append(el("span", "", t("{agent} reads {file} instead of this file while it has anything in it.", { agent: a.name, file: tilde(a.override) }) + " "), pathLink(a.override));
|
||
det.append(w);
|
||
}
|
||
if (a.agent === "opencode" || a.agent === "mimocode") det.append(el("p", "lib-aside", t("{agent} reads Claude Code's CLAUDE.md when it has no AGENTS.md of its own.", { agent: a.name })));
|
||
const lab = el("label", "lib-lab", t("Only for {agent}, after the shared text", { agent: a.name }));
|
||
const ta = el("textarea", "lib-text small");
|
||
ta.dataset.lib = "extra:" + a.agent;
|
||
ta.spellcheck = false;
|
||
ta.value = extra;
|
||
ta.placeholder = t("Anything only {agent} should be told", { agent: a.name });
|
||
ta.oninput = () => { extras[a.agent] = ta.value; renderSaveBars(); };
|
||
ta.onkeydown = (e) => { e.stopPropagation(); if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); saveInstructions(); } };
|
||
autosize(ta, 64);
|
||
det.append(lab, ta);
|
||
if (!a.on && extra) det.append(el("p", "lib-aside", t("Switch {agent} on for it to read this.", { agent: a.name })));
|
||
const acts = el("div", "lib-acts");
|
||
if (a.own) {
|
||
const b = button(t("Move its own into the shared text"), "action", () => change("instructions/import", { name: a.agent }, t("Moved into the shared instructions — {agent} reads the same as before", { agent: a.name })));
|
||
b.title = t("Takes the {n} lines out of the file, adds them to the shared text, and switches {agent} on — so it reads what it read before, and the other agents can too", { n: a.own, agent: a.name });
|
||
acts.append(b);
|
||
}
|
||
if (a.edited) {
|
||
const b = button(t("Write the library's text again"), "action", () => change("instructions/save", { rewrite: [a.agent] }, t("{agent}'s file has the library's text again", { agent: a.name })));
|
||
b.title = t("Replaces what was changed in magpie's part of the file; the file is kept aside first");
|
||
acts.append(b);
|
||
}
|
||
if (acts.childElementCount) det.append(acts);
|
||
det.onclick = (e) => e.stopPropagation();
|
||
return [row, det];
|
||
}
|
||
|
||
function renderSaveBars() {
|
||
const bar = page.querySelector(".lib-savebar");
|
||
if (bar) bar.hidden = !dirty();
|
||
}
|
||
|
||
function tag(text, kind, title) {
|
||
const s = el("span", "lib-tag " + (kind || ""), text);
|
||
if (title) s.title = title;
|
||
return s;
|
||
}
|
||
|
||
// ---------- MCP servers ----------
|
||
|
||
function serverLine(s) {
|
||
if (s.transport === "stdio") return [s.command, ...(s.args || [])].map(quote).join(" ");
|
||
return s.url;
|
||
}
|
||
function quote(a) { return /^[\w@%+=:,./-]+$/.test(a) ? a : "'" + a.replace(/'/g, `'\\''`) + "'"; }
|
||
|
||
function sseBlocked(s) {
|
||
return (a) => {
|
||
if (remote(s) && a.noRemote) return t("{agent} runs only a command from its settings — add a remote server in its Connectors instead", { agent: a.name });
|
||
return s.transport === "sse" && a.noSSE ? t("{agent} can't reach a server over SSE — only a command or streamable HTTP", { agent: a.name }) : "";
|
||
};
|
||
}
|
||
// reaches(s) says whether an agent can be given the server
|
||
const remote = (s) => s.transport === "http" || s.transport === "sse";
|
||
const reaches = (s) => (a) => !a || (!(remote(s) && a.noRemote) && !(s.transport === "sse" && a.noSSE));
|
||
|
||
function renderServers(body) {
|
||
body.append(intro(t("Add a server once and switch it on for the agents that should have it — magpie writes it into each one's config in the shape that agent reads.")));
|
||
const all = mcpAgents();
|
||
if (!lib.servers.length) {
|
||
const add = button(t("+ Add a server"), "action", () => editServer(null));
|
||
body.append(empty(t("No servers in the library yet"), lib.foundServers.length ? t("Add one, or bring in those your agents already have, below.") : t("Paste a server's JSON from its README, or fill it in."), add));
|
||
} else {
|
||
const list = el("div", "list lib-list");
|
||
for (const s of lib.servers) list.append(serverRow(s, all));
|
||
body.append(list);
|
||
const after = el("div", "after-list");
|
||
after.append(button(t("+ Add server"), "", () => editServer(null)));
|
||
body.append(after);
|
||
}
|
||
const found = lib.foundServers.filter((f) => !f.own), own = lib.foundServers.filter((f) => f.own);
|
||
if (found.length) {
|
||
const rh = el("div", "row-head");
|
||
rh.append(el("span", "label", t("In your agents")), el("span", "grow"), el("span", "note", t("not in the library — bring one in to manage it here")));
|
||
body.append(rh);
|
||
const list = el("div", "list lib-list");
|
||
for (const f of found) list.append(foundServerRow(f));
|
||
body.append(list);
|
||
}
|
||
if (own.length) {
|
||
const by = [...new Set(own.flatMap((f) => f.server.agents))].map(nameOf).join(", ");
|
||
const p = el("p", "lib-aside", t("{agents} adds these itself, each time it starts, and magpie leaves them as they are: {names}", { agents: by, names: own.map((f) => f.server.name).join(", ") }));
|
||
p.title = own.map((f) => f.server.name + ": " + serverLine(f.server)).join("\n");
|
||
body.append(p);
|
||
}
|
||
const skip = shownAgents().filter((a) => !a.mcp);
|
||
if (skip.length) body.append(el("p", "lib-aside", t("{agents} has no MCP servers magpie can write.", { agents: skip.map((a) => a.name).join(", ") })));
|
||
body.append(discover("mcp"));
|
||
}
|
||
|
||
function serverRow(s, all) {
|
||
const row = el("div", "row lib-row click");
|
||
const who = el("div", "who");
|
||
who.append(el("div", "name mono", s.name));
|
||
const sub = el("div", "sub mono", serverLine(s));
|
||
sub.title = serverLine(s);
|
||
who.append(sub);
|
||
row.append(mark(s.icon, s.transport === "stdio" ? GLYPH.cmd : GLYPH.web), who,
|
||
agentChips(all, s.agents, chipsChange("servers/agents", s.name, "servers", (x) => serverRow(x, all)), { problems: s.problems, blocked: sseBlocked(s) }));
|
||
row.onclick = () => editServer(s);
|
||
row.title = t("Edit {name}", { name: s.name });
|
||
return row;
|
||
}
|
||
|
||
function foundServerRow(f) {
|
||
const s = f.server;
|
||
const row = el("div", "row lib-row");
|
||
const who = el("div", "who");
|
||
const nm = el("div", "name mono", s.name);
|
||
who.append(nm);
|
||
const sub = el("div", "sub mono", serverLine(s));
|
||
sub.title = serverLine(s);
|
||
who.append(sub);
|
||
const have = el("div", "lib-have");
|
||
for (const id of s.agents) { const a = agentOf(id); if (a) { const i = icon(a.icon); i.title = a.name; have.append(i); } }
|
||
row.append(mark(f.icon, s.transport === "stdio" ? GLYPH.cmd : GLYPH.web), who, have);
|
||
if (f.others?.length) row.append(tag(t("differs in {agents}", { agents: f.others.map(nameOf).join(", ") }), "warn", t("{agents} has another server by this name; bringing this one in leaves that one as it is", { agents: f.others.map(nameOf).join(", ") })));
|
||
const b = button(t("Bring in"), "action", () => change("servers/import", { name: s.name }, t("{name} is in the library now", { name: s.name })));
|
||
b.title = t("Keeps it in the library: {agents} go on having it, and you can give it to the others", { agents: s.agents.map(nameOf).join(", ") });
|
||
row.append(b);
|
||
return row;
|
||
}
|
||
|
||
// A command line split the way a shell would, quotes and all.
|
||
function words(s) {
|
||
const out = [];
|
||
let cur = "", q = "", any = false;
|
||
for (let i = 0; i < s.length; i++) {
|
||
const c = s[i];
|
||
if (q) {
|
||
if (c === q) q = "";
|
||
else if (c === "\\" && q === '"' && i + 1 < s.length) cur += s[++i];
|
||
else cur += c;
|
||
} else if (c === "'" || c === '"') { q = c; any = true; }
|
||
else if (c === "\\" && i + 1 < s.length) { cur += s[++i]; any = true; }
|
||
else if (/\s/.test(c)) { if (cur || any) out.push(cur); cur = ""; any = false; }
|
||
else cur += c;
|
||
}
|
||
if (cur || any) out.push(cur);
|
||
return out;
|
||
}
|
||
|
||
// The servers in JSON the user pasted: a README's {"mcpServers": {...}},
|
||
// one server's entry, or VS Code's {"servers": {...}}.
|
||
function serversIn(text) {
|
||
let v;
|
||
try { v = JSON.parse(text.trim().replace(/,\s*([}\]])/g, "$1")); } catch {
|
||
try { v = JSON.parse("{" + text.trim().replace(/,\s*$/, "") + "}"); } catch { return null; }
|
||
}
|
||
if (!v || typeof v !== "object") return null;
|
||
const map = v.mcpServers || v.servers || v.mcp || v.context_servers || null;
|
||
const one = (name, e) => {
|
||
if (!e || typeof e !== "object") return null;
|
||
const url = e.url || e.serverUrl || e.httpUrl || e.uri;
|
||
const s = { name: (name || "").replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, ""), env: {}, headers: {} };
|
||
if (url) {
|
||
s.transport = e.type === "sse" || e.transport === "sse" || /\/sse\/?$/.test(url) ? "sse" : "http";
|
||
s.url = url;
|
||
s.headers = { ...(e.headers || e.http_headers || {}) };
|
||
} else if (e.command) {
|
||
s.transport = "stdio";
|
||
const cmd = Array.isArray(e.command) ? e.command : [e.command];
|
||
s.command = String(cmd[0]);
|
||
s.args = [...cmd.slice(1), ...(e.args || [])].map(String);
|
||
s.env = { ...(e.env || e.environment || {}) };
|
||
} else return null;
|
||
return s;
|
||
};
|
||
if (map && typeof map === "object") {
|
||
return Object.entries(map).map(([n, e]) => one(n, e)).filter(Boolean);
|
||
}
|
||
if (v.command || v.url || v.serverUrl || v.httpUrl) { const s = one(v.name || "", v); return s ? [s] : null; }
|
||
const entries = Object.entries(v).filter(([, e]) => e && typeof e === "object" && (e.command || e.url || e.serverUrl || e.httpUrl));
|
||
return entries.length ? entries.map(([n, e]) => one(n, e)).filter(Boolean) : null;
|
||
}
|
||
|
||
// Key–value rows for a server's environment or headers.
|
||
function pairs(obj, keyHint, valHint, onChange) {
|
||
const box = el("div", "lib-pairs");
|
||
const rows = Object.entries(obj || {});
|
||
const emit = () => onChange(Object.fromEntries(rows.filter(([k]) => k.trim()).map(([k, v]) => [k.trim(), v])));
|
||
const draw = () => {
|
||
box.replaceChildren();
|
||
rows.forEach((r, i) => {
|
||
const line = el("div", "lib-pair");
|
||
const k = field2(r[0], keyHint, (v) => { r[0] = v; emit(); });
|
||
const v = field2(r[1], valHint, (x) => { r[1] = x; emit(); });
|
||
const x = button("", "lib-x", () => { rows.splice(i, 1); emit(); draw(); });
|
||
x.append(svg("M4.5 4.5l7 7M11.5 4.5l-7 7", 11, 1.6));
|
||
x.title = t("Remove");
|
||
line.append(k, v, x);
|
||
box.append(line);
|
||
});
|
||
box.append(button(t("+ Add"), "lib-addpair", () => { rows.push(["", ""]); draw(); box.querySelectorAll(".lib-pair input")[rows.length * 2 - 2]?.focus(); }));
|
||
};
|
||
draw();
|
||
return box;
|
||
}
|
||
function field2(value, placeholder, onInput) {
|
||
const i = el("input");
|
||
i.type = "text";
|
||
i.value = value || "";
|
||
i.placeholder = placeholder || "";
|
||
i.spellcheck = false;
|
||
i.autocomplete = "off";
|
||
i.oninput = () => onInput(i.value);
|
||
i.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Escape") closeLibModal(); else if (e.key === "Enter" && modal?.save) { e.preventDefault(); modal.save(); } };
|
||
return i;
|
||
}
|
||
|
||
function editServer(s, prefill) {
|
||
const all = mcpAgents();
|
||
const d = prefill || (s ? structuredClone(s) : { name: "", transport: "stdio", command: "", args: [], env: {}, url: "", headers: {}, agents: all.map((a) => a.id) });
|
||
if (!d.env) d.env = {};
|
||
if (!d.headers) d.headers = {};
|
||
const ed = el("div", "editor lib-editor");
|
||
const head = el("div", "ehead");
|
||
head.append(mark(s?.icon, d.transport === "stdio" ? GLYPH.cmd : GLYPH.web), el("b", "", s ? s.name : t("New MCP server")));
|
||
if (!s) head.append(el("span", "note", t("or paste its JSON anywhere here")));
|
||
ed.append(head);
|
||
const err = el("div", "editor-error");
|
||
|
||
const name = field2(d.name, t("e.g. github"), (v) => { d.name = v.trim(); });
|
||
ed.append(...field(t("Name"), name, t("Letters, digits, - and _ — what the agents call it")));
|
||
const kindOf = () => segs([["stdio", t("Command")], ["http", "HTTP"], ["sse", "SSE"]], d.transport, (v) => { d.transport = v; draw(); });
|
||
const kindBox = el("div");
|
||
kindBox.append(kindOf());
|
||
ed.append(...field(t("Runs as"), kindBox));
|
||
const slot = el("div", "lib-slot");
|
||
ed.append(slot);
|
||
const agentsBox = el("div");
|
||
ed.append(...field(t("Agents"), agentsBox));
|
||
ed.append(err);
|
||
|
||
function drawAgents() {
|
||
agentsBox.replaceChildren(agentChips(all, d.agents, (next) => { d.agents = next; drawAgents(); }, { names: true, blocked: sseBlocked(d) }));
|
||
}
|
||
function draw() {
|
||
slot.replaceChildren();
|
||
const g = el("div", "lib-grid");
|
||
if (d.transport === "stdio") {
|
||
const line = field2([d.command, ...(d.args || [])].filter((x, i) => i > 0 || x).map(quote).join(" "), "npx -y @modelcontextprotocol/server-github", (v) => { const w = words(v); d.command = w[0] || ""; d.args = w.slice(1); });
|
||
line.classList.add("mono");
|
||
g.append(...field(t("Command"), line, t("The command and its arguments, as you'd type them")));
|
||
g.append(...field(t("Environment"), pairs(d.env, "GITHUB_TOKEN", t("value"), (v) => { d.env = v; })));
|
||
} else {
|
||
const url = field2(d.url, "https://example.com/mcp", (v) => { d.url = v.trim(); });
|
||
url.classList.add("mono");
|
||
g.append(...field("URL", url));
|
||
g.append(...field(t("Headers"), pairs(d.headers, "Authorization", "Bearer …", (v) => { d.headers = v; })));
|
||
}
|
||
slot.append(g);
|
||
// its own icon while it runs as it did; another way of running is another server
|
||
head.firstChild.replaceWith(mark(s && d.transport === s.transport ? s.icon : "", d.transport === "stdio" ? GLYPH.cmd : GLYPH.web));
|
||
drawAgents();
|
||
}
|
||
draw();
|
||
|
||
const bar = el("div", "bar");
|
||
if (s) bar.append(button(t("Remove from the library"), "danger", async () => {
|
||
if (await change("servers/remove", { name: s.name }, t("{name} is out of the library and the agents it was given to", { name: s.name }))) closeLibModal();
|
||
}));
|
||
bar.append(el("span", "grow"), button(t("Cancel"), "", closeLibModal));
|
||
const save = async () => {
|
||
err.textContent = "";
|
||
const body = { old: s ? s.name : "", server: { name: d.name, transport: d.transport, agents: d.agents } };
|
||
if (d.transport === "stdio") Object.assign(body.server, { command: d.command, args: d.args, env: d.env });
|
||
else Object.assign(body.server, { url: d.url, headers: d.headers });
|
||
try {
|
||
lib = await api("library/servers/save", body);
|
||
report(lib.result, s ? t("{name} saved", { name: d.name }) : "");
|
||
closeLibModal();
|
||
render();
|
||
} catch (e) { err.textContent = e.message; }
|
||
};
|
||
const ok = button(s ? t("Save") : t("Add"), "primary", save);
|
||
bar.append(ok);
|
||
ed.append(bar);
|
||
|
||
// a README's JSON pasted anywhere in the dialog fills it in
|
||
ed.addEventListener("paste", (e) => {
|
||
const text = e.clipboardData?.getData("text") || "";
|
||
if (!/[{]/.test(text)) return;
|
||
const found = serversIn(text);
|
||
if (!found?.length) return;
|
||
e.preventDefault();
|
||
if (found.length === 1) {
|
||
const f = found[0];
|
||
Object.assign(d, { transport: f.transport, command: f.command || "", args: f.args || [], env: f.env || {}, url: f.url || "", headers: f.headers || {} });
|
||
if (f.name && (!d.name || !s)) d.name = f.name;
|
||
name.value = d.name;
|
||
kindBox.replaceChildren(kindOf());
|
||
draw();
|
||
status(t("Filled in from what you pasted"), "ok");
|
||
} else pasteMany(found, d.agents);
|
||
});
|
||
modal = { save };
|
||
openLib(ed);
|
||
if (!s) requestAnimationFrame(() => name.focus());
|
||
}
|
||
|
||
// Several servers pasted at once: pick those to add.
|
||
function pasteMany(found, agents) {
|
||
const all = mcpAgents();
|
||
const have = new Set(lib.servers.map((s) => s.name));
|
||
const pick = new Set(found.filter((f) => f.name && !have.has(f.name)).map((f) => f.name));
|
||
let who = [...agents];
|
||
const ed = el("div", "editor lib-editor");
|
||
const head = el("div", "ehead");
|
||
head.append(glyph(GLYPH.cmd), el("b", "", t("{n} servers in what you pasted", { n: found.length })));
|
||
ed.append(head);
|
||
const list = el("div", "lib-picks");
|
||
for (const f of found) {
|
||
const r = el("label", "lib-pick" + (have.has(f.name) ? " dim" : ""));
|
||
const c = el("input");
|
||
c.type = "checkbox";
|
||
c.checked = pick.has(f.name);
|
||
c.disabled = !f.name || have.has(f.name);
|
||
c.onchange = () => { if (c.checked) pick.add(f.name); else pick.delete(f.name); go.textContent = label(); go.disabled = !pick.size; };
|
||
const w = el("span", "who");
|
||
w.append(el("span", "name mono", f.name || t("(no name)")), el("span", "sub mono", serverLine(f)));
|
||
r.append(c, w);
|
||
if (have.has(f.name)) r.append(tag(t("in the library"), ""));
|
||
list.append(r);
|
||
}
|
||
ed.append(list);
|
||
const agentsBox = el("div");
|
||
const drawAgents = () => agentsBox.replaceChildren(agentChips(all, who, (n) => { who = n; drawAgents(); }, { names: true }));
|
||
drawAgents();
|
||
ed.append(...field(t("Agents"), agentsBox));
|
||
const err = el("div", "editor-error");
|
||
ed.append(err);
|
||
const bar = el("div", "bar");
|
||
const label = () => (pick.size === 1 ? t("Add 1 server") : t("Add {n} servers", { n: pick.size }));
|
||
const go = button(label(), "primary", async () => {
|
||
go.disabled = true;
|
||
let last = null;
|
||
for (const f of found.filter((x) => pick.has(x.name))) {
|
||
try {
|
||
last = await api("library/servers/save", { old: "", server: { ...f, agents: who.filter((id) => reaches(f)(agentOf(id))) } });
|
||
} catch (e) { err.textContent = f.name + ": " + e.message; go.disabled = false; if (last) { lib = last; render(); } return; }
|
||
}
|
||
lib = last;
|
||
report(lib.result, t("Added {n} servers", { n: pick.size }));
|
||
closeLibModal();
|
||
render();
|
||
});
|
||
go.disabled = !pick.size;
|
||
bar.append(el("span", "grow"), button(t("Cancel"), "", closeLibModal), go);
|
||
ed.append(bar);
|
||
modal = { save: () => go.click() };
|
||
openLib(ed);
|
||
}
|
||
|
||
// ---------- skills ----------
|
||
|
||
function renderSkills(body) {
|
||
body.append(intro(t("A skill is a folder with a SKILL.md an agent loads when it's needed. The library keeps each one once and links it into the agents you pick.")));
|
||
body.append(installCard());
|
||
const all = skillAgents();
|
||
if (lib.skills.length) {
|
||
const rh = el("div", "row-head");
|
||
rh.append(el("span", "label", t("In the library")));
|
||
const fresh = lib.skills.filter((s) => s.kind === "github" || s.origin);
|
||
if (fresh.length > 1) {
|
||
const u = button(t("Update all"), "action lib-updall", async (e, b) => {
|
||
b.classList.add("busy");
|
||
b.textContent = t("Updating…");
|
||
await updateAllSkills();
|
||
b.classList.remove("busy");
|
||
b.textContent = t("Update all");
|
||
});
|
||
u.title = t("Fetch the {n} skills from GitHub again", { n: fresh.length });
|
||
rh.append(el("span", "grow"), u);
|
||
}
|
||
body.append(rh);
|
||
const list = el("div", "list lib-list");
|
||
for (const s of lib.skills) list.append(skillRow(s, all));
|
||
body.append(list);
|
||
}
|
||
if (lib.foundSkills.length) {
|
||
const rh = el("div", "row-head");
|
||
rh.append(el("span", "label", t("In your agents")), el("span", "grow"), el("span", "note", t("not in the library — bring one in to give it to the others")));
|
||
body.append(rh);
|
||
const list = el("div", "list lib-list");
|
||
for (const f of lib.foundSkills) list.append(foundSkillRow(f));
|
||
body.append(list);
|
||
}
|
||
const skip = shownAgents().filter((a) => !a.skills);
|
||
if (skip.length) body.append(el("p", "lib-aside", t("{agents} has no skills folder.", { agents: skip.map((a) => a.name).join(", ") })));
|
||
body.append(discover("skills"));
|
||
}
|
||
|
||
// Every skill from GitHub, fetched again; the ones that couldn't be are
|
||
// said, and the rest are updated all the same.
|
||
async function updateAllSkills() {
|
||
try {
|
||
const v = await api("library/skills/update-all", {});
|
||
lib = v;
|
||
const res = v.result || {};
|
||
const up = res.updated?.length || 0, no = res.unupdated || [];
|
||
if (no.length) {
|
||
const p = no[0];
|
||
status(t("{name} wasn't updated: {error}", { name: p.what.replace(/^skill:/, ""), error: p.error }) + (no.length > 1 ? " " + t("(and {n} more)", { n: no.length - 1 }) : "") + (up ? " · " + t("{n} up to date", { n: up }) : ""), "warn", 8000);
|
||
} else report(res, t("{n} skills up to date", { n: up }));
|
||
render();
|
||
} catch (e) {
|
||
status(e.message, "err", 6000);
|
||
}
|
||
}
|
||
|
||
// Claude Code's skills are OpenCode's and Crush's too: a chip for one of
|
||
// those says so while it has none of its own.
|
||
function viaFor(s) {
|
||
return (a) => {
|
||
const also = lib.agents.find((x) => x.id === a.id)?.skillsAlso || [];
|
||
const other = also.find((id) => s.agents.includes(id));
|
||
return other ? nameOf(other) : "";
|
||
};
|
||
}
|
||
|
||
function installCard() {
|
||
const card = el("div", "list lib-card lib-install");
|
||
const line = el("div", "lib-find");
|
||
const inp = el("input");
|
||
inp.type = "text";
|
||
inp.dataset.lib = "source";
|
||
inp.placeholder = t("owner/repo, a GitHub link, or a folder on this computer");
|
||
inp.spellcheck = false;
|
||
inp.autocomplete = "off";
|
||
inp.value = probe?.source || "";
|
||
const go = button(probing ? t("Looking…") : t("Find skills"), "action", () => find());
|
||
go.disabled = probing;
|
||
if (probing) go.classList.add("busy");
|
||
inp.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") find(); else if (e.key === "Escape" && probe) { probe = null; render(); } };
|
||
const g = glyph(GLYPH.search, "lib-mini");
|
||
line.append(g, inp, go);
|
||
card.append(line);
|
||
async function find() {
|
||
const src = inp.value.trim();
|
||
if (!src || probing) return;
|
||
probing = true;
|
||
probe = { source: src };
|
||
render();
|
||
try {
|
||
const p = await api("library/skills/probe", { source: src });
|
||
const fresh = p.candidates.filter((c) => !c.have);
|
||
probe = { source: src, found: p, pick: new Set((fresh.length <= 3 ? fresh : []).map((c) => c.path)), agents: skillAgents().map((a) => a.id), filter: "" };
|
||
if (!p.candidates.length) status(t("No SKILL.md found there"), "warn");
|
||
} catch (e) {
|
||
probe = { source: src, error: e.message };
|
||
}
|
||
probing = false;
|
||
render();
|
||
}
|
||
if (probe?.error) card.append(el("div", "lib-err", probe.error));
|
||
if (probe?.found) card.append(probeResult(probe));
|
||
return card;
|
||
}
|
||
|
||
function probeResult(p) {
|
||
const box = el("div", "lib-probe");
|
||
const c = p.found.candidates;
|
||
if (!c.length) { box.append(el("div", "lib-none", t("No skills there — a skill is a folder with a SKILL.md in it."))); return box; }
|
||
const head = el("div", "lib-probehead");
|
||
const src = p.found.kind === "github" ? "github.com/" + p.found.source.replace(/^https?:\/\/github\.com\//, "") : tilde(p.found.source);
|
||
head.append(el("span", "note", c.length === 1 ? t("1 skill in {src}", { src }) : t("{n} skills in {src}", { n: c.length, src })));
|
||
head.append(el("span", "grow"));
|
||
const fresh = c.filter((x) => !x.have);
|
||
if (fresh.length > 1) {
|
||
const allOn = fresh.every((x) => p.pick.has(x.path));
|
||
head.append(button(allOn ? t("Select none") : t("Select all"), "", () => { for (const x of fresh) allOn ? p.pick.delete(x.path) : p.pick.add(x.path); render(); }));
|
||
}
|
||
box.append(head);
|
||
if (c.length > 8) {
|
||
const f = el("input", "lib-filter");
|
||
f.type = "text";
|
||
f.dataset.lib = "filter";
|
||
f.placeholder = t("Filter…");
|
||
f.value = p.filter;
|
||
f.oninput = () => { p.filter = f.value; drawList(); };
|
||
f.onkeydown = (e) => e.stopPropagation();
|
||
box.append(f);
|
||
}
|
||
const list = el("div", "lib-picks");
|
||
const drawList = () => {
|
||
list.replaceChildren();
|
||
const q = p.filter.toLowerCase();
|
||
for (const x of c) {
|
||
if (q && !(x.name + " " + x.description).toLowerCase().includes(q)) continue;
|
||
const r = el("label", "lib-pick" + (x.have ? " dim" : ""));
|
||
const cb = el("input");
|
||
cb.type = "checkbox";
|
||
cb.checked = p.pick.has(x.path);
|
||
cb.disabled = x.have;
|
||
cb.onchange = () => { if (cb.checked) p.pick.add(x.path); else p.pick.delete(x.path); syncGo(); };
|
||
const w = el("span", "who");
|
||
w.append(el("span", "name", x.name), el("span", "sub", x.description || x.path || ""));
|
||
r.append(cb, w);
|
||
if (x.have) r.append(tag(t("in the library"), ""));
|
||
list.append(r);
|
||
}
|
||
};
|
||
drawList();
|
||
box.append(list);
|
||
const foot = el("div", "lib-probefoot");
|
||
const agentsBox = el("div");
|
||
const drawAgents = () => agentsBox.replaceChildren(agentChips(skillAgents(), p.agents, (n) => { p.agents = n; drawAgents(); }, { names: false }));
|
||
drawAgents();
|
||
foot.append(el("span", "note", t("for")), agentsBox, el("span", "grow"));
|
||
foot.append(button(t("Cancel"), "", () => { probe = null; render(); }));
|
||
const go = button("", "primary", async () => {
|
||
go.disabled = true;
|
||
go.textContent = t("Installing…");
|
||
const n = p.pick.size;
|
||
if (await change("skills/install", { source: p.source, paths: [...p.pick], agents: p.agents }, n === 1 ? t("1 skill installed") : t("{n} skills installed", { n }))) { probe = null; render(); }
|
||
else { go.disabled = false; syncGo(); }
|
||
});
|
||
const syncGo = () => { const n = p.pick.size; go.textContent = n === 1 ? t("Install 1 skill") : t("Install {n} skills", { n }); go.disabled = !n; };
|
||
syncGo();
|
||
foot.append(go);
|
||
box.append(foot);
|
||
return box;
|
||
}
|
||
|
||
function skillRow(s, all) {
|
||
const row = el("div", "row lib-row click lib-skill" + (s.missing ? " missing" : ""));
|
||
const who = el("div", "who");
|
||
const nm = el("div", "name", s.name);
|
||
who.append(nm);
|
||
const sub = el("div", "sub", s.missing ? t("Its folder is gone from the library") : s.description || "");
|
||
sub.title = s.description || "";
|
||
who.append(sub);
|
||
if (s.source) {
|
||
const src = el("div", "lib-src");
|
||
if (s.kind === "github") {
|
||
const a = el("a", "lib-srclink", s.source.replace(/^https:\/\/github\.com\//, ""));
|
||
a.href = s.source;
|
||
a.onclick = (e) => { e.preventDefault(); e.stopPropagation(); browse(s.source); };
|
||
src.append(a);
|
||
} else {
|
||
src.append(el("span", "", t("linked from")), pathLink(s.source));
|
||
}
|
||
who.append(src);
|
||
}
|
||
const acts = el("div", "lib-rowacts");
|
||
if (s.kind === "github" || s.origin) {
|
||
const u = button("", "lib-icon", async (e, b) => {
|
||
b.classList.add("busy");
|
||
await change("skills/update", { name: s.name }, t("{name} is up to date", { name: s.name }));
|
||
b.classList.remove("busy");
|
||
});
|
||
u.append(svg(GLYPH.up, 13, 1.5));
|
||
u.title = s.origin ? t("Update from GitHub ({repo}, as CC Switch installed it)", { repo: s.origin.replace(/^https:\/\/github\.com\//, "") }) : t("Update from GitHub");
|
||
acts.append(u);
|
||
}
|
||
const rm = button("", "lib-icon danger", () => confirmRemoveSkill(s));
|
||
rm.append(svg(GLYPH.trash, 13, 1.4));
|
||
rm.title = t("Remove");
|
||
acts.append(rm);
|
||
row.append(mark(s.icon, GLYPH.skill), who, acts, agentChips(all, s.agents, chipsChange("skills/agents", s.name, "skills", (x) => skillRow(x, all)), { problems: s.problems, via: viaFor(s) }));
|
||
row.onclick = () => viewSkill(s);
|
||
row.title = t("Read {name}'s SKILL.md", { name: s.name });
|
||
return row;
|
||
}
|
||
|
||
function confirmRemoveSkill(s) {
|
||
const ed = el("div", "editor lib-editor");
|
||
const head = el("div", "ehead");
|
||
head.append(glyph(GLYPH.trash), el("b", "", t("Remove {name}?", { name: s.name })));
|
||
ed.append(head);
|
||
ed.append(el("p", "lib-confirm", s.kind === "folder"
|
||
? t("It is taken out of every agent it was given to. The folder it was linked from stays where it is.")
|
||
: t("It is taken out of every agent it was given to, and its folder is moved to magpie's backups.")));
|
||
const bar = el("div", "bar");
|
||
const go = button(t("Remove"), "primary danger-fill", async () => { if (await change("skills/remove", { name: s.name }, t("{name} removed", { name: s.name }))) closeLibModal(); });
|
||
bar.append(el("span", "grow"), button(t("Cancel"), "", closeLibModal), go);
|
||
ed.append(bar);
|
||
modal = { save: () => go.click() };
|
||
openLib(ed);
|
||
}
|
||
|
||
async function viewSkill(s) {
|
||
const ed = el("div", "editor lib-editor lib-reader");
|
||
const head = el("div", "ehead");
|
||
head.append(mark(s.icon, GLYPH.skill), el("b", "", s.name), el("span", "grow"));
|
||
const pre = el("pre", "lib-md", "…");
|
||
ed.append(head, pre);
|
||
const bar = el("div", "bar");
|
||
const where = el("span", "note");
|
||
bar.append(where, el("span", "grow"), button(t("Close"), "", closeLibModal));
|
||
ed.append(bar);
|
||
modal = {};
|
||
openLib(ed);
|
||
try {
|
||
const r = await api("library/skill?name=" + encodeURIComponent(s.name));
|
||
pre.textContent = r.text;
|
||
where.replaceChildren(pathLink(r.path));
|
||
} catch (e) { pre.textContent = e.message; }
|
||
}
|
||
|
||
function foundSkillRow(f) {
|
||
const row = el("div", "row lib-row");
|
||
const who = el("div", "who");
|
||
who.append(el("div", "name", f.name));
|
||
const sub = el("div", "sub", f.description || "");
|
||
sub.title = f.description || "";
|
||
who.append(sub);
|
||
if (f.link) { const src = el("div", "lib-src"); src.append(el("span", "", t("linked from")), pathLink(f.link)); who.append(src); }
|
||
const have = el("div", "lib-have");
|
||
for (const id of f.agents) { const a = agentOf(id); if (a) { const i = icon(a.icon); i.title = a.name; have.append(i); } }
|
||
row.append(glyph(GLYPH.skill), who, have);
|
||
if (f.others?.length) row.append(tag(t("differs in {agents}", { agents: f.others.map(nameOf).join(", ") }), "warn", t("{agents} has another skill by this name; bringing this one in leaves that one as it is", { agents: f.others.map(nameOf).join(", ") })));
|
||
const b = button(t("Bring in"), "action", () => change("skills/import", { name: f.name }, t("{name} is in the library now", { name: f.name })));
|
||
b.title = f.link ? t("Keeps a link to where it is: {agents} go on having it, and you can give it to the others", { agents: f.agents.map(nameOf).join(", ") })
|
||
: t("Moves it into the library and links it back: {agents} go on having it, and you can give it to the others", { agents: f.agents.map(nameOf).join(", ") });
|
||
row.append(b);
|
||
return row;
|
||
}
|
||
|
||
// ---------- the market ----------
|
||
|
||
// What the market offers: popular and featured ones until the reader
|
||
// searches. Kept across renders; asked for the first time a tab shows it.
|
||
const market = {
|
||
mcp: { q: "", items: null, error: "", loading: false, seq: 0, timer: 0 },
|
||
skills: { q: "", items: null, error: "", loading: false, seq: 0, timer: 0 },
|
||
};
|
||
const asked = new Set(); // skills whose description has been asked for
|
||
|
||
async function fetchMarket(kind) {
|
||
const m = market[kind];
|
||
const seq = ++m.seq;
|
||
m.loading = true;
|
||
drawMarket(kind);
|
||
try {
|
||
const r = await api("library/market/" + (kind === "mcp" ? "servers" : "skills") + "?q=" + encodeURIComponent(m.q.trim()));
|
||
if (seq !== m.seq) return;
|
||
m.items = r.items || [];
|
||
m.error = r.error || "";
|
||
} catch (e) {
|
||
if (seq !== m.seq) return;
|
||
m.items = m.items || [];
|
||
m.error = e.message;
|
||
}
|
||
m.loading = false;
|
||
drawMarket(kind);
|
||
if (kind === "skills") describeSkills();
|
||
// The market reads the library as it is now; one it calls added that
|
||
// this page doesn't list was added elsewhere — another window, the CLI.
|
||
const mine = new Set((kind === "mcp" ? lib?.servers : lib?.skills)?.map((x) => x.name) || []);
|
||
if (lib && m.items.some((x) => x.have && !mine.has(x.have))) quietLoad();
|
||
}
|
||
|
||
// The descriptions skills.sh doesn't list, read from each SKILL.md.
|
||
async function describeSkills() {
|
||
const ids = (market.skills.items || []).filter((x) => !x.description && !asked.has(x.id)).map((x) => x.id).slice(0, 60);
|
||
if (!ids.length) return;
|
||
ids.forEach((id) => asked.add(id));
|
||
try {
|
||
const about = await api("library/market/about", { ids });
|
||
for (const x of market.skills.items || []) if (about[x.id]) x.description = about[x.id];
|
||
for (const [id, d] of Object.entries(about)) {
|
||
const p = page.querySelector(`.mk-card[data-id="${CSS.escape(id)}"] .mk-desc`);
|
||
if (p && d) { p.textContent = d; p.title = d; p.classList.remove("wait"); }
|
||
}
|
||
} catch {}
|
||
for (const p of page.querySelectorAll(".mk-desc.wait")) { p.classList.remove("wait"); p.textContent = ""; }
|
||
}
|
||
|
||
function discover(kind) {
|
||
const m = market[kind];
|
||
const box = el("section", "mk");
|
||
box.dataset.market = kind;
|
||
const rh = el("div", "row-head");
|
||
rh.append(el("span", "label", t("Discover")), el("span", "grow"));
|
||
const note = el("span", "note mk-note");
|
||
rh.append(note);
|
||
box.append(rh);
|
||
const find = el("div", "mk-find");
|
||
const inp = el("input");
|
||
inp.type = "search";
|
||
inp.dataset.lib = "market-" + kind;
|
||
inp.placeholder = kind === "mcp" ? t("Search MCP servers — GitHub, Postgres, Figma…") : t("Search skills on skills.sh — react, pdf, testing…");
|
||
inp.spellcheck = false;
|
||
inp.autocomplete = "off";
|
||
inp.value = m.q;
|
||
inp.oninput = () => {
|
||
m.q = inp.value;
|
||
clearTimeout(m.timer);
|
||
const q = m.q.trim();
|
||
if (kind === "skills" && q.length === 1) return;
|
||
m.timer = setTimeout(() => fetchMarket(kind), 320);
|
||
};
|
||
inp.onkeydown = (e) => {
|
||
e.stopPropagation();
|
||
if (e.key === "Escape" && inp.value) { e.preventDefault(); inp.value = ""; inp.oninput(); }
|
||
else if (e.key === "Enter") { clearTimeout(m.timer); fetchMarket(kind); }
|
||
};
|
||
find.append(glyph(GLYPH.search, "lib-mini"), inp, el("span", "mk-spin"));
|
||
box.append(find);
|
||
box.append(el("div", "mk-grid"));
|
||
drawMarket(kind, box); // now, so the page keeps its scroll across a render
|
||
if (!m.items && !m.loading) queueMicrotask(() => fetchMarket(kind));
|
||
return box;
|
||
}
|
||
|
||
function drawMarket(kind, box = page.querySelector(`.mk[data-market="${kind}"]`)) {
|
||
if (!box) return;
|
||
const m = market[kind];
|
||
const q = m.q.trim();
|
||
box.classList.toggle("loading", m.loading);
|
||
const note = box.querySelector(".mk-note");
|
||
note.textContent = q && m.items ? (m.items.length === 1 ? t("1 result") : t("{n} results", { n: m.items.length }))
|
||
: kind === "mcp" ? t("Featured, and the official MCP Registry") : t("Popular on skills.sh");
|
||
const grid = box.querySelector(".mk-grid");
|
||
grid.replaceChildren();
|
||
if (!m.items) {
|
||
for (let i = 0; i < 6; i++) {
|
||
const c = el("div", "mk-card mk-skel");
|
||
const top = el("div", "mk-top");
|
||
const lines = el("div", "mk-who");
|
||
const a = el("span", "skeleton"); a.style.cssText = "width:55%;height:10px";
|
||
const b = el("span", "skeleton"); b.style.cssText = "width:35%;height:8px";
|
||
lines.append(a, b);
|
||
top.append(el("span", "mk-icon skeleton"), lines);
|
||
const d = el("span", "skeleton"); d.style.cssText = "width:90%;height:8px;margin-top:4px";
|
||
c.append(top, d);
|
||
grid.append(c);
|
||
}
|
||
return;
|
||
}
|
||
if (m.error) grid.append(el("div", "mk-msg err", m.error));
|
||
if (!m.items.length && !m.error) {
|
||
grid.append(el("div", "mk-msg", q ? t("Nothing matches “{q}”.", { q }) : t("Nothing to show.")));
|
||
return;
|
||
}
|
||
for (const x of m.items) grid.append(kind === "mcp" ? serverCard(x) : skillCard(x));
|
||
}
|
||
|
||
// A real picture: the project's own logo or its owner's, through magpie so
|
||
// it's cached; its initial on a tile when there's none.
|
||
function logo(url, name) {
|
||
const box = el("span", "mk-icon");
|
||
const fallback = () => { box.replaceChildren(el("span", "mk-mono", (name || "?").replace(/^[^a-z0-9]+/i, "").charAt(0).toUpperCase() || "?")); box.classList.add("mono"); };
|
||
if (!url) { fallback(); return box; }
|
||
const img = el("img");
|
||
img.alt = "";
|
||
img.decoding = "async";
|
||
img.loading = "lazy";
|
||
img.referrerPolicy = "no-referrer";
|
||
img.src = "/api/library/icon?u=" + encodeURIComponent(url);
|
||
if (/\.svg(\?|$)/i.test(url)) box.classList.add("svg");
|
||
img.onerror = fallback;
|
||
box.append(img);
|
||
return box;
|
||
}
|
||
|
||
function compact(n) {
|
||
if (n >= 1e6) return (n / 1e6).toFixed(n >= 1e7 ? 0 : 1).replace(/\.0$/, "") + "M";
|
||
if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e4 ? 0 : 1).replace(/\.0$/, "") + "K";
|
||
return String(n);
|
||
}
|
||
|
||
function runsLabel(x) {
|
||
if (x.transport !== "stdio") return x.signIn ? t("Remote · sign-in") : t("Remote");
|
||
return x.runs || t("Local");
|
||
}
|
||
const needsKey = (x) => x.inputs?.some((i) => i.required);
|
||
// the featured servers' words are magpie's own, so they're translated
|
||
const about = (x) => (x.featured ? t(x.description) : x.description) || "";
|
||
|
||
function addButton(x, onAdd) {
|
||
if (x.have) {
|
||
const b = el("span", "mk-have");
|
||
b.append(svg(CHECK, 11, 2), el("span", "", t("Added")));
|
||
b.title = x.have === x.name ? t("In the library") : t("In the library as {name}", { name: x.have });
|
||
return b;
|
||
}
|
||
const b = button(t("Add"), "action mk-add", async (e, btn) => {
|
||
btn.disabled = true;
|
||
btn.classList.add("busy");
|
||
btn.textContent = t("Adding…");
|
||
const ok = await onAdd();
|
||
if (!ok && btn.isConnected) { btn.disabled = false; btn.classList.remove("busy"); btn.textContent = t("Add"); }
|
||
});
|
||
return b;
|
||
}
|
||
|
||
function serverCard(x) {
|
||
const c = el("div", "mk-card click" + (x.have ? " have" : ""));
|
||
c.dataset.id = x.id;
|
||
const top = el("div", "mk-top");
|
||
const who = el("div", "mk-who");
|
||
const nm = el("div", "mk-name");
|
||
nm.append(el("span", "mk-title", x.title || x.name));
|
||
who.append(nm);
|
||
const meta = el("div", "mk-meta");
|
||
if (x.publisher) meta.append(el("span", "mk-pub", x.publisher));
|
||
meta.append(el("span", "mk-badge", runsLabel(x)));
|
||
if (needsKey(x)) { const k = el("span", "mk-badge mk-needs"); k.append(svg(GLYPH.key, 11, 1.5)); k.title = t("Needs {what} to add", { what: x.inputs.filter((i) => i.required).map((i) => t(i.label)).join(", ") }); meta.append(k); }
|
||
who.append(meta);
|
||
top.append(logo(x.icon, x.title || x.name), who);
|
||
const d = el("p", "mk-desc", about(x));
|
||
d.title = about(x);
|
||
const foot = el("div", "mk-foot");
|
||
foot.append(el("span", "mk-id mono", x.name), el("span", "grow"), needsKey(x) && !x.have ? button(t("Add…"), "action mk-add", () => serverSheet(x)) : addButton(x, () => addServer(x, {}, null)));
|
||
c.append(top, d, foot);
|
||
c.onclick = () => serverSheet(x);
|
||
c.title = t("About {name}", { name: x.title || x.name });
|
||
return c;
|
||
}
|
||
|
||
async function addServer(x, values, agents) {
|
||
const body = { id: x.id, values };
|
||
if (agents) body.agents = agents;
|
||
const ok = await change("market/server", body, t("{name} is in the library now", { name: x.title || x.name }));
|
||
if (ok) { x.have = x.name; drawMarket("mcp"); fetchMarket("mcp"); }
|
||
return ok;
|
||
}
|
||
|
||
// A market server up close: what it is, what it needs, and who gets it.
|
||
function serverSheet(x) {
|
||
const all = mcpAgents();
|
||
let agents = all.filter(reaches(x)).map((a) => a.id);
|
||
const values = {};
|
||
const ed = el("div", "editor lib-editor mk-sheet");
|
||
const head = el("div", "mk-sheethead");
|
||
const who = el("div", "mk-who");
|
||
who.append(el("div", "mk-name big", x.title || x.name));
|
||
const meta = el("div", "mk-meta");
|
||
if (x.publisher) meta.append(el("span", "mk-pub", x.publisher));
|
||
meta.append(el("span", "mk-badge", runsLabel(x)));
|
||
who.append(meta);
|
||
head.append(logo(x.icon, x.title || x.name), who);
|
||
if (x.homepage) head.append(extLink(x.homepage, t("Homepage")));
|
||
ed.append(head);
|
||
if (x.description) ed.append(el("p", "mk-about", about(x)));
|
||
if (x.signIn) ed.append(el("p", "mk-hint", t("Each agent asks you to sign in, in the browser, the first time it uses it.")));
|
||
const firsts = [];
|
||
for (const i of x.inputs || []) {
|
||
const f = field2("", i.placeholder || (i.where === "env" ? i.key : ""), (v) => { values[i.key] = v.trim(); });
|
||
if (i.secret) f.type = "password";
|
||
f.classList.add("mono");
|
||
f.dataset.key = i.key;
|
||
firsts.push(f);
|
||
const hint = [i.description && t(i.description), i.where === "env" ? t("Set as {key}", { key: i.key }) : i.where === "header" ? t("Sent as the {key} header", { key: i.key }) : ""].filter(Boolean).join(" · ");
|
||
ed.append(...field(t(i.label) + (i.required ? "" : " " + t("(optional)")), f, hint));
|
||
}
|
||
const agentsBox = el("div");
|
||
const drawAgents = () => agentsBox.replaceChildren(agentChips(all, agents, (n) => { agents = n; drawAgents(); }, { names: true, blocked: sseBlocked(x) }));
|
||
drawAgents();
|
||
ed.append(...field(t("Agents"), agentsBox));
|
||
const err = el("div", "editor-error");
|
||
ed.append(err);
|
||
const bar = el("div", "bar");
|
||
bar.append(el("span", "mk-id mono", x.name), el("span", "grow"), button(t(x.have ? "Close" : "Cancel"), "", closeLibModal));
|
||
let save = () => {};
|
||
if (!x.have) {
|
||
const go = button(t("Add to the library"), "primary", async () => {
|
||
err.textContent = "";
|
||
const miss = (x.inputs || []).find((i) => i.required && !values[i.key]);
|
||
if (miss) { err.textContent = t("{what} is needed", { what: t(miss.label) }); ed.querySelector(`[data-key="${CSS.escape(miss.key)}"]`)?.focus(); return; }
|
||
go.disabled = true;
|
||
go.textContent = t("Adding…");
|
||
if (await addServer(x, values, agents)) closeLibModal();
|
||
else { go.disabled = false; go.textContent = t("Add to the library"); }
|
||
});
|
||
bar.append(go);
|
||
save = () => go.click();
|
||
} else bar.prepend(el("span", "mk-have", t("In the library as {name}", { name: x.have })));
|
||
ed.append(bar);
|
||
modal = { save };
|
||
openLib(ed);
|
||
if (firsts.length) requestAnimationFrame(() => firsts[0].focus());
|
||
}
|
||
|
||
function extLink(href, text) {
|
||
const a = el("a", "mk-ext");
|
||
a.href = href;
|
||
a.append(el("span", "", text), svg(GLYPH.out, 11, 1.5));
|
||
a.onclick = (e) => { e.preventDefault(); e.stopPropagation(); browse(href); };
|
||
return a;
|
||
}
|
||
|
||
function skillCard(x) {
|
||
const c = el("div", "mk-card click" + (x.have ? " have" : ""));
|
||
c.dataset.id = x.id;
|
||
const top = el("div", "mk-top");
|
||
const who = el("div", "mk-who");
|
||
const nm = el("div", "mk-name");
|
||
nm.append(el("span", "mk-title", x.name));
|
||
if (x.official) { const o = el("span", "mk-official"); o.append(svg(CHECK, 9, 2.2)); o.title = t("Official — from the team that makes it"); nm.append(o); }
|
||
who.append(nm);
|
||
const meta = el("div", "mk-meta");
|
||
meta.append(el("span", "mk-pub", x.source));
|
||
who.append(meta);
|
||
top.append(logo(x.icon, x.source), who);
|
||
const d = el("p", "mk-desc" + (x.description ? "" : " wait"), x.description || "");
|
||
d.title = x.description || "";
|
||
const foot = el("div", "mk-foot");
|
||
const n = el("span", "mk-installs");
|
||
n.append(svg(GLYPH.down, 11, 1.5), el("span", "", compact(x.installs)));
|
||
n.title = t("{n} installs", { n: x.installs.toLocaleString() });
|
||
foot.append(n, el("span", "grow"), addButton(x, () => addSkill(x)));
|
||
c.append(top, d, foot);
|
||
c.onclick = () => skillSheet(x);
|
||
c.title = t("About {name}", { name: x.name });
|
||
return c;
|
||
}
|
||
|
||
async function addSkill(x, agents) {
|
||
const body = { source: x.source, id: x.skillId };
|
||
if (agents) body.agents = agents;
|
||
const ok = await change("market/skill", body, t("{name} is in the library now", { name: x.name }));
|
||
if (ok) { x.have = x.name; drawMarket("skills"); fetchMarket("skills"); }
|
||
return ok;
|
||
}
|
||
|
||
function skillSheet(x) {
|
||
const all = skillAgents();
|
||
let agents = all.map((a) => a.id);
|
||
const ed = el("div", "editor lib-editor mk-sheet");
|
||
const head = el("div", "mk-sheethead");
|
||
const who = el("div", "mk-who");
|
||
who.append(el("div", "mk-name big", x.name));
|
||
const meta = el("div", "mk-meta");
|
||
meta.append(el("span", "mk-pub", x.source), el("span", "mk-badge", t("{n} installs", { n: compact(x.installs) })));
|
||
if (x.official) meta.append(el("span", "mk-badge", t("Official")));
|
||
who.append(meta);
|
||
head.append(logo(x.icon, x.source), who, extLink("https://skills.sh/" + x.source + "/" + x.skillId, "skills.sh"));
|
||
ed.append(head);
|
||
const about = el("p", "mk-about", x.description || "…");
|
||
ed.append(about);
|
||
if (!x.description) api("library/market/about", { ids: [x.id] }).then((r) => { x.description = r[x.id] || ""; about.textContent = x.description || t("No description."); }).catch(() => { about.textContent = ""; });
|
||
const src = el("div", "mk-hint");
|
||
src.append(el("span", "", t("From")), extLink("https://github.com/" + x.source, "github.com/" + x.source));
|
||
ed.append(src);
|
||
const agentsBox = el("div");
|
||
const drawAgents = () => agentsBox.replaceChildren(agentChips(all, agents, (n) => { agents = n; drawAgents(); }, { names: true }));
|
||
drawAgents();
|
||
if (!x.have) ed.append(...field(t("Agents"), agentsBox));
|
||
const bar = el("div", "bar");
|
||
bar.append(el("span", "grow"), button(t(x.have ? "Close" : "Cancel"), "", closeLibModal));
|
||
let save = () => {};
|
||
if (!x.have) {
|
||
const go = button(t("Add to the library"), "primary", async () => {
|
||
go.disabled = true;
|
||
go.textContent = t("Adding…");
|
||
if (await addSkill(x, agents)) closeLibModal();
|
||
else { go.disabled = false; go.textContent = t("Add to the library"); }
|
||
});
|
||
bar.append(go);
|
||
save = () => go.click();
|
||
} else bar.prepend(el("span", "mk-have", t("In the library as {name}", { name: x.have })));
|
||
ed.append(bar);
|
||
modal = { save };
|
||
openLib(ed);
|
||
}
|
||
|
||
// ---------- the dialog ----------
|
||
|
||
function openLib(content) { openModal(content); $("#modal").classList.add("lib"); }
|
||
function closeLibModal() { modal = null; closeModal(); setTimeout(() => { if (!modal) $("#modal").classList.remove("lib"); }, 200); }
|
||
// The dialog is the providers page's; while the library has it, its
|
||
// backdrop and Escape close it here.
|
||
$("#modal").addEventListener("click", (e) => {
|
||
if (modal && e.target === e.currentTarget) { e.stopImmediatePropagation(); closeLibModal(); }
|
||
}, true);
|
||
document.addEventListener("keydown", (e) => {
|
||
if (modal && e.key === "Escape") { e.stopImmediatePropagation(); closeLibModal(); }
|
||
}, true);
|
||
// leaving the page with the shared text not saved asks first
|
||
window.addEventListener("beforeunload", (e) => { if (lib && dirty()) e.preventDefault(); });
|
||
// Back to the window, the library is read again — it may have changed
|
||
// in another window or through the CLI meanwhile. Not over an open dialog,
|
||
// unsaved text, or a lookup under way.
|
||
page.addEventListener("scroll", () => page.querySelector(".lib-head")?.classList.toggle("stuck", page.scrollTop > 0), { passive: true });
|
||
// What changed there changes what the market calls added, too.
|
||
async function quietLoad() {
|
||
if (page.hidden || modal || dirty() || probing) return;
|
||
const was = shelf();
|
||
await load();
|
||
if (shelf() !== was) for (const kind of ["mcp", "skills"]) if (market[kind].items) fetchMarket(kind);
|
||
}
|
||
const shelf = () => JSON.stringify([lib?.servers?.map((x) => x.name), lib?.skills?.map((x) => x.name)]);
|
||
document.addEventListener("visibilitychange", () => { if (!document.hidden) quietLoad(); });
|
||
window.addEventListener("focus", quietLoad);
|
||
// opened on ?view=library: app.js showed the page before this was here
|
||
if (!page.hidden) load();
|
||
})();
|