diff --git a/src/shared/opencode-command-dirs.ts b/src/shared/opencode-command-dirs.ts index 34f2ecce1..4066fe640 100644 --- a/src/shared/opencode-command-dirs.ts +++ b/src/shared/opencode-command-dirs.ts @@ -1,5 +1,5 @@ import { basename, dirname, join } from "node:path" -import { getOpenCodeConfigDir } from "./opencode-config-dir" +import { getOpenCodeConfigDirs } from "./opencode-config-dir" import type { OpenCodeConfigDirOptions } from "./opencode-config-dir-types" function getParentOpencodeConfigDir(configDir: string): string | null { @@ -12,25 +12,33 @@ function getParentOpencodeConfigDir(configDir: string): string | null { } export function getOpenCodeCommandDirs(options: OpenCodeConfigDirOptions): string[] { - const configDir = getOpenCodeConfigDir(options) - const parentConfigDir = getParentOpencodeConfigDir(configDir) + const configDirs = getOpenCodeConfigDirs(options) return Array.from( new Set([ - join(configDir, "commands"), - join(configDir, "command"), - ...(parentConfigDir ? [join(parentConfigDir, "commands"), join(parentConfigDir, "command")] : []), + ...configDirs.flatMap((configDir) => { + const parentConfigDir = getParentOpencodeConfigDir(configDir) + return [ + join(configDir, "commands"), + join(configDir, "command"), + ...(parentConfigDir ? [join(parentConfigDir, "commands"), join(parentConfigDir, "command")] : []), + ] + }), ]) ) } export function getOpenCodeSkillDirs(options: OpenCodeConfigDirOptions): string[] { - const configDir = getOpenCodeConfigDir(options) - const parentConfigDir = getParentOpencodeConfigDir(configDir) + const configDirs = getOpenCodeConfigDirs(options) return Array.from( new Set([ - join(configDir, "skills"), - join(configDir, "skill"), - ...(parentConfigDir ? [join(parentConfigDir, "skills"), join(parentConfigDir, "skill")] : []), + ...configDirs.flatMap((configDir) => { + const parentConfigDir = getParentOpencodeConfigDir(configDir) + return [ + join(configDir, "skills"), + join(configDir, "skill"), + ...(parentConfigDir ? [join(parentConfigDir, "skills"), join(parentConfigDir, "skill")] : []), + ] + }), ]) ) } diff --git a/src/shared/opencode-config-dir.test.ts b/src/shared/opencode-config-dir.test.ts index 7ddf8e009..275f988c5 100644 --- a/src/shared/opencode-config-dir.test.ts +++ b/src/shared/opencode-config-dir.test.ts @@ -3,6 +3,7 @@ import { homedir } from "node:os" import { join, resolve, win32 } from "node:path" import { getOpenCodeConfigDir, + getOpenCodeConfigDirs, getOpenCodeConfigPaths, isDevBuild, detectExistingConfigDir, @@ -45,7 +46,7 @@ describe("opencode-config-dir", () => { const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" }) // then returns the custom path - expect(result).toBe("/custom/opencode/path") + expect(result).toBe(resolve("/custom/opencode/path")) }) test("falls back to default when env var is not set", () => { @@ -109,7 +110,23 @@ describe("opencode-config-dir", () => { const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" }) // then OPENCODE_CONFIG_DIR takes priority - expect(result).toBe("/custom/opencode/path") + expect(result).toBe(resolve("/custom/opencode/path")) + }) + + test("returns both custom and default config directories for additive discovery", () => { + // given both OPENCODE_CONFIG_DIR and XDG_CONFIG_HOME are set + process.env.OPENCODE_CONFIG_DIR = "/custom/opencode/path" + process.env.XDG_CONFIG_HOME = "/xdg/config" + Object.defineProperty(process, "platform", { value: "linux" }) + + // when getOpenCodeConfigDirs is called + const result = getOpenCodeConfigDirs({ binary: "opencode", version: "1.0.200" }) + + // then the custom path stays first, but the default global path remains visible + expect(result).toEqual([ + resolve("/custom/opencode/path"), + resolve("/xdg/config/opencode"), + ]) }) }) @@ -163,7 +180,7 @@ describe("opencode-config-dir", () => { const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" }) // then returns $XDG_CONFIG_HOME/opencode - expect(result).toBe("/custom/config/opencode") + expect(result).toBe(resolve("/custom/config/opencode")) }) test("returns ~/.config/opencode on macOS", () => { diff --git a/src/shared/opencode-config-dir.ts b/src/shared/opencode-config-dir.ts index 691d0e2c4..de51fbdf1 100644 --- a/src/shared/opencode-config-dir.ts +++ b/src/shared/opencode-config-dir.ts @@ -55,16 +55,37 @@ function resolveConfigPath(pathValue: string): string { } } -function getCliConfigDir(): string { - const envConfigDir = process.env.OPENCODE_CONFIG_DIR?.trim() - if (envConfigDir) { - return resolveConfigPath(envConfigDir) - } - +function getCliDefaultConfigDir(): string { const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config") return resolveConfigPath(join(xdgConfig, "opencode")) } +function getCliCustomConfigDir(): string | null { + const envConfigDir = process.env.OPENCODE_CONFIG_DIR?.trim() + if (!envConfigDir) { + return null + } + + return resolveConfigPath(envConfigDir) +} + +function getCliConfigDir(): string { + return getCliCustomConfigDir() ?? getCliDefaultConfigDir() +} + +export function getOpenCodeConfigDirs(options: OpenCodeConfigDirOptions): string[] { + if (options.binary !== "opencode") { + return [getOpenCodeConfigDir(options)] + } + + return Array.from( + new Set([ + ...(getCliCustomConfigDir() ? [getCliCustomConfigDir()!] : []), + getCliDefaultConfigDir(), + ]), + ) +} + export function getOpenCodeConfigDir(options: OpenCodeConfigDirOptions): string { const { binary, version, checkExisting = true } = options diff --git a/src/tools/slashcommand/command-discovery.test.ts b/src/tools/slashcommand/command-discovery.test.ts index fc193b61f..d45ef88b8 100644 --- a/src/tools/slashcommand/command-discovery.test.ts +++ b/src/tools/slashcommand/command-discovery.test.ts @@ -20,6 +20,7 @@ const ENV_KEYS = [ "CLAUDE_PLUGINS_HOME", "CLAUDE_SETTINGS_PATH", "OPENCODE_CONFIG_DIR", + "XDG_CONFIG_HOME", ] as const type EnvKey = (typeof ENV_KEYS)[number] @@ -119,6 +120,7 @@ describe("slashcommand command discovery plugin integration", () => { CLAUDE_PLUGINS_HOME: process.env.CLAUDE_PLUGINS_HOME, CLAUDE_SETTINGS_PATH: process.env.CLAUDE_SETTINGS_PATH, OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR, + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, } const setup = writePluginFixture(tempDir) projectDir = setup.projectDir @@ -193,6 +195,40 @@ Use parent opencode commit command. expect(commitCommand?.content).toContain("Use parent opencode commit command.") }) + it("discovers commands from both OPENCODE_CONFIG_DIR and the default global config directory", () => { + const defaultGlobalDir = join(tempDir, "xdg", "opencode", "commands") + const customGlobalDir = join(tempDir, "custom-opencode", "commands") + + mkdirSync(defaultGlobalDir, { recursive: true }) + mkdirSync(customGlobalDir, { recursive: true }) + + writeFileSync( + join(defaultGlobalDir, "global-default.md"), + `--- +description: Default global opencode command +--- +Use default global command. +`, + ) + writeFileSync( + join(customGlobalDir, "global-custom.md"), + `--- +description: Custom global opencode command +--- +Use custom global command. +`, + ) + + process.env.XDG_CONFIG_HOME = join(tempDir, "xdg") + process.env.OPENCODE_CONFIG_DIR = join(tempDir, "custom-opencode") + + const commands = discoverCommandsSync(projectDir) + const names = commands.map(command => command.name) + + expect(names).toContain("global-default") + expect(names).toContain("global-custom") + }) + it("discovers ancestor project opencode commands from plural commands directory", () => { const projectRoot = join(projectDir, "workspace") const childDir = join(projectRoot, "apps", "cli")