233 lines
10 KiB
TypeScript
233 lines
10 KiB
TypeScript
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<typeof entrySchema>;
|
|
type Options = NonNullable<Entry["options"]>;
|
|
|
|
// 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<Entry["options"]> = {};
|
|
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<typeof configSchema> = {
|
|
...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);
|
|
}
|