Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
447 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
263export type InstallInput = {
264 workspaceId: string;
265 listing: PluginListing;
266 entry: MarketplacePluginEntry;
267 marketplace: { name: string; source: MarketplaceSource; pluginRoot: string | null };
268 host: string | null;
269 by: string;
270 deps: FetchDeps;
271 now?: Date;
272};
273
274/** What installing wrote: the install, and the plugin as read, for connectors to be made from. */
275export type Installed = { install: PluginInstall; plugin: ReadPlugin; skills: { name: string; id: string; problem: string | null }[] };
276
277/**
278 * Installs a plugin: fetches it, writes its skills into the library under
279 * its name, keeps its subagents, and records what it brought. Connectors
280 * for its servers are made by the caller (connectors.ts), which holds the
281 * sealing key. A conflict when it is installed already.
282 */
283export async function install(db: D1Database, input: InstallInput): Promise<Result<Installed>> {
284 const now = input.now ?? new Date();
285 const { listing } = input;
286 if (listing.availability.kind === "installed") return fail("conflict", `${listing.name} is installed already.`);
287 if (listing.availability.kind === "soon") return fail("invalid", `${listing.name} isn't released yet, so it can't be installed.`);
288 if (listing.availability.kind === "unavailable") return fail("invalid", listing.availability.why);
289 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 }>();
290 if ((count?.n ?? 0) >= MAX_PLUGIN_INSTALLS) return fail("limit", `A workspace installs at most ${MAX_PLUGIN_INSTALLS} plugins.`);
291 const plan = fetchPlan(input.entry.source, input.marketplace.source, { pluginRoot: input.marketplace.pluginRoot, host: input.host });
292 const fetched = await fetchPlugin(plan, input.deps);
293 if (!fetched.ok) return fail("invalid", fetched.message);
294 const plugin = readPlugin(fetched.files, fetched.sha, input.entry, fetched.omitted);
295 const at = now.toISOString();
296 const row: PluginRow = {
297 id: newId("plg", now.getTime()),
298 workspace_id: input.workspaceId,
299 ref: listing.ref,
300 name: listing.name,
301 marketplace: listing.marketplace,
302 display_name: plugin.manifest?.displayName ?? listing.display_name,
303 description: plugin.manifest?.description || listing.description,
304 version: plugin.manifest?.version ?? listing.version,
305 sha: plugin.sha,
306 fetch: JSON.stringify(plan.kind === "github" && plugin.sha ? { ...plan, sha: plugin.sha } : plan),
307 components: JSON.stringify(plugin.components),
308 agents: JSON.stringify(plugin.agents),
309 problems: "[]",
310 enabled: 1,
311 installed_by: input.by,
312 installed_at: at,
313 updated_at: at,
314 uninstalled_by: null,
315 uninstalled_at: null,
316 };
317 try {
318 await db
319 .prepare(
320 `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)
321 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`,
322 )
323 .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)
324 .run();
325 } catch (error) {
326 if (String(error).includes("UNIQUE")) return fail("conflict", `${listing.name} is installed already.`);
327 throw error;
328 }
329 // Its skills, into the library under the plugin's name.
330 const skills: Installed["skills"] = [];
331 const problems = [...plugin.problems];
332 const folders: { name: string; path: string; files: SkillFile[] }[] = [
333 ...plugin.layout.skills.map((s) => ({ name: s.name, path: s.path, files: folder(plugin.files, s.path) })),
334 ...plugin.layout.commands.map((c) => ({ name: c.name, path: c.path, files: commandAsSkill(c.name, textOf(plugin.files, c.path) ?? "") })),
335 ];
336 const seen = new Set<string>();
337 for (const one of folders) {
338 const fitted = fitSkillFolder(one.files);
339 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.`);
340 const checked = checkSkillFolder(fitted.files, { expectName: one.name, lenient: true, limits: PLUGIN_SKILL_LIMITS });
341 if (!checked.ok) {
342 problems.push(`${one.path}: ${checked.message}`);
343 skills.push({ name: one.name, id: "", problem: checked.message });
344 continue;
345 }
346 if (seen.has(checked.skill.name.toLowerCase())) {
347 problems.push(`${one.path}: the plugin has two skills called ${checked.skill.name}.`);
348 continue;
349 }
350 seen.add(checked.skill.name.toLowerCase());
351 const existing = await skillByName(db, input.workspaceId, checked.skill.name, listing.ref);
352 const written = await writeVersion(db, {
353 workspaceId: input.workspaceId,
354 existing,
355 checked: checked.skill,
356 origin: { kind: "plugin", plugin: listing.ref, marketplace: listing.marketplace, path: one.path, sha: plugin.sha },
357 note: `From ${listing.ref}${plugin.sha ? ` at ${plugin.sha.slice(0, 8)}` : ""}${fitted.omitted.length ? ` (${fitted.omitted.length} large files left out)` : ""}`,
358 by: input.by,
359 now,
360 status: "published",
361 mirrored: false,
362 plugin: listing.ref,
363 move: () => true,
364 });
365 if (!written.ok) {
366 problems.push(`${one.path}: ${written.error.message}`);
367 skills.push({ name: checked.skill.name, id: "", problem: written.error.message });
368 } else skills.push({ name: checked.skill.name, id: written.value.id, problem: null });
369 }
370 if (problems.length) await db.prepare("UPDATE plugin_installs SET problems = ? WHERE id = ?").bind(JSON.stringify(problems.slice(0, 50)), row.id).run();
371 return ok({ install: installOut({ ...row, problems: JSON.stringify(problems.slice(0, 50)) }), plugin, skills });
372}
373
374/** Switches an install on or off: off, its skills leave agents' prompts and its connectors stop (the kill switch). */
375export async function setEnabled(db: D1Database, workspaceId: string, ref: string, enabled: boolean, now = new Date()): Promise<Result<PluginInstall>> {
376 const row = await installByRef(db, workspaceId, ref);
377 if (!row) return fail("not_found", "That plugin isn't installed.");
378 const at = now.toISOString();
379 await db.batch([
380 db.prepare("UPDATE plugin_installs SET enabled = ?, updated_at = ? WHERE id = ?").bind(enabled ? 1 : 0, at, row.id),
381 // Its skills follow: a draft can't be attached, which is how a switched-off plugin's skills stay out of prompts.
382 db.prepare("UPDATE skills SET status = ? WHERE workspace_id = ? AND plugin = ? AND archived_at IS NULL").bind(enabled ? "published" : "draft", workspaceId, ref),
383 ]);
384 return ok(installOut({ ...row, enabled: enabled ? 1 : 0, updated_at: at }));
385}
386
387/** Removes an install: its skills are archived with their attachments, and the row kept. Connectors are the caller's. */
388export async function uninstall(db: D1Database, workspaceId: string, ref: string, by: string, now = new Date()): Promise<Result<{ skills: string[] }>> {
389 const row = await installByRef(db, workspaceId, ref);
390 if (!row) return fail("not_found", "That plugin isn't installed.");
391 const at = now.toISOString();
392 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 }>();
393 const statements = [
394 db.prepare("UPDATE plugin_installs SET uninstalled_by = ?, uninstalled_at = ?, updated_at = ? WHERE id = ?").bind(by, at, at, row.id),
395 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),
396 ...(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)))] : []),
397 ];
398 await db.batch(statements);
399 return ok({ skills: results.map((s) => s.name) });
400}
401
402/** The subagents installed plugins bring, as templates to hire from. */
403export async function pluginAgents(db: D1Database, workspaceId: string): Promise<PluginAgentTemplate[]> {
404 const out: PluginAgentTemplate[] = [];
405 for (const row of await listInstalls(db, workspaceId)) {
406 if (row.enabled !== 1) continue;
407 for (const agent of json<SubagentFile[]>(row.agents, [])) {
408 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 });
409 }
410 }
411 return out;
412}
413
414/** The installs as their page lists them, each with whether its marketplace now lists a newer version. */
415export function installsOut(rows: PluginRow[], listings: PluginListing[]): PluginInstall[] {
416 return rows.map((row) => {
417 const listing = listings.find((l) => l.ref === row.ref);
418 const newerVersion = listing?.version && row.version && listing.version !== row.version;
419 const newerSha = listing?.fetch.kind === "github" && listing.fetch.sha && row.sha && listing.fetch.sha !== row.sha;
420 return installOut(row, listing && (newerVersion || newerSha) ? { version: listing.version, sha: listing.fetch.kind === "github" ? listing.fetch.sha : null } : null);
421 });
422}
423
424/** A plugin's page: fetched fresh when it isn't installed (so Discover can show what it holds), from the install otherwise. */
425export 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> {
426 const row = await installByRef(db, workspaceId, listing.ref);
427 const install = row ? installOut(row) : null;
428 let read: ReadPlugin | null = null;
429 if (listing.availability.kind !== "unavailable" && listing.availability.kind !== "soon") {
430 const plan = fetchPlan(entry?.source ?? { source: "path", path: "./" }, marketplace.source, { pluginRoot: marketplace.pluginRoot, host });
431 const fetched = await fetchPlugin(row ? json<FetchPlan>(row.fetch, plan) : plan, deps);
432 if (fetched.ok) read = readPlugin(fetched.files, fetched.sha, entry, fetched.omitted);
433 }
434 return {
435 listing,
436 install,
437 components: read?.components ?? install?.components ?? null,
438 readme: read?.readme ?? null,
439 manifest: read?.manifest ? { name: read.manifest.name, version: read.manifest.version, license: read.manifest.license, repository: read.manifest.repository } : null,
440 agents: read?.agents ?? (row ? json<SubagentFile[]>(row.agents, []) : []),
441 problems: read?.problems ?? install?.problems ?? [],
442 source: { marketplace: listing.marketplace, owner: marketplace.owner },
443 };
444}
445
446export { pluginRef };
447export type { SkillRow };