Files
claude-engineering-plugin/src/targets/opencode.ts
Matt Van Horn 1886c747d0 refactor: extract shared resolveCommandPath helper for colon-splitting
Deduplicate colon-separated command name logic across all 4 targets
(opencode, droid, gemini, qwen) into a single resolveCommandPath()
helper in utils/files.ts.

Addresses review feedback on PR #251.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 07:08:07 -07:00

127 lines
4.6 KiB
TypeScript

import path from "path"
import { backupFile, copyDir, ensureDir, pathExists, readJson, resolveCommandPath, writeJson, writeText } from "../utils/files"
import type { OpenCodeBundle, OpenCodeConfig } from "../types/opencode"
// Merges plugin config into existing opencode.json. User keys win on conflict. See ADR-002.
async function mergeOpenCodeConfig(
configPath: string,
incoming: OpenCodeConfig,
): Promise<OpenCodeConfig> {
// If no existing config, write plugin config as-is
if (!(await pathExists(configPath))) return incoming
let existing: OpenCodeConfig
try {
existing = await readJson<OpenCodeConfig>(configPath)
} catch {
// Safety first per AGENTS.md -- do not destroy user data even if their config is malformed.
// Warn and fall back to plugin-only config rather than crashing.
console.warn(
`Warning: existing ${configPath} is not valid JSON. Writing plugin config without merging.`
)
return incoming
}
// User config wins on conflict -- see ADR-002
// MCP servers: add plugin entry, skip keys already in user config.
const mergedMcp = {
...(incoming.mcp ?? {}),
...(existing.mcp ?? {}), // existing takes precedence (overwrites same-named plugin entry)
}
// Permission: add plugin entry, skip keys already in user config.
const mergedPermission = incoming.permission
? {
...(incoming.permission),
...(existing.permission ?? {}), // existing takes precedence
}
: existing.permission
// Tools: same pattern
const mergedTools = incoming.tools
? {
...(incoming.tools),
...(existing.tools ?? {}),
}
: existing.tools
return {
...existing, // all user keys preserved
$schema: incoming.$schema ?? existing.$schema,
mcp: Object.keys(mergedMcp).length > 0 ? mergedMcp : undefined,
permission: mergedPermission,
tools: mergedTools,
}
}
export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBundle): Promise<void> {
const openCodePaths = resolveOpenCodePaths(outputRoot)
await ensureDir(openCodePaths.root)
const hadExistingConfig = await pathExists(openCodePaths.configPath)
const backupPath = await backupFile(openCodePaths.configPath)
if (backupPath) {
console.log(`Backed up existing config to ${backupPath}`)
}
const merged = await mergeOpenCodeConfig(openCodePaths.configPath, bundle.config)
await writeJson(openCodePaths.configPath, merged)
if (hadExistingConfig) {
console.log("Merged plugin config into existing opencode.json (user settings preserved)")
}
const agentsDir = openCodePaths.agentsDir
for (const agent of bundle.agents) {
await writeText(path.join(agentsDir, `${agent.name}.md`), agent.content + "\n")
}
for (const commandFile of bundle.commandFiles) {
const dest = await resolveCommandPath(openCodePaths.commandDir, commandFile.name, ".md")
const cmdBackupPath = await backupFile(dest)
if (cmdBackupPath) {
console.log(`Backed up existing command file to ${cmdBackupPath}`)
}
await writeText(dest, commandFile.content + "\n")
}
if (bundle.plugins.length > 0) {
const pluginsDir = openCodePaths.pluginsDir
for (const plugin of bundle.plugins) {
await writeText(path.join(pluginsDir, plugin.name), plugin.content + "\n")
}
}
if (bundle.skillDirs.length > 0) {
const skillsRoot = openCodePaths.skillsDir
for (const skill of bundle.skillDirs) {
await copyDir(skill.sourceDir, path.join(skillsRoot, skill.name))
}
}
}
function resolveOpenCodePaths(outputRoot: string) {
const base = path.basename(outputRoot)
// Global install: ~/.config/opencode (basename is "opencode")
// Project install: .opencode (basename is ".opencode")
if (base === "opencode" || base === ".opencode") {
return {
root: outputRoot,
configPath: path.join(outputRoot, "opencode.json"),
agentsDir: path.join(outputRoot, "agents"),
pluginsDir: path.join(outputRoot, "plugins"),
skillsDir: path.join(outputRoot, "skills"),
// .md command files; alternative to the command key in opencode.json
commandDir: path.join(outputRoot, "commands"),
}
}
// Custom output directory - nest under .opencode subdirectory
return {
root: outputRoot,
configPath: path.join(outputRoot, "opencode.json"),
agentsDir: path.join(outputRoot, ".opencode", "agents"),
pluginsDir: path.join(outputRoot, ".opencode", "plugins"),
skillsDir: path.join(outputRoot, ".opencode", "skills"),
// .md command files; alternative to the command key in opencode.json
commandDir: path.join(outputRoot, ".opencode", "commands"),
}
}