zmarketplace 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,255 @@
1
+ /**
2
+ * zmarketplace — /zmarketplace slash command for pi/omp.
3
+ *
4
+ * Flow: /zmarketplace → type query → browse results → enter → detail+README → install (audit first)
5
+ */
6
+
7
+ import { search } from "./core/search.ts";
8
+ import { getDetail } from "./core/detail.ts";
9
+ import { auditPackage } from "./core/audit.ts";
10
+ import { cacheResults, resolveRef, cacheAudit } from "./core/cache.ts";
11
+ import { formatResultOption, formatAuditReport, formatHelp, parseArgs } from "./core/tui.ts";
12
+ import type { PackageResult } from "./core/types.ts";
13
+
14
+ // ── Types ──────────────────────────────────────────────────────────────────
15
+
16
+ interface UI {
17
+ select(title: string, options: Array<string | { label: string; description?: string }>): Promise<string | undefined>;
18
+ confirm(title: string, message: string): Promise<boolean>;
19
+ input(title: string, placeholder?: string): Promise<string | undefined>;
20
+ notify(message: string, level?: string): void;
21
+ setStatus?(message: string): void;
22
+ }
23
+
24
+ interface Ctx { cwd: string; hasUI: boolean; ui: UI }
25
+
26
+ // ── Helpers ────────────────────────────────────────────────────────────────
27
+
28
+ function openUrl(url: string): void {
29
+ if (process.platform === "win32") Bun.spawn(["cmd", "/c", "start", "", url], { stdout: "ignore", stderr: "ignore" });
30
+ else if (process.platform === "darwin") Bun.spawn(["open", url], { stdout: "ignore", stderr: "ignore" });
31
+ else Bun.spawn(["xdg-open", url], { stdout: "ignore", stderr: "ignore" });
32
+ }
33
+
34
+ function extractUrl(line: string, repoBase?: string): string | null {
35
+ const direct = line.match(/https?:\/\/[^\s)>]+/);
36
+ if (direct) return direct[0].replace(/[.)]+$/, "");
37
+ const rel = line.match(/🖼 IMAGE: (.+)/);
38
+ if (rel && repoBase) return `${repoBase}/raw/main/${rel[1].replace(/^\.\//, "")}`;
39
+ return null;
40
+ }
41
+
42
+ // ── Search ─────────────────────────────────────────────────────────────────
43
+
44
+ async function doSearch(query: string, ctx: Ctx, limit = 50): Promise<void> {
45
+ ctx.ui.setStatus?.("Searching...");
46
+ const results = await search({ query, limit, type: "all", ecosystem: "all" });
47
+ cacheResults(results, query);
48
+ if (results.length === 0) { ctx.ui.notify("No packages found.", "warning"); return; }
49
+ ctx.ui.notify(`Found ${results.length} packages.`, "info");
50
+ await browseResults(results, ctx);
51
+ }
52
+
53
+ const PAGE_SIZE = 50;
54
+
55
+ async function browseResults(results: PackageResult[], ctx: Ctx): Promise<void> {
56
+ let pageStart = 0;
57
+ while (true) {
58
+ const pageEnd = Math.min(pageStart + PAGE_SIZE, results.length);
59
+ const page = results.slice(pageStart, pageEnd);
60
+
61
+ const options = page.map((r, i) => {
62
+ const globalIdx = pageStart + i;
63
+ return { label: formatResultOption(r, globalIdx).label, description: r.installCommand ?? "" };
64
+ });
65
+
66
+ // Pagination controls
67
+ if (pageStart > 0) {
68
+ const prevStart = Math.max(0, pageStart - PAGE_SIZE) + 1;
69
+ const prevEnd = pageStart;
70
+ options.push({ label: `← Previous (${prevStart}-${prevEnd})`, description: "" });
71
+ }
72
+ if (pageEnd < results.length) {
73
+ const nextStart = pageEnd + 1;
74
+ const nextEnd = Math.min(pageEnd + PAGE_SIZE, results.length);
75
+ options.push({ label: `→ Next (${nextStart}-${nextEnd})`, description: "" });
76
+ }
77
+ options.push({ label: "↩ Done", description: "" });
78
+
79
+ const title = `zmarketplace: ${results.length} results (page ${Math.floor(pageStart / PAGE_SIZE) + 1}/${Math.ceil(results.length / PAGE_SIZE)})`;
80
+ const selected = await ctx.ui.select(title, options);
81
+
82
+ if (!selected || selected === "↩ Done") return;
83
+ if (selected.startsWith("← Previous")) { pageStart = Math.max(0, pageStart - PAGE_SIZE); continue; }
84
+ if (selected.startsWith("→ Next")) { pageStart += PAGE_SIZE; continue; }
85
+
86
+ const match = selected.match(/\[(\d+)\]/);
87
+ if (match) {
88
+ const pkg = results[parseInt(match[1], 10) - 1];
89
+ if (pkg) await packageDetail(pkg, ctx);
90
+ }
91
+ }
92
+ }
93
+
94
+ // ── Detail + README ────────────────────────────────────────────────────────
95
+
96
+ async function packageDetail(pkg: PackageResult, ctx: Ctx): Promise<void> {
97
+ ctx.ui.setStatus?.(`Loading ${pkg.name}...`);
98
+ const detail = await getDetail(pkg.name);
99
+ if (!detail) { ctx.ui.notify(`"${pkg.name}" not found.`, "warning"); return; }
100
+
101
+ const repoBase = detail.repository?.replace(/^git\+/, "").replace(/\.git$/, "");
102
+ const lines: string[] = [
103
+ "⬇ Install (audit first)",
104
+ "🔒 Audit only",
105
+ "↩ Back to results",
106
+ "━━━ Package ━━━",
107
+ `📦 ${detail.name} v${detail.version ?? "?"}`,
108
+ detail.description || "",
109
+ `License: ${detail.license ?? "?"} Deps: ${detail.dependencyCount ?? "?"} Size: ${detail.size ? (detail.size / 1024).toFixed(1) + " KB" : "?"}`,
110
+ `Published: ${detail.publishedAt?.slice(0, 10) ?? "?"}`,
111
+ ];
112
+ if (detail.keywords?.length) lines.push(`Keywords: ${detail.keywords.join(", ")}`);
113
+ if (detail.npmUrl) lines.push(`🔗 ${detail.npmUrl}`);
114
+ if (repoBase) lines.push(`🔗 ${repoBase}`);
115
+
116
+ if (detail.readme) {
117
+ lines.push("━━━ README (enter on 🔗/🖼 to open) ━━━");
118
+ const rl = detail.readme
119
+ .replace(/!\[.*?\]\((https?:\/\/[^)]+)\)/g, "\n🖼 IMAGE: $1\n")
120
+ .replace(/!\[.*?\]\(([^)]+)\)/g, (_m, p) => `\n🖼 IMAGE: ${repoBase ? repoBase + "/raw/main/" + p.replace(/^\.\//, "") : p}\n`)
121
+ .replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, "$1 → $2")
122
+ .replace(/<img[^>]*src=["']([^"']+)["'][^>]*>/gi, (_m, p) => `\n🖼 IMAGE: ${p}\n`)
123
+ .replace(/<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi, "$2 → $1")
124
+ .replace(/<[^>]+>/g, "")
125
+ .split("\n").map(l => l.trimEnd()).filter(l => l.length > 0)
126
+ .slice(0, 150);
127
+ lines.push(...rl);
128
+ if (detail.readme.length > 8000) lines.push("...(truncated — see npm for full README)");
129
+ }
130
+
131
+ while (true) {
132
+ const selected = await ctx.ui.select(`${detail.name} — Details`, lines);
133
+ if (!selected) continue; // ESC → stay in detail, don't quit to results
134
+ if (selected.includes("Back to results")) return;
135
+ if (selected.includes("Install")) { await doInstall(pkg, ctx); continue; } // stay in detail after install
136
+ if (selected.includes("Audit only")) { await doAudit(pkg.name, ctx); continue; }
137
+ const url = extractUrl(selected, repoBase);
138
+ if (url) { openUrl(url); ctx.ui.notify("🌐 Opened in browser", "info"); }
139
+ }
140
+ }
141
+
142
+ // ── Audit ──────────────────────────────────────────────────────────────────
143
+
144
+ async function doAudit(name: string, ctx: Ctx): Promise<void> {
145
+ ctx.ui.setStatus?.(`Auditing ${name}...`);
146
+ const report = await auditPackage(name, { deepScan: true });
147
+ cacheAudit(name, report);
148
+ const lines = [
149
+ `${report.risk === "safe" ? "✅" : report.risk === "low" ? "🟢" : report.risk === "moderate" ? "🟡" : "🔴"} Risk: ${report.risk.toUpperCase()}`,
150
+ `Deep scan: ${report.deepScanned ? "yes" : "no"}`,
151
+ "",
152
+ report.findings.length === 0 ? "No issues found." : `${report.findings.length} finding(s):`,
153
+ ...report.findings.slice(0, 20).map(f => `[${f.severity.toUpperCase()}] ${f.reason}${f.file ? ` (${f.file})` : ""}`),
154
+ "↩ Back",
155
+ ];
156
+ await ctx.ui.select(`Audit: ${name}`, lines);
157
+ }
158
+
159
+ // ── Install (audit FIRST, then options) ────────────────────────────────────
160
+
161
+ async function doInstall(pkg: PackageResult, ctx: Ctx): Promise<void> {
162
+ // Audit first
163
+ ctx.ui.setStatus?.(`Auditing ${pkg.name} before install...`);
164
+ const report = await auditPackage(pkg.name, { deepScan: true });
165
+ cacheAudit(pkg.name, report);
166
+
167
+ // Show audit results
168
+ const auditLines = [
169
+ `${report.risk === "safe" ? "✅" : "🔴"} Risk: ${report.risk.toUpperCase()} (${report.findings.length} findings)`,
170
+ ...report.findings.slice(0, 10).map(f => `[${f.severity}] ${f.reason}`),
171
+ "",
172
+ report.risk === "critical" || report.risk === "high" ? "⚠️ High risk — proceed with caution" : "✅ Safe to install",
173
+ "",
174
+ ];
175
+
176
+ // Build install options
177
+ const cmds: Array<{ label: string; description: string }> = [];
178
+ const seen = new Set<string>();
179
+ for (const eco of pkg.ecosystems) {
180
+ if ((eco === "pi" || eco === "omp") && !seen.has("pi")) {
181
+ seen.add("pi");
182
+ auditLines.push("🥧 pi install");
183
+ cmds.push({ label: "🥧 pi install", description: `pi install npm:${pkg.name}` });
184
+ cmds.push({ label: "⌥ omp install", description: `omp plugin install npm:${pkg.name}` });
185
+ auditLines.push("⌥ omp install");
186
+ }
187
+ if (eco === "claude" && !seen.has("claude")) { seen.add("claude"); cmds.push({ label: "🤖 claude", description: `claude plugin install npm:${pkg.name}` }); auditLines.push("🤖 claude install"); }
188
+ if (eco === "opencode" && !seen.has("opencode")) { seen.add("opencode"); cmds.push({ label: "🔓 opencode", description: `opencode plugin ${pkg.name}` }); auditLines.push("🔓 opencode install"); }
189
+ if (eco === "gemini" && !seen.has("gemini")) { seen.add("gemini"); cmds.push({ label: "💎 gemini", description: `gemini extension install ${pkg.repository ?? pkg.name}` }); auditLines.push("💎 gemini install"); }
190
+ if (eco === "codex" && !seen.has("codex")) { seen.add("codex"); cmds.push({ label: "🔲 codex", description: `codex plugin add npm:${pkg.name}` }); auditLines.push("🔲 codex install"); }
191
+ }
192
+ if (!seen.has("npm")) { cmds.push({ label: "📦 npm", description: `npm install ${pkg.name}` }); auditLines.push("📦 npm install"); }
193
+ cmds.push({ label: "⚡ bunx", description: `bunx ${pkg.name}` });
194
+ cmds.push({ label: "↩ Cancel", description: "" });
195
+
196
+ // High risk confirmation
197
+ if (report.risk === "critical" || report.risk === "high") {
198
+ const proceed = await ctx.ui.confirm(`${report.risk} risk`, `⚠️ ${pkg.name}: ${report.findings.length} security findings. Install anyway?`);
199
+ if (!proceed) { ctx.ui.notify("Cancelled.", "info"); return; }
200
+ }
201
+
202
+ const choice = await ctx.ui.select(`Install ${pkg.name} — Audit: ${report.risk}`, cmds);
203
+ if (!choice || choice.includes("Cancel")) return;
204
+ const selected = cmds.find(c => c.label === choice);
205
+ if (!selected) return;
206
+ ctx.ui.notify(`✅ Run:\n ${selected.description}`, "info");
207
+ }
208
+
209
+ // ── Factory ────────────────────────────────────────────────────────────────
210
+
211
+ export default function zmarketplace(pi: {
212
+ registerCommand(name: string, def: { description: string; handler: (args: string, ctx: Ctx) => Promise<void> }): void;
213
+ setLabel?(label: string): void;
214
+ }) {
215
+ pi.setLabel?.("Z Marketplace");
216
+ pi.registerCommand("zmarketplace", {
217
+ description: "Search, audit, and install packages across agent ecosystems",
218
+ handler: async (rawArgs: string, ctx: Ctx) => {
219
+ const args = parseArgs(rawArgs.trim().split(/\s+/).filter(Boolean));
220
+
221
+ switch (args.subcommand) {
222
+ case "help": {
223
+ ctx.ui.notify(formatHelp(), "info");
224
+ break;
225
+ }
226
+ case "audit":
227
+ case "a": {
228
+ const ref = args.positional[0];
229
+ if (!ref) { ctx.ui.notify("Usage: /zmarketplace audit <name>", "info"); break; }
230
+ await doAudit(resolveRef(ref)?.name ?? ref, ctx);
231
+ break;
232
+ }
233
+ case "search":
234
+ case "s":
235
+ case "": {
236
+ let query = args.positional.join(" ").trim();
237
+ let limit = 50;
238
+ if (!query && ctx.hasUI) {
239
+ // Choose result limit
240
+ const limitChoice = await ctx.ui.select("Results limit (50 per page)", ["25", "50", "150", "All (paged — may lag on fetch)"]);
241
+ limit = limitChoice?.startsWith("All") ? 999999 : parseInt(limitChoice ?? "50", 10);
242
+ query = (await ctx.ui.input("🔍 Search packages", "Type search query...")) ?? "";
243
+ if (!query) return;
244
+ }
245
+ await doSearch(query, ctx, limit);
246
+ break;
247
+ }
248
+ default: {
249
+ // Treat unknown as search query
250
+ await doSearch(rawArgs.trim(), ctx);
251
+ }
252
+ }
253
+ },
254
+ });
255
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * OpenCode plugin entry point.
3
+ *
4
+ * OpenCode plugins are npm packages with `exports["./server"]` pointing here.
5
+ * They export a Plugin function that returns hooks.
6
+ *
7
+ * Install in opencode:
8
+ * opencode plugin zmarketplace
9
+ *
10
+ * Or add to opencode.json:
11
+ * { "plugin": ["zmarketplace"] }
12
+ */
13
+
14
+ import { search } from "./core/search.ts";
15
+ import { getDetail } from "./core/detail.ts";
16
+ import { auditPackage } from "./core/audit.ts";
17
+ import { installPackage } from "./core/install.ts";
18
+ import type { SearchOptions } from "./core/types.ts";
19
+
20
+ /**
21
+ * Minimal OpenCode plugin type shape.
22
+ * Not imported from @opencode-ai/plugin to keep zero deps.
23
+ */
24
+ interface OpenCodeToolContext {
25
+ sessionID?: string;
26
+ messageID?: string;
27
+ agent?: string;
28
+ directory?: string;
29
+ ask?(message: string): Promise<string>;
30
+ }
31
+
32
+ interface OpenCodePluginParams {
33
+ project?: string;
34
+ serverUrl?: string;
35
+ // Bun shell `$`
36
+ $?: unknown;
37
+ }
38
+
39
+ interface OpenCodeHooks {
40
+ "tool.definition"?: (tools: unknown[]) => unknown[];
41
+ }
42
+
43
+ type PluginFn = (params: OpenCodePluginParams) => Promise<OpenCodeHooks>;
44
+
45
+ const ZMARKETPLACE_TOOL = {
46
+ type: "function" as const,
47
+ function: {
48
+ name: "zmarketplace",
49
+ description:
50
+ "Search, inspect, audit, and install packages across agent ecosystems. " +
51
+ "Actions: search, detail, audit, install.",
52
+ parameters: {
53
+ type: "object",
54
+ properties: {
55
+ action: { type: "string", enum: ["search", "detail", "audit", "install"] },
56
+ query: { type: "string" },
57
+ name: { type: "string" },
58
+ type: { type: "string", enum: ["extension", "skill", "theme", "prompt", "plugin", "mcp", "all"] },
59
+ ecosystem: { type: "string", enum: ["pi", "omp", "claude", "opencode", "gemini", "codex", "all"] },
60
+ limit: { type: "number" },
61
+ deepScan: { type: "boolean" },
62
+ skipAudit: { type: "boolean" },
63
+ },
64
+ required: ["action"],
65
+ },
66
+ execute: async (args: Record<string, unknown>, _ctx: OpenCodeToolContext): Promise<string> => {
67
+ const action = args["action"] as string;
68
+
69
+ if (action === "search") {
70
+ const opts: SearchOptions = {
71
+ query: (args["query"] as string) ?? "",
72
+ type: (args["type"] as SearchOptions["type"]) ?? "all",
73
+ ecosystem: (args["ecosystem"] as SearchOptions["ecosystem"]) ?? "all",
74
+ limit: (args["limit"] as number) ?? 20,
75
+ };
76
+ const results = await search(opts);
77
+ return results.length === 0
78
+ ? "No packages found."
79
+ : results.map(r => `${r.name} [${r.ecosystems.filter(e => e !== "unknown").join(",")}] — ${r.description}`).join("\n");
80
+ }
81
+
82
+ if (action === "detail") {
83
+ const name = args["name"] as string;
84
+ if (!name) return "Error: 'name' required for detail.";
85
+ const detail = await getDetail(name);
86
+ if (!detail) return `Package "${name}" not found.`;
87
+ return `${detail.name} v${detail.version}\n${detail.description}\nDeps: ${detail.dependencyCount} Size: ${detail.size ? (detail.size / 1024).toFixed(1) + " KB" : "?"}\n${detail.npmUrl}`;
88
+ }
89
+
90
+ if (action === "audit") {
91
+ const name = args["name"] as string;
92
+ if (!name) return "Error: 'name' required for audit.";
93
+ const report = await auditPackage(name, { deepScan: (args["deepScan"] as boolean) ?? true });
94
+ return report.summary + "\n" + (report.findings.map(f => `[${f.severity.toUpperCase()}] ${f.reason}`).join("\n") || "No findings.");
95
+ }
96
+
97
+ if (action === "install") {
98
+ const name = args["name"] as string;
99
+ if (!name) return "Error: 'name' required for install.";
100
+ const result = await installPackage(name, { skipAudit: (args["skipAudit"] as boolean) ?? false });
101
+ return result.message;
102
+ }
103
+
104
+ return `Unknown action: ${action}`;
105
+ },
106
+ },
107
+ };
108
+
109
+ const ZmarketplacePlugin: PluginFn = async (_params: OpenCodePluginParams) => {
110
+ return {
111
+ "tool.definition": (tools: unknown[]) => {
112
+ tools.push(ZMARKETPLACE_TOOL);
113
+ return tools;
114
+ },
115
+ };
116
+ };
117
+
118
+ export default ZmarketplacePlugin;
119
+ export { ZmarketplacePlugin };
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Claude marketplace adapter — fetches plugin catalogs from GitHub raw.
3
+ * Sources: anthropics/claude-plugins-official + anthropics/claude-plugins-community.
4
+ */
5
+
6
+ import type { PackageResult, PackageType, RegistrySource } from "../core/types.ts";
7
+
8
+ const MARKETPLACE_URLS = [
9
+ "https://raw.githubusercontent.com/anthropics/claude-plugins-official/main/.claude-plugin/marketplace.json",
10
+ "https://raw.githubusercontent.com/anthropics/claude-plugins-community/main/.claude-plugin/marketplace.json",
11
+ ] as const;
12
+
13
+ interface MarketplacePlugin {
14
+ name: string;
15
+ description?: string;
16
+ source: string | { source: string; url?: string; repo?: string; ref?: string };
17
+ version?: string;
18
+ author?: { name?: string; email?: string } | string;
19
+ homepage?: string;
20
+ repository?: string | { url?: string };
21
+ license?: string;
22
+ keywords?: string[];
23
+ category?: string;
24
+ }
25
+
26
+ interface MarketplaceCatalog {
27
+ name: string;
28
+ owner?: { name?: string };
29
+ plugins: MarketplacePlugin[];
30
+ }
31
+
32
+ /** Search the Claude marketplace catalogs. */
33
+ export async function searchClaudeMarketplace(query: string, limit = 25): Promise<PackageResult[]> {
34
+ const catalogs = await Promise.allSettled(
35
+ MARKETPLACE_URLS.map(async url => {
36
+ const resp = await fetch(url, { signal: AbortSignal.timeout(10000) });
37
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
38
+ return await resp.json() as MarketplaceCatalog;
39
+ }),
40
+ );
41
+
42
+ const results: PackageResult[] = [];
43
+ const q = query.toLowerCase().trim();
44
+
45
+ for (const settled of catalogs) {
46
+ if (settled.status !== "fulfilled") continue;
47
+ for (const plugin of settled.value.plugins) {
48
+ const name = plugin.name.toLowerCase();
49
+ const desc = (plugin.description ?? "").toLowerCase();
50
+ // No query → return all; otherwise match name or description
51
+ if (q && !name.includes(q) && !desc.includes(q)) continue;
52
+
53
+ const repo = typeof plugin.repository === "string"
54
+ ? plugin.repository
55
+ : plugin.repository?.url;
56
+ const author = typeof plugin.author === "string"
57
+ ? plugin.author
58
+ : plugin.author?.name;
59
+
60
+ results.push({
61
+ name: plugin.name,
62
+ description: plugin.description ?? "",
63
+ version: plugin.version,
64
+ author,
65
+ ecosystems: ["claude"],
66
+ type: (plugin.category === "theme" ? "theme" : "plugin") as PackageType,
67
+ source: "claude-marketplace" as RegistrySource,
68
+ homepage: plugin.homepage ?? repo,
69
+ repository: repo,
70
+ license: plugin.license,
71
+ installCommand: `claude plugin install ${plugin.name}`,
72
+ });
73
+
74
+ if (results.length >= limit) break;
75
+ }
76
+ if (results.length >= limit) break;
77
+ }
78
+
79
+ return results;
80
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Gemini CLI extensions adapter — fetches the community-maintained extensions registry.
3
+ * Source: geminicli.com/extensions.json (~12,000+ extensions crawled from GitHub topics).
4
+ */
5
+
6
+ import type { PackageResult, PackageType, RegistrySource } from "../core/types.ts";
7
+
8
+ const GEMINI_REGISTRY_URL = "https://geminicli.com/extensions.json";
9
+
10
+ interface GeminiExtension {
11
+ id?: string;
12
+ url?: string;
13
+ fullName?: string;
14
+ repoDescription?: string;
15
+ stars?: number;
16
+ extensionName?: string;
17
+ extensionVersion?: string;
18
+ extensionDescription?: string;
19
+ avatarUrl?: string;
20
+ hasMCP?: boolean;
21
+ hasContext?: boolean;
22
+ hasHooks?: boolean;
23
+ hasSkills?: boolean;
24
+ hasCustomCommands?: boolean;
25
+ isGoogleOwned?: boolean;
26
+ licenseKey?: string;
27
+ lastUpdated?: string;
28
+ }
29
+
30
+ let cachedExtensions: GeminiExtension[] | null = null;
31
+ let cacheTime = 0;
32
+ const CACHE_TTL_MS = 5 * 60 * 1000;
33
+
34
+ /** Fetch the full Gemini extensions registry (cached for 5 min). */
35
+ async function getRegistry(): Promise<GeminiExtension[]> {
36
+ const now = Date.now();
37
+ if (cachedExtensions && now - cacheTime < CACHE_TTL_MS) return cachedExtensions;
38
+
39
+ try {
40
+ const resp = await fetch(GEMINI_REGISTRY_URL, { signal: AbortSignal.timeout(15000) });
41
+ if (!resp.ok) return cachedExtensions ?? [];
42
+ const data = await resp.json();
43
+ cachedExtensions = Array.isArray(data) ? data : [];
44
+ cacheTime = now;
45
+ return cachedExtensions;
46
+ } catch {
47
+ return cachedExtensions ?? [];
48
+ }
49
+ }
50
+
51
+ /** Determine the package type from Gemini extension flags. */
52
+ function geminiType(ext: GeminiExtension): PackageType {
53
+ if (ext.hasMCP) return "mcp";
54
+ if (ext.hasSkills) return "skill";
55
+ return "extension";
56
+ }
57
+
58
+ /** Search the Gemini CLI extensions registry. */
59
+ export async function searchGeminiExtensions(query: string, limit = 25): Promise<PackageResult[]> {
60
+ const extensions = await getRegistry();
61
+ const q = query.toLowerCase().trim();
62
+
63
+ const matched = q
64
+ ? extensions.filter(ext => {
65
+ const name = (ext.extensionName ?? ext.id ?? "").toLowerCase();
66
+ const desc = (ext.extensionDescription ?? ext.repoDescription ?? "").toLowerCase();
67
+ return name.includes(q) || desc.includes(q);
68
+ })
69
+ : extensions;
70
+
71
+ return matched.slice(0, limit).map(ext => {
72
+ const repoUrl = ext.url ?? `https://github.com/${ext.fullName ?? ""}`;
73
+ return {
74
+ name: ext.extensionName ?? ext.id ?? ext.fullName ?? "unknown",
75
+ description: ext.extensionDescription ?? ext.repoDescription ?? "",
76
+ version: ext.extensionVersion,
77
+ author: ext.fullName?.split("/")[0],
78
+ ecosystems: ["gemini"],
79
+ type: geminiType(ext),
80
+ source: "gemini-extensions" as RegistrySource,
81
+ homepage: repoUrl,
82
+ repository: repoUrl,
83
+ publishedAt: ext.lastUpdated,
84
+ installCommand: ext.url
85
+ ? `gemini extension install ${ext.url}`
86
+ : "gemini extension install <github-url>",
87
+ };
88
+ });
89
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Official MCP Registry adapter — queries registry.modelcontextprotocol.io.
3
+ * Free, no auth required. Returns MCP server metadata.
4
+ */
5
+
6
+ import type { PackageResult, PackageType, RegistrySource } from "../core/types.ts";
7
+
8
+ const MCP_REGISTRY_URL = "https://registry.modelcontextprotocol.io/v0.1/servers";
9
+
10
+ interface McpRegistryServer {
11
+ name?: string;
12
+ description?: string;
13
+ repository?: { url?: string; source?: string };
14
+ version_detail?: { version?: string };
15
+ authors?: Array<{ name?: string; email?: string }>;
16
+ homepage?: string;
17
+ license?: string;
18
+ }
19
+
20
+ interface McpRegistryResponse {
21
+ servers?: McpRegistryServer[];
22
+ next_cursor?: string;
23
+ }
24
+
25
+ /** Search the official MCP registry. */
26
+ export async function searchMcpRegistry(query: string, limit = 25): Promise<PackageResult[]> {
27
+ const q = query.toLowerCase().trim();
28
+ const results: PackageResult[] = [];
29
+ let cursor: string | undefined;
30
+
31
+ // Paginate until we have enough results or run out
32
+ for (let page = 0; page < 5 && results.length < limit; page++) {
33
+ const url = cursor
34
+ ? `${MCP_REGISTRY_URL}?cursor=${encodeURIComponent(cursor)}&limit=100`
35
+ : `${MCP_REGISTRY_URL}?limit=100`;
36
+
37
+ try {
38
+ const resp = await fetch(url, { signal: AbortSignal.timeout(15000) });
39
+ if (!resp.ok) break;
40
+ const data = await resp.json() as McpRegistryResponse;
41
+ const servers = data.servers ?? [];
42
+
43
+ for (const srv of servers) {
44
+ const name = srv.name ?? "unknown";
45
+ const desc = srv.description ?? "";
46
+ // Filter by query
47
+ if (q && !name.toLowerCase().includes(q) && !desc.toLowerCase().includes(q)) continue;
48
+
49
+ const repo = srv.repository?.url ?? srv.repository?.source;
50
+ const author = srv.authors?.[0]?.name;
51
+
52
+ results.push({
53
+ name,
54
+ description: desc,
55
+ version: srv.version_detail?.version,
56
+ author,
57
+ ecosystems: ["universal"],
58
+ type: "mcp" as PackageType,
59
+ source: "mcp-registry" as RegistrySource,
60
+ homepage: srv.homepage ?? repo,
61
+ repository: repo,
62
+ license: srv.license,
63
+ installCommand: `npx ${name}`,
64
+ });
65
+
66
+ if (results.length >= limit) break;
67
+ }
68
+
69
+ cursor = data.next_cursor;
70
+ if (!cursor) break;
71
+ } catch {
72
+ break;
73
+ }
74
+ }
75
+
76
+ return results;
77
+ }