Skip to content
514 linesCodeBlameRaw
1/**
2 * Plugins installed in a workspace (docs.g1t.sh/guides/customize/,
3 * "Plugins"): a plugin from a marketplace, read at a commit, whose parts
4 * are spread to where g1t's agents use them:
5 *
6 * - its skills (`skills/<name>/SKILL.md`, and `commands/*.md` as one-file
7 * skills) go into the skill library, named `plugin:skill`, and are then
8 * attached like any skill (every agent, a team, one agent);
9 * - its subagents (`agents/*.md`) become templates to hire from;
10 * - the remote MCP servers in its `.mcp.json` become connectors
11 * (connectors.ts), connected by an owner, then given to agents;
12 * - hooks, LSP servers, output styles and `bin/` are kept for Claude Code
13 * on a runner, which fetches the plugin whole at the same commit.
14 *
15 * Uninstalling takes its skills out of the library (and their
16 * attachments), its connectors with them, and keeps the row with who did
17 * it. The rules and the tables are here, tested on SQLite with a fake
18 * fetch; who may do what and the audit log are index.ts's.
19 */
20import type { FailureCode, Result } from "@g1t/contracts";
21import {
22 type FetchPlan,
23 type MarketplacePluginEntry,
24 type MarketplaceSource,
25 type McpServerConfig,
26 type PluginLayout,
27 type PluginManifest,
28 type SubagentFile,
29 fetchPlan,
30 parseMcpServers,
31 parsePluginManifest,
32 pluginLayout,
33 pluginRef,
34 subagentOf,
35} from "../../../packages/contracts/src/claude-plugin.ts";
36import { MAX_PLUGIN_INSTALLS, type PluginAgentTemplate, type PluginComponents, type PluginDetail, type PluginInstall, type PluginListing, componentsOf } from "../../../packages/contracts/src/customize.ts";
37import { newId } from "../../../packages/contracts/src/ids.ts";
38import { PLUGIN_SKILL_LIMITS, type SkillFile, checkSkillFolder, parseFrontMatter, skillFileBytes, splitFrontMatter } from "../../../packages/contracts/src/skill-format.ts";
39import { type FetchDeps, fetchPlugin } from "./customize-fetch.ts";
40import { type SkillRow, skillByName, writeVersion } from "./skill-library.ts";
41
42const ok = <T>(value: T): Result<T> => ({ ok: true, value });
43const fail = (code: FailureCode, message: string): Result<never> => ({ ok: false, error: { code, message } });
44
45export type PluginRow = {
46 id: string;
47 workspace_id: string;
48 ref: string;
49 name: string;
50 marketplace: string;
51 display_name: string | null;
52 description: string;
53 version: string | null;
54 sha: string | null;
55 fetch: string;
56 components: string;
57 agents: string;
58 problems: string;
59 enabled: number;
60 installed_by: string;
61 installed_at: string;
62 updated_at: string;
63 uninstalled_by: string | null;
64 uninstalled_at: string | null;
65};
66
67function json<T>(text: string | null, fallback: T): T {
68 if (!text) return fallback;
69 try {
70 return JSON.parse(text) as T;
71 } catch {
72 return fallback;
73 }
74}
75
76const EMPTY: PluginComponents = { skills: [], agents: [], commands: [], mcp_servers: [], hooks: false, lsp: false, bin: false, output_styles: false };
77
78export function installOut(row: PluginRow, update: PluginInstall["update"] = null): PluginInstall {
79 return {
80 id: row.id,
81 ref: row.ref,
82 name: row.name,
83 marketplace: row.marketplace,
84 display_name: row.display_name,
85 description: row.description,
86 version: row.version,
87 sha: row.sha,
88 fetch: json<FetchPlan>(row.fetch, { kind: "unsupported", why: "unknown" }),
89 components: json<PluginComponents>(row.components, EMPTY),
90 problems: json<string[]>(row.problems, []),
91 enabled: row.enabled === 1,
92 installed_by: row.installed_by,
93 installed_at: row.installed_at,
94 update,
95 };
96}
97
98/** The workspace's live installs, oldest first. */
99export async function listInstalls(db: D1Database, workspaceId: string): Promise<PluginRow[]> {
100 const { results } = await db.prepare("SELECT * FROM plugin_installs WHERE workspace_id = ? AND uninstalled_at IS NULL ORDER BY installed_at").bind(workspaceId).all<PluginRow>();
101 return results;
102}
103
104export async function installByRef(db: D1Database, workspaceId: string, ref: string): Promise<PluginRow | null> {
105 return db.prepare("SELECT * FROM plugin_installs WHERE workspace_id = ? AND ref = ? AND uninstalled_at IS NULL").bind(workspaceId, ref).first<PluginRow>();
106}
107
108// ── Reading a plugin ─────────────────────────────────────────────────────
109
110/** A plugin's files, read and understood: its manifest, layout, servers and subagents. */
111export type ReadPlugin = {
112 files: SkillFile[];
113 sha: string | null;
114 manifest: PluginManifest | null;
115 layout: PluginLayout;
116 servers: McpServerConfig[];
117 agents: SubagentFile[];
118 components: PluginComponents;
119 readme: string | null;
120 problems: string[];
121};
122
123function textOf(files: SkillFile[], path: string): string | null {
124 const file = files.find((f) => f.path === path);
125 return file && file.encoding !== "base64" ? file.content : null;
126}
127
128/** The files under a folder, re-rooted at it. */
129function folder(files: SkillFile[], dir: string): SkillFile[] {
130 if (dir === ".") return files;
131 const prefix = `${dir}/`;
132 return files.filter((f) => f.path.startsWith(prefix)).map((f) => ({ ...f, path: f.path.slice(prefix.length) }));
133}
134
135/** What a skill's front-matter says about when to use it, or null. */
136function descriptionOf(skillMd: string | null): string | null {
137 if (!skillMd) return null;
138 const split = splitFrontMatter(skillMd);
139 if (!split.ok) return null;
140 try {
141 const front = parseFrontMatter(split.yaml);
142 return typeof front.description === "string" ? front.description.replace(/\s+/g, " ").trim().slice(0, 1024) : null;
143 } catch {
144 return null;
145 }
146}
147
148/** Makes sense of a plugin's files, with the marketplace entry that listed it. */
149export function readPlugin(files: SkillFile[], sha: string | null, entry: MarketplacePluginEntry | null, omitted: string[] = []): ReadPlugin {
150 const problems = omitted.map((path) => `${path} was left out (too large to read here).`);
151 let manifest: PluginManifest | null = null;
152 const manifestText = textOf(files, ".claude-plugin/plugin.json");
153 if (manifestText) {
154 try {
155 const parsed = parsePluginManifest(JSON.parse(manifestText));
156 if (parsed.ok) manifest = parsed.value;
157 else problems.push(`plugin.json: ${parsed.message}`);
158 } catch {
159 problems.push("plugin.json isn't JSON.");
160 }
161 }
162 const layout = pluginLayout(
163 files.map((f) => f.path),
164 manifest,
165 entry,
166 );
167 // MCP servers: .mcp.json, the manifest's inline ones, or (without a manifest) the entry's.
168 let servers: McpServerConfig[] = [];
169 const mcpText = layout.mcp ? textOf(files, layout.mcp) : null;
170 if (mcpText) {
171 try {
172 const read = parseMcpServers(JSON.parse(mcpText));
173 servers = read.servers;
174 problems.push(...read.problems.map((p) => `.mcp.json: ${p}`));
175 } catch {
176 problems.push(".mcp.json isn't JSON.");
177 }
178 }
179 const inline = manifest && manifest.mcpServers && typeof manifest.mcpServers === "object" ? manifest.mcpServers : !manifest && entry?.mcpServers ? entry.mcpServers : null;
180 if (inline) {
181 const read = parseMcpServers(inline);
182 for (const server of read.servers) if (!servers.some((s) => s.name === server.name)) servers.push(server);
183 problems.push(...read.problems.map((p) => `mcpServers: ${p}`));
184 }
185 const agents: SubagentFile[] = [];
186 for (const file of layout.agents) {
187 const text = textOf(files, file.path);
188 if (!text) continue;
189 const split = splitFrontMatter(text);
190 if (!split.ok) {
191 problems.push(`${file.path}: a subagent file starts with front-matter between --- lines.`);
192 continue;
193 }
194 try {
195 const read = subagentOf(parseFrontMatter(split.yaml), split.body);
196 if (read.ok) agents.push(read.value);
197 else problems.push(`${file.path}: ${read.message}`);
198 } catch (error) {
199 problems.push(`${file.path}: its front-matter can't be read (${(error as Error).message}).`);
200 }
201 }
202 const skillDescriptions: Record<string, string | null> = {};
203 for (const skill of layout.skills) skillDescriptions[skill.path] = descriptionOf(textOf(files, skill.path === "." ? "SKILL.md" : `${skill.path}/SKILL.md`));
204 const agentDescriptions: Record<string, string | null> = {};
205 for (const file of layout.agents) agentDescriptions[file.path] = agents.find((a) => a.name === file.name)?.description ?? null;
206 return {
207 files,
208 sha,
209 manifest,
210 layout,
211 servers,
212 agents,
213 components: componentsOf(layout, servers, skillDescriptions, agentDescriptions),
214 readme: layout.readme ? textOf(files, layout.readme) : null,
215 problems,
216 };
217}
218
219/**
220 * A skill's folder cut to what one version holds: its biggest binary files
221 * go first, then its biggest other files besides SKILL.md, until it fits
222 * `PLUGIN_SKILL_LIMITS`. What was left out is named, for the install's
223 * problems and the version's note.
224 */
225export function fitSkillFolder(files: SkillFile[]): { files: SkillFile[]; omitted: string[] } {
226 let total = files.reduce((n, f) => n + skillFileBytes(f), 0);
227 if (files.length <= PLUGIN_SKILL_LIMITS.files && total <= PLUGIN_SKILL_LIMITS.bytes) return { files, omitted: [] };
228 const keep = new Map(files.map((f) => [f.path, f]));
229 const order = [...files]
230 .filter((f) => f.path.toLowerCase() !== "skill.md")
231 .sort((a, b) => Number(b.encoding === "base64") - Number(a.encoding === "base64") || skillFileBytes(b) - skillFileBytes(a));
232 const omitted: string[] = [];
233 for (const file of order) {
234 if (keep.size <= PLUGIN_SKILL_LIMITS.files && total <= PLUGIN_SKILL_LIMITS.bytes) break;
235 keep.delete(file.path);
236 total -= skillFileBytes(file);
237 omitted.push(file.path);
238 }
239 return { files: [...keep.values()], omitted: omitted.sort() };
240}
241
242/** A command file (`commands/<name>.md`) as a one-file skill: its front-matter's description, or its first line. */
243function commandAsSkill(name: string, text: string): SkillFile[] {
244 const split = splitFrontMatter(text);
245 let body = text;
246 let description = "";
247 if (split.ok) {
248 body = split.body;
249 try {
250 const front = parseFrontMatter(split.yaml);
251 description = typeof front.description === "string" ? front.description : "";
252 } catch {
253 // Kept as the body alone.
254 }
255 }
256 const first = body.split("\n").find((line) => line.trim())?.replace(/^#+\s*/, "").trim() ?? "";
257 const when = (description || first || `Use when asked to run ${name}.`).replace(/\s+/g, " ").slice(0, 1024);
258 return [{ path: "SKILL.md", content: `---\nname: ${name}\ndescription: ${JSON.stringify(when)}\n---\n\n${body.trim()}\n`, encoding: "utf8" }];
259}
260
261// ── Installing ───────────────────────────────────────────────────────────
262
263/**
264 * Spreads a plugin's skills into the library under its name: `skills/<name>`
265 * folders and `commands/*.md` files, each a version of the skill of its
266 * name (a new version when it is there already, moving its attachments),
267 * cut to what a version holds. Skills the plugin no longer has are
268 * archived. What couldn't be written is named.
269 */
270async function spreadSkills(db: D1Database, workspaceId: string, ref: { ref: string; marketplace: string }, plugin: ReadPlugin, by: string, now: Date): Promise<{ skills: Installed["skills"]; problems: string[]; archived: string[] }> {
271 const skills: Installed["skills"] = [];
272 const problems: string[] = [];
273 const folders: { name: string; path: string; files: SkillFile[] }[] = [
274 ...plugin.layout.skills.map((s) => ({ name: s.name, path: s.path, files: folder(plugin.files, s.path) })),
275 ...plugin.layout.commands.map((c) => ({ name: c.name, path: c.path, files: commandAsSkill(c.name, textOf(plugin.files, c.path) ?? "") })),
276 ];
277 const seen = new Set<string>();
278 for (const one of folders) {
279 const fitted = fitSkillFolder(one.files);
280 if (fitted.omitted.length) problems.push(`${one.path}: ${fitted.omitted.length} ${fitted.omitted.length === 1 ? "file" : "files"} left out to fit (${fitted.omitted.slice(0, 5).join(", ")}${fitted.omitted.length > 5 ? "…" : ""}); Claude Code on a runner gets the whole plugin.`);
281 const checked = checkSkillFolder(fitted.files, { expectName: one.name, lenient: true, limits: PLUGIN_SKILL_LIMITS });
282 if (!checked.ok) {
283 problems.push(`${one.path}: ${checked.message}`);
284 skills.push({ name: one.name, id: "", problem: checked.message });
285 continue;
286 }
287 if (seen.has(checked.skill.name.toLowerCase())) {
288 problems.push(`${one.path}: the plugin has two skills called ${checked.skill.name}.`);
289 continue;
290 }
291 seen.add(checked.skill.name.toLowerCase());
292 const existing = await skillByName(db, workspaceId, checked.skill.name, ref.ref);
293 const written = await writeVersion(db, {
294 workspaceId: workspaceId,
295 existing,
296 checked: checked.skill,
297 origin: { kind: "plugin", plugin: ref.ref, marketplace: ref.marketplace, path: one.path, sha: plugin.sha },
298 note: `From ${ref.ref}${plugin.sha ? ` at ${plugin.sha.slice(0, 8)}` : ""}${fitted.omitted.length ? ` (${fitted.omitted.length} large files left out)` : ""}`,
299 by: by,
300 now,
301 status: "published",
302 mirrored: false,
303 plugin: ref.ref,
304 move: () => true,
305 });
306 if (!written.ok) {
307 problems.push(`${one.path}: ${written.error.message}`);
308 skills.push({ name: checked.skill.name, id: "", problem: written.error.message });
309 } else skills.push({ name: checked.skill.name, id: written.value.id, problem: null });
310 }
311 // Gone from the plugin: archived, with their attachments.
312 const { results } = await db.prepare("SELECT id, name FROM skills WHERE workspace_id = ? AND plugin = ? AND archived_at IS NULL").bind(workspaceId, ref.ref).all<{ id: string; name: string }>();
313 const gone = results.filter((s) => !seen.has(s.name.toLowerCase()));
314 if (gone.length) {
315 const at = now.toISOString();
316 await db.batch([
317 db.prepare("UPDATE skills SET archived_at = ?, updated_by = ?, updated_at = ? WHERE id IN (SELECT value FROM json_each(?))").bind(at, by, at, JSON.stringify(gone.map((s) => s.id))),
318 db.prepare("DELETE FROM skill_attachments WHERE skill_id IN (SELECT value FROM json_each(?))").bind(JSON.stringify(gone.map((s) => s.id))),
319 ]);
320 }
321 return { skills, problems, archived: gone.map((s) => s.name) };
322}
323
324
325export type InstallInput = {
326 workspaceId: string;
327 listing: PluginListing;
328 entry: MarketplacePluginEntry;
329 marketplace: { name: string; source: MarketplaceSource; pluginRoot: string | null };
330 host: string | null;
331 by: string;
332 deps: FetchDeps;
333 now?: Date;
334};
335
336/** What installing wrote: the install, and the plugin as read, for connectors to be made from. */
337export type Installed = { install: PluginInstall; plugin: ReadPlugin; skills: { name: string; id: string; problem: string | null }[] };
338
339/**
340 * Installs a plugin: fetches it, writes its skills into the library under
341 * its name, keeps its subagents, and records what it brought. Connectors
342 * for its servers are made by the caller (connectors.ts), which holds the
343 * sealing key. A conflict when it is installed already.
344 */
345export async function install(db: D1Database, input: InstallInput): Promise<Result<Installed>> {
346 const now = input.now ?? new Date();
347 const { listing } = input;
348 if (listing.availability.kind === "installed") return fail("conflict", `${listing.name} is installed already.`);
349 if (listing.availability.kind === "soon") return fail("invalid", `${listing.name} isn't released yet, so it can't be installed.`);
350 if (listing.availability.kind === "unavailable") return fail("invalid", listing.availability.why);
351 const count = await db.prepare("SELECT COUNT(*) AS n FROM plugin_installs WHERE workspace_id = ? AND uninstalled_at IS NULL").bind(input.workspaceId).first<{ n: number }>();
352 if ((count?.n ?? 0) >= MAX_PLUGIN_INSTALLS) return fail("limit", `A workspace installs at most ${MAX_PLUGIN_INSTALLS} plugins.`);
353 const plan = fetchPlan(input.entry.source, input.marketplace.source, { pluginRoot: input.marketplace.pluginRoot, host: input.host });
354 const fetched = await fetchPlugin(plan, input.deps);
355 if (!fetched.ok) return fail("invalid", fetched.message);
356 const plugin = readPlugin(fetched.files, fetched.sha, input.entry, fetched.omitted);
357 const at = now.toISOString();
358 const row: PluginRow = {
359 id: newId("plg", now.getTime()),
360 workspace_id: input.workspaceId,
361 ref: listing.ref,
362 name: listing.name,
363 marketplace: listing.marketplace,
364 display_name: plugin.manifest?.displayName ?? listing.display_name,
365 description: plugin.manifest?.description || listing.description,
366 version: plugin.manifest?.version ?? listing.version,
367 sha: plugin.sha,
368 fetch: JSON.stringify(plan.kind === "github" && plugin.sha ? { ...plan, sha: plugin.sha } : plan),
369 components: JSON.stringify(plugin.components),
370 agents: JSON.stringify(plugin.agents),
371 problems: "[]",
372 enabled: 1,
373 installed_by: input.by,
374 installed_at: at,
375 updated_at: at,
376 uninstalled_by: null,
377 uninstalled_at: null,
378 };
379 try {
380 await db
381 .prepare(
382 `INSERT INTO plugin_installs (id, workspace_id, ref, name, marketplace, display_name, description, version, sha, fetch, components, agents, problems, enabled, installed_by, installed_at, updated_at)
383 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`,
384 )
385 .bind(row.id, row.workspace_id, row.ref, row.name, row.marketplace, row.display_name, row.description, row.version, row.sha, row.fetch, row.components, row.agents, row.problems, row.installed_by, row.installed_at, row.updated_at)
386 .run();
387 } catch (error) {
388 if (String(error).includes("UNIQUE")) return fail("conflict", `${listing.name} is installed already.`);
389 throw error;
390 }
391 // Its skills, into the library under the plugin's name.
392 const spread = await spreadSkills(db, input.workspaceId, listing, plugin, input.by, now);
393 const { skills } = spread;
394 const problems = [...plugin.problems, ...spread.problems];
395 if (problems.length) await db.prepare("UPDATE plugin_installs SET problems = ? WHERE id = ?").bind(JSON.stringify(problems.slice(0, 50)), row.id).run();
396 return ok({ install: installOut({ ...row, problems: JSON.stringify(problems.slice(0, 50)) }), plugin, skills });
397}
398
399/**
400 * Reads an installed plugin again from its marketplace's current listing
401 * and brings the install to it: its skills get new versions (attachments
402 * follow), skills it no longer has are archived, its subagents and what it
403 * brought are rewritten, and the install's commit and version move. New
404 * remote servers are the caller's (connectors.ts). Nothing changes when
405 * the plugin is at the same commit already.
406 */
407export async function update(db: D1Database, input: InstallInput): Promise<Result<Installed & { unchanged: boolean }>> {
408 const now = input.now ?? new Date();
409 const { listing } = input;
410 const row = await installByRef(db, input.workspaceId, listing.ref);
411 if (!row) return fail("not_found", "That plugin isn't installed.");
412 const plan = fetchPlan(input.entry.source, input.marketplace.source, { pluginRoot: input.marketplace.pluginRoot, host: input.host });
413 if (plan.kind === "unsupported") return fail("invalid", plan.why);
414 if (plan.kind === "github" && plan.sha && plan.sha === row.sha) return ok({ install: installOut(row), plugin: readPlugin([], row.sha, input.entry), skills: [], unchanged: true });
415 const fetched = await fetchPlugin(plan, input.deps);
416 if (!fetched.ok) return fail("invalid", fetched.message);
417 if (fetched.sha && fetched.sha === row.sha) return ok({ install: installOut(row), plugin: readPlugin(fetched.files, fetched.sha, input.entry, fetched.omitted), skills: [], unchanged: true });
418 const plugin = readPlugin(fetched.files, fetched.sha, input.entry, fetched.omitted);
419 const spread = await spreadSkills(db, input.workspaceId, listing, plugin, input.by, now);
420 const problems = [...plugin.problems, ...spread.problems].slice(0, 50);
421 const at = now.toISOString();
422 const fresh: PluginRow = {
423 ...row,
424 display_name: plugin.manifest?.displayName ?? listing.display_name,
425 description: plugin.manifest?.description || listing.description,
426 version: plugin.manifest?.version ?? listing.version,
427 sha: plugin.sha,
428 fetch: JSON.stringify(plan.kind === "github" && plugin.sha ? { ...plan, sha: plugin.sha } : plan),
429 components: JSON.stringify(plugin.components),
430 agents: JSON.stringify(plugin.agents),
431 problems: JSON.stringify(problems),
432 updated_at: at,
433 };
434 await db
435 .prepare("UPDATE plugin_installs SET display_name = ?, description = ?, version = ?, sha = ?, fetch = ?, components = ?, agents = ?, problems = ?, updated_at = ? WHERE id = ?")
436 .bind(fresh.display_name, fresh.description, fresh.version, fresh.sha, fresh.fetch, fresh.components, fresh.agents, fresh.problems, fresh.updated_at, row.id)
437 .run();
438 return ok({ install: installOut(fresh), plugin, skills: spread.skills, unchanged: false });
439}
440
441/** Switches an install on or off: off, its skills leave agents' prompts, its subagents leave the templates, and its connectors stop reaching agents (connectors.ts `reaching`): the kill switch. */
442export async function setEnabled(db: D1Database, workspaceId: string, ref: string, enabled: boolean, now = new Date()): Promise<Result<PluginInstall>> {
443 const row = await installByRef(db, workspaceId, ref);
444 if (!row) return fail("not_found", "That plugin isn't installed.");
445 const at = now.toISOString();
446 await db.batch([
447 db.prepare("UPDATE plugin_installs SET enabled = ?, updated_at = ? WHERE id = ?").bind(enabled ? 1 : 0, at, row.id),
448 // Its skills follow: a draft can't be attached, which is how a switched-off plugin's skills stay out of prompts.
449 db.prepare("UPDATE skills SET status = ? WHERE workspace_id = ? AND plugin = ? AND archived_at IS NULL").bind(enabled ? "published" : "draft", workspaceId, ref),
450 ]);
451 return ok(installOut({ ...row, enabled: enabled ? 1 : 0, updated_at: at }));
452}
453
454/** Removes an install: its skills are archived with their attachments, and the row kept. Connectors are the caller's. */
455export async function uninstall(db: D1Database, workspaceId: string, ref: string, by: string, now = new Date()): Promise<Result<{ skills: string[] }>> {
456 const row = await installByRef(db, workspaceId, ref);
457 if (!row) return fail("not_found", "That plugin isn't installed.");
458 const at = now.toISOString();
459 const { results } = await db.prepare("SELECT id, name FROM skills WHERE workspace_id = ? AND plugin = ? AND archived_at IS NULL").bind(workspaceId, ref).all<{ id: string; name: string }>();
460 const statements = [
461 db.prepare("UPDATE plugin_installs SET uninstalled_by = ?, uninstalled_at = ?, updated_at = ? WHERE id = ?").bind(by, at, at, row.id),
462 db.prepare("UPDATE skills SET archived_at = ?, updated_by = ?, updated_at = ? WHERE workspace_id = ? AND plugin = ? AND archived_at IS NULL").bind(at, by, at, workspaceId, ref),
463 ...(results.length ? [db.prepare(`DELETE FROM skill_attachments WHERE skill_id IN (SELECT value FROM json_each(?))`).bind(JSON.stringify(results.map((s) => s.id)))] : []),
464 ];
465 await db.batch(statements);
466 return ok({ skills: results.map((s) => s.name) });
467}
468
469/** The subagents installed plugins bring, as templates to hire from. */
470export async function pluginAgents(db: D1Database, workspaceId: string): Promise<PluginAgentTemplate[]> {
471 const out: PluginAgentTemplate[] = [];
472 for (const row of await listInstalls(db, workspaceId)) {
473 if (row.enabled !== 1) continue;
474 for (const agent of json<SubagentFile[]>(row.agents, [])) {
475 out.push({ ref: `${row.name}:${agent.name}`, plugin: row.name, marketplace: row.marketplace, name: agent.name, description: agent.description, prompt: agent.prompt, model: agent.model, skills: agent.skills, tools: agent.tools });
476 }
477 }
478 return out;
479}
480
481/** The installs as their page lists them, each with whether its marketplace now lists a newer version. */
482export function installsOut(rows: PluginRow[], listings: PluginListing[]): PluginInstall[] {
483 return rows.map((row) => {
484 const listing = listings.find((l) => l.ref === row.ref);
485 const newerVersion = listing?.version && row.version && listing.version !== row.version;
486 const newerSha = listing?.fetch.kind === "github" && listing.fetch.sha && row.sha && listing.fetch.sha !== row.sha;
487 return installOut(row, listing && (newerVersion || newerSha) ? { version: listing.version, sha: listing.fetch.kind === "github" ? listing.fetch.sha : null } : null);
488 });
489}
490
491/** A plugin's page: fetched fresh when it isn't installed (so Discover can show what it holds), from the install otherwise. */
492export async function detail(db: D1Database, workspaceId: string, listing: PluginListing, entry: MarketplacePluginEntry | null, marketplace: { source: MarketplaceSource; pluginRoot: string | null; owner: string | null }, host: string | null, deps: FetchDeps): Promise<PluginDetail> {
493 const row = await installByRef(db, workspaceId, listing.ref);
494 const install = row ? installOut(row) : null;
495 let read: ReadPlugin | null = null;
496 if (listing.availability.kind !== "unavailable" && listing.availability.kind !== "soon") {
497 const plan = fetchPlan(entry?.source ?? { source: "path", path: "./" }, marketplace.source, { pluginRoot: marketplace.pluginRoot, host });
498 const fetched = await fetchPlugin(row ? json<FetchPlan>(row.fetch, plan) : plan, deps);
499 if (fetched.ok) read = readPlugin(fetched.files, fetched.sha, entry, fetched.omitted);
500 }
501 return {
502 listing,
503 install,
504 components: read?.components ?? install?.components ?? null,
505 readme: read?.readme ?? null,
506 manifest: read?.manifest ? { name: read.manifest.name, version: read.manifest.version, license: read.manifest.license, repository: read.manifest.repository } : null,
507 agents: read?.agents ?? (row ? json<SubagentFile[]>(row.agents, []) : []),
508 problems: read?.problems ?? install?.problems ?? [],
509 source: { marketplace: listing.marketplace, owner: marketplace.owner },
510 };
511}
512
513export { pluginRef };
514export type { SkillRow };