Browse Source

feat: support OPENCODE_CONFIG_DIR for custom config directory (#185)

* feat: support OPENCODE_CONFIG env var for custom config location

Respect the OPENCODE_CONFIG environment variable across all config
directory resolution paths. OPENCODE_CONFIG points to a config file;
the config directory is derived as its parent via dirname().

Precedence: OPENCODE_CONFIG > XDG_CONFIG_HOME/opencode > ~/.config/opencode

- cli/paths.ts: check OPENCODE_CONFIG in getConfigDir()
- config/loader.ts: rename getUserConfigDir → getOpenCodeConfigDir,
  return full path including /opencode suffix, add OPENCODE_CONFIG check
- cli/custom-skills.ts: use shared getConfigDir() instead of hardcoded path
- tests: add OPENCODE_CONFIG tests and isolate existing tests from env

* refactor: deduplicate config dir resolution and add OPENCODE_CONFIG integration tests

Address PR review feedback:
- Remove duplicate getOpenCodeConfigDir() from loader.ts, import shared
  getConfigDir() from cli/paths.ts instead
- Add integration tests for loadPluginConfig and loadAgentPrompt verifying
  OPENCODE_CONFIG is respected end-to-end

* fix: support OPENCODE_CONFIG_DIR and preserve custom config filename

Align path resolution with OpenCode custom directory semantics and keep
OPENCODE_CONFIG exact filename when resolving config paths. This also
ensures installer writes OpenCode plugin entries to the correct config
file directory and adds coverage for env precedence edge cases.

* refactor: scope config customization to OPENCODE_CONFIG_DIR only

Remove OPENCODE_CONFIG-specific path handling and tests so this PR focuses
only on custom directory behavior. Keep OpenCode config path resolution
on the standard opencode.json/opencode.jsonc locations while allowing
plugin assets/config to load from OPENCODE_CONFIG_DIR.

* Update src/cli/paths.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Alvin <alvin@cmngoal.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
oribi 3 weeks ago
parent
commit
7d9182343b

+ 2 - 0
src/cli/config-io.test.ts

@@ -28,6 +28,7 @@ describe('config-io', () => {
 
   beforeEach(() => {
     tmpDir = mkdtempSync(join(tmpdir(), 'opencode-io-test-'));
+    delete process.env.OPENCODE_CONFIG_DIR;
     process.env.XDG_CONFIG_HOME = tmpDir;
   });
 
@@ -127,6 +128,7 @@ describe('config-io', () => {
       hasTmux: true,
       installSkills: false,
       installCustomSkills: false,
+      reset: false,
     });
     expect(result.success).toBe(true);
 

+ 6 - 6
src/cli/config-io.ts

@@ -8,7 +8,7 @@ import {
 } from 'node:fs';
 import {
   ensureConfigDir,
-  getConfigDir,
+  ensureOpenCodeConfigDir,
   getExistingConfigPath,
   getLiteConfig,
 } from './paths';
@@ -93,18 +93,18 @@ export function writeConfig(configPath: string, config: OpenCodeConfig): void {
 }
 
 export async function addPluginToOpenCodeConfig(): Promise<ConfigMergeResult> {
+  const configPath = getExistingConfigPath();
+
   try {
-    ensureConfigDir();
+    ensureOpenCodeConfigDir();
   } catch (err) {
     return {
       success: false,
-      configPath: getConfigDir(),
+      configPath,
       error: `Failed to create config directory: ${err}`,
     };
   }
 
-  const configPath = getExistingConfigPath();
-
   try {
     const { config: parsedConfig, error } = parseConfig(configPath);
     if (error) {
@@ -177,7 +177,7 @@ export function disableDefaultAgents(): ConfigMergeResult {
   const configPath = getExistingConfigPath();
 
   try {
-    ensureConfigDir();
+    ensureOpenCodeConfigDir();
     const { config: parsedConfig, error } = parseConfig(configPath);
     if (error) {
       return {

+ 4 - 4
src/cli/custom-skills.ts

@@ -5,13 +5,13 @@ import {
   readdirSync,
   statSync,
 } from 'node:fs';
-import { homedir } from 'node:os';
 import { dirname, join } from 'node:path';
 import { fileURLToPath } from 'node:url';
+import { getConfigDir } from './paths';
 
 /**
  * A custom skill bundled in this repository.
- * Unlike npx-installed skills, these are copied from src/skills/ to ~/.config/opencode/skills/
+ * Unlike npx-installed skills, these are copied from src/skills/ to the OpenCode skills directory
  */
 export interface CustomSkill {
   /** Skill name (folder name) */
@@ -40,7 +40,7 @@ export const CUSTOM_SKILLS: CustomSkill[] = [
  * Get the target directory for custom skills installation.
  */
 export function getCustomSkillsDir(): string {
-  return join(homedir(), '.config', 'opencode', 'skills');
+  return join(getConfigDir(), 'skills');
 }
 
 /**
@@ -70,7 +70,7 @@ function copyDirRecursive(src: string, dest: string): void {
 }
 
 /**
- * Install a custom skill by copying from src/skills/ to ~/.config/opencode/skills/
+ * Install a custom skill by copying from src/skills/ to the OpenCode skills directory
  * @param skill - The custom skill to install
  * @param projectRoot - Root directory of oh-my-opencode-slim project
  * @returns True if installation succeeded, false otherwise

+ 27 - 1
src/cli/paths.test.ts

@@ -1,6 +1,6 @@
 /// <reference types="bun-types" />
 
-import { afterEach, describe, expect, test } from 'bun:test';
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
 import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
 import { homedir, tmpdir } from 'node:os';
 import { join } from 'node:path';
@@ -17,16 +17,28 @@ import {
 describe('paths', () => {
   const originalEnv = { ...process.env };
 
+  beforeEach(() => {
+    delete process.env.OPENCODE_CONFIG_DIR;
+  });
+
   afterEach(() => {
     process.env = { ...originalEnv };
   });
 
+  test('getConfigDir() uses OPENCODE_CONFIG_DIR when set', () => {
+    process.env.OPENCODE_CONFIG_DIR = '/custom/directory';
+    delete process.env.XDG_CONFIG_HOME;
+    expect(getConfigDir()).toBe('/custom/directory');
+  });
+
   test('getConfigDir() uses XDG_CONFIG_HOME when set', () => {
+    delete process.env.OPENCODE_CONFIG_DIR;
     process.env.XDG_CONFIG_HOME = '/tmp/xdg-config';
     expect(getConfigDir()).toBe('/tmp/xdg-config/opencode');
   });
 
   test('getConfigDir() falls back to ~/.config when XDG_CONFIG_HOME is unset', () => {
+    delete process.env.OPENCODE_CONFIG_DIR;
     delete process.env.XDG_CONFIG_HOME;
     const expected = join(homedir(), '.config', 'opencode');
     expect(getConfigDir()).toBe(expected);
@@ -40,6 +52,15 @@ describe('paths', () => {
     ]);
   });
 
+  test('getOpenCodeConfigPaths() ignores OPENCODE_CONFIG_DIR', () => {
+    process.env.OPENCODE_CONFIG_DIR = '/custom/directory';
+    process.env.XDG_CONFIG_HOME = '/tmp/xdg-config';
+    expect(getOpenCodeConfigPaths()).toEqual([
+      '/tmp/xdg-config/opencode/opencode.json',
+      '/tmp/xdg-config/opencode/opencode.jsonc',
+    ]);
+  });
+
   test('getConfigJson() returns correct path', () => {
     process.env.XDG_CONFIG_HOME = '/tmp/xdg-config';
     expect(getConfigJson()).toBe('/tmp/xdg-config/opencode/opencode.json');
@@ -57,6 +78,11 @@ describe('paths', () => {
     );
   });
 
+  test('getLiteConfig() respects OPENCODE_CONFIG_DIR', () => {
+    process.env.OPENCODE_CONFIG_DIR = '/custom/directory';
+    expect(getLiteConfig()).toBe('/custom/directory/oh-my-opencode-slim.json');
+  });
+
   describe('getExistingConfigPath()', () => {
     let tmpDir: string;
 

+ 37 - 7
src/cli/paths.ts

@@ -1,10 +1,8 @@
 import { existsSync, mkdirSync } from 'node:fs';
 import { homedir } from 'node:os';
-import { join } from 'node:path';
+import { dirname, join } from 'node:path';
 
-export function getConfigDir(): string {
-  // Keep this aligned with OpenCode itself and the plugin config loader:
-  // base dir is $XDG_CONFIG_HOME (if set) else ~/.config, and OpenCode config lives under /opencode.
+function getDefaultOpenCodeConfigDir(): string {
   const userConfigDir = process.env.XDG_CONFIG_HOME
     ? process.env.XDG_CONFIG_HOME
     : join(homedir(), '.config');
@@ -12,17 +10,39 @@ export function getConfigDir(): string {
   return join(userConfigDir, 'opencode');
 }
 
+function getCustomOpenCodeConfigDir(): string | undefined {
+  const configDir = process.env.OPENCODE_CONFIG_DIR?.trim();
+  return configDir || undefined;
+}
+
+/**
+ * Get the OpenCode plugin config directory.
+ *
+ * Resolution order:
+ * 1. OPENCODE_CONFIG_DIR (custom OpenCode directory)
+ * 2. XDG_CONFIG_HOME/opencode
+ * 3. ~/.config/opencode
+ */
+export function getConfigDir(): string {
+  const customConfigDir = getCustomOpenCodeConfigDir();
+  if (customConfigDir) {
+    return customConfigDir;
+  }
+
+  return getDefaultOpenCodeConfigDir();
+}
+
 export function getOpenCodeConfigPaths(): string[] {
-  const configDir = getConfigDir();
+  const configDir = getDefaultOpenCodeConfigDir();
   return [join(configDir, 'opencode.json'), join(configDir, 'opencode.jsonc')];
 }
 
 export function getConfigJson(): string {
-  return join(getConfigDir(), 'opencode.json');
+  return getOpenCodeConfigPaths()[0];
 }
 
 export function getConfigJsonc(): string {
-  return join(getConfigDir(), 'opencode.jsonc');
+  return getOpenCodeConfigPaths()[1];
 }
 
 export function getLiteConfig(): string {
@@ -59,3 +79,13 @@ export function ensureConfigDir(): void {
     mkdirSync(configDir, { recursive: true });
   }
 }
+
+/**
+ * Ensure the directory for OpenCode's main config file exists.
+ */
+export function ensureOpenCodeConfigDir(): void {
+  const configDir = dirname(getConfigJson());
+  if (!existsSync(configDir)) {
+    mkdirSync(configDir, { recursive: true });
+  }
+}

+ 50 - 0
src/config/loader.test.ts

@@ -17,6 +17,7 @@ describe('loadPluginConfig', () => {
     userConfigDir = path.join(tempDir, 'user-config');
     originalEnv = { ...process.env };
     // Isolate from real user config
+    delete process.env.OPENCODE_CONFIG_DIR;
     process.env.XDG_CONFIG_HOME = userConfigDir;
   });
 
@@ -152,6 +153,31 @@ describe('loadPluginConfig', () => {
     );
     expect(loadPluginConfig(projectDir)).toEqual({});
   });
+
+  test('respects OPENCODE_CONFIG_DIR for user config location', () => {
+    const customDir = fs.mkdtempSync(
+      path.join(os.tmpdir(), 'omc-opencode-config-'),
+    );
+    process.env.OPENCODE_CONFIG_DIR = customDir;
+
+    // Write plugin config in the custom directory
+    fs.writeFileSync(
+      path.join(customDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        agents: { oracle: { model: 'custom/model-from-opencode-config-dir' } },
+      }),
+    );
+
+    const projectDir = path.join(tempDir, 'project');
+    fs.mkdirSync(projectDir, { recursive: true });
+
+    const config = loadPluginConfig(projectDir);
+    expect(config.agents?.oracle?.model).toBe(
+      'custom/model-from-opencode-config-dir',
+    );
+
+    fs.rmSync(customDir, { recursive: true, force: true });
+  });
 });
 
 describe('deepMerge behavior', () => {
@@ -165,6 +191,7 @@ describe('deepMerge behavior', () => {
     originalEnv = { ...process.env };
 
     // Set XDG_CONFIG_HOME to control user config location
+    delete process.env.OPENCODE_CONFIG_DIR;
     process.env.XDG_CONFIG_HOME = userConfigDir;
   });
 
@@ -408,6 +435,7 @@ describe('preset resolution', () => {
   beforeEach(() => {
     tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-test-'));
     originalEnv = { ...process.env };
+    delete process.env.OPENCODE_CONFIG_DIR;
     process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
   });
 
@@ -587,6 +615,7 @@ describe('environment variable preset override', () => {
   beforeEach(() => {
     tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'env-preset-test-'));
     originalEnv = { ...process.env };
+    delete process.env.OPENCODE_CONFIG_DIR;
     process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
   });
 
@@ -712,6 +741,7 @@ describe('JSONC config support', () => {
   beforeEach(() => {
     tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonc-test-'));
     originalEnv = { ...process.env };
+    delete process.env.OPENCODE_CONFIG_DIR;
     process.env.XDG_CONFIG_HOME = path.join(tempDir, 'user-config');
   });
 
@@ -898,6 +928,7 @@ describe('loadAgentPrompt', () => {
   beforeEach(() => {
     tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'prompt-test-'));
     originalEnv = { ...process.env };
+    delete process.env.OPENCODE_CONFIG_DIR;
     process.env.XDG_CONFIG_HOME = tempDir;
   });
 
@@ -1086,4 +1117,23 @@ describe('loadAgentPrompt', () => {
     const result = loadAgentPrompt('xdg-agent');
     expect(result.prompt).toBe('xdg prompt');
   });
+
+  test('respects OPENCODE_CONFIG_DIR for prompt location', () => {
+    const customDir = fs.mkdtempSync(
+      path.join(os.tmpdir(), 'omc-prompt-config-'),
+    );
+    process.env.OPENCODE_CONFIG_DIR = customDir;
+
+    const promptsDir = path.join(customDir, 'oh-my-opencode-slim');
+    fs.mkdirSync(promptsDir, { recursive: true });
+    fs.writeFileSync(
+      path.join(promptsDir, 'oracle.md'),
+      'prompt from OPENCODE_CONFIG_DIR dir',
+    );
+
+    const result = loadAgentPrompt('oracle');
+    expect(result.prompt).toBe('prompt from OPENCODE_CONFIG_DIR dir');
+
+    fs.rmSync(customDir, { recursive: true, force: true });
+  });
 });

+ 5 - 22
src/config/loader.ts

@@ -1,22 +1,12 @@
 import * as fs from 'node:fs';
-import * as os from 'node:os';
 import * as path from 'node:path';
 import { stripJsonComments } from '../cli/config-io';
+import { getConfigDir } from '../cli/paths';
 import { type PluginConfig, PluginConfigSchema } from './schema';
 
 const PROMPTS_DIR_NAME = 'oh-my-opencode-slim';
 
 /**
- * Get the user's configuration directory following XDG Base Directory specification.
- * Falls back to ~/.config if XDG_CONFIG_HOME is not set.
- *
- * @returns The absolute path to the user's config directory
- */
-function getUserConfigDir(): string {
-  return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
-}
-
-/**
  * Load and validate plugin configuration from a specific file path.
  * Supports both .json and .jsonc formats (JSON with comments).
  * Returns null if the file doesn't exist, is invalid, or cannot be read.
@@ -119,7 +109,8 @@ function deepMerge<T extends Record<string, unknown>>(
  * Load plugin configuration from user and project config files, merging them appropriately.
  *
  * Configuration is loaded from two locations:
- * 1. User config: ~/.config/opencode/oh-my-opencode-slim.jsonc or .json (or $XDG_CONFIG_HOME)
+ * 1. User config: $OPENCODE_CONFIG_DIR/oh-my-opencode-slim.jsonc or .json,
+ *    or ~/.config/opencode/oh-my-opencode-slim.jsonc or .json (or $XDG_CONFIG_HOME)
  * 2. Project config: <directory>/.opencode/oh-my-opencode-slim.jsonc or .json
  *
  * JSONC format is preferred over JSON (allows comments and trailing commas).
@@ -130,11 +121,7 @@ function deepMerge<T extends Record<string, unknown>>(
  * @returns Merged plugin configuration (empty object if no configs found)
  */
 export function loadPluginConfig(directory: string): PluginConfig {
-  const userConfigBasePath = path.join(
-    getUserConfigDir(),
-    'opencode',
-    'oh-my-opencode-slim',
-  );
+  const userConfigBasePath = path.join(getConfigDir(), 'oh-my-opencode-slim');
 
   const projectConfigBasePath = path.join(
     directory,
@@ -210,11 +197,7 @@ export function loadAgentPrompt(
 } {
   const presetDirName =
     preset && /^[a-zA-Z0-9_-]+$/.test(preset) ? preset : undefined;
-  const promptsDir = path.join(
-    getUserConfigDir(),
-    'opencode',
-    PROMPTS_DIR_NAME,
-  );
+  const promptsDir = path.join(getConfigDir(), PROMPTS_DIR_NAME);
   const promptSearchDirs = presetDirName
     ? [path.join(promptsDir, presetDirName), promptsDir]
     : [promptsDir];