library, gui: the MCP servers page has the skills page's Select, Turn all on, Turn all off and Remove all, under one "In the library" heading (#1027, emo172)

The servers page had only By agent and the order, each with its own "In the library" heading, so with both the heading showed twice. Turning every server on or off, or removing them all, took one chip or one row at a time. The page now has one heading (lib-serverhead, which wraps when narrow) holding the order, Select, Turn all on, Turn all off, By agent and Remove all, as the skills page does. The skills page's helpers now serve both pages rather than being copied. picking names the page ("skills" or "servers"). pickingFor, pickRow, pickBar and groupChips(kind) are shared, and so are everyButtons, confirmEveryOff and everyOf, which replace everySkillButtons and everySkill. removeEveryButton, confirmRemoveEvery and removeEvery replace removeEverySkillButton. The skills page says and does what it did before.

- Turn all on and Turn all off post every agent shown to servers/agents-all (EveryServerAgents, now serversAgents(nil, …)). A hidden agent is left alone. Turn all on is greyed once every server is on for every agent that can reach it. Turn all off asks first, in the page.
- Select puts a box on each row. A row's click ticks it rather than opening the server. The bar's chips post the servers picked to the new servers/agents-some (SomeServersAgents), which gives an agent only the ones it can reach (mcpFile.supports) and leaves unnamed servers and agents as they are. An agent that can reach none of the picks has its chip greyed, with the reason.
- Remove all asks first. It says which agents lose the servers, the projects they are taken from, the sign-ins forgotten, and that the agents' own servers stay. It then posts every name to the new servers/remove-all (RemoveServers). This is one change that takes each server out as RemoveServer does: out of the library, its projects and its icon (removeServer, now shared by both), then out of every agent through syncMCP, which takes only what Applied says magpie wrote, and then forgets its sign-in. A name the library hasn't is listed in Unremoved, and the rest go all the same.
- The pick bar wraps, so its chips and Done fit at 440px in ja and de.

TestRemoveServers checks a server the user put in Claude Code's file and Codex's own "docs" of a library server's name: both stay, while fs, docs and neon leave the library, both agents and the project's .mcp.json, and the project's own "team" stays. neon's sign-in is gone, "nope" is reported, and an empty list or one with only "nope" errors. TestSomeServersAgents checks that Codex gets fs but not the SSE web, that git stays untouched, that a call naming no servers or no agents errors, and that a call naming an unknown server writes nothing. Both pass with -race -count=10, alongside TestEveryServerAgents and TestRemoveServer*.

library-servers-bulk.test.cjs passes on Chromium and WebKit in en, zh, ja and de at 440px. It checks one heading with every button, Turn all on's post without the hidden Copilot CLI, Turn all off's Cancel posting nothing and Confirm keeping Copilot CLI's, the pick bar's codex chip posting agents-some for the two picked and its greyed state for web alone, the All box, Done, and Remove all's lines, Cancel and post. It also checks for no sideways scroll and that each control clicked stays where it was on the screen. The fake check answers every server, since an unchecked one is checked again at each drawing. It fails without the change and passes 6 runs beside pick-skills, every-skill and gui-ja at --test-concurrency=4. by-agent, pick-skills, every-skill, remove-all-skills, group-*, library-*, mcp-signin, project-servers, skill-*, i18n-keys, gui-ja and gui-de pass. skill-how's WebKit case and click-scroll's "Live and a day" fail the same on the unchanged origin/main when run beside each other, and pass alone. go test -tags nogui ./internal/..., go vet, gofmt -l, and the linux (nogui) and windows builds pass. Not tried in the real app. docs/subsystems/library.md says it.
This commit is contained in:
yetone committed 2026-10-06 22:24:04 +08:00
1 parent 51d2cf88ea
commit dcbae2ef98
8 files changed
+706 -109

No files matched your search

+3 -3
View File
@@ -23,7 +23,7 @@ agents RTK, a CLI that cuts shell output down before the model reads it.
## Runtime path
1. **Edit.** The Library page or `magpie library` calls an exported function such as `SaveServer`, `ServerAgents`, `InstallSkills`, `SkillAgents` or `SetSkillHow`.
1. **Edit.** The Library page or `magpie library` calls an exported function such as `SaveServer`, `ServerAgents`, `InstallSkills`, `SkillAgents` or `SetSkillHow`. The skills and servers pages each have Select, Turn all on, Turn all off and Remove all. Select's bar calls `SomeSkillsAgents` or `SomeServersAgents` (`/api/library/{skills,servers}/agents-some`), Turn all on or off calls `EverySkillAgents` or `EveryServerAgents` for the agents shown, and Remove all calls `RemoveSkills` or `RemoveServers`. Each of these is one change that takes every item out as a single removal would (#1027).
2. **Change.** `change` edits `library.json` under the lock and calls `sync`.
3. **Write.** For each target, `syncInstructions` and `syncMCP` write magpie's part in the agent's format and record it in `Applied`. Skills are linked or copied into the agent's skills folder. A copy edited since it was made is taken into the library first.
4. **Result.** The `Result` lists the agents changed, a `Problem` for each thing that couldn't be given, and the backup folder.
@@ -40,9 +40,9 @@ agents RTK, a CLI that cuts shell output down before the model reads it.
## Verification
```sh
go test -tags nogui ./internal/library -run 'TestTargets|TestServer|TestImportServer|TestCheck|TestInstructions|TestInstructionSets|TestSkill|TestEverySkillAgents|TestRTK'
go test -tags nogui ./internal/library -run 'TestTargets|TestServer|TestImportServer|TestCheck|TestInstructions|TestInstructionSets|TestSkill|TestEverySkillAgents|TestEveryServerAgents|TestSomeServersAgents|TestRemoveServers|TestRTK'
go test -tags nogui ./internal/library
node --test internal/gui/tests/library-items.test.cjs internal/gui/tests/library-rtk-gain.test.cjs internal/gui/tests/library-rtk-nohook.test.cjs
node --test internal/gui/tests/library-items.test.cjs internal/gui/tests/library-rtk-gain.test.cjs internal/gui/tests/library-rtk-nohook.test.cjs internal/gui/tests/library-servers-bulk.test.cjs
```
The package's `TestMain` runs in a home of its own (`testenv`), never the
+72
View File
@@ -3193,6 +3193,30 @@ const I18N = {
"Remove all {n}": "删除全部 {n} 个",
"{n} skills removed": "已删除 {n} 个技能",
"{name} wasn't removed: {error}": "{name} 未能删除:{error}",
// the servers' page's Select, Turn all on/off and Remove all (#1027)
"Stop picking servers": "结束多选",
"Pick some servers and turn them on or off together": "选中多个服务器,一起开启或关闭",
"Pick servers to turn them on or off together": "勾选服务器,再用右侧图标一起开启或关闭",
"{agent} can reach none of these servers": "{agent} 无法连接其中任何服务器",
"{agent} has all of these servers — click to take them away": "{agent} 已有其中全部服务器,点击全部关闭",
"{agent} has {n} of these servers — click to give it the rest": "{agent} 已有其中 {n} 个服务器,点击补齐其余的",
"Give {agent} all of these servers": "为 {agent} 启用其中全部服务器",
"Every agent has all of these servers — click to take them from every one": "每个 Agent 都已有其中全部服务器,点击全部关闭",
"Give all of these servers to every agent": "为每个 Agent 启用其中全部服务器",
"{n} servers are on for {agents}": "已为 {agents} 启用 {n} 个服务器",
"{n} servers are off for {agents}": "已从 {agents} 中关闭 {n} 个服务器",
"Give every server to all {n} agents, each the ones it can reach": "为全部 {n} 个 Agent 启用每个服务器(各自只启用能连接的)",
"Take every server from all {n} agents": "从全部 {n} 个 Agent 中关闭每个服务器",
"Turn off all {n} servers?": "关闭全部 {n} 个服务器?",
"Every server is taken out of {agents}. They stay in the library, to turn on again.": "所有服务器将从 {agents} 中移除,仍保留在资源库中,可随时重新启用。",
"{n} servers are on for all {m} agents": "已为全部 {m} 个 Agent 启用 {n} 个服务器",
"{n} servers are off for all {m} agents": "已从全部 {m} 个 Agent 中关闭 {n} 个服务器",
"Take all {n} servers out of the library": "从资源库中删除全部 {n} 个服务器",
"Remove all {n} servers?": "删除全部 {n} 个服务器?",
"The servers magpie placed in {n} projects are taken away too.": "magpie 放进 {n} 个项目的这些服务器也会一并移除。",
"magpie's sign-in to {n} of them is forgotten too.": "magpie 对其中 {n} 个服务器的登录也会一并清除。",
"Servers your agents have that aren't in the library stay as they are.": "Agent 自己的、不在资源库中的服务器保持不变。",
"{n} servers removed": "已删除 {n} 个服务器",
"Keeps it in the library: {agents} go on having it, and you can give it to the others": "放进资源库:{agents} 继续拥有它,你也可以把它给其他 Agent",
"+ Add": "+ 添加",
"New MCP server": "新的 MCP 服务器",
@@ -6693,6 +6717,30 @@ const I18N = {
"Remove all {n}": "{n} 個すべて削除",
"{n} skills removed": "{n} 個のスキルを削除しました",
"{name} wasn't removed: {error}": "{name} を削除できませんでした:{error}",
// the servers' page's Select, Turn all on/off and Remove all (#1027)
"Stop picking servers": "選択を終了",
"Pick some servers and turn them on or off together": "複数のサーバーを選んでまとめてオン・オフする",
"Pick servers to turn them on or off together": "サーバーを選び、右のアイコンでまとめてオン・オフ",
"{agent} can reach none of these servers": "{agent} はこれらのサーバーのどれにも接続できません",
"{agent} has all of these servers — click to take them away": "{agent} はこれらのサーバーをすべて持っています — クリックですべて外す",
"{agent} has {n} of these servers — click to give it the rest": "{agent} はこれらのうち {n} 個のサーバーを持っています — クリックで残りを渡す",
"Give {agent} all of these servers": "{agent} にこれらのサーバーをすべて渡す",
"Every agent has all of these servers — click to take them from every one": "すべてのエージェントがこれらのサーバーをすべて持っています — クリックですべてから外す",
"Give all of these servers to every agent": "すべてのエージェントにこれらのサーバーをすべて渡す",
"{n} servers are on for {agents}": "{agents} で {n} 個のサーバーをオンにしました",
"{n} servers are off for {agents}": "{agents} で {n} 個のサーバーをオフにしました",
"Give every server to all {n} agents, each the ones it can reach": "{n} 個のエージェントすべてに、それぞれ接続できる全サーバーを渡す",
"Take every server from all {n} agents": "{n} 個のエージェントすべてから全サーバーを外す",
"Turn off all {n} servers?": "{n} 個のサーバーをすべてオフにしますか?",
"Every server is taken out of {agents}. They stay in the library, to turn on again.": "すべてのサーバーが {agents} から外されます。ライブラリには残り、いつでもオンに戻せます。",
"{n} servers are on for all {m} agents": "{m} 個のエージェントすべてで {n} 個のサーバーをオンにしました",
"{n} servers are off for all {m} agents": "{m} 個のエージェントすべてで {n} 個のサーバーをオフにしました",
"Take all {n} servers out of the library": "{n} 個のサーバーをすべてライブラリから削除",
"Remove all {n} servers?": "{n} 個のサーバーをすべて削除しますか?",
"The servers magpie placed in {n} projects are taken away too.": "magpie が {n} 個のプロジェクトに置いたこれらのサーバーも外されます。",
"magpie's sign-in to {n} of them is forgotten too.": "そのうち {n} 個のサーバーへの magpie のサインインも消去されます。",
"Servers your agents have that aren't in the library stay as they are.": "エージェント自身の、ライブラリにないサーバーはそのまま残ります。",
"{n} servers removed": "{n} 個のサーバーを削除しました",
"Keeps it in the library: {agents} go on having it, and you can give it to the others": "ライブラリに保管します:{agents} はそのまま使え、他のエージェントにも渡せます",
"+ Add": "+ 追加",
"New MCP server": "新しい MCP サーバー",
@@ -10211,6 +10259,30 @@ const I18N = {
"Remove all {n}": "Alle {n} entfernen",
"{n} skills removed": "{n} Skills entfernt",
"{name} wasn't removed: {error}": "{name} wurde nicht entfernt: {error}",
// the servers' page's Select, Turn all on/off and Remove all (#1027)
"Stop picking servers": "Mehrfachauswahl beenden",
"Pick some servers and turn them on or off together": "Mehrere Server auswählen und gemeinsam ein- oder ausschalten",
"Pick servers to turn them on or off together": "Wählen Sie Server aus, um sie gemeinsam ein- oder auszuschalten",
"{agent} can reach none of these servers": "{agent} erreicht keinen dieser Server",
"{agent} has all of these servers — click to take them away": "{agent} hat alle diese Server — klicken, um sie zu entfernen",
"{agent} has {n} of these servers — click to give it the rest": "{agent} hat {n} dieser Server — klicken, um ihm den Rest zu geben",
"Give {agent} all of these servers": "{agent} alle diese Server geben",
"Every agent has all of these servers — click to take them from every one": "Jeder Agent hat alle diese Server — klicken, um sie allen zu entfernen",
"Give all of these servers to every agent": "Allen Agenten alle diese Server geben",
"{n} servers are on for {agents}": "{n} Server sind für {agents} an",
"{n} servers are off for {agents}": "{n} Server sind für {agents} aus",
"Give every server to all {n} agents, each the ones it can reach": "Jeden Server für alle {n} Agenten aktivieren, jeweils die, die er erreicht",
"Take every server from all {n} agents": "Jeden Server bei allen {n} Agenten ausschalten",
"Turn off all {n} servers?": "Alle {n} Server ausschalten?",
"Every server is taken out of {agents}. They stay in the library, to turn on again.": "Jeder Server wird aus {agents} entfernt. Sie bleiben in der Bibliothek und lassen sich wieder einschalten.",
"{n} servers are on for all {m} agents": "{n} Server sind für alle {m} Agenten an",
"{n} servers are off for all {m} agents": "{n} Server sind für alle {m} Agenten aus",
"Take all {n} servers out of the library": "Alle {n} Server aus der Bibliothek entfernen",
"Remove all {n} servers?": "Alle {n} Server entfernen?",
"The servers magpie placed in {n} projects are taken away too.": "Die Server, die magpie in {n} Projekte gelegt hat, werden ebenfalls entfernt.",
"magpie's sign-in to {n} of them is forgotten too.": "magpies Anmeldung bei {n} von ihnen wird ebenfalls vergessen.",
"Servers your agents have that aren't in the library stay as they are.": "Server Ihrer Agenten, die nicht in der Bibliothek sind, bleiben unverändert.",
"{n} servers removed": "{n} Server entfernt",
"Keeps it in the library: {agents} go on having it, and you can give it to the others": "Behält ihn in der Bibliothek: {agents} behalten ihn, und Sie können ihn den anderen geben",
"+ Add": "+ Hinzufügen",
"New MCP server": "Neuer MCP-Server",
+3 -2
View File
@@ -483,7 +483,7 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col
.lib-ag.some .ic.baked > img.grey { display: none; }
/* the skills' heading has the most on it: in a narrow window it goes on
to a second line rather than past the page's side */
#view-library .row-head.lib-skillshead { flex-wrap: wrap; row-gap: 6px; }
#view-library .row-head.lib-skillshead, #view-library .row-head.lib-serverhead { flex-wrap: wrap; row-gap: 6px; }
#view-library .row-head.lib-skillshead .lib-skillq { flex: 1 1 90px; max-width: 190px; }
#view-library .row-head .lib-skillq { width: 190px; height: 24px; margin: 0; flex: 0 1 auto; min-width: 90px; }
.lib-skillq::-webkit-search-cancel-button { -webkit-appearance: none; }
@@ -521,7 +521,8 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col
heading, and a bar that stays in view under the list while it's long */
.lib-pickbox { margin: 0; flex: none; }
.lib-vl > .row.lib-skill.picked { background: var(--accent-soft); }
.lib-pickbar { position: sticky; bottom: 12px; z-index: 3; margin-top: 8px; min-height: 44px; border: 1px solid var(--line); border-radius: 12px; background: var(--card); box-shadow: var(--shadow); }
.lib-list > .row.lib-server.picked { background: var(--accent-soft); }
.lib-pickbar { position: sticky; bottom: 12px; z-index: 3; margin-top: 8px; min-height: 44px; flex-wrap: wrap; row-gap: 6px; border: 1px solid var(--line); border-radius: 12px; background: var(--card); box-shadow: var(--shadow); }
.lib-pickall { display: inline-flex; align-items: center; gap: 8px; font-size: 12px; color: var(--fg-2); white-space: nowrap; }
.lib-pickn { font-size: 12px; font-weight: 500; color: var(--fg); white-space: nowrap; }
.lib-pickn.none { font-weight: 400; color: var(--faint); white-space: normal; }
+159 -86
View File
@@ -1220,26 +1220,30 @@
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();
pickingFor("servers", lib.servers);
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));
// one heading over the list, with what the skills' has (#1027): the
// order, Select, every server on or off for every agent shown, one
// agent's (#475, or out of a hidden one) and every server out
const many = lib.servers.length > 1, by = byAgentRows("servers").length;
if (many || by) {
const rh = el("div", "row-head lib-serverhead");
rh.append(el("span", "label", t("In the library")), el("span", "grow"));
if (many) rh.append(sortBy("libServers", NAME_SORTS, fill));
if (many && all.length) rh.append(pickButton("servers"), ...everyButtons("servers", all));
if (by) rh.append(byAgentButton("servers"));
if (many) rh.append(removeEveryButton("servers"));
body.append(rh);
}
fill();
body.append(list);
if (picking) body.append(pickBar(all));
const after = el("div", "after-list");
after.append(button(t("+ Add server"), "", () => editServer(null)));
body.append(after);
@@ -1266,7 +1270,7 @@
}
function serverRow(s, all) {
const row = el("div", "row lib-row click");
const row = el("div", "row lib-row click lib-server");
const who = el("div", "who");
const nm = el("div", "name mono", s.name);
if (s.signIn?.dead) nm.append(tag(t("Sign-in ran out"), "warn", t("Open it to sign in again")));
@@ -1279,6 +1283,7 @@
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 }));
if (picking) return pickRow(row, s.name);
row.onclick = () => editServer(s);
row.title = t("Edit {name}", { name: s.name });
return row;
@@ -1722,8 +1727,7 @@
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();
for (const n of picked) if (!skillNamed(n)) picked.delete(n);
if (lib.skills.length < 2) picking = false;
pickingFor("skills", lib.skills);
if (lib.skills.length) {
const rh = el("div", "row-head lib-skillshead");
rh.append(el("span", "label", t("In the library")));
@@ -1732,9 +1736,9 @@
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(pickButton(), ...everySkillButtons(all));
if (lib.skills.length > 1 && all.length) rh.append(pickButton("skills"), ...everyButtons("skills", all));
if (byAgentRows("skills").length) rh.append(byAgentButton("skills"));
if (lib.skills.length > 1) rh.append(removeEverySkillButton());
if (lib.skills.length > 1) rh.append(removeEveryButton("skills"));
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");
@@ -1813,7 +1817,7 @@
// 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 picking = false; // the skills' rows have a box each, to pick some (#791)
let picking = ""; // "skills" or "servers": that page's rows have a box each, to pick some (#791, #1027)
let naming = false; // the pick bar asks the name of a group to put them in
const picked = new Set(); // the names picked
let skillQuery = ""; // what the filter over the skills holds
@@ -2135,39 +2139,53 @@
// A part's chips (#791, mintonight: the lark-* skills of Feishu's pack
// on or off together) and a pick's are the same, said of so many skills
// rather than of a repository.
function groupChips(skills, all, repo) {
// Servers picked (#1027) are the same, counted of those an agent can
// reach: one given every server it can reach of them has them all, and
// one that can reach none of them can't be given them.
function groupChips(skills, all, repo, kind = "skills") {
const servers = kind === "servers";
const n = skills.length, names = skills.map((s) => s.name);
const count = (id) => skills.filter((s) => s.agents?.includes(id)).length;
const full = all.filter((a) => count(a.id) === n).map((a) => a.id);
const can = (s, a) => !servers || reaches(s)(a);
const reach = (a) => skills.filter((s) => can(s, a)).length;
const count = (a) => skills.filter((s) => can(s, a) && s.agents?.includes(a.id)).length;
const full = all.filter((a) => reach(a) && count(a) === reach(a)).map((a) => a.id);
const box = agentChips(all, full, async (next) => {
const on = next.filter((id) => !full.includes(id)), off = full.filter((id) => !next.includes(id));
box.classList.add("busy");
try {
let v = null;
if (on.length) v = await api("library/skills/agents-some", { names, agents: on, on: true });
if (off.length) v = await api("library/skills/agents-some", { names, agents: off, on: false });
if (on.length) v = await api("library/" + kind + "/agents-some", { names, agents: on, on: true });
if (off.length) v = await api("library/" + kind + "/agents-some", { names, agents: off, on: false });
if (!v) return;
take(v);
const who = (ids) => ids.map(nameOf).join(", ");
report(v.result, repo != null
? (on.length ? t("{repo}'s skills are on for {agents}", { repo, agents: who(on) }) : t("{repo}'s skills are off for {agents}", { repo, agents: who(off) }))
: n === 1 ? (on.length ? t("{name} is on for {agents}", { name: names[0], agents: who(on) }) : t("{name} is off for {agents}", { name: names[0], agents: who(off) }))
: servers ? (on.length ? t("{n} servers are on for {agents}", { n, agents: who(on) }) : t("{n} servers are off for {agents}", { n, agents: who(off) }))
: on.length ? t("{n} skills are on for {agents}", { n, agents: who(on) }) : t("{n} skills are off for {agents}", { n, agents: who(off) }));
} catch (e) {
status(e.message, "err", 6000);
}
render();
}, { all: true });
}, { all: true, blocked: servers ? (a) => (reach(a) ? "" : n === 1 ? sseBlocked(skills[0])(a) : t("{agent} can reach none of these servers", { agent: a.name })) : null });
box.classList.add("lib-groupchips");
for (const c of box.querySelectorAll(".lib-ag[data-agent]")) {
const a = all.find((x) => x.id === c.dataset.agent), k = count(a.id);
if (k && k < n) c.classList.add("some");
c.title = k === n ? t("{agent} has all of them — click to take them away", { agent: a.name })
: k ? t("{agent} has {n} of them — click to give it the rest", { agent: a.name, n: k })
: t("Give {agent} all of them", { agent: a.name });
const a = all.find((x) => x.id === c.dataset.agent), k = count(a), m = reach(a);
if (!m) continue;
if (k && k < m) c.classList.add("some");
c.title = servers
? (k === m ? t("{agent} has all of these servers — click to take them away", { agent: a.name })
: k ? t("{agent} has {n} of these servers — click to give it the rest", { agent: a.name, n: k })
: t("Give {agent} all of these servers", { agent: a.name }))
: k === n ? t("{agent} has all of them — click to take them away", { agent: a.name })
: k ? t("{agent} has {n} of them — click to give it the rest", { agent: a.name, n: k })
: t("Give {agent} all of them", { agent: a.name });
}
const every = box.querySelector(".lib-ag.all");
if (every) every.title = every.classList.contains("on") ? t("Every agent has all of them — click to take them from every one") : t("Give all of them to every agent");
if (every) every.title = every.classList.contains("on")
? (servers ? t("Every agent has all of these servers — click to take them from every one") : t("Every agent has all of them — click to take them from every one"))
: servers ? t("Give all of these servers to every agent") : t("Give all of them to every agent");
return box;
}
@@ -2177,16 +2195,36 @@
// skills under it, and a bar under the list turns the ones picked on or
// off for an agent at once (#791, mintonight: 能不能支持多选来开启或关闭):
// its chips work as a group's do. Done takes the boxes away.
function pickButton() {
// The servers' page has the same (#1027, emo172), one box on each row.
function pickButton(kind) {
const b = button(picking ? t("Done") : t("Select"), "action lib-updall lib-select", () => {
picking = !picking;
picking = picking ? "" : kind;
picked.clear();
render();
});
b.title = picking ? t("Stop picking skills") : t("Pick some skills and turn them on or off together");
const skills = kind === "skills";
b.title = picking ? (skills ? t("Stop picking skills") : t("Stop picking servers"))
: skills ? t("Pick some skills and turn them on or off together") : t("Pick some servers and turn them on or off together");
return b;
}
// what's picked is a page's own: another page, or a list too short to
// pick from, ends it, and a name no longer in the list isn't picked
function pickingFor(kind, items) {
if (picking && (picking !== kind || items.length < 2)) { picking = ""; picked.clear(); }
for (const n of picked) if (!items.some((x) => x.name === n)) picked.delete(n);
}
// a row with a box, which its click ticks rather than opening it
function pickRow(row, name) {
const box = pickBox([name]);
row.prepend(box);
row.classList.toggle("picked", picked.has(name));
row.onclick = () => box.click();
row.title = t("Select {name}", { name });
return row;
}
// the skills the filter shows, in every group, folded or not
function shownSkills() {
const q = skillQuery.trim().toLowerCase();
@@ -2214,30 +2252,33 @@
box.checked = k > 0 && k === box._names.length;
box.indeterminate = k > 0 && k < box._names.length;
const row = box.parentElement;
if (row?.classList.contains("lib-skill")) row.classList.toggle("picked", box.checked);
if (row?.classList.contains("lib-skill") || row?.classList.contains("lib-server")) row.classList.toggle("picked", box.checked);
}
// every box drawn, and the bar, as the picks are now
function syncPicks() {
if (!picked.size) naming = false;
for (const b of page.querySelectorAll(".lib-pickbox")) paintPick(b);
const bar = page.querySelector(".lib-pickbar");
if (bar) bar.replaceWith(pickBar(skillAgents()));
if (bar) bar.replaceWith(pickBar(picking === "servers" ? mcpAgents() : skillAgents()));
}
// the servers' bar is the same, with no filter or groups (#1027)
function pickBar(all) {
const bar = el("div", "row lib-row lib-pickbar");
const shown = shownSkills(), q = skillQuery.trim();
const servers = picking === "servers";
const shown = servers ? lib.servers : shownSkills(), q = servers ? "" : skillQuery.trim();
const every = el("label", "lib-pickall");
every.append(pickAll(shown), el("span", "", q ? t("All {n} that match", { n: shown.length }) : t("All {n}", { n: shown.length })));
every.onclick = (e) => e.stopPropagation();
const skills = lib.skills.filter((s) => picked.has(s.name));
const skills = (servers ? lib.servers : lib.skills).filter((s) => picked.has(s.name));
const n = skills.length;
bar.append(every, el("span", "lib-pickn" + (n ? "" : " none"), n ? t("{n} selected", { n }) : t("Pick skills to turn them on or off together")), el("span", "grow"));
bar.append(every, el("span", "lib-pickn" + (n ? "" : " none"), n ? t("{n} selected", { n })
: servers ? t("Pick servers to turn them on or off together") : t("Pick skills to turn them on or off together")), el("span", "grow"));
if (n && naming) return nameBar(bar, skills);
if (n) {
bar.append(groupChips(skills, all, null));
bar.append(groupChips(skills, all, null, servers ? "servers" : "skills"));
// a group of the user's made of them, or the ones in one let go (#791)
if (sortOf("libSkills", SKILL_SORTS()) === "source") {
if (!servers && sortOf("libSkills", SKILL_SORTS()) === "source") {
const gb = button(t("Group…"), "lib-updall lib-pickgroup", () => { naming = true; syncPicks(); });
gb.title = t("Put the skills picked in a group of your own");
bar.append(gb);
@@ -2254,7 +2295,7 @@
c.title = t("Unpick them all");
bar.append(c);
}
bar.append(button(t("Done"), "action lib-updall lib-pickdone", () => { picking = false; picked.clear(); render(); }));
bar.append(button(t("Done"), "action lib-updall lib-pickdone", () => { picking = ""; picked.clear(); render(); }));
return bar;
}
@@ -2301,7 +2342,7 @@
take(v);
folds["my:" + name] = false; // a group just made is shown open
saveFolds();
picking = false;
picking = "";
naming = false;
picked.clear();
report(v.result, skills.length === 1 ? t("{skill} is in {name}", { skill: skills[0].name, name }) : t("{n} skills are in {name}", { n: skills.length, name }));
@@ -2860,37 +2901,51 @@
openLib(ed);
}
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)));
// Every skill, or every server (#1027), on or off for every agent shown
// at once; an agent not shown keeps what it has, and a server goes only
// to the agents that can reach it, as its All chip gives it.
function everyButtons(kind, all) {
const ids = all.map((a) => a.id), n = all.length, skills = kind === "skills";
const items = skills ? lib.skills : lib.servers;
const can = (x, a) => skills || reaches(x)(a);
const on = button(t("Turn all on"), "action lib-updall lib-everyon", () => everyOf(kind, ids, true));
on.title = skills ? t("Give every skill to all {n} agents that can take skills", { n }) : t("Give every server to all {n} agents, each the ones it can reach", { n });
on.disabled = items.every((x) => all.every((a) => !can(x, a) || x.agents?.includes(a.id)));
const off = button(t("Turn all off"), "action lib-updall lib-everyoff", () => confirmEveryOff(kind, ids));
off.title = skills ? t("Take every skill from all {n} agents", { n }) : t("Take every server from all {n} agents", { n });
off.disabled = !items.some((x) => ids.some((id) => x.agents?.includes(id)));
return [on, off];
}
function confirmEveryOff(ids) {
function confirmEveryOff(kind, ids) {
const skills = kind === "skills", n = (skills ? lib.skills : lib.servers).length, agents = ids.map(nameOf).join(", ");
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 })));
head.append(glyph(skills ? GLYPH.skill : GLYPH.cmd), el("b", "", skills ? t("Turn off all {n} skills?", { n }) : t("Turn off all {n} servers?", { n })));
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(", ") })));
ed.append(el("p", "lib-confirm", skills
? t("Every skill is taken out of {agents}. They stay in the library, to turn on again.", { agents })
: t("Every server is taken out of {agents}. They stay in the library, to turn on again.", { agents })));
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(true); else go.disabled = false; });
const go = button(t("Turn all off"), "primary danger-fill", async () => { go.disabled = true; if (await everyOf(kind, ids, false)) closeLibModal(true); 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) {
async function everyOf(kind, ids, on) {
try {
const v = await api("library/skills/agents-all", { agents: ids, on });
const v = await api("library/" + kind + "/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 }));
const m = ids.length;
if (kind === "skills") {
const n = lib.skills.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 }));
} else {
const n = lib.servers.length;
report(v.result, on ? t("{n} servers are on for all {m} agents", { n, m }) : t("{n} servers are off for all {m} agents", { n, m }));
}
render();
return true;
} catch (e) {
@@ -2984,47 +3039,72 @@
// 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 });
// taken away. Every server likewise (#1027): out of every agent and
// project magpie gave it to, its sign-in forgotten; the agents' servers
// of their own stay. It asks first, in the page, saying who loses what.
function removeEveryButton(kind) {
const skills = kind === "skills";
const b = button(t("Remove all"), "action danger lib-updall lib-everyrm", () => skills ? confirmRemoveEverySkill() : confirmRemoveEveryServer());
b.title = skills ? t("Take all {n} skills out of the library", { n: lib.skills.length }) : t("Take all {n} servers out of the library", { n: lib.servers.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;
// the sheet asking first: its head and what it says, then Remove all n
function confirmRemoveEvery(kind, names, title, lines) {
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 })));
head.append(glyph(GLYPH.trash), el("b", "", title));
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(" ")));
for (const l of lines) if (l) ed.append(el("p", "lib-confirm", l));
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(true); else go.disabled = false; });
const go = button(t("Remove all {n}", { n: names.length }), "primary danger-fill", async () => { go.disabled = true; if (await removeEvery(kind, names)) closeLibModal(true); else go.disabled = false; });
bar.append(el("span", "grow"), button(t("Cancel"), "", closeLibModal), go);
ed.append(bar);
modal = { save: () => go.click() };
openLib(ed);
}
const takenFrom = (items) => {
const agents = [...new Set(items.flatMap((s) => s.agents || []))];
return 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.");
};
async function removeEverySkill(names) {
function confirmRemoveEverySkill() {
const skills = lib.skills.slice(), names = skills.map((s) => s.name), n = names.length;
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 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 }));
confirmRemoveEvery("skills", names, t("Remove all {n} skills?", { n }), [
takenFrom(skills),
projects && t("The skills magpie placed in {n} projects are taken away too.", { n: projects }),
where.join(" "),
]);
}
function confirmRemoveEveryServer() {
const servers = lib.servers.slice(), names = servers.map((s) => s.name), n = names.length;
const projects = (lib.projects || []).filter((p) => names.some((x) => p.servers?.[x]?.length)).length;
const signed = servers.filter((s) => s.signIn?.signedIn).length;
confirmRemoveEvery("servers", names, t("Remove all {n} servers?", { n }), [
takenFrom(servers),
projects && t("The servers magpie placed in {n} projects are taken away too.", { n: projects }),
signed && t("magpie's sign-in to {n} of them is forgotten too.", { n: signed }),
t("Servers your agents have that aren't in the library stay as they are."),
]);
}
async function removeEvery(kind, names) {
try {
const v = await api("library/skills/remove-all", { names });
const v = await api("library/" + kind + "/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 }));
status(t("{name} wasn't removed: {error}", { name: p.what.replace(/^(skill|mcp):/, ""), error: p.error }) + (no.length > 1 ? " " + t("(and {n} more)", { n: no.length - 1 }) : ""), "warn", 8000);
} else report(res, kind === "skills" ? t("{n} skills removed", { n: names.length }) : t("{n} servers removed", { n: names.length }));
render();
return true;
} catch (e) {
@@ -3257,14 +3337,7 @@
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, always: alwaysFor(s) }));
if (picking) {
const box = pickBox([s.name]);
row.prepend(box);
row.classList.toggle("picked", picked.has(s.name));
row.onclick = () => box.click();
row.title = t("Select {name}", { name: s.name });
return row;
}
if (picking) return pickRow(row, s.name);
row.onclick = () => viewSkill(s);
row.title = t("Read {name}'s SKILL.md", { name: s.name });
return row;
+4
View File
@@ -366,8 +366,12 @@ func libraryRoutes(mux *http.ServeMux, w Windows) {
res, err = library.ServerAgents(in.Name, in.Agents)
case "servers/agents-all":
res, err = library.EveryServerAgents(in.Agents, in.On)
case "servers/agents-some":
res, err = library.SomeServersAgents(in.Names, in.Agents, in.On)
case "servers/remove":
res, err = library.RemoveServer(in.Name)
case "servers/remove-all":
res, err = library.RemoveServers(in.Names)
case "servers/import":
res, err = library.ImportServer(in.Name)
case "skills/install":
@@ -0,0 +1,268 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// #1027 (emo172): the Library's MCP servers page had only By agent and the
// order, each under an "In the library" heading of its own, so with both
// the page showed the heading twice; turning every server on or off, or
// removing them, was a chip or a row at a time. It now has the skills
// page's: one heading holding the order, Select, Turn all on, Turn all off,
// By agent and Remove all. Turn all on posts every agent shown (not a
// hidden one) to servers/agents-all; Turn all off asks first, in the page,
// and Cancel posts nothing. Select puts a box on each row, a row's click
// ticks it rather than opening the editor, and the bar's chips post the
// servers picked to servers/agents-some; an agent that can reach none of
// them (Codex, no SSE) has its chip greyed and a click posts nothing.
// Remove all asks first, saying who loses them, the sign-ins forgotten and
// that the agents' own servers stay, then posts every name to
// servers/remove-all. At 440px wide in en, zh, ja and de: no sideways
// scroll, and what each click pressed stays where it was on the screen.
// No backend: the API is faked here.
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const path = require("node:path");
const { test } = require("node:test");
const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const HOME = "/Users/emo";
const agent = (id, name, more = {}) => ({ id, name, icon: "", skills: `${HOME}/.${id}/skills`, mcp: `${HOME}/.${id}/mcp.json`, ...more });
const sv = (name, transport, agents, more = {}) => ({ name, transport, command: transport === "stdio" ? name + "-mcp" : "", url: transport === "stdio" ? "" : "http://localhost:9/" + name, agents, ...more });
const lib = (servers) => ({
dir: `${HOME}/.magpie/library`, backups: `${HOME}/.magpie/backups`, home: HOME,
agents: [agent("claude", "Claude Code"), agent("codex", "Codex", { noSSE: true }), agent("copilot", "Copilot CLI"), agent("gemini", "Gemini CLI")],
instructions: { agents: [] }, servers, skills: [], foundServers: [], projects: [], foundSkills: [], problems: [],
});
let I18N;
async function loadI18N() {
const src = await fs.readFile(path.join(assets, "i18n.js"), "utf8");
I18N = new Function(src.slice(0, src.indexOf("\n};") + 3) + "; return I18N;")();
}
// the words the page says in lang; a string missing from a language fails
function tr(lang, key, vars = {}) {
let s = key;
if (lang !== "en") {
assert.equal(typeof I18N[lang][key], "string", `${lang} has no ${JSON.stringify(key)}`);
s = I18N[lang][key];
}
return s.replace(/\{(\w+)\}/g, (m, k) => (k in vars ? String(vars[k]) : m));
}
function server(lang, state, posts) {
const can = (s, a) => !(s.transport === "sse" && a === "codex");
return async (route) => {
const req = route.request(), url = new URL(req.url());
const json = (data) => route.fulfill({ json: data });
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light", agentsHidden: ["copilot"] } });
if (url.pathname === "/api/library") return json(lib(state.servers));
// every server answers, as a real check does: one without a result is
// checked again on each drawing, which moves the rows under a click
if (url.pathname === "/api/library/mcp/check") return json({ servers: Object.fromEntries(req.postDataJSON().names.map((n) => [n, { state: "ok", tools: 2 }])) });
if (url.pathname.startsWith("/api/library/") && req.method() === "POST" && !url.pathname.endsWith("/reveal")) {
const body = req.postDataJSON(), what = url.pathname.replace("/api/library/", "");
posts.push({ what, body });
const put = (x, agents, on) => ({ ...x, agents: on ? [...new Set([...x.agents, ...agents.filter((a) => can(x, a))])].sort() : x.agents.filter((a) => !agents.includes(a)) });
if (what === "servers/agents-all") state.servers = state.servers.map((x) => put(x, body.agents, body.on));
if (what === "servers/agents-some") state.servers = state.servers.map((x) => (body.names.includes(x.name) ? put(x, body.agents, body.on) : x));
if (what === "servers/remove-all") state.servers = state.servers.filter((x) => !body.names.includes(x.name));
return json({ ...lib(state.servers), result: { changed: ["claude"] } });
}
if (url.pathname === "/api/plugins") return json({ plugins: [] });
if (url.pathname === "/api/usage/quotas") return json([]);
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname === "/api/providers") return json({ providers: [], gateway: { running: true } });
if (url.pathname.startsWith("/api/")) return json({});
if (url.host !== "magpie.test") return route.fulfill({ status: 404, body: "" });
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
};
}
// a click where the button is, as the reader's (Playwright's own click first
// scrolls the button into view), and what was clicked stays where it was on
// the screen while the click redraws around it: a click never scrolls the
// page (app.js's guard holds it). The window is tall enough for the whole
// list, as the pick bar stays over the last rows of a longer one.
const press = async (page, loc) => {
const b = await loc.boundingBox();
const sel = String(loc);
await page.mouse.click(b.x + b.width / 2, b.y + b.height / 2);
await page.waitForTimeout(250);
if (!(await loc.count())) return; // gone with what it did (Clear, Done)
const a = await loc.boundingBox();
if (a && a.width) assert(Math.abs(a.y - b.y) <= 1, `${sel} moved on the screen from ${Math.round(b.y)} to ${Math.round(a.y)}`);
};
const settled = (page) => page.waitForFunction(() => !document.querySelector("#modal").getAnimations({ subtree: true }).length);
// nothing wider than the window: the page, the Library, its heading and bar
const sideways = (page) => page.evaluate(() => {
const over = [];
const w = document.documentElement.clientWidth;
if (document.documentElement.scrollWidth > w) over.push("page " + document.documentElement.scrollWidth);
const v = document.querySelector("#view-library");
if (v.scrollWidth > v.clientWidth + 1) over.push("library " + v.scrollWidth + ">" + v.clientWidth);
for (const e of document.querySelectorAll("#view-library .lib-serverhead > *, #view-library .lib-pickbar > *, #view-library .lib-pickbar")) {
const r = e.getBoundingClientRect();
if (r.width && (r.right > w + 0.5 || r.left < -0.5)) over.push((e.className || e.tagName) + " " + Math.round(r.left) + "–" + Math.round(r.right));
}
return over;
});
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
test(engine + ": the servers page's Select, Turn all on/off and Remove all", async (t) => {
assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit");
await loadI18N();
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || "chromium" }));
t.after(() => browser.close());
for (const lang of ["en", "zh", "ja", "de"]) {
await t.test(lang, async () => {
const w = (key, vars) => tr(lang, key, vars);
const state = { servers: [sv("fs", "stdio", ["claude", "copilot"]), sv("web", "sse", ["claude"]), sv("docs", "http", [], { signIn: { signedIn: true } })] };
const posts = [], errors = [];
const ctx = await browser.newContext({ viewport: { width: 440, height: 1000 }, reducedMotion: "reduce" });
await ctx.addInitScript(() => { try { localStorage.setItem("magpie.libTab", "mcp"); } catch {} });
const page = await ctx.newPage();
page.setDefaultTimeout(5000);
page.on("pageerror", (e) => errors.push(e.message));
page.on("dialog", (d) => { errors.push("a browser dialog: " + d.message()); d.dismiss(); });
await page.route("**/*", server(lang, state, posts));
await page.goto("http://magpie.test/");
await page.locator('button[data-view="library"]').click();
const v = page.locator("#view-library");
await v.locator(".lib-server").first().waitFor();
const row = (name) => v.locator(".lib-server").filter({ has: page.locator(".name", { hasText: new RegExp("^" + name) }) });
const agentsOf = (name) => state.servers.find((s) => s.name === name)?.agents;
// one heading over the list, holding every action
const heads = await v.locator(".row-head .label").allTextContents();
assert.deepEqual(heads.filter((x) => x === w("In the library")), [w("In the library")], "one In the library heading: " + heads);
const head = v.locator(".row-head.lib-serverhead");
assert.equal((await head.locator(".lib-select").textContent()).trim(), w("Select"));
assert.equal((await head.locator(".lib-everyon").textContent()).trim(), w("Turn all on"));
assert.equal((await head.locator(".lib-everyoff").textContent()).trim(), w("Turn all off"));
assert.equal((await head.locator(".lib-byagent").textContent()).trim(), w("By agent"));
assert.equal((await head.locator(".lib-everyrm").textContent()).trim(), w("Remove all"));
assert.equal(await head.locator(".lib-everyon").getAttribute("title"), w("Give every server to all {n} agents, each the ones it can reach", { n: 3 }));
assert.equal(await head.locator(".lib-everyrm").getAttribute("title"), w("Take all {n} servers out of the library", { n: 3 }));
assert.deepEqual(await sideways(page), [], "sideways scroll with the heading");
// Turn all on: every agent shown, the hidden Copilot CLI left alone
await press(page, head.locator(".lib-everyon"));
await page.locator("#status").filter({ hasText: w("{n} servers are on for all {m} agents", { n: 3, m: 3 }) }).waitFor();
assert.deepEqual(posts.shift(), { what: "servers/agents-all", body: { agents: ["claude", "codex", "gemini"], on: true } });
assert.deepEqual(agentsOf("web"), ["claude", "gemini"], "SSE is no server for Codex");
await page.waitForFunction(() => document.querySelector("#view-library .lib-serverhead .lib-everyon")?.disabled);
// Turn all off asks first; Cancel posts nothing
await press(page, v.locator(".lib-serverhead .lib-everyoff"));
const sheet = page.locator("#modal .lib-editor");
await sheet.waitFor();
await settled(page);
assert.equal((await sheet.locator(".ehead b").textContent()).trim(), w("Turn off all {n} servers?", { n: 3 }));
assert.equal((await sheet.locator(".lib-confirm").textContent()).trim(), w("Every server is taken out of {agents}. They stay in the library, to turn on again.", { agents: "Claude Code, Codex, Gemini CLI" }));
await sheet.locator(".bar button", { hasText: w("Cancel") }).click();
await sheet.waitFor({ state: "detached" });
await page.waitForTimeout(150);
assert.deepEqual(posts, [], "Cancel posts nothing");
await press(page, v.locator(".lib-serverhead .lib-everyoff"));
await sheet.waitFor();
await settled(page);
await sheet.locator(".bar button.danger-fill").click();
await sheet.waitFor({ state: "detached" });
assert.deepEqual(posts.shift(), { what: "servers/agents-all", body: { agents: ["claude", "codex", "gemini"], on: false } });
await page.locator("#status").filter({ hasText: w("{n} servers are off for all {m} agents", { n: 3, m: 3 }) }).waitFor();
assert.deepEqual(agentsOf("fs"), ["copilot"], "the hidden agent keeps its own");
// Select: a box on each row, which a row's click ticks
assert.equal(await v.locator(".lib-pickbox").count(), 0);
await press(page, v.locator(".lib-serverhead .lib-select"));
assert.equal((await v.locator(".lib-serverhead .lib-select").textContent()).trim(), w("Done"));
assert.equal(await v.locator(".lib-server .lib-pickbox").count(), 3);
await v.locator(".lib-pickbar .lib-pickn.none").filter({ hasText: w("Pick servers to turn them on or off together") }).waitFor();
await press(page, row("fs").locator(".sub"));
assert.equal(await row("fs").locator(".lib-pickbox").isChecked(), true);
assert.equal(await row("fs").evaluate((r) => r.classList.contains("picked")), true);
assert.equal(await page.locator("#modal .lib-editor").count(), 0, "the click opened the server");
await press(page, row("web").locator(".lib-pickbox"));
await v.locator(".lib-pickbar .lib-pickn").filter({ hasText: w("{n} selected", { n: 2 }) }).waitFor();
assert.deepEqual(await sideways(page), [], "sideways scroll with the bar");
// the bar's chips: Codex is given the one of the two it can reach
const chip = (id) => v.locator(`.lib-pickbar .lib-ag[data-agent="${id}"]`);
assert.equal(await chip("copilot").count(), 0, "a hidden agent has a chip");
assert.equal(await chip("codex").getAttribute("title"), w("Give {agent} all of these servers", { agent: "Codex" }));
await press(page, chip("codex"));
await page.locator("#status").filter({ hasText: w("{n} servers are on for {agents}", { n: 2, agents: "Codex" }) }).waitFor();
assert.deepEqual(posts.shift(), { what: "servers/agents-some", body: { names: ["fs", "web"], agents: ["codex"], on: true } });
assert.deepEqual(agentsOf("fs"), ["codex", "copilot"]);
assert.deepEqual(agentsOf("docs"), [], "a server not picked was given");
// the bar drawn again from magpie's answer: the chip's title, not
// only its lit state, which a click sets before the answer
await page.waitForFunction((tip) => document.querySelector('#view-library .lib-pickbar .lib-ag[data-agent="codex"]')?.title === tip,
w("{agent} has all of these servers — click to take them away", { agent: "Codex" }));
assert.equal(await row("web").locator(".lib-pickbox").isChecked(), true, "the picks were lost");
// and taken back
await press(page, chip("codex"));
await page.locator("#status").filter({ hasText: w("{n} servers are off for {agents}", { n: 2, agents: "Codex" }) }).waitFor();
assert.deepEqual(posts.shift(), { what: "servers/agents-some", body: { names: ["fs", "web"], agents: ["codex"], on: false } });
// web alone: Codex can't reach it, and a click says so, posting nothing
await press(page, v.locator(".lib-pickclear"));
await v.locator(".lib-pickbar .lib-pickn.none").waitFor();
await press(page, row("web").locator(".lib-pickbox"));
await v.locator(".lib-pickbar .lib-pickn").filter({ hasText: w("{n} selected", { n: 1 }) }).waitFor();
assert.equal(await chip("codex").getAttribute("aria-disabled"), "true");
await press(page, chip("codex"));
await page.waitForTimeout(150);
assert.deepEqual(posts, [], "a greyed chip posted");
// the bar's box picks every server
await press(page, v.locator(".lib-pickall input"));
await v.locator(".lib-pickbar .lib-pickn").filter({ hasText: w("{n} selected", { n: 3 }) }).waitFor();
assert.equal((await v.locator(".lib-pickall").textContent()).trim(), w("All {n}", { n: 3 }));
assert.deepEqual(await sideways(page), [], "sideways scroll with every server picked");
// Done takes the boxes away; a row opens its server again
await press(page, v.locator(".lib-pickdone"));
assert.equal(await v.locator(".lib-pickbox").count(), 0);
assert.equal(await v.locator(".lib-pickbar").count(), 0);
assert.equal((await v.locator(".lib-serverhead .lib-select").textContent()).trim(), w("Select"));
// Remove all asks first, saying what goes; Cancel posts nothing
await press(page, v.locator(".lib-serverhead .lib-everyrm"));
await sheet.waitFor();
await settled(page);
assert.equal((await sheet.locator(".ehead b").textContent()).trim(), w("Remove all {n} servers?", { n: 3 }));
const said = (await sheet.locator(".lib-confirm").allTextContents()).map((x) => x.trim());
assert.deepEqual(said, [
// only the hidden Copilot CLI has one now, after Turn all off
w("They are taken out of the library and out of {agents}.", { agents: "Copilot CLI" }),
w("magpie's sign-in to {n} of them is forgotten too.", { n: 1 }),
w("Servers your agents have that aren't in the library stay as they are."),
]);
await sheet.locator(".bar button", { hasText: w("Cancel") }).click();
await sheet.waitFor({ state: "detached" });
await page.waitForTimeout(150);
assert.deepEqual(posts, [], "Cancel posts nothing");
assert.equal(await v.locator(".lib-server").count(), 3);
await press(page, v.locator(".lib-serverhead .lib-everyrm"));
await sheet.waitFor();
await settled(page);
const go = sheet.locator(".bar button.danger-fill");
assert.equal((await go.textContent()).trim(), w("Remove all {n}", { n: 3 }));
await go.click();
await sheet.waitFor({ state: "detached" });
assert.deepEqual(posts.shift(), { what: "servers/remove-all", body: { names: ["fs", "web", "docs"] } }, "one post, with every server");
await page.locator("#status").filter({ hasText: w("{n} servers removed", { n: 3 }) }).waitFor();
assert.equal(await v.locator(".lib-server").count(), 0, "a server is left");
assert.equal(await v.locator(".lib-serverhead").count(), 0);
assert.deepEqual(posts, []);
assert.deepEqual(errors, []);
await ctx.close();
});
}
});
}
+112
View File
@@ -0,0 +1,112 @@
package library
import (
"path/filepath"
"slices"
"strings"
"testing"
"github.com/yetone/magpie/internal/mcpauth"
"github.com/yetone/magpie/internal/mcpauth/mcpauthtest"
)
// Every server out of the library at once (#1027), each as RemoveServer
// takes it: out of every agent and project magpie gave it to, its sign-in
// forgotten. The servers of the user's own in the same files stay, one by
// a library server's name that magpie never wrote there too, and a name
// the library hasn't is said, the rest gone all the same.
func TestRemoveServers(t *testing.T) {
h, proj := mcpProject(t) // fs and docs, and the project's own "team"
f := mcpauthtest.New(t)
ok(t)(SaveServer("", Server{Name: "neon", Transport: "http", URL: f.URL, Agents: []string{"claude"}}))
f.SignIn(t, "neon")
ok(t)(ServerAgents("fs", []string{"claude", "codex"}))
ok(t)(ServerAgents("docs", []string{"claude"}))
ok(t)(ProjectServer(proj, "fs", []string{"claude"}))
// the user's own, beside magpie's: "mine" in Claude Code's file, and
// a "docs" of their own in Codex's, which the library never gave it
cj := filepath.Join(h, ".claude.json")
write(t, cj, strings.Replace(read(t, cj), `"mcpServers": {`, `"mcpServers": {"mine": {"command": "my-mcp"},`, 1))
cfg := filepath.Join(h, ".codex/config.toml")
write(t, cfg, read(t, cfg)+"\n[mcp_servers.docs]\ncommand = \"my-docs\"\n")
if s := servers(t, cj, "mcpServers"); s["mine"] == nil || s["fs"] == nil || s["docs"] == nil || s["neon"] == nil {
t.Fatalf("claude before: %v", s)
}
if _, err := RemoveServers(nil); err == nil {
t.Error("no servers named was taken")
}
if _, err := RemoveServers([]string{"nope"}); err == nil {
t.Error("only a server the library hasn't was taken")
}
res, err := RemoveServers([]string{"fs", "docs", "neon", "nope", "fs"})
if err != nil {
t.Fatal(err)
}
if len(res.Unremoved) != 1 || res.Unremoved[0].What != "mcp:nope" {
t.Errorf("unremoved: %+v", res.Unremoved)
}
if v, _ := Read(nil); len(v.Servers) != 0 {
t.Errorf("left in the library: %+v", v.Servers)
}
if s := servers(t, cj, "mcpServers"); len(s) != 1 || s["mine"] == nil {
t.Errorf("claude after: %v", s)
}
c := read(t, cfg)
if strings.Contains(c, "[mcp_servers.fs]") || !strings.Contains(c, "[mcp_servers.docs]") || !strings.Contains(c, `"my-docs"`) {
t.Errorf("codex after, its own docs kept:\n%s", c)
}
if s := servers(t, filepath.Join(proj, ".mcp.json"), "mcpServers"); len(s) != 1 || s["team"] == nil {
t.Errorf("the project's .mcp.json after: %v", s)
}
if v, _ := Read(nil); len(v.Projects) != 1 || len(v.Projects[0].Wrote) != 0 {
t.Errorf("projects: %+v", v.Projects)
}
if _, signed := mcpauth.Get("neon"); signed {
t.Error("neon's sign-in was kept")
}
}
// Some servers on or off for some agents at once (#1027): the ones named
// alone; an agent isn't given one it can't reach, and the others keep
// theirs.
func TestSomeServersAgents(t *testing.T) {
h := sandbox(t)
ok(t)(SaveServer("", Server{Name: "fs", Transport: "stdio", Command: "fs", Agents: []string{"claude"}}))
ok(t)(SaveServer("", Server{Name: "web", Transport: "sse", URL: "http://localhost:9/sse", Agents: []string{"claude"}}))
ok(t)(SaveServer("", Server{Name: "git", Transport: "stdio", Command: "git-mcp", Agents: []string{"claude"}}))
agents := func() map[string][]string {
v, _ := Read(nil)
m := map[string][]string{}
for _, s := range v.Servers {
m[s.Name] = s.Agents
}
return m
}
ok(t)(SomeServersAgents([]string{"fs", "web"}, []string{"codex"}, true))
if m := agents(); !slices.Equal(m["fs"], []string{"claude", "codex"}) || !slices.Equal(m["web"], []string{"claude"}) || !slices.Equal(m["git"], []string{"claude"}) {
t.Errorf("on for codex: %v", m)
}
if c := read(t, filepath.Join(h, ".codex/config.toml")); !strings.Contains(c, "[mcp_servers.fs]") || strings.Contains(c, "web") || strings.Contains(c, "git") {
t.Errorf("codex's config:\n%s", c)
}
ok(t)(SomeServersAgents([]string{"fs", "web"}, []string{"claude"}, false))
if m := agents(); !slices.Equal(m["fs"], []string{"codex"}) || len(m["web"]) != 0 || !slices.Equal(m["git"], []string{"claude"}) {
t.Errorf("off for claude: %v", m)
}
if s := servers(t, filepath.Join(h, ".claude.json"), "mcpServers"); s["fs"] != nil || s["web"] != nil || s["git"] == nil {
t.Errorf("claude's: %v", s)
}
if _, err := SomeServersAgents(nil, []string{"claude"}, true); err == nil {
t.Error("no servers named was taken as every server")
}
if _, err := SomeServersAgents([]string{"fs"}, nil, true); err == nil {
t.Error("no agents named was taken")
}
if _, err := SomeServersAgents([]string{"fs", "nope"}, []string{"codex"}, false); err == nil {
t.Error("a server the library hasn't was taken")
}
if m := agents(); !slices.Equal(m["fs"], []string{"codex"}) {
t.Errorf("a refused change wrote: %v", m)
}
}
+85 -18
View File
@@ -1,6 +1,7 @@
package library
import (
"errors"
"fmt"
"os"
"slices"
@@ -23,8 +24,8 @@ type Result struct {
// Unimported are, for skills brought in together, the ones that
// couldn't be (What is skill:<name>)
Unimported []Problem `json:"unimported,omitempty"`
// Unremoved are, for skills taken out together, the ones that couldn't
// be (What is skill:<name>)
// Unremoved are, for skills or servers taken out together, the ones
// that couldn't be (What is skill:<name> or mcp:<name>)
Unremoved []Problem `json:"unremoved,omitempty"`
}
@@ -405,7 +406,30 @@ func EveryServerAgents(agents []string, on bool) (*Result, error) {
if len(agents) == 0 {
return nil, fmt.Errorf("no agents to give the servers to")
}
return serversAgents(nil, agents, on)
}
// SomeServersAgents does as EveryServerAgents for the servers named alone:
// the ones picked on the page (#1027).
func SomeServersAgents(names, agents []string, on bool) (*Result, error) {
if len(names) == 0 {
return nil, fmt.Errorf("no servers to give the agents")
}
if len(agents) == 0 {
return nil, fmt.Errorf("no agents to give the servers to")
}
return serversAgents(names, agents, on)
}
// serversAgents gives the servers named, or every one when none are, to
// the agents, or takes them from them.
func serversAgents(names, agents []string, on bool) (*Result, error) {
return change(func(l *Library) error {
for _, name := range names {
if l.server(name) == nil {
return fmt.Errorf("no server called %s", name)
}
}
mcp := map[string]*mcpFile{}
if on {
for _, t := range Targets() {
@@ -415,6 +439,9 @@ func EveryServerAgents(agents []string, on bool) (*Result, error) {
}
}
for _, s := range l.MCP {
if names != nil && !slices.Contains(names, s.Name) {
continue
}
kept := slices.DeleteFunc(slices.Clone(s.Agents), func(a string) bool { return slices.Contains(agents, a) })
for _, a := range agents {
if on && mcp[a] != nil && mcp[a].supports(s) == nil {
@@ -432,22 +459,7 @@ func EveryServerAgents(agents []string, on bool) (*Result, error) {
// RemoveServer takes a server out of the library and out of every agent
// magpie gave it to.
func RemoveServer(name string) (*Result, error) {
res, err := change(func(l *Library) error {
i := slices.IndexFunc(l.MCP, func(x *Server) bool { return x.Name == name })
if i < 0 {
return fmt.Errorf("no server called %s", name)
}
k := serverKey(l.MCP[i])
l.MCP = slices.Delete(l.MCP, i, i+1)
for _, p := range l.Projects {
delete(p.Servers, name)
}
// its icon goes with it, unless another server runs the same thing
if !slices.ContainsFunc(l.MCP, func(x *Server) bool { return serverKey(x) == k }) {
delete(l.Icons, k)
}
return nil
})
res, err := change(func(l *Library) error { return removeServer(l, name) })
if err == nil {
// its sign-in goes with it
_ = mcpauth.Forget(name)
@@ -455,6 +467,61 @@ func RemoveServer(name string) (*Result, error) {
return res, err
}
// RemoveServers takes the servers named out of the library and every agent
// in one write (#1027), each as RemoveServer does: only the entries magpie
// wrote (Applied) leave the agents' files, a server of the agent's own by
// another name stays, and each one's sign-in goes with it. One that can't
// be taken out stays, and is said in Unremoved.
func RemoveServers(names []string) (*Result, error) {
if len(names) == 0 {
return nil, fmt.Errorf("no servers to remove")
}
names = slices.Compact(slices.Sorted(slices.Values(names)))
var failed []Problem
var gone []string
res, err := change(func(l *Library) error {
failed, gone = nil, nil
for _, name := range names {
if err := removeServer(l, name); err != nil {
failed = append(failed, Problem{What: "mcp:" + name, Error: err.Error()})
} else {
gone = append(gone, name)
}
}
if len(failed) == len(names) {
return errors.New(failed[0].Error)
}
return nil
})
if err == nil {
for _, name := range gone {
_ = mcpauth.Forget(name)
}
}
if res != nil {
res.Unremoved = failed
}
return res, err
}
// removeServer takes a server out of l and its projects; its icon goes
// with it, unless another server runs the same thing.
func removeServer(l *Library, name string) error {
i := slices.IndexFunc(l.MCP, func(x *Server) bool { return x.Name == name })
if i < 0 {
return fmt.Errorf("no server called %s", name)
}
k := serverKey(l.MCP[i])
l.MCP = slices.Delete(l.MCP, i, i+1)
for _, p := range l.Projects {
delete(p.Servers, name)
}
if !slices.ContainsFunc(l.MCP, func(x *Server) bool { return serverKey(x) == k }) {
delete(l.Icons, k)
}
return nil
}
// ImportServer takes a server the agents have into the library: the agents
// that have it as it is get the library's from then on, the same entry.
func ImportServer(name string) (*Result, error) {