From d47824d0de7cff394d808646f52422c40ea6b1ba Mon Sep 17 00:00:00 2001 From: bhaktatejas922 Date: Fri, 1 May 2026 13:48:40 -0700 Subject: [PATCH] Add plugin config API key support --- CHANGELOG.md | 4 + README.md | 39 ++++++++-- index.test.ts | 72 +++++++++++++++++- index.ts | 142 +++++++++++++++++++++--------------- instructions/morph-tools.md | 2 +- 5 files changed, 189 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4704a1d..133373d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Plugin config API key** — `apiKey` can now be set in the OpenCode plugin config entry, with `MORPH_API_KEY` preserved as the fallback. + ## [2.0.3] - 2026-03-16 ### Fixed diff --git a/README.md b/README.md index 4032cdb..5bf111a 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,9 @@ On production repos and SWE-Bench Pro, enabling WarpGrep and compaction improves ### 1. Get a Morph API key -Sign up at [morphllm.com/dashboard](https://morphllm.com/dashboard/api-keys) and export it: +Sign up at [morphllm.com/dashboard](https://morphllm.com/dashboard/api-keys). + +For terminal usage, export it: ```bash export MORPH_API_KEY="sk-..." @@ -48,6 +50,30 @@ Edit `~/.config/opencode/opencode.json`: } ``` +For OpenCode desktop or other environments where shell environment variables +are inconvenient, configure the API key as a plugin option in your global +OpenCode config: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "plugin": [ + [ + "@morphllm/opencode-morph-plugin", + { + "apiKey": "{file:~/.secrets/morph-api-key}" + } + ] + ], + "instructions": [ + "node_modules/@morphllm/opencode-morph-plugin/instructions/morph-tools.md" + ] +} +``` + +You can also set `"apiKey": "sk-..."` directly, but prefer global config or +`{file:...}` for secrets. Do not commit API keys in project config. + ### 4. Start OpenCode ```bash @@ -131,11 +157,14 @@ Search public GitHub repositories without cloning. Pass an `owner/repo` or GitHu ## Configuration -All configuration is via environment variables. +Credentials can be configured with the plugin `apiKey` option or the +`MORPH_API_KEY` environment variable. The plugin option is checked first; if it +is missing or blank, `MORPH_API_KEY` is used. -| Variable | Default | Description | -|----------|---------|-------------| -| `MORPH_API_KEY` | *required* | Your Morph API key | +| Setting | Default | Description | +|---------|---------|-------------| +| Plugin option `"apiKey"` | falls back to `MORPH_API_KEY` | Your Morph API key in the OpenCode `plugin` config entry | +| `MORPH_API_KEY` | *required unless `apiKey` is set* | Your Morph API key | | `MORPH_COMPACT_TOKEN_LIMIT` | auto (70% of model window) | Fixed token threshold for compaction | | `MORPH_COMPACT_CONTEXT_THRESHOLD` | `0.7` | Fraction of model context window to trigger compaction (used when `TOKEN_LIMIT` is not set) | | `MORPH_COMPACT_PRESERVE_RECENT` | `1` | Number of recent messages to keep uncompacted | diff --git a/index.test.ts b/index.test.ts index c37a166..a349e60 100644 --- a/index.test.ts +++ b/index.test.ts @@ -29,7 +29,10 @@ function normalizeCodeEditInput(codeEdit: string): string { async function importPluginWithEnv( env: Record, ): Promise<{ - default: (input: any) => Promise>; + default: ( + input: any, + options?: Record, + ) => Promise>; }> { const previous = new Map(); @@ -50,11 +53,17 @@ async function importPluginWithEnv( } } -function makePluginInput(directory: string, worktree = directory) { +function makePluginInput( + directory: string, + worktree = directory, + logs: any[] = [], +) { return { client: { app: { - log: async () => {}, + log: async (entry: any) => { + logs.push(entry.body); + }, }, }, project: {}, @@ -113,6 +122,7 @@ describe("packaged tool-selection instructions", () => { expect(content).toContain("warpgrep_codebase_search"); expect(content).toContain("warpgrep_github_search"); expect(content).toContain("MORPH_API_KEY"); + expect(content).toContain('"apiKey"'); expect(content).toContain("MORPH_COMPACT_TOKEN_LIMIT"); expect(content).toContain("opencode.json"); }); @@ -933,7 +943,7 @@ describe("plugin runtime hooks", () => { expect(output.description).toContain("Runtime notes:"); expect(output.description).toContain( - "Currently unavailable until MORPH_API_KEY is configured.", + "Currently unavailable until a Morph API key is configured.", ); expect(output.description).toContain( "Blocked in readonly agents: plan, explore.", @@ -963,6 +973,60 @@ describe("plugin runtime hooks", () => { ); expect(combined).toContain("Use write for brand new files."); }); + + test("apiKey plugin option enables morph tools without MORPH_API_KEY", async () => { + const { default: MorphPlugin } = await importPluginWithEnv({ + MORPH_API_KEY: undefined, + }); + const logs: any[] = []; + + const hooks = await MorphPlugin( + makePluginInput("/tmp/morph-plugin", "/tmp/morph-plugin", logs), + { apiKey: "\n morph-config-test-key \n" }, + ); + const output = { + description: "Base description", + parameters: {}, + }; + + await hooks["tool.definition"]?.({ toolID: "morph_edit" }, output); + + expect(output.description).toContain("Runtime notes:"); + expect(output.description).not.toContain("Currently unavailable"); + + const system = { system: [] as string[] }; + await hooks["experimental.chat.system.transform"]?.( + { + sessionID: "session-test", + model: {}, + }, + system, + ); + + expect(system.system.join("\n")).toContain("Morph plugin routing hints:"); + expect(JSON.stringify(logs)).not.toContain("morph-config-test-key"); + }); + + test("blank apiKey plugin option falls back to MORPH_API_KEY", async () => { + const { default: MorphPlugin } = await importPluginWithEnv({ + MORPH_API_KEY: "morph-env-test-key", + }); + + const hooks = await MorphPlugin(makePluginInput("/tmp/morph-plugin"), { + apiKey: " ", + }); + const output = { system: [] as string[] }; + + await hooks["experimental.chat.system.transform"]?.( + { + sessionID: "session-test", + model: {}, + }, + output, + ); + + expect(output.system.join("\n")).toContain("Morph plugin routing hints:"); + }); }); describe("ToolContext path resolution", () => { diff --git a/index.ts b/index.ts index 0190aa7..87b5ff8 100644 --- a/index.ts +++ b/index.ts @@ -13,8 +13,8 @@ import type { WarpGrepResult, CompactResult } from "@morphllm/morphsdk"; import type { Part, TextPart, ToolPart, Message } from "@opencode-ai/sdk"; import { isAbsolute, resolve as resolvePath } from "node:path"; -// Config from environment — only MORPH_API_KEY is required -const MORPH_API_KEY = process.env.MORPH_API_KEY; +// API key can be configured via plugin options or MORPH_API_KEY. +const MORPH_ENV_API_KEY = normalizeApiKey(process.env.MORPH_API_KEY); const MORPH_API_URL = "https://api.morphllm.com"; const MORPH_TIMEOUT = 30000; const MORPH_WARP_GREP_TIMEOUT = 60000; @@ -74,39 +74,6 @@ const PLUGIN_VERSION = "2.0.0"; const EXISTING_CODE_MARKER = "// ... existing code ..."; const MORPH_ROUTING_HINT_HEADER = "Morph plugin routing hints:"; -/** - * Shared MorphClient — FastApply uses morph.fastApply.applyEdit() - * with MORPH_API_URL passed as per-call override. - */ -const morph = MORPH_API_KEY - ? new MorphClient({ - apiKey: MORPH_API_KEY, - timeout: MORPH_TIMEOUT, - }) - : null; - -/** - * Separate WarpGrep client with its own timeout (typically longer than fast apply). - */ -const warpGrep = MORPH_API_KEY - ? new WarpGrepClient({ - morphApiKey: MORPH_API_KEY, - morphApiUrl: MORPH_API_URL, - timeout: MORPH_WARP_GREP_TIMEOUT, - }) - : null; - -/** - * Separate CompactClient for context compaction. - */ -const compactClient = MORPH_API_KEY - ? new CompactClient({ - morphApiKey: MORPH_API_KEY, - morphApiUrl: MORPH_API_URL, - timeout: MORPH_COMPACT_TIMEOUT, - }) - : null; - /** * Model context window size in tokens. Updated from chat.params hook. * Default is conservative — actual value captured on first LLM call. @@ -127,6 +94,52 @@ let compactionState: { frozenChars: number; } | null = null; +type MorphPluginOptions = { + apiKey?: unknown; +}; + +function normalizeApiKey(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function resolveMorphApiKey(options?: MorphPluginOptions): string | undefined { + return normalizeApiKey(options?.apiKey) ?? MORPH_ENV_API_KEY; +} + +function createMorphClients(apiKey: string | undefined): { + morph: MorphClient | null; + warpGrep: WarpGrepClient | null; + compactClient: CompactClient | null; +} { + if (!apiKey) { + return { + morph: null, + warpGrep: null, + compactClient: null, + }; + } + + return { + morph: new MorphClient({ + apiKey, + timeout: MORPH_TIMEOUT, + }), + warpGrep: new WarpGrepClient({ + morphApiKey: apiKey, + morphApiUrl: MORPH_API_URL, + timeout: MORPH_WARP_GREP_TIMEOUT, + }), + compactClient: new CompactClient({ + morphApiKey: apiKey, + morphApiUrl: MORPH_API_URL, + timeout: MORPH_COMPACT_TIMEOUT, + }), + }; +} + /** * Normalize code_edit input from LLM tool calls. * @@ -238,7 +251,10 @@ function appendRuntimeNotes(description: string, notes: string[]): string { return `${description}\n\nRuntime notes:\n${notes.map((note) => `- ${note}`).join("\n")}`; } -function buildToolRuntimeNotes(toolID: string): string[] { +function buildToolRuntimeNotes( + toolID: string, + apiKey: string | undefined, +): string[] { switch (toolID) { case "morph_edit": { const notes = [ @@ -251,8 +267,8 @@ function buildToolRuntimeNotes(toolID: string): string[] { ); } - if (!MORPH_API_KEY) { - notes.push("Currently unavailable until MORPH_API_KEY is configured."); + if (!apiKey) { + notes.push("Currently unavailable until a Morph API key is configured."); } return notes; @@ -263,8 +279,8 @@ function buildToolRuntimeNotes(toolID: string): string[] { "Searches the current project worktree, not just the immediate cwd.", ]; - if (!MORPH_API_KEY) { - notes.push("Currently unavailable until MORPH_API_KEY is configured."); + if (!apiKey) { + notes.push("Currently unavailable until a Morph API key is configured."); } return notes; @@ -275,8 +291,8 @@ function buildToolRuntimeNotes(toolID: string): string[] { "Use this for public GitHub source questions, not the current checked-out repo.", ]; - if (!MORPH_API_KEY) { - notes.push("Currently unavailable until MORPH_API_KEY is configured."); + if (!apiKey) { + notes.push("Currently unavailable until a Morph API key is configured."); } return notes; @@ -287,11 +303,11 @@ function buildToolRuntimeNotes(toolID: string): string[] { } } -function buildMorphSystemRoutingHint(): string | null { - if (!MORPH_API_KEY) { +function buildMorphSystemRoutingHint(apiKey: string | undefined): string | null { + if (!apiKey) { return [ MORPH_ROUTING_HINT_HEADER, - "- Morph remote tools are currently unavailable because MORPH_API_KEY is not configured.", + "- Morph remote tools are currently unavailable because no Morph API key is configured.", "- Use native edit/write/grep tools until Morph credentials are configured.", ].join("\n"); } @@ -664,7 +680,13 @@ async function fetchGitHubRepoSuggestions( }); } -const MorphPlugin: Plugin = async ({ directory, worktree, client }) => { +const MorphPlugin: Plugin = async ( + { directory, worktree, client }, + options?: MorphPluginOptions, +) => { + const morphApiKey = resolveMorphApiKey(options); + const { morph, warpGrep, compactClient } = createMorphClients(morphApiKey); + const log = async ( level: "debug" | "info" | "warn" | "error", message: string, @@ -693,10 +715,10 @@ const MorphPlugin: Plugin = async ({ directory, worktree, client }) => { } catch {} }; - if (!MORPH_API_KEY) { + if (!morphApiKey) { await log( "warn", - "MORPH_API_KEY not set - morph tools will be disabled", + "Morph API key not configured - morph tools will be disabled", ); } else { const features = [ @@ -785,10 +807,10 @@ Options: directory, ); - if (!MORPH_API_KEY) { - return `Error: MORPH_API_KEY not configured. + if (!morphApiKey) { + return `Error: Morph API key not configured. -To use morph_edit, set the MORPH_API_KEY environment variable. +To use morph_edit, set the plugin apiKey option or the MORPH_API_KEY environment variable. Get your API key at: https://morphllm.com/dashboard/api-keys Alternatively, use the native 'edit' tool for this change.`; @@ -957,10 +979,10 @@ For exact keyword searches (specific function names, variable names), prefer gre }, async execute(args, context) { - if (!MORPH_API_KEY) { - return `Error: MORPH_API_KEY not configured. + if (!morphApiKey) { + return `Error: Morph API key not configured. -To use warpgrep_codebase_search, set the MORPH_API_KEY environment variable. +To use warpgrep_codebase_search, set the plugin apiKey option or the MORPH_API_KEY environment variable. Get your API key at: https://morphllm.com/dashboard/api-keys`; } @@ -1061,10 +1083,10 @@ Provide exactly one repository locator: }, async execute(args) { - if (!MORPH_API_KEY) { - return `Error: MORPH_API_KEY not configured. + if (!morphApiKey) { + return `Error: Morph API key not configured. -To use warpgrep_github_search, set the MORPH_API_KEY environment variable. +To use warpgrep_github_search, set the plugin apiKey option or the MORPH_API_KEY environment variable. Get your API key at: https://morphllm.com/dashboard/api-keys`; } @@ -1121,13 +1143,13 @@ Get your API key at: https://morphllm.com/dashboard/api-keys`; }; hooks["tool.definition"] = async (input: any, output: any) => { - const notes = buildToolRuntimeNotes(input.toolID); + const notes = buildToolRuntimeNotes(input.toolID, morphApiKey); if (notes.length === 0) return; output.description = appendRuntimeNotes(output.description, notes); }; - const systemRoutingHint = buildMorphSystemRoutingHint(); + const systemRoutingHint = buildMorphSystemRoutingHint(morphApiKey); if (systemRoutingHint) { hooks["experimental.chat.system.transform"] = async ( _input: any, @@ -1238,7 +1260,7 @@ Get your API key at: https://morphllm.com/dashboard/api-keys`; // preserving the LLM provider's prompt prefix cache. // Re-compaction only fires when the threshold is crossed again. hooks["experimental.chat.messages.transform"] = async (_input: any, output: any) => { - if (!MORPH_API_KEY) return; + if (!morphApiKey) return; const messages = output.messages; diff --git a/instructions/morph-tools.md b/instructions/morph-tools.md index 5fb9d8b..8c4e9c4 100644 --- a/instructions/morph-tools.md +++ b/instructions/morph-tools.md @@ -34,7 +34,7 @@ faster or more reliable than exact-string replacement. - The change is a small exact `oldString` -> `newString` replacement - You are creating a brand new file - The current agent is readonly and cannot edit files -- `MORPH_API_KEY` is not configured; fall back to native `edit` +- A Morph API key is not configured via plugin `apiKey` or `MORPH_API_KEY`; fall back to native `edit` ### WarpGrep Usage