diff --git a/src/cli/cli-installer.ts b/src/cli/cli-installer.ts index 5db015531..173d1e624 100644 --- a/src/cli/cli-installer.ts +++ b/src/cli/cli-installer.ts @@ -4,10 +4,13 @@ import type { InstallArgs } from "./types" import { addPluginToOpenCodeConfig, detectCurrentConfig, + getConfigDir, getOpenCodeVersion, isOpenCodeInstalled, writeOmoConfig, } from "./config-manager" +import { loadPluginConfig } from "../plugin-config" +import { syncBootstrapAgents } from "../plugin-handlers/opencode-agent-bootstrap" import { SYMBOLS, argsToConfig, @@ -124,6 +127,14 @@ export async function runCliInstaller(args: InstallArgs, version: string): Promi } printSuccess(`Config written ${SYMBOLS.arrow} ${color.dim(omoResult.configPath)}`) + const pluginConfig = loadPluginConfig(getConfigDir(), {}) + const bootstrapResult = await syncBootstrapAgents({ + directory: getConfigDir(), + targetDir: getConfigDir(), + pluginConfig, + }) + printSuccess(`Bootstrapped ${bootstrapResult.agentCount} OpenCode agent files`) + printBox(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete") if (!config.hasClaude) { diff --git a/src/cli/config-manager.ts b/src/cli/config-manager.ts index 43cbd6dab..a6047e0e4 100644 --- a/src/cli/config-manager.ts +++ b/src/cli/config-manager.ts @@ -2,6 +2,7 @@ export type { ConfigContext } from "./config-manager/config-context" export { initConfigContext, getConfigContext, + getConfigDir, resetConfigContext, } from "./config-manager/config-context" diff --git a/src/cli/tui-installer.ts b/src/cli/tui-installer.ts index 4272557c0..8dceddb15 100644 --- a/src/cli/tui-installer.ts +++ b/src/cli/tui-installer.ts @@ -5,10 +5,13 @@ import type { InstallArgs } from "./types" import { addPluginToOpenCodeConfig, detectCurrentConfig, + getConfigDir, getOpenCodeVersion, isOpenCodeInstalled, writeOmoConfig, } from "./config-manager" +import { loadPluginConfig } from "../plugin-config" +import { syncBootstrapAgents } from "../plugin-handlers/opencode-agent-bootstrap" import { detectedToInitialValues, formatConfigSummary, SYMBOLS } from "./install-validators" import { getUnsupportedOpenCodeVersionMessage } from "./minimum-opencode-version" import { promptInstallConfig } from "./tui-install-prompts" @@ -70,6 +73,15 @@ export async function runTuiInstaller(args: InstallArgs, version: string): Promi } spinner.stop(`Config written to ${color.cyan(omoResult.configPath)}`) + spinner.start("Bootstrapping OpenCode agent files") + const pluginConfig = loadPluginConfig(getConfigDir(), {}) + const bootstrapResult = await syncBootstrapAgents({ + directory: getConfigDir(), + targetDir: getConfigDir(), + pluginConfig, + }) + spinner.stop(`Bootstrapped ${bootstrapResult.agentCount} OpenCode agent files`) + if (!config.hasClaude) { p.log.info( `${color.bold("Note:")} Sisyphus agent performs best with Claude Opus 4.5+.\n` + diff --git a/src/index.ts b/src/index.ts index b36e6c4b9..3084ca02e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ import { detectExternalSkillPlugin, getSkillPluginConflictWarning } from "./shar import { startBackgroundCheck as startTmuxCheck } from "./tools/interactive-bash" import { lspManager } from "./tools/lsp/client" import { createPluginPostHog, getPostHogDistinctId } from "./shared/posthog" +import { syncBootstrapAgents } from "./plugin-handlers/opencode-agent-bootstrap" let activePluginDispose: PluginDispose | null = null @@ -39,6 +40,13 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const pluginConfig = loadPluginConfig(ctx.directory, ctx) + syncBootstrapAgents({ + directory: ctx.directory, + pluginConfig, + }).catch((error) => { + log("[OhMyOpenCodePlugin] bootstrap agent sync failed", { error: String(error) }) + }) + const posthog = createPluginPostHog() const distinctId = getPostHogDistinctId() try { diff --git a/src/plugin-handlers/opencode-agent-bootstrap.test.ts b/src/plugin-handlers/opencode-agent-bootstrap.test.ts new file mode 100644 index 000000000..49737b693 --- /dev/null +++ b/src/plugin-handlers/opencode-agent-bootstrap.test.ts @@ -0,0 +1,31 @@ +/// + +import { describe, expect, test } from "bun:test" + +import { buildBootstrapAgentFiles } from "./opencode-agent-bootstrap" + +describe("buildBootstrapAgentFiles", () => { + test("keeps ZWSP runtime names while writing plain-key agent files", () => { + const files = buildBootstrapAgentFiles({ + "\u200BSisyphus - Ultraworker": { + name: "\u200BSisyphus - Ultraworker", + mode: "primary", + model: "openai/gpt-5.4", + topP: 0.9, + prompt: "sisyphus prompt", + }, + build: { + name: "build", + mode: "primary", + prompt: "build prompt", + }, + }) + + expect(files).toHaveLength(1) + expect(files[0]?.fileName).toBe("sisyphus.md") + expect(files[0]?.content).toContain("name: \u200BSisyphus - Ultraworker") + expect(files[0]?.content).toContain("top_p: 0.9") + expect(files[0]?.content).toContain("sisyphus prompt") + expect(files[0]?.content).not.toContain("build prompt") + }) +}) diff --git a/src/plugin-handlers/opencode-agent-bootstrap.ts b/src/plugin-handlers/opencode-agent-bootstrap.ts new file mode 100644 index 000000000..60ecd5f44 --- /dev/null +++ b/src/plugin-handlers/opencode-agent-bootstrap.ts @@ -0,0 +1,173 @@ +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { join } from "node:path" + +import yaml from "js-yaml" + +import type { OhMyOpenCodeConfig } from "../config" +import { detectPluginConfigFile, getOpenCodeConfigDir, parseJsonc } from "../shared" +import { getAgentConfigKey } from "../shared/agent-display-names" +import { applyAgentConfig } from "./agent-config-handler" +import { applyToolConfig } from "./tool-config-handler" + +const MANIFEST_FILE_NAME = ".oh-my-openagent-agent-bootstrap.json" +const AGENT_DIRECTORY_NAME = "agent" +const EXCLUDED_AGENT_KEYS = new Set(["build", "plan"]) + +type PluginComponents = { + commands: Record + skills: Record + agents: Record + mcpServers: Record + hooksConfigs: Array<{ hooks?: Record }> + plugins: Array<{ name: string; version: string }> + errors: Array<{ pluginKey: string; installPath: string; error: string }> +} + +type AgentFileEntry = { + fileName: string + content: string +} + +type AgentManifest = { + files: string[] +} + +function createEmptyPluginComponents(): PluginComponents { + return { + commands: {}, + skills: {}, + agents: {}, + mcpServers: {}, + hooksConfigs: [], + plugins: [], + errors: [], + } +} + +function normalizeFrontmatterValue(value: Record): Record { + const normalized = { ...value } + + if ("prompt" in normalized) { + delete normalized.prompt + } + + if ("topP" in normalized && normalized.topP !== undefined) { + normalized["top_p"] = normalized.topP + delete normalized.topP + } + + return normalized +} + +export function buildBootstrapAgentFiles(agentResult: Record): AgentFileEntry[] { + const files: AgentFileEntry[] = [] + + for (const [runtimeKey, agentConfig] of Object.entries(agentResult)) { + if (!agentConfig || typeof agentConfig !== "object") { + continue + } + + const configKey = getAgentConfigKey(runtimeKey) + if (!configKey || EXCLUDED_AGENT_KEYS.has(configKey)) { + continue + } + + const normalized = normalizeFrontmatterValue(agentConfig as Record) + const prompt = typeof (agentConfig as Record).prompt === "string" + ? ((agentConfig as Record).prompt as string) + : "" + + const frontmatter = yaml.dump(normalized, { + lineWidth: 1000, + noRefs: true, + sortKeys: true, + }).trim() + + files.push({ + fileName: `${configKey}.md`, + content: `---\n${frontmatter}\n---\n${prompt}\n`, + }) + } + + return files +} + +function readManifest(manifestPath: string): AgentManifest { + if (!existsSync(manifestPath)) { + return { files: [] } + } + + try { + const content = readFileSync(manifestPath, "utf-8") + const parsed = parseJsonc(content) + if (Array.isArray(parsed?.files)) { + return { files: parsed.files.filter((file) => typeof file === "string") } + } + } catch { + // Ignore malformed manifests and rebuild from scratch. + } + + return { files: [] } +} + +function writeManifest(manifestPath: string, files: string[]): void { + writeFileSync(manifestPath, `${JSON.stringify({ files }, null, 2)}\n`, "utf-8") +} + +export function resolveBootstrapTargetDir(directory: string): string { + const projectPluginConfig = detectPluginConfigFile(join(directory, ".opencode")) + if (projectPluginConfig.format !== "none") { + return join(directory, ".opencode") + } + + return getOpenCodeConfigDir({ binary: "opencode" }) +} + +export async function syncBootstrapAgents(params: { + directory: string + pluginConfig: OhMyOpenCodeConfig + targetDir?: string +}): Promise<{ agentCount: number; targetDir: string }> { + const targetDir = params.targetDir ?? resolveBootstrapTargetDir(params.directory) + const config: Record = { agent: {} } + + const agentResult = await applyAgentConfig({ + config, + pluginConfig: params.pluginConfig, + ctx: { directory: params.directory }, + pluginComponents: createEmptyPluginComponents(), + }) + + applyToolConfig({ + config, + pluginConfig: params.pluginConfig, + agentResult, + }) + + const files = buildBootstrapAgentFiles(agentResult) + const agentDir = join(targetDir, AGENT_DIRECTORY_NAME) + const manifestPath = join(targetDir, MANIFEST_FILE_NAME) + const previousManifest = readManifest(manifestPath) + + mkdirSync(agentDir, { recursive: true }) + + const nextFileNames = new Set(files.map((file) => file.fileName)) + for (const previousFile of previousManifest.files) { + if (nextFileNames.has(previousFile)) { + continue + } + + rmSync(join(agentDir, previousFile), { force: true }) + } + + for (const file of files) { + writeFileSync(join(agentDir, file.fileName), file.content, "utf-8") + } + + writeManifest(manifestPath, files.map((file) => file.fileName)) + + return { + agentCount: files.length, + targetDir, + } +}