Merge pull request #3875 from jollyxenon/fix/3846-opencode-config-dir-additive
fix(config): align OPENCODE_CONFIG_DIR with additive OpenCode semantics
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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<string, ClaudeCode
|
||||
}
|
||||
|
||||
export function loadOpencodeGlobalAgents(): Record<string, ClaudeCodeAgentConfig> {
|
||||
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
|
||||
const opencodeAgentsDir = join(configDir, "agents")
|
||||
const agents = loadAgentsFromDir(opencodeAgentsDir, "opencode")
|
||||
|
||||
const result: Record<string, ClaudeCodeAgentConfig> = 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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-"))
|
||||
|
||||
+46
-32
@@ -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<string, unknown> | 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<string, unknown> = {}
|
||||
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", {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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")] : []),
|
||||
]
|
||||
}),
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user