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.
8 files+258−550/8 viewed
| 91 | 91 | ||
| 92 | 92 | When a plugin is **switched off**, its skills leave every agent that had | |
| 93 | 93 | 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. | |
| 95 | 97 | ||
| 96 | 98 | ## Connectors | |
| 97 | 99 | ||
| ⋯ | |||
| 318 | 320 | ||
| 319 | 321 | | | | | |
| 320 | 322 | | --- | --- | | |
| 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. | | |
| 322 | 324 | | **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. | | |
| 324 | 326 | ||
| 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 | |
| 326 | 328 | off and uninstalling are in the audit log under | |
| 327 | 329 | `customize/plugins/<name@marketplace>`, and adding or removing a | |
| 328 | 330 | marketplace under `customize/marketplaces/<name>`. | |
| 1054 | 1054 | installPlugin(workspace: string, viewer: User, ref: string): Promise<Result<PluginInstall>>; | |
| 1055 | 1055 | setPluginEnabled(workspace: string, viewer: User, ref: string, enabled: boolean): Promise<Result<PluginInstall>>; | |
| 1056 | 1056 | 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>>; | |
| 1057 | 1059 | pluginAgents(workspace: string, viewer: User): Promise<Result<PluginAgentTemplate[]>>; | |
| 1058 | 1060 | connectors(workspace: string, viewer: User): Promise<Result<WorkspaceConnector[]>>; | |
| 1059 | 1061 | connector(workspace: string, viewer: User, id: string): Promise<Result<WorkspaceConnector>>; | |
| ⋯ | |||
| 1163 | 1165 | installPlugin: (workspace, viewer, ref) => call("install_plugin", { workspace, viewer, ref }), | |
| 1164 | 1166 | setPluginEnabled: (workspace, viewer, ref, enabled) => call("set_plugin_enabled", { workspace, viewer, ref, enabled }), | |
| 1165 | 1167 | uninstallPlugin: (workspace, viewer, ref) => call("uninstall_plugin", { workspace, viewer, ref }), | |
| 1168 | + | updatePlugin: (workspace, viewer, ref) => call("update_plugin", { workspace, viewer, ref }), | |
| 1166 | 1169 | pluginAgents: (workspace, viewer) => call("plugin_agents", { workspace, viewer }), | |
| 1167 | 1170 | connectors: (workspace, viewer) => call("connectors", { workspace, viewer }), | |
| 1168 | 1171 | connector: (workspace, viewer, id) => call("connector", { workspace, viewer, id }), | |
| 198 | 198 | assert.deepEqual((await connectors.reaching(db, ws, "agt_1", ["qa"])).map((r) => [r.row.id, r.via]), [[b.value.id, "team"]]); | |
| 199 | 199 | assert.equal((await db.prepare("SELECT secrets FROM connectors WHERE id = ?").bind(a.value.id).first<{ secrets: string | null }>())!.secrets, null); | |
| 200 | 200 | }); | |
| 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 | + | }); |
| 624 | 624 | return done.meta.changes ? ok(null) : fail("not_found", "There is no such grant."); | |
| 625 | 625 | } | |
| 626 | 626 | ||
| 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. */ | |
| 628 | 628 | export async function reaching(db: D1Database, workspaceId: string, agentId: string, teams: readonly string[]): Promise<{ row: ConnectorRow; via: SkillScope }[]> { | |
| 629 | 629 | const { results } = await db | |
| 630 | 630 | .prepare( | |
| 631 | 631 | `SELECT c.*, g.scope AS via FROM connector_grants g | |
| 632 | 632 | 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 | |
| 633 | 634 | WHERE g.workspace_id = ?1 | |
| 635 | + | AND (json_extract(c.from_json, '$.kind') <> 'plugin' OR p.enabled = 1) | |
| 634 | 636 | 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)))) | |
| 635 | 637 | ORDER BY c.title COLLATE NOCASE LIMIT 200`, | |
| 636 | 638 | ) |
| 10 | 10 | import { type RepoPath, type Result, type User, fail, identityClient, ok, reposClient } from "@g1t/contracts"; | |
| 11 | 11 | ||
| 12 | 12 | 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"; | |
| 14 | 14 | import type { AgentConnector, ConnectorInput, CustomizeBundle, Marketplace, PluginAgentTemplate, PluginDetail, PluginInstall, PluginListing, SkillListing, WorkspaceConnector } from "../../../packages/contracts/src/customize.ts"; | |
| 15 | 15 | import type { SkillScope } from "../../../packages/contracts/src/skill-library.ts"; | |
| 16 | 16 | import type { SkillFile } from "../../../packages/contracts/src/skill-format.ts"; | |
| ⋯ | |||
| 162 | 162 | const { listing, entry } = found.value; | |
| 163 | 163 | 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) }); | |
| 164 | 164 | 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> { | |
| 166 | 174 | 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) { | |
| 168 | 177 | 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; | |
| 169 | 180 | const auth = server.variables.length ? "header" : "oauth"; | |
| 170 | 181 | 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(); | |
| 173 | 184 | } | |
| 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 | + | } | |
| 176 | 199 | const fresh = await plugins.installByRef(ctx.db, ctx.workspaceId, listing.ref); | |
| 177 | 200 | return ok(fresh ? plugins.installOut(fresh) : done.value.install); | |
| 178 | 201 | } | |
| ⋯ | |||
| 438 | 461 | return view(args, (ctx) => setPluginEnabled(ctx, args.ref, args.enabled)); | |
| 439 | 462 | case "uninstall_plugin": | |
| 440 | 463 | return view(args, (ctx) => uninstallPlugin(ctx, args.ref)); | |
| 464 | + | case "update_plugin": | |
| 465 | + | return view(args, (ctx) => updatePlugin(ctx, args.ref)); | |
| 441 | 466 | case "plugin_agents": | |
| 442 | 467 | return view(args, pluginAgents); | |
| 443 | 468 | case "connectors": | |
| 94 | 94 | // 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. | |
| 95 | 95 | const all = await marketplaces.loadAll(db, ws, deps, "g1t.sh", at); | |
| 96 | 96 | const listings = marketplaces.discoverPlugins(all, new Map(), "g1t.sh"); | |
| 97 | + | // The workspace's own marketplace first; g1t's announced plugins after it. | |
| 97 | 98 | 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]), | |
| 99 | 100 | [ | |
| 100 | 101 | ["release@acme-tools", "available", "github"], | |
| 101 | 102 | ["docs-skills@acme-tools", "available", "github"], | |
| 102 | 103 | ["from-npm@acme-tools", "unavailable", "unsupported"], | |
| 103 | 104 | ], | |
| 104 | 105 | ); | |
| 106 | + | assert.ok(listings.some((l) => l.ref === "mail@g1t" && l.availability.kind === "soon")); | |
| 105 | 107 | assert.deepEqual(listings[0]!.fetch, { kind: "github", repo: "acme/tools", ref: null, sha: null, path: "plugins/release" }); | |
| 106 | 108 | const skills = marketplaces.discoverSkills(listings, new Set()); | |
| 107 | 109 | assert.deepEqual( | |
| ⋯ | |||
| 244 | 246 | assert.deepEqual(fitted.files.map((f) => f.path).sort(), ["SKILL.md", "fonts/b.ttf", "reference.md", "scripts/draw.py"]); | |
| 245 | 247 | assert.deepEqual(plugins.fitSkillFolder(files.slice(0, 2)).omitted, []); | |
| 246 | 248 | }); | |
| 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 | + | }); | |
| 28 | 28 | } from "../../../packages/contracts/src/claude-plugin.ts"; | |
| 29 | 29 | import { MAX_MARKETPLACES, type Marketplace, type PluginAvailability, type PluginListing, type SkillListing } from "../../../packages/contracts/src/customize.ts"; | |
| 30 | 30 | import { newId } from "../../../packages/contracts/src/ids.ts"; | |
| 31 | + | import { FIRST_PARTY_EXTENSIONS } from "../../../packages/contracts/src/marketplace.ts"; | |
| 31 | 32 | import { type FetchDeps, fetchMarketplace } from "./customize-fetch.ts"; | |
| 32 | 33 | ||
| 33 | 34 | const ok = <T>(value: T): Result<T> => ({ ok: true, value }); | |
| ⋯ | |||
| 55 | 56 | description: "g1t's own plugins: the pages, cards and roles g1t is building for what a team does besides code.", | |
| 56 | 57 | version: null, | |
| 57 | 58 | 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 | + | ), | |
| 59 | 82 | problems: [], | |
| 60 | 83 | }; | |
| 61 | 84 | ||
| 260 | 260 | ||
| 261 | 261 | // ── Installing ─────────────────────────────────────────────────────────── | |
| 262 | 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 | + | */ | |
| 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 | + | ||
| 263 | 325 | export type InstallInput = { | |
| 264 | 326 | workspaceId: string; | |
| 265 | 327 | listing: PluginListing; | |
| ⋯ | |||
| 327 | 389 | throw error; | |
| 328 | 390 | } | |
| 329 | 391 | // 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]; | |
| 370 | 395 | if (problems.length) await db.prepare("UPDATE plugin_installs SET problems = ? WHERE id = ?").bind(JSON.stringify(problems.slice(0, 50)), row.id).run(); | |
| 371 | 396 | return ok({ install: installOut({ ...row, problems: JSON.stringify(problems.slice(0, 50)) }), plugin, skills }); | |
| 372 | 397 | } | |
| 373 | 398 | ||
| 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. */ | |
| 375 | 442 | export async function setEnabled(db: D1Database, workspaceId: string, ref: string, enabled: boolean, now = new Date()): Promise<Result<PluginInstall>> { | |
| 376 | 443 | const row = await installByRef(db, workspaceId, ref); | |
| 377 | 444 | if (!row) return fail("not_found", "That plugin isn't installed."); | |
Comments
Sign in to comment.