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'; type PluginParsedKind = 'npm' | 'path'; export type PluginEntry = { id: string; spec: string; options?: Record; 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; userAgentLookupByName: Map; userAgentIndexReady: boolean; userAgentIndexBuiltAt: number; }; const AGENT_LOOKUP_CACHE_TTL_MS = 1000; const createAgentLookupCache = (): AgentLookupCache => ({ userAgentIndexByName: new Map(), userAgentLookupByName: new Map(), 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; }; 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; }; 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[] => { 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, 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 => { const registry = new Map(); 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): Snippet[] => { const seen = new Set(); 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): void => { const aliases = Array.isArray(config.aliases) ? config.aliases.map((alias) => String(alias).trim()).filter(Boolean) : []; const frontmatter: Record = {}; 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(/[\s\S]*?(?:<\/inject>|$)/gi, '').trim(); return { inline, prepend: blocks.prepend, append: blocks.append }; }; const expandSnippetText = ( text: string, registry: Map, expansionCounts: Map, 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 => { 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; }; const isPlainObject = (value: unknown): value is Record => Boolean(value) && typeof value === 'object' && !Array.isArray(value); const mergeConfigs = (base: Record, override: Record): Record => { const result: Record = { ...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 => 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, 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, 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 } => { 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] => { 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 }> => { 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): unknown[] => Array.isArray(config.plugin) ? config.plugin : []; const writePluginArray = (config: Record, plugin: unknown[]): Record => { 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; 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(); const npmInfoInFlight = new Map>(); const NPM_CACHE_TTL_MS = 3_600_000; const lookupNpmPackage = async (name: string): Promise => { 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 => { 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(); const malformedSpecs = new Set(); 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(); 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 McpConfigEntry = { name: string; scope?: AgentScope | null; type: 'local' | 'remote'; command?: string[]; url?: string; environment?: Record; headers?: Record; enabled: boolean; }; const resolveMcpScopeFromPath = (layers: ReturnType, 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): Omit => { const entry: Omit = { 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 = {}; 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 = {}; 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) || {}; 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), scope: resolveMcpScopeFromPath(layers, source.path), }; }); }; export const getMcpConfig = (name: string, workingDirectory?: string): McpConfigEntry | null => { const layers = readConfigLayers(workingDirectory); const merged = (layers.mergedConfig as Record) || {}; 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), scope: resolveMcpScopeFromPath(layers, source.path), }; }; export const createMcpConfig = ( name: string, mcpConfig: Record, 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 = {}; 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; } 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, 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; 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), ...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; 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, sectionKey: 'agent' | 'command' | 'mcp', entryName: string ) => { const { userConfig, projectConfig, customConfig, paths } = layers; const customSection = (customConfig as Record)?.[sectionKey] as Record | undefined; if (customSection?.[entryName] !== undefined) { return { section: customSection[entryName], config: customConfig, path: paths.customPath, exists: true }; } const projectSection = (projectConfig as Record)?.[sectionKey] as Record | undefined; if (projectSection?.[entryName] !== undefined) { return { section: projectSection[entryName], config: projectConfig, path: paths.projectPath, exists: true }; } const userSection = (userConfig as Record)?.[sectionKey] as Record | 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, 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)?.agent as Record | undefined)?.[agentName] as Record | 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)?.agent as Record | undefined)?.[agentName] as Record | 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)?.agent as Record | undefined)?.[agentName] as Record | 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, 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; existingPermission = (((config.agent as Record | undefined)?.[agentName] as Record | 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> = {}; for (const [permKey, permValue] of Object.entries(existingPermission as Record)) { if (permKey === '*' || typeof permValue !== 'object' || permValue === null || Array.isArray(permValue)) continue; const nonWildcards: Record = {}; for (const [pattern, action] of Object.entries(permValue as Record)) { 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 = { ...(newPermission as Record) }; 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) }; } else { const existingValue = (existingPermission as Record)[permKey]; if (typeof existingValue === 'object' && existingValue !== null && !Array.isArray(existingValue)) { const wildcard = (existingValue as Record)['*']; merged[permKey] = wildcard ? { '*': wildcard, ...patterns } : patterns; } } } return merged; }; const parseMdFile = (filePath: string): { frontmatter: Record; 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 = {}; try { frontmatter = (yaml.parse(match[1]) || {}) as Record; } 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, 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 | 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, 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, ...rawFrontmatter } = config as Record & { prompt?: unknown; scope?: unknown }; void _ignored; // Scope is only used for path determination const frontmatter = Object.fromEntries( Object.entries(rawFrontmatter).filter(([, value]) => value !== null && value !== undefined) ); writeMdFile(targetPath, frontmatter, typeof prompt === 'string' ? prompt : ''); resetAgentLookupCache(globalAgentLookupCache); }; const deleteAgentJsonField = (config: Record, agentName: string, field: string): boolean => { const agentMap = config.agent as Record | undefined; const current = agentMap?.[agentName] as Record | undefined; if (!agentMap || !current || !(field in current)) return false; delete current[field]; if (Object.keys(current).length === 0) { delete agentMap[agentName]; } if (Object.keys(agentMap).length === 0) { delete config.agent; } return true; }; export const updateAgent = (agentName: string, updates: Record, 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 | 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; // 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, 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 || {})) { // Skip undefined values — they would overwrite existing frontmatter fields with nothing if (value === undefined) continue; 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; const current = agentMap[agentName] as Record | 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)[agentName] as Record | undefined) ?? {}; (config.agent as Record)[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)[agentName] as Record | undefined) ?? {}; (config.agent as Record)[agentName] = { ...current, permission: newPermission }; jsonModified = true; } else { const existingConfig = readConfigFile(permissionSource.path) as Record; const agentMap = (existingConfig.agent as Record | undefined) ?? {}; const current = (agentMap[agentName] as Record | 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)[agentName] as Record | undefined) ?? {}; (config.agent as Record)[agentName] = { ...current, permission: newPermission }; jsonModified = true; } else { const writeTarget = getJsonWriteTarget(layers, AGENT_SCOPE.USER); const targetConfig = (writeTarget.config || {}) as Record; const agentMap = (targetConfig.agent as Record | undefined) ?? {}; const current = (agentMap[agentName] as Record | 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); if (value === null) { if (hasMdField && mdData) { delete mdData.frontmatter[field]; mdModified = true; } if (hasJsonField && deleteAgentJsonField(config, agentName, field)) { jsonModified = true; } continue; } // 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)[agentName] as Record | undefined) ?? {}; (config.agent as Record)[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)[agentName] as Record | undefined) ?? {}; (config.agent as Record)[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); } }; const deleteJsonAgentEntry = (config: Record, agentName: string): boolean => { const agentMap = config.agent as Record | undefined; if (!agentMap?.[agentName]) return false; delete agentMap[agentName]; if (Object.keys(agentMap).length > 0) { config.agent = agentMap; } else { delete config.agent; } return true; }; export const deleteAgent = (agentName: string, workingDirectory?: string, scope?: AgentScope) => { const requestedScope = scope === AGENT_SCOPE.PROJECT || scope === AGENT_SCOPE.USER ? scope : null; // Check project level first (takes precedence) if ((!requestedScope || requestedScope === AGENT_SCOPE.PROJECT) && workingDirectory) { const projectPath = getProjectAgentPath(workingDirectory, agentName); if (fs.existsSync(projectPath)) { fs.unlinkSync(projectPath); resetAgentLookupCache(globalAgentLookupCache); return; } } // Then check user level if (!requestedScope || requestedScope === AGENT_SCOPE.USER) { const userPath = getUserAgentPath(agentName); if (fs.existsSync(userPath)) { fs.unlinkSync(userPath); resetAgentLookupCache(globalAgentLookupCache); return; } } const layers = readConfigLayers(workingDirectory); if (requestedScope === AGENT_SCOPE.PROJECT) { if (layers.paths.projectPath && deleteJsonAgentEntry(layers.projectConfig, agentName)) { writeConfig(layers.projectConfig, layers.paths.projectPath); resetAgentLookupCache(globalAgentLookupCache); return; } throw new Error(`Project agent ${agentName} not found`); } if (requestedScope === AGENT_SCOPE.USER) { const userJsonPath = layers.paths.customPath || layers.paths.userPath; const userJsonConfig = layers.paths.customPath ? layers.customConfig : layers.userConfig; if (userJsonPath && deleteJsonAgentEntry(userJsonConfig, agentName)) { writeConfig(userJsonConfig, userJsonPath); resetAgentLookupCache(globalAgentLookupCache); return; } throw new Error(`User agent ${agentName} not found`); } // Also check json config (highest precedence entry only) const jsonSource = getJsonEntrySource(layers, 'agent', agentName); if (jsonSource.exists && jsonSource.config && jsonSource.path && deleteJsonAgentEntry(jsonSource.config, agentName)) { writeConfig(jsonSource.config, jsonSource.path); resetAgentLookupCache(globalAgentLookupCache); return; } throw new Error(`Agent ${agentName} is built-in or not deletable`); }; 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 | 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, 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 & { 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, 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 | 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; // 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, 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)[commandName] as Record | undefined) ?? {}; (config.command as Record)[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)[commandName] as Record | undefined) ?? {}; (config.command as Record)[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)[commandName] as Record | undefined) ?? {}; (config.command as Record)[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)?.provider) ? (layers.customConfig as Record).provider as Record : {}; const customProvidersAlias = isPlainObject((layers.customConfig as Record)?.providers) ? (layers.customConfig as Record).providers as Record : {}; const projectProviders = isPlainObject((layers.projectConfig as Record)?.provider) ? (layers.projectConfig as Record).provider as Record : {}; const projectProvidersAlias = isPlainObject((layers.projectConfig as Record)?.providers) ? (layers.projectConfig as Record).providers as Record : {}; const userProviders = isPlainObject((layers.userConfig as Record)?.provider) ? (layers.userConfig as Record).provider as Record : {}; const userProvidersAlias = isPlainObject((layers.userConfig as Record)?.providers) ? (layers.userConfig as Record).providers as Record : {}; 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).provider) ? (targetConfig as Record).provider as Record : {}; const providersConfig = isPlainObject((targetConfig as Record).providers) ? (targetConfig as Record).providers as Record : {}; 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).provider; } else { (targetConfig as Record).provider = providerConfig; } } if (removedProviders) { delete providersConfig[providerId]; if (Object.keys(providersConfig).length === 0) { delete (targetConfig as Record).providers; } else { (targetConfig as Record).providers = providersConfig; } } writeConfig(targetConfig as Record, 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; const commandMap = (targetConfig.command as Record | 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, 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, 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'; 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 = ''; export const mergeDiscoveredSkills = ( primarySkills: DiscoveredSkill[] = [], fallbackSkills: DiscoveredSkill[] = [] ): DiscoveredSkill[] => { const merged: DiscoveredSkill[] = []; const seenNames = new Set(); 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, 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); }; 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(); // 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, 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 & { 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, 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`); } };