diff --git a/packages/coding-agent/src/discovery/opencode.ts b/packages/coding-agent/src/discovery/opencode.ts index cfacb23c6..2a422d4e9 100644 --- a/packages/coding-agent/src/discovery/opencode.ts +++ b/packages/coding-agent/src/discovery/opencode.ts @@ -77,9 +77,11 @@ async function loadJsonConfig( /** * OpenCode config sources in ascending effective precedence (lowest first): - * user `opencode.json` → user `opencode.jsonc` → project `opencode.json` → - * project `opencode.jsonc`. This matches how OpenCode merges configs — project - * overrides user, and within a scope `opencode.jsonc` overrides `opencode.json`. + * 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 @@ -94,6 +96,10 @@ function getConfigSources(ctx: LoadContext): OpenCodeConfigSource[] { 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; } @@ -419,7 +425,7 @@ registerProvider(contextFileCapability.id, { registerProvider(mcpCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, - description: "Load MCP servers from opencode.json mcp key", + description: "Load MCP servers from OpenCode config files", priority: PRIORITY, load: loadMCPServers, }); @@ -451,7 +457,7 @@ registerProvider(slashCommandCapability.id, { registerProvider(settingsCapability.id, { id: PROVIDER_ID, displayName: DISPLAY_NAME, - description: "Load settings from opencode.json", + description: "Load settings from OpenCode config files", priority: PRIORITY, load: loadSettings, }); diff --git a/packages/coding-agent/test/discovery/opencode.test.ts b/packages/coding-agent/test/discovery/opencode.test.ts index fde8fc237..386357af3 100644 --- a/packages/coding-agent/test/discovery/opencode.test.ts +++ b/packages/coding-agent/test/discovery/opencode.test.ts @@ -88,6 +88,42 @@ describe("OpenCode MCP discovery", () => { ); }); + test("loads project .opencode config after project-root config", async () => { + const projectDir = path.join(tempDir, "project"); + const projectConfigDir = path.join(projectDir, ".opencode"); + await fs.mkdir(projectConfigDir, { recursive: true }); + + await fs.writeFile( + path.join(projectDir, "opencode.json"), + JSON.stringify({ + model: "root-model", + mcp: { shared: { type: "local", command: ["root-server"] } }, + }), + ); + await fs.writeFile( + path.join(projectConfigDir, "opencode.jsonc"), + `{ + // Project .opencode config has higher precedence. + "model": "dotdir-model", + "mcp": { "shared": { "command": ["dotdir-server"] } } + }`, + ); + + const [servers, discoveredSettings] = await Promise.all([ + loadOpenCodeMcpConfig(projectDir), + loadOpenCodeSettings(projectDir), + ]); + + expect(servers.filter(server => server.name === "shared")).toEqual([ + expect.objectContaining({ command: "dotdir-server", transport: "stdio" }), + ]); + expect(discoveredSettings.at(-1)).toMatchObject({ + path: path.join(projectConfigDir, "opencode.jsonc"), + level: "project", + data: expect.objectContaining({ model: "dotdir-model" }), + }); + }); + test("resolves same-named MCP servers by OpenCode precedence", async () => { const projectDir = path.join(tempDir, "project"); const userConfigDir = path.join(tempDir, ".config", "opencode");