library: Claude Desktop takes skills, into Cowork's skills-plugin with their manifest entries (#638) — Desktop had MCP servers only and showed as "has no skills folder". Every account's local-agent-mode-sessions/skills-plugin/<org>/<account> (one with a manifest.json or .claude-plugin/plugin.json, in Claude and Claude-3p alike, no id hard-coded) gets a copy of each skill it is given — a real folder, as Cowork won't read a linked skill's other files from outside the plugin — and an entry in manifest.json (skillId, name, description from SKILL.md, creatorType user, syncManaged false, updatedAt, enabled), which Desktop lists from. The other entries and keys, and an entry's enabled and other fields, stay; times are written as the manifest's are (ms or ISO). A name Desktop has of its own (built in, or made there) is left and reported. The copy carries magpie's mark with the hash of what was copied: it is made again when the library's skill changes, left as it is once changed in Desktop (reported), and set aside into the backups rather than deleted when the library stops giving it; only magpie's copies and entries are ever taken out, and magpie's entry whose folder went gets it back. Desktop's own skills aren't offered to bring in. The Skills tab says Desktop shows changes once its window is reloaded (⌘R / Ctrl+R). TestClaudeDesktopSkills (two accounts, Claude and Claude-3p) and library-desktop-skills.test.cjs (WebKit, en/zh) cover it. go vet, go build -tags nogui (darwin, GOOS=linux), GOOS=windows go build and go test -tags nogui -count=1 ./... under a temp HOME pass. Not tried with a real Claude Desktop.
This commit is contained in:
1 parent
960e611a79
commit
84554e57e7
8 files changed
+621
-2
No files matched your search
@@ -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 忽略它们。",
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
}
|
||||
@@ -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:
|
||||
//
|
||||
// <Claude or Claude-3p>/local-agent-mode-sessions/skills-plugin/<org>/<account>/
|
||||
// .claude-plugin/plugin.json {"name":"anthropic-skills",…}
|
||||
// manifest.json {"lastUpdated":…,"skills":[{"skillId","name",
|
||||
// "description","creatorType":"user",
|
||||
// "syncManaged":false,"updatedAt","enabled"}]}
|
||||
// skills/<name>/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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user