Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 21 additions & 8 deletions src/commands/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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}`)
}

Expand All @@ -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"
Comment on lines +168 to +172
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply OpenCode global scope in --to all path

Fresh evidence from this commit: isGlobalOpenCodeConfig and effectiveScope were added, but effectiveScope is only computed in the single-target branch after the targetName === "all" early return. If OPENCODE_CONFIG_DIR is set to a directory whose basename is not opencode/.opencode, install --to all still calls writeOpenCodeBundle without global scope, so OpenCode files are written under <envDir>/.opencode/... instead of the global <envDir>/{agents,skills,commands} layout.

Useful? React with 👍 / 👎.

: resolvedScope

const bundle = target.convert(plugin, options)
if (!bundle) {
Expand All @@ -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)
Expand Down Expand Up @@ -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 }
Comment on lines +278 to +280
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep global opencode.json writes when OPENCODE_CONFIG_DIR is set

resolveOutputRoot now treats OPENCODE_CONFIG_DIR as the primary global output root, so install --to opencode writes opencode.json there and skips ~/.config/opencode/opencode.json whenever the env var exists. In this repo’s OpenCode spec (docs/specs/opencode.md), custom directory sources are additive in precedence rather than a replacement for global config files, so MCP/permission updates can be silently missed for users who set OPENCODE_CONFIG_DIR for overlays. The config file target should remain the global config path (or explicit OPENCODE_CONFIG) instead of being redirected by OPENCODE_CONFIG_DIR.

Useful? React with 👍 / 👎.

}
Comment on lines +278 to +281
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Treat OPENCODE_CONFIG_DIR as global OpenCode root

Returning OPENCODE_CONFIG_DIR directly as outputRoot causes a layout regression when the directory name is not opencode/.opencode: writeOpenCodeBundle() infers global-vs-workspace structure from path.basename(outputRoot) (src/targets/opencode.ts, resolveOpenCodePaths), so installs go to <envDir>/.opencode/... instead of <envDir>/agents|skills|commands. In that scenario, users who set a custom config dir (for example /tmp/custom-config) will get files written to a path OpenCode does not treat as its global config tree.

Useful? React with 👍 / 👎.

return { root: path.join(os.homedir(), ".config", "opencode"), isGlobalOpenCodeConfig: true }
}

async function resolveBundledPluginPath(pluginName: string): Promise<string | null> {
Expand Down
13 changes: 11 additions & 2 deletions src/sync/registry.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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")
}
Expand Down Expand Up @@ -43,10 +52,10 @@ export const syncTargets: SyncTargetDefinition[] = [
{
name: "opencode",
detectPaths: (home, cwd) => [
path.join(home, ".config", "opencode"),
resolveOpenCodeConfigDir(home),
path.join(cwd, ".opencode"),
Comment on lines 54 to 56
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve global OpenCode detection when custom dir is set

With this change, OpenCode detection uses resolveOpenCodeConfigDir(home) first and no longer checks ~/.config/opencode when OPENCODE_CONFIG_DIR is defined. If that env var points to a missing or transient directory, detectInstalledTools reports OpenCode as not installed even when the global install exists, which can cause install --to all and sync all to skip OpenCode unexpectedly. Detection should consider both the custom directory and the standard global directory rather than treating the env path as exclusive.

Useful? React with 👍 / 👎.

],
resolveOutputRoot: (home) => path.join(home, ".config", "opencode"),
resolveOutputRoot: (home) => resolveOpenCodeConfigDir(home),
sync: syncToOpenCode,
Comment on lines 52 to 59
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

detectPaths / resolveOutputRoot now depend on OPENCODE_CONFIG_DIR, but the tool detection/sync path logic isn’t covered by a test for this env override. The repo already has detectInstalledTools tests; consider adding a case that sets OPENCODE_CONFIG_DIR to a temp dir, creates that dir, and asserts OpenCode is detected (and not detected when only ~/.config/opencode exists and the env var points elsewhere).

Copilot uses AI. Check for mistakes.
},
{
Expand Down
14 changes: 8 additions & 6 deletions src/targets/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,8 @@ async function mergeOpenCodeConfig(
}
}

export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBundle): Promise<void> {
const openCodePaths = resolveOpenCodePaths(outputRoot)
export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBundle, scope?: string): Promise<void> {
const openCodePaths = resolveOpenCodePaths(outputRoot, scope)
await ensureDir(openCodePaths.root)

const hadExistingConfig = await pathExists(openCodePaths.configPath)
Expand Down Expand Up @@ -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"),
Expand Down
40 changes: 40 additions & 0 deletions tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
45 changes: 44 additions & 1 deletion tests/detect-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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", () => {
Expand Down