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.
|
- `removeProviderConfig(providerId, workingDirectory, scope?)`: Removes a provider block from the selected config layer.
|
||||||
|
|
||||||
## Public exports (shared.js)
|
## 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.
|
- `AGENT_SCOPE`, `COMMAND_SCOPE`, `SKILL_SCOPE`: Scope constants with USER and PROJECT values.
|
||||||
- `ensureDirs()`: Creates required OpenCode directories.
|
- `ensureDirs()`: Creates required OpenCode directories.
|
||||||
- `parseMdFile(filePath)`, `writeMdFile(filePath, frontmatter, body)`: Markdown file operations with YAML frontmatter.
|
- `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 fs from 'fs';
|
||||||
import os from 'os';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import {
|
import {
|
||||||
AGENT_SCOPE,
|
AGENT_SCOPE,
|
||||||
|
OPENCODE_CONFIG_DIR,
|
||||||
readConfigFile,
|
readConfigFile,
|
||||||
readConfigLayer,
|
readConfigLayer,
|
||||||
writeConfig,
|
writeConfig,
|
||||||
@@ -71,7 +71,7 @@ function getActiveOpencodeConfigDir() {
|
|||||||
if (customConfigPath) {
|
if (customConfigPath) {
|
||||||
return path.dirname(path.resolve(customConfigPath));
|
return path.dirname(path.resolve(customConfigPath));
|
||||||
}
|
}
|
||||||
return path.join(os.homedir(), '.config', 'opencode');
|
return OPENCODE_CONFIG_DIR;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActiveUserConfigPaths() {
|
function getActiveUserConfigPaths() {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
getProviderSources,
|
getProviderSources,
|
||||||
removeProviderConfig,
|
removeProviderConfig,
|
||||||
} from './providers.js';
|
} from './providers.js';
|
||||||
|
import { OPENCODE_CONFIG_DIR } from './shared.js';
|
||||||
|
|
||||||
let projectDir;
|
let projectDir;
|
||||||
|
|
||||||
@@ -334,8 +335,8 @@ describe('custom provider config persistence', () => {
|
|||||||
expect(sources.sources.custom.exists).toBe(false);
|
expect(sources.sources.custom.exists).toBe(false);
|
||||||
|
|
||||||
for (const userPath of [
|
for (const userPath of [
|
||||||
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
|
path.join(OPENCODE_CONFIG_DIR, 'opencode.json'),
|
||||||
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
|
path.join(OPENCODE_CONFIG_DIR, 'config.json'),
|
||||||
]) {
|
]) {
|
||||||
if (!fs.existsSync(userPath)) continue;
|
if (!fs.existsSync(userPath)) continue;
|
||||||
const userConfig = readJson(userPath);
|
const userConfig = readJson(userPath);
|
||||||
@@ -373,8 +374,8 @@ describe('custom provider config persistence', () => {
|
|||||||
expect(sources.sources.project.exists).toBe(false);
|
expect(sources.sources.project.exists).toBe(false);
|
||||||
|
|
||||||
for (const userPath of [
|
for (const userPath of [
|
||||||
path.join(os.homedir(), '.config', 'opencode', 'opencode.json'),
|
path.join(OPENCODE_CONFIG_DIR, 'opencode.json'),
|
||||||
path.join(os.homedir(), '.config', 'opencode', 'config.json'),
|
path.join(OPENCODE_CONFIG_DIR, 'config.json'),
|
||||||
]) {
|
]) {
|
||||||
if (!fs.existsSync(userPath)) continue;
|
if (!fs.existsSync(userPath)) continue;
|
||||||
const userConfig = readJson(userPath);
|
const userConfig = readJson(userPath);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { createProjectIdFromPath } from '../projects/project-id.js';
|
import { createProjectIdFromPath } from '../projects/project-id.js';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import os from 'os';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import {
|
import {
|
||||||
buildDeferredRestartResponse,
|
buildDeferredRestartResponse,
|
||||||
} from './config-mutation-response.js';
|
} from './config-mutation-response.js';
|
||||||
import { getClaudeCliAuthStatus } from './claude-cli-auth.js';
|
import { getClaudeCliAuthStatus } from './claude-cli-auth.js';
|
||||||
|
import { OPENCODE_CONFIG_DIR } from './shared.js';
|
||||||
|
|
||||||
export const registerOpenCodeRoutes = (app, dependencies) => {
|
export const registerOpenCodeRoutes = (app, dependencies) => {
|
||||||
const {
|
const {
|
||||||
@@ -807,7 +807,7 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">Return
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Behavior / Global AGENTS.md endpoints
|
// 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
|
const MAX_BEHAVIOR_PROMPT_SIZE = 1024 * 1024; // 1 MB
|
||||||
|
|
||||||
app.get('/api/behavior/agents-md', async (_req, res) => {
|
app.get('/api/behavior/agents-md', async (_req, res) => {
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import { parse as parseJsonc, printParseErrorCode } from 'jsonc-parser';
|
|||||||
|
|
||||||
// ============== PATH CONSTANTS ==============
|
// ============== 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 AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents');
|
||||||
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
|
const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands');
|
||||||
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills');
|
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||||||
import { buildDeferredRestartResponse } from './config-mutation-response.js';
|
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
|
* Matches how OpenCode reads its own boolean env flags: any value other than
|
||||||
@@ -110,7 +111,7 @@ export const registerSkillRoutes = (app, dependencies) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userRoots = [
|
const userRoots = [
|
||||||
path.join(home, '.config', 'opencode'),
|
OPENCODE_CONFIG_DIR,
|
||||||
path.join(home, '.opencode'),
|
path.join(home, '.opencode'),
|
||||||
path.join(home, '.claude', 'skills'),
|
path.join(home, '.claude', 'skills'),
|
||||||
path.join(home, '.agents', 'skills'),
|
path.join(home, '.agents', 'skills'),
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
|
||||||
import yaml from 'yaml';
|
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 = path.join(OPENCODE_CONFIG_DIR, 'snippet');
|
||||||
const GLOBAL_SNIPPET_DIR_ALT = path.join(OPENCODE_CONFIG_DIR, 'snippets');
|
const GLOBAL_SNIPPET_DIR_ALT = path.join(OPENCODE_CONFIG_DIR, 'snippets');
|
||||||
const SNIPPET_EXTENSION = '.md';
|
const SNIPPET_EXTENSION = '.md';
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
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');
|
const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode');
|
||||||
|
|
||||||
export const ANTIGRAVITY_ACCOUNTS_PATHS = [
|
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).
|
- `safeRm(dir)`: Safely remove directory recursively (ignores errors).
|
||||||
- `ensureDir(dirPath)`: Ensure directory exists with recursive creation.
|
- `ensureDir(dirPath)`: Ensure directory exists with recursive creation.
|
||||||
- `copyDirectoryNoSymlinks(srcDir, dstDir)`: Copy directory contents without symlinks, with path traversal protection.
|
- `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`)
|
### 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.
|
- `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 { assertGitAvailable, looksLikeAuthError, runGit } from './git.js';
|
||||||
import { parseSkillRepoSource } from './source.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]$/;
|
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
||||||
|
|
||||||
function normalizeUserSkillDir(userSkillDir) {
|
function normalizeUserSkillDir(userSkillDir) {
|
||||||
if (!userSkillDir) return null;
|
if (!userSkillDir) return null;
|
||||||
const legacySkillDir = path.join(os.homedir(), '.config', 'opencode', 'skill');
|
const legacySkillDir = path.join(OPENCODE_CONFIG_DIR, 'skill');
|
||||||
const pluralSkillDir = path.join(os.homedir(), '.config', 'opencode', 'skills');
|
const pluralSkillDir = path.join(OPENCODE_CONFIG_DIR, 'skills');
|
||||||
if (userSkillDir === legacySkillDir) {
|
if (userSkillDir === legacySkillDir) {
|
||||||
if (fs.existsSync(legacySkillDir) && !fs.existsSync(pluralSkillDir)) return legacySkillDir;
|
if (fs.existsSync(legacySkillDir) && !fs.existsSync(pluralSkillDir)) return legacySkillDir;
|
||||||
return pluralSkillDir;
|
return pluralSkillDir;
|
||||||
|
|||||||
Reference in New Issue
Block a user