| 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 | */ |
| 20 | import type { FailureCode, Result } from "@g1t/contracts"; |
| 21 | import { |
| 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"; |
| 36 | import { MAX_PLUGIN_INSTALLS, type PluginAgentTemplate, type PluginComponents, type PluginDetail, type PluginInstall, type PluginListing, componentsOf } from "../../../packages/contracts/src/customize.ts"; |
| 37 | import { newId } from "../../../packages/contracts/src/ids.ts"; |
| 38 | import { PLUGIN_SKILL_LIMITS, type SkillFile, checkSkillFolder, parseFrontMatter, skillFileBytes, splitFrontMatter } from "../../../packages/contracts/src/skill-format.ts"; |
| 39 | import { type FetchDeps, fetchPlugin } from "./customize-fetch.ts"; |
| 40 | import { type SkillRow, skillByName, writeVersion } from "./skill-library.ts"; |
| 41 | |
| 42 | const ok = <T>(value: T): Result<T> => ({ ok: true, value }); |
| 43 | const fail = (code: FailureCode, message: string): Result<never> => ({ ok: false, error: { code, message } }); |
| 44 | |
| 45 | export 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 | |
| 67 | function 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 | |
| 76 | const EMPTY: PluginComponents = { skills: [], agents: [], commands: [], mcp_servers: [], hooks: false, lsp: false, bin: false, output_styles: false }; |
| 77 | |
| 78 | export 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. */ |
| 99 | export 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 | |
| 104 | export 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. */ |
| 111 | export 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 | |
| 123 | function 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. */ |
| 129 | function 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. */ |
| 136 | function 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. */ |
| 149 | export 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 | */ |
| 225 | export 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. */ |
| 243 | function commandAsSkill(name: string, text: string): SkillFile[] { |
| 244 | const split = splitFrontMatter(text); |
| 245 | let body = text; |
| 246 | let description = ""; |
| 247 | if (split.ok) { |
| 248 | body = split.body; |
| 249 | try { |
| 250 | const front = parseFrontMatter(split.yaml); |
| 251 | description = typeof front.description === "string" ? front.description : ""; |
| 252 | } catch { |
| 253 | // Kept as the body alone. |
| 254 | } |
| 255 | } |
| 256 | const first = body.split("\n").find((line) => line.trim())?.replace(/^#+\s*/, "").trim() ?? ""; |
| 257 | const when = (description || first || `Use when asked to run ${name}.`).replace(/\s+/g, " ").slice(0, 1024); |
| 258 | return [{ path: "SKILL.md", content: `---\nname: ${name}\ndescription: ${JSON.stringify(when)}\n---\n\n${body.trim()}\n`, encoding: "utf8" }]; |
| 259 | } |
| 260 | |
| 261 | // ── Installing ─────────────────────────────────────────────────────────── |
| 262 | |
| 263 | /** |
| 264 | * Spreads a plugin's skills into the library under its name: `skills/<name>` |
| 265 | * folders and `commands/*.md` files, each a version of the skill of its |
| 266 | * name (a new version when it is there already, moving its attachments), |
| 267 | * cut to what a version holds. Skills the plugin no longer has are |
| 268 | * archived. What couldn't be written is named. |
| 269 | */ |
| 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 | |
| 325 | export type InstallInput = { |
| 326 | workspaceId: string; |
| 327 | listing: PluginListing; |
| 328 | entry: MarketplacePluginEntry; |
| 329 | marketplace: { name: string; source: MarketplaceSource; pluginRoot: string | null }; |
| 330 | host: string | null; |
| 331 | by: string; |
| 332 | deps: FetchDeps; |
| 333 | now?: Date; |
| 334 | }; |
| 335 | |
| 336 | /** What installing wrote: the install, and the plugin as read, for connectors to be made from. */ |
| 337 | export type Installed = { install: PluginInstall; plugin: ReadPlugin; skills: { name: string; id: string; problem: string | null }[] }; |
| 338 | |
| 339 | /** |
| 340 | * Installs a plugin: fetches it, writes its skills into the library under |
| 341 | * its name, keeps its subagents, and records what it brought. Connectors |
| 342 | * for its servers are made by the caller (connectors.ts), which holds the |
| 343 | * sealing key. A conflict when it is installed already. |
| 344 | */ |
| 345 | export async function install(db: D1Database, input: InstallInput): Promise<Result<Installed>> { |
| 346 | const now = input.now ?? new Date(); |
| 347 | const { listing } = input; |
| 348 | if (listing.availability.kind === "installed") return fail("conflict", `${listing.name} is installed already.`); |
| 349 | if (listing.availability.kind === "soon") return fail("invalid", `${listing.name} isn't released yet, so it can't be installed.`); |
| 350 | if (listing.availability.kind === "unavailable") return fail("invalid", listing.availability.why); |
| 351 | const count = await db.prepare("SELECT COUNT(*) AS n FROM plugin_installs WHERE workspace_id = ? AND uninstalled_at IS NULL").bind(input.workspaceId).first<{ n: number }>(); |
| 352 | if ((count?.n ?? 0) >= MAX_PLUGIN_INSTALLS) return fail("limit", `A workspace installs at most ${MAX_PLUGIN_INSTALLS} plugins.`); |
| 353 | const plan = fetchPlan(input.entry.source, input.marketplace.source, { pluginRoot: input.marketplace.pluginRoot, host: input.host }); |
| 354 | const fetched = await fetchPlugin(plan, input.deps); |
| 355 | if (!fetched.ok) return fail("invalid", fetched.message); |
| 356 | const plugin = readPlugin(fetched.files, fetched.sha, input.entry, fetched.omitted); |
| 357 | const at = now.toISOString(); |
| 358 | const row: PluginRow = { |
| 359 | id: newId("plg", now.getTime()), |
| 360 | workspace_id: input.workspaceId, |
| 361 | ref: listing.ref, |
| 362 | name: listing.name, |
| 363 | marketplace: listing.marketplace, |
| 364 | display_name: plugin.manifest?.displayName ?? listing.display_name, |
| 365 | description: plugin.manifest?.description || listing.description, |
| 366 | version: plugin.manifest?.version ?? listing.version, |
| 367 | sha: plugin.sha, |
| 368 | fetch: JSON.stringify(plan.kind === "github" && plugin.sha ? { ...plan, sha: plugin.sha } : plan), |
| 369 | components: JSON.stringify(plugin.components), |
| 370 | agents: JSON.stringify(plugin.agents), |
| 371 | problems: "[]", |
| 372 | enabled: 1, |
| 373 | installed_by: input.by, |
| 374 | installed_at: at, |
| 375 | updated_at: at, |
| 376 | uninstalled_by: null, |
| 377 | uninstalled_at: null, |
| 378 | }; |
| 379 | try { |
| 380 | await db |
| 381 | .prepare( |
| 382 | `INSERT INTO plugin_installs (id, workspace_id, ref, name, marketplace, display_name, description, version, sha, fetch, components, agents, problems, enabled, installed_by, installed_at, updated_at) |
| 383 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, |
| 384 | ) |
| 385 | .bind(row.id, row.workspace_id, row.ref, row.name, row.marketplace, row.display_name, row.description, row.version, row.sha, row.fetch, row.components, row.agents, row.problems, row.installed_by, row.installed_at, row.updated_at) |
| 386 | .run(); |
| 387 | } catch (error) { |
| 388 | if (String(error).includes("UNIQUE")) return fail("conflict", `${listing.name} is installed already.`); |
| 389 | throw error; |
| 390 | } |
| 391 | // Its skills, into the library under the plugin's name. |
| 392 | const spread = await spreadSkills(db, input.workspaceId, listing, plugin, input.by, now); |
| 393 | const { skills } = spread; |
| 394 | const problems = [...plugin.problems, ...spread.problems]; |
| 395 | if (problems.length) await db.prepare("UPDATE plugin_installs SET problems = ? WHERE id = ?").bind(JSON.stringify(problems.slice(0, 50)), row.id).run(); |
| 396 | return ok({ install: installOut({ ...row, problems: JSON.stringify(problems.slice(0, 50)) }), plugin, skills }); |
| 397 | } |
| 398 | |
| 399 | /** |
| 400 | * Reads an installed plugin again from its marketplace's current listing |
| 401 | * and brings the install to it: its skills get new versions (attachments |
| 402 | * follow), skills it no longer has are archived, its subagents and what it |
| 403 | * brought are rewritten, and the install's commit and version move. New |
| 404 | * remote servers are the caller's (connectors.ts). Nothing changes when |
| 405 | * the plugin is at the same commit already. |
| 406 | */ |
| 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. */ |
| 442 | export async function setEnabled(db: D1Database, workspaceId: string, ref: string, enabled: boolean, now = new Date()): Promise<Result<PluginInstall>> { |
| 443 | const row = await installByRef(db, workspaceId, ref); |
| 444 | if (!row) return fail("not_found", "That plugin isn't installed."); |
| 445 | const at = now.toISOString(); |
| 446 | await db.batch([ |
| 447 | db.prepare("UPDATE plugin_installs SET enabled = ?, updated_at = ? WHERE id = ?").bind(enabled ? 1 : 0, at, row.id), |
| 448 | // Its skills follow: a draft can't be attached, which is how a switched-off plugin's skills stay out of prompts. |
| 449 | db.prepare("UPDATE skills SET status = ? WHERE workspace_id = ? AND plugin = ? AND archived_at IS NULL").bind(enabled ? "published" : "draft", workspaceId, ref), |
| 450 | ]); |
| 451 | return ok(installOut({ ...row, enabled: enabled ? 1 : 0, updated_at: at })); |
| 452 | } |
| 453 | |
| 454 | /** Removes an install: its skills are archived with their attachments, and the row kept. Connectors are the caller's. */ |
| 455 | export async function uninstall(db: D1Database, workspaceId: string, ref: string, by: string, now = new Date()): Promise<Result<{ skills: string[] }>> { |
| 456 | const row = await installByRef(db, workspaceId, ref); |
| 457 | if (!row) return fail("not_found", "That plugin isn't installed."); |
| 458 | const at = now.toISOString(); |
| 459 | const { results } = await db.prepare("SELECT id, name FROM skills WHERE workspace_id = ? AND plugin = ? AND archived_at IS NULL").bind(workspaceId, ref).all<{ id: string; name: string }>(); |
| 460 | const statements = [ |
| 461 | db.prepare("UPDATE plugin_installs SET uninstalled_by = ?, uninstalled_at = ?, updated_at = ? WHERE id = ?").bind(by, at, at, row.id), |
| 462 | db.prepare("UPDATE skills SET archived_at = ?, updated_by = ?, updated_at = ? WHERE workspace_id = ? AND plugin = ? AND archived_at IS NULL").bind(at, by, at, workspaceId, ref), |
| 463 | ...(results.length ? [db.prepare(`DELETE FROM skill_attachments WHERE skill_id IN (SELECT value FROM json_each(?))`).bind(JSON.stringify(results.map((s) => s.id)))] : []), |
| 464 | ]; |
| 465 | await db.batch(statements); |
| 466 | return ok({ skills: results.map((s) => s.name) }); |
| 467 | } |
| 468 | |
| 469 | /** The subagents installed plugins bring, as templates to hire from. */ |
| 470 | export async function pluginAgents(db: D1Database, workspaceId: string): Promise<PluginAgentTemplate[]> { |
| 471 | const out: PluginAgentTemplate[] = []; |
| 472 | for (const row of await listInstalls(db, workspaceId)) { |
| 473 | if (row.enabled !== 1) continue; |
| 474 | for (const agent of json<SubagentFile[]>(row.agents, [])) { |
| 475 | out.push({ ref: `${row.name}:${agent.name}`, plugin: row.name, marketplace: row.marketplace, name: agent.name, description: agent.description, prompt: agent.prompt, model: agent.model, skills: agent.skills, tools: agent.tools }); |
| 476 | } |
| 477 | } |
| 478 | return out; |
| 479 | } |
| 480 | |
| 481 | /** The installs as their page lists them, each with whether its marketplace now lists a newer version. */ |
| 482 | export function installsOut(rows: PluginRow[], listings: PluginListing[]): PluginInstall[] { |
| 483 | return rows.map((row) => { |
| 484 | const listing = listings.find((l) => l.ref === row.ref); |
| 485 | const newerVersion = listing?.version && row.version && listing.version !== row.version; |
| 486 | const newerSha = listing?.fetch.kind === "github" && listing.fetch.sha && row.sha && listing.fetch.sha !== row.sha; |
| 487 | return installOut(row, listing && (newerVersion || newerSha) ? { version: listing.version, sha: listing.fetch.kind === "github" ? listing.fetch.sha : null } : null); |
| 488 | }); |
| 489 | } |
| 490 | |
| 491 | /** A plugin's page: fetched fresh when it isn't installed (so Discover can show what it holds), from the install otherwise. */ |
| 492 | export async function detail(db: D1Database, workspaceId: string, listing: PluginListing, entry: MarketplacePluginEntry | null, marketplace: { source: MarketplaceSource; pluginRoot: string | null; owner: string | null }, host: string | null, deps: FetchDeps): Promise<PluginDetail> { |
| 493 | const row = await installByRef(db, workspaceId, listing.ref); |
| 494 | const install = row ? installOut(row) : null; |
| 495 | let read: ReadPlugin | null = null; |
| 496 | if (listing.availability.kind !== "unavailable" && listing.availability.kind !== "soon") { |
| 497 | const plan = fetchPlan(entry?.source ?? { source: "path", path: "./" }, marketplace.source, { pluginRoot: marketplace.pluginRoot, host }); |
| 498 | const fetched = await fetchPlugin(row ? json<FetchPlan>(row.fetch, plan) : plan, deps); |
| 499 | if (fetched.ok) read = readPlugin(fetched.files, fetched.sha, entry, fetched.omitted); |
| 500 | } |
| 501 | return { |
| 502 | listing, |
| 503 | install, |
| 504 | components: read?.components ?? install?.components ?? null, |
| 505 | readme: read?.readme ?? null, |
| 506 | manifest: read?.manifest ? { name: read.manifest.name, version: read.manifest.version, license: read.manifest.license, repository: read.manifest.repository } : null, |
| 507 | agents: read?.agents ?? (row ? json<SubagentFile[]>(row.agents, []) : []), |
| 508 | problems: read?.problems ?? install?.problems ?? [], |
| 509 | source: { marketplace: listing.marketplace, owner: marketplace.owner }, |
| 510 | }; |
| 511 | } |
| 512 | |
| 513 | export { pluginRef }; |
| 514 | export type { SkillRow }; |