2894 lines
140 KiB
JavaScript
2894 lines
140 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 checking = false; // asking GitHub which skills it has changed
|
||
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;
|
||
}
|
||
|
||
// An agent's icon, made once and copied: a list of hundreds of skills has
|
||
// a chip for each agent on every row, and making each icon afresh (its
|
||
// image, and a probe of whether it loads) was most of drawing the list.
|
||
// One whose picture didn't load is let go, so the next row makes it
|
||
// afresh: kept, its copies would all be empty until the page was loaded
|
||
// again (as Claude Code's were seen to, in the skills list).
|
||
// It's made apart from the Providers page's kept icons, which are its
|
||
// own rows' to take back.
|
||
const icons = new Map();
|
||
function agentIcon(name) {
|
||
let i = icons.get(name);
|
||
if (!i) {
|
||
const kept = keptIcons;
|
||
keptIcons = null;
|
||
try { i = icon(name); } finally { keptIcons = kept; }
|
||
icons.set(name, i);
|
||
const im = i.querySelector(":scope > img");
|
||
if (im) im.onerror = () => { if (icons.get(name) === i) icons.delete(name); };
|
||
greyIcon(i);
|
||
}
|
||
return i.cloneNode(true);
|
||
}
|
||
// A copy already drawn whose picture failed asks for it again, a few
|
||
// times and a little later each time; the grey copy is left to the filter.
|
||
document.addEventListener("error", (e) => {
|
||
const im = e.target;
|
||
if (im.tagName !== "IMG" || im.classList.contains("grey") || !im.parentElement?.classList.contains("ic") || !im.closest("#view-library, #modal.lib")) return;
|
||
const n = +(im.dataset.tries || 0);
|
||
if (n >= 3) return;
|
||
im.dataset.tries = n + 1;
|
||
setTimeout(() => { if (im.isConnected) im.src = srcOf(im) + "?try=" + (n + 1); }, 400 * (n + 1));
|
||
}, true);
|
||
const srcOf = (im) => (im.getAttribute("src") || "").split("?")[0];
|
||
|
||
// A chip's icon is grey while its agent hasn't the item. A filter made it
|
||
// so on every paint of every chip — most of what a scroll through
|
||
// hundreds of skills painted — so it's made grey once instead: a picture
|
||
// gets a grey copy (by the filter's own sum), which stands in for
|
||
// it once ready, and an icon drawn in the text's colour takes that colour
|
||
// grey (library.css). Until then, or if it can't be, the filter does it.
|
||
function greyIcon(i) {
|
||
if (i.querySelector(":scope > .mask, :scope > svg")) { i.classList.add("flat"); return; }
|
||
const src = i.querySelector(":scope > img") && srcOf(i.querySelector(":scope > img"));
|
||
if (!src) return;
|
||
greyCopy(src).then((url) => {
|
||
if (!url) return;
|
||
const add = (ic) => {
|
||
if (ic.classList.contains("baked")) return;
|
||
const g = el("img", "grey");
|
||
g.src = url;
|
||
g.alt = "";
|
||
g.draggable = false;
|
||
ic.append(g);
|
||
ic.classList.add("baked");
|
||
};
|
||
add(i);
|
||
for (const x of document.querySelectorAll(".lib-ag > .ic:not(.baked) > img")) if (srcOf(x) === src) add(x.parentElement);
|
||
}, () => {});
|
||
}
|
||
// grayscale(1)'s own sum: each colour's red, green and blue become this
|
||
const lum = (r, g, b) => Math.round(r * .2126 + g * .7152 + b * .0722);
|
||
let tint;
|
||
function greyColour(v) {
|
||
tint ||= document.createElement("canvas").getContext("2d");
|
||
// a colour in any spelling, read back as #rrggbb or rgba(…); anything
|
||
// else (none, url(#…), currentColor) is left as it is
|
||
if (/^\s*(none|currentcolor|inherit|transparent|url\()/i.test(v)) return v;
|
||
tint.fillStyle = "#010203";
|
||
tint.fillStyle = v;
|
||
const c = tint.fillStyle;
|
||
let m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(c);
|
||
if (m) {
|
||
if (c === "#010203" && !/010203/.test(v)) return v;
|
||
const y = lum(parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)).toString(16).padStart(2, "0");
|
||
return "#" + y + y + y;
|
||
}
|
||
m = /^rgba?\(\s*([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\s*\)$/.exec(c);
|
||
if (!m) return v;
|
||
const y = lum(+m[1], +m[2], +m[3]);
|
||
return `rgba(${y}, ${y}, ${y}, ${m[4] ?? 1})`;
|
||
}
|
||
async function greyCopy(src) {
|
||
const r = await fetch(src);
|
||
if (!r.ok) return null;
|
||
let blob;
|
||
if (/svg/.test(r.headers.get("content-type") || "") || /\.svg$/.test(src)) {
|
||
// the drawing with each of its colours made grey — the same as the
|
||
// filter, which is a sum over each colour (blending and gradients
|
||
// mix colours in sRGB, as the filter does, so they come out the same)
|
||
// and drawn as sharp as the drawing itself
|
||
// (a name inside another, as data-fill, is left: checked by hand, as a
|
||
// lookbehind in the pattern is a syntax error before Safari 16.4, #220)
|
||
const inWord = (s, at) => /[\w-]/.test(s[at - 1] || "");
|
||
const s = (await r.text()).replace(/(fill|stroke|stop-color|flood-color|lighting-color|color)(\s*=\s*)(["'])([^"']*)\3/g, (all, k, eq, q, v, at, s) => inWord(s, at) ? all : k + eq + q + greyColour(v) + q)
|
||
.replace(/(fill|stroke|stop-color|flood-color|lighting-color|color)(\s*:\s*)([^;"'}]+)/g, (all, k, eq, v, at, s) => inWord(s, at) ? all : k + eq + greyColour(v));
|
||
if (/<(image|feColorMatrix|feComponentTransfer|feTurbulence)\b/.test(s)) return null; // a colour the sum can't reach
|
||
blob = new Blob([s], { type: "image/svg+xml" });
|
||
} else {
|
||
// a picture made grey pixel by pixel
|
||
const im = new Image();
|
||
im.src = URL.createObjectURL(await r.blob());
|
||
await im.decode();
|
||
const c = document.createElement("canvas");
|
||
c.width = im.naturalWidth;
|
||
c.height = im.naturalHeight;
|
||
const x = c.getContext("2d");
|
||
x.drawImage(im, 0, 0);
|
||
URL.revokeObjectURL(im.src);
|
||
const d = x.getImageData(0, 0, c.width, c.height), p = d.data;
|
||
for (let k = 0; k < p.length; k += 4) p[k] = p[k + 1] = p[k + 2] = p[k] * .2126 + p[k + 1] * .7152 + p[k + 2] * .0722;
|
||
x.putImageData(d, 0, 0);
|
||
blob = await new Promise((done) => c.toBlob(done));
|
||
if (!blob) return null;
|
||
}
|
||
const url = URL.createObjectURL(blob);
|
||
const t = new Image();
|
||
t.src = url;
|
||
await t.decode(); // ready before it stands in, so nothing blinks
|
||
return url;
|
||
}
|
||
|
||
// 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(agentIcon(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");
|
||
// What's lit is read off the chips clicked, not the list they were
|
||
// drawn with: a row's chips change in place, several clicks before
|
||
// magpie has answered the first, and take on the handlers of chips
|
||
// drawn for them (morphChips). An agent not shown keeps what it has.
|
||
c.onclick = (e) => {
|
||
e.stopPropagation();
|
||
const me = e.currentTarget;
|
||
const lit = [...me.parentElement.children].filter((x) => x.dataset.agent && x.getAttribute("aria-pressed") === "true").map((x) => x.dataset.agent);
|
||
const kept = on.filter((id) => !all.some((x) => x.id === id));
|
||
onChange([...kept, ...(lit.includes(a.id) ? lit.filter((x) => x !== a.id) : [...lit, a.id])], me);
|
||
};
|
||
box.append(c);
|
||
}
|
||
if (opts.all) allChip(box, all, on, onChange);
|
||
return box;
|
||
}
|
||
|
||
// All, ahead of a row's chips: one click gives the item to every agent
|
||
// shown that can take it (not those a chip is greyed out for: no SSE, no
|
||
// remote server), and when they all have it, takes it from every one.
|
||
// An agent not shown keeps what it has, as with a chip.
|
||
function allChip(box, all, on, onChange) {
|
||
const can = [...box.children].filter((c) => !c.disabled).map((c) => c.dataset.agent);
|
||
if (can.length < 2) return;
|
||
const c = el("button", "lib-ag all", t("All"));
|
||
c.dataset.all = "1";
|
||
c.onclick = (e) => {
|
||
e.stopPropagation();
|
||
const me = e.currentTarget;
|
||
const lit = [...me.parentElement.children].filter((x) => x.dataset.agent && x.getAttribute("aria-pressed") === "true").map((x) => x.dataset.agent);
|
||
const kept = on.filter((id) => !all.some((x) => x.id === id));
|
||
onChange(can.every((id) => lit.includes(id)) ? kept : [...kept, ...new Set([...lit, ...can])], me);
|
||
};
|
||
box.prepend(c);
|
||
paintAll(box);
|
||
}
|
||
// the All chip shows whether every agent that can take the item has it
|
||
function paintAll(box) {
|
||
const c = box.querySelector(":scope > .lib-ag.all");
|
||
if (!c) return;
|
||
const can = [...box.children].filter((x) => x.dataset.agent && !x.disabled);
|
||
const n = can.length, full = can.every((x) => x.getAttribute("aria-pressed") === "true");
|
||
c.classList.toggle("on", full);
|
||
c.setAttribute("aria-pressed", full ? "true" : "false");
|
||
c.title = full ? t("Every agent that can take it has it — click to take it from all {n}", { n }) : t("Give it to all {n} agents that can take it", { n });
|
||
}
|
||
|
||
// 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 and the next click needn't wait: a row writes one list at a time,
|
||
// and clicks made meanwhile are sent together once it's answered, so the
|
||
// last click is what the agents end up with. What magpie wrote is then
|
||
// painted onto the same buttons — or, when it couldn't, what it has, which
|
||
// takes back the clicks it refused. Nothing else on the page is drawn
|
||
// again for it: the rest is drawn from the new answer when the page next is.
|
||
const writing = new Map(); // path + name → { list, name, want, box } while a row's agents are written
|
||
let refused = 0; // when a row's write last failed, whose error another row's "Written" doesn't cover
|
||
function chipsChange(path, name, list, rowOf) {
|
||
return async (next, c) => {
|
||
// All lights or darkens every chip of the row, a chip only itself
|
||
const every = !!c.dataset.all;
|
||
for (const x of every ? [...c.parentElement.children].filter((y) => y.dataset.agent) : [c]) {
|
||
const on = next.includes(x.dataset.agent);
|
||
x.classList.toggle("on", on);
|
||
if (on || x === c) x.classList.remove("via");
|
||
x.setAttribute("aria-pressed", on ? "true" : "false");
|
||
}
|
||
paintAll(c.parentElement);
|
||
const key = path + "\n" + name;
|
||
const w = writing.get(key);
|
||
if (w) { w.want = next; w.box = c.parentElement; w.every = every; take(lib); return; }
|
||
const me = { list, name, want: next, box: c.parentElement, every };
|
||
writing.set(key, me);
|
||
take(lib);
|
||
let sent = null, failed = false;
|
||
try {
|
||
// clicks that came back to what was sent aren't sent again
|
||
const same = () => [...sent].sort().join() === [...me.want].sort().join();
|
||
while (!sent || !same()) {
|
||
sent = me.want;
|
||
const v = await api("library/" + path, { name, agents: sent });
|
||
take(v);
|
||
if (same() && me.every && Date.now() - refused > 6000) { reportAll(v, me); continue; }
|
||
if (same() && Date.now() - refused > 6000) report(v.result, null, sent);
|
||
}
|
||
} catch (e) {
|
||
failed = true;
|
||
refused = Date.now();
|
||
status(e.message, "err", 6000);
|
||
}
|
||
writing.delete(key);
|
||
syncProblems();
|
||
// the page held the clicks: what magpie really has is read again
|
||
if (failed) await api("library").then(take, () => {});
|
||
const x = lib[list].find((y) => y.name === name);
|
||
const fresh = x && rowOf(x).querySelector(":scope > .lib-agents");
|
||
if (!me.box.isConnected) { if (failed) render(); } // drawn again meanwhile, from the clicks
|
||
else if (fresh && !morphChips(me.box, fresh)) me.box.replaceWith(fresh);
|
||
};
|
||
}
|
||
// What All did, said of the item: on for how many of the agents it went
|
||
// to, and which of them couldn't be given it, and why.
|
||
function reportAll(v, me) {
|
||
const what = (me.list === "servers" ? "mcp:" : "skill:") + me.name;
|
||
const shown = [...me.box.children].filter((x) => x.dataset.agent && !x.disabled).map((x) => x.dataset.agent);
|
||
const n = shown.filter((id) => me.want.includes(id)).length;
|
||
const bad = (v.result?.problems || []).filter((p) => p.what === what && shown.includes(p.agent));
|
||
if (!bad.length) {
|
||
status(n ? t("{name} is on for all {n} agents", { name: me.name, n }) : t("{name} is off for every agent", { name: me.name }), "ok");
|
||
return;
|
||
}
|
||
const p = bad[0];
|
||
status((n ? t("{name} is on for {ok} of {n} agents", { name: me.name, ok: n - bad.length, n }) : t("{name} couldn't be taken from every agent", { name: me.name })) + " — "
|
||
+ t("{agent}: {error}", { agent: nameOf(p.agent), error: p.error }) + (bad.length > 1 ? " " + t("(and {n} more)", { n: bad.length - 1 }) : ""), "warn", 8000);
|
||
}
|
||
|
||
// take is the page as magpie answered it, with the rows still being
|
||
// written kept as they were last clicked. A server or skill added,
|
||
// removed or renamed changes what the market calls added (#300): one gone
|
||
// from the library loses its mark at once, and the market is asked again.
|
||
function take(v) {
|
||
const was = lib && shelf();
|
||
lib = v;
|
||
for (const w of writing.values()) {
|
||
const x = lib[w.list].find((y) => y.name === w.name);
|
||
if (x) x.agents = w.want;
|
||
}
|
||
if (!was || shelf() === was) return;
|
||
for (const [kind, list] of [["mcp", lib.servers], ["skills", lib.skills]]) {
|
||
const m = market[kind];
|
||
if (!m.items) continue;
|
||
const names = new Set((list || []).map((x) => x.name));
|
||
for (const x of m.items) if (x.have && !names.has(x.have)) x.have = "";
|
||
drawMarket(kind);
|
||
fetchMarket(kind);
|
||
}
|
||
}
|
||
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 ----------
|
||
|
||
// quiet is a read on coming back to the window: the page is drawn again
|
||
// only when the library changed meanwhile, so a click that brings the
|
||
// window forward doesn't redraw what it clicked on.
|
||
async function load(quiet) {
|
||
if (!lib) renderLoading();
|
||
try {
|
||
const v = await api("library");
|
||
const was = lib && seen(lib);
|
||
take(v);
|
||
if (quiet === true && seen(lib) === was) return;
|
||
rtk = null; // asked again when its tab is drawn: an agent may have been installed since
|
||
render();
|
||
} catch (e) {
|
||
status(e.message, "err");
|
||
}
|
||
}
|
||
window.loadLibrary = load;
|
||
// the library as the page shows it: what a change did is said once, not shown
|
||
const seen = (v) => JSON.stringify({ ...v, result: undefined });
|
||
|
||
// 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);
|
||
take(v);
|
||
report(v.result, done);
|
||
render();
|
||
return true;
|
||
} catch (e) {
|
||
status(e.message, "err", 6000);
|
||
return false;
|
||
}
|
||
}
|
||
// given, for a row's agent chips, is the agents that have it now: an
|
||
// agent written that isn't one of them had it taken out (#332).
|
||
function report(res, done, given) {
|
||
if (!res) return;
|
||
if (res.problems?.length) {
|
||
const p = res.problems[0];
|
||
status(t("{agent}: {error}", { agent: tilde(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;
|
||
const out = given ? (res.changed || []).filter((id) => !given.includes(id)).length : 0;
|
||
if (done) status(done, "ok");
|
||
else if (out && out === n) status(n === 1 ? t("Removed from {agent}", { agent: nameOf(res.changed[0]) }) : t("Removed from {n} agents", { n }), "ok");
|
||
else if (out) status(t("{n} agents updated", { n }), "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");
|
||
}
|
||
|
||
// What the last change couldn't write, whole: a chip's amber dot holds
|
||
// only its own item's, so an agent's instructions, its MCP file, a
|
||
// project or a skill that wouldn't update were said once in a toast
|
||
// ("see Library") and shown nowhere.
|
||
function problemsCard() {
|
||
const list = lib.problems || [];
|
||
if (!list.length) return null;
|
||
const card = el("div", "lib-problems");
|
||
card.setAttribute("role", "status");
|
||
const ttl = el("div", "lib-problems-head");
|
||
const again = button(t("Try again"), "action", () => change("all/sync", {}));
|
||
again.title = t("Writes the library into every agent again");
|
||
ttl.append(el("span", "lib-problems-dot"), el("b", "", t("Some of the Library couldn't be written")), el("span", "grow"), again);
|
||
const ul = el("ul");
|
||
for (const p of list) {
|
||
const li = el("li");
|
||
const who = p.what.startsWith("project:") ? tilde(p.agent) : p.agent ? nameOf(p.agent) : "";
|
||
if (who) li.append(el("b", "", who), el("span", "lib-problems-sep", " · "));
|
||
// an agent's own skill in the library's way is settled right here:
|
||
// the library's in its place (the agent's kept aside), or the agent's
|
||
const own = p.own && p.what.startsWith("skill:") && p.agent;
|
||
const name = own && p.what.slice("skill:".length);
|
||
li.append(el("span", "", problemWhat(p.what)), el("span", "lib-problems-err", own ? t("{agent} has a skill of its own called {name}, not the same as the library's", { agent: who, name }) : p.error));
|
||
if (own) {
|
||
const fix = el("div", "lib-problems-fix");
|
||
const use = button(t("Use the library's"), "action", () => change("skills/use-library", { name, agent: p.agent }, t("{agent} has the library's {name} now; its own is kept with the backups", { agent: who, name })));
|
||
use.title = t("Sets {agent}'s own {name} aside with the backups and gives it the library's in its place", { agent: who, name });
|
||
const keep = button(t("Keep {agent}'s", { agent: who }), "action", () => change("skills/keep-own", { name, agent: p.agent }, t("{agent} keeps its own {name}", { agent: who, name })));
|
||
keep.title = t("{agent} keeps its own {name}, and the library no longer gives it this skill", { agent: who, name });
|
||
fix.append(use, keep);
|
||
li.append(fix);
|
||
}
|
||
ul.append(li);
|
||
}
|
||
card.append(ttl, ul);
|
||
return card;
|
||
}
|
||
function problemWhat(what) {
|
||
const [kind, ...rest] = what.split(":");
|
||
const name = rest.join(":");
|
||
if (kind === "instructions") return t("Instructions");
|
||
if (kind === "mcp") return name ? t("MCP server {name}", { name }) : t("MCP servers");
|
||
if (kind === "skill") return t("Skill {name}", { name });
|
||
if (kind === "project") return name ? t("Skill {name}", { name }) : t("The project");
|
||
return what;
|
||
}
|
||
// a chip's write changes the list without drawing the page again: the
|
||
// card is put right in place (the chip clicked is held where it is on the
|
||
// screen by app.js, as for any click)
|
||
function syncProblems() {
|
||
const was = page.querySelector(":scope > .lib-problems");
|
||
const card = problemsCard();
|
||
if (!was && !card) return;
|
||
if (was && card && was.textContent === card.textContent) return;
|
||
if (was && card) was.replaceWith(card);
|
||
else if (was) was.remove();
|
||
else page.querySelector(":scope > .lib-head")?.after(card);
|
||
}
|
||
|
||
// Until the library comes, the page is drawn as it will be: the real tabs
|
||
// (one can be picked already) and, for the tab open, its rows in outline,
|
||
// so nothing moves when they're filled in. The outline shows only if the
|
||
// wait is long enough to see it.
|
||
function renderLoading() {
|
||
page.replaceChildren();
|
||
shown = "";
|
||
page.append(libHead(null), skeleton(tab));
|
||
}
|
||
|
||
function skeleton(which) {
|
||
const box = el("div", "lib-body lib-skel");
|
||
box.setAttribute("aria-busy", "true");
|
||
const bar = (w, h, cls = "") => {
|
||
const b = el("span", "skeleton " + cls);
|
||
b.style.cssText = `width:${w};height:${h}px`;
|
||
return b;
|
||
};
|
||
// name and path lengths vary, as they will
|
||
const widths = [[34, 46], [22, 40], [30, 52], [26, 38], [38, 44], [20, 36], [28, 48]];
|
||
const rows = (n, sw) => {
|
||
const list = el("div", "list lib-list");
|
||
for (let i = 0; i < n; i++) {
|
||
const [a, b] = widths[i % widths.length];
|
||
const row = el("div", "row lib-row");
|
||
const who = el("div", "who");
|
||
who.append(bar(a + "%", 10), bar(b + "%", 8));
|
||
row.append(bar("26px", 26, "lib-sk-icon"), who, el("span", "grow"));
|
||
if (sw) row.append(bar("34px", 20, "lib-sk-switch"));
|
||
list.append(row);
|
||
}
|
||
return list;
|
||
};
|
||
const head = (w) => {
|
||
const rh = el("div", "row-head");
|
||
rh.append(bar(w, 8));
|
||
return rh;
|
||
};
|
||
const intro = el("div", "lib-intro lib-sk-intro");
|
||
intro.append(bar("min(520px, 80%)", 9));
|
||
box.append(intro);
|
||
if (which === "instructions") {
|
||
const card = el("div", "list lib-list");
|
||
const ch = el("div", "row lib-row");
|
||
ch.append(bar("26px", 26, "lib-sk-icon"), bar("150px", 11));
|
||
card.append(ch, rows(1, false).firstChild);
|
||
box.append(card, head("60px"), rows(6, true));
|
||
} else if (which === "rtk") {
|
||
box.append(rows(1, false), head("60px"), rows(5, true));
|
||
} else {
|
||
box.append(rows(5, true));
|
||
}
|
||
return box;
|
||
}
|
||
|
||
// the tabs and the Library folder; without the library yet (lib is null
|
||
// while it loads) the tabs have no counts and the folder waits
|
||
function libHead(counts) {
|
||
const head = el("div", "lib-head");
|
||
const n = (k) => (counts?.[k] ? " · " + counts[k] : "");
|
||
const tabs = segs([
|
||
["instructions", t("Instructions")],
|
||
["mcp", t("MCP servers") + n("mcp")],
|
||
["skills", t("Skills") + n("skills")],
|
||
["rtk", "RTK"],
|
||
], tab, (id) => {
|
||
tab = id;
|
||
try { localStorage.setItem("magpie.libTab", id); } catch {}
|
||
if (!lib) return page.querySelector(":scope > .lib-skel")?.replaceWith(skeleton(id));
|
||
render(); syncLists();
|
||
});
|
||
tabs.classList.add("lib-tabs");
|
||
head.append(tabs, el("span", "grow"));
|
||
const more = button("", "lib-more", () => lib && reveal(lib.dir));
|
||
more.append(glyph(GLYPH.folder, "lib-mini"), el("span", "", t("Library folder")));
|
||
if (lib) more.title = tilde(lib.dir);
|
||
else more.disabled = true;
|
||
head.append(more);
|
||
return head;
|
||
}
|
||
|
||
// ---------- 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 = libHead({ mcp: lib.servers.length, skills: lib.skills.length });
|
||
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 problems = problemsCard();
|
||
if (problems) page.append(problems);
|
||
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;
|
||
syncLists(); // a long list's rows where the view is, before it's painted
|
||
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)));
|
||
// a newer release: upgraded the way this rtk was installed
|
||
const behind = rtk.latest && rtk.version && vNewer(rtk.latest, rtk.version);
|
||
if (rtkUpgrading) ttl.append(tag(t("Upgrading…"), "lib-new"));
|
||
else if (behind) ttl.append(tag(t("v{v} is out", { v: rtk.latest }), "lib-new", t("RTK {v} is the latest release; this one is {have}", { v: rtk.latest, have: rtk.version })));
|
||
else if (rtk.latest && rtk.version) ttl.append(tag(t("Up to date"), "", t("RTK {v} is the latest release", { v: rtk.latest })));
|
||
if (behind || rtkUpgrading) {
|
||
const ub = button(rtkUpgrading ? t("Upgrading…") : t("Upgrade"), "action", upgradeRTK);
|
||
ub.disabled = rtkUpgrading || !rtk.upgrade;
|
||
ub.title = rtk.upgrade ? t("Runs {cmd}", { cmd: rtk.upgrade }) : t("magpie can't tell how this RTK was installed — update it the way you installed it");
|
||
ttl.append(ub);
|
||
}
|
||
card.append(ttl);
|
||
if (rtk.note) card.append(el("p", "lib-rtk-note", rtk.note));
|
||
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.")));
|
||
if (rtk.days?.length) card.append(rtkChart(rtk.days));
|
||
} 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 })));
|
||
// OpenCode 2 won't load rtk's plugin (written for OpenCode 1): it can't
|
||
// be switched on, and one already there can be switched off
|
||
if (a.blocked && a.id !== "opencode") row.append(tag(t("Update RTK"), "warn", t("{agent}'s hook needs RTK 0.50 or newer: older ones only add @RTK.md to AGENTS.md, which rewrites no command. Update RTK (brew upgrade rtk, or its installer again), then switch it on.", { agent: a.name })));
|
||
else if (a.blocked) row.append(tag(t("Not for OpenCode 2"), "warn", a.on
|
||
? t("RTK's plugin is written for OpenCode 1, and OpenCode 2 refuses to load it. Switch this off until RTK supports OpenCode 2.")
|
||
: t("RTK's plugin is written for OpenCode 1, and OpenCode 2 refuses to load it. It can be switched on once RTK supports OpenCode 2.")));
|
||
const sw = toggle(a.on, t("{agent} runs its commands through RTK", { agent: a.name }), (on) => setRTK(a, on));
|
||
if ((!rtk.path && !a.on) || (a.blocked && !a.on) || rtkBusy.has(a.id) || rtkInstalling || rtkUpgrading) 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();
|
||
}
|
||
// what rtk saved day by day (week by week past 92 days), over the last
|
||
// 30 or 90 days or since it first ran: each bar the commands' whole
|
||
// output, the part RTK kept from the model on top of what still went
|
||
let rtkRange = 0; // days; 0 all
|
||
try { rtkRange = +(localStorage.getItem("magpie.rtkRange") ?? 0) || 0; } catch {}
|
||
const isoDay = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||
const dayOf = (s) => { const [y, m, d] = s.split("-").map(Number); return new Date(y, m - 1, d); };
|
||
function rtkChart(days) {
|
||
const chart = el("div", "chart lib-rtk-chart");
|
||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||
let first = dayOf(days[0].date);
|
||
if (rtkRange) { first = new Date(today); first.setDate(first.getDate() - rtkRange + 1); }
|
||
if (first > today) first = new Date(today);
|
||
const n = Math.round((today - first) / 864e5) + 1;
|
||
const step = n > 92 ? 7 : 1;
|
||
const buckets = [], at = new Map();
|
||
for (let d = new Date(first); d <= today; d.setDate(d.getDate() + step)) {
|
||
const b = { day: new Date(d), commands: 0, input: 0, saved: 0 };
|
||
for (let i = 0; i < step; i++) { const x = new Date(d); x.setDate(x.getDate() + i); at.set(isoDay(x), b); }
|
||
buckets.push(b);
|
||
}
|
||
const sum = { commands: 0, input: 0, saved: 0 };
|
||
for (const r of days) {
|
||
const b = at.get(r.date);
|
||
if (!b) continue;
|
||
b.commands += r.commands; b.input += r.input; b.saved += r.saved;
|
||
sum.commands += r.commands; sum.input += r.input; sum.saved += r.saved;
|
||
}
|
||
const peak = Math.max(1, ...buckets.map((b) => b.input));
|
||
const head = el("div", "sess-chart-head");
|
||
const seg = segs([[30, t("30 days")], [90, t("90 days")], [0, t("All")]], rtkRange, (r) => {
|
||
rtkRange = r;
|
||
try { localStorage.setItem("magpie.rtkRange", String(r)); } catch {}
|
||
chart.replaceWith(rtkChart(days));
|
||
});
|
||
head.append(el("span", "label", t(step === 7 ? "Saved by week" : "Saved by day")), seg, el("span", "grow"), el("span", "peak", tokens(peak)));
|
||
const bars = el("div", "bars"), labels = el("div", "labels");
|
||
const k = buckets.length;
|
||
const every = k <= 8 ? 1 : k <= 31 ? Math.ceil(k / 6) : Math.ceil(k / 5);
|
||
const short = (d) => `${d.getMonth() + 1}/${d.getDate()}`;
|
||
buckets.forEach((b, i) => {
|
||
const bar = el("div", "bar");
|
||
const saved = el("i", "out"), kept = el("i", "in");
|
||
saved.style.height = (100 * b.saved / peak).toFixed(1) + "%";
|
||
kept.style.height = (100 * Math.max(0, b.input - b.saved) / peak).toFixed(1) + "%";
|
||
bar.append(saved, kept);
|
||
const when = step === 7 ? t("week of {label}", { label: short(b.day) }) : short(b.day);
|
||
bar.title = b.commands
|
||
? t("{when} · {saved} tokens saved over {n} commands — {pct}%", { when, saved: tokens(b.saved), n: b.commands.toLocaleString(), pct: b.input ? Math.round(100 * b.saved / b.input) : 0 })
|
||
: t("{when} · nothing", { when });
|
||
bars.append(bar);
|
||
const end = i === k - 1 && (k - 1) % every >= every / 2;
|
||
labels.append(el("span", "", i % every === 0 || end ? short(b.day) : ""));
|
||
});
|
||
const foot = el("div", "lib-rtk-foot");
|
||
const key = (cls, text) => { const x = el("span", "lib-rtk-key"); x.append(el("i", cls), el("span", "", text)); return x; };
|
||
foot.append(key("out", t("saved")), key("in", t("still sent")), el("span", "grow"),
|
||
el("span", "", sum.commands
|
||
? t("{saved} tokens saved over {n} commands — {pct}%", { saved: tokens(sum.saved), n: sum.commands.toLocaleString(), pct: sum.input ? Math.round(100 * sum.saved / sum.input) : 0 })
|
||
: t("Nothing run through RTK in this time")));
|
||
chart.append(head, bars, labels, foot);
|
||
return chart;
|
||
}
|
||
// vNewer: version a is after b
|
||
function vNewer(a, b) {
|
||
const x = a.split(/[.-]/).map((n) => parseInt(n, 10) || 0), y = b.split(/[.-]/).map((n) => parseInt(n, 10) || 0);
|
||
for (let i = 0; i < 3; i++) if ((x[i] || 0) !== (y[i] || 0)) return (x[i] || 0) > (y[i] || 0);
|
||
return false;
|
||
}
|
||
let rtkUpgrading = false;
|
||
async function upgradeRTK() {
|
||
rtkUpgrading = true;
|
||
render();
|
||
try {
|
||
rtk = await api("library/rtk/upgrade", {});
|
||
if (rtk.note) status(rtk.note, "", 10000);
|
||
else status(t("RTK is now {v}", { v: rtk.version }), "ok", 6000);
|
||
} catch (e) {
|
||
status(e.message, "err", 10000);
|
||
}
|
||
rtkUpgrading = false;
|
||
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");
|
||
// a hidden agent still reading them is listed, to switch it off (#475)
|
||
const rows = iv.agents.filter((a) => !isHidden({ id: a.agent }) || a.on);
|
||
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 (isHidden({ id: a.agent })) tags.append(tag(t("Hidden"), "", t("Hidden on the Agents page: switch it off here to take the shared instructions out of its file")));
|
||
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 })));
|
||
if (a.agent === "agy") det.append(el("p", "lib-aside", t("Antigravity reads Gemini CLI's ~/.gemini/GEMINI.md as well as this file, so text given to both is read twice.")));
|
||
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 {
|
||
// one agent's servers on or off at once (#475), or out of a hidden one
|
||
if (byAgentRows("servers").length) {
|
||
const rh = el("div", "row-head");
|
||
rh.append(el("span", "label", t("In the library")), el("span", "grow"), byAgentButton("servers"));
|
||
body.append(rh);
|
||
}
|
||
const list = el("div", "list lib-list");
|
||
// in the order the reader picked (#481), A→Z till then
|
||
const fill = () => list.replaceChildren(...[...lib.servers].sort(byName(sortOf("libServers"))).map((s) => serverRow(s, all)));
|
||
if (lib.servers.length > 1) {
|
||
const rh = el("div", "row-head");
|
||
rh.append(el("span", "label", t("In the library")), el("span", "grow"), sortBy("libServers", NAME_SORTS, fill));
|
||
body.append(rh);
|
||
}
|
||
fill();
|
||
body.append(list);
|
||
const after = el("div", "after-list");
|
||
after.append(button(t("+ Add server"), "", () => editServer(null)));
|
||
body.append(after);
|
||
}
|
||
// an agent's own servers (Codex's node_repl, added each time it starts)
|
||
// aren't listed: magpie leaves them as they are
|
||
const found = 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);
|
||
}
|
||
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), all: true }));
|
||
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 {
|
||
take(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) { take(last); render(); } return; }
|
||
}
|
||
take(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 lib-skillshead");
|
||
rh.append(el("span", "label", t("In the library")));
|
||
const box = el("div", "lib-groups");
|
||
if (lib.skills.length > 8) rh.append(skillFilter(box, all));
|
||
const fresh = lib.skills.filter((s) => s.kind === "github" || s.origin);
|
||
rh.append(el("span", "grow"));
|
||
if (lib.skills.length > 1) rh.append(sortBy("libSkills", SKILL_SORTS(), () => { if (box.isConnected) drawSkills(box, all); }));
|
||
if (lib.skills.length > 1 && all.length) rh.append(...everySkillButtons(all));
|
||
if (byAgentRows("skills").length) rh.append(byAgentButton("skills"));
|
||
if (lib.skills.length > 1) rh.append(removeEverySkillButton());
|
||
if (lib.skills.some((s) => s.kind === "github")) {
|
||
const c = button(checking ? t("Checking…") : t("Check for updates"), "lib-updall", () => checkSkills());
|
||
c.title = t("Ask GitHub which skills changed since they were installed");
|
||
c.disabled = checking;
|
||
if (checking) c.classList.add("busy");
|
||
rh.append(c);
|
||
}
|
||
const stale = lib.skills.filter((s) => s.check?.status === "update");
|
||
if (stale.length) {
|
||
const n = stale.length;
|
||
const u = button(t("Update {n}", { n }), "action lib-updall", async (e, b) => {
|
||
b.classList.add("busy");
|
||
b.textContent = t("Updating…");
|
||
await updateAllSkills(stale.map((s) => s.name));
|
||
b.classList.remove("busy");
|
||
});
|
||
u.title = n === 1 ? t("Fetch {name} from GitHub again", { name: stale[0].name }) : t("Fetch the {n} skills GitHub changed again", { n });
|
||
rh.append(u);
|
||
} else 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(u);
|
||
}
|
||
body.append(rh);
|
||
drawSkills(box, all);
|
||
body.append(box);
|
||
}
|
||
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")));
|
||
// every one at once, rather than a click for each
|
||
if (lib.foundSkills.length > 1) {
|
||
const names = lib.foundSkills.map((f) => f.name);
|
||
const all = button(t("Bring in all"), "action lib-updall lib-importall", async (e, b) => {
|
||
b.classList.add("busy");
|
||
b.textContent = t("Bringing in…");
|
||
await importAllSkills(names);
|
||
b.classList.remove("busy");
|
||
b.textContent = t("Bring in all");
|
||
});
|
||
all.title = t("Brings the {n} skills into the library: the agents that have them go on having them, and you can give them to the others", { n: names.length });
|
||
rh.append(all);
|
||
}
|
||
body.append(rh);
|
||
const list = el("div", "list lib-list");
|
||
for (const f of lib.foundSkills) list.append(foundSkillRow(f));
|
||
body.append(list);
|
||
}
|
||
if (lib.skills.length || lib.projects.length) renderProjects(body);
|
||
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"));
|
||
}
|
||
|
||
// ---------- the library's skills, by where they came from ----------
|
||
|
||
// Hundreds of skills were one list of rows, every one of them drawn with
|
||
// a chip for each agent — thousands of icons laid out and painted on each
|
||
// redraw, scroll and hover. They're grouped by the GitHub repository they
|
||
// came from (the ones on this computer together), a big group again by
|
||
// the folder they sit in there (or the start of their names), and a
|
||
// group's rows are a window on its list: only those near the view are
|
||
// drawn, the rest is room kept for them, so a scroll through a thousand
|
||
// skills draws a few rows now and then instead of laying out and painting
|
||
// each as it comes into view.
|
||
let skillQuery = ""; // what the filter over the skills holds
|
||
let skillTimer = 0; // the filter's redraw, waiting for typing to pause
|
||
const unfiltered = new Set(); // groups folded while filtering, till the filter changes
|
||
let folds = {}; // group → true when folded, false when opened by hand
|
||
try { folds = JSON.parse(localStorage.getItem("magpie.libSkillFolds") || "{}") || {}; } catch {}
|
||
function saveFolds() { try { localStorage.setItem("magpie.libSkillFolds", JSON.stringify(folds)); } catch {} }
|
||
const repoOf = (u) => (u || "").replace(/^https:\/\/github\.com\//, "").split("/").slice(0, 2).join("/");
|
||
const MANY = 40; // a group bigger than this starts folded, and so do its parts
|
||
const SPLIT = 8; // a group bigger than this is split by folder, or by name
|
||
// Heights the rows are held to (library.css): a list's room is known
|
||
// without drawing it.
|
||
const ROW_H = 51, ROW_SRC_H = 67, SUB_H = 36;
|
||
|
||
// By source (the groups, as they always were) or one flat list of every
|
||
// skill by name, A→Z or Z→A (#481)
|
||
const SKILL_SORTS = () => [["source", t("By source")], ...NAME_SORTS];
|
||
// the groups, and each skill's text to filter by, worked out once for
|
||
// each answer from magpie and each pick
|
||
let grouped = null;
|
||
function skillGroups() {
|
||
const pick = sortOf("libSkills", SKILL_SORTS());
|
||
if (grouped?.lib === lib && grouped.pick === pick) return grouped.groups;
|
||
if (pick !== "source") {
|
||
const skills = [...lib.skills].sort(byName(pick));
|
||
const repo = (s) => (s.kind === "github" ? repoOf(s.source) : s.origin ? repoOf(s.origin) : "");
|
||
const g = { key: "flat", repo: "", skills, parts: null,
|
||
text: new Map(skills.map((s) => [s, (s.name + " " + (s.description || "") + " " + repo(s)).toLowerCase()])) };
|
||
grouped = { lib, pick, groups: [g] };
|
||
return grouped.groups;
|
||
}
|
||
const by = new Map();
|
||
for (const s of lib.skills) {
|
||
const repo = s.kind === "github" ? repoOf(s.source) : s.origin ? repoOf(s.origin) : "";
|
||
const key = repo ? "gh:" + repo.toLowerCase() : "local";
|
||
let g = by.get(key);
|
||
if (!g) by.set(key, (g = { key, repo, skills: [] }));
|
||
g.skills.push(s);
|
||
}
|
||
const groups = [...by.values()].sort((a, b) => (a.key === "local") - (b.key === "local") || a.repo.localeCompare(b.repo));
|
||
for (const g of groups) {
|
||
g.skills.sort((a, b) => a.name.localeCompare(b.name));
|
||
g.text = new Map(g.skills.map((s) => [s, (s.name + " " + (s.description || "") + " " + g.repo).toLowerCase()]));
|
||
g.parts = partsOf(g);
|
||
}
|
||
grouped = { lib, pick, groups };
|
||
return groups;
|
||
}
|
||
|
||
// A big group's parts: by the folder its skills sit in — in the
|
||
// repository, or on this computer — when they sit in more than one;
|
||
// else by the start their names share ("gh-review", "gh-triage" → gh),
|
||
// a start three or more share being a part and the rest one more.
|
||
// Nothing is fetched: it's all in where each skill came from.
|
||
function partsOf(g) {
|
||
if (g.skills.length <= SPLIT) return null;
|
||
const split = (keyOf) => {
|
||
const m = new Map();
|
||
for (const s of g.skills) {
|
||
const k = keyOf(s);
|
||
if (!m.has(k)) m.set(k, []);
|
||
m.get(k).push(s);
|
||
}
|
||
return m.size > 1 ? m : null;
|
||
};
|
||
const folderIn = (s) => {
|
||
if (s.kind === "github") {
|
||
// …/tree/<ref>/<path to the folder>/<skill>
|
||
const m = /^https:\/\/github\.com\/[^/]+\/[^/]+\/tree\/[^/]+\/(.+)$/.exec(s.source || "");
|
||
return m ? m[1].split("/").slice(0, -1).join("/") : "";
|
||
}
|
||
if (s.kind === "folder") return tilde((s.source || "").replace(/[\\/][^\\/]+[\\/]?$/, ""));
|
||
return "";
|
||
};
|
||
let m = split(folderIn), mono = true;
|
||
if (!m) {
|
||
mono = false;
|
||
const segs = (s) => s.name.toLowerCase().split(/[-_:.\s]+/).filter(Boolean);
|
||
// a start every name has says nothing: the part after it does
|
||
const all = g.skills.map(segs);
|
||
let skip = 0;
|
||
while (all.every((x) => x.length > skip + 1 && x[skip] === all[0][skip])) skip++;
|
||
const lead = new Map();
|
||
for (const x of all) if (x.length > skip + 1) lead.set(x[skip], (lead.get(x[skip]) || 0) + 1);
|
||
m = split((s) => { const x = segs(s); const k = x.length > skip + 1 ? x[skip] : ""; return lead.get(k) >= 3 ? k : ""; });
|
||
if (m && m.size === 2 && m.has("") && m.get("").length > g.skills.length * 0.8) m = null; // one small part and the rest
|
||
}
|
||
if (!m) return null;
|
||
return [...m].map(([key, skills]) => ({ key, skills, mono,
|
||
label: key || (mono ? (g.repo ? t("At the top of the repository") : t("Kept in the library")) : t("Others")) }))
|
||
.sort((a, b) => (!a.key) - (!b.key) || a.key.localeCompare(b.key));
|
||
}
|
||
|
||
function skillFilter(box, all) {
|
||
const f = el("input", "lib-filter lib-skillq");
|
||
f.type = "search";
|
||
f.dataset.lib = "skillq";
|
||
f.placeholder = t("Filter {n} skills…", { n: lib.skills.length });
|
||
f.spellcheck = false;
|
||
f.autocomplete = "off";
|
||
f.value = skillQuery;
|
||
// what's typed is kept at once (a redraw of the page shows it), the
|
||
// rows drawn again once typing pauses
|
||
const redraw = () => { clearTimeout(skillTimer); unfiltered.clear(); if (box.isConnected) drawSkills(box, all); };
|
||
f.oninput = () => { skillQuery = f.value; clearTimeout(skillTimer); skillTimer = setTimeout(redraw, 120); };
|
||
f.onkeydown = (e) => {
|
||
e.stopPropagation();
|
||
if (e.key === "Escape" && f.value) { e.preventDefault(); f.value = ""; skillQuery = ""; redraw(); }
|
||
};
|
||
return f;
|
||
}
|
||
|
||
// The skills' lists as last drawn into a box: a group's list keeps its
|
||
// rows through a filter or a fold, and draws only those it hasn't.
|
||
let drawn = null; // { box, lists: Map group → window }
|
||
function drawSkills(box, all) {
|
||
const groups = skillGroups();
|
||
const q = skillQuery.trim().toLowerCase();
|
||
if (drawn?.box !== box) drawn = { box, lists: new Map() };
|
||
const hitsOf = (g) => (q ? g.skills.filter((s) => g.text.get(s).includes(q)) : g.skills);
|
||
const cards = [];
|
||
const single = groups.length === 1;
|
||
const many = lib.skills.length > MANY;
|
||
const redraw = () => { drawSkills(box, all); };
|
||
for (const g of groups) {
|
||
const hits = hitsOf(g);
|
||
if (!hits.length) continue;
|
||
const folded = !single && (q ? unfiltered.has(g.key) : folds[g.key] ?? many);
|
||
let w = drawn.lists.get(g.key);
|
||
if (!folded) {
|
||
if (!w) drawn.lists.set(g.key, (w = windowed()));
|
||
w.set(groupItems(g, hits, all, !!q, redraw));
|
||
}
|
||
// from one place only: its list with no heading over it
|
||
if (single) {
|
||
const card = el("div", "list lib-list lib-vcard");
|
||
w.el.classList.add("bare");
|
||
card.append(w.el);
|
||
cards.push(card);
|
||
} else cards.push(groupCard(g, hits, all, folded, !!q, w, redraw));
|
||
}
|
||
if (!cards.length) cards.push(el("div", "list lib-none", t("No skill matches “{q}”.", { q: skillQuery.trim() })));
|
||
box.replaceChildren(...cards);
|
||
if (box.isConnected) syncLists();
|
||
}
|
||
|
||
// A group's list: its skills, or its parts, each a heading over its own.
|
||
function groupItems(g, hits, all, filtering, redraw) {
|
||
const row = (s) => ({ key: "s\n" + s.name, h: s.source ? ROW_SRC_H : ROW_H, make: () => skillRow(skillNamed(s.name) || s, all) });
|
||
if (!g.parts) return hits.map(row);
|
||
const hit = hits.length === g.skills.length ? null : new Set(hits);
|
||
const items = [];
|
||
for (const p of g.parts) {
|
||
const ph = hit ? p.skills.filter((s) => hit.has(s)) : p.skills;
|
||
if (!ph.length) continue;
|
||
const fk = g.key + "\n" + p.key;
|
||
const folded = filtering ? unfiltered.has(fk) : folds[fk] ?? g.skills.length > MANY;
|
||
items.push({ key: ["p", p.key, folded, filtering, ph.length].join("\n"), h: SUB_H, make: () => partHead(g, p, ph, folded, filtering, fk, redraw) });
|
||
if (!folded) for (const s of ph) items.push(row(s));
|
||
}
|
||
return items;
|
||
}
|
||
|
||
function partHead(g, p, hits, folded, filtering, fk, redraw) {
|
||
const head = el("div", "row lib-row click lib-subhead" + (folded ? "" : " open"));
|
||
const chev = el("span", "chev");
|
||
chev.append(svg(CHEV_R, 10, 1.7));
|
||
const n = p.skills.length;
|
||
head.append(chev, el("span", "name" + (p.mono && p.key ? " mono" : ""), p.label),
|
||
el("span", "sub", filtering && hits.length !== n ? t("{n} of {total}", { n: hits.length, total: n }) : String(n)));
|
||
// a dot when GitHub changed some of its skills
|
||
const stale = p.skills.filter((s) => s.check?.status === "update").length;
|
||
if (stale) {
|
||
const dot = el("span", "lib-dot");
|
||
dot.title = stale === 1 ? t("1 skill has an update") : t("{n} skills have updates", { n: stale });
|
||
head.append(dot);
|
||
}
|
||
head.title = folded ? t("Show its skills") : t("Hide its skills");
|
||
head.onclick = () => {
|
||
if (filtering) { if (folded) unfiltered.delete(fk); else unfiltered.add(fk); }
|
||
else { folds[fk] = !folded; saveFolds(); }
|
||
redraw();
|
||
};
|
||
return head;
|
||
}
|
||
|
||
// the skill as magpie last said, for a row drawn after the list was:
|
||
// a row scrolled away and back is drawn from what the page has now
|
||
let named = null;
|
||
function skillNamed(name) {
|
||
if (named?.lib !== lib) named = { lib, by: new Map(lib.skills.map((s) => [s.name, s])) };
|
||
return named.by.get(name);
|
||
}
|
||
|
||
function groupCard(g, hits, all, folded, filtering, w, redraw) {
|
||
const card = el("div", "list lib-card lib-group");
|
||
card.dataset.group = g.key;
|
||
const head = el("div", "row lib-row click lib-grouphead" + (folded ? "" : " open"));
|
||
const who = el("div", "who");
|
||
who.append(el("div", "name" + (g.repo ? " mono" : ""), g.repo || t("On this computer")));
|
||
const n = g.skills.length;
|
||
who.append(el("div", "sub", filtering && hits.length !== n ? t("{n} of {total} skills", { n: hits.length, total: n })
|
||
: n === 1 ? t("1 skill") : t("{n} skills", { n })));
|
||
const tags = el("div", "lib-tags");
|
||
const stale = g.skills.filter((s) => s.check?.status === "update");
|
||
if (stale.length) {
|
||
const u = button(t("Update {n}", { n: stale.length }), "action lib-updall", async (e, b) => {
|
||
b.classList.add("busy");
|
||
b.textContent = t("Updating…");
|
||
await updateAllSkills(stale.map((s) => s.name));
|
||
});
|
||
u.title = stale.length === 1 ? t("Fetch {name} from GitHub again", { name: stale[0].name }) : t("Fetch the {n} skills GitHub changed again", { n: stale.length });
|
||
tags.append(u);
|
||
}
|
||
// which agents have its skills, all of them or some
|
||
const have = el("div", "lib-have");
|
||
for (const a of all) {
|
||
const k = g.skills.filter((s) => s.agents.includes(a.id)).length;
|
||
if (!k) continue;
|
||
const i = agentIcon(a.icon);
|
||
i.title = k === n ? t("{agent} has all of them", { agent: a.name }) : t("{agent} has {n} of them", { agent: a.name, n: k });
|
||
if (k < n) i.classList.add("some");
|
||
have.append(i);
|
||
}
|
||
const acts = el("div", "lib-rowacts");
|
||
if (g.repo) {
|
||
const o = button("", "lib-icon", () => browse("https://github.com/" + g.repo));
|
||
o.append(svg(GLYPH.out, 13, 1.4));
|
||
o.title = t("Open {repo} on GitHub", { repo: g.repo });
|
||
acts.append(o);
|
||
}
|
||
const chev = el("span", "chev");
|
||
chev.append(svg(CHEV_R, 11, 1.7));
|
||
const pic = g.repo ? mark(g.skills.find((s) => s.icon)?.icon, GLYPH.skill) : glyph(GLYPH.folder);
|
||
head.append(pic, who, tags, have, acts, chev);
|
||
head.title = folded ? t("Show its skills") : t("Hide its skills");
|
||
head.onclick = () => {
|
||
if (filtering) { if (folded) unfiltered.delete(g.key); else unfiltered.add(g.key); }
|
||
else { folds[g.key] = !folded; saveFolds(); }
|
||
redraw();
|
||
};
|
||
card.append(head);
|
||
if (!folded) card.append(w.el);
|
||
return card;
|
||
}
|
||
|
||
// ---------- a long list, drawn near the view first ----------
|
||
|
||
// A list of items of known heights ({ key, h, make }) drawn in two goes:
|
||
// the rows near the page's view at once, with room kept above and below
|
||
// for the rest, and then the rest while the page is idle, a few rows at
|
||
// a time, each kept once drawn. A scroll builds nothing and takes nothing
|
||
// away, so it only paints what's there — unless it outruns the filling,
|
||
// when what's left is drawn at once rather than a view a frame. A row
|
||
// still wanted after a change (a filter, a fold) is kept as it is.
|
||
const lists = new Set();
|
||
let listFrame = 0;
|
||
function syncLists() {
|
||
cancelAnimationFrame(listFrame);
|
||
listFrame = 0;
|
||
if (page.hidden) return;
|
||
let vp = null;
|
||
for (const w of lists) {
|
||
if (!w.el.isConnected) { if (w.shown) lists.delete(w); continue; } // its page is gone; one drawn again comes back
|
||
if (!w.full()) w.sync(vp ||= page.getBoundingClientRect());
|
||
}
|
||
}
|
||
const queueLists = () => { if (!listFrame) listFrame = requestAnimationFrame(syncLists); };
|
||
// and while the page scrolls, its lists say so (library.css, .scrolling)
|
||
let still = 0;
|
||
const settled = () => { still = 0; for (const w of lists) w.el.classList.remove("scrolling"); };
|
||
page.addEventListener("scroll", () => {
|
||
queueLists();
|
||
if (!still) for (const w of lists) w.el.classList.add("scrolling");
|
||
clearTimeout(still);
|
||
still = setTimeout(settled, 150);
|
||
}, { passive: true });
|
||
window.addEventListener("resize", queueLists);
|
||
|
||
// The filling: one list's next few rows per idle moment, the lists in turn.
|
||
const FILL = 20;
|
||
const filling = new Set();
|
||
let fillTask = 0;
|
||
const idle = window.requestIdleCallback
|
||
? (f) => requestIdleCallback(f, { timeout: 200 })
|
||
: (f) => setTimeout(f, 24);
|
||
function queueFill(w) {
|
||
filling.add(w);
|
||
if (!fillTask) fillTask = idle(fillSome);
|
||
}
|
||
function fillSome() {
|
||
fillTask = 0;
|
||
for (const w of filling) {
|
||
if (!w.el.isConnected || w.full()) { filling.delete(w); continue; }
|
||
w.grow(FILL);
|
||
if (w.full()) filling.delete(w);
|
||
break;
|
||
}
|
||
if (filling.size) fillTask = idle(fillSome);
|
||
}
|
||
|
||
function windowed() {
|
||
const box = el("div", "lib-vl");
|
||
const above = el("div", "lib-vpad"), below = el("div", "lib-vpad");
|
||
box.append(above, below);
|
||
let items = [], tops = [0], from = 0, to = 0;
|
||
let live = new Map(); // key → its row, for the items from…to
|
||
const w = { el: box, shown: false };
|
||
// the first item whose bottom is below y
|
||
const at = (y) => {
|
||
let lo = 0, hi = items.length;
|
||
while (lo < hi) { const mid = (lo + hi) >> 1; if (tops[mid + 1] > y) hi = mid; else lo = mid + 1; }
|
||
return lo;
|
||
};
|
||
const node = (i) => {
|
||
const it = items[i];
|
||
let n = live.get(it.key);
|
||
if (!n) n = it.make();
|
||
n.classList.toggle("first", i === 0);
|
||
return n;
|
||
};
|
||
const pads = () => {
|
||
above.style.height = tops[from] + "px";
|
||
below.style.height = tops[items.length] - tops[to] + "px";
|
||
};
|
||
// draw items a…b around those drawn (a ≤ from, b ≥ to), or afresh,
|
||
// keeping the rows of those already drawn
|
||
function show(a, b, fresh) {
|
||
if (!fresh && a === from && b === to) return;
|
||
if (!fresh && a <= from && b >= to && from < to) {
|
||
if (a < from) {
|
||
const f = document.createDocumentFragment();
|
||
const made = [];
|
||
for (let i = a; i < from; i++) { const n = node(i); made.push([items[i].key, n]); f.append(n); }
|
||
above.after(f);
|
||
for (const [k, n] of made) live.set(k, n);
|
||
}
|
||
if (b > to) {
|
||
const f = document.createDocumentFragment();
|
||
for (let i = to; i < b; i++) { const n = node(i); live.set(items[i].key, n); f.append(n); }
|
||
below.before(f);
|
||
}
|
||
} else {
|
||
const next = new Map(), rows = [];
|
||
for (let i = a; i < b; i++) { const n = node(i); next.set(items[i].key, n); rows.push(n); }
|
||
for (const [k, n] of live) if (next.get(k) !== n) n.remove();
|
||
live = next;
|
||
// rows kept stay where they are; the others go in between them
|
||
let prev = above;
|
||
for (const n of rows) {
|
||
if (prev.nextSibling !== n) prev.after(n);
|
||
prev = n;
|
||
}
|
||
}
|
||
from = a; to = b;
|
||
pads();
|
||
}
|
||
w.full = () => from === 0 && to === items.length;
|
||
// n more rows, below what's drawn first, then above it
|
||
w.grow = (n) => {
|
||
const b = Math.min(items.length, to + n);
|
||
show(Math.max(0, from - (n - (b - to))), b);
|
||
};
|
||
w.set = (next) => {
|
||
items = next;
|
||
tops = [0];
|
||
for (const it of items) tops.push(tops[tops.length - 1] + it.h);
|
||
// placed, what's near the view; until then the first rows, enough for one
|
||
if (box.isConnected && !page.hidden) w.sync(page.getBoundingClientRect(), true);
|
||
else show(0, Math.min(items.length, 12), true);
|
||
if (!w.full()) queueFill(w);
|
||
};
|
||
w.sync = (vp, fresh) => {
|
||
w.shown = true;
|
||
lists.add(w);
|
||
const r = box.getBoundingClientRect();
|
||
const total = tops[items.length];
|
||
if (!total) { show(0, 0, fresh); return; }
|
||
const k = r.height ? r.height / total : 1; // the page may be zoomed
|
||
const view = vp.height / k;
|
||
const top = (vp.top - r.top) / k, bottom = (vp.bottom - r.top) / k;
|
||
// the rows in view and half a view about it
|
||
const a = top - view / 2 > total ? items.length : at(Math.max(0, top - view / 2));
|
||
const b = bottom + view / 2 < 0 ? 0 : Math.min(items.length, at(bottom + view / 2) + 1);
|
||
if (fresh) { show(a, Math.max(a, b), true); return; }
|
||
// a scroll past what's filled in: the rest, now
|
||
if (from === to || (a < b && (a < from || b > to))) show(0, items.length, from === to);
|
||
};
|
||
lists.add(w);
|
||
return w;
|
||
}
|
||
|
||
// ---------- projects ----------
|
||
|
||
// A project is a folder of the user's whose agents get some of the
|
||
// library's skills there, as the project's own: linked (or copied) into
|
||
// the folder each agent reads a project's skills from, and kept out of
|
||
// git in the project's .gitignore. Only what magpie placed is taken away.
|
||
let addingProject = null; // the folder being added: { dir, error, busy }
|
||
const openProjects = new Set(); // projects whose skills are shown
|
||
const projectAgents = () => shownAgents().filter((a) => a.projectSkills);
|
||
|
||
function renderProjects(body) {
|
||
const rh = el("div", "row-head");
|
||
rh.append(el("span", "label", t("In projects")), el("span", "grow"));
|
||
if (!addingProject) rh.append(button(t("Add a project"), "action lib-updall", () => { addingProject = { dir: "" }; render(); }));
|
||
body.append(rh);
|
||
if (addingProject) body.append(addProjectCard());
|
||
if (!lib.projects.length && !addingProject) {
|
||
body.append(el("p", "lib-aside", t("Give a project's agents some of these skills as the project's own: magpie links them into its .claude/skills and .agents/skills and keeps them out of git.")));
|
||
return;
|
||
}
|
||
for (const p of lib.projects) body.append(projectCard(p));
|
||
}
|
||
|
||
function addProjectCard() {
|
||
const a = addingProject;
|
||
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 = "project";
|
||
inp.placeholder = t("The project's folder, like ~/code/app");
|
||
inp.spellcheck = false;
|
||
inp.autocomplete = "off";
|
||
inp.value = a.dir;
|
||
inp.oninput = () => { a.dir = inp.value; };
|
||
inp.onkeydown = (e) => { e.stopPropagation(); if (e.key === "Enter") add(); else if (e.key === "Escape") { addingProject = null; render(); } };
|
||
line.append(glyph(GLYPH.folder, "lib-mini"), inp);
|
||
// the app has the system's folder picker; a browser tab only the path
|
||
if (!web) {
|
||
line.append(button(t("Choose…"), "", async () => {
|
||
try {
|
||
const r = await api("library/projects/choose", {});
|
||
if (r.dir) { a.dir = r.dir; a.error = ""; render(); }
|
||
} catch (e) { status(e.message, "err", 6000); }
|
||
}));
|
||
}
|
||
const go = button(a.busy ? t("Adding…") : t("Add"), "action", () => add());
|
||
go.disabled = !!a.busy;
|
||
line.append(go, button(t("Cancel"), "", () => { addingProject = null; render(); }));
|
||
card.append(line);
|
||
if (a.error) card.append(el("div", "lib-err", a.error));
|
||
async function add() {
|
||
const dir = a.dir.trim();
|
||
if (!dir || a.busy) return;
|
||
a.busy = true;
|
||
render();
|
||
const was = new Set(lib.projects.map((x) => x.dir));
|
||
try {
|
||
take(await api("library/projects/add", { dir }));
|
||
addingProject = null;
|
||
const p = lib.projects.find((x) => !was.has(x.dir));
|
||
if (p) openProjects.add(p.dir);
|
||
status(t("{name} added — pick the skills it gets", { name: p?.name || dir }), "ok");
|
||
} catch (e) {
|
||
a.busy = false;
|
||
a.error = e.message;
|
||
}
|
||
render();
|
||
}
|
||
return card;
|
||
}
|
||
|
||
function projectCard(p) {
|
||
const card = el("div", "list lib-card lib-project" + (p.missing ? " missing" : ""));
|
||
const isOpen = openProjects.has(p.dir);
|
||
const head = el("div", "row lib-row click lib-projhead" + (isOpen ? " open" : ""));
|
||
const who = el("div", "who");
|
||
const nm = el("div", "name", p.name);
|
||
if (p.missing) nm.append(tag(t("Folder is gone"), "warn"));
|
||
else if (p.copy) nm.append(tag(t("Copies"), ""));
|
||
who.append(nm);
|
||
const src = el("div", "lib-src");
|
||
src.append(pathLink(p.dir));
|
||
who.append(src);
|
||
const names = Object.keys(p.skills).sort();
|
||
const pills = el("div", "lib-tags");
|
||
if (!isOpen) {
|
||
for (const n of names.slice(0, 4)) pills.append(tag(n, "lib-dot"));
|
||
if (names.length > 4) pills.append(tag("+" + (names.length - 4), ""));
|
||
if (!names.length) pills.append(tag(t("No skills yet"), "lib-unchecked"));
|
||
}
|
||
const acts = el("div", "lib-rowacts");
|
||
const rm = button("", "lib-icon danger", () => confirmRemoveProject(p));
|
||
rm.append(svg(GLYPH.trash, 13, 1.4));
|
||
rm.title = t("Remove the project");
|
||
acts.append(rm);
|
||
const chev = el("span", "chev");
|
||
chev.append(svg(CHEV_R, 11, 1.7));
|
||
head.append(glyph(GLYPH.folder), who, pills, acts, chev);
|
||
head.onclick = () => { isOpen ? openProjects.delete(p.dir) : openProjects.add(p.dir); render(); };
|
||
head.title = isOpen ? t("Hide its skills") : t("Pick the skills it gets");
|
||
card.append(head);
|
||
if (p.problems?.[""]) card.append(el("div", "lib-err", p.problems[""]));
|
||
if (!isOpen) return card;
|
||
|
||
const opts = el("div", "lib-projopts");
|
||
const copy = toggle(p.copy, t("Copy files instead of linking"), (on) =>
|
||
change("projects/copy", { dir: p.dir, copy: on }, on ? t("{name} gets copies now", { name: p.name }) : t("{name} gets links now", { name: p.name })));
|
||
const lab = el("label", "lib-projcopy");
|
||
const words = el("span", "", t("Copy files instead of linking"));
|
||
words.onclick = (e) => { e.preventDefault(); copy.click(); };
|
||
lab.append(copy, words);
|
||
opts.append(lab, el("span", "note", p.copy
|
||
? t("Copies are made again whenever the library's skill changes; edits made to them are replaced.")
|
||
: t("Links follow the library's skill: an update reaches the project at once.")));
|
||
card.append(opts);
|
||
|
||
const all = projectAgents();
|
||
for (const s of lib.skills) {
|
||
const row = el("div", "row lib-row lib-projskill");
|
||
const w = el("div", "who");
|
||
const n = el("div", "name", s.name);
|
||
const problem = p.problems?.[s.name];
|
||
if (problem) n.append(tag(t("Not placed"), "warn", problem));
|
||
w.append(n);
|
||
const sub = el("div", "sub", problem || s.description || "");
|
||
sub.title = problem || s.description || "";
|
||
w.append(sub);
|
||
const on = p.skills[s.name] || [];
|
||
// an agent reading the same folder as one that has it has it too
|
||
const via = (a) => { const o = on.find((id) => agentOf(id)?.projectSkills === a.projectSkills); return o ? nameOf(o) : ""; };
|
||
row.append(mark(s.icon, GLYPH.skill), w, agentChips(all, on, (next) =>
|
||
change("projects/skill", { dir: p.dir, name: s.name, agents: next }, next.length
|
||
? t("{skill} is in {name} for {agents}", { skill: s.name, name: p.name, agents: next.map(nameOf).join(", ") })
|
||
: t("{skill} is out of {name}", { skill: s.name, name: p.name })), { via }));
|
||
card.append(row);
|
||
}
|
||
const dirs = [...new Set(all.map((a) => a.projectSkills))];
|
||
card.append(el("p", "lib-aside lib-projfoot", t("Placed in {dirs} of the project and listed in its .gitignore. A folder there that isn't magpie's is left as it is.", { dirs: dirs.join(", ") })));
|
||
const skip = shownAgents().filter((a) => a.skills && !a.projectSkills);
|
||
if (skip.length) card.append(el("p", "lib-aside lib-projfoot", t("{agents} reads no project skills folder magpie knows of.", { agents: skip.map((a) => a.name).join(", ") })));
|
||
return card;
|
||
}
|
||
|
||
function confirmRemoveProject(p) {
|
||
const ed = el("div", "editor lib-editor");
|
||
const head = el("div", "ehead");
|
||
head.append(glyph(GLYPH.trash), el("b", "", t("Remove {name}?", { name: p.name })));
|
||
ed.append(head);
|
||
ed.append(el("p", "lib-confirm", t("The skills magpie placed in it are taken away, with their lines in its .gitignore. Nothing else in the folder is touched.")));
|
||
const bar = el("div", "bar");
|
||
const go = button(t("Remove"), "primary danger-fill", async () => {
|
||
if (await change("projects/remove", { dir: p.dir }, t("{name} removed", { name: p.name }))) { openProjects.delete(p.dir); closeLibModal(); }
|
||
});
|
||
bar.append(el("span", "grow"), button(t("Cancel"), "", closeLibModal), go);
|
||
ed.append(bar);
|
||
modal = { save: () => go.click() };
|
||
openLib(ed);
|
||
}
|
||
|
||
// Every skill from GitHub, fetched again; the ones that couldn't be are
|
||
// said, and the rest are updated all the same.
|
||
// names, when given, are the skills to update: those a check found GitHub
|
||
// to have changed.
|
||
async function updateAllSkills(names) {
|
||
try {
|
||
const v = await api(names ? "library/skills/update-some" : "library/skills/update-all", names ? { names } : {});
|
||
take(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);
|
||
}
|
||
}
|
||
|
||
// Every skill found in the agents brought in at once: one that couldn't be
|
||
// is said, and the others are brought in all the same.
|
||
async function importAllSkills(names) {
|
||
try {
|
||
const v = await api("library/skills/import-all", { names });
|
||
take(v);
|
||
const res = v.result || {}, no = res.unimported || [];
|
||
if (no.length) {
|
||
const p = no[0];
|
||
status(t("{name} wasn't brought in: {error}", { name: p.what.replace(/^skill:/, ""), error: p.error }) + (no.length > 1 ? " " + t("(and {n} more)", { n: no.length - 1 }) : ""), "warn", 8000);
|
||
} else report(res, t("{n} skills are in the library now", { n: names.length }));
|
||
render();
|
||
} catch (e) {
|
||
status(e.message, "err", 6000);
|
||
}
|
||
}
|
||
|
||
// Every skill on, or off, for every agent shown that can take skills, in
|
||
// one write rather than a row's All for each (#443); an agent not shown
|
||
// keeps what it has. Off asks first, in the page.
|
||
function everySkillButtons(all) {
|
||
const ids = all.map((a) => a.id), n = all.length;
|
||
const on = button(t("Turn all on"), "action lib-updall lib-everyon", () => everySkill(ids, true));
|
||
on.title = t("Give every skill to all {n} agents that can take skills", { n });
|
||
on.disabled = lib.skills.every((s) => ids.every((id) => s.agents?.includes(id)));
|
||
const off = button(t("Turn all off"), "action lib-updall lib-everyoff", () => confirmEveryOff(ids));
|
||
off.title = t("Take every skill from all {n} agents", { n });
|
||
off.disabled = !lib.skills.some((s) => ids.some((id) => s.agents?.includes(id)));
|
||
return [on, off];
|
||
}
|
||
|
||
function confirmEveryOff(ids) {
|
||
const ed = el("div", "editor lib-editor");
|
||
const head = el("div", "ehead");
|
||
head.append(glyph(GLYPH.skill), el("b", "", t("Turn off all {n} skills?", { n: lib.skills.length })));
|
||
ed.append(head);
|
||
ed.append(el("p", "lib-confirm", t("Every skill is taken out of {agents}. They stay in the library, to turn on again.", { agents: ids.map(nameOf).join(", ") })));
|
||
const bar = el("div", "bar");
|
||
const go = button(t("Turn all off"), "primary danger-fill", async () => { go.disabled = true; if (await everySkill(ids, false)) closeLibModal(); else go.disabled = false; });
|
||
bar.append(el("span", "grow"), button(t("Cancel"), "", closeLibModal), go);
|
||
ed.append(bar);
|
||
modal = { save: () => go.click() };
|
||
openLib(ed);
|
||
}
|
||
|
||
async function everySkill(ids, on) {
|
||
try {
|
||
const v = await api("library/skills/agents-all", { agents: ids, on });
|
||
take(v);
|
||
const n = lib.skills.length, m = ids.length;
|
||
report(v.result, on ? t("{n} skills are on for all {m} agents", { n, m }) : t("{n} skills are off for all {m} agents", { n, m }));
|
||
render();
|
||
return true;
|
||
} catch (e) {
|
||
status(e.message, "err", 6000);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// One agent's skills, or servers, on or off at once (#475), rather than a
|
||
// chip on every row: the agents that can take them, each with how many it
|
||
// has, a button that gives it every one it can take and one that takes
|
||
// every one from it. Every other agent keeps what it has. An agent hidden
|
||
// on the Agents page (#71) has no chips, so it's listed here while it has
|
||
// any, to take them out of it; it isn't given more.
|
||
function byAgentButton(kind) {
|
||
const b = button(t("By agent"), "action lib-updall lib-byagent", () => openByAgent(kind));
|
||
b.title = kind === "skills" ? t("Turn every skill on or off for one agent") : t("Turn every server on or off for one agent");
|
||
return b;
|
||
}
|
||
|
||
// kept: agents listed when the sheet opened, which stay listed in it once
|
||
// a hidden one has nothing left
|
||
function byAgentRows(kind, kept) {
|
||
const items = kind === "skills" ? lib.skills : lib.servers;
|
||
const can = kind === "skills" ? () => true : (s, a) => reaches(s)(a);
|
||
return lib.agents.filter((a) => kind === "skills" ? a.skills : a.mcp).map((a) => {
|
||
const has = items.filter((x) => x.agents?.includes(a.id)).length;
|
||
const missing = items.filter((x) => can(x, a) && !x.agents?.includes(a.id)).length;
|
||
return { a, has, missing, hidden: isHidden(a) };
|
||
}).filter((r) => !r.hidden || r.has || kept?.has(r.a.id));
|
||
}
|
||
|
||
function openByAgent(kind) {
|
||
const skills = kind === "skills", items = () => skills ? lib.skills : lib.servers;
|
||
const ed = el("div", "editor lib-editor lib-byagent-sheet");
|
||
const head = el("div", "ehead");
|
||
head.append(glyph(skills ? GLYPH.skill : GLYPH.cmd), el("b", "", skills ? t("Skills by agent") : t("MCP servers by agent")));
|
||
ed.append(head);
|
||
ed.append(el("p", "lib-confirm", skills
|
||
? t("Every skill on or off for one agent; the others keep theirs. Turned off, they stay in the library.")
|
||
: t("Every server on or off for one agent; the others keep theirs. Turned off, they stay in the library.")));
|
||
const list = el("div", "list lib-list lib-byagent-list");
|
||
let busy = false;
|
||
const listed = new Set(byAgentRows(kind).map((r) => r.a.id));
|
||
const draw = () => list.replaceChildren(...byAgentRows(kind, listed).map(({ a, has, missing, hidden }) => {
|
||
const row = el("div", "row lib-row lib-byagent-row");
|
||
row.dataset.agent = a.id;
|
||
const who = el("div", "who");
|
||
const name = el("div", "name", a.name);
|
||
if (hidden) name.append(tag(t("Hidden"), "", t("Hidden on the Agents page: it isn't given anything new, and what it has can be taken out here")));
|
||
who.append(name, el("div", "sub", skills ? t("{n} of {m} skills", { n: has, m: items().length }) : t("{n} of {m} servers", { n: has, m: items().length })));
|
||
const on = button(t("Turn all on"), "action lib-updall lib-agenton", (e, b) => everyFor(a, true, b));
|
||
on.disabled = busy || hidden || !missing;
|
||
const off = button(t("Turn all off"), "action lib-updall lib-agentoff", (e, b) => everyFor(a, false, b));
|
||
off.disabled = busy || !has;
|
||
on.title = skills ? t("Give {agent} every skill", { agent: a.name }) : t("Give {agent} every server it can reach", { agent: a.name });
|
||
off.title = skills ? t("Take every skill from {agent}", { agent: a.name }) : t("Take every server from {agent}", { agent: a.name });
|
||
row.append(agentIcon(a.icon), who, on, off);
|
||
return row;
|
||
}));
|
||
// what each button wrote is what the list then shows; the page under
|
||
// it is drawn again too, its chips with it
|
||
async function everyFor(a, on, b) {
|
||
busy = true;
|
||
for (const x of list.querySelectorAll("button")) x.disabled = true;
|
||
b.classList.add("busy");
|
||
try {
|
||
const v = await api("library/" + (skills ? "skills" : "servers") + "/agents-all", { agents: [a.id], on });
|
||
take(v);
|
||
const n = items().filter((x) => x.agents?.includes(a.id)).length;
|
||
report(v.result, skills
|
||
? (on ? t(n === 1 ? "1 skill is on for {agent}" : "{n} skills are on for {agent}", { n, agent: a.name }) : t("Every skill is off for {agent}", { agent: a.name }))
|
||
: (on ? t(n === 1 ? "1 server is on for {agent}" : "{n} servers are on for {agent}", { n, agent: a.name }) : t("Every server is off for {agent}", { agent: a.name })));
|
||
render();
|
||
} catch (e) {
|
||
status(e.message, "err", 6000);
|
||
}
|
||
busy = false;
|
||
if (list.isConnected) draw();
|
||
}
|
||
draw();
|
||
ed.append(list);
|
||
const bar = el("div", "bar");
|
||
const done = button(t("Done"), "primary", closeLibModal);
|
||
bar.append(el("span", "grow"), done);
|
||
ed.append(bar);
|
||
modal = { save: () => done.click() };
|
||
openLib(ed);
|
||
}
|
||
|
||
// Every skill out of the library at once (#449), each as a row's Remove
|
||
// takes it: out of every agent and project, its folder moved to magpie's
|
||
// backups, or for one linked from a folder of the user's only the link
|
||
// taken away. It asks first, in the page, saying who loses what.
|
||
function removeEverySkillButton() {
|
||
const b = button(t("Remove all"), "action danger lib-updall lib-everyrm", () => confirmRemoveEverySkill());
|
||
b.title = t("Take all {n} skills out of the library", { n: lib.skills.length });
|
||
return b;
|
||
}
|
||
|
||
function confirmRemoveEverySkill() {
|
||
const skills = lib.skills.slice(), names = skills.map((s) => s.name), n = names.length;
|
||
const agents = [...new Set(skills.flatMap((s) => s.agents || []))];
|
||
const linked = skills.filter((s) => s.kind === "folder").length, kept = n - linked;
|
||
const projects = (lib.projects || []).filter((p) => names.some((x) => p.skills?.[x]?.length)).length;
|
||
const ed = el("div", "editor lib-editor");
|
||
const head = el("div", "ehead");
|
||
head.append(glyph(GLYPH.trash), el("b", "", t("Remove all {n} skills?", { n })));
|
||
ed.append(head);
|
||
ed.append(el("p", "lib-confirm", agents.length
|
||
? t("They are taken out of the library and out of {agents}.", { agents: agents.map(nameOf).join(", ") })
|
||
: t("They are taken out of the library; no agent has any of them.")));
|
||
if (projects) ed.append(el("p", "lib-confirm", t("The skills magpie placed in {n} projects are taken away too.", { n: projects })));
|
||
const where = [];
|
||
if (kept) where.push(t("{n} folders are moved to magpie's backups (Backups, at the foot of the Library), not deleted.", { n: kept }));
|
||
if (linked) where.push(t("{n} linked from folders of your own are only unlinked: those folders stay where they are.", { n: linked }));
|
||
ed.append(el("p", "lib-confirm", where.join(" ")));
|
||
const bar = el("div", "bar");
|
||
const go = button(t("Remove all {n}", { n }), "primary danger-fill", async () => { go.disabled = true; if (await removeEverySkill(names)) closeLibModal(); else go.disabled = false; });
|
||
bar.append(el("span", "grow"), button(t("Cancel"), "", closeLibModal), go);
|
||
ed.append(bar);
|
||
modal = { save: () => go.click() };
|
||
openLib(ed);
|
||
}
|
||
|
||
async function removeEverySkill(names) {
|
||
try {
|
||
const v = await api("library/skills/remove-all", { names });
|
||
take(v);
|
||
const res = v.result || {}, no = res.unremoved || [];
|
||
if (no.length) {
|
||
const p = no[0];
|
||
status(t("{name} wasn't removed: {error}", { name: p.what.replace(/^skill:/, ""), error: p.error }) + (no.length > 1 ? " " + t("(and {n} more)", { n: no.length - 1 }) : ""), "warn", 8000);
|
||
} else report(res, t("{n} skills removed", { n: names.length }));
|
||
render();
|
||
return true;
|
||
} catch (e) {
|
||
status(e.message, "err", 6000);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Which skills GitHub changed since they were installed: each row says,
|
||
// and the heading offers to update just those.
|
||
async function checkSkills() {
|
||
if (checking) return;
|
||
checking = true;
|
||
render();
|
||
try {
|
||
take(await api("library/skills/check", {}));
|
||
const got = lib.skills.filter((s) => s.check);
|
||
const n = got.filter((s) => s.check.status === "update").length;
|
||
const unknown = got.filter((s) => s.check.status === "unknown");
|
||
let msg = n ? (n === 1 ? t("1 skill has an update") : t("{n} skills have updates", { n })) : t("Every skill is up to date");
|
||
if (unknown.length) {
|
||
msg += " · " + t("{n} couldn't be checked: {error}", { n: unknown.length, error: unknown[0].check.error });
|
||
status(msg, "warn", 8000);
|
||
} else status(msg, "ok");
|
||
} catch (e) {
|
||
status(e.message, "err", 6000);
|
||
}
|
||
checking = false;
|
||
render();
|
||
}
|
||
|
||
// what a check found of a skill, for a tooltip: the last commit to it
|
||
function checkLine(c) {
|
||
const when = c.date ? new Date(c.date).toLocaleDateString() : "";
|
||
return [c.message, [when, c.commit?.slice(0, 7)].filter(Boolean).join(" · ")].filter(Boolean).join("\n");
|
||
}
|
||
|
||
// 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" : "") + (s.source ? " src" : ""));
|
||
const who = el("div", "who");
|
||
const nm = el("div", "name", s.name);
|
||
const c = s.check;
|
||
if (c?.status === "update") {
|
||
const b = tag(t("Update available"), "lib-new", t("Changed on GitHub since it was installed") + "\n" + checkLine(c));
|
||
if (c.url) {
|
||
b.classList.add("link");
|
||
b.onclick = (e) => { e.stopPropagation(); browse(c.url); };
|
||
}
|
||
nm.append(b);
|
||
} else if (c?.status === "unknown") {
|
||
nm.append(tag(t("Not checked"), "lib-unchecked", c.error));
|
||
}
|
||
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");
|
||
if (c?.status === "current") u.title += "\n" + t("Up to date with GitHub") + "\n" + checkLine(c);
|
||
else if (c?.status === "update") u.title += "\n" + checkLine(c);
|
||
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), all: true }));
|
||
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.shared) { const src = el("div", "lib-src"); src.append(el("span", "", t("shared in")), pathLink(f.shared)); who.append(src); }
|
||
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");
|
||
// an agent with a copy of the very same files has it as much as one
|
||
// with the folder itself: it isn't said to differ
|
||
for (const id of [...f.agents, ...(f.copies || [])]) { const a = agentOf(id); if (a) { const i = agentIcon(a.icon); i.title = f.agents.includes(id) ? a.name : a.name + " — " + t("{agents} has the very same files; bringing it in gives it the library's, its copy kept with the backups", { agents: 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.shared ? t("Keeps it where it is in the shared skills folder and links to it: you can give it to any agent")
|
||
: 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, ...(f.copies || [])].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, ...(f.copies || [])].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.optIn) && !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"); } // take asked the market again
|
||
return ok;
|
||
}
|
||
|
||
// A market server up close: what it is, what it needs, and who gets it.
|
||
function serverSheet(x) {
|
||
const all = mcpAgents();
|
||
// an opt-in one (magpie's image generation costs what its model does)
|
||
// goes only to the agents picked for it
|
||
let agents = x.optIn ? [] : 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.")));
|
||
if (x.optIn) ed.append(el("p", "mk-hint", t("Pick the agents that may generate images: each image costs what the model set in Settings → Images charges.")));
|
||
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"); } // take asked the market again
|
||
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().then(() => { if (!modal) $("#modal").classList.remove("lib"); }); }
|
||
// 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: take asks it again.
|
||
async function quietLoad() {
|
||
if (page.hidden || modal || dirty() || probing) return;
|
||
await load(true);
|
||
}
|
||
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();
|
||
})();
|