@darthph0enix/commandcode-go-opencode-provider 0.5.0 → 0.6.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@darthph0enix/commandcode-go-opencode-provider",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "author": "Brent Weatherall",
5
5
  "repository": {
6
6
  "type": "git",
package/plugin.ts CHANGED
@@ -21,7 +21,18 @@ export default async function commandcodePlugin() {
21
21
  if (!cc.env) cc.env = ["COMMANDCODE_API_KEY"]
22
22
 
23
23
  if (!cc.models) {
24
- const { models } = await loadSyncedModels()
24
+ const options = (cc.options as Record<string, unknown> | undefined) ?? {}
25
+ const baseURL = typeof options.baseURL === "string" ? options.baseURL.replace(/\/+$/, "") : undefined
26
+ const catalogUrl =
27
+ typeof options.catalogUrl === "string"
28
+ ? options.catalogUrl
29
+ : baseURL
30
+ ? `${baseURL}/catalog.json`
31
+ : undefined
32
+ const { models, source } = await loadSyncedModels({ catalogUrl })
33
+ if (process.env.COMMANDCODE_DEBUG) {
34
+ console.warn(`[commandcode] injected ${models.length} models from ${source}`)
35
+ }
25
36
  const modelsObj: Record<string, unknown> = {}
26
37
  for (const entry of models) {
27
38
  const key = toConfigKey(entry.id)
package/src/model-sync.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, realpathSync, renameSync, writeFileSync } from "fs"
1
+ import { existsSync, readFileSync, readdirSync, realpathSync, renameSync, writeFileSync } from "fs"
2
2
  import { dirname, join } from "path"
3
3
  import { fileURLToPath } from "url"
4
4
  import { resolveApiKey } from "./auth.js"
@@ -55,6 +55,11 @@ function writeAtomic(file: string, data: string) {
55
55
  // Locate the model catalog bundled with the installed Command Code CLI.
56
56
  // The catalog is the vendor-maintained list for the same API endpoint this
57
57
  // provider talks to, so it is the most accurate metadata source available.
58
+ function bundledMdIn(root: string): string | null {
59
+ const candidate = join(root, "command-code", "dist", "bundled", "command-code-knowledge", "reference", "models.md")
60
+ return existsSync(candidate) ? candidate : null
61
+ }
62
+
58
63
  function findModelsMd(): string | null {
59
64
  const explicit = process.env.COMMANDCODE_MODELS_MD
60
65
  if (explicit && existsSync(explicit)) return explicit
@@ -80,6 +85,28 @@ function findModelsMd(): string | null {
80
85
  }
81
86
  }
82
87
  }
88
+
89
+ // Common global npm roots — PATH-independent so background services
90
+ // (OpenChamber, launchd/systemd) resolve the catalog even when the npm
91
+ // global bin dir is not exported into their environment.
92
+ const roots: string[] = []
93
+ const home = process.env.HOME
94
+ if (home) {
95
+ for (const base of [join(home, ".local", "share", "nvm"), join(home, ".nvm", "versions", "node"), join(home, ".npm-global", "lib")]) {
96
+ try {
97
+ for (const version of readdirSync(base)) {
98
+ roots.push(join(base, version, "lib", "node_modules"))
99
+ }
100
+ } catch {
101
+ // directory absent — skip
102
+ }
103
+ }
104
+ }
105
+ roots.push("/usr/local/lib/node_modules", "/usr/lib/node_modules", "/opt/homebrew/lib/node_modules")
106
+ for (const root of roots) {
107
+ const found = bundledMdIn(root)
108
+ if (found) return found
109
+ }
83
110
  return null
84
111
  }
85
112
 
@@ -247,6 +274,34 @@ export function parseModelsMd(text: string, hints: Map<string, ModelEntry>): Mod
247
274
  return entries
248
275
  }
249
276
 
277
+ // Server-managed master catalog. The server (keypool) serves the same
278
+ // enriched model list this provider builds locally, so clients fetch it at
279
+ // startup and always see current models/capabilities — no plugin update and
280
+ // no `op pull` needed when the catalog changes.
281
+ export async function fetchRemoteCatalog(url: string, timeoutMs = 1500): Promise<ModelEntry[] | null> {
282
+ const controller = new AbortController()
283
+ const timer = setTimeout(() => controller.abort(), timeoutMs)
284
+ try {
285
+ const response = await fetch(url, { signal: controller.signal, headers: { accept: "application/json" } })
286
+ if (!response.ok) return null
287
+ const payload = (await response.json()) as unknown
288
+ const list = Array.isArray(payload)
289
+ ? payload
290
+ : payload && typeof payload === "object" && Array.isArray((payload as { models?: unknown }).models)
291
+ ? (payload as { models: unknown[] }).models
292
+ : null
293
+ if (!list) return null
294
+ const entries = list.filter(
295
+ (entry): entry is ModelEntry => typeof entry === "object" && entry !== null && typeof (entry as { id?: unknown }).id === "string",
296
+ )
297
+ return entries.length >= 5 ? entries : null
298
+ } catch {
299
+ return null
300
+ } finally {
301
+ clearTimeout(timer)
302
+ }
303
+ }
304
+
250
305
  async function fetchApiModels(): Promise<ApiModel[] | null> {
251
306
  const apiKey = resolveApiKey({})
252
307
  const controller = new AbortController()
@@ -266,7 +321,7 @@ async function fetchApiModels(): Promise<ApiModel[] | null> {
266
321
  }
267
322
  }
268
323
 
269
- function mergeApiModels(entries: ModelEntry[], api: ApiModel[]): ModelEntry[] {
324
+ function mergeApiModels(entries: ModelEntry[], api: ApiModel[], hints?: Map<string, ModelEntry>): ModelEntry[] {
270
325
  const seen = new Map(entries.map((entry) => [entry.id.toLowerCase(), entry]))
271
326
  for (const model of api) {
272
327
  if (!model?.id) continue
@@ -278,15 +333,32 @@ function mergeApiModels(entries: ModelEntry[], api: ApiModel[]): ModelEntry[] {
278
333
  }
279
334
  continue
280
335
  }
281
- const entry: ModelEntry = {
282
- id: model.id,
283
- name: model.name?.trim() || model.id.split("/").pop() || model.id,
284
- tier: "open-source",
285
- reasoning: true,
286
- tool_call: true,
287
- cost: { input: 0, output: 0 },
288
- limit: { context: model.context_length && model.context_length > 0 ? model.context_length : 200000, output: 65536 },
289
- }
336
+ // Prefer the bundled snapshot's metadata (plan badge, variants, vision) so
337
+ // machines without the CLI catalog still get full capabilities.
338
+ const hint = hints?.get(key)
339
+ const entry: ModelEntry = hint
340
+ ? {
341
+ ...hint,
342
+ modalities: hint.modalities ? { input: [...hint.modalities.input], output: [...hint.modalities.output] } : undefined,
343
+ variants: hint.variants ? { ...hint.variants } : undefined,
344
+ reasoningEfforts: hint.reasoningEfforts ? [...hint.reasoningEfforts] : undefined,
345
+ limit: {
346
+ context:
347
+ model.context_length && model.context_length > 0
348
+ ? model.context_length
349
+ : hint.limit.context,
350
+ output: hint.limit.output,
351
+ },
352
+ }
353
+ : {
354
+ id: model.id,
355
+ name: model.name?.trim() || model.id.split("/").pop() || model.id,
356
+ tier: "open-source",
357
+ reasoning: true,
358
+ tool_call: true,
359
+ cost: { input: 0, output: 0 },
360
+ limit: { context: model.context_length && model.context_length > 0 ? model.context_length : 200000, output: 65536 },
361
+ }
290
362
  entries.push(entry)
291
363
  seen.set(key, entry)
292
364
  }
@@ -294,44 +366,68 @@ function mergeApiModels(entries: ModelEntry[], api: ApiModel[]): ModelEntry[] {
294
366
  }
295
367
 
296
368
  // Build the model list fresh on every opencode instance start:
297
- // 1. CLI-bundled catalog (vendor-maintained, refreshed by `cmd update`)
298
- // 2. Command Code's public model endpoint (picks up brand-new models)
299
- // 3. last synced cache, then the bundled snapshot as an offline fallback.
300
- export async function loadSyncedModels(): Promise<SyncResult> {
369
+ // 1. REMOTE master catalog (server-managed; always current — primary source
370
+ // so client devices never depend on a local CLI install for metadata)
371
+ // 2. local CLI-bundled catalog + Command Code's public model endpoint
372
+ // (used to append models newer than the server catalog, and as the
373
+ // offline fallback when the server is unreachable)
374
+ // 3. last synced cache, then the bundled snapshot as a final fallback.
375
+ export async function loadSyncedModels(options?: { catalogUrl?: string }): Promise<SyncResult> {
301
376
  const bundled = loadCachedModels(BUNDLED_MODELS)
302
377
  const cached = loadCachedModels(CACHE_MODELS)
303
378
  const hints = new Map<string, ModelEntry>()
304
379
  for (const entry of [...(cached ?? []), ...(bundled ?? [])]) hints.set(entry.id.toLowerCase(), entry)
305
380
 
306
- let docModels: ModelEntry[] | null = null
381
+ let localModels: ModelEntry[] | null = null
382
+ let localSource = ""
307
383
  try {
308
384
  const modelsMd = findModelsMd()
309
385
  if (modelsMd) {
310
386
  const parsed = parseModelsMd(readFileSync(modelsMd, "utf-8"), hints)
311
- if (parsed.length >= 5) docModels = parsed
387
+ if (parsed.length >= 5) {
388
+ const apiModels = await fetchApiModels()
389
+ localModels = mergeApiModels(parsed, apiModels ?? [], hints)
390
+ localSource = apiModels?.length ? "cli-catalog+api" : "cli-catalog"
391
+ }
312
392
  }
313
393
  } catch {
314
- // intentionally silent: fall through to the API or cache
394
+ // intentionally silent: fall through to the API or remote catalog
395
+ }
396
+ if (!localModels?.length) {
397
+ const apiModels = await fetchApiModels()
398
+ if (apiModels?.length) {
399
+ localModels = mergeApiModels([], apiModels, hints)
400
+ localSource = "api"
401
+ }
315
402
  }
316
-
317
- const apiModels = await fetchApiModels()
318
403
 
319
404
  let models: ModelEntry[] | null = null
320
405
  let source = ""
321
- if (docModels?.length) {
322
- models = mergeApiModels(docModels, apiModels ?? [])
323
- source = apiModels?.length ? "cli-catalog+api" : "cli-catalog"
324
- } else if (apiModels?.length) {
325
- models = mergeApiModels([], apiModels)
326
- source = "api"
406
+ if (options?.catalogUrl) {
407
+ const remote = await fetchRemoteCatalog(options.catalogUrl)
408
+ if (remote) {
409
+ models = remote
410
+ source = "remote"
411
+ if (localModels?.length) {
412
+ const seen = new Set(remote.map((entry) => entry.id.toLowerCase()))
413
+ const extra = localModels.filter((entry) => !seen.has(entry.id.toLowerCase()))
414
+ if (extra.length) {
415
+ models = [...remote, ...extra]
416
+ source = "remote+local"
417
+ }
418
+ }
419
+ }
420
+ }
421
+ if (!models?.length && localModels?.length) {
422
+ models = localModels
423
+ source = localSource
327
424
  }
328
-
329
425
  if (!models?.length) {
330
426
  models = cached ?? bundled ?? []
331
427
  source = cached ? "cache" : "bundled"
332
428
  }
333
429
 
334
- if (!models.length) throw new Error("Command Code model catalog unavailable (no CLI, API, cache, or bundled list)")
430
+ if (!models.length) throw new Error("Command Code model catalog unavailable (no remote, CLI, API, cache, or bundled list)")
335
431
 
336
432
  if (source !== "cache" && source !== "bundled") {
337
433
  try {
@@ -341,5 +437,9 @@ export async function loadSyncedModels(): Promise<SyncResult> {
341
437
  }
342
438
  }
343
439
 
440
+ if (process.env.COMMANDCODE_DEBUG) {
441
+ console.warn(`[commandcode] catalog source=${source} models=${models.length}`)
442
+ }
443
+
344
444
  return { models, source }
345
445
  }