diff --git a/src/features/claude-code-agent-loader/loader.test.ts b/src/features/claude-code-agent-loader/loader.test.ts index 672ac76eb..2a81b2d51 100644 --- a/src/features/claude-code-agent-loader/loader.test.ts +++ b/src/features/claude-code-agent-loader/loader.test.ts @@ -55,12 +55,32 @@ const NO_FRONTMATTER_AGENT = `Just a prompt with no frontmatter.`; describe("claude-code-agent-loader", () => { const dirs: string[] = []; + const originalEnv = { + CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR, + OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR, + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + } afterEach(() => { for (const dir of dirs) { rmSync(dir, { recursive: true, force: true }); } dirs.length = 0; + if (originalEnv.CLAUDE_CONFIG_DIR === undefined) { + delete process.env.CLAUDE_CONFIG_DIR + } else { + process.env.CLAUDE_CONFIG_DIR = originalEnv.CLAUDE_CONFIG_DIR + } + if (originalEnv.OPENCODE_CONFIG_DIR === undefined) { + delete process.env.OPENCODE_CONFIG_DIR + } else { + process.env.OPENCODE_CONFIG_DIR = originalEnv.OPENCODE_CONFIG_DIR + } + if (originalEnv.XDG_CONFIG_HOME === undefined) { + delete process.env.XDG_CONFIG_HOME + } else { + process.env.XDG_CONFIG_HOME = originalEnv.XDG_CONFIG_HOME + } }); function trackDir(dir: string): string { @@ -204,6 +224,29 @@ describe("claude-code-agent-loader", () => { const result = loadOpencodeGlobalAgents() expect(result).toEqual({}) }) + + test("loads agents from both the custom and default opencode config directories", () => { + const root = trackDir(mkdtempSync(join(tmpdir(), "agent-loader-opencode-global-"))) + const defaultAgentsDir = join(root, "xdg", "opencode", "agents") + const customAgentsDir = join(root, "custom-opencode", "agents") + + mkdirSync(defaultAgentsDir, { recursive: true }) + mkdirSync(customAgentsDir, { recursive: true }) + + writeFileSync(join(defaultAgentsDir, "default-agent.md"), BASIC_AGENT, "utf-8") + writeFileSync( + join(customAgentsDir, "custom-agent.md"), + `---\nname: custom-agent\ndescription: Custom agent\n---\nFrom custom config.`, + "utf-8", + ) + + process.env.XDG_CONFIG_HOME = join(root, "xdg") + process.env.OPENCODE_CONFIG_DIR = join(root, "custom-opencode") + + const result = loadOpencodeGlobalAgents() + + expect(Object.keys(result).sort()).toEqual(["custom-agent", "test-agent"]) + }) }) describe("tools parsing", () => { diff --git a/src/features/claude-code-agent-loader/loader.ts b/src/features/claude-code-agent-loader/loader.ts index 380f479eb..d6bd166c0 100644 --- a/src/features/claude-code-agent-loader/loader.ts +++ b/src/features/claude-code-agent-loader/loader.ts @@ -3,7 +3,7 @@ import { join } from "path" import { isMarkdownFile } from "../../shared/file-utils" import { getClaudeConfigDir } from "../../shared" import type { AgentScope, ClaudeCodeAgentConfig, LoadedAgent } from "./types" -import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir" +import { getOpenCodeConfigDirs } from "../../shared/opencode-config-dir" import { parseMarkdownAgentFile } from "./agent-definitions-loader" function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[] { @@ -51,14 +51,20 @@ export function loadProjectAgents(directory?: string): Record { - const configDir = getOpenCodeConfigDir({ binary: "opencode" }) - const opencodeAgentsDir = join(configDir, "agents") - const agents = loadAgentsFromDir(opencodeAgentsDir, "opencode") - const result: Record = Object.create(null) - for (const agent of agents) { - result[agent.name] = agent.config + const configDirs = getOpenCodeConfigDirs({ binary: "opencode" }) + + for (const configDir of configDirs) { + const opencodeAgentsDir = join(configDir, "agents") + const agents = loadAgentsFromDir(opencodeAgentsDir, "opencode") + + for (const agent of agents) { + if (!(agent.name in result)) { + result[agent.name] = agent.config + } + } } + return result } diff --git a/src/plugin-config.test.ts b/src/plugin-config.test.ts index 4f11b0064..30ec72392 100644 --- a/src/plugin-config.test.ts +++ b/src/plugin-config.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, mock } from "bun:test"; +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" @@ -23,6 +23,7 @@ afterEach(() => { mock.restore() clearConfigLoadErrors() delete process.env.OPENCODE_CONFIG_DIR + delete process.env.XDG_CONFIG_HOME for (const dir of tempDirs.splice(0)) { rmSync(dir, { recursive: true, force: true }) @@ -426,6 +427,12 @@ describe("loadConfigFromPath agent_order warnings", () => { }) describe("loadPluginConfig", () => { + beforeEach(() => { + const isolatedXdgRoot = mkdtempSync(join(tmpdir(), "omo-plugin-config-xdg-")) + tempDirs.push(isolatedXdgRoot) + process.env.XDG_CONFIG_HOME = isolatedXdgRoot + }) + it("should only honor mcp_env_allowlist from user config", async () => { // given const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-")) @@ -757,6 +764,39 @@ describe("loadPluginConfig", () => { expect(config.agents?.oracle?.model).toBe("project/model") }) + it("should load user config from the default global directory even when OPENCODE_CONFIG_DIR is set", async () => { + // given + const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-additive-user-")) + const defaultGlobalConfigDir = join(rootDir, "xdg", "opencode") + const customConfigDir = join(rootDir, "custom-opencode") + const projectDir = join(rootDir, "project") + + tempDirs.push(rootDir) + mkdirSync(defaultGlobalConfigDir, { recursive: true }) + mkdirSync(customConfigDir, { recursive: true }) + mkdirSync(join(projectDir, ".opencode"), { recursive: true }) + + writeFileSync( + join(defaultGlobalConfigDir, "oh-my-openagent.jsonc"), + JSON.stringify({ agents: { oracle: { model: "default/oracle" } } }), + ) + writeFileSync( + join(customConfigDir, "oh-my-openagent.jsonc"), + JSON.stringify({ agents: { hephaestus: { model: "custom/hephaestus" } } }), + ) + + process.env.XDG_CONFIG_HOME = join(rootDir, "xdg") + process.env.OPENCODE_CONFIG_DIR = customConfigDir + + // when + const { loadPluginConfig } = await importFreshPluginConfigModule() + const config = loadPluginConfig(projectDir, {}) + + // then + expect(config.agents?.oracle?.model).toBe("default/oracle") + expect(config.agents?.hephaestus?.model).toBe("custom/hephaestus") + }) + it("should layer ancestor configs so each contributes fields not overridden by closer ones", async () => { // given const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-layer-")) diff --git a/src/plugin-config.ts b/src/plugin-config.ts index b203b00ac..f671ab327 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -6,7 +6,7 @@ import { log, containsPath, deepMerge, - getOpenCodeConfigDir, + getOpenCodeConfigDirs, addConfigLoadError, parseJsonc, detectPluginConfigFile, @@ -281,25 +281,23 @@ export function loadPluginConfig( directory: string, ctx: unknown ): OhMyOpenCodeConfig { - // User-level config path - prefer .jsonc over .json - const configDir = getOpenCodeConfigDir({ binary: "opencode" }); - const userDetected = detectPluginConfigFile(configDir); - let userConfigPath = - userDetected.format !== "none" - ? userDetected.path - : path.join(configDir, `${CONFIG_BASENAME}.json`); + const userConfigDirs = [...getOpenCodeConfigDirs({ binary: "opencode" })].reverse() + const userConfigLayers = userConfigDirs.map((configDir) => { + const detected = detectPluginConfigFile(configDir) - if (userDetected.legacyPath) { - log("Canonical plugin config detected alongside legacy config. Remove the legacy file to avoid confusion.", { - canonicalPath: userDetected.path, - legacyPath: userDetected.legacyPath, - }); - } + if (detected.legacyPath) { + log("Canonical plugin config detected alongside legacy config. Remove the legacy file to avoid confusion.", { + canonicalPath: detected.path, + legacyPath: detected.legacyPath, + }) + } - // Auto-copy legacy config file to canonical name if needed - if (userDetected.format !== "none") { - userConfigPath = resolveConfigPathAfterLegacyMigration(userConfigPath) - } + const configPath = detected.format !== "none" + ? resolveConfigPathAfterLegacyMigration(detected.path) + : null + + return { configDir, configPath } + }) // Pin the walk to $HOME only when the start directory is inside it. Outside // $HOME the walker would otherwise reach FS root and surface unrelated configs @@ -331,20 +329,36 @@ export function loadPluginConfig( }, ) - // Load user config first (base). Parse empty config through Zod to apply field defaults. - const userConfig = loadConfigFromPath(userConfigPath, ctx) - const userGitMasterOverrides = loadExplicitGitMasterOverrides(userConfigPath) + let config: OhMyOpenCodeConfig = OhMyOpenCodeConfigSchema.parse({}) + let mergedUserGitMasterOverrides: Record | null = null - if (userConfig?.agent_definitions) { - userConfig.agent_definitions = resolveAgentDefinitionPaths( - userConfig.agent_definitions, - configDir, - null - ) + for (const userLayer of userConfigLayers) { + if (!userLayer.configPath) continue + + const userConfig = loadConfigFromPath(userLayer.configPath, ctx) + const userGitMasterOverrides = loadExplicitGitMasterOverrides(userLayer.configPath) + + if (userConfig?.agent_definitions) { + userConfig.agent_definitions = resolveAgentDefinitionPaths( + userConfig.agent_definitions, + userLayer.configDir, + null, + ) + } + + if (userConfig) { + config = mergeConfigs(config, userConfig) + } + + if (userGitMasterOverrides) { + mergedUserGitMasterOverrides = { + ...(mergedUserGitMasterOverrides ?? {}), + ...userGitMasterOverrides, + } + } } - let config: OhMyOpenCodeConfig = - userConfig ?? OhMyOpenCodeConfigSchema.parse({}); + const userMcpEnvAllowlist = config.mcp_env_allowlist ?? [] const canonicalAncestorPathsFarthestFirst = [...canonicalAncestorPathsNearestFirst].reverse() const defaultGitMaster = OhMyOpenCodeConfigSchema.parse({}).git_master @@ -374,7 +388,7 @@ export function loadPluginConfig( } } - if (userGitMasterOverrides || ancestorGitMasterOverridesFarthestFirst.length > 0) { + if (mergedUserGitMasterOverrides || ancestorGitMasterOverridesFarthestFirst.length > 0) { const mergedAncestorGitMaster: Record = {} for (const override of ancestorGitMasterOverridesFarthestFirst) { Object.assign(mergedAncestorGitMaster, override) @@ -383,7 +397,7 @@ export function loadPluginConfig( ...config, git_master: { ...defaultGitMaster, - ...(userGitMasterOverrides ?? {}), + ...(mergedUserGitMasterOverrides ?? {}), ...mergedAncestorGitMaster, }, } @@ -395,7 +409,7 @@ export function loadPluginConfig( // expansion in .mcp.json files. See commit 316d2504 for context. config = { ...config, - mcp_env_allowlist: userConfig?.mcp_env_allowlist ?? [], + mcp_env_allowlist: userMcpEnvAllowlist, }; log("Final merged config", { diff --git a/src/shared/opencode-command-dirs.test.ts b/src/shared/opencode-command-dirs.test.ts index e75b0284c..d64db37e0 100644 --- a/src/shared/opencode-command-dirs.test.ts +++ b/src/shared/opencode-command-dirs.test.ts @@ -1,34 +1,43 @@ import { describe, expect, it, mock, beforeEach, afterEach } from "bun:test" -import { join } from "node:path" +import { resolve } from "node:path" describe("opencode-command-dirs", () => { - let originalEnv: string | undefined + let originalOpencodeConfigDir: string | undefined + let originalXdgConfigHome: string | undefined beforeEach(() => { - originalEnv = process.env.OPENCODE_CONFIG_DIR + originalOpencodeConfigDir = process.env.OPENCODE_CONFIG_DIR + originalXdgConfigHome = process.env.XDG_CONFIG_HOME }) afterEach(() => { - if (originalEnv !== undefined) { - process.env.OPENCODE_CONFIG_DIR = originalEnv + if (originalOpencodeConfigDir !== undefined) { + process.env.OPENCODE_CONFIG_DIR = originalOpencodeConfigDir } else { delete process.env.OPENCODE_CONFIG_DIR } + + if (originalXdgConfigHome !== undefined) { + process.env.XDG_CONFIG_HOME = originalXdgConfigHome + } else { + delete process.env.XDG_CONFIG_HOME + } }) describe("getOpenCodeSkillDirs", () => { describe("#given config dir inside profiles/", () => { describe("#when getOpenCodeSkillDirs is called", () => { it("#then returns both profile and parent skill dirs", async () => { + process.env.XDG_CONFIG_HOME = "/home/user/.config" process.env.OPENCODE_CONFIG_DIR = "/home/user/.config/opencode/profiles/opus" const { getOpenCodeSkillDirs } = await import("./opencode-command-dirs") const dirs = getOpenCodeSkillDirs({ binary: "opencode" }) - expect(dirs).toContain("/home/user/.config/opencode/profiles/opus/skills") - expect(dirs).toContain("/home/user/.config/opencode/profiles/opus/skill") - expect(dirs).toContain("/home/user/.config/opencode/skill") - expect(dirs).toContain("/home/user/.config/opencode/skills") + expect(dirs).toContain(resolve("/home/user/.config/opencode/profiles/opus/skills")) + expect(dirs).toContain(resolve("/home/user/.config/opencode/profiles/opus/skill")) + expect(dirs).toContain(resolve("/home/user/.config/opencode/skill")) + expect(dirs).toContain(resolve("/home/user/.config/opencode/skills")) expect(dirs).toHaveLength(4) }) }) @@ -37,13 +46,14 @@ describe("opencode-command-dirs", () => { describe("#given config dir NOT inside profiles/", () => { describe("#when getOpenCodeSkillDirs is called", () => { it("#then returns only the config dir skills", async () => { + process.env.XDG_CONFIG_HOME = "/home/user/.config" process.env.OPENCODE_CONFIG_DIR = "/home/user/.config/opencode" const { getOpenCodeSkillDirs } = await import("./opencode-command-dirs") const dirs = getOpenCodeSkillDirs({ binary: "opencode" }) - expect(dirs).toContain("/home/user/.config/opencode/skills") - expect(dirs).toContain("/home/user/.config/opencode/skill") + expect(dirs).toContain(resolve("/home/user/.config/opencode/skills")) + expect(dirs).toContain(resolve("/home/user/.config/opencode/skill")) expect(dirs).toHaveLength(2) }) }) @@ -54,15 +64,16 @@ describe("opencode-command-dirs", () => { describe("#given config dir inside profiles/", () => { describe("#when getOpenCodeCommandDirs is called", () => { it("#then returns both profile and parent command dirs", async () => { + process.env.XDG_CONFIG_HOME = "/home/user/.config" process.env.OPENCODE_CONFIG_DIR = "/home/user/.config/opencode/profiles/opus" const { getOpenCodeCommandDirs } = await import("./opencode-command-dirs") const dirs = getOpenCodeCommandDirs({ binary: "opencode" }) - expect(dirs).toContain("/home/user/.config/opencode/profiles/opus/commands") - expect(dirs).toContain("/home/user/.config/opencode/profiles/opus/command") - expect(dirs).toContain("/home/user/.config/opencode/commands") - expect(dirs).toContain("/home/user/.config/opencode/command") + expect(dirs).toContain(resolve("/home/user/.config/opencode/profiles/opus/commands")) + expect(dirs).toContain(resolve("/home/user/.config/opencode/profiles/opus/command")) + expect(dirs).toContain(resolve("/home/user/.config/opencode/commands")) + expect(dirs).toContain(resolve("/home/user/.config/opencode/command")) expect(dirs).toHaveLength(4) }) }) 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..874b62c75 100644 --- a/src/shared/opencode-config-dir.ts +++ b/src/shared/opencode-config-dir.ts @@ -55,16 +55,39 @@ 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)] + } + + const customConfigDir = getCliCustomConfigDir() + + return Array.from( + new Set([ + ...(customConfigDir ? [customConfigDir] : []), + 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")