library: the skills market offers magpie's own magpie-quota skill first, marked official, so an agent can read what is left of every subscription, plan and key (magpie quota --json) and wait for allowance (magpie quota wait); it lives in magpie-community/plugins, and its card shows no installs or skills.sh link until skills.sh lists it (ttmouse on X: magpie quota works, suggested an official skill to go with it)

Verified: TestFeaturedSkills; market-featured-skill.test.cjs, Chromium and
WebKit, en/zh/ja/de at 520px, 8 pass (4 fail on the old library.js);
market-have and skill-online-local pass (34); a livemarket-tagged run of
InstallMarketSkill in a sandbox HOME fetched the real skill from GitHub
into the library; npx skills add magpie-community/plugins -l lists it.
go test ./...: all pass but TestSetAnswersWhileDevinAndProvidersHang
(2.8s under two other -count=5 suites); alone -count=5 passes.
This commit is contained in:
yetone committed 2026-10-08 04:25:16 +08:00
1 parent bbf9914307
commit bc7c62dc11
5 files changed
+164 -8

No files matched your search

+1
View File
@@ -18,6 +18,7 @@ agents RTK, a CLI that cuts shell output down before the model reads it.
| Instructions | magpie's part of an agent's instructions file sits between two marker lines. The rest of the file is the user's. Several named sets can be switched between (#106). | [`instructions.go`](../../internal/library/instructions.go) | | Instructions | magpie's part of an agent's instructions file sits between two marker lines. The rest of the file is the user's. Several named sets can be switched between (#106). | [`instructions.go`](../../internal/library/instructions.go) |
| MCP servers | A `Server` is a command or a URL (`http`, `sse`). `mcpFile` encodes it in each agent's own format (one `mcpFormat` per agent family) and merges it with the user's own fields for that entry. `Also` files get the same servers; `Extra` files are only read when importing. `CheckServers` connects as an agent would and keeps the result in memory only. A server that needs OAuth is signed in once in magpie, and agents reach it through the gateway's `/mcp/<name>` (#615). | [`mcp.go`](../../internal/library/mcp.go), [`mcp_check.go`](../../internal/library/mcp_check.go), [`mcp_signin.go`](../../internal/library/mcp_signin.go) | | MCP servers | A `Server` is a command or a URL (`http`, `sse`). `mcpFile` encodes it in each agent's own format (one `mcpFormat` per agent family) and merges it with the user's own fields for that entry. `Also` files get the same servers; `Extra` files are only read when importing. `CheckServers` connects as an agent would and keeps the result in memory only. A server that needs OAuth is signed in once in magpie, and agents reach it through the gateway's `/mcp/<name>` (#615). | [`mcp.go`](../../internal/library/mcp.go), [`mcp_check.go`](../../internal/library/mcp_check.go), [`mcp_signin.go`](../../internal/library/mcp_signin.go) |
| Skills | A skill is a folder with a `SKILL.md`. It comes from GitHub, from a folder on this machine (linked, so editing the folder edits the skill) or from the market. Each agent gets a link or a marked copy (#896, `SkillHow`). An agent in a WSL distro always gets a copy. A copy's mark keeps the hash of what was copied, so a sync knows a copy unchanged since then is current without reading its files (`copyIsFresh`, #1031); other copies are hashed. | [`skills.go`](../../internal/library/skills.go), [`skill_how.go`](../../internal/library/skill_how.go), [`skill_edits.go`](../../internal/library/skill_edits.go), [`skill_from.go`](../../internal/library/skill_from.go) | | Skills | A skill is a folder with a `SKILL.md`. It comes from GitHub, from a folder on this machine (linked, so editing the folder edits the skill) or from the market. Each agent gets a link or a marked copy (#896, `SkillHow`). An agent in a WSL distro always gets a copy. A copy's mark keeps the hash of what was copied, so a sync knows a copy unchanged since then is current without reading its files (`copyIsFresh`, #1031); other copies are hashed. | [`skills.go`](../../internal/library/skills.go), [`skill_how.go`](../../internal/library/skill_how.go), [`skill_edits.go`](../../internal/library/skill_edits.go), [`skill_from.go`](../../internal/library/skill_from.go) |
| Skills market | `MarketSkills` lists skills.sh's most installed and what its search finds. magpie's own skills (`featuredSkills`) go first, marked official: `magpie-quota`, in [magpie-community/plugins](https://github.com/magpie-community/plugins/tree/main/skills/magpie-quota), lets an agent read `magpie quota --json` and wait with `magpie quota wait`. Until skills.sh lists one it has no count of installs and no skills.sh link (`featured`). `InstallMarketSkill` installs any of them from its repository. | [`market.go`](../../internal/library/market.go) |
| Skills a repository added | A check for updates (`CheckSkills`) also asks, for each GitHub repository and ref the library has skills from, GitHub's list of its files (`repoTree`, one API request, free when the ETag still matches). Only when that list has a skill folder beside the installed ones (under `newBase`, their deepest common folder) that the library doesn't have and hasn't offered does `findNew` fetch the repository for the skills' names and descriptions. `SeenSkills` records per `repo@ref` the paths the install picker showed and the ones set aside, so they aren't offered again. The finds live in memory (`newFound`) and reach the page as `View.NewSkills`; `AddNewSkills` gives each to the agents that have its repository's other skills, and `IgnoreNewSkills` sets it aside (`/api/library/skills/{add-new,ignore-new}`). | [`skillnew.go`](../../internal/library/skillnew.go), [`skillcheck.go`](../../internal/library/skillcheck.go) | | Skills a repository added | A check for updates (`CheckSkills`) also asks, for each GitHub repository and ref the library has skills from, GitHub's list of its files (`repoTree`, one API request, free when the ETag still matches). Only when that list has a skill folder beside the installed ones (under `newBase`, their deepest common folder) that the library doesn't have and hasn't offered does `findNew` fetch the repository for the skills' names and descriptions. `SeenSkills` records per `repo@ref` the paths the install picker showed and the ones set aside, so they aren't offered again. The finds live in memory (`newFound`) and reach the page as `View.NewSkills`; `AddNewSkills` gives each to the agents that have its repository's other skills, and `IgnoreNewSkills` sets it aside (`/api/library/skills/{add-new,ignore-new}`). | [`skillnew.go`](../../internal/library/skillnew.go), [`skillcheck.go`](../../internal/library/skillcheck.go) |
| Backups | Before a change first writes an agent file, `backups` copies it to `BackupDir`, one folder per change. The last `keepBackups` (30) changes are kept. | [`backup.go`](../../internal/library/backup.go) | | Backups | Before a change first writes an agent file, `backups` copies it to `BackupDir`, one folder per change. The last `keepBackups` (30) changes are kept. | [`backup.go`](../../internal/library/backup.go) |
| RTK | `SetRTK` runs rtk's own installer (`rtk init -g …`) for an agent being switched on. For one being switched off, it removes exactly what the installer wrote, using magpie's own code, which works even with rtk gone. `ReadRTK` reads each agent's files to say which have the hook. `rtkSpec.blocked` stops the installer where it would break an agent. A detected agent RTK has no hook for at all is still listed, from `rtkNoHook`, with the reason and a switch that can't be turned on (dsh: its hooks can allow or deny a command but not rewrite it, and `rtk init` has no `--agent dsh`). | [`rtk.go`](../../internal/library/rtk.go) | | RTK | `SetRTK` runs rtk's own installer (`rtk init -g …`) for an agent being switched on. For one being switched off, it removes exactly what the installer wrote, using magpie's own code, which works even with rtk gone. `ReadRTK` reads each agent's files to say which have the hook. `rtkSpec.blocked` stops the installer where it would break an agent. A detected agent RTK has no hook for at all is still listed, from `rtkNoHook`, with the reason and a switch that can't be turned on (dsh: its hooks can allow or deny a command but not rewrite it, and `rtk init` has no `--agent dsh`). | [`rtk.go`](../../internal/library/rtk.go) |
+12 -6
View File
@@ -3767,10 +3767,14 @@
const d = el("p", "mk-desc" + (x.description ? "" : " wait"), x.description || ""); const d = el("p", "mk-desc" + (x.description ? "" : " wait"), x.description || "");
d.title = x.description || ""; d.title = x.description || "";
const foot = el("div", "mk-foot"); const foot = el("div", "mk-foot");
const n = el("span", "mk-installs"); // magpie's own skill has no count until skills.sh lists it
n.append(svg(GLYPH.down, 11, 1.5), el("span", "", compact(x.installs))); if (!x.featured || x.installs) {
n.title = t("{n} installs", { n: x.installs.toLocaleString() }); const n = el("span", "mk-installs");
foot.append(n, el("span", "grow"), addButton(x, () => addSkill(x))); n.append(svg(GLYPH.down, 11, 1.5), el("span", "", compact(x.installs)));
n.title = t("{n} installs", { n: x.installs.toLocaleString() });
foot.append(n);
}
foot.append(el("span", "grow"), addButton(x, () => addSkill(x)));
c.append(top, d, foot); c.append(top, d, foot);
c.onclick = () => skillSheet(x); c.onclick = () => skillSheet(x);
c.title = t("About {name}", { name: x.name }); c.title = t("About {name}", { name: x.name });
@@ -3793,10 +3797,12 @@
const who = el("div", "mk-who"); const who = el("div", "mk-who");
who.append(el("div", "mk-name big", x.name)); who.append(el("div", "mk-name big", x.name));
const meta = el("div", "mk-meta"); const meta = el("div", "mk-meta");
meta.append(el("span", "mk-pub", x.source), el("span", "mk-badge", t("{n} installs", { n: compact(x.installs) }))); meta.append(el("span", "mk-pub", x.source));
if (!x.featured || x.installs) meta.append(el("span", "mk-badge", t("{n} installs", { n: compact(x.installs) })));
if (x.official) meta.append(el("span", "mk-badge", t("Official"))); if (x.official) meta.append(el("span", "mk-badge", t("Official")));
who.append(meta); who.append(meta);
head.append(logo(x.icon, x.source), who, extLink("https://skills.sh/" + x.source + "/" + x.skillId, "skills.sh")); head.append(logo(x.icon, x.source), who);
if (!x.featured) head.append(extLink("https://skills.sh/" + x.source + "/" + x.skillId, "skills.sh"));
ed.append(head); ed.append(head);
const about = el("p", "mk-about", x.description || "…"); const about = el("p", "mk-about", x.description || "…");
ed.append(about); ed.append(about);
@@ -0,0 +1,94 @@
// Run with Node's test runner and Playwright on the module path; see README.md.
// magpie's own skill (magpie-quota, ttmouse on X) is first in the market's
// skills: until skills.sh lists it there is no count of installs to show and
// no skills.sh page to link, so its card and sheet show neither, where a
// skills.sh skill keeps both. Every language, a narrow window, both engines.
// 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/tester";
const items = [
{ id: "magpie-community/plugins/magpie-quota", name: "magpie-quota", source: "magpie-community/plugins", skillId: "magpie-quota", installs: 0, official: true, featured: true, description: "Check what is left of every subscription." },
{ id: "acme/skills/pdf", name: "pdf", source: "acme/skills", skillId: "pdf", installs: 1200, description: "PDFs" },
];
function serve(lang) {
return async (route) => {
const url = new URL(route.request().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/plugins") return json({ plugins: [] });
if (url.pathname === "/api/library") {
return json({
dir: `${HOME}/.magpie/library`, backups: `${HOME}/.magpie/backups`, home: HOME,
agents: [{ id: "claude", name: "Claude Code", icon: "claudecode-color", skills: `${HOME}/.claude/skills`, mcp: `${HOME}/.claude.json` }],
instructions: { agents: [], sets: [] }, servers: [], foundServers: [], projects: [], foundSkills: [], skills: [], problems: [],
});
}
if (url.pathname === "/api/library/market/servers") return json({ items: [] });
if (url.pathname === "/api/library/market/skills") return json({ items: structuredClone(items) });
if (url.pathname === "/api/groups") return json({ groups: [] });
if (url.pathname === "/api/providers") return json({ providers: [], gateway: { running: true } });
if (url.pathname.startsWith("/api/")) return 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: "" }));
};
}
for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium", "webkit"])) {
for (const lang of ["en", "zh", "ja", "de"]) {
test(`${engine} ${lang}: magpie's own skill shows no installs and no skills.sh link`, async (t) => {
const browser = await (engine === "webkit" ? webkit.launch() : chromium.launch({ channel: "chromium" }));
const context = await browser.newContext({ viewport: { width: 520, height: 760 }, reducedMotion: "reduce" });
const page = await context.newPage();
page.setDefaultTimeout(5000);
const errors = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.route("**/*", serve(lang));
await page.addInitScript(() => { localStorage.setItem("magpie.libTab", "skills"); });
t.after(() => browser.close());
await page.goto("http://magpie.test/?view=library");
const view = page.locator("#view-library");
const card = (id) => view.locator(`.mk[data-market="skills"] .mk-card[data-id="${id}"]`);
const own = card("magpie-community/plugins/magpie-quota"), pdf = card("acme/skills/pdf");
await own.waitFor();
await pdf.waitFor();
// first, marked official, nothing where a count would be
assert.equal(await view.locator('.mk[data-market="skills"] .mk-card').first().getAttribute("data-id"), "magpie-community/plugins/magpie-quota");
assert.equal(await own.locator(".mk-official").count(), 1);
assert.equal(await own.locator(".mk-installs").count(), 0);
assert.equal(await own.locator(".mk-add").count(), 1);
assert.equal(await pdf.locator(".mk-installs").count(), 1);
// nothing squeezed out of the narrow card
for (const c of [own, pdf]) {
const fits = await c.evaluate((e) => [...e.querySelectorAll(".mk-title, .mk-add")].every((x) => x.getBoundingClientRect().width > 0 && x.getBoundingClientRect().right <= e.getBoundingClientRect().right + 1));
assert(fits, "a title or button is cut off");
}
const ext = () => page.locator(".mk-sheet .mk-ext");
await own.locator(".mk-title").click();
await page.locator(".mk-sheet").waitFor();
assert.deepEqual(await ext().allTextContents(), ["github.com/magpie-community/plugins"]);
assert.equal(await page.locator(".mk-sheet .mk-badge").count(), 1); // Official, no installs
await page.keyboard.press("Escape");
await page.locator(".mk-sheet").waitFor({ state: "detached" });
await pdf.locator(".mk-title").click();
await page.locator(".mk-sheet").waitFor();
assert.deepEqual(await ext().allTextContents(), ["skills.sh", "github.com/acme/skills"]);
assert.equal(await page.locator(".mk-sheet .mk-badge").count(), 1); // installs
assert.deepEqual(errors, []);
});
}
}
+33 -2
View File
@@ -776,12 +776,22 @@ type MarketSkill struct {
Name string `json:"name"` Name string `json:"name"`
Installs int `json:"installs"` Installs int `json:"installs"`
Official bool `json:"official,omitempty"` Official bool `json:"official,omitempty"`
// Featured is magpie's own, not one skills.sh lists
Featured bool `json:"featured,omitempty"`
Icon string `json:"icon"` Icon string `json:"icon"`
// Description is known once it has been fetched // Description is known once it has been fetched
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Have string `json:"have,omitempty"` Have string `json:"have,omitempty"`
} }
// featuredSkills are magpie's own skills, first in the market: in
// magpie-community/plugins, beside its plugins (ttmouse on X: an official
// skill for an agent to read what magpie quota tells).
var featuredSkills = []MarketSkill{{
Source: "magpie-community/plugins", SkillID: "magpie-quota", Name: "magpie-quota", Official: true, Featured: true,
Description: "Check how much is left of the user's AI subscriptions, coding plans and API key balances through magpie: each usage window's percent used and when it resets, each key's balance, and wait until a subscription has allowance again.",
}}
type skillsShEntry struct { type skillsShEntry struct {
Source string `json:"source"` Source string `json:"source"`
SkillID string `json:"skillId"` SkillID string `json:"skillId"`
@@ -867,8 +877,9 @@ func searchSkillsSh(q string) ([]skillsShEntry, error) {
return slices.DeleteFunc(body.Skills, func(e skillsShEntry) bool { return !repoRe.MatchString(e.Source) || e.SkillID == "" }), nil return slices.DeleteFunc(body.Skills, func(e skillsShEntry) bool { return !repoRe.MatchString(e.Source) || e.SkillID == "" }), nil
} }
// MarketSkills is what the market shows for a search: the most installed // MarketSkills is what the market shows for a search: magpie's own skills
// skills that match it, then what skills.sh finds for it. // that match it, the most installed skills that do, then what skills.sh
// finds for it.
func MarketSkills(q string) ([]MarketSkill, error) { func MarketSkills(q string) ([]MarketSkill, error) {
q = strings.ToLower(strings.TrimSpace(q)) q = strings.ToLower(strings.TrimSpace(q))
var list []skillsShEntry var list []skillsShEntry
@@ -899,6 +910,26 @@ func MarketSkills(q string) ([]MarketSkill, error) {
about := cachedAbout() about := cachedAbout()
l, _ := loadLocked() l, _ := loadLocked()
out := []MarketSkill{} out := []MarketSkill{}
for _, f := range featuredSkills {
if q != "" && !strings.Contains(strings.ToLower(f.Name+" "+f.Source+" "+f.Description), q) {
continue
}
owner, _, _ := strings.Cut(f.Source, "/")
f.ID, f.Icon = f.Source+"/"+f.SkillID, gh(owner)
if l != nil {
f.Have = l.haveSkill(f.Source, f.SkillID, f.Name)
}
// once skills.sh lists it too, its count of installs is told, and
// it isn't shown twice
list = slices.DeleteFunc(list, func(e skillsShEntry) bool {
if e.Source == f.Source && e.SkillID == f.SkillID {
f.Installs = e.Installs
return true
}
return false
})
out = append(out, f)
}
for _, e := range list { for _, e := range list {
owner, _, _ := strings.Cut(e.Source, "/") owner, _, _ := strings.Cut(e.Source, "/")
ms := MarketSkill{ID: e.Source + "/" + e.SkillID, Source: e.Source, SkillID: e.SkillID, Name: e.Name, Installs: e.Installs, ms := MarketSkill{ID: e.Source + "/" + e.SkillID, Source: e.Source, SkillID: e.SkillID, Name: e.Name, Installs: e.Installs,
+24
View File
@@ -221,6 +221,30 @@ func TestSkillsShPages(t *testing.T) {
} }
} }
// magpie's own skills come first, marked official, and only where a
// search names them (ttmouse on X).
func TestFeaturedSkills(t *testing.T) {
sandbox(t)
t.Setenv("LOCALAPPDATA", t.TempDir())
skillsSh(t)
popular.Lock()
popular.list = nil
popular.Unlock()
t.Cleanup(func() { popular.Lock(); popular.list = nil; popular.Unlock() })
list, _ := MarketSkills("")
if len(list) != 2 || list[0].ID != "magpie-community/plugins/magpie-quota" || !list[0].Official || !list[0].Featured ||
list[0].Description == "" || list[0].Icon != gh("magpie-community") || !offered(list[0].Icon) || list[1].ID != "o/r/pdf" {
t.Fatalf("market: %+v", list)
}
if list, _ := MarketSkills("quota"); len(list) == 0 || list[0].SkillID != "magpie-quota" {
t.Fatalf("quota: %+v", list)
}
if list, _ := MarketSkills("pdf"); slices.ContainsFunc(list, func(m MarketSkill) bool { return m.Featured }) {
t.Fatalf("pdf: %+v", list)
}
}
func TestInstallFromRegistry(t *testing.T) { func TestInstallFromRegistry(t *testing.T) {
sandbox(t) sandbox(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {