diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index da875ea6..2c2af75b 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -4,6 +4,26 @@ // works. {name} slots are filled by t(str, vars). const I18N = { zh: { + "Give skills as": "Skills 启用方式", + "Links": "符号链接", + "The agents get copies of their skills now": "现在各 Agent 拿到的是 Skills 的副本", + "The agents get links to their skills now": "现在各 Agent 拿到的是指向资源库的符号链接", + "By agent ({n})": "按 Agent({n})", + "Give an agent its skills its own way": "给某个 Agent 单独设定启用方式", + "Each agent gets a folder of its own, made again when the library's skill changes; edits made in a copy are replaced.": "每个 Agent 拿到一份独立的文件夹,资源库里的 Skill 变了就重新复制;在副本里做的修改会被覆盖。", + "Links follow the library's skill: an update reaches the agents at once.": "符号链接跟着资源库里的 Skill 走:一更新,各 Agent 立刻就用上。", + "How each agent gets its skills": "各 Agent 的 Skills 启用方式", + "An agent can have its own way; the others get copies, as the library gives them.": "可以给某个 Agent 单独设定;其余的按资源库的方式,拿副本。", + "An agent can have its own way; the others get links, as the library gives them.": "可以给某个 Agent 单独设定;其余的按资源库的方式,用符号链接。", + "Only ever takes copies": "只能用副本", + "Gets copies": "用副本", + "Gets links": "用符号链接", + "Library's way": "跟随资源库", + "{agent} gets copies of its skills now": "{agent} 现在用的是 Skills 的副本", + "{agent} gets links to its skills now": "{agent} 现在用的是符号链接", + "Copy out of date": "副本已落后", + "The copy in {agents} differs from the library's skill. Click to copy it again.": "{agents} 里的副本和资源库里的 Skill 不一样了。点一下重新复制。", + "Copies updated": "副本已更新", "Restore saved settings and remove magpie?": "恢复已保存的设置并移除 magpie?", "Restore saved settings and remove magpie without contacting Aside? Close Aside first if it is running.": "要在不连接 Aside 的情况下恢复已保存的设置并移除 magpie 吗?如果 Aside 正在运行,请先关闭 Aside。", "Restore saved settings and remove magpie": "恢复已保存的设置并移除 magpie", @@ -3309,6 +3329,26 @@ const I18N = { "credits": "积分", }, ja: { + "Give skills as": "スキルの渡し方", + "Links": "リンク", + "The agents get copies of their skills now": "エージェントにはスキルのコピーを渡すようになりました", + "The agents get links to their skills now": "エージェントにはスキルへのリンクを渡すようになりました", + "By agent ({n})": "エージェント別({n})", + "Give an agent its skills its own way": "エージェントごとに渡し方を決める", + "Each agent gets a folder of its own, made again when the library's skill changes; edits made in a copy are replaced.": "各エージェントに独立したフォルダを渡し、ライブラリのスキルが変わると作り直します。コピーへの編集は上書きされます。", + "Links follow the library's skill: an update reaches the agents at once.": "リンクはライブラリのスキルに従います。更新はすぐにエージェントに届きます。", + "How each agent gets its skills": "エージェントごとのスキルの渡し方", + "An agent can have its own way; the others get copies, as the library gives them.": "エージェントごとに決められます。ほかはライブラリの設定どおりコピーを受け取ります。", + "An agent can have its own way; the others get links, as the library gives them.": "エージェントごとに決められます。ほかはライブラリの設定どおりリンクを受け取ります。", + "Only ever takes copies": "コピーのみ", + "Gets copies": "コピーを受け取る", + "Gets links": "リンクを受け取る", + "Library's way": "ライブラリに従う", + "{agent} gets copies of its skills now": "{agent} にはスキルのコピーを渡すようになりました", + "{agent} gets links to its skills now": "{agent} にはスキルへのリンクを渡すようになりました", + "Copy out of date": "コピーが古い", + "The copy in {agents} differs from the library's skill. Click to copy it again.": "{agents} のコピーがライブラリのスキルと異なります。クリックしてコピーし直します。", + "Copies updated": "コピーを更新しました", "Restore saved settings and remove magpie?": "保存済みの設定を復元して magpie を削除しますか?", "Restore saved settings and remove magpie without contacting Aside? Close Aside first if it is running.": "Aside に接続せずに保存済みの設定を復元して magpie を削除しますか?Aside が実行中の場合は、先に終了してください。", "Restore saved settings and remove magpie": "保存済みの設定を復元して magpie を削除", @@ -6591,6 +6631,26 @@ const I18N = { "{agent} is signed in to this account and asks its vendor itself, not through magpie, so with no other account on to move it to, {agent} goes on using it past the cap. Add another account, or pick {agent}'s models via magpie, for the cap to hold it": "{agent} はこのアカウントでサインインしており、magpie を経由せず自分でベンダーに要求します。切り替え先となる有効なアカウントが他にないため、{agent} は上限を超えても使い続けます。上限を効かせるには、別のアカウントを追加するか、{agent} のモデルを magpie 経由で選んでください", }, de: { + "Give skills as": "Skills geben als", + "Links": "Links", + "The agents get copies of their skills now": "Die Agenten bekommen jetzt Kopien ihrer Skills", + "The agents get links to their skills now": "Die Agenten bekommen jetzt Links zu ihren Skills", + "By agent ({n})": "Nach Agenten ({n})", + "Give an agent its skills its own way": "Einem Agenten seine Skills auf eigene Art geben", + "Each agent gets a folder of its own, made again when the library's skill changes; edits made in a copy are replaced.": "Jeder Agent bekommt einen eigenen Ordner, der neu angelegt wird, wenn sich der Skill der Bibliothek ändert; Änderungen an einer Kopie werden ersetzt.", + "Links follow the library's skill: an update reaches the agents at once.": "Links folgen dem Skill der Bibliothek: Ein Update erreicht die Agenten sofort.", + "How each agent gets its skills": "Wie jeder Agent seine Skills bekommt", + "An agent can have its own way; the others get copies, as the library gives them.": "Ein Agent kann seine eigene Art haben; die anderen bekommen Kopien, wie die Bibliothek sie gibt.", + "An agent can have its own way; the others get links, as the library gives them.": "Ein Agent kann seine eigene Art haben; die anderen bekommen Links, wie die Bibliothek sie gibt.", + "Only ever takes copies": "Nimmt nur Kopien", + "Gets copies": "Bekommt Kopien", + "Gets links": "Bekommt Links", + "Library's way": "Wie die Bibliothek", + "{agent} gets copies of its skills now": "{agent} bekommt jetzt Kopien seiner Skills", + "{agent} gets links to its skills now": "{agent} bekommt jetzt Links zu seinen Skills", + "Copy out of date": "Kopie veraltet", + "The copy in {agents} differs from the library's skill. Click to copy it again.": "Die Kopie in {agents} weicht vom Skill der Bibliothek ab. Klicken, um sie neu zu kopieren.", + "Copies updated": "Kopien aktualisiert", "Restore saved settings and remove magpie?": "Gespeicherte Einstellungen wiederherstellen und magpie entfernen?", "Restore saved settings and remove magpie without contacting Aside? Close Aside first if it is running.": "Gespeicherte Einstellungen wiederherstellen und magpie entfernen, ohne Aside zu kontaktieren? Schließe Aside zuerst, falls es läuft.", "Restore saved settings and remove magpie": "Gespeicherte Einstellungen wiederherstellen und magpie entfernen", diff --git a/internal/gui/assets/library.css b/internal/gui/assets/library.css index d4beebb2..71c23b20 100644 --- a/internal/gui/assets/library.css +++ b/internal/gui/assets/library.css @@ -300,6 +300,13 @@ input.lib-setname:focus { border-color: var(--accent); box-shadow: 0 0 0 3px col .lib-byagent-row .name { display: flex; align-items: center; gap: 6px; } .lib-byagent-row .lib-updall { height: 24px; padding: 0 9px; font-size: 12px; flex: none; } .lib-byagent-row .lib-updall:disabled { color: var(--faint); cursor: default; } +/* how the agents are given their skills (#896) */ +.lib-skillhow { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 12px 0 14px; } +.lib-skillhow .label { font-size: 12px; color: var(--fg-2); } +.lib-skillhow .segs { align-self: center; } +.lib-skillhow .lib-updall { height: 24px; padding: 0 9px; font-size: 12px; } +.lib-skillhow .note { font-size: 11px; color: var(--faint); line-height: 1.45; min-width: 0; flex: 1 0 100%; margin-top: -4px; } +.lib-how-row .segs { flex: none; align-self: center; } .lib-reader { grid-template-columns: minmax(0, 1fr); } .lib-md { margin: 0; max-height: min(60vh, 520px); overflow: auto; font: 12px/1.6 var(--code); color: var(--fg-2); white-space: pre-wrap; overflow-wrap: anywhere; background: var(--card-2); border-radius: 8px; padding: 12px 14px; user-select: text; -webkit-user-select: text; } .lib-reader .bar .lib-path { max-width: 70%; } diff --git a/internal/gui/assets/library.js b/internal/gui/assets/library.js index ced7bd4f..6b926318 100644 --- a/internal/gui/assets/library.js +++ b/internal/gui/assets/library.js @@ -1754,6 +1754,8 @@ body.append(rh); drawSkills(box, all); body.append(box); + // under the list, so the skills stay where they were above it + if (all.length) body.append(skillHowBar()); if (picking) body.append(pickBar(all)); } if (lib.foundSkills.length) { @@ -2743,6 +2745,71 @@ // Every skill on, or off, for every agent shown that can take skills, in // one write rather than a row's All for each (#443); an agent not shown // keeps what it has. Off asks first, in the page. + // How the agents are given their skills (#896): links to the library's + // folder, which an update reaches at once, or folders of their own, made + // again when the library's skill changes. By agent gives one agent its + // own way; Claude Desktop and an agent in WSL only ever take copies. + function skillHowBar() { + const bar = el("div", "lib-skillhow"); + bar.id = "lib-skillhow"; + const how = lib.copySkills ? "copy" : "link"; + bar.append(el("span", "label", t("Give skills as")), segs([["link", t("Links")], ["copy", t("Copies")]], how, (h) => { + if (h !== how) change("skills/how", { agent: "", how: h }, h === "copy" ? t("The agents get copies of their skills now") : t("The agents get links to their skills now")); + })); + const own = skillAgents().filter((a) => a.howOwn).length; + const by = button(own ? t("By agent ({n})", { n: own }) : t("By agent"), "action lib-updall lib-howagent", () => openSkillHow()); + by.title = t("Give an agent its skills its own way"); + bar.append(by, el("span", "note", how === "copy" + ? t("Each agent gets a folder of its own, made again when the library's skill changes; edits made in a copy are replaced.") + : t("Links follow the library's skill: an update reaches the agents at once."))); + return bar; + } + + function openSkillHow() { + const ed = el("div", "editor lib-editor lib-byagent-sheet lib-howsheet"); + const head = el("div", "ehead"); + head.append(glyph(GLYPH.skill), el("b", "", t("How each agent gets its skills"))); + ed.append(head); + ed.append(el("p", "lib-confirm", lib.copySkills + ? t("An agent can have its own way; the others get copies, as the library gives them.") + : t("An agent can have its own way; the others get links, as the library gives them."))); + const list = el("div", "list lib-list lib-byagent-list"); + let busy = false; + const draw = () => list.replaceChildren(...lib.agents.filter((a) => a.skills && (!isHidden(a) || a.howOwn)).map((a) => { + const row = el("div", "row lib-row lib-byagent-row lib-how-row"); + row.dataset.agent = a.id; + const who = el("div", "who"); + who.append(el("div", "name", a.name), el("div", "sub", a.mustCopy ? t("Only ever takes copies") : a.how === "copy" ? t("Gets copies") : t("Gets links"))); + row.append(agentIcon(a.icon), who); + if (!a.mustCopy) { + row.append(segs([["", t("Library's way")], ["link", t("Links")], ["copy", t("Copies")]], a.howOwn ? a.how : "", async (h) => { + if (busy) return; + busy = true; + try { + const v = await api("library/skills/how", { agent: a.id, how: h }); + take(v); + const now = agentOf(a.id); + report(v.result, now?.how === "copy" ? t("{agent} gets copies of its skills now", { agent: a.name }) : t("{agent} gets links to its skills now", { agent: a.name })); + render(); + } catch (e) { + status(e.message, "err", 6000); + } + busy = false; + if (list.isConnected) draw(); + })); + } + return row; + })); + draw(); + ed.append(list); + const bar = el("div", "bar"); + const done = button(t("Done"), "primary", closeLibModal); + bar.append(el("span", "grow"), done); + ed.append(bar); + modal = { save: () => done.click() }; + openLib(ed); + } + 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)); @@ -3093,6 +3160,13 @@ } else if (c?.status === "unknown") { nm.append(tag(t("Not checked"), "lib-unchecked", checkError(c))); } + // a copy in an agent that differs from the library's skill (#896): a + // sync makes it again + if (s.behind?.length) { + const b = tag(t("Copy out of date"), "lib-new link lib-behind", t("The copy in {agents} differs from the library's skill. Click to copy it again.", { agents: s.behind.map(nameOf).join(", ") })); + b.onclick = (e) => { e.stopPropagation(); change("all/sync", {}, t("Copies updated")); }; + nm.append(b); + } who.append(nm); const sub = el("div", "sub", s.missing ? t("Its folder is gone from the library") : s.description || ""); sub.title = s.description || ""; diff --git a/internal/gui/library.go b/internal/gui/library.go index d95f1bf2..b1ce58a3 100644 --- a/internal/gui/library.go +++ b/internal/gui/library.go @@ -343,6 +343,7 @@ func libraryRoutes(mux *http.ServeMux, w Windows) { Copy bool // a project gets copies, not links Keep bool // a project removed keeps what magpie put in it On bool // every skill or server given to the agents, or taken from them + How string // link or copy: how skills are given to Agent, or to every agent with none (#896) library.InstructionsChange } if err := json.NewDecoder(r.Body).Decode(&in); err != nil { @@ -396,6 +397,8 @@ func libraryRoutes(mux *http.ServeMux, w Windows) { res, err = library.ImportSkills(in.Names) case "skills/use-library": res, err = library.UseLibrarySkill(in.Name, in.Agent) + case "skills/how": + res, err = library.SetSkillHow(in.Agent, in.How) case "skills/keep-own": res, err = library.KeepAgentSkill(in.Name, in.Agent) case "market/server": diff --git a/internal/gui/tests/skill-how.test.cjs b/internal/gui/tests/skill-how.test.cjs new file mode 100644 index 00000000..62210b86 --- /dev/null +++ b/internal/gui/tests/skill-how.test.cjs @@ -0,0 +1,191 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// ADabbler (#896): the Library gives skills as links to its folder, and +// some want each agent to have a copy of its own. The Skills tab says how +// they're given, Links or Copies, and switching posts the library's way; +// By agent lists the agents that take skills, each with Library's way / +// Links / Copies (Claude Desktop only ever takes copies, and has no +// switch), and a pick posts that agent's way; a skill whose copy in an +// agent differs from the library's says so, and a click syncs. No select, +// no scroll, no left border, in English and Chinese. 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/ada"; + +function server(lang, posts) { + const st = { copy: false, own: {}, behind: ["codex"] }; + const how = (id) => st.own[id] || (st.copy ? "copy" : "link"); + const agent = (id, name, extra = {}) => ({ id, name, icon: "", skills: `${HOME}/.${id}/skills`, how: how(id), howOwn: !!st.own[id], ...extra }); + const lib = () => ({ + dir: `${HOME}/.magpie/library`, backups: `${HOME}/.magpie/backups`, home: HOME, copySkills: st.copy, + agents: [agent("claude", "Claude Code"), agent("codex", "Codex"), agent("claude-desktop", "Claude Desktop", { how: "copy", mustCopy: true })], + instructions: { agents: [] }, servers: [], foundServers: [], projects: [], foundSkills: [], + skills: [ + { name: "pdf", description: "Read PDFs", kind: "", agents: ["claude", "codex"], behind: st.behind.length ? st.behind : undefined }, + { name: "tdd", description: "Tests first", kind: "", agents: ["claude"] }, + ], + }); + return async (route) => { + const req = route.request(), url = new URL(req.url()); + 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 route.fulfill({ json: { agents: [], profiles: [], settings: { lang, theme: "light" } } }); + if (url.pathname === "/api/library") return route.fulfill({ json: lib() }); + if (url.pathname === "/api/library/skills/how") { + const b = req.postDataJSON(); + posts.push({ path: "how", ...b }); + if (!b.agent) st.copy = b.how === "copy"; + else if (b.how) st.own[b.agent] = b.how; + else delete st.own[b.agent]; + return route.fulfill({ json: { ...lib(), result: { changed: ["claude", "codex"], problems: [] } } }); + } + if (url.pathname === "/api/library/all/sync") { + posts.push({ path: "sync" }); + st.behind = []; + return route.fulfill({ json: { ...lib(), result: { changed: ["codex"], problems: [] } } }); + } + if (url.pathname === "/api/plugins") return route.fulfill({ json: { plugins: [] } }); + if (url.pathname === "/api/usage/quotas") return route.fulfill({ json: [] }); + if (url.pathname === "/api/groups") return route.fulfill({ json: { groups: [] } }); + if (url.pathname === "/api/providers") return route.fulfill({ json: { providers: [], gateway: { running: true } } }); + if (url.pathname.startsWith("/api/")) return route.fulfill({ json: {} }); + 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)]; + await route.fulfill({ body: await fs.readFile(file), contentType }); + }; +} + +const scrolled = (page) => page.evaluate(() => [window.scrollX, window.scrollY, ...[...document.querySelectorAll("*")].filter((e) => e.scrollTop || e.scrollLeft).map((e) => `${e.className}:${e.scrollTop},${e.scrollLeft}`)].join(" ")); +const press = async (page, loc) => { + const b = await loc.boundingBox(); + await page.mouse.click(b.x + b.width / 2, b.y + b.height / 2); +}; +// no coloured stripe down the left of anything it drew +const leftBorders = (page, sel) => page.evaluate((sel) => [...document.querySelectorAll(sel)].flatMap((e) => [e, ...e.querySelectorAll("*")]) + .filter((e) => parseFloat(getComputedStyle(e).borderLeftWidth) > 0 && getComputedStyle(e).borderLeftColor !== getComputedStyle(e).borderTopColor) + .map((e) => e.className), sel); + +const words = { + en: { + label: "Give skills as", links: "Links", copies: "Copies", byAgent: "By agent", byAgentOne: "By agent (1)", + linkNote: "Links follow the library's skill: an update reaches the agents at once.", + copyNote: "Each agent gets a folder of its own, made again when the library's skill changes; edits made in a copy are replaced.", + copied: "The agents get copies of their skills now", behind: "Copy out of date", behindTip: "The copy in Codex differs from the library's skill. Click to copy it again.", + synced: "Copies updated", sheet: "How each agent gets its skills", libWay: "Library's way", only: "Only ever takes copies", getsCopies: "Gets copies", getsLinks: "Gets links", + codexLinks: "Codex gets links to its skills now", + }, + zh: { + label: "Skills 启用方式", links: "符号链接", copies: "副本", byAgent: "按 Agent", byAgentOne: "按 Agent(1)", + linkNote: "符号链接跟着资源库里的 Skill 走:一更新,各 Agent 立刻就用上。", + copyNote: "每个 Agent 拿到一份独立的文件夹,资源库里的 Skill 变了就重新复制;在副本里做的修改会被覆盖。", + copied: "现在各 Agent 拿到的是 Skills 的副本", behind: "副本已落后", behindTip: "Codex 里的副本和资源库里的 Skill 不一样了。点一下重新复制。", + synced: "副本已更新", sheet: "各 Agent 的 Skills 启用方式", libWay: "跟随资源库", only: "只能用副本", getsCopies: "用副本", getsLinks: "用符号链接", + codexLinks: "Codex 现在用的是符号链接", + }, +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + test(engine + ": skills given as links or copies (#896)", async (t) => { + assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit"); + const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" })); + const errors = []; + t.after(async () => { + if (errors.length) console.log(errors); + await browser.close(); + }); + const open = async (lang, posts) => { + const ctx = await browser.newContext({ viewport: { width: 900, height: 700 } }); + await ctx.addInitScript(() => { try { localStorage.setItem("magpie.libTab", "skills"); } 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, posts)); + await page.goto("http://magpie.test/"); + await page.locator('button[data-view="library"]').click(); + await page.locator("#view-library .lib-row").first().waitFor(); + return page; + }; + + for (const lang of ["en", "zh"]) { + await t.test(lang, async () => { + const w = words[lang], posts = []; + const page = await open(lang, posts); + const bar = page.locator("#lib-skillhow"); + assert.equal((await bar.locator(".label").textContent()).trim(), w.label); + const segs = bar.locator(".segs .opt"); + assert.deepEqual(await segs.allTextContents(), [w.links, w.copies]); + assert.equal((await bar.locator(".segs .opt.on").textContent()).trim(), w.links); + assert.equal((await bar.locator(".note").textContent()).trim(), w.linkNote); + assert.equal((await bar.locator(".lib-howagent").textContent()).trim(), w.byAgent); + const before = await scrolled(page); + + // the library's way: copies + await press(page, segs.nth(1)); + await page.waitForTimeout(300); + assert.deepEqual(posts, [{ path: "how", agent: "", how: "copy" }]); + assert.equal((await page.locator("#status").textContent()).trim(), w.copied); + assert.equal((await bar.locator(".segs .opt.on").textContent()).trim(), w.copies); + assert.equal((await bar.locator(".note").textContent()).trim(), w.copyNote); + assert.equal(await scrolled(page), before, "the switch scrolls nothing"); + + // a copy behind the library's says so, and a click syncs + const pdf = page.locator("#view-library .lib-skill", { hasText: "pdf" }); + const behind = pdf.locator(".lib-behind"); + assert.equal((await behind.textContent()).trim(), w.behind); + assert.equal(await behind.getAttribute("title"), w.behindTip); + assert.equal(await page.locator("#view-library .lib-skill", { hasText: "tdd" }).locator(".lib-behind").count(), 0); + await press(page, behind); + await page.waitForTimeout(300); + assert.deepEqual(posts[1], { path: "sync" }); + assert.equal(await page.locator("#view-library .lib-behind").count(), 0, "no copy is behind after the sync"); + assert.equal((await page.locator("#status").textContent()).trim(), w.synced); + assert.equal(await page.locator("#modal .lib-editor").count(), 0, "the tag's click doesn't open the skill"); + assert.equal(await scrolled(page), before, "the sync scrolls nothing"); + + // one agent its own way + await press(page, bar.locator(".lib-howagent")); + const sheet = page.locator("#modal .lib-howsheet"); + await sheet.waitFor(); + assert.equal((await sheet.locator(".ehead b").textContent()).trim(), w.sheet); + const rows = sheet.locator(".lib-how-row"); + assert.deepEqual(await rows.evaluateAll((r) => r.map((x) => x.dataset.agent)), ["claude", "codex", "claude-desktop"]); + const codex = rows.nth(1), desktop = rows.nth(2); + assert.equal((await codex.locator(".sub").textContent()).trim(), w.getsCopies); + assert.deepEqual(await codex.locator(".segs .opt").allTextContents(), [w.libWay, w.links, w.copies]); + assert.equal((await codex.locator(".segs .opt.on").textContent()).trim(), w.libWay); + assert.equal((await desktop.locator(".sub").textContent()).trim(), w.only); + assert.equal(await desktop.locator(".segs").count(), 0, "Claude Desktop has no switch"); + await press(page, codex.locator(".segs .opt").nth(1)); + await page.waitForTimeout(300); + assert.deepEqual(posts[2], { path: "how", agent: "codex", how: "link" }); + assert.equal((await page.locator("#status").textContent()).trim(), w.codexLinks); + assert.equal((await rows.nth(1).locator(".sub").textContent()).trim(), w.getsLinks); + assert.equal((await rows.nth(1).locator(".segs .opt.on").textContent()).trim(), w.links); + assert.equal((await rows.nth(0).locator(".sub").textContent()).trim(), w.getsCopies, "the others keep the library's way"); + assert.deepEqual(await leftBorders(page, "#modal .lib-howsheet"), []); + // back to the library's way + await press(page, rows.nth(1).locator(".segs .opt").nth(0)); + await page.waitForTimeout(300); + assert.deepEqual(posts[3], { path: "how", agent: "codex", how: "" }); + assert.equal((await rows.nth(1).locator(".segs .opt.on").textContent()).trim(), w.libWay); + await press(page, rows.nth(1).locator(".segs .opt").nth(1)); + await page.waitForTimeout(300); + await sheet.locator(".bar button.primary").click(); + await sheet.waitFor({ state: "detached" }); + assert.equal((await bar.locator(".lib-howagent").textContent()).trim(), w.byAgentOne, "the button counts the agents with a way of their own"); + assert.equal(await scrolled(page), before, "the sheet scrolls nothing"); + + assert.equal(await page.locator("select").count(), 0, "no native select"); + assert.deepEqual(await leftBorders(page, "#lib-skillhow, #view-library .lib-skill"), []); + }); + } + + assert.deepEqual(errors, []); + }); +} diff --git a/internal/library/library.go b/internal/library/library.go index 5c3e5f20..eb0527c6 100644 --- a/internal/library/library.go +++ b/internal/library/library.go @@ -35,6 +35,11 @@ type Library struct { // SkillGroups are the groups the user made of some skills on the // page, each shown as a group of its own (#791) SkillGroups []*SkillGroup `json:"skillGroups,omitempty"` + // CopySkills gives the agents copies of their skills rather than links + // to the library's, and SkillHow is an agent's own way over it, link or + // copy (#896, skill_how.go) + CopySkills bool `json:"copySkills,omitempty"` + SkillHow map[string]string `json:"skillHow,omitempty"` // kept is where a change put what it kept aside before the sync, for // the sync to keep the agents' files beside it kept *backups diff --git a/internal/library/skill_how.go b/internal/library/skill_how.go new file mode 100644 index 00000000..3915c619 --- /dev/null +++ b/internal/library/skill_how.go @@ -0,0 +1,143 @@ +package library + +// How an agent is given its skills (#896): a link to the library's folder, +// which an update reaches at once, or a copy of its own, which a tool that +// doesn't follow links (or a sync, a backup, an archive) takes as a folder +// like any other. The library has one way for every agent, and an agent +// can have its own. A copy carries magpie's mark (marker), so it is known +// for magpie's own and is all a change of way or a Remove ever takes away; +// a folder of the user's by that name is never touched. A copy is made +// again on each sync once the library's skill differs from it: the page +// says which copies differ until then (SkillView.Behind), with a button +// that syncs. + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" +) + +// The ways an agent can be given its skills. +const ( + HowLink = "link" + HowCopy = "copy" +) + +// copies is whether the agent of t gets copies of its skills: one that +// can only take copies (in a WSL distro), else its own way, else the +// library's. Agents reading the very same folder (sharers) get copies when +// any of them is to, so that they don't undo each other's. +func (l *Library) copies(t *Target, sharers []string) bool { + if t.Copy { + return true + } + return slices.ContainsFunc(append([]string{t.Agent.ID}, sharers...), func(id string) bool { return l.howOf(id) == HowCopy }) +} + +// howOf is the way the agent is given its skills: its own, else the +// library's. +func (l *Library) howOf(id string) string { + if h := l.SkillHow[id]; h == HowLink || h == HowCopy { + return h + } + if l.CopySkills { + return HowCopy + } + return HowLink +} + +// SetSkillHow sets the way the library gives its skills to every agent +// (agent "") or to one ("" for how the library does it); the agents' links +// and copies are made again the new way. +func SetSkillHow(agent, how string) (*Result, error) { + if how != "" && how != HowLink && how != HowCopy { + return nil, fmt.Errorf("skills are given as %s or %s, not %q", HowLink, HowCopy, how) + } + return change(func(l *Library) error { + if agent == "" { + if how == "" { + return fmt.Errorf("the library gives skills as %s or %s", HowLink, HowCopy) + } + l.CopySkills = how == HowCopy + return nil + } + if err := checkAgent(agent); err != nil { + return err + } + if how == "" { + delete(l.SkillHow, agent) + if len(l.SkillHow) == 0 { + l.SkillHow = nil + } + return nil + } + if l.SkillHow == nil { + l.SkillHow = map[string]string{} + } + l.SkillHow[agent] = how + return nil + }) +} + +// relink puts a link to the library's skill at p in place of magpie's copy +// there, not ok when no link can be made (Windows without the right to), +// which leaves the copy as it is. A copy that differs from the library's +// skill (edited in the agent) is kept aside rather than lost. +func relink(agent, p, name string) (bool, error) { + next := filepath.Join(filepath.Dir(p), "."+name+".magpie-link") + os.Remove(next) + if err := os.Symlink(skillDir(name), next); err != nil { + return false, nil + } + var err error + if fresh(p, name) { + old := oldPath(p, name) + if err = os.Rename(p, old); err == nil { + // best effort: what's left is tried again on the next sync + defer os.RemoveAll(old) + } + } else { + _, err = setAside(agent, p) + } + if err != nil { + os.Remove(next) + return false, err + } + if err := os.Rename(next, p); err != nil { + os.Remove(next) + return false, errors.Join(err, link(p, name)) + } + return true, nil +} + +// behind are, for each skill, the agents given it as a copy (of their own +// choosing, on this machine) whose copy differs from the library's skill. +func (l *Library) behind(targets []*Target) map[string][]string { + out := map[string][]string{} + libHash := map[string]string{} + for _, t := range targets { + if t.Skills == "" || t.Desktop != nil || t.Agent.WSL != "" || l.howOf(t.Agent.ID) != HowCopy { + continue + } + for _, s := range l.Skills { + if !slices.Contains(s.Agents, t.Agent.ID) { + continue + } + p := filepath.Join(t.Skills, s.Name) + if !ours(p, s.Name) || linked(p) { + continue + } + h, ok := libHash[s.Name] + if !ok { + h = hashDir(realDir(skillDir(s.Name))) + libHash[s.Name] = h + } + if h != "" && hashDir(p) != h && !slices.Contains(out[s.Name], t.Agent.ID) { + out[s.Name] = append(out[s.Name], t.Agent.ID) + } + } + } + return out +} diff --git a/internal/library/skill_how_test.go b/internal/library/skill_how_test.go new file mode 100644 index 00000000..c8a0e2af --- /dev/null +++ b/internal/library/skill_how_test.go @@ -0,0 +1,142 @@ +package library + +import ( + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +// #896: skills are given as links or as copies, for every agent or one; +// a change of way makes each again, a copy behind the library's is said +// and made again by a sync, and the user's own folders are never touched. +func TestSkillHow(t *testing.T) { + h := sandbox(t) + src := filepath.Join(h, "src/skills") + skill(t, filepath.Join(src, "pdf"), "pdf", "Read PDFs") + // the user's own skill, which the library has nothing to do with + skill(t, filepath.Join(h, ".claude/skills/mine"), "mine", "Mine") + ok(t)(InstallSkills(src, []string{"pdf"}, []string{"claude", "codex"})) + cl, cx := filepath.Join(h, ".claude/skills/pdf"), filepath.Join(h, ".codex/skills/pdf") + if !isLink(t, cl) || !isLink(t, cx) { + t.Fatal("not linked at first") + } + + if _, err := SetSkillHow("", "both"); err == nil { + t.Error("an unknown way was taken") + } + if _, err := SetSkillHow("", ""); err == nil { + t.Error("the library was given no way") + } + + // copies for every agent + ok(t)(SetSkillHow("", HowCopy)) + for _, p := range []string{cl, cx} { + if isLink(t, p) { + t.Fatalf("%s is still a link", p) + } + if read(t, filepath.Join(p, "SKILL.md")) != read(t, filepath.Join(src, "pdf/SKILL.md")) { + t.Errorf("%s: not a copy of the skill", p) + } + if !ours(p, "pdf") { + t.Errorf("%s: not marked magpie's", p) + } + } + v, err := Read(nil) + if err != nil { + t.Fatal(err) + } + if !v.CopySkills { + t.Error("the page doesn't say copies") + } + for _, a := range v.Agents { + if (a.ID == "claude" || a.ID == "codex") && (a.How != HowCopy || a.HowOwn) { + t.Errorf("%s: how %q own %v", a.ID, a.How, a.HowOwn) + } + } + if len(v.Skills) != 1 || v.Skills[0].Behind != nil { + t.Fatalf("behind at once: %+v", v.Skills) + } + + // the library's skill changes (a folder edited by hand): the copies + // are behind till a sync makes them again + write(t, filepath.Join(src, "pdf/SKILL.md"), "---\nname: pdf\ndescription: Changed\n---\n") + v, _ = Read(nil) + if b := v.Skills[0].Behind; !slices.Equal(b, []string{"claude", "codex"}) { + t.Fatalf("behind: %v", b) + } + ok(t)(Sync()) + if !strings.Contains(read(t, filepath.Join(cl, "SKILL.md")), "Changed") { + t.Error("the sync didn't copy it again") + } + if v, _ = Read(nil); v.Skills[0].Behind != nil { + t.Errorf("still behind: %v", v.Skills[0].Behind) + } + + // one agent its own way + ok(t)(SetSkillHow("codex", HowLink)) + if !isLink(t, cx) || isLink(t, cl) { + t.Fatal("codex isn't a link again, or claude's copy went") + } + v, _ = Read(nil) + for _, a := range v.Agents { + if a.ID == "codex" && (a.How != HowLink || !a.HowOwn) { + t.Errorf("codex: how %q own %v", a.How, a.HowOwn) + } + } + if _, err := SetSkillHow("no such/agent", HowCopy); err == nil { + t.Error("a malformed agent id was given a way") + } + + // an edit made in claude's copy isn't lost when it is a link again: + // it is kept with the backups + write(t, filepath.Join(cl, "notes.md"), "my notes\n") + ok(t)(SetSkillHow("", HowLink)) + if !isLink(t, cl) { + t.Fatal("claude isn't a link again") + } + var kept []string + filepath.WalkDir(BackupDir(), func(p string, d fs.DirEntry, err error) error { + if err == nil && d.Name() == "notes.md" { + kept = append(kept, p) + } + return nil + }) + if len(kept) != 1 || read(t, kept[0]) != "my notes\n" { + t.Errorf("the edited copy wasn't kept: %v", kept) + } + // codex goes the library's way again + ok(t)(SetSkillHow("codex", HowCopy)) + ok(t)(SetSkillHow("codex", "")) + if !isLink(t, cx) { + t.Error("codex didn't go back to the library's way") + } + + // a skill of the user's by the library's name stands, whichever way + ok(t)(SkillAgents("pdf", []string{"codex"})) + skill(t, cl, "pdf", "The user's own") + for _, how := range []string{HowCopy, HowLink} { + ok(t)(SetSkillHow("", how)) + if isLink(t, cl) || ours(cl, "pdf") || !strings.Contains(read(t, filepath.Join(cl, "SKILL.md")), "The user's own") { + t.Fatalf("%s: the user's own folder was touched", how) + } + } + if !strings.Contains(read(t, filepath.Join(h, ".claude/skills/mine/SKILL.md")), "Mine") { + t.Error("the user's own skill was touched") + } + + // a copy is taken away when the skill is + ok(t)(SetSkillHow("", HowCopy)) + if isLink(t, cx) { + t.Fatal("codex isn't a copy") + } + ok(t)(RemoveSkill("pdf")) + if _, err := os.Lstat(cx); !os.IsNotExist(err) { + t.Error("codex's copy is still there") + } + if _, err := os.Stat(filepath.Join(cl, "SKILL.md")); err != nil { + t.Error("the user's own pdf went with the library's") + } +} diff --git a/internal/library/skills.go b/internal/library/skills.go index 5a2bc015..8ff33dc4 100644 --- a/internal/library/skills.go +++ b/internal/library/skills.go @@ -398,6 +398,7 @@ func (l *Library) syncSkills(t *Target, res *Result, all []*Target) { sharers = append(sharers, o.Agent.ID) } } + cp := l.copies(t, sharers) wanted := func(s *Skill) bool { if s == nil { return false @@ -461,10 +462,20 @@ func (l *Library) syncSkills(t *Target, res *Result, all []*Target) { if ours(p, s.Name) { dropOld(p, s.Name) } + // magpie's copy in an agent given links now is a link again + // (#896), unless no link can be made here (Windows without the + // right to), where the copy stays + if ours(p, s.Name) && !cp && !linked(p) { + if ok, err := relink(id, p, s.Name); err != nil { + res.fail(id, "skill:"+s.Name, err) + } else if ok { + res.changed(id) + } + } // a copy is made again once the library's skill has changed: in // an agent that takes copies, and where magpie couldn't link // (Windows without the right to) and left a copy instead - if ours(p, s.Name) && ((t.Copy && linked(p)) || (!linked(p) && !fresh(p, s.Name) && hashDir(realDir(skillDir(s.Name))) != "")) { + if ours(p, s.Name) && ((cp && linked(p)) || (!linked(p) && !fresh(p, s.Name) && hashDir(realDir(skillDir(s.Name))) != "")) { if err := copyIn(p, s.Name); err != nil { res.fail(id, "skill:"+s.Name, err) } else { @@ -491,7 +502,7 @@ func (l *Library) syncSkills(t *Target, res *Result, all []*Target) { } } put := link - if t.Copy { + if cp { put = copyIn } if err := put(p, s.Name); err != nil { diff --git a/internal/library/skills_copy_test.go b/internal/library/skills_copy_test.go index ae6ab007..c5f27772 100644 --- a/internal/library/skills_copy_test.go +++ b/internal/library/skills_copy_test.go @@ -31,17 +31,12 @@ func TestSkillCopyFollowsTheLibrary(t *testing.T) { ok(t)(InstallSkills("owner/repo", []string{"skills/pdf"}, []string{"claude"})) p := filepath.Join(h, ".claude/skills/pdf") - // the copy link makes where it can't link, wherever this runs - if fi, err := os.Lstat(p); err != nil { - t.Fatal(err) - } else if fi.Mode()&os.ModeSymlink != 0 { - if err := os.Remove(p); err != nil { - t.Fatal(err) - } - if err := copyDir(skillDir("pdf"), p); err != nil { - t.Fatal(err) - } - write(t, filepath.Join(p, marker), "copied from "+skillDir("pdf")+"\n") + // the copy link makes where it can't link, wherever this runs: the + // library giving copies (a copy left where links are given is made a + // link again, #896) + ok(t)(SetSkillHow("", HowCopy)) + if linked(p) { + t.Fatal("claude's pdf is still a link") } if !ours(p, "pdf") { t.Fatal("the copy isn't magpie's") @@ -103,16 +98,11 @@ func TestSkillCopySwapSurvivesAnUndeletableFile(t *testing.T) { t.Fatal(err) } write(t, filepath.Join(skillDir("pdf"), "sub/forms.md"), "forms one") - if fi, err := os.Lstat(p); err != nil { - t.Fatal(err) - } else if fi.Mode()&os.ModeSymlink != 0 { - if err := os.Remove(p); err != nil { - t.Fatal(err) - } - if err := copyDir(skillDir("pdf"), p); err != nil { - t.Fatal(err) - } - write(t, filepath.Join(p, marker), "copied from "+skillDir("pdf")+"\n") + // a copy, as where magpie can't link (#896: a copy left where links are + // given is made a link again) + ok(t)(SetSkillHow("", HowCopy)) + if linked(p) { + t.Fatal("claude's pdf is still a link") } // sub/forms.md in the agent's copy can't be removed sub := filepath.Join(p, "sub") diff --git a/internal/library/sync.go b/internal/library/sync.go index f1c480c6..ed080474 100644 --- a/internal/library/sync.go +++ b/internal/library/sync.go @@ -173,6 +173,12 @@ type AgentView struct { NoSSE bool `json:"noSSE,omitempty"` NoRemote bool `json:"noRemote,omitempty"` MCPVia string `json:"mcpVia,omitempty"` + // How is the way it is given its skills, link or copy, and HowOwn its + // own over the library's; MustCopy is one that can only take copies + // (in WSL) + How string `json:"how,omitempty"` + HowOwn bool `json:"howOwn,omitempty"` + MustCopy bool `json:"mustCopy,omitempty"` } // ServerView is a library server, and what each agent it's on made of it. @@ -201,6 +207,9 @@ type SkillView struct { // Always are the agents that have it whatever the library gives them: // it is kept in ~/.agents/skills, which they read themselves (#595) Always []string `json:"always,omitempty"` + // Behind are the agents given it as a copy whose copy differs from it, + // till the next sync makes it again + Behind []string `json:"behind,omitempty"` } // View is the Library page. @@ -215,6 +224,7 @@ type View struct { Dir string `json:"dir"` Backups string `json:"backups"` SkillGroups []SkillGroup `json:"skillGroups"` + CopySkills bool `json:"copySkills"` // the library gives skills as copies (#896) } // Read is the whole page: the library, and what's found in the agents. @@ -230,12 +240,20 @@ func Read(problems []Problem) (*View, error) { if err != nil { return nil, err } - v := &View{Agents: []AgentView{}, Servers: []ServerView{}, Skills: []SkillView{}, Instructions: iv, Dir: Dir(), Backups: BackupDir()} + v := &View{Agents: []AgentView{}, Servers: []ServerView{}, Skills: []SkillView{}, Instructions: iv, Dir: Dir(), Backups: BackupDir(), CopySkills: l.CopySkills} targets := Targets() + behind := l.behind(targets) for _, t := range targets { av := AgentView{ID: t.Agent.ID, Name: t.Agent.Name, Icon: t.Agent.Icon, Instructions: t.Instructions, Skills: t.Skills, SkillsAlso: t.SkillsAlso, Note: t.Note, MCPVia: t.MCPVia, ProjectSkills: ProjectSkillsDir(t.Agent.ID), ProjectMCP: ProjectMCPFile(t.Agent.ID), ProjectNoSSE: ProjectNoSSE(t.Agent.ID)} + if t.Skills != "" { + _, av.HowOwn = l.SkillHow[t.Agent.ID] + av.How, av.MustCopy = l.howOf(t.Agent.ID), t.Copy || t.Desktop != nil + if av.MustCopy { + av.How = HowCopy + } + } if t.MCP != nil { av.MCP = t.MCP.Path av.NoSSE = t.MCP.supports(&Server{Transport: "sse"}) != nil @@ -312,6 +330,7 @@ func Read(problems []Problem) (*View, error) { sv.Missing = true } sv.Check = lastCheck(s.Name) + sv.Behind = behind[s.Name] v.Skills = append(v.Skills, sv) } v.FoundServers = foundServers(l) diff --git a/library_cli.go b/library_cli.go index 16d5fc6a..366c00e2 100644 --- a/library_cli.go +++ b/library_cli.go @@ -25,6 +25,8 @@ const libraryUsage = `magpie library what the library gives magpie library skill rm (skills are installed from the app's Library page) magpie library skill rm --all [--yes] every skill out of the library and the agents (asks first; --yes doesn't) magpie library skill update [name] fetch a skill from GitHub again; with no name, every one from there + magpie library skill how link|copy [agent] give skills as links to the library's or as copies: every agent's way, or one agent's + magpie library skill how default the agent goes the library's way again magpie library skill use-library an agent's own skill by that name is in the way: set it aside, link the library's magpie library skill keep-own …or keep the agent's, and take the agent off the library's magpie library rtk which agents run their shell commands through RTK (rtk-ai.app), to save tokens @@ -134,6 +136,18 @@ func libraryCmd(args []string) error { } case len(rest) == 1 && rest[0] == "update": res, err = library.UpdateSkills() + case (len(rest) == 2 || len(rest) == 3) && rest[0] == "how": + how, agent := rest[1], "" + if len(rest) == 3 { + agent = rest[2] + } + if how == "default" { + if agent == "" { + return fmt.Errorf("usage:\n %s", libraryUsage) + } + how = "" + } + res, err = library.SetSkillHow(agent, how) default: return fmt.Errorf("usage:\n %s", libraryUsage) }