import { mkdir, rename, rm, copyFile } from "node:fs/promises"; import { join } from "node:path"; import { createInterface } from "node:readline/promises"; import { z } from "zod"; const names = ["agents", "multi-work", "quota"] as const; const base = "./plugins/stanp-opencode"; const entrySchema = z .object({ package: z.string(), options: z.record(z.string(), z.json()).optional() }) .catchall(z.json()); type Entry = z.infer; type Options = NonNullable; // Every option the plugins read, empty so users can fill them in; plugins treat "" as unset. function skeleton(name: (typeof names)[number]): Options { if (name === "agents") return { agents: Object.fromEntries( ["fast-lo", "fast-hi", "good-lo", "good-hi", "deep-lo", "deep-hi"].map((worker) => [ worker, "", ]), ), }; return {}; } const nested = z.record(z.string(), z.json()); /** Adds missing keys from `defaults` without overwriting existing values. */ function fill(target: Options, defaults: Options) { for (const [key, value] of Object.entries(defaults)) { const existing = nested.safeParse(target[key]); const missing = nested.safeParse(value); if (target[key] === undefined) target[key] = structuredClone(value); else if (existing.success && missing.success) { fill(existing.data, missing.data); target[key] = existing.data; } } } const pluginSchema = z.union([z.string(), entrySchema]); const registrationSchema = z .union([z.string().transform((packageName) => ({ package: packageName })), entrySchema]) .pipe(entrySchema); const experimentalSchema = z.object({ subagent_depth: z.number().optional() }).catchall(z.json()); const configSchema = z .object({ plugins: z.array(pluginSchema).optional(), $schema: z.string().optional() }) .catchall(z.json()); const nativePlugin = z.union([ z.object({ id: z.string(), setup: z.function() }), z.object({ id: z.string(), effect: z.function() }), ]); type DirectoryExports = { ".": string; "./tui"?: string }; export async function install(source: string, config: string, selection?: string) { const absoluteBase = join(config, "plugins", "stanp-opencode"); const monolith = (target: string) => target === base || target === absoluteBase || /^stanp-opencode(?:@[^/]+)?$/.test(target); const owned = new Set( names.flatMap((name) => [ `${base}/${name}`, `${absoluteBase}/${name}`, `stanp-opencode/${name}`, ]), ); const isOwned = (target: string) => monolith(target) || owned.has(target); const configFile = join(config, "opencode.json"); const current = configSchema.parse( (await Bun.file(configFile).exists()) ? await Bun.file(configFile).json() : {}, ); const entries = current.plugins ?? []; // Normalize only the lookup view; unrelated entries retain their original representation. const configured = entries.map((entry) => registrationSchema.parse(entry)); const previous = configured.filter((entry) => isOwned(entry.package)); const oldMonolith = previous.some((entry) => monolith(entry.package)); const retained = oldMonolith ? [...names] : names.filter((name) => previous.some( (entry) => entry.package === `${base}/${name}` || entry.package === `${absoluteBase}/${name}` || entry.package === `stanp-opencode/${name}`, ), ); let chosen: string[]; if (selection !== undefined) chosen = selection.split(","); else if (process.stdin.isTTY) { const input = createInterface({ input: process.stdin, output: process.stdout }); try { const defaults = retained.length ? retained : names; chosen = ( ( await input.question( `Plugins (agents,multi-work,quota) [${defaults.join(",")}]: `, ) ).trim() || defaults.join(",") ).split(","); if (chosen.includes("multi-work") && !chosen.includes("agents")) { if ( /^(y|yes)?$/i.test( ( await input.question("Multi-work requires Agents. Add Agents? [Y/n]: ") ).trim(), ) ) chosen.push("agents"); } } finally { input.close(); } } else { if (!retained.length) throw new Error( "Noninteractive installation requires --plugins agents,multi-work,quota (or a valid subset)", ); chosen = [...retained]; } if ( !z.array(z.enum(names)).min(1).safeParse(chosen).success || new Set(chosen).size !== chosen.length ) throw new Error("Invalid --plugins selection; use unique names: agents,multi-work,quota"); if (chosen.includes("multi-work") && !chosen.includes("agents")) throw new Error("Multi-work requires Agents; select agents,multi-work"); const selected = names.filter((name) => chosen.includes(name)); const registrations: Entry[] = selected.map((name) => { const prior = previous.find( (entry) => entry.package === `${base}/${name}` || entry.package === `${absoluteBase}/${name}` || entry.package === `stanp-opencode/${name}` || (name === "agents" && monolith(entry.package)), ); const options: NonNullable = {}; Object.assign(options, prior?.options); delete options.playwright; fill(options, skeleton(name)); const entry = entrySchema.parse({ ...prior, package: `${base}/${name}` }); delete entry.options; if (Object.keys(options).length) entry.options = options; return entry; }); const first = configured.findIndex((entry) => isOwned(entry.package)); const next = entries.filter((_entry, index) => !isOwned(configured[index]!.package)); next.splice(first < 0 ? next.length : first, 0, ...registrations); const updated: z.infer = { ...current, $schema: current.$schema ?? "https://opencode.ai/config.json", plugins: next, }; // Coordinate delegates to workers from Multi-work children, so routing needs depth >= 2. const experimental = experimentalSchema.safeParse(current.experimental ?? {}); if (selected.includes("agents") && experimental.success) updated.experimental = { ...experimental.data, subagent_depth: Math.max(experimental.data.subagent_depth ?? 0, 2), }; const parent = join(config, "plugins"); await mkdir(parent, { recursive: true }); const staged = join(parent, `.stanp-opencode.new.${process.pid}`); const backup = join(parent, `.stanp-opencode.old.${process.pid}`); const target = join(parent, "stanp-opencode"); const configStage = join(config, `.stanp-opencode.config.${process.pid}`); let saved = false; let activated = false; try { await mkdir(staged); for (const name of selected) { const file = name === "quota" ? "quota-plugin" : name; const entrypoint = join(source, "dist", `${file}.js`); const { default: definition } = await import(entrypoint); const plugin = nativePlugin.parse(definition); if (plugin.id !== `stanp-opencode.${name}`) throw new Error(`Invalid native OpenCode plugin: ${name}`); const directory = join(staged, name); await mkdir(directory); await copyFile(entrypoint, join(directory, "index.js")); const exports: DirectoryExports = { ".": "./index.js" }; if (name === "quota") { const tui = join(source, "dist", "tui.js"); if ( !new Bun.Transpiler({ loader: "js" }) .scan(await Bun.file(tui).text()) .exports.includes("default") ) throw new Error("Quota lacks its TUI entrypoint"); await copyFile(tui, join(directory, "tui.js")); exports["./tui"] = "./tui.js"; } await Bun.write( join(directory, "package.json"), JSON.stringify({ name: `stanp-opencode-${name}`, type: "module", main: "./index.js", exports, }), ); } await Bun.write(configStage, JSON.stringify(updated, null, 4) + "\n"); try { await rename(target, backup); saved = true; } catch (error) { if (!(error instanceof Error && "code" in error && error.code === "ENOENT")) throw error; } await rename(staged, target); activated = true; await rename(configStage, configFile); } catch (error) { if (activated) await rm(target, { recursive: true, force: true }); if (saved) await rename(backup, target); throw error; } finally { await rm(staged, { recursive: true, force: true }); await rm(configStage, { force: true }); } if (saved) await rm(backup, { recursive: true, force: true }); await rm(join(parent, "stanp-opencode.js"), { force: true }); console.log(`Installed ${selected.join(", ")}. Restart OpenCode.`); if (oldMonolith) console.log( "Clean break: quota tracking resets; old Multi-work bindings cannot resume. Existing worktrees are untouched.", ); } if (import.meta.main) { const [source, config, selection] = process.argv.slice(2); if (!source || !config) throw new Error("Expected package directory and config directory"); await install(source, config, selection); }