Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 2 additions & 61 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type { Plugin } from "@opencode-ai/plugin";
import type { AgentConfig, McpLocalConfig } from "@opencode-ai/sdk";

import { agents, PRIMARY_AGENT_NAME } from "@/agents";
import { loadMicodeConfig, loadModelContextLimits, type MicodeFeatures, mergeAgentConfigs } from "@/config-loader";
import { loadMicodeConfig, loadModelContextLimits, mergeAgentConfigs } from "@/config-loader";
import {
createArtifactAutoIndexHook,
createAutoCompactHook,
Expand All @@ -20,6 +19,7 @@ import {
getFileOps,
warnUnknownAgents,
} from "@/hooks";
import { mergeMcpServers, mergePluginAgents } from "@/plugin-config";
import {
artifact_search,
ast_grep_replace,
Expand Down Expand Up @@ -54,49 +54,6 @@ function detectThinkKeyword(text: string): boolean {
return THINK_KEYWORDS.some((pattern) => pattern.test(text));
}

/**
* MCP servers the plugin offers. Every one is opt-out: context7 through
* features.context7, the research servers through their API keys. Callers
* spread the result *under* the host config so a user's own entry always wins.
*/
export function buildMcpServers(features?: MicodeFeatures): Record<string, McpLocalConfig> {
const servers: Record<string, McpLocalConfig> = {};

if (features?.context7 !== false) {
servers.context7 = {
type: "local",
command: ["npx", "-y", "@upstash/context7-mcp@latest"],
};
}

if (process.env.PERPLEXITY_API_KEY) {
servers.perplexity = {
type: "local",
command: ["npx", "-y", "@anthropic/mcp-perplexity"],
};
}

if (process.env.FIRECRAWL_API_KEY) {
servers.firecrawl = {
type: "local",
command: ["npx", "-y", "firecrawl-mcp"],
};
}

return servers;
}

/**
* Layer the plugin's MCP servers beneath whatever the host already declares,
* so a user entry of the same name replaces the bundled one outright.
*/
export function mergeMcpServers<T>(
hostServers: Record<string, T> | undefined,
features?: MicodeFeatures,
): Record<string, T | McpLocalConfig> {
return { ...buildMcpServers(features), ...hostServers };
}

const PLUGIN_COMMANDS = {
init: {
description: "Initialize project with ARCHITECTURE.md and CODE_STYLE.md",
Expand Down Expand Up @@ -127,22 +84,6 @@ function extractTextFromParts(parts: Array<{ type: string; text?: string }>): st
.join("");
}

export function mergePluginAgentConfig(existingAgent: AgentConfig | undefined, pluginAgent: AgentConfig): AgentConfig {
return {
...existingAgent,
...pluginAgent,
};
}

export function mergePluginAgents(
existingAgents: Record<string, AgentConfig | undefined> | undefined,
pluginAgents: Record<string, AgentConfig>,
): Record<string, AgentConfig> {
return Object.fromEntries(
Object.entries(pluginAgents).map(([name, agent]) => [name, mergePluginAgentConfig(existingAgents?.[name], agent)]),
);
}

// eslint-disable-next-line max-lines-per-function
const OpenCodeConfigPlugin: Plugin = async (ctx) => {
// Validate external tool dependencies at startup
Expand Down
69 changes: 69 additions & 0 deletions src/plugin-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// src/plugin-config.ts
// Config shaping the plugin applies to the host's opencode config.
//
// These live outside the plugin entry point on purpose: opencode calls every
// exported function in a plugin module as a plugin factory and installs each
// return value as hooks, so the entry point must export the plugin alone.

import type { AgentConfig, McpLocalConfig } from "@opencode-ai/sdk";

import type { MicodeFeatures } from "@/config-loader";

/**
* MCP servers the plugin offers. Every one is opt-out: context7 through
* features.context7, the research servers through their API keys. Callers
* spread the result *under* the host config so a user's own entry always wins.
*/
export function buildMcpServers(features?: MicodeFeatures): Record<string, McpLocalConfig> {
const servers: Record<string, McpLocalConfig> = {};

if (features?.context7 !== false) {
servers.context7 = {
type: "local",
command: ["npx", "-y", "@upstash/context7-mcp@latest"],
};
}

if (process.env.PERPLEXITY_API_KEY) {
servers.perplexity = {
type: "local",
command: ["npx", "-y", "@anthropic/mcp-perplexity"],
};
}

if (process.env.FIRECRAWL_API_KEY) {
servers.firecrawl = {
type: "local",
command: ["npx", "-y", "firecrawl-mcp"],
};
}

return servers;
}

/**
* Layer the plugin's MCP servers beneath whatever the host already declares,
* so a user entry of the same name replaces the bundled one outright.
*/
export function mergeMcpServers<T>(
hostServers: Record<string, T> | undefined,
features?: MicodeFeatures,
): Record<string, T | McpLocalConfig> {
return { ...buildMcpServers(features), ...hostServers };
}

export function mergePluginAgentConfig(existingAgent: AgentConfig | undefined, pluginAgent: AgentConfig): AgentConfig {
return {
...existingAgent,
...pluginAgent,
};
}

export function mergePluginAgents(
existingAgents: Record<string, AgentConfig | undefined> | undefined,
pluginAgents: Record<string, AgentConfig>,
): Record<string, AgentConfig> {
return Object.fromEntries(
Object.entries(pluginAgents).map(([name, agent]) => [name, mergePluginAgentConfig(existingAgents?.[name], agent)]),
);
}
19 changes: 18 additions & 1 deletion tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { describe, expect, it } from "bun:test";
import { readFile } from "node:fs/promises";

import { mergePluginAgents } from "../src/index";
import { mergePluginAgents } from "../src/plugin-config";

describe("mergePluginAgents", () => {
it("preserves user iteration limits from existing OpenCode agent config", () => {
Expand Down Expand Up @@ -81,3 +81,20 @@ describe("index.ts commands", () => {
expect(mindmodelMatch?.[1]).toBe("mm-orchestrator");
});
});

// opencode calls every exported function in a plugin module as a plugin factory
// and treats each return value as a hooks object. A stray helper export is
// therefore never harmless: it either throws and kills registration, or returns
// something nonsensical that opencode installs as hooks. Which one happens to
// win depends on alphabetical sort order of the module namespace, so the only
// safe surface is the plugin itself.
describe("plugin module surface", () => {
it("exports the plugin factory and nothing else callable", async () => {
const surface: Record<string, unknown> = await import("../src/index");
const callable = Object.entries(surface)
.filter(([, value]) => typeof value === "function")
.map(([name]) => name);

expect(callable).toEqual(["OpenCodeConfigPlugin"]);
});
});
2 changes: 1 addition & 1 deletion tests/mcp-servers.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// tests/mcp-servers.test.ts
import { afterEach, beforeEach, describe, expect, it } from "bun:test";

import { buildMcpServers, mergeMcpServers } from "../src/index";
import { buildMcpServers, mergeMcpServers } from "../src/plugin-config";

const RESEARCH_KEYS = ["PERPLEXITY_API_KEY", "FIRECRAWL_API_KEY"] as const;

Expand Down