opencode-morphllm 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,7 +22,21 @@ Example configs:
22
22
 
23
23
  ```json
24
24
  {
25
- "MORPH_API_KEY": "sk-I05oNc_g83uRY-T4iOE47vCWuqbkG_MOKNhbZqcmzU-aXuXQ",
25
+ "MORPH_API_KEY": "YOUR_API_KEY_HERE",
26
+ "MORPH_ROUTER_CONFIGS": {
27
+ "MORPH_MODEL_EASY": "github-copilot/gpt-5-mini",
28
+ "MORPH_MODEL_MEDIUM": "opencode/minimax-m2.1-free",
29
+ "MORPH_MODEL_HARD": "github-copilot/gemini-2.5-pro",
30
+ "MORPH_ROUTER_ENABLED": true
31
+ }
32
+ }
33
+ ```
34
+
35
+ Legacy format (still supported):
36
+
37
+ ```json
38
+ {
39
+ "MORPH_API_KEY": "YOUR_API_KEY_HERE",
26
40
  "MORPH_MODEL_EASY": "github-copilot/gpt-5-mini",
27
41
  "MORPH_MODEL_MEDIUM": "opencode/minimax-m2.1-free",
28
42
  "MORPH_MODEL_HARD": "github-copilot/gemini-2.5-pro",
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { createBuiltinMcps } from './mcps';
2
- import { createModelRouterHook } from './router';
3
- import { MORPH_ROUTER_ENABLED } from './config';
1
+ import { createBuiltinMcps } from './morph/mcps';
2
+ import { createModelRouterHook } from './morph/router';
3
+ import { MORPH_ROUTER_ENABLED } from './shared/config';
4
4
  const MorphOpenCodePlugin = async () => {
5
5
  const builtinMcps = createBuiltinMcps();
6
6
  const routerHook = MORPH_ROUTER_ENABLED ? createModelRouterHook() : {};
@@ -1,4 +1,4 @@
1
- import { API_KEY } from './config';
1
+ import { API_KEY } from '../shared/config';
2
2
  export function createBuiltinMcps() {
3
3
  return {
4
4
  morph_mcp: {
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, vi } from 'bun:test';
2
+ vi.mock('../shared/config', () => ({
3
+ API_KEY: 'test-api-key-123',
4
+ }));
5
+ import { createBuiltinMcps } from './mcps';
6
+ describe('mcps.ts', () => {
7
+ describe('createBuiltinMcps', () => {
8
+ it('should create morph_mcp configuration', () => {
9
+ const mcps = createBuiltinMcps();
10
+ expect(mcps).toHaveProperty('morph_mcp');
11
+ expect(mcps.morph_mcp.type).toBe('local');
12
+ expect(mcps.morph_mcp.enabled).toBe(true);
13
+ });
14
+ it('should set correct command for morph_mcp', () => {
15
+ const mcps = createBuiltinMcps();
16
+ expect(mcps.morph_mcp.command).toEqual([
17
+ 'npx',
18
+ '-y',
19
+ '@morphllm/morphmcp',
20
+ ]);
21
+ });
22
+ it('should set MORPH_API_KEY in environment', () => {
23
+ const mcps = createBuiltinMcps();
24
+ const env = mcps.morph_mcp.environment;
25
+ expect(env).toBeDefined();
26
+ expect(env?.MORPH_API_KEY).toBe('test-api-key-123');
27
+ });
28
+ it('should set ENABLED_TOOLS environment variable', () => {
29
+ const mcps = createBuiltinMcps();
30
+ const env = mcps.morph_mcp.environment;
31
+ expect(env).toBeDefined();
32
+ expect(env?.ENABLED_TOOLS).toBe('edit_file,warpgrep_codebase_search');
33
+ });
34
+ it('should only create morph_mcp key', () => {
35
+ const mcps = createBuiltinMcps();
36
+ expect(Object.keys(mcps)).toEqual(['morph_mcp']);
37
+ });
38
+ });
39
+ });
@@ -5,8 +5,17 @@ import {
5
5
  MORPH_MODEL_MEDIUM,
6
6
  MORPH_MODEL_HARD,
7
7
  MORPH_MODEL_DEFAULT,
8
- } from './config';
9
- const morph = new MorphClient({ apiKey: API_KEY });
8
+ MORPH_ROUTER_ONLY_FIRST_MESSAGE,
9
+ } from '../shared/config';
10
+ // Lazy initialization to allow mocking in tests
11
+ let morph = null;
12
+ const sessionsWithModelSelected = new Set();
13
+ function getMorphClient() {
14
+ if (!morph) {
15
+ morph = new MorphClient({ apiKey: API_KEY });
16
+ }
17
+ return morph;
18
+ }
10
19
  function parseModel(s) {
11
20
  if (!s) return { providerID: '', modelID: '' };
12
21
  const [providerID = '', modelID = ''] = s.split('/');
@@ -29,9 +38,15 @@ export function createModelRouterHook() {
29
38
  return {
30
39
  'chat.message': async (input, output) => {
31
40
  input.model = input.model ?? { providerID: '', modelID: '' };
41
+ if (MORPH_ROUTER_ONLY_FIRST_MESSAGE) {
42
+ if (sessionsWithModelSelected.has(input.sessionID)) {
43
+ return;
44
+ }
45
+ }
32
46
  const promptText = extractPromptText(output.parts);
33
47
  const classifier =
34
- input.classify ?? ((args) => morph.routers.raw.classify(args));
48
+ input.classify ??
49
+ ((args) => getMorphClient().routers.raw.classify(args));
35
50
  const classification = await classifier({
36
51
  input: promptText,
37
52
  });
@@ -40,6 +55,9 @@ export function createModelRouterHook() {
40
55
  const finalModelID = chosen.modelID || input.model.modelID;
41
56
  input.model.providerID = finalProviderID;
42
57
  input.model.modelID = finalModelID;
58
+ if (MORPH_ROUTER_ONLY_FIRST_MESSAGE) {
59
+ sessionsWithModelSelected.add(input.sessionID);
60
+ }
43
61
  },
44
62
  };
45
63
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,240 @@
1
+ import { describe, it, expect, vi } from 'bun:test';
2
+ vi.mock('@morphllm/morphsdk', () => ({
3
+ MorphClient: vi.fn().mockImplementation(() => ({
4
+ routers: {
5
+ raw: {
6
+ classify: vi.fn(),
7
+ },
8
+ },
9
+ })),
10
+ }));
11
+ vi.mock('../shared/config', () => ({
12
+ API_KEY: 'sk-test-api-key-123',
13
+ MORPH_MODEL_EASY: 'easy/easy',
14
+ MORPH_MODEL_MEDIUM: 'medium/medium',
15
+ MORPH_MODEL_HARD: 'hard/hard',
16
+ MORPH_MODEL_DEFAULT: 'default/default',
17
+ MORPH_ROUTER_ONLY_FIRST_MESSAGE: false,
18
+ }));
19
+ import { createModelRouterHook, extractPromptText } from './router';
20
+ describe('router.ts', () => {
21
+ describe('extractPromptText', () => {
22
+ it('should extract text from parts', () => {
23
+ const parts = [
24
+ { type: 'text', text: 'Hello' },
25
+ { type: 'text', text: 'World' },
26
+ ];
27
+ expect(extractPromptText(parts)).toBe('Hello World');
28
+ });
29
+ it('should handle empty parts', () => {
30
+ const parts = [];
31
+ expect(extractPromptText(parts)).toBe('');
32
+ });
33
+ it('should filter out non-text parts', () => {
34
+ const parts = [
35
+ { type: 'text', text: 'Hello' },
36
+ { type: 'other', text: 'Ignore' },
37
+ { type: 'text', text: 'World' },
38
+ ];
39
+ expect(extractPromptText(parts)).toBe('Hello World');
40
+ });
41
+ it('should handle parts with no text', () => {
42
+ const parts = [
43
+ { type: 'text', text: 'Hello' },
44
+ { type: 'text' },
45
+ { type: 'text', text: 'World' },
46
+ ];
47
+ expect(extractPromptText(parts)).toBe('Hello World');
48
+ });
49
+ it('should handle all non-text parts', () => {
50
+ const parts = [
51
+ { type: 'image', data: 'base64...' },
52
+ { type: 'tool_use', name: 'bash' },
53
+ ];
54
+ expect(extractPromptText(parts)).toBe('');
55
+ });
56
+ });
57
+ describe('createModelRouterHook', () => {
58
+ it('should return a chat.message hook', () => {
59
+ const hook = createModelRouterHook();
60
+ expect('chat.message' in hook).toBe(true);
61
+ expect(typeof hook['chat.message']).toBe('function');
62
+ });
63
+ it('should call the classifier with the correct input', async () => {
64
+ const hook = createModelRouterHook();
65
+ const classify = vi.fn().mockResolvedValue({ difficulty: 'easy' });
66
+ const input = {
67
+ sessionID: '123',
68
+ classify,
69
+ };
70
+ const output = {
71
+ message: {},
72
+ parts: [{ type: 'text', text: 'test prompt' }],
73
+ };
74
+ await hook['chat.message'](input, output);
75
+ expect(classify).toHaveBeenCalledWith({ input: 'test prompt' });
76
+ });
77
+ it('should assign the correct model based on difficulty', async () => {
78
+ const hook = createModelRouterHook();
79
+ const classify = vi.fn().mockResolvedValue({ difficulty: 'hard' });
80
+ const input = {
81
+ sessionID: '123',
82
+ classify,
83
+ };
84
+ const output = {
85
+ message: {},
86
+ parts: [{ type: 'text', text: 'test prompt' }],
87
+ };
88
+ await hook['chat.message'](input, output);
89
+ expect(input).toHaveProperty('model');
90
+ expect(input.model.providerID).toBe('hard');
91
+ expect(input.model.modelID).toBe('hard');
92
+ });
93
+ it('should use the default model if no difficulty is returned', async () => {
94
+ const hook = createModelRouterHook();
95
+ const classify = vi.fn().mockResolvedValue({});
96
+ const input = {
97
+ sessionID: '123',
98
+ classify,
99
+ };
100
+ const output = {
101
+ message: {},
102
+ parts: [{ type: 'text', text: 'test prompt' }],
103
+ };
104
+ await hook['chat.message'](input, output);
105
+ expect(input).toHaveProperty('model');
106
+ expect(input.model.providerID).toBe('default');
107
+ expect(input.model.modelID).toBe('default');
108
+ });
109
+ it('should use default model when router returns nothing', async () => {
110
+ const hook = createModelRouterHook();
111
+ const classify = vi.fn().mockResolvedValue({});
112
+ const input = {
113
+ sessionID: '123',
114
+ classify,
115
+ };
116
+ const output = {
117
+ message: {},
118
+ parts: [{ type: 'text', text: 'test prompt' }],
119
+ };
120
+ await hook['chat.message'](input, output);
121
+ expect(input).toHaveProperty('model');
122
+ expect(input.model.providerID).toBe('default');
123
+ expect(input.model.modelID).toBe('default');
124
+ });
125
+ it('should handle classifier throwing an error gracefully', async () => {
126
+ const hook = createModelRouterHook();
127
+ const classify = vi.fn().mockRejectedValue(new Error('API Error'));
128
+ const input = {
129
+ sessionID: '123',
130
+ classify,
131
+ };
132
+ const output = {
133
+ message: {},
134
+ parts: [{ type: 'text', text: 'test prompt' }],
135
+ };
136
+ await expect(hook['chat.message'](input, output)).rejects.toThrow(
137
+ 'API Error'
138
+ );
139
+ });
140
+ it('should handle classifier returning null', async () => {
141
+ const hook = createModelRouterHook();
142
+ const classify = vi.fn().mockResolvedValue(null);
143
+ const input = {
144
+ sessionID: '123',
145
+ classify,
146
+ };
147
+ const output = {
148
+ message: {},
149
+ parts: [{ type: 'text', text: 'test prompt' }],
150
+ };
151
+ await hook['chat.message'](input, output);
152
+ expect(input.model.providerID).toBe('default');
153
+ expect(input.model.modelID).toBe('default');
154
+ });
155
+ it('should handle classifier returning undefined difficulty', async () => {
156
+ const hook = createModelRouterHook();
157
+ const classify = vi.fn().mockResolvedValue({ difficulty: undefined });
158
+ const input = {
159
+ sessionID: '123',
160
+ classify,
161
+ };
162
+ const output = {
163
+ message: {},
164
+ parts: [{ type: 'text', text: 'test prompt' }],
165
+ };
166
+ await hook['chat.message'](input, output);
167
+ expect(input.model.providerID).toBe('default');
168
+ expect(input.model.modelID).toBe('default');
169
+ });
170
+ it('should default to default model for invalid difficulty values', async () => {
171
+ const hook = createModelRouterHook();
172
+ const classify = vi.fn().mockResolvedValue({ difficulty: 'ultra-hard' });
173
+ const input = {
174
+ sessionID: '123',
175
+ classify,
176
+ };
177
+ const output = {
178
+ message: {},
179
+ parts: [{ type: 'text', text: 'test prompt' }],
180
+ };
181
+ await hook['chat.message'](input, output);
182
+ expect(input.model.providerID).toBe('default');
183
+ expect(input.model.modelID).toBe('default');
184
+ });
185
+ it('should default to default model for empty string difficulty', async () => {
186
+ const hook = createModelRouterHook();
187
+ const classify = vi.fn().mockResolvedValue({ difficulty: '' });
188
+ const input = {
189
+ sessionID: '123',
190
+ classify,
191
+ };
192
+ const output = {
193
+ message: {},
194
+ parts: [{ type: 'text', text: 'test prompt' }],
195
+ };
196
+ await hook['chat.message'](input, output);
197
+ expect(input.model.providerID).toBe('default');
198
+ expect(input.model.modelID).toBe('default');
199
+ });
200
+ it('should handle case-insensitive difficulty matching', async () => {
201
+ const hook = createModelRouterHook();
202
+ const classify = vi.fn().mockResolvedValue({ difficulty: 'HARD' });
203
+ const input = {
204
+ sessionID: '123',
205
+ classify,
206
+ };
207
+ const output = {
208
+ message: {},
209
+ parts: [{ type: 'text', text: 'test prompt' }],
210
+ };
211
+ await hook['chat.message'](input, output);
212
+ expect(input.model.providerID).toBe('hard');
213
+ expect(input.model.modelID).toBe('hard');
214
+ });
215
+ it('should route all messages when MORPH_ROUTER_ONLY_FIRST_MESSAGE is disabled', async () => {
216
+ const hook = createModelRouterHook();
217
+ const classify = vi
218
+ .fn()
219
+ .mockResolvedValueOnce({ difficulty: 'hard' })
220
+ .mockResolvedValueOnce({ difficulty: 'easy' });
221
+ const sessionID = 'session-456';
222
+ const input1 = { sessionID, classify };
223
+ const output1 = {
224
+ message: {},
225
+ parts: [{ type: 'text', text: 'first message' }],
226
+ };
227
+ await hook['chat.message'](input1, output1);
228
+ expect(classify).toHaveBeenCalledTimes(1);
229
+ expect(input1.model.providerID).toBe('hard');
230
+ const input2 = { sessionID, classify };
231
+ const output2 = {
232
+ message: {},
233
+ parts: [{ type: 'text', text: 'second message' }],
234
+ };
235
+ await hook['chat.message'](input2, output2);
236
+ expect(classify).toHaveBeenCalledTimes(2);
237
+ expect(input2.model.providerID).toBe('easy');
238
+ });
239
+ });
240
+ });
@@ -1,7 +1,17 @@
1
1
  export declare function getMorphPluginConfigPath(): string;
2
+ interface MorphRouterConfigs {
3
+ MORPH_ROUTER_ENABLED?: boolean;
4
+ MORPH_ROUTER_ONLY_FIRST_MESSAGE?: boolean;
5
+ MORPH_MODEL_EASY?: string;
6
+ MORPH_MODEL_MEDIUM?: string;
7
+ MORPH_MODEL_HARD?: string;
8
+ MORPH_MODEL_DEFAULT?: string;
9
+ }
2
10
  interface MorphConfig {
3
11
  MORPH_API_KEY?: string;
12
+ MORPH_ROUTER_CONFIGS?: MorphRouterConfigs;
4
13
  MORPH_ROUTER_ENABLED?: boolean;
14
+ MORPH_ROUTER_ONLY_FIRST_MESSAGE?: boolean;
5
15
  MORPH_MODEL_EASY?: string;
6
16
  MORPH_MODEL_MEDIUM?: string;
7
17
  MORPH_MODEL_HARD?: string;
@@ -17,4 +27,5 @@ export declare const MORPH_MODEL_MEDIUM: string;
17
27
  export declare const MORPH_MODEL_HARD: string;
18
28
  export declare const MORPH_MODEL_DEFAULT: string;
19
29
  export declare const MORPH_ROUTER_ENABLED: boolean;
30
+ export declare const MORPH_ROUTER_ONLY_FIRST_MESSAGE: boolean;
20
31
  export {};
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { getOpenCodeConfigDir } from './shared/opencode-config-dir';
3
+ import { getOpenCodeConfigDir } from './opencode-config-dir';
4
4
  const MORPH_PLUGIN_NAME = 'morph';
5
5
  export function getMorphPluginConfigPath() {
6
6
  const configDir = getOpenCodeConfigDir({ binary: 'opencode' });
@@ -62,10 +62,21 @@ export function loadMorphPluginConfigWithProjectOverride(
62
62
  return { ...userConfig, ...projectConfig };
63
63
  }
64
64
  const config = loadMorphPluginConfigWithProjectOverride();
65
+ const routerConfigs = config.MORPH_ROUTER_CONFIGS || {};
65
66
  export const API_KEY = config.MORPH_API_KEY || '';
66
- export const MORPH_MODEL_EASY = config.MORPH_MODEL_EASY || '';
67
- export const MORPH_MODEL_MEDIUM = config.MORPH_MODEL_MEDIUM || '';
68
- export const MORPH_MODEL_HARD = config.MORPH_MODEL_HARD || '';
67
+ export const MORPH_MODEL_EASY =
68
+ routerConfigs.MORPH_MODEL_EASY || config.MORPH_MODEL_EASY || '';
69
+ export const MORPH_MODEL_MEDIUM =
70
+ routerConfigs.MORPH_MODEL_MEDIUM || config.MORPH_MODEL_MEDIUM || '';
71
+ export const MORPH_MODEL_HARD =
72
+ routerConfigs.MORPH_MODEL_HARD || config.MORPH_MODEL_HARD || '';
69
73
  export const MORPH_MODEL_DEFAULT =
70
- config.MORPH_MODEL_DEFAULT || MORPH_MODEL_MEDIUM;
71
- export const MORPH_ROUTER_ENABLED = config.MORPH_ROUTER_ENABLED ?? true;
74
+ routerConfigs.MORPH_MODEL_DEFAULT ||
75
+ config.MORPH_MODEL_DEFAULT ||
76
+ MORPH_MODEL_MEDIUM;
77
+ export const MORPH_ROUTER_ENABLED =
78
+ routerConfigs.MORPH_ROUTER_ENABLED ?? config.MORPH_ROUTER_ENABLED ?? true;
79
+ export const MORPH_ROUTER_ONLY_FIRST_MESSAGE =
80
+ routerConfigs.MORPH_ROUTER_ONLY_FIRST_MESSAGE ??
81
+ config.MORPH_ROUTER_ONLY_FIRST_MESSAGE ??
82
+ false;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,204 @@
1
+ import {
2
+ describe,
3
+ it,
4
+ expect,
5
+ beforeEach,
6
+ vi,
7
+ beforeAll,
8
+ afterAll,
9
+ } from 'bun:test';
10
+ import { existsSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import { tmpdir } from 'node:os';
13
+ // Mock the opencode-config-dir module before importing config
14
+ const mockConfigDir = join(tmpdir(), 'mock-opencode-config-test');
15
+ const mockMorphJson = join(mockConfigDir, 'morph.json');
16
+ const mockMorphJsonc = join(mockConfigDir, 'morph.jsonc');
17
+ vi.mock('./opencode-config-dir', () => ({
18
+ getOpenCodeConfigDir: vi.fn(() => mockConfigDir),
19
+ }));
20
+ import {
21
+ getMorphPluginConfigPath,
22
+ loadMorphPluginConfig,
23
+ loadMorphPluginConfigWithProjectOverride,
24
+ } from './config';
25
+ describe('config.ts', () => {
26
+ beforeAll(() => {
27
+ // Create mock config directory
28
+ if (!existsSync(mockConfigDir)) {
29
+ mkdirSync(mockConfigDir, { recursive: true });
30
+ }
31
+ });
32
+ afterAll(() => {
33
+ // Cleanup
34
+ try {
35
+ if (existsSync(mockMorphJson)) rmSync(mockMorphJson);
36
+ if (existsSync(mockMorphJsonc)) rmSync(mockMorphJsonc);
37
+ if (existsSync(mockConfigDir)) rmSync(mockConfigDir, { recursive: true });
38
+ } catch {
39
+ // Ignore cleanup errors
40
+ }
41
+ });
42
+ beforeEach(() => {
43
+ // Clean up any existing config files before each test
44
+ try {
45
+ if (existsSync(mockMorphJson)) rmSync(mockMorphJson);
46
+ if (existsSync(mockMorphJsonc)) rmSync(mockMorphJsonc);
47
+ } catch {
48
+ // Ignore cleanup errors
49
+ }
50
+ });
51
+ describe('getMorphPluginConfigPath', () => {
52
+ it('should return path to morph.json in config directory', () => {
53
+ const path = getMorphPluginConfigPath();
54
+ expect(path).toContain('morph.json');
55
+ expect(path).toContain(mockConfigDir);
56
+ });
57
+ });
58
+ describe('loadMorphPluginConfig', () => {
59
+ it('should return null when no config files exist', () => {
60
+ const result = loadMorphPluginConfig();
61
+ expect(result).toBeNull();
62
+ });
63
+ it('should load config from .jsonc file', () => {
64
+ const content = `{
65
+ "MORPH_API_KEY": "test-key-123",
66
+ "MORPH_ROUTER_ENABLED": false
67
+ }`;
68
+ writeFileSync(mockMorphJsonc, content);
69
+ const result = loadMorphPluginConfig();
70
+ expect(result).not.toBeNull();
71
+ expect(result?.MORPH_API_KEY).toBe('test-key-123');
72
+ expect(result?.MORPH_ROUTER_ENABLED).toBe(false);
73
+ });
74
+ it('should load config from .json file', () => {
75
+ const content = JSON.stringify({
76
+ MORPH_API_KEY: 'json-key-456',
77
+ MORPH_MODEL_EASY: 'provider/model-easy',
78
+ });
79
+ writeFileSync(mockMorphJson, content);
80
+ const result = loadMorphPluginConfig();
81
+ expect(result).not.toBeNull();
82
+ expect(result?.MORPH_API_KEY).toBe('json-key-456');
83
+ expect(result?.MORPH_MODEL_EASY).toBe('provider/model-easy');
84
+ });
85
+ it('should prefer .jsonc over .json when both exist', () => {
86
+ const jsoncContent = '{"MORPH_API_KEY": "from-jsonc"}';
87
+ const jsonContent = '{"MORPH_API_KEY": "from-json"}';
88
+ writeFileSync(mockMorphJsonc, jsoncContent);
89
+ writeFileSync(mockMorphJson, jsonContent);
90
+ const result = loadMorphPluginConfig();
91
+ expect(result?.MORPH_API_KEY).toBe('from-jsonc');
92
+ });
93
+ it('should return null for invalid .jsonc content', () => {
94
+ writeFileSync(mockMorphJsonc, 'invalid json content');
95
+ const result = loadMorphPluginConfig();
96
+ expect(result).toBeNull();
97
+ });
98
+ it('should return null for invalid .json content', () => {
99
+ writeFileSync(mockMorphJson, 'invalid json content');
100
+ const result = loadMorphPluginConfig();
101
+ expect(result).toBeNull();
102
+ });
103
+ it('should handle all MORPH config fields', () => {
104
+ const content = JSON.stringify({
105
+ MORPH_API_KEY: 'api-key',
106
+ MORPH_ROUTER_ENABLED: true,
107
+ MORPH_MODEL_EASY: 'easy/provider/model',
108
+ MORPH_MODEL_MEDIUM: 'medium/provider/model',
109
+ MORPH_MODEL_HARD: 'hard/provider/model',
110
+ MORPH_MODEL_DEFAULT: 'default/provider/model',
111
+ });
112
+ writeFileSync(mockMorphJson, content);
113
+ const result = loadMorphPluginConfig();
114
+ expect(result).toEqual({
115
+ MORPH_API_KEY: 'api-key',
116
+ MORPH_ROUTER_ENABLED: true,
117
+ MORPH_MODEL_EASY: 'easy/provider/model',
118
+ MORPH_MODEL_MEDIUM: 'medium/provider/model',
119
+ MORPH_MODEL_HARD: 'hard/provider/model',
120
+ MORPH_MODEL_DEFAULT: 'default/provider/model',
121
+ });
122
+ });
123
+ it('should handle nested MORPH_ROUTER_CONFIGS', () => {
124
+ const content = JSON.stringify({
125
+ MORPH_API_KEY: 'api-key',
126
+ MORPH_ROUTER_CONFIGS: {
127
+ MORPH_ROUTER_ENABLED: true,
128
+ MORPH_MODEL_EASY: 'easy/provider/model',
129
+ MORPH_MODEL_MEDIUM: 'medium/provider/model',
130
+ MORPH_MODEL_HARD: 'hard/provider/model',
131
+ MORPH_MODEL_DEFAULT: 'default/provider/model',
132
+ },
133
+ });
134
+ writeFileSync(mockMorphJson, content);
135
+ const result = loadMorphPluginConfig();
136
+ expect(result?.MORPH_API_KEY).toBe('api-key');
137
+ expect(result?.MORPH_ROUTER_CONFIGS).toEqual({
138
+ MORPH_ROUTER_ENABLED: true,
139
+ MORPH_MODEL_EASY: 'easy/provider/model',
140
+ MORPH_MODEL_MEDIUM: 'medium/provider/model',
141
+ MORPH_MODEL_HARD: 'hard/provider/model',
142
+ MORPH_MODEL_DEFAULT: 'default/provider/model',
143
+ });
144
+ });
145
+ });
146
+ describe('loadMorphPluginConfigWithProjectOverride', () => {
147
+ it('should return empty object when no config exists', () => {
148
+ const result =
149
+ loadMorphPluginConfigWithProjectOverride('/non-existent-path');
150
+ expect(result).toEqual({});
151
+ });
152
+ it('should merge user config with project config', () => {
153
+ // Create user config
154
+ writeFileSync(
155
+ mockMorphJson,
156
+ JSON.stringify({
157
+ MORPH_API_KEY: 'user-api-key',
158
+ MORPH_MODEL_EASY: 'user-easy-model',
159
+ })
160
+ );
161
+ // Create project config directory
162
+ const projectDir = join(tmpdir(), 'test-project');
163
+ const projectConfigDir = join(projectDir, '.opencode');
164
+ const projectConfigPath = join(projectConfigDir, 'morph.json');
165
+ mkdirSync(projectConfigDir, { recursive: true });
166
+ writeFileSync(
167
+ projectConfigPath,
168
+ JSON.stringify({
169
+ MORPH_API_KEY: 'project-api-key',
170
+ MORPH_MODEL_HARD: 'project-hard-model',
171
+ })
172
+ );
173
+ const result = loadMorphPluginConfigWithProjectOverride(projectDir);
174
+ // Project config should override user config
175
+ expect(result.MORPH_API_KEY).toBe('project-api-key');
176
+ expect(result.MORPH_MODEL_EASY).toBe('user-easy-model');
177
+ expect(result.MORPH_MODEL_HARD).toBe('project-hard-model');
178
+ });
179
+ it('should handle project .jsonc config', () => {
180
+ // Create user config
181
+ writeFileSync(
182
+ mockMorphJson,
183
+ JSON.stringify({
184
+ MORPH_API_KEY: 'user-key',
185
+ })
186
+ );
187
+ // Create project config with .jsonc
188
+ const projectDir = join(tmpdir(), 'test-project2');
189
+ const projectConfigDir = join(projectDir, '.opencode');
190
+ const projectConfigPath = join(projectConfigDir, 'morph.jsonc');
191
+ mkdirSync(projectConfigDir, { recursive: true });
192
+ const jsoncContent = `
193
+ // Project config with comments
194
+ {
195
+ "MORPH_MODEL_MEDIUM": "project-medium-model"
196
+ }
197
+ `;
198
+ writeFileSync(projectConfigPath, jsoncContent);
199
+ const result = loadMorphPluginConfigWithProjectOverride(projectDir);
200
+ expect(result.MORPH_API_KEY).toBe('user-key');
201
+ expect(result.MORPH_MODEL_MEDIUM).toBe('project-medium-model');
202
+ });
203
+ });
204
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-morphllm",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "author": "Vito Lin",
5
5
  "main": "dist/index.js",
6
6
  "description": "OpenCode plugin for MorphLLM",
package/src/index.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import { Plugin } from '@opencode-ai/plugin';
2
2
  import type { McpLocalConfig } from '@opencode-ai/sdk';
3
3
 
4
- import { createBuiltinMcps } from './mcps';
5
- import { createModelRouterHook } from './router';
6
- import { MORPH_ROUTER_ENABLED } from './config';
4
+ import { createBuiltinMcps } from './morph/mcps';
5
+ import { createModelRouterHook } from './morph/router';
6
+ import { MORPH_ROUTER_ENABLED } from './shared/config';
7
7
 
8
8
  const MorphOpenCodePlugin: Plugin = async () => {
9
9
  const builtinMcps: Record<string, McpLocalConfig> = createBuiltinMcps();