Files
openchamber/packages/vscode/src/bridge-config-runtime.ts
T
Shyamalan KannanandBohdan Triapitsyn bcbf34b1d1 feat: add Behavior settings page for global AGENTS.md (#1079)
* feat: add Behavior settings page for global AGENTS.md

- Add new 'Behavior' settings page to manage global system prompt
- Sync global behavior prompt to ~/.config/opencode/AGENTS.md
- Add GET/PUT /api/behavior/agents-md endpoints with 1MB size limit
- Add AbortController guards and parallel fetches in BehaviorPage
- Add i18n translations for en, es, ko, pt-BR, uk, zh-CN
- Add globalBehaviorPrompt to DesktopSettings type and sanitizer
- Add /api/behavior to express.json() body-parser whitelist
- Ensure atomic save: AGENTS.md written before settings updated

* fix: address Greptile review feedback

- Rename misleading 'trimmed' variable to 'value' in settings-helpers.js
- Add Content-Length guard for /api/behavior before 50mb JSON parser
- Use express.json({ limit: '1mb' }) for behavior endpoints
- Add actual translations for es, ko, pt-BR, uk, zh-CN locales

* fix(vscode): support behavior settings endpoint

* fix(behavior): wait for settings persistence

* fix(behavior): end agents file with newline

* fix(behavior): avoid duplicate textarea resize handles

* fix(behavior): clarify global rules copy

* fix(behavior): move rules note into tooltip

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-05-01 00:39:55 +03:00

661 lines
24 KiB
TypeScript

import * as vscode from 'vscode';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
createAgent,
createCommand,
deleteAgent,
deleteCommand,
getAgentSources,
getCommandSources,
updateAgent,
updateCommand,
type AgentScope,
type CommandScope,
AGENT_SCOPE,
COMMAND_SCOPE,
discoverSkills,
getSkillSources,
createSkill,
updateSkill,
deleteSkill,
readSkillSupportingFile,
writeSkillSupportingFile,
deleteSkillSupportingFile,
type SkillScope,
type DiscoveredSkill,
SKILL_SCOPE,
listMcpConfigs,
getMcpConfig,
createMcpConfig,
updateMcpConfig,
deleteMcpConfig,
} from './opencodeConfig';
import {
getSkillsCatalog,
scanSkillsRepository as scanSkillsRepositoryFromGit,
installSkillsFromRepository as installSkillsFromGit,
type SkillsCatalogSourceConfig,
} from './skillsCatalog';
import type { BridgeContext, BridgeResponse } from './bridge';
type BridgeMessageInput = {
id: string;
type: string;
payload?: unknown;
};
type ConfigRuntimeDeps = {
readSettings: (ctx?: BridgeContext) => Record<string, unknown>;
persistSettings: (changes: Record<string, unknown>, ctx?: BridgeContext) => Promise<Record<string, unknown>>;
readMagicPromptOverrides: () => { version: number; overrides: Record<string, string> };
saveMagicPromptOverride: (id: string, text: string) => Promise<{ version: number; overrides: Record<string, string> }>;
resetMagicPromptOverride: (id: string) => Promise<{ version: number; overrides: Record<string, string> }>;
resetAllMagicPromptOverrides: () => Promise<{ version: number; overrides: Record<string, string> }>;
fetchOpenCodeSkillsFromApi: (ctx: BridgeContext | undefined, workingDirectory?: string) => Promise<DiscoveredSkill[] | null>;
clientReloadDelayMs: number;
};
const AGENTS_MD_PATH = path.join(os.homedir(), '.config', 'opencode', 'AGENTS.md');
const MAX_BEHAVIOR_PROMPT_SIZE = 1024 * 1024;
const resolveWorkingDirectory = (ctx: BridgeContext | undefined, directory?: string): string | undefined => (
(typeof directory === 'string' && directory.trim())
? directory.trim()
: (ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath)
);
const parseSkillsCatalogSources = (settings: Record<string, unknown>): SkillsCatalogSourceConfig[] => {
const rawCatalogs = (settings as { skillCatalogs?: unknown }).skillCatalogs;
if (!Array.isArray(rawCatalogs)) {
return [];
}
return rawCatalogs
.map((entry) => {
if (!entry || typeof entry !== 'object') return null;
const candidate = entry as Record<string, unknown>;
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
const source = typeof candidate.source === 'string' ? candidate.source.trim() : '';
const subpath = typeof candidate.subpath === 'string' ? candidate.subpath.trim() : '';
if (!id || !label || !source) return null;
const normalized: SkillsCatalogSourceConfig = {
id,
label,
description: source,
source,
...(subpath ? { defaultSubpath: subpath } : {}),
};
return normalized;
})
.filter((value): value is SkillsCatalogSourceConfig => value !== null);
};
export async function handleConfigBridgeMessage(
message: BridgeMessageInput,
ctx: BridgeContext | undefined,
deps: ConfigRuntimeDeps,
): Promise<BridgeResponse | null> {
const { id, type, payload } = message;
switch (type) {
case 'api:config/opencode-resolution:get': {
const debugInfo = ctx?.manager?.getDebugInfo();
const configuredFromWorkspace = vscode.workspace.getConfiguration('openchamber').get<string>('opencodeBinary');
const configured = typeof configuredFromWorkspace === 'string' && configuredFromWorkspace.trim().length > 0
? configuredFromWorkspace.trim()
: null;
const resolved = debugInfo?.cliPath ?? null;
const source = (() => {
if (!resolved) return null;
if (configured && configured === resolved) return 'settings';
const envBinary = typeof process.env.OPENCODE_BINARY === 'string' ? process.env.OPENCODE_BINARY.trim() : '';
if (envBinary && envBinary === resolved) return 'env';
return 'path';
})();
return {
id,
type,
success: true,
data: {
configured,
resolved,
resolvedDir: resolved ? path.dirname(resolved) : null,
source,
detectedNow: resolved,
detectedSourceNow: source,
shim: null,
viaWsl: false,
wslBinary: null,
wslPath: null,
wslDistro: null,
node: process.execPath || null,
bun: null,
},
};
}
case 'api:config/settings:get': {
const settings = deps.readSettings(ctx);
return { id, type, success: true, data: settings };
}
case 'api:config/settings:save': {
const changes = (payload as Record<string, unknown>) || {};
const updated = await deps.persistSettings(changes, ctx);
return { id, type, success: true, data: updated };
}
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 } };
} catch (error) {
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
return { id, type, success: true, data: { content: '', exists: false } };
}
throw error;
}
}
case 'api:behavior/agents-md:save': {
const request = (payload || {}) as { content?: unknown };
const content = typeof request.content === 'string' ? request.content : '';
if (content.length > MAX_BEHAVIOR_PROMPT_SIZE) {
return { id, type, success: false, error: `Content exceeds maximum size of ${MAX_BEHAVIOR_PROMPT_SIZE} bytes` };
}
await fs.promises.mkdir(path.dirname(AGENTS_MD_PATH), { recursive: true });
await fs.promises.writeFile(AGENTS_MD_PATH, content, 'utf8');
await ctx?.manager?.restart();
return { id, type, success: true, data: { success: true } };
}
case 'api:magic-prompts:get': {
return { id, type, success: true, data: deps.readMagicPromptOverrides() };
}
case 'api:magic-prompts:save': {
const request = (payload || {}) as { id?: string; text?: string };
const promptId = typeof request.id === 'string' ? request.id : '';
if (!promptId) {
return { id, type, success: false, error: 'Prompt id is required' };
}
if (typeof request.text !== 'string') {
return { id, type, success: false, error: 'Prompt text is required' };
}
const data = await deps.saveMagicPromptOverride(promptId, request.text);
return { id, type, success: true, data };
}
case 'api:magic-prompts:reset': {
const request = (payload || {}) as { id?: string };
const promptId = typeof request.id === 'string' ? request.id : '';
if (!promptId) {
return { id, type, success: false, error: 'Prompt id is required' };
}
const data = await deps.resetMagicPromptOverride(promptId);
return { id, type, success: true, data };
}
case 'api:magic-prompts:reset-all': {
const data = await deps.resetAllMagicPromptOverrides();
return { id, type, success: true, data };
}
case 'api:config/reload': {
await ctx?.manager?.restart();
return { id, type, success: true, data: { restarted: true } };
}
case 'api:config/agents': {
const { method, name, body, directory } = (payload || {}) as {
method?: string;
name?: string;
body?: Record<string, unknown>;
directory?: string;
};
const agentName = typeof name === 'string' ? name.trim() : '';
if (!agentName) {
return { id, type, success: false, error: 'Agent name is required' };
}
const workingDirectory = resolveWorkingDirectory(ctx, directory);
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
if (normalizedMethod === 'GET') {
const sources = getAgentSources(agentName, workingDirectory);
const scope = sources.md.exists
? sources.md.scope
: (sources.json.exists ? sources.json.scope : null);
return {
id,
type,
success: true,
data: { name: agentName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists },
};
}
if (normalizedMethod === 'POST') {
const scopeValue = body?.scope as string | undefined;
const scope: AgentScope | undefined = scopeValue === 'project' ? AGENT_SCOPE.PROJECT : scopeValue === 'user' ? AGENT_SCOPE.USER : undefined;
createAgent(agentName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
requiresReload: true,
message: `Agent ${agentName} created successfully. Reloading interface…`,
reloadDelayMs: deps.clientReloadDelayMs,
},
};
}
if (normalizedMethod === 'PATCH') {
updateAgent(agentName, (body || {}) as Record<string, unknown>, workingDirectory);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
requiresReload: true,
message: `Agent ${agentName} updated successfully. Reloading interface…`,
reloadDelayMs: deps.clientReloadDelayMs,
},
};
}
if (normalizedMethod === 'DELETE') {
deleteAgent(agentName, workingDirectory);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
requiresReload: true,
message: `Agent ${agentName} deleted successfully. Reloading interface…`,
reloadDelayMs: deps.clientReloadDelayMs,
},
};
}
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
}
case 'api:config/commands': {
const { method, name, body, directory } = (payload || {}) as {
method?: string;
name?: string;
body?: Record<string, unknown>;
directory?: string;
};
const commandName = typeof name === 'string' ? name.trim() : '';
if (!commandName) {
return { id, type, success: false, error: 'Command name is required' };
}
const workingDirectory = resolveWorkingDirectory(ctx, directory);
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
if (normalizedMethod === 'GET') {
const sources = getCommandSources(commandName, workingDirectory);
const scope = sources.md.exists
? sources.md.scope
: (sources.json.exists ? sources.json.scope : null);
return {
id,
type,
success: true,
data: { name: commandName, sources, scope, isBuiltIn: !sources.md.exists && !sources.json.exists },
};
}
if (normalizedMethod === 'POST') {
const scopeValue = body?.scope as string | undefined;
const scope: CommandScope | undefined = scopeValue === 'project' ? COMMAND_SCOPE.PROJECT : scopeValue === 'user' ? COMMAND_SCOPE.USER : undefined;
createCommand(commandName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
requiresReload: true,
message: `Command ${commandName} created successfully. Reloading interface…`,
reloadDelayMs: deps.clientReloadDelayMs,
},
};
}
if (normalizedMethod === 'PATCH') {
updateCommand(commandName, (body || {}) as Record<string, unknown>, workingDirectory);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
requiresReload: true,
message: `Command ${commandName} updated successfully. Reloading interface…`,
reloadDelayMs: deps.clientReloadDelayMs,
},
};
}
if (normalizedMethod === 'DELETE') {
deleteCommand(commandName, workingDirectory);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
requiresReload: true,
message: `Command ${commandName} deleted successfully. Reloading interface…`,
reloadDelayMs: deps.clientReloadDelayMs,
},
};
}
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
}
case 'api:config/mcp': {
const { method, name, body, directory } = (payload || {}) as {
method?: string;
name?: string;
body?: Record<string, unknown>;
directory?: string;
};
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
const mcpName = typeof name === 'string' ? name.trim() : '';
const workingDirectory = resolveWorkingDirectory(ctx, directory);
if (normalizedMethod === 'GET' && !mcpName) {
const configs = listMcpConfigs(workingDirectory);
return { id, type, success: true, data: configs };
}
if (!mcpName) {
return { id, type, success: false, error: 'MCP server name is required' };
}
if (normalizedMethod === 'GET') {
const config = getMcpConfig(mcpName, workingDirectory);
if (!config) {
return { id, type, success: false, error: `MCP server "${mcpName}" not found` };
}
return { id, type, success: true, data: config };
}
if (normalizedMethod === 'POST') {
const scope = body?.scope as 'user' | 'project' | undefined;
createMcpConfig(mcpName, (body || {}) as Record<string, unknown>, workingDirectory, scope);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
requiresReload: true,
message: `MCP server "${mcpName}" created. Reloading interface…`,
reloadDelayMs: deps.clientReloadDelayMs,
},
};
}
if (normalizedMethod === 'PATCH') {
updateMcpConfig(mcpName, (body || {}) as Record<string, unknown>, workingDirectory);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
requiresReload: true,
message: `MCP server "${mcpName}" updated. Reloading interface…`,
reloadDelayMs: deps.clientReloadDelayMs,
},
};
}
if (normalizedMethod === 'DELETE') {
deleteMcpConfig(mcpName, workingDirectory);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
requiresReload: true,
message: `MCP server "${mcpName}" deleted. Reloading interface…`,
reloadDelayMs: deps.clientReloadDelayMs,
},
};
}
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
}
case 'api:config/skills': {
const { method, name, body } = (payload || {}) as { method?: string; name?: string; body?: Record<string, unknown> };
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
if (!name && normalizedMethod === 'GET') {
const skills = (await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || discoverSkills(workingDirectory);
return { id, type, success: true, data: { skills } };
}
const skillName = typeof name === 'string' ? name.trim() : '';
if (!skillName) {
return { id, type, success: false, error: 'Skill name is required' };
}
if (normalizedMethod === 'GET') {
const discoveredSkill = ((await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || [])
.find((skill) => skill.name === skillName);
const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null);
return {
id,
type,
success: true,
data: { name: skillName, sources, scope: sources.md.scope, source: sources.md.source },
};
}
if (normalizedMethod === 'POST') {
const scopeValue = body?.scope as string | undefined;
const sourceValue = body?.source as string | undefined;
const scope: SkillScope | undefined = scopeValue === 'project' ? SKILL_SCOPE.PROJECT : scopeValue === 'user' ? SKILL_SCOPE.USER : undefined;
const normalizedSource = sourceValue === 'agents' ? 'agents' : 'opencode';
createSkill(skillName, { ...(body || {}), source: normalizedSource } as Record<string, unknown>, workingDirectory, scope);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
requiresReload: true,
message: `Skill ${skillName} created successfully. Reloading interface…`,
reloadDelayMs: deps.clientReloadDelayMs,
},
};
}
if (normalizedMethod === 'PATCH') {
updateSkill(skillName, (body || {}) as Record<string, unknown>, workingDirectory);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
requiresReload: true,
message: `Skill ${skillName} updated successfully. Reloading interface…`,
reloadDelayMs: deps.clientReloadDelayMs,
},
};
}
if (normalizedMethod === 'DELETE') {
deleteSkill(skillName, workingDirectory);
await ctx?.manager?.restart();
return {
id,
type,
success: true,
data: {
success: true,
requiresReload: true,
message: `Skill ${skillName} deleted successfully. Reloading interface…`,
reloadDelayMs: deps.clientReloadDelayMs,
},
};
}
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
}
case 'api:config/skills:catalog': {
const refresh = Boolean((payload as { refresh?: boolean } | undefined)?.refresh);
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const settings = deps.readSettings(ctx);
const additionalSources = parseSkillsCatalogSources(settings);
const installedSkills = (await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || undefined;
const data = await getSkillsCatalog(workingDirectory, refresh, additionalSources, installedSkills);
return { id, type, success: true, data };
}
case 'api:config/skills:scan': {
const body = (payload || {}) as { source?: string; subpath?: string; gitIdentityId?: string };
const data = await scanSkillsRepositoryFromGit({
source: String(body.source || ''),
subpath: body.subpath,
});
return { id, type, success: true, data };
}
case 'api:config/skills:install': {
const body = (payload || {}) as {
source?: string;
subpath?: string;
scope?: 'user' | 'project';
targetSource?: 'opencode' | 'agents';
selections?: Array<{ skillDir: string }>;
conflictPolicy?: 'prompt' | 'skipAll' | 'overwriteAll';
conflictDecisions?: Record<string, 'skip' | 'overwrite'>;
};
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const data = await installSkillsFromGit({
source: String(body.source || ''),
subpath: body.subpath,
scope: body.scope === 'project' ? 'project' : 'user',
targetSource: body.targetSource === 'agents' ? 'agents' : 'opencode',
workingDirectory: body.scope === 'project' ? workingDirectory : undefined,
selections: Array.isArray(body.selections) ? body.selections : [],
conflictPolicy: body.conflictPolicy,
conflictDecisions: body.conflictDecisions,
});
if (data.ok) {
const installed = data.installed || [];
const skipped = data.skipped || [];
const requiresReload = installed.length > 0;
if (requiresReload) {
await ctx?.manager?.restart();
}
return {
id,
type,
success: true,
data: {
ok: true,
installed,
skipped,
requiresReload,
message: requiresReload ? 'Skills installed successfully. Reloading interface…' : 'No skills were installed',
reloadDelayMs: requiresReload ? deps.clientReloadDelayMs : undefined,
},
};
}
return { id, type, success: true, data };
}
case 'api:config/skills/files': {
const { method, name, filePath, content } = (payload || {}) as {
method?: string;
name?: string;
filePath?: string;
content?: string;
};
const workingDirectory = ctx?.manager?.getWorkingDirectory() || vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const skillName = typeof name === 'string' ? name.trim() : '';
if (!skillName) {
return { id, type, success: false, error: 'Skill name is required' };
}
const relativePath = typeof filePath === 'string' ? filePath.trim() : '';
if (!relativePath) {
return { id, type, success: false, error: 'File path is required' };
}
const discoveredSkill = ((await deps.fetchOpenCodeSkillsFromApi(ctx, workingDirectory)) || [])
.find((skill) => skill.name === skillName);
const sources = getSkillSources(skillName, workingDirectory, discoveredSkill || null);
if (!sources.md.dir) {
return { id, type, success: false, error: `Skill "${skillName}" not found` };
}
const skillDir = sources.md.dir;
const normalizedMethod = typeof method === 'string' && method.trim() ? method.trim().toUpperCase() : 'GET';
if (normalizedMethod === 'GET') {
const fileContent = readSkillSupportingFile(skillDir, relativePath);
if (fileContent === null) {
return { id, type, success: false, error: `File "${relativePath}" not found in skill "${skillName}"` };
}
return { id, type, success: true, data: { content: fileContent } };
}
if (normalizedMethod === 'PUT') {
writeSkillSupportingFile(skillDir, relativePath, content || '');
return { id, type, success: true, data: { success: true } };
}
if (normalizedMethod === 'DELETE') {
deleteSkillSupportingFile(skillDir, relativePath);
return { id, type, success: true, data: { success: true } };
}
return { id, type, success: false, error: `Unsupported method: ${normalizedMethod}` };
}
default:
return null;
}
}