fix(web): honor XDG_CONFIG_HOME for OpenCode paths (#3305)

Closes #3304. Thanks for consolidating the global OpenCode paths. VS Code path parity and effective-path display follow-ups are maintainer-owned.
This commit is contained in:
tdoherty
2026-09-05 02:14:26 +03:00
committed by GitHub
parent 64f9f3aba7
commit 0543b954c3
11 changed files with 130 additions and 17 deletions
@@ -68,7 +68,7 @@ This module provides OpenCode server integration utilities for the web server ru
- `removeProviderConfig(providerId, workingDirectory, scope?)`: Removes a provider block from the selected config layer.
## Public exports (shared.js)
- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`: Path constants. `OPENCODE_CONFIG` is resolved at call time for the custom config layer path.
- `OPENCODE_CONFIG_DIR`, `AGENT_DIR`, `COMMAND_DIR`, `SKILL_DIR`, `CONFIG_FILE`: Path constants rooted at `$XDG_CONFIG_HOME/opencode` when `XDG_CONFIG_HOME` is non-empty, otherwise `~/.config/opencode`. These constants are evaluated when the module loads; no files are migrated. `OPENCODE_CONFIG` remains a separate explicit config-file path and is resolved at call time for the custom config layer; it does not replace the global config directory.
- `AGENT_SCOPE`, `COMMAND_SCOPE`, `SKILL_SCOPE`: Scope constants with USER and PROJECT values.
- `ensureDirs()`: Creates required OpenCode directories.
- `parseMdFile(filePath)`, `writeMdFile(filePath, frontmatter, body)`: Markdown file operations with YAML frontmatter.
@@ -0,0 +1,108 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
const originalOpenCodeConfig = process.env.OPENCODE_CONFIG;
afterEach(() => {
if (originalXdgConfigHome === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
if (originalOpenCodeConfig === undefined) delete process.env.OPENCODE_CONFIG;
else process.env.OPENCODE_CONFIG = originalOpenCodeConfig;
vi.resetModules();
});
async function loadOpenCodeModules() {
vi.resetModules();
return Promise.all([
import('./shared.js'),
import('./agents.js'),
import('./commands.js'),
import('./skills.js'),
import('./snippets.js'),
import('./plugins.js'),
import('./routes.js'),
]);
}
describe('OpenCode global config paths', () => {
it('derives all shared constants from a non-empty XDG_CONFIG_HOME', async () => {
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-xdg-'));
process.env.XDG_CONFIG_HOME = xdgConfigHome;
const [{ OPENCODE_CONFIG_DIR, AGENT_DIR, COMMAND_DIR, SKILL_DIR, CONFIG_FILE }] = await loadOpenCodeModules();
const configDir = path.join(xdgConfigHome, 'opencode');
expect(OPENCODE_CONFIG_DIR).toBe(configDir);
expect(AGENT_DIR).toBe(path.join(configDir, 'agents'));
expect(COMMAND_DIR).toBe(path.join(configDir, 'commands'));
expect(SKILL_DIR).toBe(path.join(configDir, 'skills'));
expect(CONFIG_FILE).toBe(path.join(configDir, 'config.json'));
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
});
it.each([undefined, ' '])('falls back to ~/.config/opencode when XDG_CONFIG_HOME is %s', async (xdgConfigHome) => {
if (xdgConfigHome === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = xdgConfigHome;
const [{ OPENCODE_CONFIG_DIR }] = await loadOpenCodeModules();
expect(OPENCODE_CONFIG_DIR).toBe(path.join(os.homedir(), '.config', 'opencode'));
});
it('keeps global CRUD below XDG_CONFIG_HOME while project files stay in the project', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-config-paths-'));
const xdgConfigHome = path.join(root, 'xdg');
const projectDir = path.join(root, 'project');
process.env.XDG_CONFIG_HOME = xdgConfigHome;
delete process.env.OPENCODE_CONFIG;
const [, agents, commands, skills, snippets, plugins] = await loadOpenCodeModules();
agents.createAgent('global-agent', { description: 'Global', prompt: 'Global prompt' }, projectDir, 'user');
commands.createCommand('global-command', { description: 'Global', template: 'Global template' }, projectDir, 'user');
skills.createSkill('global-skill', { description: 'Global', instructions: 'Global instructions' }, projectDir, 'user');
snippets.createSnippet('global-snippet', { content: 'Global snippet' }, projectDir, 'global');
plugins.createPluginEntry({ spec: 'global-plugin', scope: 'user' }, projectDir);
const configDir = path.join(xdgConfigHome, 'opencode');
expect(fs.existsSync(path.join(configDir, 'agents', 'global-agent.md'))).toBe(true);
expect(fs.existsSync(path.join(configDir, 'commands', 'global-command.md'))).toBe(true);
expect(fs.existsSync(path.join(configDir, 'skills', 'global-skill', 'SKILL.md'))).toBe(true);
expect(fs.existsSync(path.join(configDir, 'snippet', 'global-snippet.md'))).toBe(true);
expect(JSON.parse(fs.readFileSync(path.join(configDir, 'config.json'), 'utf8')).plugin).toEqual(['global-plugin']);
agents.createAgent('project-agent', { description: 'Project', prompt: 'Project prompt' }, projectDir, 'project');
commands.createCommand('project-command', { description: 'Project', template: 'Project template' }, projectDir, 'project');
skills.createSkill('project-skill', { description: 'Project', instructions: 'Project instructions' }, projectDir, 'project');
expect(fs.existsSync(path.join(projectDir, '.opencode', 'agents', 'project-agent.md'))).toBe(true);
expect(fs.existsSync(path.join(projectDir, '.opencode', 'commands', 'project-command.md'))).toBe(true);
expect(fs.existsSync(path.join(projectDir, '.opencode', 'skills', 'project-skill', 'SKILL.md'))).toBe(true);
fs.rmSync(root, { recursive: true, force: true });
});
it('writes global AGENTS.md below XDG_CONFIG_HOME', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-agents-md-'));
process.env.XDG_CONFIG_HOME = path.join(root, 'xdg');
delete process.env.OPENCODE_CONFIG;
const [, , , , , , routes] = await loadOpenCodeModules();
const handlers = new Map();
const app = {
get(route, ...callbacks) { handlers.set(`GET ${route}`, callbacks.at(-1)); },
put(route, ...callbacks) { handlers.set(`PUT ${route}`, callbacks.at(-1)); },
post() {},
delete() {},
};
routes.registerOpenCodeRoutes(app, {});
const response = { json: vi.fn(), status: vi.fn(() => response) };
await handlers.get('PUT /api/behavior/agents-md')({ body: { content: 'Global behavior' } }, response);
expect(fs.readFileSync(path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'AGENTS.md'), 'utf8')).toBe('Global behavior');
expect(response.json).toHaveBeenCalled();
fs.rmSync(root, { recursive: true, force: true });
});
});
+2 -2
View File
@@ -1,8 +1,8 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
AGENT_SCOPE,
OPENCODE_CONFIG_DIR,
readConfigFile,
readConfigLayer,
writeConfig,
@@ -71,7 +71,7 @@ function getActiveOpencodeConfigDir() {
if (customConfigPath) {
return path.dirname(path.resolve(customConfigPath));
}
return path.join(os.homedir(), '.config', 'opencode');
return OPENCODE_CONFIG_DIR;
}
function getActiveUserConfigPaths() {
@@ -9,6 +9,7 @@ import {
getProviderSources,
removeProviderConfig,
} from './providers.js';
import { OPENCODE_CONFIG_DIR } from './shared.js';
let projectDir;
@@ -334,8 +335,8 @@ describe('custom provider config persistence', () => {
expect(sources.sources.custom.exists).toBe(false);
for (const userPath of [
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
path.join(OPENCODE_CONFIG_DIR, 'opencode.json'),
path.join(OPENCODE_CONFIG_DIR, 'config.json'),
]) {
if (!fs.existsSync(userPath)) continue;
const userConfig = readJson(userPath);
@@ -373,8 +374,8 @@ describe('custom provider config persistence', () => {
expect(sources.sources.project.exists).toBe(false);
for (const userPath of [
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
path.join(OPENCODE_CONFIG_DIR, 'opencode.json'),
path.join(OPENCODE_CONFIG_DIR, 'config.json'),
]) {
if (!fs.existsSync(userPath)) continue;
const userConfig = readJson(userPath);
+2 -2
View File
@@ -1,12 +1,12 @@
import express from 'express';
import { createProjectIdFromPath } from '../projects/project-id.js';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {
buildDeferredRestartResponse,
} from './config-mutation-response.js';
import { getClaudeCliAuthStatus } from './claude-cli-auth.js';
import { OPENCODE_CONFIG_DIR } from './shared.js';
export const registerOpenCodeRoutes = (app, dependencies) => {
const {
@@ -807,7 +807,7 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
});
// Behavior / Global AGENTS.md endpoints
const AGENTS_MD_PATH = path.join(os.homedir(), '.config', 'opencode', 'AGENTS.md');
const AGENTS_MD_PATH = path.join(OPENCODE_CONFIG_DIR, 'AGENTS.md');
const MAX_BEHAVIOR_PROMPT_SIZE = 1024 * 1024; // 1 MB
app.get('/api/behavior/agents-md', async (_req, res) => {
+4 -1
View File
@@ -6,7 +6,10 @@ import { parse as parseJsonc, printParseErrorCode } from 'jsonc-parser';
// ============== PATH CONSTANTS ==============
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
const XDG_CONFIG_HOME = typeof process.env.XDG_CONFIG_HOME === 'string' && process.env.XDG_CONFIG_HOME.trim()
? process.env.XDG_CONFIG_HOME.trim()
: path.join(os.homedir(), '.config');
const OPENCODE_CONFIG_DIR = path.join(XDG_CONFIG_HOME, 'opencode');
const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents');
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills');
@@ -1,5 +1,6 @@
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
import { buildDeferredRestartResponse } from './config-mutation-response.js';
import { OPENCODE_CONFIG_DIR } from './shared.js';
/**
* Matches how OpenCode reads its own boolean env flags: any value other than
@@ -110,7 +111,7 @@ export const registerSkillRoutes = (app, dependencies) => {
}
const userRoots = [
path.join(home, '.config', 'opencode'),
OPENCODE_CONFIG_DIR,
path.join(home, '.opencode'),
path.join(home, '.claude', 'skills'),
path.join(home, '.agents', 'skills'),
+1 -2
View File
@@ -1,9 +1,8 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import yaml from 'yaml';
import { OPENCODE_CONFIG_DIR } from './shared.js';
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
const GLOBAL_SNIPPET_DIR = path.join(OPENCODE_CONFIG_DIR, 'snippet');
const GLOBAL_SNIPPET_DIR_ALT = path.join(OPENCODE_CONFIG_DIR, 'snippets');
const SNIPPET_EXTENSION = '.md';
+1 -1
View File
@@ -1,8 +1,8 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
import { OPENCODE_CONFIG_DIR } from '../../opencode/shared.js';
const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode');
const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode');
export const ANTIGRAVITY_ACCOUNTS_PATHS = [
@@ -58,7 +58,7 @@ The following functions are internal helpers used by exported functions:
- `safeRm(dir)`: Safely remove directory recursively (ignores errors).
- `ensureDir(dirPath)`: Ensure directory exists with recursive creation.
- `copyDirectoryNoSymlinks(srcDir, dstDir)`: Copy directory contents without symlinks, with path traversal protection.
- `normalizeUserSkillDir(userSkillDir)`: Normalize user skill directory path (handles legacy `~/.config/opencode/skill` → `~/.config/opencode/skills` migration).
- `normalizeUserSkillDir(userSkillDir)`: Normalize the user skill directory path (handles the legacy `skill` directory in the XDG config location, or `~/.config/opencode/skill` when XDG is unset, by selecting the plural `skills` directory when appropriate).
### Git Clone Helpers (`install.js`, `scan.js`)
- `cloneRepo({ cloneUrl, identity, tempDir })`: Clone git repository with preferred partial clone (`--filter=blob:none`) and fallback. Uses non-interactive mode.
@@ -4,13 +4,14 @@ import path from 'path';
import { assertGitAvailable, looksLikeAuthError, runGit } from './git.js';
import { parseSkillRepoSource } from './source.js';
import { OPENCODE_CONFIG_DIR } from '../opencode/shared.js';
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
function normalizeUserSkillDir(userSkillDir) {
if (!userSkillDir) return null;
const legacySkillDir = path.join(os.homedir(), '.config', 'opencode', 'skill');
const pluralSkillDir = path.join(os.homedir(), '.config', 'opencode', 'skills');
const legacySkillDir = path.join(OPENCODE_CONFIG_DIR, 'skill');
const pluralSkillDir = path.join(OPENCODE_CONFIG_DIR, 'skills');
if (userSkillDir === legacySkillDir) {
if (fs.existsSync(legacySkillDir) && !fs.existsSync(pluralSkillDir)) return legacySkillDir;
return pluralSkillDir;