diff --git a/docs/subsystems/library.md b/docs/subsystems/library.md index ec239e89..83ec198f 100644 --- a/docs/subsystems/library.md +++ b/docs/subsystems/library.md @@ -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 diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index 5e6de6ce..7bd72d38 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -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", diff --git a/internal/gui/assets/library.css b/internal/gui/assets/library.css index 4bea0f32..1f9198cc 100644 --- a/internal/gui/assets/library.css +++ b/internal/gui/assets/library.css @@ -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; } diff --git a/internal/gui/assets/library.js b/internal/gui/assets/library.js index f666f6bc..1956175b 100644 --- a/internal/gui/assets/library.js +++ b/internal/gui/assets/library.js @@ -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; diff --git a/internal/gui/library.go b/internal/gui/library.go index 644998c5..517d94c0 100644 --- a/internal/gui/library.go +++ b/internal/gui/library.go @@ -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": diff --git a/internal/gui/tests/library-servers-bulk.test.cjs b/internal/gui/tests/library-servers-bulk.test.cjs new file mode 100644 index 00000000..874f1bfd --- /dev/null +++ b/internal/gui/tests/library-servers-bulk.test.cjs @@ -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(); + }); + } + }); +} diff --git a/internal/library/remove_servers_test.go b/internal/library/remove_servers_test.go new file mode 100644 index 00000000..08d036b9 --- /dev/null +++ b/internal/library/remove_servers_test.go @@ -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) + } +} diff --git a/internal/library/sync.go b/internal/library/sync.go index 0f3c46d1..16e38537 100644 --- a/internal/library/sync.go +++ b/internal/library/sync.go @@ -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:) Unimported []Problem `json:"unimported,omitempty"` - // Unremoved are, for skills taken out together, the ones that couldn't - // be (What is skill:) + // Unremoved are, for skills or servers taken out together, the ones + // that couldn't be (What is skill: or mcp:) 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) {