import { mkdir, rm, writeFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import type { ServerConfig } from "./types.js"; import { ApiError } from "./errors.js"; import { parseFrontmatter, buildFrontmatter } from "./frontmatter.js"; import { addMcp, removeMcp } from "./mcp.js"; import { createWorkspaceKvStore, isRecord } from "./workspace-kv-store.js"; const OPENCODE_SKILL_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/; const OPENCODE_MCP_NAME_RE = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/; const OPENCODE_MCP_IMPORT_PATH_PREFIX = "opencode.jsonc#mcp."; type CloudPluginConfigObjectType = "skill" | "agent" | "command" | "tool" | "mcp" | "hook" | "context" | "custom"; type CloudPluginConfigObjectVersion = { id: string; rawSourceText: string | null; normalizedPayloadJson: Record | null; }; type CloudPluginConfigObject = { id: string; objectType: CloudPluginConfigObjectType; title: string; description: string | null; currentRelativePath: string | null; status: string; updatedAt: string | null; latestVersion: CloudPluginConfigObjectVersion | null; }; type CloudPluginMembership = { configObjectId: string; configObject?: CloudPluginConfigObject; }; export type CloudPluginResolved = { plugin: { id: string; name: string; description: string | null; updatedAt: string | null; }; memberships: CloudPluginMembership[]; }; export type CloudImportedPluginFile = { configObjectId: string; versionId: string | null; objectType: string; title: string; path: string; updatedAt: string | null; }; export type CloudImportedPlugin = { pluginId: string; marketplaceId: string | null; name: string; description: string | null; updatedAt: string | null; files: CloudImportedPluginFile[]; importedAt: number | null; }; export type CloudPluginInstallResult = { item: CloudImportedPlugin; warnings: string[]; }; type WorkspaceCloudImports = { skills: Record; providers: Record; marketplaces: Record; plugins: Record; }; function readString(value: unknown): string | null { return typeof value === "string" ? value.trim() || null : null; } function readStringArray(value: unknown): string[] { return Array.isArray(value) ? value.flatMap((entry) => { const text = readString(entry); return text ? [text] : []; }) : []; } function readStringRecord(value: unknown): Record | null { if (!isRecord(value)) return null; const output: Record = {}; for (const [key, entry] of Object.entries(value)) { const text = readString(entry); if (text) output[key] = text; } return Object.keys(output).length ? output : null; } function parseJsonRecord(text: string | null): Record | null { if (!text) return null; try { const parsed: unknown = JSON.parse(text); return isRecord(parsed) ? parsed : null; } catch { return null; } } function normalizeConfigObjectType(value: unknown): CloudPluginConfigObjectType | null { switch (value) { case "skill": case "agent": case "command": case "tool": case "mcp": case "hook": case "context": case "custom": return value; default: return null; } } function normalizeConfigObjectVersion(value: unknown): CloudPluginConfigObjectVersion | null { if (!isRecord(value) || typeof value.id !== "string") return null; return { id: value.id, rawSourceText: typeof value.rawSourceText === "string" ? value.rawSourceText : null, normalizedPayloadJson: isRecord(value.normalizedPayloadJson) ? value.normalizedPayloadJson : null, }; } function normalizeConfigObject(value: unknown): CloudPluginConfigObject | null { if (!isRecord(value) || typeof value.id !== "string" || typeof value.title !== "string") return null; const objectType = normalizeConfigObjectType(value.objectType); if (!objectType) return null; return { id: value.id, objectType, title: value.title, description: typeof value.description === "string" ? value.description : null, currentRelativePath: typeof value.currentRelativePath === "string" ? value.currentRelativePath : null, status: typeof value.status === "string" ? value.status : "active", updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : null, latestVersion: normalizeConfigObjectVersion(value.latestVersion), }; } function normalizeCloudPluginResolved(value: unknown): CloudPluginResolved | null { if (!isRecord(value) || !isRecord(value.plugin) || !Array.isArray(value.memberships)) return null; if (typeof value.plugin.id !== "string" || typeof value.plugin.name !== "string") return null; const memberships = value.memberships.flatMap((entry) => { if (!isRecord(entry) || typeof entry.configObjectId !== "string") return []; const configObject = normalizeConfigObject(entry.configObject); return [{ configObjectId: entry.configObjectId, ...(configObject ? { configObject } : {}) }]; }); return { plugin: { id: value.plugin.id, name: value.plugin.name, description: typeof value.plugin.description === "string" ? value.plugin.description : null, updatedAt: typeof value.plugin.updatedAt === "string" ? value.plugin.updatedAt : null, }, memberships, }; } export function readCloudPluginResolved(value: unknown): CloudPluginResolved { const resolved = normalizeCloudPluginResolved(value); if (!resolved) throw new ApiError(400, "invalid_cloud_plugin", "resolved cloud plugin is required"); return resolved; } function extractSkillBodyMarkdown(skillText: string): string { const trimmed = skillText.trim(); if (!trimmed.startsWith("---")) return trimmed; const rest = trimmed.slice(3); const end = rest.indexOf("\n---"); if (end === -1) return trimmed; return rest.slice(end + 4).replace(/^\s*\n?/, ""); } function slugifyConfigObjectName(title: string, fallback: string): string { let base = title .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, ""); if (!base) base = "skill"; if (base.length > 64) base = base.slice(0, 64).replace(/-+$/g, ""); if (!OPENCODE_SKILL_NAME_RE.test(base)) base = "skill"; if (base === "skill" && fallback) return slugifyConfigObjectName(fallback, ""); return base; } function pluginNamespace(pluginName: string, pluginId: string): string { const base = slugifyConfigObjectName(pluginName, pluginId); return `${base.replace(/-plugin$/, "")}-plugin`; } function normalizePluginSourcePath(path: string, objectType: string, namespace: string): string { const parts = path.trim().replace(/^\/+/, "").split("/").filter(Boolean); if (parts.length === 0 || parts.some((part) => part === ".." || part === ".")) return ""; const folderByType: Record = { agent: "agents", command: "commands", context: "context", hook: "hooks", mcp: "mcps", skill: "skills", tool: "tools", }; const folder = folderByType[objectType]; if (!folder) return ""; const opencodeIndex = parts.findIndex((part) => part === ".opencode"); const searchParts = opencodeIndex >= 0 ? parts.slice(opencodeIndex + 1) : parts; const folderIndex = searchParts.findIndex((part) => part === folder); if (folderIndex < 0 || folderIndex === searchParts.length - 1) return ""; const rest = searchParts.slice(folderIndex + 1); if (rest[0] === namespace) return [".opencode", folder, ...rest].join("/"); return [".opencode", folder, namespace, ...rest].join("/"); } function getPluginObjectInstallPath(object: CloudPluginConfigObject, namespace: string): string { const existing = normalizePluginSourcePath(object.currentRelativePath ?? "", object.objectType, namespace); if (existing) { if (object.objectType === "skill") { const parts = existing.split("/").filter(Boolean); const lastPart = parts.at(-1) ?? ""; const skillName = /^SKILL\.md$/i.test(lastPart) ? parts.at(-2) ?? slugifyConfigObjectName(object.title, object.id) : lastPart || slugifyConfigObjectName(object.title, object.id); return `.opencode/skills/${namespace}/${skillName}/SKILL.md`; } return existing; } const name = slugifyConfigObjectName(object.title, object.id); switch (object.objectType) { case "skill": return `.opencode/skills/${namespace}/${name}/SKILL.md`; case "agent": return `.opencode/agents/${namespace}/${name}.md`; case "command": return `.opencode/commands/${namespace}/${name}.md`; case "mcp": return `.opencode/mcps/${namespace}/${name}.json`; case "hook": return `.opencode/hooks/${namespace}/${name}.json`; case "tool": return `.opencode/tools/${namespace}/${name}.ts`; case "context": return `.opencode/context/${namespace}/${name}.md`; default: return `.opencode/plugins/${namespace}/${name}.txt`; } } function buildCloudSkillContent(name: string, description: string, body: string): string { const safeDescription = description.replace(/\s+/g, " ").trim(); const normalizedBody = body.replace(/^\s*\n?/, ""); return [ "---", `name: ${JSON.stringify(name)}`, `description: ${JSON.stringify(safeDescription)}`, "---", "", normalizedBody, ].join("\n"); } const OPENCODE_MODEL_ID_RE = /^[^\s/]+\/[^\s]+$/; function translateClaudeTools(value: unknown): Record | null { const names = typeof value === "string" ? value.split(",") : Array.isArray(value) ? value.flatMap((entry) => (typeof entry === "string" ? [entry] : [])) : null; if (names) { const tools: Record = {}; for (const raw of names) { const name = raw.trim().toLowerCase(); if (name) tools[name] = true; } return Object.keys(tools).length ? tools : null; } if (isRecord(value)) { const tools: Record = {}; for (const [key, entry] of Object.entries(value)) { const name = key.trim().toLowerCase(); if (name && typeof entry === "boolean") tools[name] = entry; } return Object.keys(tools).length ? tools : null; } return null; } function translateClaudeModel(value: unknown): string | null { const model = readString(value); return model && OPENCODE_MODEL_ID_RE.test(model) ? model : null; } function cloudConfigObjectDescription(object: CloudPluginConfigObject): string { const rawDesc = (object.description?.trim() || object.title).trim(); return rawDesc.slice(0, 1024) || object.title.slice(0, 1024); } function buildCloudAgentContent(description: string, rawSourceText: string): string { const { data, body } = parseFrontmatter(rawSourceText.trim()); const safeDescription = (readString(data.description) ?? description).replace(/\s+/g, " ").trim(); const model = translateClaudeModel(data.model); const tools = translateClaudeTools(data.tools); const frontmatter = buildFrontmatter({ description: safeDescription, ...(model ? { model } : {}), ...(tools ? { tools } : {}), }); return frontmatter + "\n" + body.replace(/^\s*\n?/, ""); } function buildCloudCommandContent(name: string, description: string, rawSourceText: string): string { const { data, body } = parseFrontmatter(rawSourceText.trim()); const safeDescription = (readString(data.description) ?? description).replace(/\s+/g, " ").trim(); const model = translateClaudeModel(data.model); const agent = readString(data.agent); const frontmatter = buildFrontmatter({ name, description: safeDescription, ...(agent ? { agent } : {}), ...(model ? { model } : {}), ...(typeof data.subtask === "boolean" ? { subtask: data.subtask } : {}), }); return frontmatter + "\n" + body.replace(/^\s*\n?/, ""); } function pluginMcpName(rawName: string, namespace: string, fallback: string, namespaceName: boolean): string { const trimmed = rawName.trim(); const base = OPENCODE_MCP_NAME_RE.test(trimmed) ? trimmed : slugifyConfigObjectName(trimmed || fallback, fallback); if (!namespaceName) return base; const namespaced = base.startsWith(`${namespace}-`) ? base : `${namespace}-${base}`; return OPENCODE_MCP_NAME_RE.test(namespaced) ? namespaced : slugifyConfigObjectName(namespaced, fallback); } function mcpCommandFromConfig(config: Record): string[] { if (Array.isArray(config.command)) return readStringArray(config.command); const command = readString(config.command); if (!command) return []; return [command, ...readStringArray(config.args)]; } function normalizePluginMcpConfig(input: unknown): Record | null { if (!isRecord(input)) return null; const enabled = typeof input.enabled === "boolean" ? input.enabled : typeof input.disabled === "boolean" ? !input.disabled : true; const url = readString(input.url); if (url) { const config: Record = { type: "remote", url, enabled }; const headers = readStringRecord(input.headers); if (headers) config.headers = headers; if (isRecord(input.oauth)) config.oauth = input.oauth; if (input.oauth === true) config.oauth = {}; return config; } const command = mcpCommandFromConfig(input); if (command.length > 0) { const config: Record = { type: "local", command, enabled }; const environment = readStringRecord(input.environment) ?? readStringRecord(input.env); if (environment) config.environment = environment; return config; } return null; } function pluginMcpConfigsFromPayload(object: CloudPluginConfigObject, namespace: string) { const version = object.latestVersion; const payload = version?.normalizedPayloadJson ?? parseJsonRecord(version?.rawSourceText ?? null); if (!payload) return []; const configs = new Map; path: string }>(); const addConfig = (rawName: string, rawConfig: unknown, namespaceName: boolean) => { const config = normalizePluginMcpConfig(rawConfig); if (!config) return; const name = pluginMcpName(rawName, namespace, object.id, namespaceName); configs.set(name, { name, config, path: `${OPENCODE_MCP_IMPORT_PATH_PREFIX}${name}`, }); }; if (isRecord(payload.mcp)) { for (const [name, config] of Object.entries(payload.mcp)) addConfig(name, config, false); } if (isRecord(payload.mcpServers)) { for (const [name, config] of Object.entries(payload.mcpServers)) addConfig(name, config, false); } if (configs.size === 0) addConfig(object.title, payload, true); return [...configs.values()]; } function mcpNoConfigWarning(title: string): string { return `MCP component "${title}" could not be installed: no server config with a "url" or "command" was found.`; } function mcpInactiveWarning(title: string): string { return `MCP component "${title}" could not be installed because it is not active.`; } function readCloudImports(config: Record): WorkspaceCloudImports { const root = isRecord(config.cloudImports) ? config.cloudImports : {}; const marketplaces = isRecord(root.marketplaces) ? Object.fromEntries(Object.entries(root.marketplaces).flatMap(([key, value]) => { if (!isRecord(value)) return []; const marketplaceId = readString(value.marketplaceId) ?? key.trim(); const name = readString(value.name) ?? marketplaceId; if (!marketplaceId || !name) return []; return [[marketplaceId, { marketplaceId, name, updatedAt: readString(value.updatedAt), pluginIds: readStringArray(value.pluginIds), importedAt: typeof value.importedAt === "number" && Number.isFinite(value.importedAt) ? value.importedAt : null, }]]; })) : {}; const plugins = isRecord(root.plugins) ? Object.fromEntries(Object.entries(root.plugins).flatMap(([key, value]) => { if (!isRecord(value)) return []; const pluginId = readString(value.pluginId) ?? key.trim(); const name = readString(value.name) ?? pluginId; if (!pluginId || !name) return []; const files = Array.isArray(value.files) ? value.files.flatMap((file) => { if (!isRecord(file)) return []; const configObjectId = readString(file.configObjectId); const objectType = readString(file.objectType); const title = readString(file.title) ?? configObjectId; const path = readString(file.path); if (!configObjectId || !objectType || !title || !path) return []; return [{ configObjectId, versionId: readString(file.versionId), objectType, title, path, updatedAt: readString(file.updatedAt), }]; }) : []; return [[pluginId, { pluginId, marketplaceId: readString(value.marketplaceId), name, description: readString(value.description), updatedAt: readString(value.updatedAt), files, importedAt: typeof value.importedAt === "number" && Number.isFinite(value.importedAt) ? value.importedAt : null, }]]; })) : {}; return { skills: isRecord(root.skills) ? root.skills : {}, providers: isRecord(root.providers) ? root.providers : {}, marketplaces, plugins, }; } function parseInstalledCloudPlugins(configJson: string): WorkspaceCloudImports { try { return readCloudImports({ cloudImports: JSON.parse(configJson) }); } catch { return readCloudImports({}); } } const cloudPluginInstallStore = createWorkspaceKvStore({ tableName: "cloud_plugin_install_configs", valueColumn: "config_json", parse: parseInstalledCloudPlugins, serialize: (value) => JSON.stringify(value), }); export async function readInstalledCloudPlugins(config: ServerConfig, workspaceId: string): Promise { return await cloudPluginInstallStore.get(config, workspaceId) ?? readCloudImports({}); } async function writeInstalledCloudPlugins( config: ServerConfig, workspaceId: string, updater: (current: WorkspaceCloudImports) => WorkspaceCloudImports, ): Promise { const next = updater(await readInstalledCloudPlugins(config, workspaceId)); await cloudPluginInstallStore.set(config, workspaceId, next); return next; } function resolveWorkspaceInstallPath(workspaceRoot: string, relativePath: string): string { const normalized = relativePath.trim().replace(/^\/+/, ""); const parts = normalized.split("/").filter(Boolean); if (!normalized.startsWith(".opencode/") || parts.some((part) => part === "." || part === "..")) { throw new ApiError(400, "invalid_cloud_plugin_path", `Invalid cloud plugin path: ${relativePath}`); } const root = resolve(workspaceRoot); const candidate = resolve(root, normalized); if (candidate !== root && !candidate.startsWith(`${root}/`)) { throw new ApiError(400, "invalid_cloud_plugin_path", `Invalid cloud plugin path: ${relativePath}`); } return candidate; } async function writePluginWorkspaceFile(workspaceRoot: string, path: string, content: string): Promise { const absolutePath = resolveWorkspaceInstallPath(workspaceRoot, path); await mkdir(dirname(absolutePath), { recursive: true }); await writeFile(absolutePath, content.endsWith("\n") ? content : `${content}\n`, "utf8"); } async function removePluginWorkspaceFile(workspaceRoot: string, path: string): Promise { if (!path.startsWith(".opencode/")) return; const absolutePath = resolveWorkspaceInstallPath(workspaceRoot, path); if (/^\.opencode\/skills\/[^/]+\/[^/]+\/SKILL\.md$/.test(path)) { await rm(dirname(absolutePath), { recursive: true, force: true }); return; } await rm(absolutePath, { force: true }); } function cloudPluginMcpNameFromPath(path: string): string | null { if (!path.startsWith(OPENCODE_MCP_IMPORT_PATH_PREFIX)) return null; const name = path.slice(OPENCODE_MCP_IMPORT_PATH_PREFIX.length).trim(); return OPENCODE_MCP_NAME_RE.test(name) ? name : null; } export async function installCloudPlugin(input: { serverConfig: ServerConfig; workspaceId: string; workspaceRoot: string; marketplaceId: string | null; marketplace?: { id: string; name: string; updatedAt: string | null } | null; resolved: CloudPluginResolved; }): Promise { const namespace = pluginNamespace(input.resolved.plugin.name, input.resolved.plugin.id); const cloudImports = await readInstalledCloudPlugins(input.serverConfig, input.workspaceId); const existing = cloudImports.plugins[input.resolved.plugin.id]; const files: CloudImportedPluginFile[] = []; const warnings: string[] = []; for (const membership of input.resolved.memberships) { const object = membership.configObject; const version = object?.latestVersion ?? null; if (!object) continue; if (object.status !== "active") { if (object.objectType === "mcp") warnings.push(mcpInactiveWarning(object.title)); continue; } if (object.objectType === "mcp") { const configs = pluginMcpConfigsFromPayload(object, namespace); if (configs.length === 0) warnings.push(mcpNoConfigWarning(object.title)); for (const config of configs) { await addMcp(input.serverConfig, input.workspaceId, config.name, config.config); files.push({ configObjectId: object.id, versionId: version?.id ?? null, objectType: object.objectType, title: object.title, path: config.path, updatedAt: object.updatedAt, }); } continue; } if (version?.rawSourceText == null) continue; const path = getPluginObjectInstallPath(object, namespace); let content = version.rawSourceText; if (object.objectType === "skill") { const description = cloudConfigObjectDescription(object) || "Skill"; const installName = path.match(/^\.opencode\/skills\/[^/]+\/([^/]+)\/SKILL\.md$/)?.[1] ?? slugifyConfigObjectName(object.title, object.id); content = buildCloudSkillContent(installName, description, extractSkillBodyMarkdown(content)); } else if (object.objectType === "agent") { content = buildCloudAgentContent(cloudConfigObjectDescription(object), content); } else if (object.objectType === "command") { const fileName = path.match(/\/([^/]+)\.md$/)?.[1] ?? object.title; content = buildCloudCommandContent(slugifyConfigObjectName(fileName, object.id), cloudConfigObjectDescription(object), content); } await writePluginWorkspaceFile(input.workspaceRoot, path, content); files.push({ configObjectId: object.id, versionId: version.id, objectType: object.objectType, title: object.title, path, updatedAt: object.updatedAt, }); } const nextPaths = new Set(files.map((file) => file.path)); const removedMcpNames = (existing?.files ?? []).flatMap((file) => { const name = file.objectType === "mcp" && !nextPaths.has(file.path) ? cloudPluginMcpNameFromPath(file.path) : null; return name ? [name] : []; }); await Promise.all(removedMcpNames.map((name) => removeMcp(input.serverConfig, input.workspaceId, name))); const imported: CloudImportedPlugin = { pluginId: input.resolved.plugin.id, marketplaceId: input.marketplaceId, name: input.resolved.plugin.name, description: input.resolved.plugin.description, updatedAt: input.resolved.plugin.updatedAt, files, importedAt: existing?.importedAt ?? Date.now(), }; const nextPlugins = { ...cloudImports.plugins, [input.resolved.plugin.id]: imported, }; let nextMarketplaces = cloudImports.marketplaces; if (input.marketplaceId) { const existingMarketplace = cloudImports.marketplaces[input.marketplaceId]; const pluginIds = new Set(existingMarketplace?.pluginIds ?? []); pluginIds.add(input.resolved.plugin.id); nextMarketplaces = { ...cloudImports.marketplaces, [input.marketplaceId]: { marketplaceId: input.marketplaceId, name: input.marketplace?.name ?? existingMarketplace?.name ?? input.marketplaceId, updatedAt: input.marketplace?.updatedAt ?? existingMarketplace?.updatedAt ?? null, pluginIds: [...pluginIds].sort(), importedAt: existingMarketplace?.importedAt ?? Date.now(), }, }; } await writeInstalledCloudPlugins(input.serverConfig, input.workspaceId, (current) => ({ ...current, marketplaces: nextMarketplaces, plugins: nextPlugins, })); return { item: imported, warnings }; } export async function removeCloudPlugin(input: { serverConfig: ServerConfig; workspaceId: string; workspaceRoot: string; pluginId: string; }): Promise { const cloudImports = await readInstalledCloudPlugins(input.serverConfig, input.workspaceId); const imported = cloudImports.plugins[input.pluginId]; if (!imported) throw new ApiError(404, "cloud_plugin_not_installed", "Marketplace package is not installed in this workspace."); await Promise.all(imported.files.map(async (file) => { const mcpName = file.objectType === "mcp" ? cloudPluginMcpNameFromPath(file.path) : null; if (mcpName) { await removeMcp(input.serverConfig, input.workspaceId, mcpName); return; } await removePluginWorkspaceFile(input.workspaceRoot, file.path); })); const nextPlugins = { ...cloudImports.plugins }; delete nextPlugins[input.pluginId]; const nextMarketplaces = Object.fromEntries(Object.entries(cloudImports.marketplaces).flatMap(([marketplaceId, marketplace]) => { const pluginIds = marketplace.pluginIds.filter((id) => id !== input.pluginId); if (pluginIds.length === 0) return []; return [[marketplaceId, { ...marketplace, pluginIds }]]; })); await writeInstalledCloudPlugins(input.serverConfig, input.workspaceId, (current) => ({ ...current, marketplaces: nextMarketplaces, plugins: nextPlugins, })); return imported; }