diff --git a/packages/ui/src/components/sections/behavior/BehaviorPage.tsx b/packages/ui/src/components/sections/behavior/BehaviorPage.tsx index 758c954c..6738da19 100644 --- a/packages/ui/src/components/sections/behavior/BehaviorPage.tsx +++ b/packages/ui/src/components/sections/behavior/BehaviorPage.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { z } from 'zod'; import { Button } from '@/components/ui/button'; import { Textarea } from '@/components/ui/textarea'; import { toast } from '@/components/ui'; @@ -30,7 +31,11 @@ import { SETTINGS_SELECT_SIZE, } from '@/components/sections/shared/SettingsSection'; -const AGENTS_MD_PATH = '~/.config/opencode/AGENTS.md'; +const agentsMdResponseSchema = z.object({ + content: z.string(), + exists: z.boolean(), + path: z.string().min(1).optional(), +}); const readApiError = async (response: Response, fallback: string) => { const data = await response.json().catch(() => null) as { error?: unknown } | null; @@ -104,6 +109,7 @@ export const BehaviorPage: React.FC = () => { const { t } = useI18n(); const isVSCode = useIsVSCodeRuntime(); const [prompt, setPrompt] = React.useState(''); + const [agentsMdPath, setAgentsMdPath] = React.useState('AGENTS.md'); const [optimizeSystemPrompt, setOptimizeSystemPrompt] = React.useState(false); const [responseStyleEnabled, setResponseStyleEnabled] = React.useState(DEFAULT_BEHAVIOR_SETTINGS.responseStyleEnabled); const [responseStylePreset, setResponseStylePreset] = React.useState(DEFAULT_BEHAVIOR_SETTINGS.responseStylePreset); @@ -154,9 +160,11 @@ export const BehaviorPage: React.FC = () => { } } - if (!nextSettings.prompt.trim() && agentsMdRes.ok) { - const agentsData = await agentsMdRes.json(); - if (typeof agentsData.content === 'string') { + if (agentsMdRes.ok) { + const agentsData = agentsMdResponseSchema.parse(await agentsMdRes.json()); + if (abort.signal.aborted) return; + setAgentsMdPath(agentsData.path ?? 'AGENTS.md'); + if (!nextSettings.prompt.trim()) { nextSettings = { ...nextSettings, prompt: agentsData.content }; } } @@ -326,7 +334,7 @@ export const BehaviorPage: React.FC = () => { {t('settings.behavior.page.warning.title')}

- {t('settings.behavior.page.warning.description', { path: AGENTS_MD_PATH })} + {t('settings.behavior.page.warning.description', { path: agentsMdPath })}

)} diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 608077a4..1a5f27e6 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -1301,12 +1301,10 @@ class OpencodeService { * endpoint introduced in OpenCode SDK v1.17.12. Wraps * `session.permission.get`. * - * Returns a tagged `FetchPermissionResult` so the caller can distinguish - * a confirmed-resolved permission (HTTP 404) from a fetch failure - * (network error, malformed response, or pre-v1.17.12 server without - * the V2 endpoint). The auto-accept flow uses this distinction to drop - * resolved permissions from the resync output, preventing stale - * `permission.list` entries from sticking around in the UI. + * Returns the state of the V2 permission authority. Its HTTP 404 result + * does not prove that a request from `permission.list` has settled: + * list-derived reconciliation must use that list's own reply path. + * Fetch failures remain distinct from the V2 resolved result. */ async fetchPermission( sessionID: string, diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index 7aefa3ab..212c58fe 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -172,3 +172,14 @@ Reachable filesystem routes: `api:fs:read` (attachments, config), `api:fs:search Maintenance: reviews, changelog entries, and parity claims consult this map; whoever mounts or unmounts a surface updates it in the same change. + +## Global OpenCode paths + +`opencodeConfigPaths.ts` owns the global config directory for config CRUD, +skill discovery/install, global AGENTS.md, and quota config-file lookup. It +resolves `$XDG_CONFIG_HOME/opencode` at extension startup, falling back to +`~/.config/opencode` when unset or blank. Project paths, the explicit +`OPENCODE_CONFIG` file layer, and the auth data directory stay separate. +No files are migrated. The behavior GET bridge response includes the effective +`path` for both existing and missing AGENTS.md files; shared Settings uses it +in the warning. diff --git a/packages/vscode/src/bridge-config-runtime.ts b/packages/vscode/src/bridge-config-runtime.ts index 463353b3..44f1cb5c 100644 --- a/packages/vscode/src/bridge-config-runtime.ts +++ b/packages/vscode/src/bridge-config-runtime.ts @@ -1,6 +1,6 @@ +import { OPENCODE_CONFIG_DIR } from './opencodeConfigPaths'; import * as vscode from 'vscode'; import * as fs from 'fs'; -import * as os from 'os'; import * as path from 'path'; import { createAgent, @@ -78,7 +78,7 @@ type ConfigRuntimeDeps = { clientReloadDelayMs: number; }; -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; const resolveWorkingDirectory = (ctx: BridgeContext | undefined, directory?: string): string | undefined => ( @@ -186,10 +186,10 @@ export async function handleConfigBridgeMessage( case 'api:behavior/agents-md:get': { try { const content = await fs.promises.readFile(AGENTS_MD_PATH, 'utf8'); - return { id, type, success: true, data: { content, exists: true } }; + return { id, type, success: true, data: { content, exists: true, path: AGENTS_MD_PATH } }; } catch (error) { if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') { - return { id, type, success: true, data: { content: '', exists: false } }; + return { id, type, success: true, data: { content: '', exists: false, path: AGENTS_MD_PATH } }; } throw error; } diff --git a/packages/vscode/src/bridge-settings-runtime.ts b/packages/vscode/src/bridge-settings-runtime.ts index ee48b7e8..d2b01669 100644 --- a/packages/vscode/src/bridge-settings-runtime.ts +++ b/packages/vscode/src/bridge-settings-runtime.ts @@ -1,3 +1,4 @@ +import { OPENCODE_CONFIG_DIR } from './opencodeConfigPaths'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -76,7 +77,7 @@ const inferSkillScopeAndSourceFromLocation = (location: string, workingDirectory const home = os.homedir(); 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/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index 629aecaa..dbf8e28f 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -1,10 +1,10 @@ +import { OPENCODE_CONFIG_DIR } from './opencodeConfigPaths'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import yaml from 'yaml'; import { parse as parseJsonc, printParseErrorCode, type ParseError } from 'jsonc-parser'; -const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode'); const AGENT_DIR = path.join(OPENCODE_CONFIG_DIR, 'agents'); const COMMAND_DIR = path.join(OPENCODE_CONFIG_DIR, 'commands'); const GLOBAL_SNIPPET_DIR = path.join(OPENCODE_CONFIG_DIR, 'snippet'); diff --git a/packages/vscode/src/opencodeConfigPaths.test.ts b/packages/vscode/src/opencodeConfigPaths.test.ts new file mode 100644 index 00000000..f422c154 --- /dev/null +++ b/packages/vscode/src/opencodeConfigPaths.test.ts @@ -0,0 +1,51 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const moduleUrl = new URL('./opencodeConfigPaths.ts', import.meta.url).href; +const configUrl = new URL('./opencodeConfig.ts', import.meta.url).href; + +test('unset and blank XDG keep the existing global directory', () => { + for (const value of [undefined, '', ' ']) { + const env = { ...process.env }; + if (value === undefined) delete env.XDG_CONFIG_HOME; + else env.XDG_CONFIG_HOME = value; + const result = spawnSync(process.execPath, ['--eval', ` + const { OPENCODE_CONFIG_DIR } = await import(${JSON.stringify(moduleUrl)}); + process.stdout.write(OPENCODE_CONFIG_DIR); + `], { env, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, path.join(os.homedir(), '.config', 'opencode')); + } +}); + +test('global CRUD uses XDG while project writes stay in the project', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-vscode-xdg-')); + const configHome = path.join(root, 'config'); + const project = path.join(root, 'project'); + try { + const result = spawnSync(process.execPath, ['--eval', ` + const { createAgent, createCommand, createSkill } = await import(${JSON.stringify(configUrl)}); + const project = ${JSON.stringify(project)}; + for (const scope of ['user', 'project']) { + createAgent(scope + '-agent', { description: 'Test', prompt: 'Test prompt' }, project, scope); + createCommand(scope + '-command', { description: 'Test', template: 'Test command' }, project, scope); + createSkill(scope + '-skill', { description: 'Test', instructions: 'Test skill' }, project, scope); + } + `], { + env: { ...process.env, XDG_CONFIG_HOME: configHome, OPENCODE_CONFIG: '', OPENCODE_CONFIG_DIR: '' }, + encoding: 'utf8', + }); + assert.equal(result.status, 0, result.stderr); + for (const [scope, directory] of [['user', path.join(configHome, 'opencode')], ['project', path.join(project, '.opencode')]]) { + assert.match(fs.readFileSync(path.join(directory, 'agents', `${scope}-agent.md`), 'utf8'), /Test prompt/); + assert.match(fs.readFileSync(path.join(directory, 'commands', `${scope}-command.md`), 'utf8'), /Test command/); + assert.match(fs.readFileSync(path.join(directory, 'skills', `${scope}-skill`, 'SKILL.md'), 'utf8'), /Test skill/); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/packages/vscode/src/opencodeConfigPaths.ts b/packages/vscode/src/opencodeConfigPaths.ts new file mode 100644 index 00000000..bd8c19b7 --- /dev/null +++ b/packages/vscode/src/opencodeConfigPaths.ts @@ -0,0 +1,8 @@ +import os from 'node:os'; +import path from 'node:path'; + +// Resolve once at extension startup, matching the web backend. +export const OPENCODE_CONFIG_DIR = path.join( + process.env.XDG_CONFIG_HOME?.trim() || path.join(os.homedir(), '.config'), + 'opencode', +); diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index c275a07d..74be878f 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -1,3 +1,4 @@ +import { OPENCODE_CONFIG_DIR } from './opencodeConfigPaths'; import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; @@ -191,7 +192,6 @@ export type ProviderResult = { planLabel?: string | null; }; -const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode'); const OPENCODE_DATA_DIR = path.join(os.homedir(), '.local', 'share', 'opencode'); const AUTH_FILE = path.join(OPENCODE_DATA_DIR, 'auth.json'); diff --git a/packages/vscode/src/skillsCatalog.ts b/packages/vscode/src/skillsCatalog.ts index f9a171f6..7f1ac586 100644 --- a/packages/vscode/src/skillsCatalog.ts +++ b/packages/vscode/src/skillsCatalog.ts @@ -1,3 +1,4 @@ +import { OPENCODE_CONFIG_DIR } from './opencodeConfigPaths'; import fs from 'fs'; import os from 'os'; import path from 'path'; @@ -418,8 +419,8 @@ async function copyDirectoryNoSymlinks(srcDir: string, dstDir: string) { } function getUserSkillBaseDir() { - const pluralPath = path.join(os.homedir(), '.config', 'opencode', 'skills'); - const legacyPath = path.join(os.homedir(), '.config', 'opencode', 'skill'); + const pluralPath = path.join(OPENCODE_CONFIG_DIR, 'skills'); + const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'skill'); if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath; return pluralPath; } diff --git a/packages/vscode/src/webviewHtml.ts b/packages/vscode/src/webviewHtml.ts index 21894ea9..8ea45e22 100644 --- a/packages/vscode/src/webviewHtml.ts +++ b/packages/vscode/src/webviewHtml.ts @@ -228,11 +228,11 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string { return { startingApi: 'OpenCode API başlatılıyor…', initializing: 'Başlatılıyor…', - connecting: 'Bağlanılıyor…', + connecting: 'Bağlanıyor…', connected: 'Bağlandı!', connectionError: 'Bağlantı hatası', - reconnecting: 'Yeniden bağlanılıyor…', - cliNotFound: 'OpenCode CLI bulunamadı. Lütfen önce yükleyin.', + reconnecting: 'Yeniden bağlanıyor…', + cliNotFound: 'OpenCode CLI bulunamadı. Lütfen önce kurun.', }; } return { @@ -310,8 +310,8 @@ export function getWebviewHtml(options: WebviewHtmlOptions): string { } if (detected.indexOf('tr') === 0) { return { - startingDevServer: (host) => 'Webview geliştirme sunucusu başlatılıyor (' + host + ')...', - waitingDevServer: (host, attempt) => 'Webview geliştirme sunucusu bekleniyor (' + host + ')... deneme ' + attempt, + startingDevServer: (host) => 'Webview dev sunucusu başlatılıyor (' + host + ')...', + waitingDevServer: (host, attempt) => 'Webview dev sunucusu bekleniyor (' + host + ')... deneme ' + attempt, }; } } diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 937113d0..814d1a76 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -421,7 +421,7 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`. ## Storage and configuration - Provider auth: `~/.local/share/opencode/auth.json`. -- User config: `~/.config/opencode/opencode.json`. +- User config: `$XDG_CONFIG_HOME/opencode/opencode.json`, falling back to `~/.config/opencode/opencode.json` when unset or blank. - Project config: `/.opencode/opencode.json` or `opencode.json`. - Custom config: `OPENCODE_CONFIG` env var path. - Rate limit config: `OPENCHAMBER_RATE_LIMIT_MAX_ATTEMPTS`, `OPENCHAMBER_RATE_LIMIT_NO_IP_MAX_ATTEMPTS` env vars. @@ -433,3 +433,7 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`. - Config merging follows priority: custom > project > user. - UI auth uses scrypt for password hashing with constant-time comparison. - Tunnel auth treats `host.docker.internal` as local-only when the socket remote IP is private/loopback. + +The behavior `GET /api/behavior/agents-md` response includes `path`, the effective +server-side filename, whether or not the file exists. Settings displays this +path without deriving a directory from the browser environment. diff --git a/packages/web/server/lib/opencode/config-paths.test.js b/packages/web/server/lib/opencode/config-paths.test.js index 93957d90..22878cbf 100644 --- a/packages/web/server/lib/opencode/config-paths.test.js +++ b/packages/web/server/lib/opencode/config-paths.test.js @@ -99,10 +99,18 @@ describe('OpenCode global config paths', () => { routes.registerOpenCodeRoutes(app, {}); const response = { json: vi.fn(), status: vi.fn(() => response) }; + await handlers.get('GET /api/behavior/agents-md')({}, response); + expect(response.json).toHaveBeenLastCalledWith({ + content: '', exists: false, path: path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'AGENTS.md'), + }); + 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(); + await handlers.get('GET /api/behavior/agents-md')({}, response); + expect(response.json).toHaveBeenLastCalledWith({ + content: 'Global behavior', exists: true, path: path.join(process.env.XDG_CONFIG_HOME, 'opencode', 'AGENTS.md'), + }); fs.rmSync(root, { recursive: true, force: true }); }); }); diff --git a/packages/web/server/lib/opencode/routes.js b/packages/web/server/lib/opencode/routes.js index 5c5f21ca..310e5196 100644 --- a/packages/web/server/lib/opencode/routes.js +++ b/packages/web/server/lib/opencode/routes.js @@ -815,10 +815,10 @@ ${desktopReturn ? `Return try { await fs.promises.access(AGENTS_MD_PATH); } catch { - return res.json({ content: '', exists: false }); + return res.json({ content: '', exists: false, path: AGENTS_MD_PATH }); } const content = await fs.promises.readFile(AGENTS_MD_PATH, 'utf8'); - return res.json({ content, exists: true }); + return res.json({ content, exists: true, path: AGENTS_MD_PATH }); } catch (error) { console.error('Failed to read AGENTS.md:', error); return res.status(500).json({ error: 'Failed to read AGENTS.md' });