plugins: a repository tagged magpie-plugin shows the icon its package.json gives (yetone: 非官方的插件能否支持 icon)

magpie.icon, an https URL or a data:image URI, is read with the rest of
package.json and kept through pluginOwnIcon as an installed plugin's is;
the card shows it, else the puzzle piece.

Ran: go test ./internal/plugin ./internal/gui ./internal/provider; the
plugin GUI tests in Chromium and WebKit (en/zh/ja/de, 980 and 440).
This commit is contained in:
yetone committed 2026-10-10 00:42:37 +08:00
1 parent 849faea3a3
commit a3a8e25792
11 files changed
+45 -8

No files matched your search

+2
View File
@@ -554,6 +554,8 @@ OpenCode ignores:
host, which magpie fetches once and keeps, or a `data:image/…` URI;
PNG, JPEG, GIF, WebP, ICO or SVG, at most 1 MB. Anything else is ignored,
and the icon the plugin market lists for the plugin is shown instead.
The `package.json` icon is also the card's picture under Discover →
Unofficial, on GitHub for a repository tagged `magpie-plugin`.
- **An API key's field**: a `type: "api"` method's `label` titles the key's
field, as OpenCode's dialog does (one that only says "API key" reads
"<provider> API key"), and its `placeholder` is the hint inside the field
+1 -1
View File
@@ -115,7 +115,7 @@
// spec (its npm name when its author published it there from the
// repository, else github:owner/repo)
const ghListing = (r) => ({
package: r.spec, name: r.repo.split("/")[1], kind: r.kind, github: r,
package: r.spec, name: r.repo.split("/")[1], kind: r.kind, github: r, icon: r.icon || undefined,
summary: r.description ? { en: r.description } : undefined,
npm: { version: r.version || "", publisher: r.owner, repository: r.url, license: r.license, weekly: 0 },
});
+7 -1
View File
@@ -256,7 +256,13 @@ func pluginRoutes(mux *http.ServeMux, w Windows) {
// repositories on GitHub tagged magpie-plugin: nobody's list, shown
// apart as not reviewed, installed from the repository
mux.HandleFunc("GET /api/plugins/github", func(rw http.ResponseWriter, r *http.Request) {
writeJSON(rw, map[string]any{"repos": plugin.TaggedRepos(r.Context()), "topic": plugin.Topic})
// each one's own picture as the page can show it, kept here as an
// installed plugin's is (a data URI isn't sent on to the page)
repos := append([]plugin.Tagged(nil), plugin.TaggedRepos(r.Context())...)
for i := range repos {
repos[i].Icon = provider.RepoIcon(repos[i].Icon)
}
writeJSON(rw, map[string]any{"repos": repos, "topic": plugin.Topic})
})
mux.HandleFunc("GET /api/plugins/npm", func(rw http.ResponseWriter, r *http.Request) {
names := []string{}
+7 -1
View File
@@ -22,7 +22,7 @@ const { chromium, webkit } = require("playwright");
const assets = path.resolve(__dirname, "../assets");
const REPOS = [
{ spec: "opencode-claude-auth", repo: "griffinmartin/opencode-claude-auth", url: "https://github.com/griffinmartin/opencode-claude-auth", owner: "griffinmartin", ownerAvatar: "https://avatars.githubusercontent.com/u/1090329?v=4", description: "OpenCode plugin that uses your existing Claude Code credentials — no separate login needed. ", stars: 1301, license: "MIT", pushed: "2026-09-22T23:47:15Z", package: "opencode-claude-auth", version: "2.2.1" },
{ spec: "opencode-claude-auth", repo: "griffinmartin/opencode-claude-auth", url: "https://github.com/griffinmartin/opencode-claude-auth", owner: "griffinmartin", ownerAvatar: "https://avatars.githubusercontent.com/u/1090329?v=4", description: "OpenCode plugin that uses your existing Claude Code credentials — no separate login needed. ", stars: 1301, license: "MIT", pushed: "2026-09-22T23:47:15Z", package: "opencode-claude-auth", version: "2.2.1", icon: "file:claude-auth.svg" },
{ spec: "@slkiser/opencode-quota", repo: "slkiser/opencode-quota", url: "https://github.com/slkiser/opencode-quota", owner: "slkiser", ownerAvatar: "https://avatars.githubusercontent.com/u/35721408?v=4", description: "OpenCode quota & tokens usage with zero context window pollution. Supports OpenCode Go, Cursor, GitHub Copilot, OpenAl, Kimi Code, Alibaba Coding Plan, Chutes Al, Google Antigravity, Z.ai Coding Plan and more.", stars: 1000, license: "MIT", pushed: "2026-10-06T13:25:50Z", package: "@slkiser/opencode-quota", version: "5.0.2" },
{ spec: "github:keli-wen/agy-staff", repo: "keli-wen/agy-staff", url: "https://github.com/keli-wen/agy-staff", owner: "keli-wen", ownerAvatar: "https://avatars.githubusercontent.com/u/103916249?v=4", description: "Hire Google's Antigravity CLI (agy) as a fast Gemini staffer for Claude Code and OpenAI Codex.", stars: 723, license: "MIT", pushed: "2026-10-06T16:29:15Z", package: "agy-staff", version: "0.7.4" },
{ spec: "@rama_nigg/open-cursor", repo: "Nomadcxx/opencode-cursor", url: "https://github.com/Nomadcxx/opencode-cursor", owner: "Nomadcxx", ownerAvatar: "https://avatars.githubusercontent.com/u/143774106?v=4", description: "Use Cursor Pro models in OpenCode via HTTP proxy with OAuth", stars: 704, license: "BSD-3-Clause", pushed: "2026-10-02T12:34:45Z", package: "@rama_nigg/open-cursor", version: "2.5.11" },
@@ -65,6 +65,7 @@ function server(lang, asked) {
return json(state());
}
if (url.pathname === "/api/open") { asked.push(["open", body()]); return route.fulfill({ status: 204 }); }
if (url.pathname === "/api/icons/claude-auth.svg") return route.fulfill({ contentType: "image/svg+xml", body: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" fill="#d97757"/></svg>' });
if (url.pathname.startsWith("/api/")) return json({});
if (!/^\/[\w./-]*$/.test(url.pathname) || url.host !== "magpie.test") { asked.push(["fetched", url.href]); return route.fulfill({ status: 404, body: "" }); }
const file = path.join(assets, url.pathname === "/" ? "index.html" : url.pathname);
@@ -120,6 +121,11 @@ for (const engine of (process.env.BROWSER ? [process.env.BROWSER] : ["chromium",
const c = cards.first();
assert.equal((await c.locator(".pm-by").innerText()).trim(), "griffinmartin");
// the picture its package.json gives (magpie.icon), as magpie keeps it; one that gives none, the puzzle
assert.equal(await c.locator(".pm-logo img").getAttribute("src"), "/api/icons/claude-auth.svg");
assert.equal(await cards.nth(2).locator(".pm-logo img").count(), 0);
// a provider and a middleware part by hue (yetone: 中间件和供应商稍微有点区分吧)
assert.equal(await c.locator(".pm-chip.kind.pv").count(), 1);
assert.equal((await c.locator(".pm-chip.warn").innerText()).trim(), w.chip);
assert.match(await c.locator(".pm-chip.warn").getAttribute("title"), /magpie-plugin/);
assert.equal((await c.locator(".pm-dl").innerText()).trim(), "1.3k");
+10 -1
View File
@@ -38,6 +38,9 @@ type Tagged struct {
Package string `json:"package,omitempty"`
Version string `json:"version,omitempty"`
Kind string `json:"kind,omitempty"` // "middleware" when that is all it is
// its package.json's magpie.icon, as an installed plugin gives its
// provider one: an https URL or a data:image URI, as it is written
Icon string `json:"icon,omitempty"`
}
// Where GitHub is asked, moved by tests.
@@ -196,7 +199,7 @@ func askTagged(ctx context.Context) ([]Tagged, error) {
if branch == "" {
branch = "HEAD"
}
pb, err := fetchJSON(pc, githubRaw+"/"+t.Repo+"/"+url.PathEscape(branch)+"/package.json", 256<<10)
pb, err := fetchJSON(pc, githubRaw+"/"+t.Repo+"/"+url.PathEscape(branch)+"/package.json", 2<<20)
if err != nil {
read = errors.Is(err, errNotFound)
return
@@ -208,6 +211,7 @@ func askTagged(ctx context.Context) ([]Tagged, error) {
Exports json.RawMessage `json:"exports"`
Magpie struct {
Middleware string `json:"middleware"`
Icon string `json:"icon"`
} `json:"magpie"`
}
if json.Unmarshal(pb, &pj) != nil || !pkgName.MatchString(pj.Name) {
@@ -215,6 +219,11 @@ func askTagged(ctx context.Context) ([]Tagged, error) {
return
}
t.Package, t.Version = pj.Name, pj.Version
// a picture, as host.js's iconOf takes one: magpie checks and
// keeps it before the page shows it (the GUI's /api/plugins/github)
if ic := strings.TrimSpace(pj.Magpie.Icon); len(ic) <= 3<<19 && (strings.HasPrefix(strings.ToLower(ic), "https://") || strings.HasPrefix(strings.ToLower(ic), "data:image/")) {
t.Icon = ic
}
if strings.TrimSpace(pj.Magpie.Middleware) != "" && pj.Main == "" && len(pj.Exports) == 0 {
t.Kind = "middleware"
}
+8
View File
@@ -142,6 +142,14 @@ func TestTaggedRepos(t *testing.T) {
if x := byRepo["someone/flaky-entry"]; x.License != "" {
t.Fatalf("NOASSERTION is no license: %+v", x)
}
// its own picture, package.json's magpie.icon, as an installed
// plugin's provider has it; none when it gives none
if x := byRepo["someone/flaky-entry"]; !strings.HasPrefix(x.Icon, "data:image/svg+xml;base64,") {
t.Fatalf("magpie.icon: %q", x.Icon)
}
if c.Icon != "" {
t.Fatalf("no magpie.icon, yet %q", c.Icon)
}
if x := byRepo["someone/word-guard"]; x.Kind != "middleware" || x.Package != "@magpie-community/middleware-word-guard" || x.License != "" {
t.Fatalf("a middleware alone: %+v", x)
}
@@ -2,5 +2,6 @@
"name": "flaky-entry",
"version": "0.1.0",
"type": "module",
"main": "./index.js"
"main": "./index.js",
"magpie": { "icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxNiAxNiI+PGNpcmNsZSBjeD0iOCIgY3k9IjgiIHI9IjYiLz48L3N2Zz4=" }
}
+5
View File
@@ -112,6 +112,11 @@ func pluginOwnIcon(said string) string {
return ""
}
// RepoIcon is the Icon value of the picture a plugin not installed yet
// gives in its package.json (a repository tagged magpie-plugin), checked
// and kept as an installed plugin's is: "" while there is none to show.
func RepoIcon(said string) string { return pluginOwnIcon(said) }
// keptPluginIcons are the names of the stored pictures plugins gave, which
// pruneIcons leaves.
func keptPluginIcons() []string {
+1 -1
View File
@@ -581,7 +581,7 @@ m plugin logout acme</pre>
<span class="s">"summary"</span>: { <span class="s">"en"</span>: <span class="s">"Your Acme Coding Plan."</span>, <span class="s">"zh"</span>: <span class="s">"使用你的 Acme Coding Plan。"</span> } }</pre>
</div>
ミドルウェアのエントリには <code>"kind": "middleware"</code> を書き、<code>providers</code> は書きません。<span class="ui">ゲートウェイミドルウェア</span> の下に表示されます。<code>MAGPIE_PLUGIN_MARKET=&lt;url&gt;</code> は magpie に別の一覧を参照させます(自分の一覧の確認に便利です)。<code>off</code> にすると内蔵のコピーだけを使います。</li>
<li><b>GitHub のトピック。</b>GitHub で <code>magpie-plugin</code> トピックを付けたリポジトリは、<span class="ui">プラグイン → 発見 → 非公式 · GitHub</span> に、スターの多い順に、誰もレビューしていないものとして表示されます。<code>package.json</code> はリポジトリのルートに置いてください。<code>repository</code> がそのリポジトリを指す形で npm に公開されていれば npm からインストールされ、そうでなければリポジトリの現在のコードがインストールされます。そのため読み込むファイルはコミットしておく必要があり、<code>dist</code> を公開時にしかビルドしないリポジトリは表示されません。フォーク、アーカイブ済みのリポジトリ、magpie-community 自身のものも表示されません。magpie が GitHub に問い合わせるのは最大で 10 分に 1 回なので、トピックを付けたリポジトリは数分で表示されます。</li>
<li><b>GitHub のトピック。</b>GitHub で <code>magpie-plugin</code> トピックを付けたリポジトリは、<span class="ui">プラグイン → 発見 → 非公式 · GitHub</span> に、スターの多い順に、誰もレビューしていないものとして表示されます。<code>package.json</code> はリポジトリのルートに置いてください。<code>repository</code> がそのリポジトリを指す形で npm に公開されていれば npm からインストールされ、そうでなければリポジトリの現在のコードがインストールされます。そのため読み込むファイルはコミットしておく必要があり、<code>dist</code> を公開時にしかビルドしないリポジトリは表示されません。フォーク、アーカイブ済みのリポジトリ、magpie-community 自身のものも表示されません。magpie が GitHub に問い合わせるのは最大で 10 分に 1 回なので、トピックを付けたリポジトリは数分で表示されます。カードには <code>package.json</code> の <code>"magpie": { "icon": "…" }</code> で指定したアイコンが表示され、ない場合はパズルのアイコンになります。</li>
<li><b>コミュニティのパッケージ。</b><a href="https://github.com/magpie-community/plugins">magpie-community/plugins</a> に貢献するには:
<ul>
<li><code>packages/&lt;name&gt;</code> に置き、名前はプロバイダなら <code>@magpie-community/opencode-&lt;name&gt;-auth</code>、ミドルウェアなら <code>@magpie-community/middleware-&lt;name&gt;</code> とします。本当に必要でない限り、ランタイムの依存は持たせないでください。</li>
+1 -1
View File
@@ -581,7 +581,7 @@ m plugin logout acme</pre>
<span class="s">"summary"</span>: { <span class="s">"en"</span>: <span class="s">"Your Acme Coding Plan."</span>, <span class="s">"zh"</span>: <span class="s">"使用你的 Acme Coding Plan。"</span> } }</pre>
</div>
A middleware’s entry has <code>"kind": "middleware"</code> and no <code>providers</code>; it is listed under <span class="ui">Gateway middleware</span>. <code>MAGPIE_PLUGIN_MARKET=&lt;url&gt;</code> points magpie at another list, which is useful to check yours, and <code>off</code> keeps to the built-in copy.</li>
<li><b>A GitHub topic.</b> A repository tagged <code>magpie-plugin</code> on GitHub is shown under <span class="ui">Plugins → Discover → Unofficial, on GitHub</span>, the most starred first, marked as reviewed by no one. Keep <code>package.json</code> at the repository’s root. Published to npm with <code>repository</code> naming the repository, it is installed from npm; otherwise from the repository as it stands, so the file it loads must be committed: one whose <code>dist</code> is built only to publish is left out. Forks, archived repositories and magpie-community’s own are left out too. magpie asks GitHub at most every ten minutes, so a repository shows within minutes of being tagged.</li>
<li><b>A GitHub topic.</b> A repository tagged <code>magpie-plugin</code> on GitHub is shown under <span class="ui">Plugins → Discover → Unofficial, on GitHub</span>, the most starred first, marked as reviewed by no one. Keep <code>package.json</code> at the repository’s root. Published to npm with <code>repository</code> naming the repository, it is installed from npm; otherwise from the repository as it stands, so the file it loads must be committed: one whose <code>dist</code> is built only to publish is left out. Forks, archived repositories and magpie-community’s own are left out too. magpie asks GitHub at most every ten minutes, so a repository shows within minutes of being tagged. Its card shows the icon <code>"magpie": { "icon": "…" }</code> in its <code>package.json</code> gives, else a puzzle piece.</li>
<li><b>The community packages.</b> To contribute one to <a href="https://github.com/magpie-community/plugins">magpie-community/plugins</a>:
<ul>
<li>Put it under <code>packages/&lt;name&gt;</code>, named <code>@magpie-community/opencode-&lt;name&gt;-auth</code> (a provider) or <code>@magpie-community/middleware-&lt;name&gt;</code> (middleware), with no runtime dependencies unless one is really needed.</li>
+1 -1
View File
@@ -581,7 +581,7 @@ m plugin logout acme</pre>
<span class="s">"summary"</span>: { <span class="s">"en"</span>: <span class="s">"Your Acme Coding Plan."</span>, <span class="s">"zh"</span>: <span class="s">"使用你的 Acme Coding Plan。"</span> } }</pre>
</div>
中间件的条目写 <code>"kind": "middleware"</code>,不写 <code>providers</code>,列在<span class="ui">网关中间件</span>下。<code>MAGPIE_PLUGIN_MARKET=&lt;url&gt;</code> 让 magpie 改用另一份列表,可以用来检查你的条目;设为 <code>off</code> 则只用内置副本。</li>
<li><b>GitHub 标签。</b>在 GitHub 上打了 <code>magpie-plugin</code> topic 的仓库,会显示在 <span class="ui">插件 → 发现 → 非官方插件 · GitHub</span> 里,Star 多的在前,并标明没有人审阅过。<code>package.json</code> 要放在仓库根目录。发布到 npm 且 <code>repository</code> 指向这个仓库的,从 npm 安装;否则按仓库当前的代码安装,所以它加载的文件必须提交进仓库:<code>dist</code> 只在发布时才构建的仓库不会显示。fork、已归档的仓库和 magpie-community 自己的仓库也不显示。magpie 最多每十分钟问一次 GitHub,仓库打上标签后几分钟内就会出现。</li>
<li><b>GitHub 标签。</b>在 GitHub 上打了 <code>magpie-plugin</code> topic 的仓库,会显示在 <span class="ui">插件 → 发现 → 非官方插件 · GitHub</span> 里,Star 多的在前,并标明没有人审阅过。<code>package.json</code> 要放在仓库根目录。发布到 npm 且 <code>repository</code> 指向这个仓库的,从 npm 安装;否则按仓库当前的代码安装,所以它加载的文件必须提交进仓库:<code>dist</code> 只在发布时才构建的仓库不会显示。fork、已归档的仓库和 magpie-community 自己的仓库也不显示。magpie 最多每十分钟问一次 GitHub,仓库打上标签后几分钟内就会出现。卡片显示 <code>package.json</code> 里 <code>"magpie": { "icon": "…" }</code> 给的图标,没有就显示拼图图标。</li>
<li><b>社区包。</b>想把插件贡献到 <a href="https://github.com/magpie-community/plugins">magpie-community/plugins</a>:
<ul>
<li>放在 <code>packages/&lt;name&gt;</code> 下,包名为 <code>@magpie-community/opencode-&lt;name&gt;-auth</code>(供应商)或 <code>@magpie-community/middleware-&lt;name&gt;</code>(中间件),除非确实需要,不要有运行时依赖。</li>