diff --git a/internal/gui/assets/i18n.js b/internal/gui/assets/i18n.js index d5d910dd..1a581d91 100644 --- a/internal/gui/assets/i18n.js +++ b/internal/gui/assets/i18n.js @@ -2330,6 +2330,7 @@ const I18N = { "Names from Z to A": "按名称 Z→A", "not in the library — bring one in to give it to the others": "不在资源库中 — 导入后可以给其他 Agent", "{agents} has no skills folder.": "{agents} 没有技能文件夹。", + "Claude Desktop shows skill changes once its window is reloaded ({keys}). It gets a copy of each skill, and one you change in Desktop is left as it is.": "Claude Desktop 在窗口刷新({keys})后才会显示技能的变化。它拿到的是每个技能的副本,你在 Desktop 里改过的副本不会被覆盖。", "In projects": "项目中", "Add a project": "添加项目", "Give a project's agents some of these skills as the project's own: magpie links them into its .claude/skills and .agents/skills and keeps them out of git.": "把其中一些技能作为项目自己的技能给项目里的 Agent:magpie 把它们链接到项目的 .claude/skills 和 .agents/skills,并让 git 忽略它们。", diff --git a/internal/gui/assets/library.js b/internal/gui/assets/library.js index 45e5af45..abaafd63 100644 --- a/internal/gui/assets/library.js +++ b/internal/gui/assets/library.js @@ -1613,6 +1613,10 @@ if (lib.skills.length || lib.projects.length) renderProjects(body, "skills"); const skip = shownAgents().filter((a) => !a.skills); if (skip.length) body.append(el("p", "lib-aside", t("{agents} has no skills folder.", { agents: skip.map((a) => a.name).join(", ") }))); + // Desktop reads its skills list again only when its window is reloaded (#638) + if (shownAgents().some((a) => a.id === "claude-desktop" && a.skills)) { + body.append(el("p", "lib-aside", t("Claude Desktop shows skill changes once its window is reloaded ({keys}). It gets a copy of each skill, and one you change in Desktop is left as it is.", { keys: /^Mac/.test(navigator.platform) ? "⌘R" : "Ctrl+R" }))); + } body.append(discover("skills")); } diff --git a/internal/gui/tests/library-desktop-skills.test.cjs b/internal/gui/tests/library-desktop-skills.test.cjs new file mode 100644 index 00000000..88dec8a5 --- /dev/null +++ b/internal/gui/tests/library-desktop-skills.test.cjs @@ -0,0 +1,88 @@ +// Run with Node's test runner and Playwright on the module path; see README.md. +// #638: Claude Desktop takes skills (copied into Cowork's skills-plugin), so +// the Skills tab gives it an icon on each skill, and says Desktop shows +// changes once its window is reloaded; without a skills-plugin it is one +// with no skills folder, and the note isn't there. 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/aimer"; +const lib = (desktopSkills) => ({ + dir: `${HOME}/.magpie/library`, backups: `${HOME}/.magpie/backups`, home: HOME, + agents: [ + { id: "codex", name: "Codex", icon: "", skills: `${HOME}/.codex/skills` }, + { id: "claude-desktop", name: "Claude Desktop", icon: "claude-color", mcp: `${HOME}/Library/Application Support/Claude/claude_desktop_config.json`, + ...(desktopSkills ? { skills: `${HOME}/Library/Application Support/Claude-3p/local-agent-mode-sessions/skills-plugin/o/a/skills` } : {}) }, + ], + instructions: { agents: [], sets: [] }, foundServers: [], projects: [], foundSkills: [], servers: [], + skills: [{ name: "grilling", description: "Grill a plan", kind: "", agents: ["codex"] }], +}); + +function server(lang, desktopSkills) { + return async (route) => { + const url = new URL(route.request().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/plugins") return route.fulfill({ json: { plugins: [] } }); + if (url.pathname === "/api/library") return route.fulfill({ json: lib(desktopSkills) }); + 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)]; + const body = await fs.readFile(file).catch(() => null); + await (body ? route.fulfill({ body, contentType }) : route.fulfill({ status: 404, body: "" })); + }; +} + +const words = { + en: { note: "Claude Desktop shows skill changes once its window is reloaded", none: "Claude Desktop has no skills folder." }, + zh: { note: "Claude Desktop 在窗口刷新", none: "Claude Desktop 没有技能文件夹。" }, +}; + +for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) { + test(engine + ": Claude Desktop takes skills", 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 () => { + await browser.close(); + }); + for (const lang of ["en", "zh"]) { + for (const has of [true, false]) { + await t.test(`${lang} ${has ? "with" : "without"} a skills-plugin`, async () => { + const ctx = await browser.newContext({ viewport: { width: 980, height: 800 } }); + 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)); + await page.route("**/*", server(lang, has)); + await page.goto("http://magpie.test/"); + await page.locator('button[data-view="library"]').click(); + await page.locator('#view-library .lib-row .lib-ag[data-agent="codex"]').first().waitFor(); + const asides = await page.locator("#view-library p.lib-aside").allTextContents(); + const chip = await page.locator('#view-library .lib-row .lib-ag[data-agent="claude-desktop"]').count(); + if (has) { + assert.ok(chip > 0, "no Claude Desktop icon on the skill"); + const note = asides.find((s) => s.includes(words[lang].note)); + assert.ok(note && note.includes(/Mac/.test(await page.evaluate(() => navigator.platform)) ? "⌘R" : "Ctrl+R"), asides.join(" | ")); + assert.ok(!asides.some((s) => s.includes(words[lang].none)), asides.join(" | ")); + } else { + assert.equal(chip, 0); + assert.ok(asides.some((s) => s.includes(words[lang].none)), asides.join(" | ")); + assert.ok(!asides.some((s) => s.includes(words[lang].note)), asides.join(" | ")); + } + await ctx.close(); + }); + } + } + assert.deepEqual(errors, []); + }); +} diff --git a/internal/library/desktop_skills.go b/internal/library/desktop_skills.go new file mode 100644 index 00000000..55afb6d0 --- /dev/null +++ b/internal/library/desktop_skills.go @@ -0,0 +1,302 @@ +package library + +// Claude Desktop's Cowork (local-agent-mode) finds the user's skills in a +// plugin of its own, one for each account it has been signed in with: +// +// /local-agent-mode-sessions/skills-plugin/// +// .claude-plugin/plugin.json {"name":"anthropic-skills",…} +// manifest.json {"lastUpdated":…,"skills":[{"skillId","name", +// "description","creatorType":"user", +// "syncManaged":false,"updatedAt","enabled"}]} +// skills//SKILL.md +// +// A skill is listed only once the manifest has it, and stays listed after +// its folder goes, so magpie writes both (#638). Each is a copy, not a +// link: Cowork runs a linked skill but won't read the files beside its +// SKILL.md from outside the plugin's folder. The copy is magpie's by its +// mark, which also keeps the hash of what was copied: one changed in +// Desktop since is left as it is, and set aside rather than deleted when +// the library stops giving it. Desktop reads the manifest again when its +// window is reloaded. + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/tidwall/gjson" + + "github.com/yetone/magpie/internal/agent" + "github.com/yetone/magpie/internal/edit" +) + +const desktopManifest = "manifest.json" + +// desktopSkillRoots are the skills-plugin folders of every account Claude +// Desktop has, in its own folder and Claude-3p's alike: each one with a +// manifest.json or the plugin's plugin.json. +func desktopSkillRoots(desktopDir string) []string { + dirs := []string{desktopDir, filepath.Dir(agent.DesktopConfig3p(home()))} + if d, err := os.UserConfigDir(); err == nil { + dirs = append(dirs, filepath.Join(d, "Claude"), filepath.Join(d, "Claude-3p")) + } + var out []string + seen := map[string]bool{} + for _, d := range dirs { + if d == "" || seen[d] { + continue + } + seen[d] = true + base := filepath.Join(d, "local-agent-mode-sessions", "skills-plugin") + orgs, _ := os.ReadDir(base) + for _, o := range orgs { + if !o.IsDir() { + continue + } + accts, _ := os.ReadDir(filepath.Join(base, o.Name())) + for _, a := range accts { + p := filepath.Join(base, o.Name(), a.Name()) + if a.IsDir() && (isFile(filepath.Join(p, desktopManifest)) || isFile(filepath.Join(p, ".claude-plugin", "plugin.json"))) { + out = append(out, p) + } + } + } + } + return out +} + +func isFile(p string) bool { + fi, err := os.Stat(p) + return err == nil && fi.Mode().IsRegular() +} + +// desktopCopy puts a copy of the library's skill at p, its mark keeping +// the hash of what was copied. +func desktopCopy(p, name string) error { + if err := copyIn(p, name); err != nil { + return err + } + f, err := os.OpenFile(filepath.Join(p, marker), os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return err + } + _, err = fmt.Fprintf(f, "hash %s\n", hashDir(p)) + if cerr := f.Close(); err == nil { + err = cerr + } + return err +} + +// desktopEdited is whether magpie's copy at p was changed since it was +// made: its files are no longer what its mark says were copied. +func desktopEdited(p string) bool { + b, err := os.ReadFile(filepath.Join(p, marker)) + if err != nil { + return false + } + for _, line := range strings.Split(string(b), "\n") { + if h, ok := strings.CutPrefix(strings.TrimSpace(line), "hash "); ok { + return h != hashDir(p) + } + } + return false +} + +// desktopStamp is now, as the manifest's other times are written: a +// number of milliseconds where they are numbers, else an ISO time. +func desktopStamp(raw []byte) any { + t := gjson.GetBytes(raw, "lastUpdated") + if !t.Exists() { + t = gjson.GetBytes(raw, "skills.0.updatedAt") + } + if t.Type == gjson.Number { + return time.Now().UnixMilli() + } + return time.Now().UTC().Format("2006-01-02T15:04:05.000Z") +} + +// manifestEntry is a manifest's entry for the skill with that id, "" for +// none. +func manifestEntry(raw []byte, id string) string { + for _, e := range gjson.GetBytes(raw, "skills").Array() { + if e.Get("skillId").String() == id { + return e.Raw + } + } + return "" +} + +// writeManifest sets the manifest's entries for the skills in put (made +// anew, or their description and time renewed, the rest of each kept: +// enabled above all) and takes out those in drop; the +// other entries and keys stay as they are. +func writeManifest(root string, put map[string]bool, drop []string) error { + path := filepath.Join(root, desktopManifest) + raw, err := edit.Read(path) + if err != nil && isFile(path) { + return err + } + stamp := desktopStamp(raw) + var list []json.RawMessage + changed := false + done := map[string]bool{} + entry := func(name string, old string) json.RawMessage { + desc := "" + if md, ok := readMeta(skillDir(name)); ok { + desc = md.Description + } + enabled := json.RawMessage("true") + if e := gjson.Get(old, "enabled"); e.Exists() { + enabled = json.RawMessage(e.Raw) + } + // in the order Desktop writes them, any other key of the old + // entry after them + keys := []string{"skillId", "name", "description", "creatorType", "syncManaged", "updatedAt", "enabled"} + vals := []any{name, name, desc, "user", false, stamp, enabled} + var b strings.Builder + b.WriteByte('{') + for i, k := range keys { + v, _ := json.Marshal(vals[i]) + if i > 0 { + b.WriteByte(',') + } + fmt.Fprintf(&b, "%q:%s", k, v) + } + gjson.Parse(old).ForEach(func(k, v gjson.Result) bool { + if !slices.Contains(keys, k.String()) { + b.WriteString("," + k.Raw + ":" + v.Raw) + } + return true + }) + b.WriteByte('}') + return json.RawMessage(b.String()) + } + for _, e := range gjson.GetBytes(raw, "skills").Array() { + id := e.Get("skillId").String() + switch { + case slices.Contains(drop, id): + changed = true + case put[id] && !done[id]: + list = append(list, entry(id, e.Raw)) + done[id], changed = true, true + default: + list = append(list, json.RawMessage(e.Raw)) + } + } + var added []string + for id := range put { + if !done[id] { + added = append(added, id) + } + } + slices.Sort(added) + for _, id := range added { + list = append(list, entry(id, "")) + changed = true + } + if !changed { + return nil + } + if list == nil { + list = []json.RawMessage{} + } + if !isFile(path) { + b, _ := json.MarshalIndent(map[string]any{"lastUpdated": stamp, "skills": list}, "", " ") + return edit.WriteAtomic(path, append(b, '\n')) + } + return edit.SetJSON(path, edit.KV{Path: "skills", Value: list}, edit.KV{Path: "lastUpdated", Value: stamp}) +} + +// syncDesktopSkills gives Claude Desktop the library's skills it is to +// have, in every account's skills-plugin, and takes out the ones magpie +// put there that it no longer is to. +func (l *Library) syncDesktopSkills(t *Target, res *Result) { + id := t.Agent.ID + a := l.applied(id) + prior := a.Skills + var mine []string + keep := func(name string) { + if !slices.Contains(mine, name) { + mine = append(mine, name) + } + } + for _, root := range t.Desktop { + dir := filepath.Join(root, "skills") + raw, _ := edit.Read(filepath.Join(root, desktopManifest)) + put := map[string]bool{} + var drop []string + for _, name := range prior { + if s := l.skill(name); s != nil && slices.Contains(s.Agents, id) { + continue + } + p := filepath.Join(dir, name) + if !ours(p, name) { + continue + } + var err error + if desktopEdited(p) { + // changed in Desktop: kept with the backups, not lost + _, err = setAside(id, p) + } else { + err = unlink(p) + } + if err != nil { + res.fail(id, "skill:"+name, err) + keep(name) + continue + } + drop = append(drop, name) + res.changed(id) + } + for _, s := range l.Skills { + if !slices.Contains(s.Agents, id) { + continue + } + p := filepath.Join(dir, s.Name) + if ours(p, s.Name) { + keep(s.Name) + switch { + case linked(p) || !fresh(p, s.Name) && !desktopEdited(p): + if err := desktopCopy(p, s.Name); err != nil { + res.fail(id, "skill:"+s.Name, err) + continue + } + put[s.Name] = true + res.changed(id) + case !fresh(p, s.Name): + res.Problems = append(res.Problems, Problem{Agent: id, What: "skill:" + s.Name, + Error: fmt.Sprintf("%s was changed in Claude Desktop, so the library's copy isn't put over it: delete it in Desktop to have the library's again", s.Name)}) + } + if manifestEntry(raw, s.Name) == "" { + put[s.Name] = true + res.changed(id) + } + continue + } + // one of Desktop's own (built in, or made there) by that name + // stays, the library's left out; magpie's own entry, its folder + // gone, gets the folder back + _, err := os.Lstat(p) + if err == nil || manifestEntry(raw, s.Name) != "" && !slices.Contains(prior, s.Name) { + res.Problems = append(res.Problems, Problem{Agent: id, What: "skill:" + s.Name, + Error: fmt.Sprintf("Claude Desktop already has a skill of its own called %s", s.Name)}) + continue + } + if err := desktopCopy(p, s.Name); err != nil { + res.fail(id, "skill:"+s.Name, err) + continue + } + keep(s.Name) + put[s.Name] = true + res.changed(id) + } + if err := writeManifest(root, put, drop); err != nil { + res.fail(id, "skills", err) + } + } + a.Skills = mine +} diff --git a/internal/library/desktop_skills_test.go b/internal/library/desktop_skills_test.go new file mode 100644 index 00000000..c9e9b2a3 --- /dev/null +++ b/internal/library/desktop_skills_test.go @@ -0,0 +1,209 @@ +package library + +import ( + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +// desktopAccount makes a skills-plugin for one of Claude Desktop's +// accounts, under its folder (Claude or Claude-3p), its manifest holding +// what is given. +func desktopAccount(t *testing.T, dir, org, acct, manifest string) string { + t.Helper() + root := filepath.Join(dir, "local-agent-mode-sessions", "skills-plugin", org, acct) + write(t, filepath.Join(root, ".claude-plugin", "plugin.json"), `{"name": "anthropic-skills"}`) + write(t, filepath.Join(root, desktopManifest), manifest) + return root +} + +type desktopEntry struct { + SkillID, Name, Description, CreatorType string + SyncManaged, Enabled bool + UpdatedAt any +} + +func desktopEntries(t *testing.T, root string) map[string]desktopEntry { + t.Helper() + var doc struct { + LastUpdated any + Skills []desktopEntry + } + if err := json.Unmarshal([]byte(read(t, filepath.Join(root, desktopManifest))), &doc); err != nil { + t.Fatal(err) + } + out := map[string]desktopEntry{} + for _, e := range doc.Skills { + out[e.SkillID] = e + } + return out +} + +// #638: the library gives Claude Desktop's Cowork skills: a copy in every +// account's skills-plugin (no link: Cowork won't read a linked skill's +// other files) and an entry in its manifest, which Desktop lists from. +// Desktop's own skills and entries, and the user's picks of enabled, stay; +// a copy changed in Desktop isn't overwritten, and is set aside, not +// deleted, when the library stops giving it. +func TestClaudeDesktopSkills(t *testing.T) { + h := sandbox(t) + p, p3 := desktopFiles(t, h) + write(t, p, `{}`) + own := `{ + "lastUpdated": "2026-10-01T00:00:00.000Z", + "skills": [ + {"skillId": "test", "name": "test", "description": "Test", "creatorType": "user", "syncManaged": false, "updatedAt": "2026-10-01T00:00:00.000Z", "enabled": true}, + {"skillId": "frontend-design", "name": "frontend-design", "description": "Anthropic's", "creatorType": "anthropic", "syncManaged": true, "updatedAt": "2026-10-01T00:00:00.000Z", "enabled": false} + ] +} +` + a := desktopAccount(t, filepath.Dir(p3), "org1", "acct1", own) + write(t, filepath.Join(a, "skills", "test", "SKILL.md"), "---\nname: test\ndescription: Test\n---\n") + b := desktopAccount(t, filepath.Dir(p), "org2", "acct2", `{"lastUpdated": 1759300000000, "skills": []}`) + // an org folder with no account's plugin in it isn't one + os.MkdirAll(filepath.Join(filepath.Dir(p), "local-agent-mode-sessions", "skills-plugin", "org3", "empty"), 0o755) + + tg := targetByID("claude-desktop") + if tg == nil || len(tg.Desktop) != 2 || !slices.Contains(tg.Desktop, a) || !slices.Contains(tg.Desktop, b) { + t.Fatalf("claude-desktop target: %+v", tg) + } + if id, err := Takes("claude-desktop", "skills"); err != nil || id != "claude-desktop" { + t.Fatalf("Takes: %q %v", id, err) + } + // Desktop's own skills aren't ones to bring into the library + v, err := Read(nil) + if err != nil { + t.Fatal(err) + } + for _, f := range v.FoundSkills { + if slices.Contains(f.Agents, "claude-desktop") { + t.Errorf("Desktop's own skill found: %+v", f) + } + } + + src := filepath.Join(h, "src", "skills") + skill(t, filepath.Join(src, "pdf"), "pdf", "Read PDFs") + write(t, filepath.Join(src, "pdf", "ref", "notes.txt"), "beside it") + skill(t, filepath.Join(src, "frontend-design"), "frontend-design", "Mine") + skill(t, filepath.Join(src, "test"), "test", "The library's test") + r, err := InstallSkills(src, []string{"pdf", "frontend-design", "test"}, []string{"claude-desktop"}) + if err != nil { + t.Fatal(err) + } + // Desktop's own frontend-design (built in) and test (made in Desktop) + // stay theirs + var clash []string + for _, pr := range r.Problems { + if pr.Agent == "claude-desktop" { + clash = append(clash, pr.What) + } + } + slices.Sort(clash) + if !slices.Equal(clash, []string{"skill:frontend-design", "skill:test"}) { + t.Errorf("problems: %+v", r.Problems) + } + if _, err := SkillAgents("frontend-design", nil); err != nil { + t.Fatal(err) + } + ok(t)(SkillAgents("test", nil)) + for _, root := range []string{a, b} { + d := filepath.Join(root, "skills", "pdf") + fi, err := os.Lstat(d) + if err != nil || !fi.IsDir() { + t.Fatalf("%s: not a folder: %v", d, err) + } + if read(t, filepath.Join(d, "ref", "notes.txt")) != "beside it" { + t.Error("the skill's other files weren't copied") + } + e, ok := desktopEntries(t, root)["pdf"] + if !ok || e.Name != "pdf" || e.Description != "Read PDFs" || e.CreatorType != "user" || e.SyncManaged || !e.Enabled { + t.Errorf("%s: entry %+v", root, e) + } + } + if s := read(t, filepath.Join(a, "skills", "test", "SKILL.md")); !strings.Contains(s, "description: Test") { + t.Errorf("Desktop's own test was overwritten:\n%s", s) + } + es := desktopEntries(t, a) + if len(es) != 3 || es["frontend-design"].CreatorType != "anthropic" || es["frontend-design"].Enabled || es["test"].Description != "Test" { + t.Errorf("Desktop's own entries changed: %+v", es) + } + if _, ok := desktopEntries(t, b)["pdf"].UpdatedAt.(float64); !ok { + t.Errorf("a manifest of millisecond times got another kind: %+v", desktopEntries(t, b)["pdf"]) + } + // the other keys of a manifest, and its layout, stay + if s := read(t, filepath.Join(a, desktopManifest)); !strings.HasPrefix(s, "{\n \"lastUpdated\"") { + t.Errorf("manifest:\n%s", s) + } + + // switched off in Desktop: it stays off when the skill changes, and + // the copy is made again + m := filepath.Join(b, desktopManifest) + write(t, m, strings.Replace(read(t, m), `"enabled":true`, `"enabled":false,"pinned":true`, 1)) + lib := realDir(SkillPath("pdf")) + write(t, filepath.Join(lib, "SKILL.md"), "---\nname: pdf\ndescription: Read PDFs well\n---\n") + ok(t)(SkillAgents("pdf", []string{"claude-desktop"})) + for _, root := range []string{a, b} { + if s := read(t, filepath.Join(root, "skills", "pdf", "SKILL.md")); !strings.Contains(s, "Read PDFs well") { + t.Errorf("%s: the copy wasn't made again:\n%s", root, s) + } + if e := desktopEntries(t, root)["pdf"]; e.Description != "Read PDFs well" { + t.Errorf("%s: entry %+v", root, e) + } + } + if desktopEntries(t, b)["pdf"].Enabled || !strings.Contains(read(t, m), `"pinned":true`) { + t.Errorf("Desktop's own fields of the entry changed:\n%s", read(t, m)) + } + + // changed in Desktop: the library's next change doesn't go over it + write(t, filepath.Join(a, "skills", "pdf", "SKILL.md"), "---\nname: pdf\ndescription: my own\n---\n") + write(t, filepath.Join(lib, "SKILL.md"), "---\nname: pdf\ndescription: Read PDFs better\n---\n") + r, err = SkillAgents("pdf", []string{"claude-desktop"}) + if err != nil { + t.Fatal(err) + } + if !slices.ContainsFunc(r.Problems, func(pr Problem) bool { + return pr.What == "skill:pdf" && strings.Contains(pr.Error, "changed in Claude Desktop") + }) { + t.Errorf("problems: %+v", r.Problems) + } + if s := read(t, filepath.Join(a, "skills", "pdf", "SKILL.md")); !strings.Contains(s, "my own") { + t.Errorf("the changed copy was overwritten:\n%s", s) + } + if s := read(t, filepath.Join(b, "skills", "pdf", "SKILL.md")); !strings.Contains(s, "better") { + t.Errorf("the other account's copy wasn't made again:\n%s", s) + } + + // taken away: magpie's copies and entries go, the changed one into the + // backups; Desktop's own stay + ok(t)(SkillAgents("pdf", nil)) + for _, root := range []string{a, b} { + if _, err := os.Lstat(filepath.Join(root, "skills", "pdf")); !os.IsNotExist(err) { + t.Errorf("%s: the copy is still there", root) + } + if _, ok := desktopEntries(t, root)["pdf"]; ok { + t.Errorf("%s: the entry is still there", root) + } + } + kept, _ := filepath.Glob(filepath.Join(BackupDir(), "*", "*", "skills", "pdf", "SKILL.md")) + if len(kept) != 1 || !strings.Contains(read(t, kept[0]), "my own") { + t.Errorf("the changed copy wasn't kept: %v", kept) + } + if es := desktopEntries(t, a); len(es) != 2 || es["test"].Name != "test" { + t.Errorf("Desktop's own entries: %+v", es) + } + if _, err := os.Stat(filepath.Join(a, "skills", "test", "SKILL.md")); err != nil { + t.Error(err) + } + + // given again after its folder was deleted by hand, the entry left: + // magpie's own entry gets its folder back + ok(t)(SkillAgents("pdf", []string{"claude-desktop"})) + os.RemoveAll(filepath.Join(b, "skills", "pdf")) + ok(t)(Sync()) + if _, err := os.Stat(filepath.Join(b, "skills", "pdf", "SKILL.md")); err != nil { + t.Error(err) + } +} diff --git a/internal/library/skills.go b/internal/library/skills.go index b15c894a..4a790271 100644 --- a/internal/library/skills.go +++ b/internal/library/skills.go @@ -1197,7 +1197,9 @@ func foundSkills(l *Library) []FoundSkill { } places := []place{{dir: skillsDir(), lib: true}, {dir: sharedSkillsDir()}} for _, t := range Targets() { - if t.Skills != "" { + // Claude Desktop's are its own, built in or made there, kept in its + // manifest: one brought in would leave it a link it can't read from + if t.Skills != "" && t.Desktop == nil { places = append(places, place{dir: t.Skills, agent: t.Agent.ID}) } } diff --git a/internal/library/sync.go b/internal/library/sync.go index c74ef89d..c25b07ba 100644 --- a/internal/library/sync.go +++ b/internal/library/sync.go @@ -65,7 +65,12 @@ func (l *Library) sync() *Result { shared := realDir(sharedSkillsDir()) for _, first := range []bool{true, false} { for _, t := range all { - if (t.Skills != "" && realDir(t.Skills) == shared) == first { + switch { + case t.Desktop != nil: + if !first { + l.syncDesktopSkills(t, res) + } + case (t.Skills != "" && realDir(t.Skills) == shared) == first: l.syncSkills(t, res, all) } } diff --git a/internal/library/targets.go b/internal/library/targets.go index a6334908..fd7498a8 100644 --- a/internal/library/targets.go +++ b/internal/library/targets.go @@ -32,6 +32,10 @@ type Target struct { // the library's changes, rather than a link: one in a WSL distro, // which can't follow a link to a Windows folder. Copy bool + // Desktop are Claude Desktop's skills-plugin folders, one for each + // account (desktop_skills.go): each gets a copy of the skills and their + // entries in its manifest.json. Skills is the first one's skills. + Desktop []string } func home() string { h, _ := os.UserHomeDir(); return h } @@ -266,6 +270,10 @@ func targetOf(a *agent.Agent) *Target { if p := agent.DesktopConfig3p(h); p != t.MCP.Path && isDir(filepath.Dir(p)) { t.MCP.Also = []string{p} } + // Cowork's skills, in every account's skills-plugin (#638) + if t.Desktop = desktopSkillRoots(a.Dir); len(t.Desktop) > 0 { + t.Skills = filepath.Join(t.Desktop[0], "skills") + } default: return nil }