From e65d57285fcba7ae03a1b7a0c04b28d216a14881 Mon Sep 17 00:00:00 2001 From: Nguyen Khac Trung Kien Date: Thu, 22 Jan 2026 12:15:09 +0700 Subject: [PATCH] fix: respect OPENCODE_CONFIG_DIR environment variable across all config paths Multiple files were hardcoding ~/.config/opencode paths instead of using getOpenCodeConfigDir() which respects the OPENCODE_CONFIG_DIR env var. This broke profile isolation features like OCX ghost mode, where users set OPENCODE_CONFIG_DIR to a custom path but oh-my-opencode.json and other configs weren't being read from that location. Changes: - plugin-config.ts: Use getOpenCodeConfigDir() directly - cli/doctor/checks: Use getOpenCodeConfigDir() for auth and config checks - tools/lsp/config.ts: Use getOpenCodeConfigDir() for LSP config paths - command loaders: Use getOpenCodeConfigDir() for global command dirs - hooks: Use getOpenCodeConfigDir() for hook config paths - config-path.ts: Mark getUserConfigDir() as deprecated - tests: Ensure OPENCODE_CONFIG_DIR is properly isolated in tests --- src/cli/doctor/checks/auth.ts | 5 +-- src/cli/doctor/checks/config.ts | 5 +-- .../claude-code-command-loader/loader.ts | 6 +-- src/hooks/auto-slash-command/executor.ts | 5 ++- src/hooks/auto-update-checker/constants.ts | 39 ++----------------- src/hooks/claude-code-hooks/config-loader.ts | 4 +- src/plugin-config.ts | 11 ++---- src/shared/config-path.ts | 6 +-- src/shared/opencode-config-dir.test.ts | 5 +++ src/tools/lsp/config.ts | 12 +++--- src/tools/slashcommand/tools.ts | 6 +-- 11 files changed, 36 insertions(+), 68 deletions(-) diff --git a/src/cli/doctor/checks/auth.ts b/src/cli/doctor/checks/auth.ts index 1721a1e8c..00688bdc6 100644 --- a/src/cli/doctor/checks/auth.ts +++ b/src/cli/doctor/checks/auth.ts @@ -1,11 +1,10 @@ import { existsSync, readFileSync } from "node:fs" -import { homedir } from "node:os" import { join } from "node:path" import type { CheckResult, CheckDefinition, AuthProviderInfo, AuthProviderId } from "../types" import { CHECK_IDS, CHECK_NAMES } from "../constants" -import { parseJsonc } from "../../../shared" +import { parseJsonc, getOpenCodeConfigDir } from "../../../shared" -const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode") +const OPENCODE_CONFIG_DIR = getOpenCodeConfigDir({ binary: "opencode" }) const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json") const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc") diff --git a/src/cli/doctor/checks/config.ts b/src/cli/doctor/checks/config.ts index 302e8f674..c2adc670e 100644 --- a/src/cli/doctor/checks/config.ts +++ b/src/cli/doctor/checks/config.ts @@ -1,12 +1,11 @@ import { existsSync, readFileSync } from "node:fs" -import { homedir } from "node:os" import { join } from "node:path" import type { CheckResult, CheckDefinition, ConfigInfo } from "../types" import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants" -import { parseJsonc, detectConfigFile } from "../../../shared" +import { parseJsonc, detectConfigFile, getOpenCodeConfigDir } from "../../../shared" import { OhMyOpenCodeConfigSchema } from "../../../config" -const USER_CONFIG_DIR = join(homedir(), ".config", "opencode") +const USER_CONFIG_DIR = getOpenCodeConfigDir({ binary: "opencode" }) const USER_CONFIG_BASE = join(USER_CONFIG_DIR, `${PACKAGE_NAME}`) const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME) diff --git a/src/features/claude-code-command-loader/loader.ts b/src/features/claude-code-command-loader/loader.ts index 69e95b6ef..4ce62c949 100644 --- a/src/features/claude-code-command-loader/loader.ts +++ b/src/features/claude-code-command-loader/loader.ts @@ -1,10 +1,9 @@ import { promises as fs, type Dirent } from "fs" import { join, basename } from "path" -import { homedir } from "os" import { parseFrontmatter } from "../../shared/frontmatter" import { sanitizeModelField } from "../../shared/model-sanitizer" import { isMarkdownFile } from "../../shared/file-utils" -import { getClaudeConfigDir } from "../../shared" +import { getClaudeConfigDir, getOpenCodeConfigDir } from "../../shared" import { log } from "../../shared/logger" import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types" @@ -122,7 +121,8 @@ export async function loadProjectCommands(): Promise> { - const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command") + const configDir = getOpenCodeConfigDir({ binary: "opencode" }) + const opencodeCommandsDir = join(configDir, "command") const commands = await loadCommandsFromDir(opencodeCommandsDir, "opencode") return commandsToRecord(commands) } diff --git a/src/hooks/auto-slash-command/executor.ts b/src/hooks/auto-slash-command/executor.ts index d329a3067..1ab1d2411 100644 --- a/src/hooks/auto-slash-command/executor.ts +++ b/src/hooks/auto-slash-command/executor.ts @@ -1,12 +1,12 @@ import { existsSync, readdirSync, readFileSync } from "fs" import { join, basename, dirname } from "path" -import { homedir } from "os" import { parseFrontmatter, resolveCommandsInText, resolveFileReferencesInText, sanitizeModelField, getClaudeConfigDir, + getOpenCodeConfigDir, } from "../../shared" import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types" import { isMarkdownFile } from "../../shared/file-utils" @@ -101,9 +101,10 @@ export interface ExecutorOptions { } async function discoverAllCommands(options?: ExecutorOptions): Promise { + const configDir = getOpenCodeConfigDir({ binary: "opencode" }) const userCommandsDir = join(getClaudeConfigDir(), "commands") const projectCommandsDir = join(process.cwd(), ".claude", "commands") - const opencodeGlobalDir = join(homedir(), ".config", "opencode", "command") + const opencodeGlobalDir = join(configDir, "command") const opencodeProjectDir = join(process.cwd(), ".opencode", "command") const userCommands = discoverCommandsFromDir(userCommandsDir, "user") diff --git a/src/hooks/auto-update-checker/constants.ts b/src/hooks/auto-update-checker/constants.ts index d27a87c67..64bc29404 100644 --- a/src/hooks/auto-update-checker/constants.ts +++ b/src/hooks/auto-update-checker/constants.ts @@ -1,16 +1,12 @@ import * as path from "node:path" import * as os from "node:os" import * as fs from "node:fs" +import { getOpenCodeConfigDir } from "../../shared" export const PACKAGE_NAME = "oh-my-opencode" export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags` export const NPM_FETCH_TIMEOUT = 5000 -/** - * OpenCode plugin cache directory - * - Linux/macOS: ~/.cache/opencode/ - * - Windows: %LOCALAPPDATA%/opencode/ - */ function getCacheDir(): string { if (process.platform === "win32") { return path.join(process.env.LOCALAPPDATA ?? os.homedir(), "opencode") @@ -27,38 +23,11 @@ export const INSTALLED_PACKAGE_JSON = path.join( "package.json" ) -/** - * OpenCode config file locations (priority order) - * On Windows, checks ~/.config first (cross-platform), then %APPDATA% (fallback) - * This matches shared/config-path.ts behavior for consistency - */ -function getUserConfigDir(): string { - if (process.platform === "win32") { - const crossPlatformDir = path.join(os.homedir(), ".config") - const appdataDir = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming") - - // Check cross-platform path first (~/.config) - const crossPlatformConfig = path.join(crossPlatformDir, "opencode", "opencode.json") - const crossPlatformConfigJsonc = path.join(crossPlatformDir, "opencode", "opencode.jsonc") - - if (fs.existsSync(crossPlatformConfig) || fs.existsSync(crossPlatformConfigJsonc)) { - return crossPlatformDir - } - - // Fall back to %APPDATA% - return appdataDir - } - return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config") -} - -/** - * Get the Windows-specific APPDATA directory (for fallback checks) - */ export function getWindowsAppdataDir(): string | null { if (process.platform !== "win32") return null return process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming") } -export const USER_CONFIG_DIR = getUserConfigDir() -export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode", "opencode.json") -export const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode", "opencode.jsonc") +export const USER_CONFIG_DIR = getOpenCodeConfigDir({ binary: "opencode" }) +export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode.json") +export const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode.jsonc") diff --git a/src/hooks/claude-code-hooks/config-loader.ts b/src/hooks/claude-code-hooks/config-loader.ts index 8792578b6..653a67ef5 100644 --- a/src/hooks/claude-code-hooks/config-loader.ts +++ b/src/hooks/claude-code-hooks/config-loader.ts @@ -1,8 +1,8 @@ import { existsSync } from "fs" -import { homedir } from "os" import { join } from "path" import type { ClaudeHookEvent } from "./types" import { log } from "../../shared/logger" +import { getOpenCodeConfigDir } from "../../shared" export interface DisabledHooksConfig { Stop?: string[] @@ -16,7 +16,7 @@ export interface PluginExtendedConfig { disabledHooks?: DisabledHooksConfig } -const USER_CONFIG_PATH = join(homedir(), ".config", "opencode", "opencode-cc-plugin.json") +const USER_CONFIG_PATH = join(getOpenCodeConfigDir({ binary: "opencode" }), "opencode-cc-plugin.json") function getProjectConfigPath(): string { return join(process.cwd(), ".opencode", "opencode-cc-plugin.json") diff --git a/src/plugin-config.ts b/src/plugin-config.ts index d9c925472..bc1e5dc7e 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -4,7 +4,7 @@ import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config"; import { log, deepMerge, - getUserConfigDir, + getOpenCodeConfigDir, addConfigLoadError, parseJsonc, detectConfigFile, @@ -94,12 +94,9 @@ export function loadPluginConfig( directory: string, ctx: unknown ): OhMyOpenCodeConfig { - // User-level config path (OS-specific) - prefer .jsonc over .json - const userBasePath = path.join( - getUserConfigDir(), - "opencode", - "oh-my-opencode" - ); + // User-level config path - prefer .jsonc over .json + const configDir = getOpenCodeConfigDir({ binary: "opencode" }); + const userBasePath = path.join(configDir, "oh-my-opencode"); const userDetected = detectConfigFile(userBasePath); const userConfigPath = userDetected.format !== "none" diff --git a/src/shared/config-path.ts b/src/shared/config-path.ts index 7c12a4b20..b2eba27dc 100644 --- a/src/shared/config-path.ts +++ b/src/shared/config-path.ts @@ -4,11 +4,7 @@ import * as fs from "fs" /** * Returns the user-level config directory based on the OS. - * - Linux/macOS: XDG_CONFIG_HOME or ~/.config - * - Windows: Checks ~/.config first (cross-platform), then %APPDATA% (fallback) - * - * On Windows, prioritizes ~/.config for cross-platform consistency. - * Falls back to %APPDATA% for backward compatibility with existing installations. + * @deprecated Use getOpenCodeConfigDir() from opencode-config-dir.ts instead. */ export function getUserConfigDir(): string { if (process.platform === "win32") { diff --git a/src/shared/opencode-config-dir.test.ts b/src/shared/opencode-config-dir.test.ts index 5186a323c..a22d0bfd6 100644 --- a/src/shared/opencode-config-dir.test.ts +++ b/src/shared/opencode-config-dir.test.ts @@ -144,6 +144,7 @@ describe("opencode-config-dir", () => { // #given opencode CLI binary detected, platform is Linux Object.defineProperty(process, "platform", { value: "linux" }) delete process.env.XDG_CONFIG_HOME + delete process.env.OPENCODE_CONFIG_DIR // #when getOpenCodeConfigDir is called with binary="opencode" const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" }) @@ -156,6 +157,7 @@ describe("opencode-config-dir", () => { // #given opencode CLI binary detected, platform is Linux with XDG_CONFIG_HOME set Object.defineProperty(process, "platform", { value: "linux" }) process.env.XDG_CONFIG_HOME = "/custom/config" + delete process.env.OPENCODE_CONFIG_DIR // #when getOpenCodeConfigDir is called with binary="opencode" const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" }) @@ -168,6 +170,7 @@ describe("opencode-config-dir", () => { // #given opencode CLI binary detected, platform is macOS Object.defineProperty(process, "platform", { value: "darwin" }) delete process.env.XDG_CONFIG_HOME + delete process.env.OPENCODE_CONFIG_DIR // #when getOpenCodeConfigDir is called with binary="opencode" const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" }) @@ -180,6 +183,7 @@ describe("opencode-config-dir", () => { // #given opencode CLI binary detected, platform is Windows Object.defineProperty(process, "platform", { value: "win32" }) delete process.env.APPDATA + delete process.env.OPENCODE_CONFIG_DIR // #when getOpenCodeConfigDir is called with binary="opencode" const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200", checkExisting: false }) @@ -257,6 +261,7 @@ describe("opencode-config-dir", () => { // #given opencode CLI binary on Linux Object.defineProperty(process, "platform", { value: "linux" }) delete process.env.XDG_CONFIG_HOME + delete process.env.OPENCODE_CONFIG_DIR // #when getOpenCodeConfigPaths is called const paths = getOpenCodeConfigPaths({ binary: "opencode", version: "1.0.200" }) diff --git a/src/tools/lsp/config.ts b/src/tools/lsp/config.ts index 10a6febcf..4cf5107cd 100644 --- a/src/tools/lsp/config.ts +++ b/src/tools/lsp/config.ts @@ -1,8 +1,8 @@ import { existsSync, readFileSync } from "fs" import { join } from "path" -import { homedir } from "os" import { BUILTIN_SERVERS, EXT_TO_LANG, LSP_INSTALL_HINTS } from "./constants" import type { ResolvedServer, ServerLookupResult } from "./types" +import { getOpenCodeConfigDir } from "../../shared" interface LspEntry { disabled?: boolean @@ -34,10 +34,11 @@ function loadJsonFile(path: string): T | null { function getConfigPaths(): { project: string; user: string; opencode: string } { const cwd = process.cwd() + const configDir = getOpenCodeConfigDir({ binary: "opencode" }) return { project: join(cwd, ".opencode", "oh-my-opencode.json"), - user: join(homedir(), ".config", "opencode", "oh-my-opencode.json"), - opencode: join(homedir(), ".config", "opencode", "opencode.json"), + user: join(configDir, "oh-my-opencode.json"), + opencode: join(configDir, "opencode.json"), } } @@ -199,10 +200,11 @@ export function isServerInstalled(command: string[]): boolean { } const cwd = process.cwd() + const configDir = getOpenCodeConfigDir({ binary: "opencode" }) const additionalBases = [ join(cwd, "node_modules", ".bin"), - join(homedir(), ".config", "opencode", "bin"), - join(homedir(), ".config", "opencode", "node_modules", ".bin"), + join(configDir, "bin"), + join(configDir, "node_modules", ".bin"), ] for (const base of additionalBases) { diff --git a/src/tools/slashcommand/tools.ts b/src/tools/slashcommand/tools.ts index 4866a6765..3ee2c8ae9 100644 --- a/src/tools/slashcommand/tools.ts +++ b/src/tools/slashcommand/tools.ts @@ -1,7 +1,7 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin" import { existsSync, readdirSync, readFileSync } from "fs" import { join, basename, dirname } from "path" -import { parseFrontmatter, resolveCommandsInText, resolveFileReferencesInText, sanitizeModelField } from "../../shared" +import { parseFrontmatter, resolveCommandsInText, resolveFileReferencesInText, sanitizeModelField, getOpenCodeConfigDir } from "../../shared" import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types" import { isMarkdownFile } from "../../shared/file-utils" import { getClaudeConfigDir } from "../../shared" @@ -52,10 +52,10 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): Comm } export function discoverCommandsSync(): CommandInfo[] { - const { homedir } = require("os") + const configDir = getOpenCodeConfigDir({ binary: "opencode" }) const userCommandsDir = join(getClaudeConfigDir(), "commands") const projectCommandsDir = join(process.cwd(), ".claude", "commands") - const opencodeGlobalDir = join(homedir(), ".config", "opencode", "command") + const opencodeGlobalDir = join(configDir, "command") const opencodeProjectDir = join(process.cwd(), ".opencode", "command") const userCommands = discoverCommandsFromDir(userCommandsDir, "user")