/** * OpenCode Discovery Provider * * Loads configuration from OpenCode's config directories: * - User: ~/.config/opencode/ * - Project: .opencode/ (cwd) and opencode.json/opencode.jsonc (project root) * * Capabilities: * - context-files: AGENTS.md (user-level only at ~/.config/opencode/AGENTS.md) * - mcps: From opencode.json and opencode.jsonc "mcp" keys * - settings: From opencode.json and opencode.jsonc * - skills: From skills/ subdirectories * - slash-commands: From commands/ subdirectories * - extension-modules: From plugins/ subdirectories * * Priority: 55 (tool-specific provider) */ import * as path from "node:path"; import { isRecord, logger, parseFrontmatter } from "@oh-my-pi/pi-utils"; import { JSONC } from "bun"; import { registerProvider } from "../capability"; import { type ContextFile, contextFileCapability } from "../capability/context-file"; import { type ExtensionModule, extensionModuleCapability } from "../capability/extension-module"; import { readFile } from "../capability/fs"; import { type MCPServer, mcpCapability } from "../capability/mcp"; import { type Settings, settingsCapability } from "../capability/settings"; import { type Skill, skillCapability } from "../capability/skill"; import { type SlashCommand, slashCommandCapability } from "../capability/slash-command"; import type { LoadContext, LoadResult, SourceMeta } from "../capability/types"; import { settings } from "../config/settings"; import { buildExtensionModuleItems, createSourceMeta, discoverExtensionModulePaths, expandEnvVarsDeep, getProjectPath, getUserPath, loadFilesFromDir, scanSkillsFromDir, } from "./helpers"; const PROVIDER_ID = "opencode"; const DISPLAY_NAME = "OpenCode"; const PRIORITY = 55; const CONFIG_FILENAMES = ["opencode.json", "opencode.jsonc"] as const; interface OpenCodeConfigSource { path: string; level: "user" | "project"; } // ============================================================================= // JSON Config Loading // ============================================================================= async function loadJsonConfig( configPath: string, onInvalid: (configPath: string) => void, ): Promise | null> { const content = await readFile(configPath); if (!content) return null; let parsed: unknown; try { parsed = JSONC.parse(content); } catch { onInvalid(configPath); return null; } if (!isRecord(parsed)) { onInvalid(configPath); return null; } return parsed; } /** * OpenCode config sources in ascending effective precedence (lowest first): * user `opencode.json` → user `opencode.jsonc` → project-root * `opencode.json` → project-root `opencode.jsonc` → project `.opencode/opencode.json` * → project `.opencode/opencode.jsonc`. This matches how OpenCode merges configs: * project overrides user, `.opencode` overrides project-root config, and within * a directory `opencode.jsonc` overrides `opencode.json`. * * Both consumers apply this order low-to-high: settings deep-merge in item * order (last wins) and `loadMCPServers` deep-merges each server across layers * (later overrides earlier), so higher-precedence sources win in both. */ function getConfigSources(ctx: LoadContext): OpenCodeConfigSource[] { const sources: OpenCodeConfigSource[] = []; for (const filename of CONFIG_FILENAMES) { const configPath = getUserPath(ctx, "opencode", filename); if (configPath) sources.push({ path: configPath, level: "user" }); } for (const filename of CONFIG_FILENAMES) { sources.push({ path: path.join(ctx.cwd, filename), level: "project" }); } for (const filename of CONFIG_FILENAMES) { const configPath = getProjectPath(ctx, "opencode", filename); if (configPath) sources.push({ path: configPath, level: "project" }); } return sources; } // ============================================================================= // Context Files (AGENTS.md) // ============================================================================= async function loadContextFiles(ctx: LoadContext): Promise> { const items: ContextFile[] = []; const warnings: string[] = []; // User-level only: ~/.config/opencode/AGENTS.md const userAgentsMd = getUserPath(ctx, "opencode", "AGENTS.md"); if (userAgentsMd) { const content = await readFile(userAgentsMd); if (content) { items.push({ path: userAgentsMd, content, level: "user", _source: createSourceMeta(PROVIDER_ID, userAgentsMd, "user"), }); } } return { items, warnings }; } // ============================================================================= // MCP Servers (opencode.json/opencode.jsonc → mcp) // ============================================================================= /** OpenCode MCP server config (from the "mcp" key) */ interface OpenCodeMCPConfig { type?: "local" | "remote"; command?: string | string[]; args?: string[]; env?: Record; environment?: Record; url?: string; headers?: Record; enabled?: boolean; timeout?: number; } function stringArray(value: unknown): string[] | undefined { if (!Array.isArray(value)) return undefined; for (const item of value) { if (typeof item !== "string") return undefined; } return value; } function stringRecord(value: unknown): Record | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; const record: Record = {}; for (const [key, item] of Object.entries(value)) { if (typeof item !== "string") return undefined; record[key] = item; } return record; } function normalizeCommand( commandValue: string | string[] | undefined, argsValue: unknown, ): { command: string | undefined; args: string[] | undefined } { const configuredArgs = stringArray(argsValue); if (Array.isArray(commandValue)) { const [command, ...commandArgs] = commandValue; const args = configuredArgs ? [...commandArgs, ...configuredArgs] : commandArgs; return { command: typeof command === "string" ? command : undefined, args: args.length > 0 ? args : undefined, }; } return { command: typeof commandValue === "string" ? commandValue : undefined, args: configuredArgs && configuredArgs.length > 0 ? configuredArgs : undefined, }; } async function loadMCPServers(ctx: LoadContext): Promise> { const warnings: string[] = []; // Deep-merge each server across config layers in ascending precedence, the // way OpenCode itself merges configs, so a partial higher-precedence override // (e.g. project opencode.jsonc setting only mcp..timeout) inherits the // command/url from lower-precedence layers instead of shadowing the complete // definition and being rejected by mcpCapability.validate. const mergedByName = new Map>(); const sourceByName = new Map(); for (const source of getConfigSources(ctx)) { const config = await loadJsonConfig(source.path, configPath => { logger.warn("Failed to parse OpenCode config", { path: configPath }); }); if (!config || !isRecord(config.mcp)) continue; for (const name in config.mcp) { const raw = config.mcp[name]; if (!isRecord(raw)) { warnings.push(`Invalid MCP config for "${name}" in ${source.path}`); continue; } const previous = mergedByName.get(name); mergedByName.set(name, previous ? mergeConfigRecords(previous, raw) : raw); sourceByName.set(name, source); } } const items: MCPServer[] = []; for (const [name, config] of mergedByName) { const serverConfig = expandEnvVarsDeep(config) as OpenCodeMCPConfig; const source = sourceByName.get(name)!; items.push(buildMCPServer(name, serverConfig, source)); } return { items, warnings }; } /** Deep-merge two OpenCode config records; `override` wins, nested records recurse. */ function mergeConfigRecords(base: Record, override: Record): Record { const result: Record = { ...base }; for (const key in override) { const value = override[key]; const existing = result[key]; result[key] = isRecord(existing) && isRecord(value) ? mergeConfigRecords(existing, value) : value; } return result; } /** Translate one merged OpenCode MCP entry into the canonical MCPServer shape. */ function buildMCPServer(name: string, serverConfig: OpenCodeMCPConfig, source: OpenCodeConfigSource): MCPServer { // Determine transport from OpenCode's "type" field let transport: "stdio" | "sse" | "http" | undefined; if (serverConfig.type === "local") { transport = "stdio"; } else if (serverConfig.type === "remote") { transport = "http"; } else if (serverConfig.url) { transport = "http"; } else if (serverConfig.command) { transport = "stdio"; } const command = normalizeCommand(serverConfig.command, serverConfig.args); const env = stringRecord(serverConfig.environment) ?? stringRecord(serverConfig.env); return { name, command: command.command, args: command.args, env, url: typeof serverConfig.url === "string" ? serverConfig.url : undefined, headers: serverConfig.headers && typeof serverConfig.headers === "object" ? serverConfig.headers : undefined, enabled: serverConfig.enabled, timeout: typeof serverConfig.timeout === "number" ? serverConfig.timeout : undefined, transport, _source: createSourceMeta(PROVIDER_ID, source.path, source.level), }; } // ============================================================================= // Skills (skills/) // ============================================================================= async function loadSkills(ctx: LoadContext): Promise> { const userSkillsDir = getUserPath(ctx, "opencode", "skills"); const projectSkillsDir = getProjectPath(ctx, "opencode", "skills"); const promises: Promise>[] = []; if (userSkillsDir) { promises.push( scanSkillsFromDir(ctx, { dir: userSkillsDir, providerId: PROVIDER_ID, level: "user", }), ); } if (projectSkillsDir) { promises.push( scanSkillsFromDir(ctx, { dir: projectSkillsDir, providerId: PROVIDER_ID, level: "project", }), ); } const results = await Promise.all(promises); const items = results.flatMap(r => r.items); const warnings = results.flatMap(r => r.warnings || []); return { items, warnings }; } // ============================================================================= // Extension Modules (plugins/) // ============================================================================= async function loadExtensionModules(ctx: LoadContext): Promise> { const userPluginsDir = getUserPath(ctx, "opencode", "plugins"); const projectPluginsDir = getProjectPath(ctx, "opencode", "plugins"); const [userPaths, projectPaths] = await Promise.all([ userPluginsDir ? discoverExtensionModulePaths(ctx, userPluginsDir) : Promise.resolve([]), projectPluginsDir ? discoverExtensionModulePaths(ctx, projectPluginsDir) : Promise.resolve([]), ]); const items = buildExtensionModuleItems(PROVIDER_ID, userPaths, projectPaths); return { items, warnings: [] }; } // ============================================================================= // Slash Commands (commands/) // ============================================================================= /** * Read the OpenCode command-loading toggles from settings. * Falls back to true (current behavior) when settings are not initialized, * e.g. inside discovery unit tests that run without Settings.init(). */ function readOpencodeCommandToggles(): { enableUser: boolean; enableProject: boolean } { try { return { enableUser: settings.get("commands.enableOpencodeUser") ?? true, enableProject: settings.get("commands.enableOpencodeProject") ?? true, }; } catch { return { enableUser: true, enableProject: true }; } } async function loadSlashCommands(ctx: LoadContext): Promise> { const { enableUser, enableProject } = readOpencodeCommandToggles(); const userCommandsDir = enableUser ? getUserPath(ctx, "opencode", "commands") : null; const projectCommandsDir = enableProject ? getProjectPath(ctx, "opencode", "commands") : null; const transformCommand = (level: "user" | "project") => (name: string, content: string, filePath: string, source: SourceMeta) => { const { frontmatter, body } = parseFrontmatter(content, { source: filePath }); const commandName = frontmatter.name || name.replace(/\.md$/, ""); return { name: String(commandName), path: filePath, content: body, level, _source: source, }; }; const promises: Promise>[] = []; if (userCommandsDir) { promises.push( loadFilesFromDir(ctx, userCommandsDir, PROVIDER_ID, "user", { extensions: ["md"], transform: transformCommand("user"), }), ); } if (projectCommandsDir) { promises.push( loadFilesFromDir(ctx, projectCommandsDir, PROVIDER_ID, "project", { extensions: ["md"], transform: transformCommand("project"), }), ); } const results = await Promise.all(promises); const items = results.flatMap(r => r.items); const warnings = results.flatMap(r => r.warnings || []); return { items, warnings }; } // ============================================================================= // Settings (opencode.json/opencode.jsonc) // ============================================================================= async function loadSettings(ctx: LoadContext): Promise> { const items: Settings[] = []; const warnings: string[] = []; for (const source of getConfigSources(ctx)) { const parsed = await loadJsonConfig(source.path, configPath => { warnings.push(`Invalid JSON in ${configPath}`); }); if (!parsed) continue; items.push({ path: source.path, data: parsed, level: source.level, _source: createSourceMeta(PROVIDER_ID, source.path, source.level), }); } return { items, warnings }; } // ============================================================================= // Provider Registration // ============================================================================= registerProvider(contextFileCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, description: "Load AGENTS.md from ~/.config/opencode/", priority: PRIORITY, load: loadContextFiles, }); registerProvider(mcpCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, description: "Load MCP servers from OpenCode config files", priority: PRIORITY, load: loadMCPServers, }); registerProvider(skillCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, description: "Load skills from ~/.config/opencode/skills/ and .opencode/skills/", priority: PRIORITY, load: loadSkills, }); registerProvider(extensionModuleCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, description: "Load extension modules from ~/.config/opencode/plugins/ and .opencode/plugins/", priority: PRIORITY, load: loadExtensionModules, }); registerProvider(slashCommandCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, description: "Load slash commands from ~/.config/opencode/commands/ and .opencode/commands/", priority: PRIORITY, load: loadSlashCommands, }); registerProvider(settingsCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, description: "Load settings from OpenCode config files", priority: PRIORITY, load: loadSettings, });