Files
openchamber/packages/vscode/src/opencodeConfig.ts
T
Bohdan Triapitsyn ef71a997a8 fix: save agent prompt and reload changes reliably
Agent settings now correctly save an emptied system prompt instead of silently keeping the old text.

After saving agent, command, or skill settings, OpenChamber refreshes the updated configuration instead of showing stale values from a short-lived cache.

Reload actions now surface refresh failures consistently while avoiding noisy background errors.
2026-06-08 13:43:45 +03:00

2795 lines
103 KiB
TypeScript

import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import yaml from 'yaml';
import { parse as parseJsonc } 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');
const GLOBAL_SNIPPET_DIR_ALT = path.join(OPENCODE_CONFIG_DIR, 'snippets');
const CONFIG_FILE = path.join(OPENCODE_CONFIG_DIR, 'config.json');
const CUSTOM_CONFIG_FILE = process.env.OPENCODE_CONFIG
? path.resolve(process.env.OPENCODE_CONFIG)
: null;
const PROMPT_FILE_PATTERN = /^\{file:(.+)\}$/i;
const SNIPPET_EXTENSION = '.md';
const SNIPPET_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,79}$/i;
const HASHTAG_PATTERN = /#([a-z0-9_-]+)/gi;
const MAX_SNIPPET_EXPANSION_COUNT = 15;
// Scope types (shared by agents and commands)
export const AGENT_SCOPE = {
USER: 'user',
PROJECT: 'project'
} as const;
export const COMMAND_SCOPE = {
USER: 'user',
PROJECT: 'project'
} as const;
export type AgentScope = typeof AGENT_SCOPE[keyof typeof AGENT_SCOPE];
export type CommandScope = typeof COMMAND_SCOPE[keyof typeof COMMAND_SCOPE];
export type SnippetScope = 'global' | 'project';
export type Snippet = {
name: string;
content: string;
aliases: string[];
description?: string;
filePath: string;
source: SnippetScope;
};
export type PluginScope = 'user' | 'project';
export type PluginParsedKind = 'npm' | 'path';
export type PluginEntry = {
id: string;
spec: string;
options?: Record<string, unknown>;
scope: PluginScope;
kind: 'config';
parsedKind: PluginParsedKind;
};
export type PluginFile = {
id: string;
fileName: string;
scope: PluginScope;
kind: 'file';
};
export type PluginRegistryResult =
| { kind: 'npm-ok'; spec: string; name: string; currentVersion: string | null; latestVersion: string | null; versions: string[]; hasUpdate: boolean }
| { kind: 'npm-missing-version'; spec: string; name: string; currentVersion: string; latestVersion: string | null; versions: string[] }
| { kind: 'npm-missing-package'; spec: string; name: string; error: string }
| { kind: 'npm-malformed'; spec: string; error: string }
| { kind: 'npm-network'; spec: string; error: string }
| { kind: 'path-ok'; spec: string; absolutePath: string }
| { kind: 'path-missing'; spec: string; absolutePath: string }
| { kind: 'path-unreadable'; spec: string; absolutePath: string };
export type ConfigSources = {
md: { exists: boolean; path: string | null; fields: string[]; scope?: AgentScope | CommandScope | null };
json: { exists: boolean; path: string; fields: string[]; scope?: AgentScope | CommandScope | null };
projectMd?: { exists: boolean; path: string | null };
userMd?: { exists: boolean; path: string | null };
};
const ensureDirs = () => {
if (!fs.existsSync(OPENCODE_CONFIG_DIR)) fs.mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true });
if (!fs.existsSync(AGENT_DIR)) fs.mkdirSync(AGENT_DIR, { recursive: true });
if (!fs.existsSync(COMMAND_DIR)) fs.mkdirSync(COMMAND_DIR, { recursive: true });
};
// ============== AGENT SCOPE HELPERS ==============
const ensureProjectAgentDir = (workingDirectory: string): string => {
const projectAgentDir = path.join(workingDirectory, '.opencode', 'agents');
if (!fs.existsSync(projectAgentDir)) {
fs.mkdirSync(projectAgentDir, { recursive: true });
}
const legacyProjectAgentDir = path.join(workingDirectory, '.opencode', 'agent');
if (!fs.existsSync(legacyProjectAgentDir)) {
fs.mkdirSync(legacyProjectAgentDir, { recursive: true });
}
return projectAgentDir;
};
const getProjectAgentPath = (workingDirectory: string, agentName: string): string => {
const pluralPath = path.join(workingDirectory, '.opencode', 'agents', `${agentName}.md`);
const legacyPath = path.join(workingDirectory, '.opencode', 'agent', `${agentName}.md`);
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
return pluralPath;
};
type AgentLookupCache = {
userAgentIndexByName: Map<string, string>;
userAgentLookupByName: Map<string, string | null>;
userAgentIndexReady: boolean;
userAgentIndexBuiltAt: number;
};
const AGENT_LOOKUP_CACHE_TTL_MS = 1000;
const createAgentLookupCache = (): AgentLookupCache => ({
userAgentIndexByName: new Map<string, string>(),
userAgentLookupByName: new Map<string, string | null>(),
userAgentIndexReady: false,
userAgentIndexBuiltAt: 0,
});
const globalAgentLookupCache = createAgentLookupCache();
const resetAgentLookupCache = (cache: AgentLookupCache): void => {
cache.userAgentIndexByName.clear();
cache.userAgentLookupByName.clear();
cache.userAgentIndexReady = false;
cache.userAgentIndexBuiltAt = 0;
};
const buildUserAgentIndex = (cache: AgentLookupCache): void => {
if (cache.userAgentIndexReady && Date.now() - cache.userAgentIndexBuiltAt < AGENT_LOOKUP_CACHE_TTL_MS) {
return;
}
cache.userAgentIndexByName.clear();
cache.userAgentLookupByName.clear();
cache.userAgentIndexReady = true;
cache.userAgentIndexBuiltAt = Date.now();
if (!fs.existsSync(AGENT_DIR)) return;
const dirsToVisit: string[] = [AGENT_DIR];
while (dirsToVisit.length > 0) {
const dir = dirsToVisit.pop();
if (!dir) continue;
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
continue;
}
entries.sort((a, b) => a.name.localeCompare(b.name));
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
const discoveredAgentName = entry.name.slice(0, -3);
if (!cache.userAgentIndexByName.has(discoveredAgentName)) {
cache.userAgentIndexByName.set(discoveredAgentName, path.join(dir, entry.name));
}
}
for (let i = entries.length - 1; i >= 0; i -= 1) {
const entry = entries[i];
if (entry?.isDirectory()) {
dirsToVisit.push(path.join(dir, entry.name));
}
}
}
};
const getIndexedUserAgentPath = (agentName: string, cache: AgentLookupCache): string | null => {
if (cache.userAgentLookupByName.has(agentName)) {
return cache.userAgentLookupByName.get(agentName) || null;
}
buildUserAgentIndex(cache);
const found = cache.userAgentIndexByName.get(agentName) || null;
cache.userAgentLookupByName.set(agentName, found);
return found;
};
const getUserAgentPath = (agentName: string, lookupCache: AgentLookupCache = globalAgentLookupCache): string => {
const pluralPath = path.join(AGENT_DIR, `${agentName}.md`);
if (fs.existsSync(pluralPath)) return pluralPath;
const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'agent', `${agentName}.md`);
if (fs.existsSync(legacyPath)) return legacyPath;
const found = getIndexedUserAgentPath(agentName, lookupCache);
if (found) return found;
return pluralPath;
};
export const getAgentScope = (
agentName: string,
workingDirectory?: string,
lookupCache: AgentLookupCache = globalAgentLookupCache
): { scope: AgentScope | null; path: string | null } => {
if (workingDirectory) {
const projectPath = getProjectAgentPath(workingDirectory, agentName);
if (fs.existsSync(projectPath)) {
return { scope: AGENT_SCOPE.PROJECT, path: projectPath };
}
}
const userPath = getUserAgentPath(agentName, lookupCache);
if (fs.existsSync(userPath)) {
return { scope: AGENT_SCOPE.USER, path: userPath };
}
return { scope: null, path: null };
};
const getAgentWritePath = (
agentName: string,
workingDirectory?: string,
requestedScope?: AgentScope,
lookupCache: AgentLookupCache = globalAgentLookupCache
): { scope: AgentScope; path: string } => {
const existing = getAgentScope(agentName, workingDirectory, lookupCache);
if (existing.path) {
return { scope: existing.scope!, path: existing.path };
}
const scope = requestedScope || AGENT_SCOPE.USER;
if (scope === AGENT_SCOPE.PROJECT && workingDirectory) {
return {
scope: AGENT_SCOPE.PROJECT,
path: getProjectAgentPath(workingDirectory, agentName)
};
}
return {
scope: AGENT_SCOPE.USER,
path: getUserAgentPath(agentName, lookupCache)
};
};
// ============== COMMAND SCOPE HELPERS ==============
const ensureProjectCommandDir = (workingDirectory: string): string => {
const projectCommandDir = path.join(workingDirectory, '.opencode', 'commands');
if (!fs.existsSync(projectCommandDir)) {
fs.mkdirSync(projectCommandDir, { recursive: true });
}
const legacyProjectCommandDir = path.join(workingDirectory, '.opencode', 'command');
if (!fs.existsSync(legacyProjectCommandDir)) {
fs.mkdirSync(legacyProjectCommandDir, { recursive: true });
}
return projectCommandDir;
};
const getProjectCommandPath = (workingDirectory: string, commandName: string): string => {
const pluralPath = path.join(workingDirectory, '.opencode', 'commands', `${commandName}.md`);
const legacyPath = path.join(workingDirectory, '.opencode', 'command', `${commandName}.md`);
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
return pluralPath;
};
const getUserCommandPath = (commandName: string): string => {
const pluralPath = path.join(COMMAND_DIR, `${commandName}.md`);
const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'command', `${commandName}.md`);
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
return pluralPath;
};
export const getCommandScope = (commandName: string, workingDirectory?: string): { scope: CommandScope | null; path: string | null } => {
if (workingDirectory) {
const projectPath = getProjectCommandPath(workingDirectory, commandName);
if (fs.existsSync(projectPath)) {
return { scope: COMMAND_SCOPE.PROJECT, path: projectPath };
}
}
const userPath = getUserCommandPath(commandName);
if (fs.existsSync(userPath)) {
return { scope: COMMAND_SCOPE.USER, path: userPath };
}
return { scope: null, path: null };
};
const getCommandWritePath = (commandName: string, workingDirectory?: string, requestedScope?: CommandScope): { scope: CommandScope; path: string } => {
const existing = getCommandScope(commandName, workingDirectory);
if (existing.path) {
return { scope: existing.scope!, path: existing.path };
}
const scope = requestedScope || COMMAND_SCOPE.USER;
if (scope === COMMAND_SCOPE.PROJECT && workingDirectory) {
return {
scope: COMMAND_SCOPE.PROJECT,
path: getProjectCommandPath(workingDirectory, commandName)
};
}
return {
scope: COMMAND_SCOPE.USER,
path: getUserCommandPath(commandName)
};
};
// ============== SNIPPET HELPERS ==============
const getProjectSnippetDirs = (workingDirectory?: string): Array<{ dir: string; source: SnippetScope }> => {
if (!workingDirectory) return [];
return [
{ dir: path.join(workingDirectory, '.opencode', 'snippets'), source: 'project' },
{ dir: path.join(workingDirectory, '.opencode', 'snippet'), source: 'project' },
];
};
const getGlobalSnippetDirs = (): Array<{ dir: string; source: SnippetScope }> => [
{ dir: GLOBAL_SNIPPET_DIR_ALT, source: 'global' },
{ dir: GLOBAL_SNIPPET_DIR, source: 'global' },
];
const assertValidSnippetName = (name: string): void => {
if (typeof name !== 'string' || !SNIPPET_NAME_PATTERN.test(name)) {
throw new Error('Snippet name must use letters, numbers, dashes, or underscores');
}
};
const normalizeSnippetAliases = (frontmatter: Record<string, unknown>): string[] => {
const raw = frontmatter.aliases ?? frontmatter.alias;
if (!raw) return [];
const aliases = Array.isArray(raw) ? raw : [raw];
return aliases.map((alias) => String(alias).trim()).filter(Boolean);
};
const loadSnippetFile = (dir: string, filename: string, source: SnippetScope): Snippet | null => {
const name = path.basename(filename, SNIPPET_EXTENSION);
if (!SNIPPET_NAME_PATTERN.test(name)) return null;
const filePath = path.join(dir, filename);
const { frontmatter, body } = parseMdFile(filePath);
return {
name,
content: body,
aliases: normalizeSnippetAliases(frontmatter),
description: typeof frontmatter.description === 'string' ? frontmatter.description : undefined,
filePath,
source,
};
};
const registerSnippet = (registry: Map<string, Snippet>, snippet: Snippet): void => {
const key = snippet.name.toLowerCase();
const existing = registry.get(key);
if (existing) {
for (const alias of existing.aliases) registry.delete(alias.toLowerCase());
}
registry.set(key, snippet);
for (const alias of snippet.aliases) {
if (SNIPPET_NAME_PATTERN.test(alias)) registry.set(alias.toLowerCase(), snippet);
}
};
const loadSnippetRegistry = (workingDirectory?: string): Map<string, Snippet> => {
const registry = new Map<string, Snippet>();
for (const { dir, source } of [...getGlobalSnippetDirs(), ...getProjectSnippetDirs(workingDirectory)]) {
if (!fs.existsSync(dir)) continue;
for (const filename of fs.readdirSync(dir)) {
if (!filename.endsWith(SNIPPET_EXTENSION)) continue;
try {
const snippet = loadSnippetFile(dir, filename, source);
if (snippet) registerSnippet(registry, snippet);
} catch (error) {
console.warn(`[OpenChamber][VSCode] Failed to load snippet ${path.join(dir, filename)}:`, error);
}
}
}
return registry;
};
const listUniqueSnippets = (registry: Map<string, Snippet>): Snippet[] => {
const seen = new Set<string>();
const snippets: Snippet[] = [];
for (const snippet of registry.values()) {
const key = `${snippet.source}:${snippet.filePath}`;
if (seen.has(key)) continue;
seen.add(key);
snippets.push(snippet);
}
return snippets.sort((a, b) => a.name.localeCompare(b.name));
};
const getWritableSnippetDir = (scope: SnippetScope, workingDirectory?: string): string => {
if (scope === 'project') {
if (!workingDirectory) throw new Error('Project directory is required for project snippets');
const preferred = path.join(workingDirectory, '.opencode', 'snippet');
const alternate = path.join(workingDirectory, '.opencode', 'snippets');
return fs.existsSync(alternate) && !fs.existsSync(preferred) ? alternate : preferred;
}
return fs.existsSync(GLOBAL_SNIPPET_DIR_ALT) && !fs.existsSync(GLOBAL_SNIPPET_DIR)
? GLOBAL_SNIPPET_DIR_ALT
: GLOBAL_SNIPPET_DIR;
};
const findSnippetByName = (name: string, workingDirectory?: string): Snippet | null => {
assertValidSnippetName(name);
return loadSnippetRegistry(workingDirectory).get(name.toLowerCase()) ?? null;
};
const writeSnippetFile = (filePath: string, config: Record<string, unknown>): void => {
const aliases = Array.isArray(config.aliases)
? config.aliases.map((alias) => String(alias).trim()).filter(Boolean)
: [];
const frontmatter: Record<string, unknown> = {};
if (aliases.length > 0) frontmatter.aliases = aliases;
if (typeof config.description === 'string' && config.description.trim()) {
frontmatter.description = config.description.trim();
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
writeMdFile(filePath, frontmatter, typeof config.content === 'string' ? config.content : '');
};
const parseSnippetBlocks = (content: string): { inline: string; prepend: string[]; append: string[] } => {
const blocks = { prepend: [] as string[], append: [] as string[] };
let inline = content;
for (const type of ['prepend', 'append'] as const) {
const regex = new RegExp(`<${type}>([\\s\\S]*?)(?:<\\/${type}>|$)`, 'gi');
inline = inline.replace(regex, (_match, value: string) => {
const normalized = String(value).trim();
if (normalized) blocks[type].push(normalized);
return '';
});
}
inline = inline.replace(/<inject>[\s\S]*?(?:<\/inject>|$)/gi, '').trim();
return { inline, prepend: blocks.prepend, append: blocks.append };
};
const expandSnippetText = (
text: string,
registry: Map<string, Snippet>,
expansionCounts: Map<string, number>,
collector: { prepend: string[]; append: string[] },
): string => {
let expanded = text;
let changed = true;
while (changed) {
const previous = expanded;
let loopDetected = false;
HASHTAG_PATTERN.lastIndex = 0;
expanded = expanded.replace(HASHTAG_PATTERN, (match, name: string, offset: number, input: string) => {
if (name.toLowerCase() === 'skill' && input[offset + match.length] === '(') return match;
const snippet = registry.get(name.toLowerCase());
if (!snippet) return match;
const key = snippet.name.toLowerCase();
const count = (expansionCounts.get(key) || 0) + 1;
if (count > MAX_SNIPPET_EXPANSION_COUNT) {
loopDetected = true;
return match;
}
expansionCounts.set(key, count);
const parsed = parseSnippetBlocks(snippet.content);
for (const block of parsed.prepend) collector.prepend.push(expandSnippetText(block, registry, expansionCounts, collector));
for (const block of parsed.append) collector.append.push(expandSnippetText(block, registry, expansionCounts, collector));
return expandSnippetText(parsed.inline, registry, expansionCounts, collector);
});
changed = expanded !== previous && !loopDetected;
}
return expanded;
};
const isPromptFileReference = (value: unknown): value is string => {
return typeof value === 'string' && PROMPT_FILE_PATTERN.test(value.trim());
};
const resolvePromptFilePath = (reference: string): string | null => {
const match = reference.trim().match(PROMPT_FILE_PATTERN);
if (!match?.[1]) return null;
let target = match[1].trim();
if (!target) return null;
if (target.startsWith('./')) {
target = path.join(OPENCODE_CONFIG_DIR, target.slice(2));
} else if (!path.isAbsolute(target)) {
target = path.join(OPENCODE_CONFIG_DIR, target);
}
return target;
};
const writePromptFile = (filePath: string, content: string) => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, content, 'utf8');
};
/**
* Get all possible project config paths in priority order
* Priority: root > .opencode/, json > jsonc
*/
const getProjectConfigCandidates = (workingDirectory?: string): string[] => {
if (!workingDirectory) return [];
return [
path.join(workingDirectory, 'opencode.json'),
path.join(workingDirectory, 'opencode.jsonc'),
path.join(workingDirectory, '.opencode', 'opencode.json'),
path.join(workingDirectory, '.opencode', 'opencode.jsonc'),
];
};
/**
* Find existing project config file or return default path for new config
*/
const getProjectConfigPath = (workingDirectory?: string): string | null => {
if (!workingDirectory) return null;
const candidates = getProjectConfigCandidates(workingDirectory);
// Return first existing config file
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
// Default to root opencode.json for new configs
return candidates[0] || null;
};
const getConfigPaths = (workingDirectory?: string) => ({
userPaths: [
path.join(OPENCODE_CONFIG_DIR, 'config.json'),
path.join(OPENCODE_CONFIG_DIR, 'opencode.json'),
path.join(OPENCODE_CONFIG_DIR, 'opencode.jsonc'),
],
projectPath: getProjectConfigPath(workingDirectory),
customPath: CUSTOM_CONFIG_FILE
});
const getPrimaryUserConfigPath = (userPaths: string[]): string => {
for (const userPath of userPaths) {
if (fs.existsSync(userPath)) {
return userPath;
}
}
return CONFIG_FILE;
};
const readConfigFile = (filePath?: string | null): Record<string, unknown> => {
if (!filePath || !fs.existsSync(filePath)) return {};
const content = fs.readFileSync(filePath, 'utf8');
const normalized = content.trim();
if (!normalized) return {};
return parseJsonc(normalized, [], { allowTrailingComma: true }) as Record<string, unknown>;
};
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const mergeConfigs = (base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> => {
const result: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(override)) {
if (key in result) {
const baseValue = result[key];
if (isPlainObject(baseValue) && isPlainObject(value)) {
result[key] = mergeConfigs(baseValue, value);
} else {
result[key] = value;
}
} else {
result[key] = value;
}
}
return result;
};
const readConfigLayers = (workingDirectory?: string) => {
const { userPaths, projectPath, customPath } = getConfigPaths(workingDirectory);
const userPath = getPrimaryUserConfigPath(userPaths);
const userConfig = readConfigFile(userPath);
const projectConfig = readConfigFile(projectPath);
const customConfig = readConfigFile(customPath);
const mergedConfig = mergeConfigs(mergeConfigs(userConfig, projectConfig), customConfig);
return {
userConfig,
projectConfig,
customConfig,
mergedConfig,
paths: { userPath, projectPath, customPath }
};
};
const readConfig = (workingDirectory?: string): Record<string, unknown> =>
readConfigLayers(workingDirectory).mergedConfig;
const getAncestors = (startDir?: string, stopDir?: string): string[] => {
if (!startDir) return [];
const result: string[] = [];
let current = path.resolve(startDir);
const resolvedStop = stopDir ? path.resolve(stopDir) : null;
while (true) {
result.push(current);
if (resolvedStop && current === resolvedStop) break;
const parent = path.dirname(current);
if (parent === current) break;
current = parent;
}
return result;
};
const findWorktreeRoot = (startDir?: string): string | null => {
if (!startDir) return null;
let current = path.resolve(startDir);
while (true) {
if (fs.existsSync(path.join(current, '.git'))) {
return current;
}
const parent = path.dirname(current);
if (parent === current) return null;
current = parent;
}
};
const walkSkillMdFiles = (rootDir?: string | null): string[] => {
if (!rootDir || !fs.existsSync(rootDir)) return [];
const results: string[] = [];
const walkDir = (dir: string) => {
let entries: fs.Dirent[] = [];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDir(fullPath);
continue;
}
if (entry.isFile() && entry.name === 'SKILL.md') {
results.push(fullPath);
}
}
};
walkDir(rootDir);
return results;
};
const resolveSkillSearchDirectories = (workingDirectory?: string): string[] => {
const directories: string[] = [];
const pushDir = (dir?: string | null) => {
if (!dir) return;
const resolved = path.resolve(dir);
if (!directories.includes(resolved)) {
directories.push(resolved);
}
};
pushDir(OPENCODE_CONFIG_DIR);
if (workingDirectory) {
const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory);
const projectDirs = getAncestors(workingDirectory, worktreeRoot)
.map((dir) => path.join(dir, '.opencode'));
projectDirs.forEach(pushDir);
}
pushDir(path.join(os.homedir(), '.opencode'));
pushDir(process.env.OPENCODE_CONFIG_DIR ? path.resolve(process.env.OPENCODE_CONFIG_DIR) : null);
return directories;
};
const getConfigForPath = (layers: ReturnType<typeof readConfigLayers>, targetPath?: string | null) => {
if (!targetPath) return layers.userConfig;
if (layers.paths.customPath && targetPath === layers.paths.customPath) return layers.customConfig;
if (layers.paths.projectPath && targetPath === layers.paths.projectPath) return layers.projectConfig;
return layers.userConfig;
};
const writeConfig = (config: Record<string, unknown>, filePath: string = CONFIG_FILE) => {
if (fs.existsSync(filePath)) {
const backupFile = `${filePath}.openchamber.backup`;
try {
fs.copyFileSync(filePath, backupFile);
} catch {
// ignore backup failures
}
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf8');
};
const codedError = (message: string, code: string): Error & { code: string } => {
const error = new Error(message) as Error & { code: string };
error.code = code;
return error;
};
const validatePluginScope = (scope: unknown): PluginScope => {
if (scope === 'user' || scope === 'project') return scope;
throw codedError('Plugin scope must be user or project', 'INVALID_SCOPE');
};
const validatePluginSpec = (spec: unknown): string => {
if (typeof spec !== 'string' || spec.trim().length === 0) {
throw codedError('Plugin spec must be a non-empty string', 'INVALID_SPEC');
}
if (spec.includes('\0')) {
throw codedError('Plugin spec cannot contain null bytes', 'INVALID_SPEC');
}
return spec.trim();
};
const PLUGIN_FILE_NAME_PATTERN = /^[a-z0-9][a-z0-9-_.]*\.(js|ts|mjs|cjs)$/;
const validatePluginFileName = (fileName: unknown): string => {
if (typeof fileName !== 'string' || fileName.trim().length === 0) {
throw codedError('Plugin file name is required', 'INVALID_FILENAME');
}
const normalized = fileName.trim();
if (
normalized.includes('/') ||
normalized.includes('\\') ||
normalized.includes('..') ||
!PLUGIN_FILE_NAME_PATTERN.test(normalized)
) {
throw codedError('Plugin file name must match /^[a-z0-9][a-z0-9-_.]*\\.(js|ts|mjs|cjs)$/ and cannot contain path traversal', 'INVALID_FILENAME');
}
return normalized;
};
const encodePluginId = (prefix: 'config' | 'file', value: string): string =>
Buffer.from(`${prefix}:${value}`, 'utf8').toString('base64url');
const decodePluginId = (id: string): { prefix: string; value: string } => {
try {
const decoded = Buffer.from(id, 'base64url').toString('utf8');
const separator = decoded.indexOf(':');
if (separator <= 0) throw new Error('invalid plugin id');
return { prefix: decoded.slice(0, separator), value: decoded.slice(separator + 1) };
} catch {
throw codedError('Invalid plugin id', 'INVALID_SPEC');
}
};
const parsePluginIdValue = (value: string): { scope: PluginScope; rest: string } => {
const separator = value.indexOf(':');
if (separator <= 0) {
throw codedError('Plugin id value must include scope', 'INVALID_SPEC');
}
return {
scope: validatePluginScope(value.slice(0, separator)),
rest: value.slice(separator + 1),
};
};
const parsePluginRaw = (raw: unknown): { spec: string; options?: Record<string, unknown> } => {
if (typeof raw === 'string') {
return { spec: validatePluginSpec(raw) };
}
if (Array.isArray(raw) && typeof raw[0] === 'string' && isPlainObject(raw[1])) {
return { spec: validatePluginSpec(raw[0]), options: { ...raw[1] } };
}
throw codedError('Plugin spec must be a string or [string, object]', 'INVALID_SPEC');
};
const serializePluginEntry = (entry: { spec?: unknown; options?: unknown }): string | [string, Record<string, unknown>] => {
const spec = validatePluginSpec(entry.spec);
if (isPlainObject(entry.options) && Object.keys(entry.options).length > 0) {
return [spec, { ...entry.options }];
}
return spec;
};
const isPluginPathSpec = (spec: string): boolean =>
spec.startsWith('/') || spec.startsWith('./') || spec.startsWith('../') || spec.startsWith('~') || path.win32.isAbsolute(spec);
const parsePluginPathSpec = (spec: string, workingDirectory?: string | null): { absolutePath: string } => {
if (spec === '~') return { absolutePath: path.resolve(os.homedir()) };
if (spec.startsWith('~/')) return { absolutePath: path.resolve(os.homedir(), spec.slice(2)) };
if (spec.startsWith('./') || spec.startsWith('../')) {
return { absolutePath: path.resolve(workingDirectory || os.homedir(), spec) };
}
if (path.win32.isAbsolute(spec)) return { absolutePath: spec };
return { absolutePath: path.resolve(spec) };
};
const parsePluginNpmSpec = (spec: string): { name: string; version: string | null } | { malformed: true } => {
if (spec.startsWith('@')) {
const slashIdx = spec.indexOf('/');
if (slashIdx < 2) return { malformed: true };
const afterSlash = spec.slice(slashIdx + 1);
if (!afterSlash) return { malformed: true };
const atIdx = afterSlash.indexOf('@');
if (atIdx === -1) return { name: spec, version: null };
const version = afterSlash.slice(atIdx + 1);
if (!version) return { malformed: true };
return { name: spec.slice(0, slashIdx + 1 + atIdx), version };
}
if (!spec) return { malformed: true };
const atIdx = spec.indexOf('@');
if (atIdx === -1) return { name: spec, version: null };
if (atIdx === 0) return { malformed: true };
const version = spec.slice(atIdx + 1);
if (!version) return { malformed: true };
return { name: spec.slice(0, atIdx), version };
};
const isExactPluginSemver = (version: string): boolean => /^\d+\.\d+\.\d+([-+][\w.-]+)?$/.test(version);
const getActiveCustomConfigPath = (): string | null =>
process.env.OPENCODE_CONFIG ? path.resolve(process.env.OPENCODE_CONFIG) : null;
const getActiveOpencodeConfigDir = (): string => {
const customConfigPath = getActiveCustomConfigPath();
return customConfigPath ? path.dirname(customConfigPath) : OPENCODE_CONFIG_DIR;
};
const getActiveUserConfigPaths = (): string[] => {
const configDir = getActiveOpencodeConfigDir();
return [
path.join(configDir, 'config.json'),
path.join(configDir, 'opencode.json'),
path.join(configDir, 'opencode.jsonc'),
];
};
const getActivePrimaryUserConfigPath = (): string => {
const [defaultPath, ...fallbackPaths] = getActiveUserConfigPaths();
for (const userPath of [defaultPath, ...fallbackPaths]) {
if (fs.existsSync(userPath)) {
return userPath;
}
}
return defaultPath;
};
const ensureProjectPluginConfigPath = (workingDirectory?: string | null): string => {
if (!workingDirectory) throw codedError('Project plugin scope requires working directory', 'INVALID_SCOPE');
return path.join(workingDirectory, '.opencode', 'opencode.json');
};
const getPluginConfigSources = (workingDirectory?: string | null): Array<{ scope: PluginScope; path: string; config: Record<string, unknown> }> => {
const customPath = getActiveCustomConfigPath();
const userPath = getActivePrimaryUserConfigPath();
const projectPath = getProjectConfigPath(workingDirectory || undefined);
return [
customPath
? { scope: 'user', path: customPath, config: readConfigFile(customPath) }
: { scope: 'user', path: userPath, config: readConfigFile(userPath) },
...(projectPath
? [{ scope: 'project' as const, path: projectPath, config: readConfigFile(projectPath) }]
: []),
];
};
const readPluginArray = (config: Record<string, unknown>): unknown[] => Array.isArray(config.plugin) ? config.plugin : [];
const writePluginArray = (config: Record<string, unknown>, plugin: unknown[]): Record<string, unknown> => {
const next = { ...config };
if (plugin.length > 0) {
next.plugin = plugin;
} else {
delete next.plugin;
}
return next;
};
const hasPluginSpec = (plugin: unknown[], spec: string): boolean => plugin.some((raw) => {
try {
return parsePluginRaw(raw).spec === spec;
} catch {
return false;
}
});
const getPluginTarget = (id: string, workingDirectory?: string | null): null | {
scope: PluginScope;
path: string;
config: Record<string, unknown>;
plugin: unknown[];
index: number;
spec: string;
} => {
const decoded = decodePluginId(id);
if (decoded.prefix !== 'config') {
throw codedError('Plugin entry id must use config prefix', 'INVALID_SPEC');
}
const { scope, rest: spec } = parsePluginIdValue(decoded.value);
const source = getPluginConfigSources(workingDirectory).find((candidate) => candidate.scope === scope);
if (!source) return null;
const plugin = readPluginArray(source.config);
const index = plugin.findIndex((raw) => {
try {
return parsePluginRaw(raw).spec === spec;
} catch {
return false;
}
});
if (index < 0) return null;
return { scope, path: source.path, config: source.config, plugin: [...plugin], index, spec };
};
export const listPluginEntries = (workingDirectory?: string): PluginEntry[] => {
const entries: PluginEntry[] = [];
for (const source of getPluginConfigSources(workingDirectory)) {
for (const raw of readPluginArray(source.config)) {
try {
const parsed = parsePluginRaw(raw);
entries.push({
id: encodePluginId('config', `${source.scope}:${parsed.spec}`),
spec: parsed.spec,
...(parsed.options ? { options: parsed.options } : {}),
scope: source.scope,
kind: 'config',
parsedKind: isPluginPathSpec(parsed.spec) ? 'path' : 'npm',
});
} catch {
// Ignore malformed persisted plugin entries so the settings page remains usable.
}
}
}
return entries;
};
export const getPluginEntry = (id: string, workingDirectory?: string): PluginEntry | null =>
listPluginEntries(workingDirectory).find((entry) => entry.id === id) || null;
export const createPluginEntry = (entry: { spec?: unknown; options?: unknown; scope?: unknown }, workingDirectory?: string): void => {
const spec = validatePluginSpec(entry.spec);
const scope = validatePluginScope(entry.scope || 'user');
const sources = getPluginConfigSources(workingDirectory);
if (sources.some((source) => source.scope === scope && hasPluginSpec(readPluginArray(source.config), spec))) {
throw codedError(`Plugin "${spec}" already exists`, 'ENTRY_EXISTS');
}
const userSource = sources.find((source) => source.scope === 'user');
const targetPath = scope === 'project'
? ensureProjectPluginConfigPath(workingDirectory)
: userSource?.path ?? getActivePrimaryUserConfigPath();
const config = fs.existsSync(targetPath) ? readConfigFile(targetPath) : {};
const plugin = readPluginArray(config);
writeConfig(writePluginArray(config, [...plugin, serializePluginEntry({ spec, options: entry.options })]), targetPath);
};
export const updatePluginEntry = (id: string, updates: { spec?: unknown; options?: unknown }, workingDirectory?: string): void => {
const target = getPluginTarget(id, workingDirectory);
if (!target) throw codedError('Plugin entry not found', 'NOT_FOUND');
const existing = parsePluginRaw(target.plugin[target.index]);
const nextSpec = updates.spec === undefined ? existing.spec : validatePluginSpec(updates.spec);
const nextOptions = updates.options === undefined ? existing.options : updates.options;
target.plugin[target.index] = serializePluginEntry({ spec: nextSpec, options: nextOptions });
writeConfig(writePluginArray(target.config, target.plugin), target.path);
};
export const deletePluginEntry = (id: string, workingDirectory?: string): void => {
const target = getPluginTarget(id, workingDirectory);
if (!target) throw codedError('Plugin entry not found', 'NOT_FOUND');
target.plugin.splice(target.index, 1);
writeConfig(writePluginArray(target.config, target.plugin), target.path);
};
const getPluginDir = (scope: PluginScope, workingDirectory?: string | null): string => {
if (scope === 'project') {
if (!workingDirectory) throw codedError('Project plugin scope requires working directory', 'INVALID_SCOPE');
return path.join(workingDirectory, '.opencode', 'plugins');
}
return path.join(getActiveOpencodeConfigDir(), 'plugins');
};
const getPluginFileTarget = (id: string, workingDirectory?: string): { scope: PluginScope; fileName: string; filePath: string } => {
const decoded = decodePluginId(id);
if (decoded.prefix !== 'file') {
throw codedError('Plugin file id must use file prefix', 'INVALID_FILENAME');
}
const { scope, rest } = parsePluginIdValue(decoded.value);
const fileName = validatePluginFileName(rest);
return { scope, fileName, filePath: path.join(getPluginDir(scope, workingDirectory), fileName) };
};
export const listPluginDirFiles = (workingDirectory?: string): PluginFile[] => {
const files: PluginFile[] = [];
for (const scope of ['user', 'project'] as const) {
let dir: string;
try {
dir = getPluginDir(scope, workingDirectory);
} catch {
continue;
}
if (!fs.existsSync(dir)) continue;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
try {
const fileName = validatePluginFileName(entry.name);
files.push({
id: encodePluginId('file', `${scope}:${fileName}`),
fileName,
scope,
kind: 'file',
});
} catch {
// Ignore unsupported files in the plugins directory.
}
}
}
return files.sort((a, b) => `${a.scope}:${a.fileName}`.localeCompare(`${b.scope}:${b.fileName}`));
};
export const readPluginDirFile = (id: string, workingDirectory?: string): { fileName: string; scope: PluginScope; content: string } | null => {
const target = getPluginFileTarget(id, workingDirectory);
if (!fs.existsSync(target.filePath)) return null;
return {
fileName: target.fileName,
scope: target.scope,
content: fs.readFileSync(target.filePath, 'utf8'),
};
};
export const writePluginDirFile = (
file: { fileName?: unknown; content?: unknown; scope?: unknown },
workingDirectory?: string,
opts: { overwrite?: boolean } = {},
): void => {
const scope = validatePluginScope(file.scope || 'user');
const fileName = validatePluginFileName(file.fileName);
const filePath = path.join(getPluginDir(scope, workingDirectory), fileName);
if (!opts.overwrite && fs.existsSync(filePath)) {
throw codedError(`Plugin file "${fileName}" already exists`, 'FILE_EXISTS');
}
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, typeof file.content === 'string' ? file.content : '', 'utf8');
};
export const deletePluginDirFile = (id: string, workingDirectory?: string): void => {
const target = getPluginFileTarget(id, workingDirectory);
if (!fs.existsSync(target.filePath)) {
throw codedError(`Plugin file "${target.fileName}" not found`, 'NOT_FOUND');
}
fs.rmSync(target.filePath, { force: true });
};
type NpmLookupResult =
| { ok: true; latest: string | null; versions: string[] }
| { ok: false; status: number | 'network'; error: string };
const npmInfoCache = new Map<string, { fetchedAt: number; payload: NpmLookupResult }>();
const npmInfoInFlight = new Map<string, Promise<NpmLookupResult>>();
const NPM_CACHE_TTL_MS = 3_600_000;
const lookupNpmPackage = async (name: string): Promise<NpmLookupResult> => {
try {
const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(name).replace(/^%40/, '@')}`, {
headers: { Accept: 'application/json', 'User-Agent': 'openchamber-vscode/dev' },
signal: AbortSignal.timeout(5000),
});
if (response.ok) {
const data = await response.json() as { versions?: unknown; 'dist-tags'?: { latest?: unknown } };
return {
ok: true,
latest: typeof data['dist-tags']?.latest === 'string' ? data['dist-tags'].latest : null,
versions: isPlainObject(data.versions) ? Object.keys(data.versions) : [],
};
}
if (response.status === 404) return { ok: false, status: 404, error: 'Package not found' };
return { ok: false, status: response.status, error: `Registry returned ${response.status}` };
} catch (error) {
return { ok: false, status: 'network', error: error instanceof Error ? error.message : String(error) };
}
};
const getNpmInfo = async (name: string, forceRefresh = false): Promise<NpmLookupResult> => {
const cached = npmInfoCache.get(name);
if (cached && !forceRefresh && Date.now() - cached.fetchedAt < NPM_CACHE_TTL_MS) {
return cached.payload;
}
const existing = npmInfoInFlight.get(name);
if (existing && !forceRefresh) return existing;
const lookup = lookupNpmPackage(name);
npmInfoInFlight.set(name, lookup);
try {
const result = await lookup;
if (result.ok || result.status === 404) {
npmInfoCache.set(name, { fetchedAt: Date.now(), payload: result });
}
return result;
} finally {
if (npmInfoInFlight.get(name) === lookup) {
npmInfoInFlight.delete(name);
}
}
};
export const queryPluginRegistry = async (
specs: string[],
opts: { refresh?: boolean; workingDirectory?: string } = {},
): Promise<{ results: PluginRegistryResult[] }> => {
const uniqueSpecs = Array.from(new Set(specs.filter((spec) => spec.length > 0)));
if (uniqueSpecs.length > 100) {
throw codedError('too many specs', 'INVALID_SPEC');
}
const npmJobs = new Map<string, string[]>();
const malformedSpecs = new Set<string>();
for (const spec of uniqueSpecs) {
if (isPluginPathSpec(spec)) continue;
const parsed = parsePluginNpmSpec(spec);
if ('malformed' in parsed) {
malformedSpecs.add(spec);
continue;
}
npmJobs.set(parsed.name, [...(npmJobs.get(parsed.name) || []), spec]);
}
const npmInfoByName = new Map<string, NpmLookupResult>();
await Promise.all(Array.from(npmJobs.keys()).map(async (name) => {
npmInfoByName.set(name, await getNpmInfo(name, opts.refresh === true));
}));
const results: PluginRegistryResult[] = [];
for (const spec of uniqueSpecs) {
if (malformedSpecs.has(spec)) {
results.push({ kind: 'npm-malformed', spec, error: 'Spec syntax is malformed' });
continue;
}
if (isPluginPathSpec(spec)) {
const { absolutePath } = parsePluginPathSpec(spec, opts.workingDirectory || os.homedir());
try {
fs.statSync(absolutePath);
} catch {
results.push({ kind: 'path-missing', spec, absolutePath });
continue;
}
try {
fs.accessSync(absolutePath, fs.constants.R_OK);
results.push({ kind: 'path-ok', spec, absolutePath });
} catch {
results.push({ kind: 'path-unreadable', spec, absolutePath });
}
continue;
}
const parsed = parsePluginNpmSpec(spec);
if ('malformed' in parsed) {
results.push({ kind: 'npm-malformed', spec, error: 'Spec syntax is malformed' });
continue;
}
const info = npmInfoByName.get(parsed.name);
if (!info?.ok) {
if (info?.status === 404) {
results.push({ kind: 'npm-missing-package', spec, name: parsed.name, error: info.error });
} else {
results.push({ kind: 'npm-network', spec, error: info?.status === 'network' ? info.error : `Registry returned ${info?.status ?? 'unknown'}` });
}
continue;
}
const currentVersion = parsed.version;
if (currentVersion !== null && isExactPluginSemver(currentVersion) && !info.versions.includes(currentVersion)) {
results.push({
kind: 'npm-missing-version',
spec,
name: parsed.name,
currentVersion,
latestVersion: info.latest,
versions: info.versions,
});
continue;
}
results.push({
kind: 'npm-ok',
spec,
name: parsed.name,
currentVersion,
latestVersion: info.latest,
versions: info.versions,
hasUpdate: currentVersion !== null && isExactPluginSemver(currentVersion) && currentVersion !== info.latest,
});
}
return { results };
};
export type McpLocalConfig = {
type: 'local';
command?: string[];
environment?: Record<string, string>;
enabled?: boolean;
};
export type McpRemoteConfig = {
type: 'remote';
url?: string;
environment?: Record<string, string>;
headers?: Record<string, string>;
enabled?: boolean;
};
export type McpConfigPayload = McpLocalConfig | McpRemoteConfig;
export type McpConfigEntry = {
name: string;
scope?: AgentScope | null;
type: 'local' | 'remote';
command?: string[];
url?: string;
environment?: Record<string, string>;
headers?: Record<string, string>;
enabled: boolean;
};
const resolveMcpScopeFromPath = (layers: ReturnType<typeof readConfigLayers>, sourcePath?: string | null): AgentScope | null => {
if (!sourcePath) return null;
return sourcePath === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
};
const ensureProjectMcpConfigPath = (workingDirectory: string): string => {
const projectConfigDir = path.join(workingDirectory, '.opencode');
if (!fs.existsSync(projectConfigDir)) {
fs.mkdirSync(projectConfigDir, { recursive: true });
}
return path.join(projectConfigDir, 'opencode.json');
};
const validateMcpName = (name: string): void => {
if (!name || typeof name !== 'string') {
throw new Error('MCP server name is required');
}
if (!/^[a-z0-9][a-z0-9_-]*[a-z0-9]$|^[a-z0-9]$/.test(name)) {
throw new Error('MCP server name must be lowercase alphanumeric with hyphens/underscores');
}
};
const buildMcpEntry = (data: Record<string, unknown>): Omit<McpConfigEntry, 'name'> => {
const entry: Omit<McpConfigEntry, 'name'> = {
type: data.type === 'remote' ? 'remote' : 'local',
enabled: data.enabled !== false,
};
if (entry.type === 'local') {
if (Array.isArray(data.command) && data.command.length > 0) {
entry.command = data.command.map((value) => String(value));
}
} else {
if (typeof data.url === 'string' && data.url.trim()) {
entry.url = data.url.trim();
}
if (isPlainObject(data.headers)) {
const cleanedHeaders: Record<string, string> = {};
for (const [key, value] of Object.entries(data.headers)) {
if (key && value != null) {
cleanedHeaders[key] = String(value);
}
}
if (Object.keys(cleanedHeaders).length > 0) {
entry.headers = cleanedHeaders;
}
}
}
if (isPlainObject(data.environment)) {
const cleaned: Record<string, string> = {};
for (const [key, value] of Object.entries(data.environment)) {
if (key && value != null) {
cleaned[key] = String(value);
}
}
if (Object.keys(cleaned).length > 0) {
entry.environment = cleaned;
}
}
return entry;
};
export const listMcpConfigs = (workingDirectory?: string): McpConfigEntry[] => {
const layers = readConfigLayers(workingDirectory);
const merged = (layers.mergedConfig as Record<string, unknown>) || {};
const mcp = isPlainObject(merged.mcp) ? merged.mcp : {};
return Object.entries(mcp)
.filter(([, value]) => isPlainObject(value))
.map(([name, value]) => {
const source = getJsonEntrySource(layers, 'mcp', name);
return {
name,
...buildMcpEntry(value as Record<string, unknown>),
scope: resolveMcpScopeFromPath(layers, source.path),
};
});
};
export const getMcpConfig = (name: string, workingDirectory?: string): McpConfigEntry | null => {
const layers = readConfigLayers(workingDirectory);
const merged = (layers.mergedConfig as Record<string, unknown>) || {};
const mcp = isPlainObject(merged.mcp) ? merged.mcp : {};
const entry = mcp[name];
if (!isPlainObject(entry)) {
return null;
}
const source = getJsonEntrySource(layers, 'mcp', name);
return {
name,
...buildMcpEntry(entry as Record<string, unknown>),
scope: resolveMcpScopeFromPath(layers, source.path),
};
};
export const createMcpConfig = (
name: string,
mcpConfig: Record<string, unknown>,
workingDirectory?: string,
scope?: AgentScope,
): void => {
validateMcpName(name);
const layers = readConfigLayers(workingDirectory);
const source = getJsonEntrySource(layers, 'mcp', name);
if (source.exists) {
throw new Error(`MCP server "${name}" already exists`);
}
let targetPath = CONFIG_FILE;
let config: Record<string, unknown> = {};
if (scope === AGENT_SCOPE.PROJECT) {
if (!workingDirectory) {
throw new Error('Project scope requires working directory');
}
targetPath = ensureProjectMcpConfigPath(workingDirectory);
config = readConfigFile(targetPath);
} else {
const jsonTarget = getJsonWriteTarget(layers, AGENT_SCOPE.USER);
targetPath = jsonTarget.path || CONFIG_FILE;
config = (jsonTarget.config || {}) as Record<string, unknown>;
}
const mcp = isPlainObject(config.mcp) ? { ...config.mcp } : {};
const { name: _ignoredName, ...entryData } = mcpConfig;
void _ignoredName;
mcp[name] = buildMcpEntry(entryData);
config.mcp = mcp;
writeConfig(config, targetPath);
};
export const updateMcpConfig = (name: string, updates: Record<string, unknown>, workingDirectory?: string): void => {
const layers = readConfigLayers(workingDirectory);
const source = getJsonEntrySource(layers, 'mcp', name);
const targetPath = source.path || CONFIG_FILE;
const config = (source.config || readConfigFile(targetPath)) as Record<string, unknown>;
const mcp = isPlainObject(config.mcp) ? { ...config.mcp } : {};
const existing = isPlainObject(mcp[name]) ? mcp[name] : {};
const { name: _ignoredName, ...updateData } = updates;
void _ignoredName;
mcp[name] = buildMcpEntry({ ...(existing as Record<string, unknown>), ...updateData });
config.mcp = mcp;
writeConfig(config, targetPath);
};
export const deleteMcpConfig = (name: string, workingDirectory?: string): void => {
const layers = readConfigLayers(workingDirectory);
const source = getJsonEntrySource(layers, 'mcp', name);
const targetPath = source.path || CONFIG_FILE;
const config = (source.config || readConfigFile(targetPath)) as Record<string, unknown>;
const mcp = isPlainObject(config.mcp) ? { ...config.mcp } : {};
if (mcp[name] === undefined) {
throw new Error(`MCP server "${name}" not found`);
}
delete mcp[name];
if (Object.keys(mcp).length === 0) {
delete config.mcp;
} else {
config.mcp = mcp;
}
writeConfig(config, targetPath);
};
const getJsonEntrySource = (
layers: ReturnType<typeof readConfigLayers>,
sectionKey: 'agent' | 'command' | 'mcp',
entryName: string
) => {
const { userConfig, projectConfig, customConfig, paths } = layers;
const customSection = (customConfig as Record<string, unknown>)?.[sectionKey] as Record<string, unknown> | undefined;
if (customSection?.[entryName] !== undefined) {
return { section: customSection[entryName], config: customConfig, path: paths.customPath, exists: true };
}
const projectSection = (projectConfig as Record<string, unknown>)?.[sectionKey] as Record<string, unknown> | undefined;
if (projectSection?.[entryName] !== undefined) {
return { section: projectSection[entryName], config: projectConfig, path: paths.projectPath, exists: true };
}
const userSection = (userConfig as Record<string, unknown>)?.[sectionKey] as Record<string, unknown> | undefined;
if (userSection?.[entryName] !== undefined) {
return { section: userSection[entryName], config: userConfig, path: paths.userPath, exists: true };
}
return { section: null, config: null, path: null, exists: false };
};
const getJsonWriteTarget = (
layers: ReturnType<typeof readConfigLayers>,
preferredScope: AgentScope | CommandScope
) => {
const { userConfig, projectConfig, customConfig, paths } = layers;
if (paths.customPath) {
return { config: customConfig, path: paths.customPath };
}
if (preferredScope === AGENT_SCOPE.PROJECT && paths.projectPath) {
return { config: projectConfig, path: paths.projectPath };
}
return { config: userConfig, path: paths.userPath };
};
const getAgentPermissionSource = (agentName: string, workingDirectory?: string) => {
if (workingDirectory) {
const projectMdPath = getProjectAgentPath(workingDirectory, agentName);
if (fs.existsSync(projectMdPath)) {
const { frontmatter } = parseMdFile(projectMdPath);
if (frontmatter.permission !== undefined) {
return { source: 'md' as const, scope: AGENT_SCOPE.PROJECT, path: projectMdPath };
}
}
}
const userMdPath = getUserAgentPath(agentName);
if (fs.existsSync(userMdPath)) {
const { frontmatter } = parseMdFile(userMdPath);
if (frontmatter.permission !== undefined) {
return { source: 'md' as const, scope: AGENT_SCOPE.USER, path: userMdPath };
}
}
const layers = readConfigLayers(workingDirectory);
const customAgent = ((layers.customConfig as Record<string, unknown>)?.agent as Record<string, unknown> | undefined)?.[agentName] as Record<string, unknown> | undefined;
if (customAgent?.permission !== undefined && layers.paths.customPath) {
return { source: 'json' as const, scope: 'custom' as const, path: layers.paths.customPath };
}
const projectAgent = ((layers.projectConfig as Record<string, unknown>)?.agent as Record<string, unknown> | undefined)?.[agentName] as Record<string, unknown> | undefined;
if (projectAgent?.permission !== undefined && layers.paths.projectPath) {
return { source: 'json' as const, scope: AGENT_SCOPE.PROJECT, path: layers.paths.projectPath };
}
const userAgent = ((layers.userConfig as Record<string, unknown>)?.agent as Record<string, unknown> | undefined)?.[agentName] as Record<string, unknown> | undefined;
if (userAgent?.permission !== undefined) {
return { source: 'json' as const, scope: AGENT_SCOPE.USER, path: layers.paths.userPath };
}
return { source: null, scope: null, path: null };
};
const mergePermissionWithNonWildcards = (newPermission: unknown, permissionSource: ReturnType<typeof getAgentPermissionSource>, agentName: string) => {
if (!permissionSource.source || !permissionSource.path) {
return newPermission;
}
let existingPermission: unknown = null;
if (permissionSource.source === 'md') {
const { frontmatter } = parseMdFile(permissionSource.path);
existingPermission = frontmatter.permission;
} else if (permissionSource.source === 'json') {
const config = readConfigFile(permissionSource.path) as Record<string, unknown>;
existingPermission = (((config.agent as Record<string, unknown> | undefined)?.[agentName] as Record<string, unknown> | undefined)?.permission);
}
if (!existingPermission || typeof existingPermission === 'string' || newPermission == null || typeof newPermission === 'string') {
return newPermission;
}
if (typeof existingPermission !== 'object' || Array.isArray(existingPermission) || typeof newPermission !== 'object' || Array.isArray(newPermission)) {
return newPermission;
}
const nonWildcardPatterns: Record<string, Record<string, unknown>> = {};
for (const [permKey, permValue] of Object.entries(existingPermission as Record<string, unknown>)) {
if (permKey === '*' || typeof permValue !== 'object' || permValue === null || Array.isArray(permValue)) continue;
const nonWildcards: Record<string, unknown> = {};
for (const [pattern, action] of Object.entries(permValue as Record<string, unknown>)) {
if (pattern !== '*') {
nonWildcards[pattern] = action;
}
}
if (Object.keys(nonWildcards).length > 0) {
nonWildcardPatterns[permKey] = nonWildcards;
}
}
if (Object.keys(nonWildcardPatterns).length === 0) {
return newPermission;
}
const merged: Record<string, unknown> = { ...(newPermission as Record<string, unknown>) };
for (const [permKey, patterns] of Object.entries(nonWildcardPatterns)) {
const newValue = merged[permKey];
if (typeof newValue === 'string') {
merged[permKey] = { '*': newValue, ...patterns };
} else if (typeof newValue === 'object' && newValue !== null && !Array.isArray(newValue)) {
merged[permKey] = { ...patterns, ...(newValue as Record<string, unknown>) };
} else {
const existingValue = (existingPermission as Record<string, unknown>)[permKey];
if (typeof existingValue === 'object' && existingValue !== null && !Array.isArray(existingValue)) {
const wildcard = (existingValue as Record<string, unknown>)['*'];
merged[permKey] = wildcard ? { '*': wildcard, ...patterns } : patterns;
}
}
}
return merged;
};
const parseMdFile = (filePath: string): { frontmatter: Record<string, unknown>; body: string } => {
const content = fs.readFileSync(filePath, 'utf8');
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
if (!match) return { frontmatter: {}, body: content.trim() };
let frontmatter: Record<string, unknown> = {};
try {
frontmatter = (yaml.parse(match[1]) || {}) as Record<string, unknown>;
} catch (error) {
console.warn(`[OpenChamber][VSCode] Failed to parse frontmatter for ${filePath}, treating as empty:`, error);
frontmatter = {};
}
return { frontmatter, body: (match[2] || '').trim() };
};
const writeMdFile = (filePath: string, frontmatter: Record<string, unknown>, body: string) => {
// Filter out null/undefined values - OpenCode expects keys to be omitted rather than set to null
const cleanedFrontmatter = Object.fromEntries(
Object.entries(frontmatter ?? {}).filter(([, value]) => value != null)
);
const yamlStr = yaml.stringify(cleanedFrontmatter);
const content = `---\n${yamlStr}---\n\n${body ?? ''}`.trimEnd();
fs.writeFileSync(filePath, content, 'utf8');
};
export const getAgentSources = (agentName: string, workingDirectory?: string): ConfigSources => {
// Check project level first (takes precedence)
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
const projectExists = projectPath ? fs.existsSync(projectPath) : false;
// Then check user level
const userPath = getUserAgentPath(agentName);
const userExists = fs.existsSync(userPath);
// Determine which md file to use (project takes precedence)
const mdPath = projectExists ? projectPath : (userExists ? userPath : null);
const mdExists = !!mdPath;
const mdScope = projectExists ? AGENT_SCOPE.PROJECT : (userExists ? AGENT_SCOPE.USER : null);
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
const agentSection = jsonSource.section as Record<string, unknown> | undefined;
const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath;
const jsonScope = jsonSource.path === layers.paths.projectPath ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER;
const sources: ConfigSources = {
md: { exists: mdExists, path: mdPath, scope: mdScope, fields: [] },
json: { exists: jsonSource.exists, path: jsonPath || CONFIG_FILE, scope: jsonSource.exists ? jsonScope : null, fields: [] },
projectMd: { exists: projectExists, path: projectPath },
userMd: { exists: userExists, path: userPath }
};
if (mdExists && mdPath) {
const { frontmatter, body } = parseMdFile(mdPath);
sources.md.fields = Object.keys(frontmatter);
if (body) sources.md.fields.push('prompt');
}
if (agentSection) {
sources.json.fields = Object.keys(agentSection);
}
return sources;
};
export const createAgent = (agentName: string, config: Record<string, unknown>, workingDirectory?: string, scope?: AgentScope) => {
ensureDirs();
// Check if agent already exists at either level
const projectPath = workingDirectory ? getProjectAgentPath(workingDirectory, agentName) : null;
const userPath = getUserAgentPath(agentName);
if (projectPath && fs.existsSync(projectPath)) {
throw new Error(`Agent ${agentName} already exists as project-level .md file`);
}
if (fs.existsSync(userPath)) {
throw new Error(`Agent ${agentName} already exists as user-level .md file`);
}
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
if (jsonSource.exists) throw new Error(`Agent ${agentName} already exists in opencode.json`);
// Determine target path based on requested scope
let targetPath: string;
if (scope === AGENT_SCOPE.PROJECT && workingDirectory) {
ensureProjectAgentDir(workingDirectory);
targetPath = projectPath!;
} else {
targetPath = userPath;
}
// Extract scope and prompt from config - scope is only used for path determination, not written to file
const { prompt, scope: _ignored, ...frontmatter } = config as Record<string, unknown> & { prompt?: unknown; scope?: unknown };
void _ignored; // Scope is only used for path determination
writeMdFile(targetPath, frontmatter, typeof prompt === 'string' ? prompt : '');
resetAgentLookupCache(globalAgentLookupCache);
};
export const updateAgent = (agentName: string, updates: Record<string, unknown>, workingDirectory?: string) => {
ensureDirs();
// Determine correct path: project level takes precedence
const { path: mdPath } = getAgentWritePath(agentName, workingDirectory);
const mdExists = mdPath ? fs.existsSync(mdPath) : false;
// Check if agent exists in opencode.json across all config layers
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
const jsonSection = jsonSource.section as Record<string, unknown> | undefined;
const hasJsonFields = Boolean(jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0);
const jsonTarget = jsonSource.exists
? { config: jsonSource.config, path: jsonSource.path }
: getJsonWriteTarget(layers, AGENT_SCOPE.USER);
const config = (jsonTarget.config || {}) as Record<string, unknown>;
// Determine if we should create a new md file:
// Only for built-in agents (no md file AND no json config)
const isBuiltinOverride = !mdExists && !hasJsonFields;
let targetPath = mdPath;
if (!mdExists && isBuiltinOverride) {
// Built-in agent override - create at user level
targetPath = getUserAgentPath(agentName);
}
// Only create md data for existing md files or built-in overrides
const mdData = mdExists && mdPath ? parseMdFile(mdPath) : (isBuiltinOverride ? { frontmatter: {} as Record<string, unknown>, body: '' } : null);
let mdModified = false;
let jsonModified = false;
// Only create new md if it's a built-in override
const creatingNewMd = isBuiltinOverride;
for (const [field, value] of Object.entries(updates || {})) {
if (field === 'prompt') {
if (value === null) {
if (mdExists || creatingNewMd) {
if (mdData) {
mdData.body = '';
mdModified = true;
}
continue;
}
if (isPromptFileReference(jsonSection?.prompt)) {
const promptFilePath = resolvePromptFilePath(jsonSection.prompt);
if (!promptFilePath) throw new Error(`Invalid prompt file reference for agent ${agentName}`);
writePromptFile(promptFilePath, '');
continue;
}
if (config.agent) {
const agentMap = config.agent as Record<string, unknown>;
const current = agentMap[agentName] as Record<string, unknown> | undefined;
if (current) {
delete current.prompt;
if (Object.keys(current).length === 0) {
delete agentMap[agentName];
}
if (Object.keys(agentMap).length === 0) {
delete config.agent;
}
jsonModified = true;
}
}
continue;
}
const normalizedValue = typeof value === 'string' ? value : value == null ? '' : String(value);
if (mdExists || creatingNewMd) {
if (mdData) {
mdData.body = normalizedValue;
mdModified = true;
}
continue;
}
if (isPromptFileReference(jsonSection?.prompt)) {
const promptFilePath = resolvePromptFilePath(jsonSection.prompt);
if (!promptFilePath) throw new Error(`Invalid prompt file reference for agent ${agentName}`);
writePromptFile(promptFilePath, normalizedValue);
continue;
}
// For JSON-only agents, store prompt inline in JSON
if (!config.agent) config.agent = {};
const current = ((config.agent as Record<string, unknown>)[agentName] as Record<string, unknown> | undefined) ?? {};
(config.agent as Record<string, unknown>)[agentName] = { ...current, prompt: normalizedValue };
jsonModified = true;
continue;
}
if (field === 'permission') {
const permissionSource = getAgentPermissionSource(agentName, workingDirectory);
const newPermission = mergePermissionWithNonWildcards(value, permissionSource, agentName);
if (permissionSource.source === 'md' && permissionSource.path) {
if (mdData && permissionSource.path === targetPath) {
mdData.frontmatter.permission = newPermission;
mdModified = true;
} else {
const existingMdData = parseMdFile(permissionSource.path);
existingMdData.frontmatter.permission = newPermission;
writeMdFile(permissionSource.path, existingMdData.frontmatter, existingMdData.body);
}
} else if (permissionSource.source === 'json' && permissionSource.path) {
if (permissionSource.path === (jsonTarget.path || CONFIG_FILE)) {
if (!config.agent) config.agent = {};
const current = ((config.agent as Record<string, unknown>)[agentName] as Record<string, unknown> | undefined) ?? {};
(config.agent as Record<string, unknown>)[agentName] = { ...current, permission: newPermission };
jsonModified = true;
} else {
const existingConfig = readConfigFile(permissionSource.path) as Record<string, unknown>;
const agentMap = (existingConfig.agent as Record<string, unknown> | undefined) ?? {};
const current = (agentMap[agentName] as Record<string, unknown> | undefined) ?? {};
agentMap[agentName] = { ...current, permission: newPermission };
existingConfig.agent = agentMap;
writeConfig(existingConfig, permissionSource.path);
}
} else if (mdExists && mdData) {
mdData.frontmatter.permission = newPermission;
mdModified = true;
} else if (hasJsonFields) {
if (!config.agent) config.agent = {};
const current = ((config.agent as Record<string, unknown>)[agentName] as Record<string, unknown> | undefined) ?? {};
(config.agent as Record<string, unknown>)[agentName] = { ...current, permission: newPermission };
jsonModified = true;
} else {
const writeTarget = getJsonWriteTarget(layers, AGENT_SCOPE.USER);
const targetConfig = (writeTarget.config || {}) as Record<string, unknown>;
const agentMap = (targetConfig.agent as Record<string, unknown> | undefined) ?? {};
const current = (agentMap[agentName] as Record<string, unknown> | undefined) ?? {};
agentMap[agentName] = { ...current, permission: newPermission };
targetConfig.agent = agentMap;
writeConfig(targetConfig, writeTarget.path || CONFIG_FILE);
}
continue;
}
const hasMdField = Boolean(mdData?.frontmatter?.[field] !== undefined);
const hasJsonField = Boolean(jsonSection?.[field] !== undefined);
// JSON takes precedence over md, so update JSON first if field exists there
if (hasJsonField) {
if (!config.agent) config.agent = {};
const current = ((config.agent as Record<string, unknown>)[agentName] as Record<string, unknown> | undefined) ?? {};
(config.agent as Record<string, unknown>)[agentName] = { ...current, [field]: value };
jsonModified = true;
continue;
}
if (hasMdField || creatingNewMd) {
if (mdData) {
mdData.frontmatter[field] = value;
mdModified = true;
}
continue;
}
// New field - add to appropriate location based on agent source
if ((mdExists || creatingNewMd) && mdData) {
mdData.frontmatter[field] = value;
mdModified = true;
} else {
if (!config.agent) config.agent = {};
const current = ((config.agent as Record<string, unknown>)[agentName] as Record<string, unknown> | undefined) ?? {};
(config.agent as Record<string, unknown>)[agentName] = { ...current, [field]: value };
jsonModified = true;
}
}
if (mdModified && mdData && targetPath) {
writeMdFile(targetPath, mdData.frontmatter, mdData.body);
}
if (jsonModified) {
writeConfig(config, jsonTarget.path || CONFIG_FILE);
}
if (mdModified || isBuiltinOverride) {
resetAgentLookupCache(globalAgentLookupCache);
}
};
export const deleteAgent = (agentName: string, workingDirectory?: string) => {
let deleted = false;
// Check project level first (takes precedence)
if (workingDirectory) {
const projectPath = getProjectAgentPath(workingDirectory, agentName);
if (fs.existsSync(projectPath)) {
fs.unlinkSync(projectPath);
deleted = true;
}
}
// Then check user level
const userPath = getUserAgentPath(agentName);
if (fs.existsSync(userPath)) {
fs.unlinkSync(userPath);
deleted = true;
}
// Also check json config (highest precedence entry only)
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'agent', agentName);
if (jsonSource.exists && jsonSource.config && jsonSource.path) {
const targetConfig = jsonSource.config as Record<string, unknown>;
const agentMap = (targetConfig.agent as Record<string, unknown> | undefined) ?? {};
delete agentMap[agentName];
targetConfig.agent = agentMap;
writeConfig(targetConfig, jsonSource.path);
deleted = true;
}
// If nothing was deleted (built-in agent), disable it in highest-precedence config
if (!deleted) {
const jsonTarget = getJsonWriteTarget(layers, workingDirectory ? AGENT_SCOPE.PROJECT : AGENT_SCOPE.USER);
const targetConfig = (jsonTarget.config || {}) as Record<string, unknown>;
const agentMap = (targetConfig.agent as Record<string, unknown> | undefined) ?? {};
agentMap[agentName] = { disable: true };
targetConfig.agent = agentMap;
writeConfig(targetConfig, jsonTarget.path || CONFIG_FILE);
}
resetAgentLookupCache(globalAgentLookupCache);
};
export const getCommandSources = (commandName: string, workingDirectory?: string): ConfigSources => {
// Check project level first (takes precedence)
const projectPath = workingDirectory ? getProjectCommandPath(workingDirectory, commandName) : null;
const projectExists = projectPath ? fs.existsSync(projectPath) : false;
// Then check user level
const userPath = getUserCommandPath(commandName);
const userExists = fs.existsSync(userPath);
// Determine which md file to use (project takes precedence)
const mdPath = projectExists ? projectPath : (userExists ? userPath : null);
const mdExists = !!mdPath;
const mdScope = projectExists ? COMMAND_SCOPE.PROJECT : (userExists ? COMMAND_SCOPE.USER : null);
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
const commandSection = jsonSource.section as Record<string, unknown> | undefined;
const jsonPath = jsonSource.path || layers.paths.customPath || layers.paths.projectPath || layers.paths.userPath;
const jsonScope = jsonSource.path === layers.paths.projectPath ? COMMAND_SCOPE.PROJECT : COMMAND_SCOPE.USER;
const sources: ConfigSources = {
md: { exists: mdExists, path: mdPath, scope: mdScope, fields: [] },
json: { exists: jsonSource.exists, path: jsonPath || CONFIG_FILE, scope: jsonSource.exists ? jsonScope : null, fields: [] },
projectMd: { exists: projectExists, path: projectPath },
userMd: { exists: userExists, path: userPath }
};
if (mdExists && mdPath) {
const { frontmatter, body } = parseMdFile(mdPath);
sources.md.fields = Object.keys(frontmatter);
if (body) sources.md.fields.push('template');
}
if (commandSection) {
sources.json.fields = Object.keys(commandSection);
}
return sources;
};
export const createCommand = (commandName: string, config: Record<string, unknown>, workingDirectory?: string, scope?: CommandScope) => {
ensureDirs();
// Check if command already exists at either level
const projectPath = workingDirectory ? getProjectCommandPath(workingDirectory, commandName) : null;
const userPath = getUserCommandPath(commandName);
if (projectPath && fs.existsSync(projectPath)) {
throw new Error(`Command ${commandName} already exists as project-level .md file`);
}
if (fs.existsSync(userPath)) {
throw new Error(`Command ${commandName} already exists as user-level .md file`);
}
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
if (jsonSource.exists) throw new Error(`Command ${commandName} already exists in opencode.json`);
// Determine target path based on requested scope
let targetPath: string;
if (scope === COMMAND_SCOPE.PROJECT && workingDirectory) {
ensureProjectCommandDir(workingDirectory);
targetPath = projectPath!;
} else {
targetPath = userPath;
}
// Extract scope from config - it's only used for path determination, not written to file
const { template, scope: _ignored, ...frontmatter } = config as Record<string, unknown> & { template?: unknown; scope?: unknown };
void _ignored; // Scope is only used for path determination
writeMdFile(targetPath, frontmatter, typeof template === 'string' ? template : '');
};
export const updateCommand = (commandName: string, updates: Record<string, unknown>, workingDirectory?: string) => {
ensureDirs();
// Determine correct path: project level takes precedence
const { path: mdPath } = getCommandWritePath(commandName, workingDirectory);
const mdExists = mdPath ? fs.existsSync(mdPath) : false;
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
const jsonSection = jsonSource.section as Record<string, unknown> | undefined;
const hasJsonFields = Boolean(jsonSource.exists && jsonSection && Object.keys(jsonSection).length > 0);
const jsonTarget = jsonSource.exists
? { config: jsonSource.config, path: jsonSource.path }
: getJsonWriteTarget(layers, workingDirectory ? COMMAND_SCOPE.PROJECT : COMMAND_SCOPE.USER);
const config = (jsonTarget.config || {}) as Record<string, unknown>;
// Only create a new md file for built-in overrides (no md + no json)
const isBuiltinOverride = !mdExists && !hasJsonFields;
let targetPath = mdPath;
if (!mdExists && isBuiltinOverride) {
// Built-in command override - create at user level
targetPath = getUserCommandPath(commandName);
}
const mdData = mdExists && mdPath ? parseMdFile(mdPath) : (isBuiltinOverride ? { frontmatter: {} as Record<string, unknown>, body: '' } : null);
let mdModified = false;
let jsonModified = false;
const creatingNewMd = isBuiltinOverride;
for (const [field, value] of Object.entries(updates || {})) {
if (field === 'template') {
const normalizedValue = typeof value === 'string' ? value : value == null ? '' : String(value);
if (mdExists || creatingNewMd) {
if (mdData) {
mdData.body = normalizedValue;
mdModified = true;
}
continue;
}
if (isPromptFileReference(jsonSection?.template)) {
const templateFilePath = resolvePromptFilePath(jsonSection.template);
if (!templateFilePath) throw new Error(`Invalid template file reference for command ${commandName}`);
writePromptFile(templateFilePath, normalizedValue);
continue;
}
// For JSON-only commands, store template inline in JSON
if (!config.command) config.command = {};
const current = ((config.command as Record<string, unknown>)[commandName] as Record<string, unknown> | undefined) ?? {};
(config.command as Record<string, unknown>)[commandName] = { ...current, template: normalizedValue };
jsonModified = true;
continue;
}
const hasMdField = Boolean(mdData?.frontmatter?.[field] !== undefined);
const hasJsonField = Boolean(jsonSection?.[field] !== undefined);
// JSON takes precedence over md, so update JSON first if field exists there
if (hasJsonField) {
if (!config.command) config.command = {};
const current = ((config.command as Record<string, unknown>)[commandName] as Record<string, unknown> | undefined) ?? {};
(config.command as Record<string, unknown>)[commandName] = { ...current, [field]: value };
jsonModified = true;
continue;
}
if (hasMdField || creatingNewMd) {
if (mdData) {
mdData.frontmatter[field] = value;
mdModified = true;
}
continue;
}
// New field - add to appropriate location based on command source
if ((mdExists || creatingNewMd) && mdData) {
mdData.frontmatter[field] = value;
mdModified = true;
} else {
if (!config.command) config.command = {};
const current = ((config.command as Record<string, unknown>)[commandName] as Record<string, unknown> | undefined) ?? {};
(config.command as Record<string, unknown>)[commandName] = { ...current, [field]: value };
jsonModified = true;
}
}
if (mdModified && mdData && targetPath) {
writeMdFile(targetPath, mdData.frontmatter, mdData.body);
}
if (jsonModified) {
writeConfig(config, jsonTarget.path || CONFIG_FILE);
}
};
export const getProviderSources = (providerId: string, workingDirectory?: string) => {
const layers = readConfigLayers(workingDirectory);
const customProviders = isPlainObject((layers.customConfig as Record<string, unknown>)?.provider)
? (layers.customConfig as Record<string, unknown>).provider as Record<string, unknown>
: {};
const customProvidersAlias = isPlainObject((layers.customConfig as Record<string, unknown>)?.providers)
? (layers.customConfig as Record<string, unknown>).providers as Record<string, unknown>
: {};
const projectProviders = isPlainObject((layers.projectConfig as Record<string, unknown>)?.provider)
? (layers.projectConfig as Record<string, unknown>).provider as Record<string, unknown>
: {};
const projectProvidersAlias = isPlainObject((layers.projectConfig as Record<string, unknown>)?.providers)
? (layers.projectConfig as Record<string, unknown>).providers as Record<string, unknown>
: {};
const userProviders = isPlainObject((layers.userConfig as Record<string, unknown>)?.provider)
? (layers.userConfig as Record<string, unknown>).provider as Record<string, unknown>
: {};
const userProvidersAlias = isPlainObject((layers.userConfig as Record<string, unknown>)?.providers)
? (layers.userConfig as Record<string, unknown>).providers as Record<string, unknown>
: {};
const customExists = Object.prototype.hasOwnProperty.call(customProviders, providerId)
|| Object.prototype.hasOwnProperty.call(customProvidersAlias, providerId);
const projectExists = Object.prototype.hasOwnProperty.call(projectProviders, providerId)
|| Object.prototype.hasOwnProperty.call(projectProvidersAlias, providerId);
const userExists = Object.prototype.hasOwnProperty.call(userProviders, providerId)
|| Object.prototype.hasOwnProperty.call(userProvidersAlias, providerId);
return {
auth: { exists: false },
user: { exists: userExists, path: layers.paths.userPath },
project: { exists: projectExists, path: layers.paths.projectPath ?? null },
custom: { exists: customExists, path: layers.paths.customPath },
};
};
export const removeProviderConfig = (providerId: string, workingDirectory?: string, scope: 'user' | 'project' | 'custom' = 'user') => {
if (!providerId) throw new Error('Provider ID is required');
const layers = readConfigLayers(workingDirectory);
let targetPath: string | null | undefined = layers.paths.userPath;
if (scope === 'project') {
if (!workingDirectory) {
throw new Error('Working directory is required for project scope');
}
targetPath = layers.paths.projectPath ?? targetPath;
}
if (scope === 'custom') {
if (!layers.paths.customPath) {
return false;
}
targetPath = layers.paths.customPath;
}
const targetConfig = getConfigForPath(layers, targetPath);
const providerConfig = isPlainObject((targetConfig as Record<string, unknown>).provider)
? (targetConfig as Record<string, unknown>).provider as Record<string, unknown>
: {};
const providersConfig = isPlainObject((targetConfig as Record<string, unknown>).providers)
? (targetConfig as Record<string, unknown>).providers as Record<string, unknown>
: {};
const removedProvider = Object.prototype.hasOwnProperty.call(providerConfig, providerId);
const removedProviders = Object.prototype.hasOwnProperty.call(providersConfig, providerId);
if (!removedProvider && !removedProviders) {
return false;
}
if (removedProvider) {
delete providerConfig[providerId];
if (Object.keys(providerConfig).length === 0) {
delete (targetConfig as Record<string, unknown>).provider;
} else {
(targetConfig as Record<string, unknown>).provider = providerConfig;
}
}
if (removedProviders) {
delete providersConfig[providerId];
if (Object.keys(providersConfig).length === 0) {
delete (targetConfig as Record<string, unknown>).providers;
} else {
(targetConfig as Record<string, unknown>).providers = providersConfig;
}
}
writeConfig(targetConfig as Record<string, unknown>, targetPath || CONFIG_FILE);
return true;
};
export const deleteCommand = (commandName: string, workingDirectory?: string) => {
let deleted = false;
// Check project level first (takes precedence)
if (workingDirectory) {
const projectPath = getProjectCommandPath(workingDirectory, commandName);
if (fs.existsSync(projectPath)) {
fs.unlinkSync(projectPath);
deleted = true;
}
}
// Then check user level
const userPath = getUserCommandPath(commandName);
if (fs.existsSync(userPath)) {
fs.unlinkSync(userPath);
deleted = true;
}
// Also check json config (highest precedence entry only)
const layers = readConfigLayers(workingDirectory);
const jsonSource = getJsonEntrySource(layers, 'command', commandName);
if (jsonSource.exists && jsonSource.config && jsonSource.path) {
const targetConfig = jsonSource.config as Record<string, unknown>;
const commandMap = (targetConfig.command as Record<string, unknown> | undefined) ?? {};
delete commandMap[commandName];
targetConfig.command = commandMap;
writeConfig(targetConfig, jsonSource.path);
deleted = true;
}
if (!deleted) {
throw new Error(`Command "${commandName}" not found`);
}
};
export const listSnippets = (workingDirectory?: string): Snippet[] => {
return listUniqueSnippets(loadSnippetRegistry(workingDirectory));
};
export const getSnippet = (name: string, workingDirectory?: string): Snippet | null => {
return findSnippetByName(name, workingDirectory);
};
export const createSnippet = (
name: string,
config: Record<string, unknown>,
workingDirectory?: string,
scope: SnippetScope = 'global',
): Snippet | null => {
assertValidSnippetName(name);
const dir = getWritableSnippetDir(scope, workingDirectory);
const filePath = path.join(dir, `${name}${SNIPPET_EXTENSION}`);
if (fs.existsSync(filePath)) throw new Error(`Snippet "${name}" already exists`);
writeSnippetFile(filePath, config || {});
return getSnippet(name, workingDirectory);
};
export const updateSnippet = (name: string, updates: Record<string, unknown>, workingDirectory?: string): Snippet | null => {
const existing = findSnippetByName(name, workingDirectory);
if (!existing) throw new Error(`Snippet "${name}" not found`);
writeSnippetFile(existing.filePath, { ...existing, ...(updates || {}) });
return getSnippet(name, workingDirectory);
};
export const deleteSnippet = (name: string, workingDirectory?: string): void => {
const existing = findSnippetByName(name, workingDirectory);
if (!existing) throw new Error(`Snippet "${name}" not found`);
fs.unlinkSync(existing.filePath);
};
export const expandSnippets = (text: string, workingDirectory?: string): string => {
const registry = loadSnippetRegistry(workingDirectory);
const collector = { prepend: [] as string[], append: [] as string[] };
const expanded = expandSnippetText(text || '', registry, new Map(), collector).trim();
return [...collector.prepend, expanded, ...collector.append].filter(Boolean).join('\n\n');
};
// ============== SKILL SCOPE HELPERS ==============
const SKILL_DIR = path.join(OPENCODE_CONFIG_DIR, 'skills');
export const SKILL_SCOPE = {
USER: 'user',
PROJECT: 'project'
} as const;
export type SkillScope = typeof SKILL_SCOPE[keyof typeof SKILL_SCOPE];
export type SkillSource = 'opencode' | 'claude' | 'agents';
export type SupportingFile = {
name: string;
path: string;
fullPath: string;
};
export type SkillConfigSources = {
md: {
exists: boolean;
path: string | null;
dir: string | null;
fields: string[];
scope?: SkillScope | null;
source?: SkillSource | null;
supportingFiles: SupportingFile[];
name?: string;
description?: string;
instructions?: string;
};
projectMd?: { exists: boolean; path: string | null };
claudeMd?: { exists: boolean; path: string | null };
userMd?: { exists: boolean; path: string | null };
};
export type DiscoveredSkill = {
name: string;
path: string;
scope: SkillScope;
source: SkillSource;
description?: string;
content?: string;
};
export const BUILT_IN_SKILL_LOCATION = '<built-in>';
export const mergeDiscoveredSkills = (
primarySkills: DiscoveredSkill[] = [],
fallbackSkills: DiscoveredSkill[] = []
): DiscoveredSkill[] => {
const merged: DiscoveredSkill[] = [];
const seenNames = new Set<string>();
const appendSkill = (skill: DiscoveredSkill | null | undefined) => {
if (!skill) {
return;
}
const name = typeof skill?.name === 'string' ? skill.name.trim() : '';
if (!name || seenNames.has(name)) {
return;
}
seenNames.add(name);
merged.push(skill);
};
for (const skill of primarySkills || []) appendSkill(skill);
for (const skill of fallbackSkills || []) appendSkill(skill);
return merged;
};
const addSkillFromMdFile = (
skillsMap: Map<string, DiscoveredSkill>,
skillMdPath: string,
scope: SkillScope,
source: SkillSource
) => {
try {
const parsed = parseMdFile(skillMdPath);
const name = typeof parsed.frontmatter?.name === 'string'
? parsed.frontmatter.name.trim()
: '';
const description = typeof parsed.frontmatter?.description === 'string'
? parsed.frontmatter.description
: '';
if (!name) {
return;
}
skillsMap.set(name, {
name,
path: skillMdPath,
scope,
source,
description,
});
} catch {
// Ignore invalid SKILL.md entries.
}
};
const ensureSkillDirs = () => {
if (!fs.existsSync(SKILL_DIR)) {
fs.mkdirSync(SKILL_DIR, { recursive: true });
}
};
const getUserSkillDir = (skillName: string): string => {
const pluralPath = path.join(SKILL_DIR, skillName);
const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'skill', skillName);
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
return pluralPath;
};
const getUserSkillPath = (skillName: string): string => {
const pluralPath = path.join(SKILL_DIR, skillName, 'SKILL.md');
const legacyPath = path.join(OPENCODE_CONFIG_DIR, 'skill', skillName, 'SKILL.md');
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
return pluralPath;
};
const getProjectSkillDir = (workingDirectory: string, skillName: string): string => {
const pluralPath = path.join(workingDirectory, '.opencode', 'skills', skillName);
const legacyPath = path.join(workingDirectory, '.opencode', 'skill', skillName);
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
return pluralPath;
};
const getProjectSkillPath = (workingDirectory: string, skillName: string): string => {
const pluralPath = path.join(workingDirectory, '.opencode', 'skills', skillName, 'SKILL.md');
const legacyPath = path.join(workingDirectory, '.opencode', 'skill', skillName, 'SKILL.md');
if (fs.existsSync(legacyPath) && !fs.existsSync(pluralPath)) return legacyPath;
return pluralPath;
};
const getClaudeSkillDir = (workingDirectory: string, skillName: string): string => {
return path.join(workingDirectory, '.claude', 'skills', skillName);
};
const getClaudeSkillPath = (workingDirectory: string, skillName: string): string => {
return path.join(getClaudeSkillDir(workingDirectory, skillName), 'SKILL.md');
};
const getUserAgentsSkillDir = (skillName: string): string => {
return path.join(os.homedir(), '.agents', 'skills', skillName);
};
const getProjectAgentsSkillDir = (workingDirectory: string, skillName: string): string => {
return path.join(workingDirectory, '.agents', 'skills', skillName);
};
export const getSkillScope = (skillName: string, workingDirectory?: string): {
scope: SkillScope | null;
path: string | null;
source: SkillSource | null;
} => {
const discovered = discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
if (discovered?.path) {
return { scope: discovered.scope, path: discovered.path, source: discovered.source };
}
if (workingDirectory) {
// Check .opencode/skill first
const projectPath = getProjectSkillPath(workingDirectory, skillName);
if (fs.existsSync(projectPath)) {
return { scope: SKILL_SCOPE.PROJECT, path: projectPath, source: 'opencode' };
}
// Check .claude/skills (claude-compat)
const claudePath = getClaudeSkillPath(workingDirectory, skillName);
if (fs.existsSync(claudePath)) {
return { scope: SKILL_SCOPE.PROJECT, path: claudePath, source: 'claude' };
}
}
const userPath = getUserSkillPath(skillName);
if (fs.existsSync(userPath)) {
return { scope: SKILL_SCOPE.USER, path: userPath, source: 'opencode' };
}
return { scope: null, path: null, source: null };
};
const listSupportingFiles = (skillDir: string): SupportingFile[] => {
if (!fs.existsSync(skillDir)) return [];
const files: SupportingFile[] = [];
const walkDir = (dir: string, relativePath: string = '') => {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
const relPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
if (entry.isDirectory()) {
walkDir(fullPath, relPath);
} else if (entry.name !== 'SKILL.md') {
files.push({
name: entry.name,
path: relPath,
fullPath
});
}
}
};
walkDir(skillDir);
return files;
};
export const discoverSkills = (workingDirectory?: string): DiscoveredSkill[] => {
const skills = new Map<string, DiscoveredSkill>();
// 1) External global (.claude, .agents)
for (const externalRootName of ['.claude', '.agents']) {
const source: SkillSource = externalRootName === '.agents' ? 'agents' : 'claude';
const homeRoot = path.join(os.homedir(), externalRootName, 'skills');
for (const skillMdPath of walkSkillMdFiles(homeRoot)) {
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.USER, source);
}
}
// 2) External project ancestors (.claude, .agents)
if (workingDirectory) {
const worktreeRoot = findWorktreeRoot(workingDirectory) || path.resolve(workingDirectory);
const ancestors = getAncestors(workingDirectory, worktreeRoot);
for (const ancestor of ancestors) {
for (const externalRootName of ['.claude', '.agents']) {
const source: SkillSource = externalRootName === '.agents' ? 'agents' : 'claude';
const externalSkillsRoot = path.join(ancestor, externalRootName, 'skills');
for (const skillMdPath of walkSkillMdFiles(externalSkillsRoot)) {
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.PROJECT, source);
}
}
}
}
// 3) Config directories: {skill,skills}/**/SKILL.md
const configDirectories = resolveSkillSearchDirectories(workingDirectory);
const homeOpencodeDir = path.resolve(path.join(os.homedir(), '.opencode'));
const customConfigDir = process.env.OPENCODE_CONFIG_DIR
? path.resolve(process.env.OPENCODE_CONFIG_DIR)
: null;
for (const dir of configDirectories) {
for (const subDir of ['skill', 'skills']) {
const root = path.join(dir, subDir);
for (const skillMdPath of walkSkillMdFiles(root)) {
const isUserConfigDir = dir === OPENCODE_CONFIG_DIR
|| dir === homeOpencodeDir
|| (customConfigDir && dir === customConfigDir);
const scope = isUserConfigDir ? SKILL_SCOPE.USER : SKILL_SCOPE.PROJECT;
addSkillFromMdFile(skills, skillMdPath, scope, 'opencode');
}
}
}
// 4) Additional config.skills.paths
let configuredPaths: unknown[] = [];
try {
const config = readConfig(workingDirectory);
const skillsConfig = isPlainObject(config.skills) ? config.skills : null;
configuredPaths = Array.isArray(skillsConfig?.paths) ? skillsConfig.paths : [];
} catch {
configuredPaths = [];
}
for (const skillPath of configuredPaths) {
if (typeof skillPath !== 'string' || !skillPath.trim()) continue;
const expanded = skillPath.startsWith('~/')
? path.join(os.homedir(), skillPath.slice(2))
: skillPath;
const resolved = path.isAbsolute(expanded)
? path.resolve(expanded)
: path.resolve(workingDirectory || process.cwd(), expanded);
for (const skillMdPath of walkSkillMdFiles(resolved)) {
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.PROJECT, 'opencode');
}
}
// 5) Cached skills from config.skills.urls pulls (best-effort, no network)
const cacheCandidates: string[] = [];
if (process.env.XDG_CACHE_HOME) {
cacheCandidates.push(path.join(process.env.XDG_CACHE_HOME, 'opencode', 'skills'));
}
cacheCandidates.push(path.join(os.homedir(), '.cache', 'opencode', 'skills'));
cacheCandidates.push(path.join(os.homedir(), 'Library', 'Caches', 'opencode', 'skills'));
for (const cacheRoot of cacheCandidates) {
if (!fs.existsSync(cacheRoot)) continue;
const entries = fs.readdirSync(cacheRoot, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillRoot = path.join(cacheRoot, entry.name);
for (const skillMdPath of walkSkillMdFiles(skillRoot)) {
addSkillFromMdFile(skills, skillMdPath, SKILL_SCOPE.USER, 'opencode');
}
}
}
return Array.from(skills.values());
};
export const getSkillSources = (
skillName: string,
workingDirectory?: string,
discoveredSkill?: DiscoveredSkill | null
): SkillConfigSources => {
ensureSkillDirs();
const isReadableFile = (filePath: string | null): boolean => {
if (!filePath) return false;
try {
return fs.statSync(filePath).isFile();
} catch {
return false;
}
};
// Check all possible locations
const projectPath = workingDirectory ? getProjectSkillPath(workingDirectory, skillName) : null;
const projectExists = projectPath ? fs.existsSync(projectPath) : false;
const projectDir = projectExists && workingDirectory ? getProjectSkillDir(workingDirectory, skillName) : null;
const claudePath = workingDirectory ? getClaudeSkillPath(workingDirectory, skillName) : null;
const claudeExists = claudePath ? fs.existsSync(claudePath) : false;
const claudeDir = claudeExists && workingDirectory ? getClaudeSkillDir(workingDirectory, skillName) : null;
const userPath = getUserSkillPath(skillName);
const userExists = fs.existsSync(userPath);
const userDir = userExists ? getUserSkillDir(skillName) : null;
const matchedDiscovered = discoveredSkill?.name === skillName
? discoveredSkill
: discoverSkills(workingDirectory).find((skill) => skill.name === skillName);
const discoveredPath = typeof matchedDiscovered?.path === 'string' ? matchedDiscovered.path : null;
const isBuiltInDiscovered = discoveredPath === BUILT_IN_SKILL_LOCATION;
// Determine which md file to use (priority: project > claude > user)
let mdPath: string | null = null;
let mdScope: SkillScope | null = null;
let mdSource: SkillSource | null = null;
let mdDir: string | null = null;
if (isBuiltInDiscovered) {
mdScope = matchedDiscovered?.scope || SKILL_SCOPE.USER;
mdSource = matchedDiscovered?.source || 'opencode';
} else if (discoveredPath && isReadableFile(discoveredPath)) {
mdPath = discoveredPath;
mdScope = matchedDiscovered?.scope || null;
mdSource = matchedDiscovered?.source || null;
mdDir = path.dirname(discoveredPath);
} else if (projectExists) {
mdPath = projectPath;
mdScope = SKILL_SCOPE.PROJECT;
mdSource = 'opencode';
mdDir = projectDir;
} else if (claudeExists) {
mdPath = claudePath;
mdScope = SKILL_SCOPE.PROJECT;
mdSource = 'claude';
mdDir = claudeDir;
} else if (userExists) {
mdPath = userPath;
mdScope = SKILL_SCOPE.USER;
mdSource = 'opencode';
mdDir = userDir;
}
const mdExists = isBuiltInDiscovered || !!mdPath;
let mdFields: string[] = isBuiltInDiscovered ? ['description', 'instructions'] : [];
let supportingFiles: SupportingFile[] = [];
let mdDescription = typeof matchedDiscovered?.description === 'string' ? matchedDiscovered.description : '';
let mdInstructions = isBuiltInDiscovered && typeof matchedDiscovered?.content === 'string' ? matchedDiscovered.content : '';
if (mdExists && mdPath) {
const { frontmatter, body } = parseMdFile(mdPath);
mdFields = Object.keys(frontmatter);
mdDescription = typeof frontmatter.description === 'string' ? frontmatter.description : '';
if (body) mdFields.push('instructions');
mdInstructions = body || '';
if (mdDir) {
supportingFiles = listSupportingFiles(mdDir);
}
}
return {
md: {
exists: mdExists,
path: mdPath,
dir: mdDir,
fields: mdFields,
scope: mdScope,
source: mdSource,
supportingFiles,
name: matchedDiscovered?.name || skillName,
description: mdDescription,
instructions: mdInstructions,
},
projectMd: { exists: projectExists, path: projectPath },
claudeMd: { exists: claudeExists, path: claudePath },
userMd: { exists: userExists, path: userPath }
};
};
export const readSkillSupportingFile = (skillDir: string, relativePath: string): string | null => {
const fullPath = path.join(skillDir, relativePath);
if (!fs.existsSync(fullPath)) return null;
return fs.readFileSync(fullPath, 'utf8');
};
export const writeSkillSupportingFile = (skillDir: string, relativePath: string, content: string): void => {
const fullPath = path.join(skillDir, relativePath);
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
fs.writeFileSync(fullPath, content, 'utf8');
};
export const deleteSkillSupportingFile = (skillDir: string, relativePath: string): void => {
const fullPath = path.join(skillDir, relativePath);
if (fs.existsSync(fullPath)) {
fs.unlinkSync(fullPath);
// Clean up empty parent directories
let parentDir = path.dirname(fullPath);
while (parentDir !== skillDir) {
try {
const entries = fs.readdirSync(parentDir);
if (entries.length === 0) {
fs.rmdirSync(parentDir);
parentDir = path.dirname(parentDir);
} else {
break;
}
} catch {
break;
}
}
}
};
const validateSkillName = (skillName: string): void => {
if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/.test(skillName) || skillName.length > 64) {
throw new Error(`Invalid skill name "${skillName}". Must be 1-64 lowercase alphanumeric characters with hyphens, cannot start or end with hyphen.`);
}
};
export const createSkill = (skillName: string, config: Record<string, unknown>, workingDirectory?: string, scope?: SkillScope): void => {
ensureSkillDirs();
validateSkillName(skillName);
// Check if skill already exists
const existing = getSkillScope(skillName, workingDirectory);
if (existing.path) {
throw new Error(`Skill ${skillName} already exists at ${existing.path}`);
}
// Determine target directory
let targetDir: string;
const requestedScope = scope === SKILL_SCOPE.PROJECT ? SKILL_SCOPE.PROJECT : SKILL_SCOPE.USER;
const requestedSource: SkillSource = config.source === 'agents' ? 'agents' : 'opencode';
if (requestedScope === SKILL_SCOPE.PROJECT && workingDirectory) {
targetDir = requestedSource === 'agents'
? getProjectAgentsSkillDir(workingDirectory, skillName)
: getProjectSkillDir(workingDirectory, skillName);
} else {
targetDir = requestedSource === 'agents'
? getUserAgentsSkillDir(skillName)
: getUserSkillDir(skillName);
}
fs.mkdirSync(targetDir, { recursive: true });
const targetPath = path.join(targetDir, 'SKILL.md');
// Extract fields
const { instructions, scope: _ignored, source: _sourceIgnored, supportingFiles: supportingFilesData, ...frontmatter } = config as Record<string, unknown> & {
instructions?: unknown;
scope?: unknown;
source?: unknown;
supportingFiles?: Array<{ path: string; content: string }>;
};
void _ignored;
void _sourceIgnored;
// Ensure required fields
if (!frontmatter.name) {
frontmatter.name = skillName;
}
if (!frontmatter.description) {
throw new Error('Skill description is required');
}
writeMdFile(targetPath, frontmatter, typeof instructions === 'string' ? instructions : '');
// Write supporting files if provided
if (supportingFilesData && Array.isArray(supportingFilesData)) {
for (const file of supportingFilesData) {
if (file.path && file.content !== undefined) {
writeSkillSupportingFile(targetDir, file.path, file.content);
}
}
}
};
export const updateSkill = (skillName: string, updates: Record<string, unknown>, workingDirectory?: string): void => {
const existing = getSkillScope(skillName, workingDirectory);
if (!existing.path) {
throw new Error(`Skill "${skillName}" not found`);
}
const mdPath = existing.path;
const mdDir = path.dirname(mdPath);
const mdData = parseMdFile(mdPath);
let mdModified = false;
for (const [field, value] of Object.entries(updates || {})) {
if (field === 'scope') continue;
if (field === 'instructions') {
const normalizedValue = typeof value === 'string' ? value : value == null ? '' : String(value);
mdData.body = normalizedValue;
mdModified = true;
continue;
}
if (field === 'supportingFiles' && Array.isArray(value)) {
for (const file of value as Array<{ delete?: boolean; path?: string; content?: string }>) {
if (file.delete && file.path) {
deleteSkillSupportingFile(mdDir, file.path);
} else if (file.path && file.content !== undefined) {
writeSkillSupportingFile(mdDir, file.path, file.content);
}
}
continue;
}
mdData.frontmatter[field] = value;
mdModified = true;
}
if (mdModified) {
writeMdFile(mdPath, mdData.frontmatter, mdData.body);
}
};
export const deleteSkill = (skillName: string, workingDirectory?: string): void => {
let deleted = false;
// Check and delete from all locations
if (workingDirectory) {
// Project level .opencode/skill/
const projectDir = getProjectSkillDir(workingDirectory, skillName);
if (fs.existsSync(projectDir)) {
fs.rmSync(projectDir, { recursive: true, force: true });
deleted = true;
}
// Claude-compat .claude/skills/
const claudeDir = getClaudeSkillDir(workingDirectory, skillName);
if (fs.existsSync(claudeDir)) {
fs.rmSync(claudeDir, { recursive: true, force: true });
deleted = true;
}
const projectAgentsDir = getProjectAgentsSkillDir(workingDirectory, skillName);
if (fs.existsSync(projectAgentsDir)) {
fs.rmSync(projectAgentsDir, { recursive: true, force: true });
deleted = true;
}
}
// User level
const userDir = getUserSkillDir(skillName);
if (fs.existsSync(userDir)) {
fs.rmSync(userDir, { recursive: true, force: true });
deleted = true;
}
const userAgentsDir = getUserAgentsSkillDir(skillName);
if (fs.existsSync(userAgentsDir)) {
fs.rmSync(userAgentsDir, { recursive: true, force: true });
deleted = true;
}
if (!deleted) {
throw new Error(`Skill "${skillName}" not found`);
}
};