diff --git a/bun.lock b/bun.lock index 30e2ea4e9..90abd9f19 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "compound-plugin", diff --git a/src/commands/install.ts b/src/commands/install.ts index 4fee80094..7023bcf91 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -96,7 +96,7 @@ export default defineCommand({ try { const plugin = await loadClaudePlugin(resolvedPlugin.path) - const outputRoot = resolveOutputRoot(args.output) + const { root: outputRoot, isGlobalOpenCodeConfig } = resolveOutputRoot(args.output) const codexHome = resolveTargetHome(args.codexHome, path.join(os.homedir(), ".codex")) const piHome = resolveTargetHome(args.piHome, path.join(os.homedir(), ".pi", "agent")) const hasExplicitOutput = Boolean(args.output && String(args.output).trim()) @@ -144,7 +144,9 @@ export default defineCommand({ pluginName: plugin.manifest.name, hasExplicitOutput, }) - await handler.write(root, bundle) + const writeScope = + tool.name === "opencode" && isGlobalOpenCodeConfig ? "global" : handler.defaultScope + await handler.write(root, bundle, writeScope) console.log(`Installed ${plugin.manifest.name} to ${tool.name} at ${root}`) } @@ -163,6 +165,12 @@ export default defineCommand({ } const resolvedScope = validateScope(targetName, target, args.scope ? String(args.scope) : undefined) + // For OpenCode, if the output root is the global config dir (default or OPENCODE_CONFIG_DIR), + // use "global" scope so writeOpenCodeBundle writes the flat layout regardless of basename. + const effectiveScope = + targetName === "opencode" && isGlobalOpenCodeConfig && resolvedScope === undefined + ? "global" + : resolvedScope const bundle = target.convert(plugin, options) if (!bundle) { @@ -177,9 +185,9 @@ export default defineCommand({ qwenHome, pluginName: plugin.manifest.name, hasExplicitOutput, - scope: resolvedScope, + scope: effectiveScope, }) - await target.write(primaryOutputRoot, bundle, resolvedScope) + await target.write(primaryOutputRoot, bundle, effectiveScope) console.log(`Installed ${plugin.manifest.name} to ${primaryOutputRoot}`) const extraTargets = parseExtraTargets(args.also) @@ -259,14 +267,19 @@ function parseExtraTargets(value: unknown): string[] { .filter(Boolean) } -function resolveOutputRoot(value: unknown): string { +function resolveOutputRoot(value: unknown): { root: string; isGlobalOpenCodeConfig: boolean } { if (value && String(value).trim()) { const expanded = expandHome(String(value).trim()) - return path.resolve(expanded) + return { root: path.resolve(expanded), isGlobalOpenCodeConfig: false } } - // OpenCode global config lives at ~/.config/opencode per XDG spec + // OpenCode global config: respect OPENCODE_CONFIG_DIR if set, otherwise + // fall back to ~/.config/opencode per XDG spec. // See: https://opencode.ai/docs/config/ - return path.join(os.homedir(), ".config", "opencode") + const envDir = process.env.OPENCODE_CONFIG_DIR?.trim() + if (envDir) { + return { root: path.resolve(expandHome(envDir)), isGlobalOpenCodeConfig: true } + } + return { root: path.join(os.homedir(), ".config", "opencode"), isGlobalOpenCodeConfig: true } } async function resolveBundledPluginPath(pluginName: string): Promise { diff --git a/src/sync/registry.ts b/src/sync/registry.ts index e3f58e60c..3fbe55db5 100644 --- a/src/sync/registry.ts +++ b/src/sync/registry.ts @@ -1,6 +1,7 @@ import os from "os" import path from "path" import type { ClaudeHomeConfig } from "../parsers/claude-home" +import { expandHome } from "../utils/resolve-home" import { syncToCodex } from "./codex" import { syncToCopilot } from "./copilot" import { syncToDroid } from "./droid" @@ -12,6 +13,14 @@ import { syncToPi } from "./pi" import { syncToQwen } from "./qwen" import { syncToWindsurf } from "./windsurf" +function resolveOpenCodeConfigDir(home: string): string { + const envDir = process.env.OPENCODE_CONFIG_DIR?.trim() + if (envDir) { + return path.resolve(expandHome(envDir)) + } + return path.join(home, ".config", "opencode") +} + function getCopilotHomeRoot(home: string): string { return path.join(home, ".copilot") } @@ -43,10 +52,10 @@ export const syncTargets: SyncTargetDefinition[] = [ { name: "opencode", detectPaths: (home, cwd) => [ - path.join(home, ".config", "opencode"), + resolveOpenCodeConfigDir(home), path.join(cwd, ".opencode"), ], - resolveOutputRoot: (home) => path.join(home, ".config", "opencode"), + resolveOutputRoot: (home) => resolveOpenCodeConfigDir(home), sync: syncToOpenCode, }, { diff --git a/src/targets/opencode.ts b/src/targets/opencode.ts index b80f2422e..b01b44c86 100644 --- a/src/targets/opencode.ts +++ b/src/targets/opencode.ts @@ -55,8 +55,8 @@ async function mergeOpenCodeConfig( } } -export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBundle): Promise { - const openCodePaths = resolveOpenCodePaths(outputRoot) +export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBundle, scope?: string): Promise { + const openCodePaths = resolveOpenCodePaths(outputRoot, scope) await ensureDir(openCodePaths.root) const hadExistingConfig = await pathExists(openCodePaths.configPath) @@ -111,11 +111,13 @@ export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBu } } -function resolveOpenCodePaths(outputRoot: string) { +function resolveOpenCodePaths(outputRoot: string, scope?: string) { const base = path.basename(outputRoot) - // Global install: ~/.config/opencode (basename is "opencode") - // Project install: .opencode (basename is ".opencode") - if (base === "opencode" || base === ".opencode") { + // Global install: ~/.config/opencode (basename "opencode"), explicit scope "global", + // or any OPENCODE_CONFIG_DIR-derived root that OpenCode treats as its global config tree. + // Project install: .opencode (basename ".opencode") + const isGlobal = scope === "global" || base === "opencode" || base === ".opencode" + if (isGlobal) { return { root: outputRoot, configPath: path.join(outputRoot, "opencode.json"), diff --git a/tests/cli.test.ts b/tests/cli.test.ts index a062e09fa..1f4a508e6 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -100,6 +100,46 @@ describe("CLI", () => { expect(await exists(path.join(tempRoot, ".config", "opencode", "agents", "repo-research-analyst.md"))).toBe(true) }) + test("install respects OPENCODE_CONFIG_DIR env var for output root", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-opencode-env-")) + const customConfigDir = path.join(tempRoot, "custom-opencode-config") + const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin") + + const repoRoot = path.join(import.meta.dir, "..") + const proc = Bun.spawn([ + "bun", + "run", + path.join(repoRoot, "src", "index.ts"), + "install", + fixtureRoot, + "--to", + "opencode", + ], { + cwd: tempRoot, + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + HOME: tempRoot, + OPENCODE_CONFIG_DIR: customConfigDir, + }, + }) + + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + + if (exitCode !== 0) { + throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`) + } + + expect(stdout).toContain("Installed compound-engineering") + // Output should go to OPENCODE_CONFIG_DIR with flat (global) layout, not ~/.config/opencode + expect(await exists(path.join(customConfigDir, "opencode.json"))).toBe(true) + expect(await exists(path.join(customConfigDir, "agents", "repo-research-analyst.md"))).toBe(true) + expect(await exists(path.join(tempRoot, ".config", "opencode", "opencode.json"))).toBe(false) + }) + test("list returns plugins in a temp workspace", async () => { const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-list-")) const pluginsRoot = path.join(tempRoot, "plugins", "demo-plugin", ".claude-plugin") diff --git a/tests/detect-tools.test.ts b/tests/detect-tools.test.ts index b819909c1..cdf9a3eb5 100644 --- a/tests/detect-tools.test.ts +++ b/tests/detect-tools.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect, test, beforeEach, afterEach } from "bun:test" import { promises as fs } from "fs" import path from "path" import os from "os" @@ -89,6 +89,49 @@ describe("detectInstalledTools", () => { expect(results.find((t) => t.name === "copilot")?.detected).toBe(true) expect(results.find((t) => t.name === "copilot")?.reason).toContain(".github/skills") }) + + test("detects opencode via OPENCODE_CONFIG_DIR env var", async () => { + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "detect-opencode-env-home-")) + const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-opencode-env-cwd-")) + const customConfigDir = path.join(tempHome, "custom-opencode-config") + + // Create dir at the custom path only — NOT at ~/.config/opencode + await fs.mkdir(customConfigDir, { recursive: true }) + + const savedEnv = process.env.OPENCODE_CONFIG_DIR + try { + process.env.OPENCODE_CONFIG_DIR = customConfigDir + const results = await detectInstalledTools(tempHome, tempCwd) + expect(results.find((t) => t.name === "opencode")?.detected).toBe(true) + } finally { + if (savedEnv === undefined) { + delete process.env.OPENCODE_CONFIG_DIR + } else { + process.env.OPENCODE_CONFIG_DIR = savedEnv + } + } + }) + + test("does not detect opencode via OPENCODE_CONFIG_DIR when that dir does not exist", async () => { + const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "detect-opencode-env-miss-")) + const tempCwd = await fs.mkdtemp(path.join(os.tmpdir(), "detect-opencode-env-miss-cwd-")) + const missingDir = path.join(tempHome, "nonexistent-opencode") + + const savedEnv = process.env.OPENCODE_CONFIG_DIR + try { + process.env.OPENCODE_CONFIG_DIR = missingDir + // Also create ~/.config/opencode to confirm it is NOT used when env var points elsewhere + await fs.mkdir(path.join(tempHome, ".config", "opencode"), { recursive: true }) + const results = await detectInstalledTools(tempHome, tempCwd) + expect(results.find((t) => t.name === "opencode")?.detected).toBe(false) + } finally { + if (savedEnv === undefined) { + delete process.env.OPENCODE_CONFIG_DIR + } else { + process.env.OPENCODE_CONFIG_DIR = savedEnv + } + } + }) }) describe("getDetectedTargetNames", () => {