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