library, gui, cli: skills can be given to the agents as copies rather than links, for every agent or one (#896, ADabbler) — the Library linked each skill into the agents (a copy only where it couldn't link: Windows without the right to make links, WSL, Claude Desktop), and some want each agent to have a folder of its own. Skills › under the list, "Give skills as: Links | Copies" sets the library's way (library.json copySkills), and By agent gives an agent its own way (skillHow: Library's way / Links / Copies; Claude Desktop and WSL only ever take copies and have no switch). A copy is the whole skill folder with the .magpie-library marker, made by the same swap as before (.name.magpie-next, the old one set aside), so it is known for magpie's: taking a skill away removes magpie's copy and never a folder of the user's by that name. Switching makes each enabled skill again in place: a link becomes a copy; a copy becomes a link (made beside it and swapped in; a copy edited by hand is kept with the backups first). Where a link can't be made (Windows without the right) it stays a copy, as before. A copy follows the library's skill on every sync (at start, and after any change in the Library) as before; one that differs now (the library's folder edited by hand) is marked "Copy out of date" on the skill, saying in which agents, and a click copies it again. CLI: magpie library skill how link|copy [agent], magpie library skill how default <agent>. en/zh/ja/de. TestSkillHow (temp HOME: links at first, bad ways refused, copies marked, Read's how and copySkills, behind after a hand edit and cleared by a sync, codex its own way, an edited copy kept in the backups, back to the library's way, the user's own same-name folder and own skill untouched both ways, a copy removed with its skill) fails without the change; TestSkillCopyFollowsTheLibrary and TestSkillCopySwapSurvivesAnUndeletableFile now ask for copies rather than leave a copy where links are given, which is made a link again now. skill-how.test.cjs (WebKit, en and zh: the bar, Links/Copies posted with the note and status, the out-of-date tag with its agents and a click syncing without opening the skill, By agent's sheet with Claude Desktop's copies-only row and no switch, an agent's way posted and back to the library's, By agent (1), no select, no scroll, no left border) fails without it. Every Library and skill GUI test and click-scroll pass in WebKit. go vet, go build (darwin, GOOS=linux nogui, GOOS=windows) and go test -tags nogui ./... under a temp HOME pass.
This commit is contained in:
1 parent
ae23ad83f7
commit
3c7ceb68f4
12 files changed
+683
-24
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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%; }
|
||||
|
||||
@@ -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 || "";
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -25,6 +25,8 @@ const libraryUsage = `magpie library what the library gives
|
||||
magpie library skill rm <name> (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 <agent> the agent goes the library's way again
|
||||
magpie library skill use-library <name> <agent> an agent's own skill by that name is in the way: set it aside, link the library's
|
||||
magpie library skill keep-own <name> <agent> …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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user