diff --git a/README.md b/README.md index 072da8b0b..a3c4ca070 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ No stupid token consumption massive subagents here. No bloat tools here. - [MCPs](#mcps) - [LSP](#lsp) - [Experimental](#experimental) + - [Environment Variables](#environment-variables) - [Author's Note](#authors-note) - [Warnings](#warnings) - [Loved by professionals at](#loved-by-professionals-at) @@ -1181,6 +1182,12 @@ Opt-in experimental features that may change or be removed in future versions. U **Warning**: These features are experimental and may cause unexpected behavior. Enable only if you understand the implications. +### Environment Variables + +| Variable | Description | +|----------|-------------| +| `OPENCODE_CONFIG_DIR` | Override the OpenCode configuration directory. Useful for profile isolation with tools like [OCX](https://github.com/kdcokenny/ocx) ghost mode. | + ## Author's Note diff --git a/src/shared/opencode-config-dir.test.ts b/src/shared/opencode-config-dir.test.ts index 792a8cad4..5186a323c 100644 --- a/src/shared/opencode-config-dir.test.ts +++ b/src/shared/opencode-config-dir.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test" import { homedir } from "node:os" -import { join } from "node:path" +import { join, resolve } from "node:path" import { getOpenCodeConfigDir, getOpenCodeConfigPaths, @@ -20,6 +20,7 @@ describe("opencode-config-dir", () => { APPDATA: process.env.APPDATA, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, XDG_DATA_HOME: process.env.XDG_DATA_HOME, + OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR, } }) @@ -34,6 +35,84 @@ describe("opencode-config-dir", () => { } }) + describe("OPENCODE_CONFIG_DIR environment variable", () => { + test("returns OPENCODE_CONFIG_DIR when env var is set", () => { + // #given OPENCODE_CONFIG_DIR is set to a custom path + process.env.OPENCODE_CONFIG_DIR = "/custom/opencode/path" + Object.defineProperty(process, "platform", { value: "linux" }) + + // #when getOpenCodeConfigDir is called with binary="opencode" + const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" }) + + // #then returns the custom path + expect(result).toBe("/custom/opencode/path") + }) + + test("falls back to default when env var is not set", () => { + // #given OPENCODE_CONFIG_DIR is not set, platform is Linux + delete process.env.OPENCODE_CONFIG_DIR + delete process.env.XDG_CONFIG_HOME + Object.defineProperty(process, "platform", { value: "linux" }) + + // #when getOpenCodeConfigDir is called with binary="opencode" + const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" }) + + // #then returns default ~/.config/opencode + expect(result).toBe(join(homedir(), ".config", "opencode")) + }) + + test("falls back to default when env var is empty string", () => { + // #given OPENCODE_CONFIG_DIR is set to empty string + process.env.OPENCODE_CONFIG_DIR = "" + delete process.env.XDG_CONFIG_HOME + Object.defineProperty(process, "platform", { value: "linux" }) + + // #when getOpenCodeConfigDir is called with binary="opencode" + const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" }) + + // #then returns default ~/.config/opencode + expect(result).toBe(join(homedir(), ".config", "opencode")) + }) + + test("falls back to default when env var is whitespace only", () => { + // #given OPENCODE_CONFIG_DIR is set to whitespace only + process.env.OPENCODE_CONFIG_DIR = " " + delete process.env.XDG_CONFIG_HOME + Object.defineProperty(process, "platform", { value: "linux" }) + + // #when getOpenCodeConfigDir is called with binary="opencode" + const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" }) + + // #then returns default ~/.config/opencode + expect(result).toBe(join(homedir(), ".config", "opencode")) + }) + + test("resolves relative path to absolute path", () => { + // #given OPENCODE_CONFIG_DIR is set to a relative path + process.env.OPENCODE_CONFIG_DIR = "./my-opencode-config" + Object.defineProperty(process, "platform", { value: "linux" }) + + // #when getOpenCodeConfigDir is called with binary="opencode" + const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" }) + + // #then returns resolved absolute path + expect(result).toBe(resolve("./my-opencode-config")) + }) + + test("OPENCODE_CONFIG_DIR takes priority over XDG_CONFIG_HOME", () => { + // #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 getOpenCodeConfigDir is called with binary="opencode" + const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" }) + + // #then OPENCODE_CONFIG_DIR takes priority + expect(result).toBe("/custom/opencode/path") + }) + }) + describe("isDevBuild", () => { test("returns false for null version", () => { expect(isDevBuild(null)).toBe(false) @@ -213,6 +292,7 @@ describe("opencode-config-dir", () => { // #given no config files exist Object.defineProperty(process, "platform", { value: "linux" }) delete process.env.XDG_CONFIG_HOME + delete process.env.OPENCODE_CONFIG_DIR // #when detectExistingConfigDir is called const result = detectExistingConfigDir("opencode", "1.0.200") @@ -220,5 +300,19 @@ describe("opencode-config-dir", () => { // #then result is either null or a valid string path expect(result === null || typeof result === "string").toBe(true) }) + + test("includes OPENCODE_CONFIG_DIR in search locations when set", () => { + // #given OPENCODE_CONFIG_DIR is set to a custom path + process.env.OPENCODE_CONFIG_DIR = "/custom/opencode/path" + Object.defineProperty(process, "platform", { value: "linux" }) + delete process.env.XDG_CONFIG_HOME + + // #when detectExistingConfigDir is called + const result = detectExistingConfigDir("opencode", "1.0.200") + + // #then result is either null (no config file exists) or a valid string path + // The important thing is that the function doesn't throw + expect(result === null || typeof result === "string").toBe(true) + }) }) }) diff --git a/src/shared/opencode-config-dir.ts b/src/shared/opencode-config-dir.ts index 3a11ee93e..6e469e462 100644 --- a/src/shared/opencode-config-dir.ts +++ b/src/shared/opencode-config-dir.ts @@ -1,6 +1,6 @@ import { existsSync } from "node:fs" import { homedir } from "node:os" -import { join } from "node:path" +import { join, resolve } from "node:path" export type OpenCodeBinaryType = "opencode" | "opencode-desktop" @@ -47,6 +47,11 @@ function getTauriConfigDir(identifier: string): string { } function getCliConfigDir(): string { + const envConfigDir = process.env.OPENCODE_CONFIG_DIR?.trim() + if (envConfigDir) { + return resolve(envConfigDir) + } + if (process.platform === "win32") { const crossPlatformDir = join(homedir(), ".config", "opencode") const crossPlatformConfig = join(crossPlatformDir, "opencode.json") @@ -108,6 +113,11 @@ export function getOpenCodeConfigPaths(options: OpenCodeConfigDirOptions): OpenC export function detectExistingConfigDir(binary: OpenCodeBinaryType, version?: string | null): string | null { const locations: string[] = [] + const envConfigDir = process.env.OPENCODE_CONFIG_DIR?.trim() + if (envConfigDir) { + locations.push(resolve(envConfigDir)) + } + if (binary === "opencode-desktop") { const identifier = isDevBuild(version) ? TAURI_APP_IDENTIFIER_DEV : TAURI_APP_IDENTIFIER locations.push(getTauriConfigDir(identifier))