diff --git a/biome.json b/biome.json index 2ce5d062a..aa8b9d63c 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.0/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.2/schema.json", "linter": { "enabled": true, "rules": { diff --git a/bun.lock b/bun.lock index a0878f059..9b96c9ed4 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@sinclair/typebox": "0.34.48", }, "devDependencies": { - "@biomejs/biome": "^2.4.0", + "@biomejs/biome": "2.4.2", "@bufbuild/protoc-gen-es": "^2.11.0", "@types/bun": "^1.3.9", "@typescript/native-preview": "^7.0.0-dev.20260215.1", diff --git a/package.json b/package.json index 58cd1e7b3..453401c34 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "generate-models": "bun --cwd=packages/ai scripts/generate-models.ts" }, "devDependencies": { - "@biomejs/biome": "^2.4.0", + "@biomejs/biome": "^2.4.2", "@bufbuild/protoc-gen-es": "^2.11.0", "@types/bun": "^1.3.9", "@typescript/native-preview": "^7.0.0-dev.20260215.1", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index dcc63b821..be4598351 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,17 @@ # Changelog ## [Unreleased] + +### Added + +- Added OpenCode discovery provider to load configuration from ~/.config/opencode/ and .opencode/ directories +- Added support for loading MCP servers from opencode.json mcp key +- Added support for loading skills from ~/.config/opencode/skills/ and .opencode/skills/ +- Added support for loading slash commands from ~/.config/opencode/commands/ and .opencode/commands/ +- Added support for loading extension modules (plugins) from ~/.config/opencode/plugins/ and .opencode/plugins/ +- Added support for loading context files (AGENTS.md) from ~/.config/opencode/ +- Added support for loading settings from opencode.json configuration files + ### Changed - Improved path display in status line to strip both `/work/` and `~/Projects/` prefixes when abbreviating paths diff --git a/packages/coding-agent/src/discovery/helpers.ts b/packages/coding-agent/src/discovery/helpers.ts index 43cf2070a..799cffde6 100644 --- a/packages/coding-agent/src/discovery/helpers.ts +++ b/packages/coding-agent/src/discovery/helpers.ts @@ -60,6 +60,11 @@ export const SOURCE_PATHS = { userAgent: ".gemini", projectDir: ".gemini", }, + opencode: { + userBase: ".config/opencode", + userAgent: ".config/opencode", + projectDir: ".opencode", + }, cursor: { userBase: ".cursor", userAgent: ".cursor", diff --git a/packages/coding-agent/src/discovery/index.ts b/packages/coding-agent/src/discovery/index.ts index 993e9087a..76351abcd 100644 --- a/packages/coding-agent/src/discovery/index.ts +++ b/packages/coding-agent/src/discovery/index.ts @@ -29,6 +29,7 @@ import "./agents"; import "./codex"; import "./cursor"; import "./gemini"; +import "./opencode"; import "./github"; import "./mcp-json"; import "./ssh"; diff --git a/packages/coding-agent/src/discovery/opencode.ts b/packages/coding-agent/src/discovery/opencode.ts new file mode 100644 index 000000000..d9db6aafa --- /dev/null +++ b/packages/coding-agent/src/discovery/opencode.ts @@ -0,0 +1,394 @@ +/** + * OpenCode Discovery Provider + * + * Loads configuration from OpenCode's config directories: + * - User: ~/.config/opencode/ + * - Project: .opencode/ (cwd) and opencode.json (project root) + * + * Capabilities: + * - context-files: AGENTS.md (user-level only at ~/.config/opencode/AGENTS.md) + * - mcps: From opencode.json "mcp" key + * - settings: From opencode.json + * - 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 { logger } from "@oh-my-pi/pi-utils"; +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 { parseFrontmatter } from "../utils/frontmatter"; +import { + createSourceMeta, + discoverExtensionModulePaths, + expandEnvVarsDeep, + getExtensionNameFromPath, + getProjectPath, + getUserPath, + loadFilesFromDir, + loadSkillsFromDir, + parseJSON, +} from "./helpers"; + +const PROVIDER_ID = "opencode"; +const DISPLAY_NAME = "OpenCode"; +const PRIORITY = 55; + +// ============================================================================= +// JSON Config Loading +// ============================================================================= + +async function loadJsonConfig(configPath: string): Promise | null> { + const content = await readFile(configPath); + if (!content) return null; + + const parsed = parseJSON>(content); + if (!parsed) { + logger.warn("Failed to parse OpenCode JSON config", { path: configPath }); + return null; + } + return parsed; +} + +// ============================================================================= +// 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 → mcp) +// ============================================================================= + +/** OpenCode MCP server config (from opencode.json "mcp" key) */ +interface OpenCodeMCPConfig { + type?: "local" | "remote"; + command?: string; + args?: string[]; + env?: Record; + url?: string; + headers?: Record; + enabled?: boolean; + timeout?: number; +} + +async function loadMCPServers(ctx: LoadContext): Promise> { + const items: MCPServer[] = []; + const warnings: string[] = []; + + // User-level: ~/.config/opencode/opencode.json + const userConfigPath = getUserPath(ctx, "opencode", "opencode.json"); + if (userConfigPath) { + const config = await loadJsonConfig(userConfigPath); + if (config) { + const result = extractMCPServers(config, userConfigPath, "user"); + items.push(...result.items); + if (result.warnings) warnings.push(...result.warnings); + } + } + + // Project-level: opencode.json in project root + const projectConfigPath = path.join(ctx.cwd, "opencode.json"); + const projectConfig = await loadJsonConfig(projectConfigPath); + if (projectConfig) { + const result = extractMCPServers(projectConfig, projectConfigPath, "project"); + items.push(...result.items); + if (result.warnings) warnings.push(...result.warnings); + } + + return { items, warnings }; +} + +function extractMCPServers( + config: Record, + configPath: string, + level: "user" | "project", +): LoadResult { + const items: MCPServer[] = []; + const warnings: string[] = []; + + if (!config.mcp || typeof config.mcp !== "object") { + return { items, warnings }; + } + + const servers = expandEnvVarsDeep(config.mcp as Record); + + for (const [name, raw] of Object.entries(servers)) { + if (!raw || typeof raw !== "object") { + warnings.push(`Invalid MCP config for "${name}" in ${configPath}`); + continue; + } + + const serverConfig = raw as OpenCodeMCPConfig; + + // 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"; + } + + items.push({ + name, + command: serverConfig.command, + args: Array.isArray(serverConfig.args) ? (serverConfig.args as string[]) : undefined, + env: serverConfig.env && typeof serverConfig.env === "object" ? serverConfig.env : undefined, + 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, configPath, level), + }); + } + + return { items, warnings }; +} + +// ============================================================================= +// 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( + loadSkillsFromDir(ctx, { + dir: userSkillsDir, + providerId: PROVIDER_ID, + level: "user", + }), + ); + } + + if (projectSkillsDir) { + promises.push( + loadSkillsFromDir(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: ExtensionModule[] = [ + ...userPaths.map(extPath => ({ + name: getExtensionNameFromPath(extPath), + path: extPath, + level: "user" as const, + _source: createSourceMeta(PROVIDER_ID, extPath, "user"), + })), + ...projectPaths.map(extPath => ({ + name: getExtensionNameFromPath(extPath), + path: extPath, + level: "project" as const, + _source: createSourceMeta(PROVIDER_ID, extPath, "project"), + })), + ]; + + return { items, warnings: [] }; +} + +// ============================================================================= +// Slash Commands (commands/) +// ============================================================================= + +async function loadSlashCommands(ctx: LoadContext): Promise> { + const userCommandsDir = getUserPath(ctx, "opencode", "commands"); + const projectCommandsDir = getProjectPath(ctx, "opencode", "commands"); + + 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) +// ============================================================================= + +async function loadSettings(ctx: LoadContext): Promise> { + const items: Settings[] = []; + const warnings: string[] = []; + + // User-level: ~/.config/opencode/opencode.json + const userConfigPath = getUserPath(ctx, "opencode", "opencode.json"); + if (userConfigPath) { + const content = await readFile(userConfigPath); + if (content) { + const parsed = parseJSON>(content); + if (parsed) { + items.push({ + path: userConfigPath, + data: parsed, + level: "user", + _source: createSourceMeta(PROVIDER_ID, userConfigPath, "user"), + }); + } else { + warnings.push(`Invalid JSON in ${userConfigPath}`); + } + } + } + + // Project-level: opencode.json in project root + const projectConfigPath = path.join(ctx.cwd, "opencode.json"); + const content = await readFile(projectConfigPath); + if (content) { + const parsed = parseJSON>(content); + if (parsed) { + items.push({ + path: projectConfigPath, + data: parsed, + level: "project", + _source: createSourceMeta(PROVIDER_ID, projectConfigPath, "project"), + }); + } else { + warnings.push(`Invalid JSON in ${projectConfigPath}`); + } + } + + 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.json mcp key", + 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.json", + priority: PRIORITY, + load: loadSettings, +});