diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 8576cde3..937113d0 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -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. diff --git a/packages/web/server/lib/opencode/config-paths.test.js b/packages/web/server/lib/opencode/config-paths.test.js new file mode 100644 index 00000000..93957d90 --- /dev/null +++ b/packages/web/server/lib/opencode/config-paths.test.js @@ -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 }); + }); +}); diff --git a/packages/web/server/lib/opencode/plugins.js b/packages/web/server/lib/opencode/plugins.js index 143298fd..b429458b 100644 --- a/packages/web/server/lib/opencode/plugins.js +++ b/packages/web/server/lib/opencode/plugins.js @@ -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() { diff --git a/packages/web/server/lib/opencode/providers.test.js b/packages/web/server/lib/opencode/providers.test.js index 35078f05..ea53f501 100644 --- a/packages/web/server/lib/opencode/providers.test.js +++ b/packages/web/server/lib/opencode/providers.test.js @@ -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); diff --git a/packages/web/server/lib/opencode/routes.js b/packages/web/server/lib/opencode/routes.js index fdb7d95c..5c5f21ca 100644 --- a/packages/web/server/lib/opencode/routes.js +++ b/packages/web/server/lib/opencode/routes.js @@ -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 ? `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) => { diff --git a/packages/web/server/lib/opencode/shared.js b/packages/web/server/lib/opencode/shared.js index d01168f3..d154cc29 100644 --- a/packages/web/server/lib/opencode/shared.js +++ b/packages/web/server/lib/opencode/shared.js @@ -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'); diff --git a/packages/web/server/lib/opencode/skill-routes.js b/packages/web/server/lib/opencode/skill-routes.js index 0abe7f37..703aa382 100644 --- a/packages/web/server/lib/opencode/skill-routes.js +++ b/packages/web/server/lib/opencode/skill-routes.js @@ -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'), diff --git a/packages/web/server/lib/opencode/snippets.js b/packages/web/server/lib/opencode/snippets.js index 08f1dcd2..46fff970 100644 --- a/packages/web/server/lib/opencode/snippets.js +++ b/packages/web/server/lib/opencode/snippets.js @@ -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'; diff --git a/packages/web/server/lib/quota/utils/auth.js b/packages/web/server/lib/quota/utils/auth.js index 5325dfdb..6d88b2b8 100644 --- a/packages/web/server/lib/quota/utils/auth.js +++ b/packages/web/server/lib/quota/utils/auth.js @@ -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 = [ diff --git a/packages/web/server/lib/skills-catalog/DOCUMENTATION.md b/packages/web/server/lib/skills-catalog/DOCUMENTATION.md index 5c0b020e..4289a59b 100644 --- a/packages/web/server/lib/skills-catalog/DOCUMENTATION.md +++ b/packages/web/server/lib/skills-catalog/DOCUMENTATION.md @@ -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. diff --git a/packages/web/server/lib/skills-catalog/install.js b/packages/web/server/lib/skills-catalog/install.js index d72be52f..ac556ce0 100644 --- a/packages/web/server/lib/skills-catalog/install.js +++ b/packages/web/server/lib/skills-catalog/install.js @@ -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;