library, gui: a repository's skills are one group on the Library page however each came in — the skills CLI's lock, a git clone, or installed from it again (White Immortal on Discord)
White Immortal asked for the skills of github.com/mattpocock/skills to be one group, the ones installed before too. Skills installed from GitHub in magpie were already grouped by repository, but one installed another way sat under On this computer. Each skill on the Library page now carries the repository it came from (repo). A skill installed from GitHub, or by CC Switch from there, says so as before. For the others it is traced: - npx skills add (the skills CLI) writes ~/.agents/.skill-lock.json, with each skill's repository by name. A skill in ~/.agents/skills, or moved into the library from there, gets its repository from it. - A skill linked from a git clone gets the remote "origin" of the clone it is in (up to four folders up). The URL can be https, ssh or scp-style, and a user or token in it is never kept. A checkout that is the home folder, or an app folder in it such as ~/.claude kept in git, is the user's dotfiles, and its skills are not grouped by it. - When a skill is brought in from an agent's folder, its repository is traced first and kept as From, since a copy leaves its .git behind. - Typing a repository in Install (or opening it from the market) notes it as From on the skills the library has already whose SKILL.md is that repository's very own. Installing from github.com/mattpocock/skills again then groups the ones installed before. A skill of the same name whose file differs is someone else's, and stays where it was. From and a traced repository only group. Such a skill is still not updated from GitHub. A repository on another git host (gitlab.com/…) is a group of its own, and its icon opens it at that host. A repository group with skills that only were traced there goes by name, not by a folder in it. TestSkillRepoTraced, TestRemoteRepo and TestInstallAgainNotesFrom are new, along with skill-repo-groups.test.cjs (Chromium and WebKit, English and Chinese). The GUI test fails against the old library.js. go vet, go build (GOOS=linux and windows, nogui) and go test -tags nogui ./... under a temp HOME pass. TestProvidersAnswerWhileListsComeIn and TestUserPath failed once under load and pass on their own. The library's GUI tests pass on WebKit. On Chromium, import-all-skills, library-own-folder and shared-skills fail the same way at HEAD without this change.
This commit is contained in:
1 parent
b2a3449021
commit
6d198f7f23
7 files changed
+490
-8
No files matched your search
@@ -3203,6 +3203,7 @@ const I18N = {
|
||||
"{repo}'s skills are on for {agents}": "已为 {agents} 启用 {repo} 的全部技能",
|
||||
"{repo}'s skills are off for {agents}": "已从 {agents} 中关闭 {repo} 的全部技能",
|
||||
"Open {repo} on GitHub": "在 GitHub 上打开 {repo}",
|
||||
"Open {repo}": "打开 {repo}",
|
||||
"Show its skills": "展开技能",
|
||||
"1 skill has an update": "1 个技能有更新",
|
||||
"{n} skills have updates": "{n} 个技能有更新",
|
||||
@@ -6570,6 +6571,7 @@ const I18N = {
|
||||
"{repo}'s skills are on for {agents}": "{agents} で {repo} のスキルをすべてオンにしました",
|
||||
"{repo}'s skills are off for {agents}": "{agents} で {repo} のスキルをすべてオフにしました",
|
||||
"Open {repo} on GitHub": "GitHub で {repo} を開く",
|
||||
"Open {repo}": "{repo} を開く",
|
||||
"Show its skills": "スキルを表示",
|
||||
"1 skill has an update": "1 個のスキルにアップデートがあります",
|
||||
"{n} skills have updates": "{n} 個のスキルにアップデートがあります",
|
||||
@@ -9945,6 +9947,7 @@ const I18N = {
|
||||
"{repo}'s skills are on for {agents}": "Die Skills von {repo} sind für {agents} an",
|
||||
"{repo}'s skills are off for {agents}": "Die Skills von {repo} sind für {agents} aus",
|
||||
"Open {repo} on GitHub": "{repo} auf GitHub öffnen",
|
||||
"Open {repo}": "{repo} öffnen",
|
||||
"Show its skills": "Skills einblenden",
|
||||
"1 skill has an update": "1 Skill hat ein Update",
|
||||
"{n} skills have updates": "{n} Skills haben Updates",
|
||||
|
||||
@@ -1810,6 +1810,12 @@
|
||||
try { folds = JSON.parse(localStorage.getItem("magpie.libSkillFolds") || "{}") || {}; } catch {}
|
||||
function saveFolds() { try { localStorage.setItem("magpie.libSkillFolds", JSON.stringify(folds)); } catch {} }
|
||||
const repoOf = (u) => (u || "").replace(/^https:\/\/github\.com\//, "").split("/").slice(0, 2).join("/");
|
||||
// the repository a skill came from, however it came in: magpie traces
|
||||
// one not installed from GitHub too (the skills CLI's lock, the git
|
||||
// checkout it's in), so a repository's skills are one group (White
|
||||
// Immortal on Discord). owner/repo on GitHub, host/path elsewhere.
|
||||
const skillRepo = (s) => s.repo || (s.kind === "github" ? repoOf(s.source) : s.origin ? repoOf(s.origin) : "");
|
||||
const repoURL = (r) => (/^[^/]+\.[^/]+\//.test(r) ? "https://" + r : "https://github.com/" + r);
|
||||
const MANY = 40; // a group bigger than this starts folded, and so do its parts
|
||||
const SPLIT = 8; // a group bigger than this is split by folder, or by name
|
||||
// Heights the rows are held to (library.css): a list's room is known
|
||||
@@ -1827,9 +1833,8 @@
|
||||
if (grouped?.lib === lib && grouped.pick === pick) return grouped.groups;
|
||||
if (pick !== "source") {
|
||||
const skills = [...lib.skills].sort(byName(pick));
|
||||
const repo = (s) => (s.kind === "github" ? repoOf(s.source) : s.origin ? repoOf(s.origin) : "");
|
||||
const g = { key: "flat", repo: "", skills, parts: null,
|
||||
text: new Map(skills.map((s) => [s, (s.name + " " + (s.description || "") + " " + repo(s)).toLowerCase()])) };
|
||||
text: new Map(skills.map((s) => [s, (s.name + " " + (s.description || "") + " " + skillRepo(s)).toLowerCase()])) };
|
||||
grouped = { lib, pick, groups: [g] };
|
||||
return grouped.groups;
|
||||
}
|
||||
@@ -1842,7 +1847,7 @@
|
||||
for (const s of lib.skills) {
|
||||
const own = inMine.get(s.name);
|
||||
if (own) { own.skills.push(s); continue; }
|
||||
const repo = s.kind === "github" ? repoOf(s.source) : s.origin ? repoOf(s.origin) : "";
|
||||
const repo = skillRepo(s);
|
||||
const key = repo ? "gh:" + repo.toLowerCase() : "local";
|
||||
let g = by.get(key);
|
||||
if (!g) by.set(key, (g = { key, repo, skills: [] }));
|
||||
@@ -1900,7 +1905,9 @@
|
||||
const label = (key, mono) => key || (mono ? (g.repo ? t("At the top of the repository") : t("Kept in the library")) : t("Others"));
|
||||
const order = (a, b) => (!a.key) - (!b.key) || a.mono - b.mono || a.key.localeCompare(b.key);
|
||||
if (g.repo) {
|
||||
let m = split(g.skills, folderIn), mono = true;
|
||||
// the folder in the repository is known of those installed from it
|
||||
// only: one traced to it goes by its name with the rest
|
||||
let m = g.skills.every((s) => s.kind === "github") ? split(g.skills, folderIn) : null, mono = true;
|
||||
if (!m) { mono = false; m = byStart(); }
|
||||
if (!m) return null;
|
||||
return [...m].map(([key, skills]) => ({ key, skills, mono, label: label(key, mono) })).sort(order);
|
||||
@@ -2056,9 +2063,9 @@
|
||||
tags.append(rn, un);
|
||||
}
|
||||
if (g.repo) {
|
||||
const o = button("", "lib-icon", () => browse("https://github.com/" + g.repo));
|
||||
const o = button("", "lib-icon", () => browse(repoURL(g.repo)));
|
||||
o.append(svg(GLYPH.out, 13, 1.4));
|
||||
o.title = t("Open {repo} on GitHub", { repo: g.repo });
|
||||
o.title = repoURL(g.repo).startsWith("https://github.com/") ? t("Open {repo} on GitHub", { repo: g.repo }) : t("Open {repo}", { repo: g.repo });
|
||||
acts.append(o);
|
||||
}
|
||||
const chev = el("span", "chev");
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// Run with Node's test runner and Playwright on the module path; see README.md.
|
||||
// A repository's skills are one group however each came in (White Immortal
|
||||
// on Discord: the skills of github.com/mattpocock/skills together). One
|
||||
// installed from GitHub, one the skills CLI installed and one linked from a
|
||||
// clone, each with the repository magpie traced (repo), are one group, its
|
||||
// name's case aside; one from a git host other than GitHub is a group
|
||||
// opened at that host; the rest are On this computer. English and Chinese;
|
||||
// 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/me";
|
||||
const agent = (id, name, icon) => ({ id, name, icon, skills: `${HOME}/.${id}/skills`, mcp: `${HOME}/.${id}/mcp.json` });
|
||||
|
||||
function server(lang, state) {
|
||||
return async (route) => {
|
||||
const req = route.request();
|
||||
const url = new URL(req.url());
|
||||
const json = (data) => route.fulfill({ json: data });
|
||||
if (url.pathname === "/boot.js") return route.fulfill({ contentType: "text/javascript", body: `window.bootPrefs = {lang:"${lang}",theme:"light",web:true};` });
|
||||
if (url.pathname === "/wails/runtime.js") return route.fulfill({ contentType: "text/javascript", body: "export const Window = {};" });
|
||||
if (url.pathname === "/api/state") return json({ agents: [], profiles: [], settings: { lang, theme: "light" } });
|
||||
if (url.pathname === "/api/library") return json(state.lib);
|
||||
if (url.pathname === "/api/providers") return json({ providers: [], presets: [], excluded: [], gateway: { running: true, window: true }, plugins: [] });
|
||||
if (url.pathname === "/api/groups") return json({ groups: [], pools: [] });
|
||||
if (url.pathname === "/api/usage/quotas") return json([]);
|
||||
if (url.pathname.startsWith("/api/")) return json({});
|
||||
if (url.host !== "magpie.test") return route.fulfill({ status: 404, body: "" });
|
||||
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
|
||||
const contentType = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png" }[path.extname(file)];
|
||||
try { await route.fulfill({ body: await fs.readFile(file), contentType }); } catch { await route.fulfill({ status: 404, body: "" }); }
|
||||
};
|
||||
}
|
||||
|
||||
const SKILLS = [
|
||||
{ name: "tdd", kind: "github", source: "https://github.com/mattpocock/skills/tree/main/skills/tdd", repo: "mattpocock/skills", description: "d", agents: ["claude"] },
|
||||
{ name: "grill-me", kind: "folder", source: `${HOME}/.agents/skills/grill-me`, repo: "mattpocock/skills", description: "d", agents: ["claude"] },
|
||||
{ name: "write-a-prd", kind: "folder", source: `${HOME}/code/skills/skills/write-a-prd`, repo: "MattPocock/skills", description: "d", agents: [] },
|
||||
{ name: "review", kind: "", source: "", repo: "gitlab.com/me/review", description: "d", agents: ["claude"] },
|
||||
{ name: "notes", kind: "", source: "", description: "d", agents: ["claude"] },
|
||||
];
|
||||
const L = { en: { here: "On this computer", open: "Open gitlab.com/me/review", gh: "Open mattpocock/skills on GitHub" },
|
||||
zh: { here: "本机", open: "打开 gitlab.com/me/review", gh: "在 GitHub 上打开 mattpocock/skills" } };
|
||||
|
||||
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
|
||||
test(engine + ": a repository's skills are one group however each came in", async (t) => {
|
||||
assert(["chromium", "webkit"].includes(engine), "BROWSER must be chromium or webkit");
|
||||
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL || "chromium" }));
|
||||
t.after(() => browser.close());
|
||||
for (const lang of ["en", "zh"]) {
|
||||
await t.test(lang, async () => {
|
||||
const w = L[lang];
|
||||
const state = {
|
||||
lib: {
|
||||
dir: `${HOME}/.magpie/library`, backups: `${HOME}/.magpie/backups`, home: HOME,
|
||||
agents: [agent("claude", "Claude Code", "claudecode-color"), agent("codex", "Codex", "openai")],
|
||||
instructions: { agents: [], sets: [] }, servers: [], foundServers: [], projects: [], foundSkills: [], problems: [],
|
||||
skills: structuredClone(SKILLS),
|
||||
},
|
||||
};
|
||||
const ctx = await browser.newContext({ viewport: { width: 1100, height: 760 }, reducedMotion: "reduce" });
|
||||
await ctx.addInitScript(() => {
|
||||
try { localStorage.setItem("magpie.libTab", "skills"); } catch {}
|
||||
window.__opened = [];
|
||||
window.open = (u) => { window.__opened.push(u); return null; };
|
||||
});
|
||||
const page = await ctx.newPage();
|
||||
page.setDefaultTimeout(5000);
|
||||
const errors = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await page.route("**/*", server(lang, state));
|
||||
await page.goto("http://magpie.test/");
|
||||
await page.locator('button[data-view="library"]').click();
|
||||
const v = page.locator("#view-library");
|
||||
await v.locator(".lib-grouphead").first().waitFor();
|
||||
const heads = await v.locator(".lib-grouphead").evaluateAll((hs) => hs.map((h) => [h.querySelector(".name").textContent, h.querySelector(".sub").textContent.replace(/\D+/g, "")]));
|
||||
// the first spelling met names the group
|
||||
assert.deepEqual(heads, [["gitlab.com/me/review", "1"], ["mattpocock/skills", "3"], [w.here, "1"]]);
|
||||
const card = (name) => v.locator(".lib-group").filter({ has: page.locator(".lib-grouphead .name", { hasText: name }) });
|
||||
assert.equal(await card("mattpocock/skills").locator(".lib-grouphead .lib-icon").getAttribute("title"), w.gh);
|
||||
const gl = card("gitlab.com/me/review").locator(".lib-grouphead .lib-icon");
|
||||
assert.equal(await gl.getAttribute("title"), w.open);
|
||||
// the page in a browser opens it in a tab of its own
|
||||
await gl.click();
|
||||
assert.deepEqual(await page.evaluate(() => window.__opened), ["https://gitlab.com/me/review"]);
|
||||
// its three skills under it, none in a part of a folder
|
||||
const names = await card("mattpocock/skills").locator(".lib-row:not(.lib-grouphead) .name").allTextContents();
|
||||
assert.deepEqual(names.map((n) => n.trim()).filter((n) => SKILLS.some((s) => s.name === n)).sort(), ["grill-me", "tdd", "write-a-prd"]);
|
||||
assert.equal(await card("mattpocock/skills").locator(".lib-subhead").count(), 0);
|
||||
assert.deepEqual(errors, []);
|
||||
await ctx.close();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Where a skill came from, for the page to group it with the others from
|
||||
// the same repository (White Immortal on Discord: the skills of
|
||||
// github.com/mattpocock/skills together, however each came in). One
|
||||
// installed from GitHub says so itself; for the others it is traced:
|
||||
// CC Switch's record, the skills CLI's lock (npx skills add, in
|
||||
// ~/.agents/.skill-lock.json), the git checkout a linked folder is in,
|
||||
// or the repository the user installed from since that has the very same
|
||||
// skill (From). It only groups: such a skill is not updated from there.
|
||||
|
||||
// repoOf is the repository a library skill came from: owner/repo on
|
||||
// GitHub, host/path elsewhere, "" when it can't be told.
|
||||
func (tr *tracer) repoOf(s *Skill) string {
|
||||
if s.Source != nil && s.Source.Kind == "github" {
|
||||
return s.Source.Repo
|
||||
}
|
||||
if o, ok := ccSwitchOrigin(s); ok {
|
||||
return o.Repo
|
||||
}
|
||||
if s.From != "" {
|
||||
return s.From
|
||||
}
|
||||
dir := skillDir(s.Name)
|
||||
if !linked(dir) {
|
||||
// the library's own copy: the skills CLI's, moved in, by its name
|
||||
if e, ok := tr.lock()[s.Name]; ok && (s.Source == nil || within(realDir(s.Source.Dir), realDir(sharedSkillsDir()))) {
|
||||
return e
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return tr.folder(realDir(dir))
|
||||
}
|
||||
|
||||
// folder is the repository a skill's folder elsewhere came from: the
|
||||
// skills CLI's lock for one in ~/.agents/skills, else its git checkout.
|
||||
func (tr *tracer) folder(dir string) string {
|
||||
if within(dir, realDir(sharedSkillsDir())) {
|
||||
if e, ok := tr.lock()[filepath.Base(dir)]; ok {
|
||||
return e
|
||||
}
|
||||
}
|
||||
return gitRepoOf(dir)
|
||||
}
|
||||
|
||||
// tracer reads what tracing needs once for a whole page.
|
||||
type tracer struct {
|
||||
locked map[string]string
|
||||
read bool
|
||||
}
|
||||
|
||||
// lock is the skills CLI's lock: each skill's repository by its name.
|
||||
func (tr *tracer) lock() map[string]string {
|
||||
if !tr.read {
|
||||
tr.read, tr.locked = true, readSkillLock(filepath.Join(home(), ".agents", ".skill-lock.json"))
|
||||
}
|
||||
return tr.locked
|
||||
}
|
||||
|
||||
func readSkillLock(path string) map[string]string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var f struct {
|
||||
Skills map[string]struct {
|
||||
Source string `json:"source"`
|
||||
SourceType string `json:"sourceType"`
|
||||
SourceURL string `json:"sourceUrl"`
|
||||
} `json:"skills"`
|
||||
}
|
||||
if json.Unmarshal(b, &f) != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
for name, e := range f.Skills {
|
||||
r := ""
|
||||
if e.SourceType == "github" && repoRe.MatchString(e.Source) {
|
||||
r = strings.TrimSuffix(e.Source, ".git")
|
||||
} else if e.SourceURL != "" {
|
||||
r = remoteRepo(e.SourceURL)
|
||||
}
|
||||
if r != "" {
|
||||
out[name] = r
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// gitRepoOf is the repository of the git checkout a skill's folder is, or
|
||||
// is in a few folders down: its remote origin. A checkout that is the home
|
||||
// folder, or a folder of an app's own in it (~/.claude kept in git), is
|
||||
// the user's dotfiles rather than where the skill came from.
|
||||
func gitRepoOf(dir string) string {
|
||||
h := realDir(home())
|
||||
for i := 0; i < 4 && dir != "" && dir != h; i++ {
|
||||
if p := filepath.Dir(dir); p == h && strings.HasPrefix(filepath.Base(dir), ".") {
|
||||
return ""
|
||||
}
|
||||
if fi, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
|
||||
cfg := filepath.Join(dir, ".git", "config")
|
||||
if !fi.IsDir() {
|
||||
cfg = gitdirConfig(dir)
|
||||
}
|
||||
return originURL(cfg)
|
||||
}
|
||||
next := filepath.Dir(dir)
|
||||
if next == dir {
|
||||
break
|
||||
}
|
||||
dir = next
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// gitdirConfig is the config of a checkout whose .git is a file (a
|
||||
// worktree or a submodule): the folder it names, or its common one.
|
||||
func gitdirConfig(dir string) string {
|
||||
b, err := os.ReadFile(filepath.Join(dir, ".git"))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
g, ok := strings.CutPrefix(strings.TrimSpace(string(b)), "gitdir:")
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
g = strings.TrimSpace(g)
|
||||
if !filepath.IsAbs(g) {
|
||||
g = filepath.Join(dir, g)
|
||||
}
|
||||
if c, err := os.ReadFile(filepath.Join(g, "commondir")); err == nil {
|
||||
cd := strings.TrimSpace(string(c))
|
||||
if !filepath.IsAbs(cd) {
|
||||
cd = filepath.Join(g, cd)
|
||||
}
|
||||
g = cd
|
||||
}
|
||||
return filepath.Join(g, "config")
|
||||
}
|
||||
|
||||
var originSection = regexp.MustCompile(`^\[\s*remote\s+"origin"\s*\]$`)
|
||||
|
||||
// originURL is the repository remote "origin" of a git config fetches.
|
||||
func originURL(cfg string) string {
|
||||
b, err := os.ReadFile(cfg)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
in := false
|
||||
for _, line := range bytes.Split(b, []byte("\n")) {
|
||||
l := strings.TrimSpace(string(line))
|
||||
if strings.HasPrefix(l, "[") {
|
||||
in = originSection.MatchString(l)
|
||||
continue
|
||||
}
|
||||
if k, v, ok := strings.Cut(l, "="); in && ok && strings.TrimSpace(k) == "url" {
|
||||
return remoteRepo(strings.TrimSpace(v))
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
var scpRemote = regexp.MustCompile(`^(?:[\w.-]+@)?([\w.-]+\.[\w.-]+):(.+)$`)
|
||||
|
||||
// remoteRepo reads a git remote's address as a repository: owner/repo on
|
||||
// GitHub, host/path elsewhere. A user or a token in it is never kept.
|
||||
func remoteRepo(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
host, p := "", ""
|
||||
if u, err := url.Parse(s); err == nil && u.Scheme != "" && u.Host != "" {
|
||||
host, p = u.Hostname(), u.Path
|
||||
} else if m := scpRemote.FindStringSubmatch(s); m != nil {
|
||||
host, p = m[1], m[2]
|
||||
} else if repoRe.MatchString(s) {
|
||||
host, p = "github.com", s
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
host = strings.TrimPrefix(strings.ToLower(host), "www.")
|
||||
p = strings.TrimSuffix(strings.Trim(p, "/"), ".git")
|
||||
if strings.Count(p, "/") < 1 || strings.Contains(p, "..") {
|
||||
return ""
|
||||
}
|
||||
if host == "github.com" {
|
||||
parts := strings.Split(p, "/")
|
||||
return parts[0] + "/" + parts[1]
|
||||
}
|
||||
return host + "/" + p
|
||||
}
|
||||
|
||||
// noteFrom writes down, for the skills the library has already that a
|
||||
// repository being installed from has too, that they came from there,
|
||||
// when the library's SKILL.md is that repository's very own: installing
|
||||
// from it again groups them with the rest of it. root is where the
|
||||
// repository was fetched to.
|
||||
func noteFrom(src Source, root string, cands []Candidate) {
|
||||
if src.Kind != "github" {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
l, err := load()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
changed := false
|
||||
for _, c := range cands {
|
||||
s := l.skill(c.Name)
|
||||
if s == nil || (s.Source != nil && s.Source.Kind == "github") || s.From == src.Repo {
|
||||
continue
|
||||
}
|
||||
mine, err1 := os.ReadFile(filepath.Join(skillDir(s.Name), "SKILL.md"))
|
||||
theirs, err2 := os.ReadFile(filepath.Join(root, filepath.FromSlash(c.Path), "SKILL.md"))
|
||||
if err1 != nil || err2 != nil || !bytes.Equal(bytes.TrimSpace(mine), bytes.TrimSpace(theirs)) {
|
||||
continue
|
||||
}
|
||||
s.From, changed = src.Repo, true
|
||||
}
|
||||
if changed {
|
||||
l.save()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package library
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func repoIn(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
v, err := Read(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, s := range v.Skills {
|
||||
out[s.Name] = s.Repo
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A skill not installed from GitHub is grouped with the repository it
|
||||
// came from all the same (White Immortal on Discord): the skills CLI's
|
||||
// lock names it, or the git checkout it's in, whatever the remote's form,
|
||||
// and never with a user or token the remote had. A skill in ~/.claude kept
|
||||
// in git (the user's dotfiles) is not from that repository.
|
||||
func TestSkillRepoTraced(t *testing.T) {
|
||||
h := sandbox(t)
|
||||
// npx skills add mattpocock/skills: in ~/.agents/skills, in its lock
|
||||
skill(t, filepath.Join(h, ".agents/skills/grill-me"), "grill-me", "Grill")
|
||||
write(t, filepath.Join(h, ".agents/.skill-lock.json"), `{"version":3,"skills":{
|
||||
"grill-me":{"source":"mattpocock/skills","sourceType":"github","sourceUrl":"https://github.com/mattpocock/skills.git","skillPath":"skills/grill-me/SKILL.md"},
|
||||
"tdd":{"source":"x","sourceType":"git","sourceUrl":"git@gitlab.com:team/skills.git"}}}`)
|
||||
os.MkdirAll(filepath.Join(h, ".claude/skills"), 0o755)
|
||||
os.Symlink(filepath.Join(h, ".agents/skills/grill-me"), filepath.Join(h, ".claude/skills/grill-me"))
|
||||
// a clone of the repository, linked from the library
|
||||
clone := filepath.Join(h, "code/skills")
|
||||
skill(t, filepath.Join(clone, "skills/write-a-prd"), "write-a-prd", "PRD")
|
||||
write(t, filepath.Join(clone, ".git/config"), "[core]\n\tbare = false\n[remote \"upstream\"]\n\turl = https://github.com/other/fork.git\n[remote \"origin\"]\r\n\turl = https://me:ghp_secret@github.com/mattpocock/skills.git\r\n")
|
||||
// a skill that is a clone of its own, moved in
|
||||
skill(t, filepath.Join(h, ".claude/skills/review"), "review", "Review")
|
||||
write(t, filepath.Join(h, ".claude/skills/review/.git/config"), "[remote \"origin\"]\n\turl = git@gitlab.com:me/review.git\n")
|
||||
// ~/.claude in git: the user's dotfiles
|
||||
write(t, filepath.Join(h, ".claude/.git/config"), "[remote \"origin\"]\n\turl = git@github.com:me/dotfiles.git\n")
|
||||
skill(t, filepath.Join(h, ".claude/skills/notes"), "notes", "Mine")
|
||||
|
||||
ok(t)(ImportSkills([]string{"grill-me", "review", "notes"}))
|
||||
p, err := ProbeSkills(filepath.Join(clone, "skills/write-a-prd"))
|
||||
if err != nil || len(p.Candidates) != 1 {
|
||||
t.Fatalf("probe: %+v %v", p, err)
|
||||
}
|
||||
ok(t)(InstallSkills(filepath.Join(clone, "skills/write-a-prd"), []string{p.Candidates[0].Path}, nil))
|
||||
|
||||
got := repoIn(t)
|
||||
want := map[string]string{"grill-me": "mattpocock/skills", "write-a-prd": "mattpocock/skills", "review": "gitlab.com/me/review", "notes": ""}
|
||||
for n, r := range want {
|
||||
if got[n] != r {
|
||||
t.Errorf("%s: repo %q, want %q", n, got[n], r)
|
||||
}
|
||||
}
|
||||
b, _ := os.ReadFile(path())
|
||||
if strings.Contains(string(b), "ghp_secret") {
|
||||
t.Error("the remote's token was written down")
|
||||
}
|
||||
// the clone moved in kept where it came from
|
||||
if s := mustLoad(t).skill("review"); s.From != "gitlab.com/me/review" {
|
||||
t.Errorf("review's From: %q", s.From)
|
||||
}
|
||||
}
|
||||
|
||||
// The remote forms git takes, each read as the repository.
|
||||
func TestRemoteRepo(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"https://github.com/mattpocock/skills": "mattpocock/skills",
|
||||
"https://www.github.com/mattpocock/skills.git/": "mattpocock/skills",
|
||||
"git@github.com:mattpocock/skills.git": "mattpocock/skills",
|
||||
"ssh://git@github.com/mattpocock/skills.git": "mattpocock/skills",
|
||||
"https://user:tok@github.com/a/b/tree/main/x": "a/b",
|
||||
"git@gitlab.com:group/sub/proj.git": "gitlab.com/group/sub/proj",
|
||||
"https://tok@codeberg.org/me/s.git": "codeberg.org/me/s",
|
||||
"mattpocock/skills": "mattpocock/skills",
|
||||
"/Users/me/skills": "",
|
||||
"https://github.com/onlyowner": "",
|
||||
"https://example.com/../etc": "",
|
||||
} {
|
||||
if got := remoteRepo(in); got != want {
|
||||
t.Errorf("%s: %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Installing from a repository again groups the skills the library has
|
||||
// already from it (White Immortal: installed before, from that very
|
||||
// address): those whose SKILL.md is the repository's own. One of the same
|
||||
// name that differs is someone else's skill, and stays where it was.
|
||||
func TestInstallAgainNotesFrom(t *testing.T) {
|
||||
h := sandbox(t)
|
||||
newFakeGitHub(t)
|
||||
// the fake repository's skills/pdf/SKILL.md, as installed by hand
|
||||
write(t, filepath.Join(h, ".claude/skills/pdf/SKILL.md"), "---\nname: pdf\ndescription: PDFs\n---\n")
|
||||
ok(t)(ImportSkill("pdf"))
|
||||
if r := repoIn(t)["pdf"]; r != "" {
|
||||
t.Fatalf("pdf has a repo before: %q", r)
|
||||
}
|
||||
if _, err := ProbeSkills("someone/fromagain"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := repoIn(t)["pdf"]; r != "someone/fromagain" {
|
||||
t.Errorf("pdf after installing from its repository again: %q", r)
|
||||
}
|
||||
|
||||
// another's pdf, not the same file: not noted
|
||||
h = sandbox(t)
|
||||
write(t, filepath.Join(h, ".claude/skills/pdf/SKILL.md"), "---\nname: pdf\ndescription: My own\n---\n")
|
||||
ok(t)(ImportSkill("pdf"))
|
||||
if _, err := ProbeSkills("someone/notmine"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := repoIn(t)["pdf"]; r != "" {
|
||||
t.Errorf("another's pdf grouped with the repository: %q", r)
|
||||
}
|
||||
}
|
||||
|
||||
func mustLoad(t *testing.T) *Library {
|
||||
t.Helper()
|
||||
l, err := load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return l
|
||||
}
|
||||
@@ -37,6 +37,9 @@ type Skill struct {
|
||||
// changed the skill since.
|
||||
Hash string `json:"hash,omitempty"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
// From is the repository one not installed from GitHub was found to
|
||||
// come from (skill_from.go), only to group it with the rest of it
|
||||
From string `json:"from,omitempty"`
|
||||
}
|
||||
|
||||
// Source is where a skill came from: a GitHub repository it can be updated
|
||||
@@ -772,6 +775,7 @@ func ProbeSkills(input string) (*Probe, error) {
|
||||
p.Candidates[i].Have = l.skill(c.Name) != nil
|
||||
}
|
||||
}
|
||||
noteFrom(p.src, p.root, p.Candidates)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
@@ -1425,6 +1429,9 @@ func importSkill(l *Library, found []FoundSkill, name string) error {
|
||||
if err := os.MkdirAll(skillsDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
// where it came from is told before it is moved: a copy leaves its
|
||||
// .git behind, and the skills CLI's lock names it where it was
|
||||
from := (&tracer{}).folder(f.real)
|
||||
src := &Source{Kind: "folder", Dir: f.real}
|
||||
// one in the shared ~/.agents/skills (or a link into it) stays
|
||||
// there, linked to: the shared folder is the user's, never emptied
|
||||
@@ -1464,7 +1471,7 @@ func importSkill(l *Library, found []FoundSkill, name string) error {
|
||||
// place (its copy kept aside), as the agents with the folder itself do
|
||||
agents := slices.Concat(f.Agents, f.Copies)
|
||||
slices.Sort(agents)
|
||||
l.Skills = append(l.Skills, &Skill{Name: name, Source: src, Agents: slices.Compact(agents)})
|
||||
l.Skills = append(l.Skills, &Skill{Name: name, Source: src, Agents: slices.Compact(agents), From: from})
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -200,6 +200,7 @@ type SkillView struct {
|
||||
Source string `json:"source,omitempty"`
|
||||
Kind string `json:"kind"` // github, folder, or "" for one kept in the library
|
||||
Origin string `json:"origin,omitempty"` // on GitHub, as CC Switch installed it: it can be updated from there
|
||||
Repo string `json:"repo,omitempty"` // the repository it came from, however it came in, for the page to group by
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Agents []string `json:"agents"`
|
||||
Missing bool `json:"missing,omitempty"` // its folder is gone
|
||||
@@ -298,8 +299,9 @@ func Read(problems []Problem) (*View, error) {
|
||||
}
|
||||
v.Servers = append(v.Servers, sv)
|
||||
}
|
||||
tr := &tracer{}
|
||||
for _, s := range l.Skills {
|
||||
sv := SkillView{Name: s.Name, Agents: append([]string{}, s.Agents...), Icon: skillIcon(s), Problems: of("skill:" + s.Name)}
|
||||
sv := SkillView{Name: s.Name, Agents: append([]string{}, s.Agents...), Icon: skillIcon(s), Problems: of("skill:" + s.Name), Repo: tr.repoOf(s)}
|
||||
if s.Source != nil {
|
||||
sv.Source, sv.Kind = s.Source.String(), s.Source.Kind
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user