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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,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,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;
|
||||
|
||||
Reference in New Issue
Block a user