opencode-resolve 0.1.6 → 0.1.8

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.
@@ -1,7 +1,9 @@
1
+ import { spawn } from "node:child_process"
1
2
  import { constants } from "node:fs"
2
3
  import { access, mkdir, readFile, writeFile } from "node:fs/promises"
3
4
  import { homedir } from "node:os"
4
5
  import { dirname, join, resolve } from "node:path"
6
+ import { createInterface } from "node:readline/promises"
5
7
  import { fileURLToPath } from "node:url"
6
8
 
7
9
  const packageName = "opencode-resolve"
@@ -15,6 +17,30 @@ const ADDITIVE_DEFAULTS = {
15
17
  autoApprove: true,
16
18
  }
17
19
 
20
+ // ZAI local MCP server bootstrap for GLM users.
21
+ // Do not copy provider secrets from OpenCode's auth store into opencode.json.
22
+ // The MCP process should receive credentials from the user's runtime environment.
23
+ const ZAI_MCP_SERVERS = {
24
+ "zai-mcp-server": {
25
+ type: "local",
26
+ command: ["npx", "-y", "@z_ai/mcp-server"],
27
+ environment: {
28
+ Z_AI_MODE: "ZAI",
29
+ },
30
+ },
31
+ }
32
+
33
+ const COMPANION_PLUGINS = [
34
+ {
35
+ pkg: "@tarquinen/opencode-dcp",
36
+ desc: "Dynamic Context Pruning — strips obsolete tool outputs so long resolver loops cost fewer tokens",
37
+ },
38
+ {
39
+ pkg: "@slkiser/opencode-quota",
40
+ desc: "Live token/quota usage indicator — supports GLM coding-plan, OpenAI Plus/Pro, Qwen, and more",
41
+ },
42
+ ]
43
+
18
44
  if (process.env.OPENCODE_RESOLVE_SKIP_POSTINSTALL === "1") {
19
45
  process.exit(0)
20
46
  }
@@ -24,6 +50,7 @@ console.log(`[${packageName}] installing v${pluginVersion}`)
24
50
 
25
51
  try {
26
52
  await registerPlugin()
53
+ await offerCompanionPlugins()
27
54
  console.log(`[${packageName}] v${pluginVersion} install complete — restart OpenCode to load the plugin`)
28
55
  } catch (error) {
29
56
  console.warn(`[${packageName}] automatic OpenCode registration skipped: ${formatError(error)}`)
@@ -44,11 +71,19 @@ async function registerPlugin() {
44
71
  await mkdir(configDir, { recursive: true })
45
72
 
46
73
  const config = await readOpenCodeConfig()
47
- const changed = addPlugin(config)
74
+ let configChanged = addPlugin(config)
75
+
76
+ // Inject ZAI MCP servers when GLM is detected
77
+ const allModels = detectAllModels(config)
78
+ const hasGLM = allModels.some((m) => isGLMModel(m))
79
+ if (hasGLM) {
80
+ const mcpChanged = await injectZAIMCPs(config)
81
+ configChanged = configChanged || mcpChanged
82
+ }
48
83
 
49
- if (changed) {
84
+ if (configChanged) {
50
85
  await writeFile(opencodeConfigPath, `${JSON.stringify(config, null, 2)}\n`)
51
- console.log(`[${packageName}] registered in ${opencodeConfigPath}`)
86
+ console.log(`[${packageName}] updated ${opencodeConfigPath}`)
52
87
  } else {
53
88
  console.log(`[${packageName}] already registered in ${opencodeConfigPath}`)
54
89
  }
@@ -67,9 +102,27 @@ async function createAdaptiveResolveConfig(opencodeConfig) {
67
102
  const example = JSON.parse(raw)
68
103
 
69
104
  const currentModel = detectOpenCodeModel(opencodeConfig)
70
- const preset = buildModelPreset(currentModel)
71
-
105
+ const allModels = detectAllModels(opencodeConfig)
72
106
  const resolveConfig = { ...example }
107
+
108
+ // Profile selection based on detected providers
109
+ const hasGLM = allModels.some((m) => isGLMModel(m))
110
+ const hasGPT = allModels.some((m) => isGPTModel(m))
111
+ const preset = buildModelPreset(currentModel, allModels)
112
+
113
+ if (hasGLM && !hasGPT) {
114
+ // GLM only → GLM profile, silver tier (token-efficient, no deep-reviewer)
115
+ resolveConfig.profile = "glm"
116
+ resolveConfig.tier = "silver"
117
+ } else if (hasGPT && !hasGLM) {
118
+ // GPT only → GPT profile, gold tier (full power)
119
+ resolveConfig.profile = "gpt"
120
+ resolveConfig.tier = "gold"
121
+ } else {
122
+ // Mixed or unknown provider → explicit mix profile. No tier: use DEFAULT_ENABLED.
123
+ resolveConfig.profile = "mix"
124
+ }
125
+
73
126
  if (preset && Object.keys(preset).length > 0) {
74
127
  resolveConfig.models = preset
75
128
  }
@@ -107,54 +160,155 @@ function detectOpenCodeModel(config) {
107
160
  return null
108
161
  }
109
162
 
110
- function buildModelPreset(currentModel) {
111
- if (!currentModel) return {}
112
-
113
- const lower = currentModel.toLowerCase()
163
+ function buildModelPreset(currentModel, allModels = []) {
164
+ const glmModel = allModels.find((m) => isGLMModel(m))
165
+ const gptModel = allModels.find((m) => isGPTModel(m))
114
166
 
115
- // GLM / ZAI mixed preset
116
- if (lower.includes("glm") || lower.includes("zai")) {
167
+ if (glmModel && gptModel) {
117
168
  return {
118
- glm: "zai-coding-plan/glm-5.1",
119
- gpt: "openai/gpt-5.5",
169
+ mix: "gpt",
170
+ glm: glmModel,
171
+ gpt: gptModel,
172
+ bronze: "glm",
173
+ silver: "glm",
174
+ gold: "gpt",
120
175
  fast: "glm",
121
176
  strong: "gpt",
122
- coder: "glm",
123
- resolver: "gpt",
124
- reviewer: "gpt",
125
- "deep-reviewer": "gpt",
126
- explorer: "fast",
177
+ mini: "glm",
178
+ codex: "gpt",
179
+ explorer: "bronze",
180
+ coder: "silver",
181
+ resolver: "gold",
182
+ reviewer: "gold",
183
+ "deep-reviewer": "gold",
184
+ planner: "gold",
127
185
  }
128
186
  }
129
187
 
188
+ if (!currentModel) {
189
+ if (glmModel) return buildGLMOnlyPreset(glmModel)
190
+ if (gptModel) return buildGPTOnlyPreset(gptModel)
191
+ return {}
192
+ }
193
+
194
+ const lower = currentModel.toLowerCase()
195
+
196
+ // GLM / ZAI — GLM-only preset (no GPT dependency, avoids token-exhaustion errors)
197
+ if (lower.includes("glm") || lower.includes("zai")) {
198
+ return buildGLMOnlyPreset(currentModel)
199
+ }
200
+
130
201
  // OpenAI / GPT single-provider preset
131
202
  if (lower.includes("openai/") || lower.includes("gpt")) {
132
- return {
133
- gpt: currentModel,
134
- fast: "gpt",
135
- strong: "gpt",
136
- mini: "gpt",
137
- codex: "gpt",
138
- coder: "gpt",
139
- resolver: "gpt",
140
- explorer: "gpt",
141
- reviewer: "gpt",
142
- "deep-reviewer": "gpt",
143
- }
203
+ return buildGPTOnlyPreset(currentModel)
144
204
  }
145
205
 
146
206
  // Unknown provider — keep model-neutral
147
207
  return {}
148
208
  }
149
209
 
210
+ function buildGLMOnlyPreset(model) {
211
+ return {
212
+ glm: model,
213
+ fast: "glm",
214
+ strong: "glm",
215
+ coder: "glm",
216
+ resolver: "glm",
217
+ reviewer: "glm",
218
+ "deep-reviewer": "glm",
219
+ explorer: "fast",
220
+ planner: "glm",
221
+ }
222
+ }
223
+
224
+ function buildGPTOnlyPreset(model) {
225
+ return {
226
+ gpt: model,
227
+ fast: "gpt",
228
+ strong: "gpt",
229
+ mini: "gpt",
230
+ codex: "gpt",
231
+ coder: "gpt",
232
+ resolver: "gpt",
233
+ explorer: "gpt",
234
+ reviewer: "gpt",
235
+ "deep-reviewer": "gpt",
236
+ }
237
+ }
238
+
150
239
  function getPresetLabel(currentModel) {
151
240
  if (!currentModel) return "inherited"
152
241
  const lower = currentModel.toLowerCase()
153
- if (lower.includes("glm") || lower.includes("zai")) return "glm+gpt"
242
+ if (lower.includes("glm") || lower.includes("zai")) return "glm-only"
154
243
  if (lower.includes("openai/") || lower.includes("gpt")) return "gpt-only"
155
244
  return "inherited"
156
245
  }
157
246
 
247
+ function isGLMModel(currentModel) {
248
+ if (!currentModel) return false
249
+ const lower = currentModel.toLowerCase()
250
+ return lower.includes("glm") || lower.includes("zai")
251
+ }
252
+
253
+ function isGPTModel(currentModel) {
254
+ if (!currentModel) return false
255
+ const lower = currentModel.toLowerCase()
256
+ return lower.includes("openai/") || lower.includes("gpt")
257
+ }
258
+
259
+ function detectAllModels(config) {
260
+ const models = new Set()
261
+
262
+ // Top-level model
263
+ if (typeof config.model === "string" && config.model.length > 0) {
264
+ models.add(config.model)
265
+ }
266
+
267
+ // Top-level models object values
268
+ if (isObject(config.models)) {
269
+ for (const value of Object.values(config.models)) {
270
+ if (typeof value === "string" && value.length > 0) {
271
+ models.add(value)
272
+ }
273
+ }
274
+ }
275
+
276
+ // Provider model lists
277
+ if (isObject(config.provider)) {
278
+ for (const [providerId, providerConfig] of Object.entries(config.provider)) {
279
+ if (isObject(providerConfig) && isObject(providerConfig.models)) {
280
+ for (const [modelKey, modelEntry] of Object.entries(providerConfig.models)) {
281
+ if (typeof modelKey === "string" && modelKey.length > 0) {
282
+ models.add(qualifyModelId(providerId, modelKey))
283
+ }
284
+ if (typeof modelEntry === "string") {
285
+ models.add(qualifyModelId(providerId, modelEntry))
286
+ } else if (isObject(modelEntry) && typeof modelEntry.id === "string") {
287
+ models.add(qualifyModelId(providerId, modelEntry.id))
288
+ }
289
+ }
290
+ }
291
+ }
292
+ }
293
+
294
+ // Agent model values
295
+ if (isObject(config.agent)) {
296
+ for (const agentConfig of Object.values(config.agent)) {
297
+ if (isObject(agentConfig) && typeof agentConfig.model === "string" && agentConfig.model.length > 0) {
298
+ models.add(agentConfig.model)
299
+ }
300
+ }
301
+ }
302
+
303
+ return [...models]
304
+ }
305
+
306
+ function qualifyModelId(providerId, modelId) {
307
+ if (typeof modelId !== "string" || modelId.length === 0) return modelId
308
+ if (modelId.includes("/")) return modelId
309
+ return `${providerId}/${modelId}`
310
+ }
311
+
158
312
  async function migrateResolveConfig() {
159
313
  let raw
160
314
  try {
@@ -225,6 +379,28 @@ function addPlugin(config) {
225
379
  return true
226
380
  }
227
381
 
382
+ async function injectZAIMCPs(config) {
383
+ config.mcp ??= {}
384
+ if (!isObject(config.mcp)) return false
385
+
386
+ const added = []
387
+ for (const [name, mcpConfig] of Object.entries(ZAI_MCP_SERVERS)) {
388
+ if (config.mcp[name] === undefined) {
389
+ config.mcp[name] = mcpConfig
390
+ added.push(name)
391
+ }
392
+ }
393
+
394
+ if (added.length > 0) {
395
+ console.log(`[${packageName}] injected ZAI MCP server config: ${added.join(", ")}`)
396
+ console.log(`[${packageName}] note: API keys are not copied into opencode.json; export Z_AI_API_KEY if the MCP server requires it.`)
397
+ return true
398
+ }
399
+
400
+ console.log(`[${packageName}] ZAI MCP server already present — skipping`)
401
+ return false
402
+ }
403
+
228
404
  function isRegisteredPluginEntry(entry) {
229
405
  if (typeof entry === "string") return isResolvePluginName(entry)
230
406
  if (Array.isArray(entry) && typeof entry[0] === "string") return isResolvePluginName(entry[0])
@@ -261,3 +437,107 @@ function isObject(value) {
261
437
  function formatError(error) {
262
438
  return error instanceof Error ? error.message : String(error)
263
439
  }
440
+
441
+ async function offerCompanionPlugins() {
442
+ if (process.env.OPENCODE_RESOLVE_SKIP_COMPANIONS === "1") return
443
+
444
+ const config = await readOpenCodeConfig()
445
+ const existing = collectPluginBaseNames(config.plugin ?? [])
446
+ const missing = COMPANION_PLUGINS.filter((c) => !existing.has(c.pkg))
447
+
448
+ if (missing.length === 0) {
449
+ console.log(`[${packageName}] recommended companion plugins already present — skipping`)
450
+ return
451
+ }
452
+
453
+ const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY)
454
+
455
+ if (!isInteractive) {
456
+ console.log(`[${packageName}] recommended companion plugins not detected:`)
457
+ for (const c of missing) {
458
+ console.log(` - ${c.pkg} — ${c.desc}`)
459
+ console.log(` install: opencode plugin ${c.pkg}@latest --global --force`)
460
+ }
461
+ return
462
+ }
463
+
464
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
465
+ try {
466
+ for (const c of missing) {
467
+ console.log("")
468
+ console.log(`[${packageName}] recommended companion: ${c.pkg}`)
469
+ console.log(` ${c.desc}`)
470
+ const raw = await rl.question(" install now? [Y/n] ")
471
+ const answer = raw.trim().toLowerCase()
472
+ const accepted = answer === "" || answer === "y" || answer === "yes"
473
+ if (!accepted) {
474
+ console.log(` skipped — install later via: opencode plugin ${c.pkg}@latest --global --force`)
475
+ continue
476
+ }
477
+ const installed = await installCompanion(c.pkg)
478
+ if (!installed) {
479
+ console.warn(` ${c.pkg} install command failed — leave plugin list untouched, retry manually`)
480
+ continue
481
+ }
482
+ await addCompanionToOpenCodeConfig(`${c.pkg}@latest`)
483
+ console.log(` ${c.pkg} cached and registered — restart OpenCode to activate`)
484
+ }
485
+ } finally {
486
+ rl.close()
487
+ }
488
+ }
489
+
490
+ function collectPluginBaseNames(plugins) {
491
+ const names = new Set()
492
+ for (const entry of plugins) {
493
+ const raw = typeof entry === "string"
494
+ ? entry
495
+ : Array.isArray(entry) && typeof entry[0] === "string"
496
+ ? entry[0]
497
+ : null
498
+ if (!raw) continue
499
+ names.add(stripVersionSuffix(raw))
500
+ }
501
+ return names
502
+ }
503
+
504
+ function stripVersionSuffix(name) {
505
+ if (name.startsWith("@")) {
506
+ const slashIndex = name.indexOf("/")
507
+ if (slashIndex === -1) return name
508
+ const scope = name.slice(0, slashIndex)
509
+ const rest = name.slice(slashIndex + 1)
510
+ return `${scope}/${rest.split("@")[0]}`
511
+ }
512
+ return name.split("@")[0]
513
+ }
514
+
515
+ async function installCompanion(pkg) {
516
+ return new Promise((resolveSpawn) => {
517
+ const child = spawn("opencode", ["plugin", `${pkg}@latest`, "--global", "--force"], {
518
+ stdio: "inherit",
519
+ })
520
+ child.on("exit", (code) => resolveSpawn(code === 0))
521
+ child.on("error", () => resolveSpawn(false))
522
+ })
523
+ }
524
+
525
+ async function addCompanionToOpenCodeConfig(pluginEntry) {
526
+ const config = await readOpenCodeConfig()
527
+ config.plugin ??= []
528
+ if (!Array.isArray(config.plugin)) {
529
+ throw new Error(`${opencodeConfigPath}.plugin must be an array`)
530
+ }
531
+ const baseName = stripVersionSuffix(pluginEntry)
532
+ const alreadyPresent = config.plugin.some((entry) => {
533
+ const raw = typeof entry === "string"
534
+ ? entry
535
+ : Array.isArray(entry) && typeof entry[0] === "string"
536
+ ? entry[0]
537
+ : null
538
+ return raw !== null && stripVersionSuffix(raw) === baseName
539
+ })
540
+ if (alreadyPresent) return
541
+ config.plugin.push(pluginEntry)
542
+ await writeFile(opencodeConfigPath, `${JSON.stringify(config, null, 2)}\n`)
543
+ }