fix: complete config path and review follow-ups (#3348)
This commit is contained in:
committed by
GitHub
parent
0543b954c3
commit
260bfe666b
@@ -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<ResponseStyleValue>(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')}
|
||||
</p>
|
||||
<p>
|
||||
{t('settings.behavior.page.warning.description', { path: AGENTS_MD_PATH })}
|
||||
{t('settings.behavior.page.warning.description', { path: agentsMdPath })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: `<workingDirectory>/.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.
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -815,10 +815,10 @@ ${desktopReturn ? `<a class="return" href="openchamber://focus/mcp-auth">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' });
|
||||
|
||||
Reference in New Issue
Block a user