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

Commit

An installed plugin updates in place (update_plugin): read again at its marketplace's current commit, its skills get new versions and their attachments follow, skills it no longer has are archived, its agents and what it brought are rewritten, and a server it already brought keeps its connector. Switching a plugin off now also stops the connectors it brought from reaching agents, as the kill switch says. g1t's own announced plugins are listed in Discover under the g1t marketplace, as Soon. The Customize guide says so.

syntaqxcommitted Parentfb5b719Browse files
8 files+258−550/8 viewed
+6−4
9191
9292 When a plugin is **switched off**, its skills leave every agent that had
9393 them at once, and come back when it is switched on. When it is
94−**uninstalled**, its skills and their attachments go.
94+**uninstalled**, its skills and their attachments go. **Update** reads the
95+plugin again at its marketplace's current listing: each skill gets a new
96+version, and every attachment follows it.
9597
9698 ## Connectors
9799
318320
319321 | | |
320322 | --- | --- |
321−| **Switch off** | The kill switch. The plugin's skills leave every agent at once, its agents leave Templates, and runners stop getting it. **Switch on** brings them back. |
323+| **Switch off** | The kill switch. The plugin's skills leave every agent at once, its agents leave Templates, the connectors it brought stop reaching agents, and runners stop getting it. **Switch on** brings them all back. |
322324 | **Uninstall** | Its skills and their attachments go, and the connectors it brought are removed. The record of who installed and removed it stays. |
323−| **Update available** | Its marketplace lists a newer version or commit than the one installed. An install never moves on its own: to take the update, uninstall the plugin and install it again, then make its skills available again. |
325+| **Update** | Shown as **Update available** when its marketplace lists a newer version or commit than the one installed. An install never moves on its own. **Update** reads the plugin again at that commit: its skills get new versions and their attachments follow, skills it no longer has are archived, its agents and connectors are refreshed (a connector it already brought is left as it is), and the install's version and commit move. |
324326
325−A workspace installs at most 100 plugins. Installing, switching on and
327+A workspace installs at most 100 plugins. Installing, updating, switching on and
326328 off and uninstalling are in the audit log under
327329 `customize/plugins/<name@marketplace>`, and adding or removing a
328330 marketplace under `customize/marketplaces/<name>`.
+3−0
10541054 installPlugin(workspace: string, viewer: User, ref: string): Promise<Result<PluginInstall>>;
10551055 setPluginEnabled(workspace: string, viewer: User, ref: string, enabled: boolean): Promise<Result<PluginInstall>>;
10561056 uninstallPlugin(workspace: string, viewer: User, ref: string): Promise<Result<null>>;
1057+ /** Reads an installed plugin again at its marketplace's current listing: new skill versions, subagents and servers; attachments follow. */
1058+ updatePlugin(workspace: string, viewer: User, ref: string): Promise<Result<PluginInstall>>;
10571059 pluginAgents(workspace: string, viewer: User): Promise<Result<PluginAgentTemplate[]>>;
10581060 connectors(workspace: string, viewer: User): Promise<Result<WorkspaceConnector[]>>;
10591061 connector(workspace: string, viewer: User, id: string): Promise<Result<WorkspaceConnector>>;
11631165 installPlugin: (workspace, viewer, ref) => call("install_plugin", { workspace, viewer, ref }),
11641166 setPluginEnabled: (workspace, viewer, ref, enabled) => call("set_plugin_enabled", { workspace, viewer, ref, enabled }),
11651167 uninstallPlugin: (workspace, viewer, ref) => call("uninstall_plugin", { workspace, viewer, ref }),
1168+ updatePlugin: (workspace, viewer, ref) => call("update_plugin", { workspace, viewer, ref }),
11661169 pluginAgents: (workspace, viewer) => call("plugin_agents", { workspace, viewer }),
11671170 connectors: (workspace, viewer) => call("connectors", { workspace, viewer }),
11681171 connector: (workspace, viewer, id) => call("connector", { workspace, viewer, id }),
+20−0
198198 assert.deepEqual((await connectors.reaching(db, ws, "agt_1", ["qa"])).map((r) => [r.row.id, r.via]), [[b.value.id, "team"]]);
199199 assert.equal((await db.prepare("SELECT secrets FROM connectors WHERE id = ?").bind(a.value.id).first<{ secrets: string | null }>())!.secrets, null);
200200 });
201+
202+test("a switched-off plugin's connectors stop reaching agents; switched on, they are back", async () => {
203+ const db = fakeD1();
204+ const { fetchFn } = fakeServer();
205+ const deps: connectors.Deps = { key: await connectors.sealingKey(KEY), fetchFn, site: "https://g1t.sh", now: at };
206+ await db
207+ .prepare("INSERT INTO plugin_installs (id, workspace_id, ref, name, marketplace, description, fetch, installed_by, installed_at, updated_at, enabled) VALUES ('plg_1', ?, 'release@acme', 'release', 'acme', '', '{}', 'chase', ?, ?, 1)")
208+ .bind(ws, at.toISOString(), at.toISOString())
209+ .run();
210+ const fromPlugin = (await connectors.addConnector(db, deps, ws, { from: { kind: "plugin", ref: "release@acme", server: "tracker" }, auth: "header", header: { name: "X-Key", value: "secret-key" } }, "chase", { title: "Release: tracker", url: "https://mcp.example.com/mcp" })) as { ok: true; value: connectors.ConnectorRow };
211+ const byHand = (await connectors.addConnector(db, deps, ws, { from: { kind: "address", name: "mine", url: "https://mcp.example.com/mcp" }, auth: "header", header: { name: "X-Key", value: "secret-key" } }, "chase")) as { ok: true; value: connectors.ConnectorRow };
212+ assert.ok(fromPlugin.ok && byHand.ok);
213+ await connectors.grant(db, deps, ws, fromPlugin.value.id, "workspace", "", "chase");
214+ await connectors.grant(db, deps, ws, byHand.value.id, "workspace", "", "chase");
215+ assert.deepEqual((await connectors.reaching(db, ws, "agt_1", [])).map((r) => r.row.name).sort(), ["mine", "tracker"]);
216+ await db.prepare("UPDATE plugin_installs SET enabled = 0 WHERE id = 'plg_1'").run();
217+ assert.deepEqual((await connectors.reaching(db, ws, "agt_1", [])).map((r) => r.row.name), ["mine"]);
218+ await db.prepare("UPDATE plugin_installs SET enabled = 1 WHERE id = 'plg_1'").run();
219+ assert.equal((await connectors.reaching(db, ws, "agt_1", [])).length, 2);
220+});
+3−1
624624 return done.meta.changes ? ok(null) : fail("not_found", "There is no such grant.");
625625 }
626626
627−/** The connectors that reach one agent, with how each reaches it (the closest grant wins), connected or not. */
627+/** The connectors that reach one agent, with how each reaches it (the closest grant wins), connected or not. A switched-off plugin's connectors don't reach anyone. */
628628 export async function reaching(db: D1Database, workspaceId: string, agentId: string, teams: readonly string[]): Promise<{ row: ConnectorRow; via: SkillScope }[]> {
629629 const { results } = await db
630630 .prepare(
631631 `SELECT c.*, g.scope AS via FROM connector_grants g
632632 JOIN connectors c ON c.id = g.connector_id AND c.removed_at IS NULL
633+ LEFT JOIN plugin_installs p ON p.workspace_id = c.workspace_id AND p.ref = json_extract(c.from_json, '$.ref') AND p.uninstalled_at IS NULL
633634 WHERE g.workspace_id = ?1
635+ AND (json_extract(c.from_json, '$.kind') <> 'plugin' OR p.enabled = 1)
634636 AND (g.scope = 'workspace' OR (g.scope = 'agent' AND g.target = ?2) OR (g.scope = 'team' AND g.target IN (SELECT value FROM json_each(?3))))
635637 ORDER BY c.title COLLATE NOCASE LIMIT 200`,
636638 )
+32−7
1010 import { type RepoPath, type Result, type User, fail, identityClient, ok, reposClient } from "@g1t/contracts";
1111
1212 import { type McpServer } from "../../../packages/contracts/src/abilities.ts";
13−import { parseMarketplaceAddress, parsePluginRef } from "../../../packages/contracts/src/claude-plugin.ts";
13+import { type McpServerConfig, parseMarketplaceAddress, parsePluginRef } from "../../../packages/contracts/src/claude-plugin.ts";
1414 import type { AgentConnector, ConnectorInput, CustomizeBundle, Marketplace, PluginAgentTemplate, PluginDetail, PluginInstall, PluginListing, SkillListing, WorkspaceConnector } from "../../../packages/contracts/src/customize.ts";
1515 import type { SkillScope } from "../../../packages/contracts/src/skill-library.ts";
1616 import type { SkillFile } from "../../../packages/contracts/src/skill-format.ts";
162162 const { listing, entry } = found.value;
163163 const done = await plugins.install(ctx.db, { workspaceId: ctx.workspaceId, listing, entry: entry.entry, marketplace: { name: listing.marketplace, source: entry.source, pluginRoot: entry.pluginRoot }, host: hostOf(ctx), by: ctx.viewer.username, deps: fetchDeps(ctx) });
164164 if (!done.ok) return done;
165− // Its remote servers become connectors, for an owner to sign in or give a key.
165+ await pluginConnectors(ctx, listing, done.value.plugin.servers, done.value.install.id);
166+ ctx.audit?.("install_plugin", `customize/plugins/${listing.ref}`, `Installed the plugin ${listing.ref}${done.value.install.version ? ` ${done.value.install.version}` : ""} (${done.value.skills.filter((s) => !s.problem).length} skills, ${done.value.plugin.agents.length} agents)`);
167+ await answerListing(ctx, listingRef("plugin", listing.ref));
168+ const fresh = await plugins.installByRef(ctx.db, ctx.workspaceId, listing.ref);
169+ return ok(fresh ? plugins.installOut(fresh) : done.value.install);
170+}
171+
172+/** A plugin's remote servers become connectors, for an owner to sign in or give a key; one the workspace has from this plugin already is left as it is. */
173+async function pluginConnectors(ctx: ViewContext, listing: PluginListing, servers: McpServerConfig[], installId: string): Promise<void> {
166174 const deps = await connectorDeps(ctx);
167− for (const server of done.value.plugin.servers) {
175+ const have = new Set((await connectors.listConnectors(ctx.db, ctx.workspaceId)).map((row) => JSON.stringify(JSON.parse(row.from_json))));
176+ for (const server of servers) {
168177 if ((server.type !== "http" && server.type !== "sse") || !server.url) continue;
178+ const from = { kind: "plugin" as const, ref: listing.ref, server: server.name };
179+ if (have.has(JSON.stringify(from))) continue;
169180 const auth = server.variables.length ? "header" : "oauth";
170181 const headerName = Object.keys(server.headers)[0] ?? "Authorization";
171− const added = await connectors.addConnector(ctx.db, deps, ctx.workspaceId, { from: { kind: "plugin", ref: listing.ref, server: server.name }, auth, header: auth === "header" ? { name: headerName, value: "" } : null }, ctx.viewer.username, { title: `${listing.display_name ?? listing.name}: ${server.name}`, url: server.url });
172− if (!added.ok) await ctx.db.prepare("UPDATE plugin_installs SET problems = json_insert(problems, '$[#]', ?) WHERE id = ?").bind(`${server.name}: ${added.error.message}`, done.value.install.id).run();
182+ const added = await connectors.addConnector(ctx.db, deps, ctx.workspaceId, { from, auth, header: auth === "header" ? { name: headerName, value: "" } : null }, ctx.viewer.username, { title: `${listing.display_name ?? listing.name}: ${server.name}`, url: server.url });
183+ if (!added.ok) await ctx.db.prepare("UPDATE plugin_installs SET problems = json_insert(problems, '$[#]', ?) WHERE id = ?").bind(`${server.name}: ${added.error.message}`, installId).run();
173184 }
174− ctx.audit?.("install_plugin", `customize/plugins/${listing.ref}`, `Installed the plugin ${listing.ref}${done.value.install.version ? ` ${done.value.install.version}` : ""} (${done.value.skills.filter((s) => !s.problem).length} skills, ${done.value.plugin.agents.length} agents)`);
175− await answerListing(ctx, listingRef("plugin", listing.ref));
185+}
186+
187+async function updatePlugin(ctx: ViewContext, ref: unknown): Promise<Result<PluginInstall>> {
188+ const may = owners(ctx, "update plugins");
189+ if (!may.ok) return may;
190+ const found = await listingFor(ctx, ref);
191+ if (!found.ok) return found;
192+ const { listing, entry } = found.value;
193+ const done = await plugins.update(ctx.db, { workspaceId: ctx.workspaceId, listing, entry: entry.entry, marketplace: { name: listing.marketplace, source: entry.source, pluginRoot: entry.pluginRoot }, host: hostOf(ctx), by: ctx.viewer.username, deps: fetchDeps(ctx) });
194+ if (!done.ok) return done;
195+ if (!done.value.unchanged) {
196+ await pluginConnectors(ctx, listing, done.value.plugin.servers, done.value.install.id);
197+ ctx.audit?.("update_plugin", `customize/plugins/${listing.ref}`, `Updated the plugin ${listing.ref}${done.value.install.version ? ` to ${done.value.install.version}` : ""}${done.value.install.sha ? ` (${done.value.install.sha.slice(0, 8)})` : ""}`);
198+ }
176199 const fresh = await plugins.installByRef(ctx.db, ctx.workspaceId, listing.ref);
177200 return ok(fresh ? plugins.installOut(fresh) : done.value.install);
178201 }
438461 return view(args, (ctx) => setPluginEnabled(ctx, args.ref, args.enabled));
439462 case "uninstall_plugin":
440463 return view(args, (ctx) => uninstallPlugin(ctx, args.ref));
464+ case "update_plugin":
465+ return view(args, (ctx) => updatePlugin(ctx, args.ref));
441466 case "plugin_agents":
442467 return view(args, pluginAgents);
443468 case "connectors":
+62−1
9494 // The built-ins can't be read in this test (no GitHub), so they are listed with a problem and no plugins; the workspace's own lists three.
9595 const all = await marketplaces.loadAll(db, ws, deps, "g1t.sh", at);
9696 const listings = marketplaces.discoverPlugins(all, new Map(), "g1t.sh");
97+ // The workspace's own marketplace first; g1t's announced plugins after it.
9798 assert.deepEqual(
98− listings.map((l) => [l.ref, l.availability.kind, l.fetch.kind]),
99+ listings.filter((l) => l.marketplace === "acme-tools").map((l) => [l.ref, l.availability.kind, l.fetch.kind]),
99100 [
100101 ["release@acme-tools", "available", "github"],
101102 ["docs-skills@acme-tools", "available", "github"],
102103 ["from-npm@acme-tools", "unavailable", "unsupported"],
103104 ],
104105 );
106+ assert.ok(listings.some((l) => l.ref === "mail@g1t" && l.availability.kind === "soon"));
105107 assert.deepEqual(listings[0]!.fetch, { kind: "github", repo: "acme/tools", ref: null, sha: null, path: "plugins/release" });
106108 const skills = marketplaces.discoverSkills(listings, new Set());
107109 assert.deepEqual(
244246 assert.deepEqual(fitted.files.map((f) => f.path).sort(), ["SKILL.md", "fonts/b.ttf", "reference.md", "scripts/draw.py"]);
245247 assert.deepEqual(plugins.fitSkillFolder(files.slice(0, 2)).omitted, []);
246248 });
249+
250+test("an installed plugin updates in place at its marketplace's newer commit: skill versions move with their attachments, gone skills are archived; unchanged when at the same commit", async () => {
251+ const db = fakeD1();
252+ await marketplaces.addMarketplace(db, ws, null, { source: "github", repo: "acme/tools" }, "chase", deps, "g1t.sh", at);
253+ const all = await marketplaces.loadAll(db, ws, deps, "g1t.sh", at);
254+ const listings = marketplaces.discoverPlugins(all, new Map(), "g1t.sh");
255+ const listing = marketplaces.findListing(listings, "release@acme-tools")!;
256+ const entry = marketplaces.entryOf(all, { name: "release", marketplace: "acme-tools" })!;
257+ const input = { workspaceId: ws, listing, entry: entry.entry, marketplace: { name: "acme-tools", source: entry.source, pluginRoot: entry.pluginRoot }, host: "g1t.sh", by: "chase", deps, now: at };
258+ const installed = await plugins.install(db, input);
259+ assert.ok(installed.ok);
260+ const notes = (await skillByName(db, ws, "release-notes", "release@acme-tools"))!;
261+ await db.prepare("INSERT INTO skill_attachments (id, workspace_id, skill_id, scope, target, version, attached_by, attached_at) VALUES ('ska_1', ?, ?, 'workspace', '', 1, 'chase', ?)").bind(ws, notes.id, at.toISOString()).run();
262+ // Same commit: nothing moves.
263+ const same = await plugins.update(db, input);
264+ assert.ok(same.ok && same.value.unchanged);
265+ // The repository moves on: a new version, a changed skill, the "code" skill gone.
266+ const NEXT = "fedcba9876543210fedcba9876543210fedcba98";
267+ const files = { ...REPO };
268+ delete files["plugins/release/skills/code/SKILL.md"];
269+ files["plugins/release/.claude-plugin/plugin.json"] = JSON.stringify({ name: "release", version: "1.3.0", description: "Cuts releases, now with notes" });
270+ files["plugins/release/skills/release-notes/SKILL.md"] = "---\nname: release-notes\ndescription: Use when asked for release notes or a changelog.\n---\n\nRead the merged pull requests, group them, write the notes, link each.\n";
271+ const moved: Fetch = async (url, init) => {
272+ const u = new URL(url);
273+ if (u.hostname === "api.github.com" && (u.pathname.endsWith("/commits/HEAD") || u.pathname.endsWith("/commits/main"))) return Response.json({ sha: NEXT });
274+ if (u.hostname === "api.github.com" && u.pathname === `/repos/acme/tools/git/trees/${NEXT}`) return Response.json({ tree: Object.entries(files).map(([path, content]) => ({ path, type: "blob", size: content.length })), truncated: false });
275+ if (u.hostname === "raw.githubusercontent.com") {
276+ const [, , , ref, ...rest] = u.pathname.split("/");
277+ const file = files[rest.map(decodeURIComponent).join("/")];
278+ return ref === NEXT && file !== undefined ? new Response(file) : new Response("no", { status: 404 });
279+ }
280+ return githubFetch()(url, init);
281+ };
282+ const updated = await plugins.update(db, { ...input, deps: { fetchFn: moved }, now: new Date(at.getTime() + 60_000) });
283+ assert.ok(updated.ok, !updated.ok ? updated.error.message : "");
284+ assert.equal(updated.value.unchanged, false);
285+ assert.equal(updated.value.install.version, "1.3.0");
286+ assert.equal(updated.value.install.sha, NEXT);
287+ assert.equal(updated.value.install.description, "Cuts releases, now with notes");
288+ const after = (await skillByName(db, ws, "release-notes", "release@acme-tools"))!;
289+ assert.equal(after.version, 2);
290+ assert.equal((await db.prepare("SELECT version FROM skill_attachments WHERE id = 'ska_1'").first<{ version: number }>())!.version, 2, "the attachment followed");
291+ assert.equal(await skillByName(db, ws, "code", "release@acme-tools"), null, "a skill the plugin no longer has is archived");
292+ assert.deepEqual(plugins.installsOut([(await plugins.installByRef(db, ws, "release@acme-tools"))!], [listing])[0]!.update, { version: "1.2.0", sha: null }, "the marketplace's cached listing still says 1.2.0 until it is read again");
293+});
294+
295+test("g1t's own announced plugins are listed, as Soon, and can't be installed yet", async () => {
296+ const db = fakeD1();
297+ const all = await marketplaces.loadAll(db, ws, deps, "g1t.sh", at);
298+ const listings = marketplaces.discoverPlugins(all, new Map(), "g1t.sh");
299+ const own = listings.filter((l) => l.marketplace === "g1t");
300+ assert.ok(own.length >= 5);
301+ assert.ok(own.every((l) => l.availability.kind === "soon"));
302+ const mail = marketplaces.findListing(listings, "mail@g1t")!;
303+ assert.equal(mail.display_name, "Mail");
304+ const entry = marketplaces.entryOf(all, { name: "mail", marketplace: "g1t" })!;
305+ const refused = await plugins.install(db, { workspaceId: ws, listing: mail, entry: entry.entry, marketplace: { name: "g1t", source: entry.source, pluginRoot: null }, host: "g1t.sh", by: "chase", deps, now: at });
306+ assert.equal(!refused.ok && refused.error.code, "invalid");
307+});
+24−1
2828 } from "../../../packages/contracts/src/claude-plugin.ts";
2929 import { MAX_MARKETPLACES, type Marketplace, type PluginAvailability, type PluginListing, type SkillListing } from "../../../packages/contracts/src/customize.ts";
3030 import { newId } from "../../../packages/contracts/src/ids.ts";
31+import { FIRST_PARTY_EXTENSIONS } from "../../../packages/contracts/src/marketplace.ts";
3132 import { type FetchDeps, fetchMarketplace } from "./customize-fetch.ts";
3233
3334 const ok = <T>(value: T): Result<T> => ({ ok: true, value });
5556 description: "g1t's own plugins: the pages, cards and roles g1t is building for what a team does besides code.",
5657 version: null,
5758 pluginRoot: null,
58− plugins: [],
59+ // What g1t is building, announced before release: each a plugin here once it ships.
60+ plugins: FIRST_PARTY_EXTENSIONS.map(
61+ (extension): MarketplacePluginEntry => ({
62+ name: extension.id,
63+ source: { source: "path", path: `./${extension.id}` },
64+ description: extension.tagline,
65+ version: null,
66+ author: { name: "g1t" },
67+ homepage: null,
68+ repository: null,
69+ license: null,
70+ keywords: [],
71+ category: extension.category.toLowerCase(),
72+ tags: [],
73+ skills: [],
74+ commands: [],
75+ agents: [],
76+ mcpServers: null,
77+ strict: true,
78+ displayName: extension.name,
79+ extra: { status: extension.status === "available" ? "available" : "soon" },
80+ }),
81+ ),
5982 problems: [],
6083 };
6184
+108−41
260260
261261 // ── Installing ───────────────────────────────────────────────────────────
262262
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+ */
270+async 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+
263325 export type InstallInput = {
264326 workspaceId: string;
265327 listing: PluginListing;
327389 throw error;
328390 }
329391 // 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− }
392+ const spread = await spreadSkills(db, input.workspaceId, listing, plugin, input.by, now);
393+ const { skills } = spread;
394+ const problems = [...plugin.problems, ...spread.problems];
370395 if (problems.length) await db.prepare("UPDATE plugin_installs SET problems = ? WHERE id = ?").bind(JSON.stringify(problems.slice(0, 50)), row.id).run();
371396 return ok({ install: installOut({ ...row, problems: JSON.stringify(problems.slice(0, 50)) }), plugin, skills });
372397 }
373398
374−/** Switches an install on or off: off, its skills leave agents' prompts and its connectors stop (the kill switch). */
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+ */
407+export 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. */
375442 export async function setEnabled(db: D1Database, workspaceId: string, ref: string, enabled: boolean, now = new Date()): Promise<Result<PluginInstall>> {
376443 const row = await installByRef(db, workspaceId, ref);
377444 if (!row) return fail("not_found", "That plugin isn't installed.");

Comments

Sign in to comment.