diff --git a/docs/subsystems/library.md b/docs/subsystems/library.md index f40fe506..db232369 100644 --- a/docs/subsystems/library.md +++ b/docs/subsystems/library.md @@ -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) | | 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/` (#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 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) | | 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) | diff --git a/internal/gui/assets/library.js b/internal/gui/assets/library.js index 3bf8fdd3..6807bbc7 100644 --- a/internal/gui/assets/library.js +++ b/internal/gui/assets/library.js @@ -3767,10 +3767,14 @@ const d = el("p", "mk-desc" + (x.description ? "" : " wait"), x.description || ""); d.title = x.description || ""; const foot = el("div", "mk-foot"); - const n = el("span", "mk-installs"); - 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, el("span", "grow"), addButton(x, () => addSkill(x))); + // magpie's own skill has no count until skills.sh lists it + if (!x.featured || x.installs) { + const n = el("span", "mk-installs"); + 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.onclick = () => skillSheet(x); c.title = t("About {name}", { name: x.name }); @@ -3793,10 +3797,12 @@ const who = el("div", "mk-who"); who.append(el("div", "mk-name big", x.name)); 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"))); 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); const about = el("p", "mk-about", x.description || "…"); ed.append(about); diff --git a/internal/gui/tests/market-featured-skill.test.cjs b/internal/gui/tests/market-featured-skill.test.cjs new file mode 100644 index 00000000..b55bdbc6 --- /dev/null +++ b/internal/gui/tests/market-featured-skill.test.cjs @@ -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, []); + }); + } +} diff --git a/internal/library/market.go b/internal/library/market.go index ed027677..c766a50c 100644 --- a/internal/library/market.go +++ b/internal/library/market.go @@ -776,12 +776,22 @@ type MarketSkill struct { Name string `json:"name"` Installs int `json:"installs"` Official bool `json:"official,omitempty"` + // Featured is magpie's own, not one skills.sh lists + Featured bool `json:"featured,omitempty"` Icon string `json:"icon"` // Description is known once it has been fetched Description string `json:"description,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 { Source string `json:"source"` 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 } -// MarketSkills is what the market shows for a search: the most installed -// skills that match it, then what skills.sh finds for it. +// MarketSkills is what the market shows for a search: magpie's own skills +// that match it, the most installed skills that do, then what skills.sh +// finds for it. func MarketSkills(q string) ([]MarketSkill, error) { q = strings.ToLower(strings.TrimSpace(q)) var list []skillsShEntry @@ -899,6 +910,26 @@ func MarketSkills(q string) ([]MarketSkill, error) { about := cachedAbout() l, _ := loadLocked() 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 { owner, _, _ := strings.Cut(e.Source, "/") ms := MarketSkill{ID: e.Source + "/" + e.SkillID, Source: e.Source, SkillID: e.SkillID, Name: e.Name, Installs: e.Installs, diff --git a/internal/library/market_test.go b/internal/library/market_test.go index a0e1dba7..b89e556b 100644 --- a/internal/library/market_test.go +++ b/internal/library/market_test.go @@ -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) { sandbox(t) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {