dsh-opencode 0.1.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/lib/catalog.js ADDED
@@ -0,0 +1,499 @@
1
+ import { METADATA_PROVIDER_GO, METADATA_PROVIDER_ZEN, SOURCES, joinProduct, parseMetadataProvider, parseOfficialList } from "./normalize.js";
2
+ import { loadCache, restoreOfficialList, saveCache } from "./cache.js";
3
+ import { buildSnapshot } from "./snapshot.js";
4
+ //#region src/catalog.ts
5
+ /** How long one source's decoded body may grow before the read is cut off. */
6
+ const OFFICIAL_LIST_MAX_BYTES = 1024 * 1024;
7
+ const MODELS_DEV_MAX_BYTES = 32 * 1024 * 1024;
8
+ /** Small delay jitter ratio for periodic refresh (multi-profile thundering herd). */
9
+ const REFRESH_JITTER_RATIO = .1;
10
+ /** Maximum retained length for one diagnostic message. */
11
+ const MAX_MESSAGE_CHARS = 300;
12
+ /**
13
+ * The catalog manager. Construct, `start()`, and `stop()` with the plugin
14
+ * fiber; every other method is safe from any async context but must not be
15
+ * called after `stop()`.
16
+ */
17
+ var CatalogManager = class {
18
+ fetchImpl;
19
+ now;
20
+ onChange;
21
+ warn;
22
+ configValue;
23
+ sources = /* @__PURE__ */ new Map();
24
+ snapshot;
25
+ inFlight = /* @__PURE__ */ new Map();
26
+ timer;
27
+ lifecycle = new AbortController();
28
+ disposed = false;
29
+ firstListResolve;
30
+ constructor(options) {
31
+ this.configValue = options.config;
32
+ this.fetchImpl = options.fetch ?? ((url, init) => fetch(url, init));
33
+ this.now = options.now ?? (() => Date.now());
34
+ this.onChange = options.onChange;
35
+ this.warn = options.warn ?? (() => {});
36
+ this.sources.set("zen-list", { state: {} });
37
+ this.sources.set("go-list", { state: {} });
38
+ this.sources.set("models-dev", { state: {} });
39
+ }
40
+ /** The active catalog configuration. */
41
+ get config() {
42
+ return this.configValue;
43
+ }
44
+ /** The currently published snapshot, if one has been built. */
45
+ get current() {
46
+ return this.snapshot;
47
+ }
48
+ /**
49
+ * Restore cached source payloads (if valid), publish an initial snapshot,
50
+ * run the first refresh to completion, and arm the periodic timer.
51
+ * The composition never awaits this; tests do, for determinism.
52
+ */
53
+ async start() {
54
+ if (this.disposed) return;
55
+ await this.restoreFromCache();
56
+ this.publish();
57
+ await this.refresh();
58
+ if (this.disposed) return;
59
+ this.scheduleNextRefresh();
60
+ }
61
+ /**
62
+ * Stop timers, abort in-flight fetches, and refuse every late completion:
63
+ * a disposed manager never publishes, saves, or re-arms.
64
+ */
65
+ stop() {
66
+ this.disposed = true;
67
+ this.lifecycle.abort();
68
+ if (this.timer !== void 0) clearTimeout(this.timer);
69
+ this.timer = void 0;
70
+ this.inFlight.clear();
71
+ }
72
+ /** Apply new catalog configuration values; re-arms the periodic timer. */
73
+ reconfigure(config) {
74
+ if (this.disposed) return;
75
+ this.configValue = { ...config };
76
+ this.scheduleNextRefresh();
77
+ }
78
+ /**
79
+ * Refresh the given products' sources (all when omitted). Concurrent calls
80
+ * coalesce per source: a fetch already in flight is joined, not repeated.
81
+ * `force` bypasses nothing here — single-flight is the TTL — the caller
82
+ * uses the flag only to skip its own TTL checks.
83
+ * @param options - which products to cover and a cancellation signal.
84
+ */
85
+ async refresh(options = {}) {
86
+ if (this.disposed) return;
87
+ const products = options.products ?? ["zen", "go"];
88
+ const wantsZen = products.includes("zen");
89
+ const wantsGo = products.includes("go");
90
+ const tasks = [];
91
+ if (wantsZen) tasks.push(this.singleFlight("zen", "zen-list", options.signal));
92
+ if (wantsGo) tasks.push(this.singleFlight("go", "go-list", options.signal));
93
+ if (wantsZen || wantsGo) tasks.push(this.singleFlight("both", "models-dev", options.signal));
94
+ await Promise.all(tasks);
95
+ }
96
+ /**
97
+ * Force one refresh for a product and wait for it, joining any fetch that
98
+ * is already running. Used by the unknown-model path; the "once" budget is
99
+ * the caller's to enforce per selection attempt.
100
+ * @param product - the product whose sources must refresh.
101
+ */
102
+ async forceRefreshOnce(product) {
103
+ await this.refresh({ products: [product] });
104
+ }
105
+ /**
106
+ * Bounded wait for the first usable data. Resolves immediately when any
107
+ * source has ever validated (including a restored cache); otherwise waits
108
+ * up to `timeoutMs` for the in-flight first refresh.
109
+ * @param timeoutMs - the wait bound.
110
+ * @returns whether any data is available when the wait ends.
111
+ */
112
+ async ensureInitial(timeoutMs) {
113
+ if (this.hasAnySuccess()) return { gotData: true };
114
+ if (this.disposed) return { gotData: false };
115
+ return { gotData: await new Promise((resolve) => {
116
+ let settled = false;
117
+ const done = (value) => {
118
+ if (settled) return;
119
+ settled = true;
120
+ this.firstListResolve = void 0;
121
+ clearTimeout(handle);
122
+ resolve(value);
123
+ };
124
+ this.firstListResolve = () => done(true);
125
+ const handle = setTimeout(() => done(false), timeoutMs);
126
+ if (this.hasAnySuccess()) done(true);
127
+ }) };
128
+ }
129
+ /**
130
+ * Trigger a background revalidation of one product's official list when the
131
+ * last check is older than the revalidation TTL. Non-blocking: selection
132
+ * latency must not depend on a network round trip.
133
+ * @param product - the product whose list is being displayed or selected.
134
+ */
135
+ revalidateIfNeeded(product) {
136
+ if (this.disposed) return;
137
+ const lastCheckedAt = (this.sources.get(product === "zen" ? "zen-list" : "go-list")?.state)?.lastCheckedAt;
138
+ if (lastCheckedAt !== void 0 && this.now() - lastCheckedAt < this.config.listRevalidateAfterMs) return;
139
+ this.refresh({ products: [product] }).catch(() => {});
140
+ }
141
+ /** Whether any source has ever validated for this instance. */
142
+ hasAnySuccess() {
143
+ for (const source of this.sources.values()) if (source.state.lastSuccessfulAt !== void 0) return true;
144
+ return false;
145
+ }
146
+ /** Single-flight wrapper: one running fetch per source key. */
147
+ singleFlight(scope, sourceId, signal) {
148
+ const running = this.inFlight.get(sourceId);
149
+ if (running !== void 0) return running;
150
+ const task = this.runFetch(scope, sourceId, signal).catch((error) => {
151
+ if (!this.disposed) this.warn(`opencode-live: refreshing "${sourceId}" failed unexpectedly: ${describeError(error)}`);
152
+ }).finally(() => {
153
+ if (this.inFlight.get(sourceId) === task) this.inFlight.delete(sourceId);
154
+ });
155
+ this.inFlight.set(sourceId, task);
156
+ return task;
157
+ }
158
+ /** Fetch one source, update its runtime state, and publish. */
159
+ async runFetch(scope, sourceId, signal) {
160
+ const source = this.sources.get(sourceId);
161
+ if (source === void 0) return;
162
+ const startedAt = this.now();
163
+ source.state = {
164
+ ...source.state,
165
+ lastCheckedAt: startedAt
166
+ };
167
+ const outcome = await this.fetchSource(sourceId, source, signal);
168
+ if (this.disposed) return;
169
+ if (outcome.kind === "failed") source.state = {
170
+ ...source.state,
171
+ lastErrorCode: outcome.code,
172
+ lastErrorMessage: outcome.message.slice(0, MAX_MESSAGE_CHARS)
173
+ };
174
+ else {
175
+ const { lastErrorCode: _code, lastErrorMessage: _message, ...prior } = source.state;
176
+ source.state = {
177
+ ...prior,
178
+ lastSuccessfulAt: this.now(),
179
+ ..."etag" in outcome && outcome.etag !== void 0 ? { etag: outcome.etag } : {}
180
+ };
181
+ if (outcome.kind === "official") source.official = outcome.list;
182
+ if (outcome.kind === "metadata") {
183
+ source.providers = outcome.providers;
184
+ source.rawProviders = collectRawProviders(outcome.providers);
185
+ }
186
+ if (sourceId !== "models-dev") this.firstListResolve?.();
187
+ }
188
+ this.publish();
189
+ await this.persistCache(sourceId);
190
+ }
191
+ /** Perform the HTTP fetch and payload validation for one source. */
192
+ async fetchSource(sourceId, source, signal) {
193
+ const isMetadata = sourceId === "models-dev";
194
+ const url = isMetadata ? SOURCES.metadataUrl : sourceId === "zen-list" ? SOURCES.zen.modelsUrl : SOURCES.go.modelsUrl;
195
+ const headers = {};
196
+ if (source.state.etag !== void 0) headers["if-none-match"] = source.state.etag;
197
+ const maxBytes = isMetadata ? MODELS_DEV_MAX_BYTES : OFFICIAL_LIST_MAX_BYTES;
198
+ const controller = new AbortController();
199
+ const abort = () => controller.abort();
200
+ signal?.addEventListener("abort", abort, { once: true });
201
+ this.lifecycle.signal.addEventListener("abort", abort, { once: true });
202
+ const timeout = setTimeout(abort, this.config.timeoutMs);
203
+ try {
204
+ const response = await this.fetchImpl(url, {
205
+ method: "GET",
206
+ headers,
207
+ signal: controller.signal,
208
+ redirect: "error"
209
+ });
210
+ if (response.status === 304) return { kind: "unchanged" };
211
+ if (!response.ok) return {
212
+ kind: "failed",
213
+ code: "HTTP_STATUS",
214
+ message: `GET ${url} answered HTTP ${response.status}`
215
+ };
216
+ const body = await readBoundedBody(response, maxBytes);
217
+ let parsed;
218
+ try {
219
+ parsed = JSON.parse(new TextDecoder().decode(body));
220
+ } catch {
221
+ return {
222
+ kind: "failed",
223
+ code: "INVALID_JSON",
224
+ message: `GET ${url} returned a body that is not JSON`
225
+ };
226
+ }
227
+ if (isMetadata) {
228
+ const providers = /* @__PURE__ */ new Map();
229
+ for (const providerId of [METADATA_PROVIDER_ZEN, METADATA_PROVIDER_GO]) {
230
+ const parsedProvider = parseMetadataProvider(parsed, providerId);
231
+ if (!parsedProvider.ok) return {
232
+ kind: "failed",
233
+ code: parsedProvider.code,
234
+ message: `models.dev slice "${providerId}": ${parsedProvider.message}`
235
+ };
236
+ providers.set(providerId, parsedProvider.value);
237
+ }
238
+ return {
239
+ kind: "metadata",
240
+ providers,
241
+ ...readEtag(response)
242
+ };
243
+ }
244
+ const parsedList = parseOfficialList(parsed);
245
+ if (!parsedList.ok) return {
246
+ kind: "failed",
247
+ code: parsedList.code,
248
+ message: `${url}: ${parsedList.message}`
249
+ };
250
+ if (parsedList.value.models.size === 0) return {
251
+ kind: "failed",
252
+ code: "EMPTY_LIST",
253
+ message: `${url} listed zero models`
254
+ };
255
+ return {
256
+ kind: "official",
257
+ list: parsedList.value,
258
+ ...readEtag(response)
259
+ };
260
+ } catch (error) {
261
+ if (signal?.aborted) return {
262
+ kind: "failed",
263
+ code: "ABORTED",
264
+ message: "caller cancelled the refresh"
265
+ };
266
+ if (this.disposed) return {
267
+ kind: "failed",
268
+ code: "ABORTED",
269
+ message: "plugin unloaded during refresh"
270
+ };
271
+ if (controller.signal.aborted) return {
272
+ kind: "failed",
273
+ code: "TIMEOUT",
274
+ message: `GET ${url} exceeded ${this.config.timeoutMs}ms`
275
+ };
276
+ if (error?.code === "TOO_LARGE") return {
277
+ kind: "failed",
278
+ code: "TOO_LARGE",
279
+ message: `GET ${url} body exceeded the ${maxBytes}-byte limit`
280
+ };
281
+ return {
282
+ kind: "failed",
283
+ code: "NETWORK",
284
+ message: `GET ${url} failed: ${describeError(error)}`
285
+ };
286
+ } finally {
287
+ clearTimeout(timeout);
288
+ signal?.removeEventListener("abort", abort);
289
+ }
290
+ }
291
+ /** Publish the current join over every source's best available data. */
292
+ publish() {
293
+ const now = this.now();
294
+ const states = /* @__PURE__ */ new Map([
295
+ ["zen-list", Object.freeze({ ...this.sources.get("zen-list")?.state })],
296
+ ["go-list", Object.freeze({ ...this.sources.get("go-list")?.state })],
297
+ ["models-dev", Object.freeze({ ...this.sources.get("models-dev")?.state })]
298
+ ]);
299
+ const previous = this.snapshot;
300
+ const products = {
301
+ zen: joinProduct("zen", {
302
+ official: this.officialInput("zen-list"),
303
+ metadata: this.metadataInput(METADATA_PROVIDER_ZEN),
304
+ previousOfficialIds: previousOfficialIds(previous, "zen")
305
+ }),
306
+ go: joinProduct("go", {
307
+ official: this.officialInput("go-list"),
308
+ metadata: this.metadataInput(METADATA_PROVIDER_GO),
309
+ previousOfficialIds: previousOfficialIds(previous, "go")
310
+ })
311
+ };
312
+ this.snapshot = buildSnapshot(previous, {
313
+ products,
314
+ sources: states,
315
+ maxStaleMs: this.config.maxStaleMs,
316
+ now
317
+ });
318
+ this.onChange?.(this.snapshot);
319
+ }
320
+ /** The official-list join input for one product, if any was ever validated. */
321
+ officialInput(sourceId) {
322
+ const source = this.sources.get(sourceId);
323
+ if (source === void 0 || source.official === void 0) return void 0;
324
+ return {
325
+ list: source.official,
326
+ ...source.state.lastCheckedAt !== void 0 ? { checkedAt: source.state.lastCheckedAt } : {},
327
+ ...source.state.lastSuccessfulAt !== void 0 ? { successfulAt: source.state.lastSuccessfulAt } : {}
328
+ };
329
+ }
330
+ /** The metadata join input for one Models.dev provider, if ever validated. */
331
+ metadataInput(providerId) {
332
+ const source = this.sources.get("models-dev");
333
+ const provider = source?.providers?.get(providerId);
334
+ if (source === void 0 || provider === void 0) return void 0;
335
+ return {
336
+ provider,
337
+ ...source.state.lastCheckedAt !== void 0 ? { checkedAt: source.state.lastCheckedAt } : {},
338
+ ...source.state.lastSuccessfulAt !== void 0 ? { successfulAt: source.state.lastSuccessfulAt } : {}
339
+ };
340
+ }
341
+ /** Persist the source that just completed; failures never block the catalog. */
342
+ async persistCache(sourceId) {
343
+ const path = this.config.cachePath;
344
+ if (path === void 0 || this.disposed) return;
345
+ const zen = this.sources.get("zen-list");
346
+ const go = this.sources.get("go-list");
347
+ const metadata = this.sources.get("models-dev");
348
+ try {
349
+ await saveCache(path, {
350
+ ...zen?.official !== void 0 ? { zenList: {
351
+ state: zen.state,
352
+ ids: [...zen.official.models.keys()]
353
+ } } : {},
354
+ ...go?.official !== void 0 ? { goList: {
355
+ state: go.state,
356
+ ids: [...go.official.models.keys()]
357
+ } } : {},
358
+ ...metadata?.providers !== void 0 && metadata.rawProviders !== void 0 ? { modelsDev: {
359
+ state: metadata.state,
360
+ providers: metadata.rawProviders
361
+ } } : {}
362
+ }, this.now());
363
+ } catch (error) {
364
+ this.warn(`opencode-live: saving the catalog cache failed: ${describeError(error)}`);
365
+ }
366
+ }
367
+ /** Restore cached source payloads and their freshness facts. */
368
+ async restoreFromCache() {
369
+ const path = this.config.cachePath;
370
+ if (path === void 0) return;
371
+ const cached = await loadCache(path);
372
+ if (cached === void 0 || this.disposed) return;
373
+ if (cached.zenList !== void 0) {
374
+ const zen = this.sources.get("zen-list");
375
+ if (zen !== void 0) {
376
+ zen.official = restoreOfficialList("zen", cached.zenList);
377
+ zen.state = { ...cached.zenList.state };
378
+ }
379
+ }
380
+ if (cached.goList !== void 0) {
381
+ const go = this.sources.get("go-list");
382
+ if (go !== void 0) {
383
+ go.official = restoreOfficialList("go", cached.goList);
384
+ go.state = { ...cached.goList.state };
385
+ }
386
+ }
387
+ if (cached.modelsDev !== void 0) {
388
+ const metadata = this.sources.get("models-dev");
389
+ if (metadata !== void 0) {
390
+ const providers = /* @__PURE__ */ new Map();
391
+ for (const providerId of [METADATA_PROVIDER_ZEN, METADATA_PROVIDER_GO]) {
392
+ const raw = cached.modelsDev.providers[providerId];
393
+ if (raw === void 0) continue;
394
+ const parsed = parseMetadataProvider({ [providerId]: raw }, providerId);
395
+ if (parsed.ok) providers.set(providerId, parsed.value);
396
+ }
397
+ if (providers.size > 0) {
398
+ metadata.providers = providers;
399
+ metadata.rawProviders = { ...cached.modelsDev.providers };
400
+ metadata.state = { ...cached.modelsDev.state };
401
+ }
402
+ }
403
+ }
404
+ }
405
+ /** Arm the next periodic refresh with jitter. */
406
+ scheduleNextRefresh() {
407
+ if (this.disposed) return;
408
+ if (this.timer !== void 0) clearTimeout(this.timer);
409
+ const base = this.config.refreshIntervalMs;
410
+ const jitter = base * REFRESH_JITTER_RATIO * Math.random();
411
+ this.timer = setTimeout(() => {
412
+ if (this.disposed) return;
413
+ this.refresh().catch(() => {});
414
+ this.scheduleNextRefresh();
415
+ }, Math.min(base + jitter, Number.MAX_SAFE_INTEGER));
416
+ }
417
+ };
418
+ /** Read the response's ETag header when present. */
419
+ function readEtag(response) {
420
+ const etag = response.headers.get("etag");
421
+ return etag !== null && etag.length > 0 ? { etag } : {};
422
+ }
423
+ /**
424
+ * Read a response body with a hard cap on decoded bytes. Content-Length is
425
+ * advisory (it sizes the compressed body); the real budget applies to what
426
+ * this process actually reads.
427
+ */
428
+ async function readBoundedBody(response, maxBytes) {
429
+ const contentLength = response.headers.get("content-length");
430
+ if (contentLength !== null) {
431
+ const declared = Number(contentLength);
432
+ if (Number.isFinite(declared) && declared > maxBytes) throw Object.assign(/* @__PURE__ */ new Error(`body exceeds ${maxBytes} bytes`), { code: "TOO_LARGE" });
433
+ }
434
+ const body = response.body;
435
+ if (body === null) throw Object.assign(/* @__PURE__ */ new Error("empty response body"), { code: "EMPTY_BODY" });
436
+ const reader = body.getReader();
437
+ const chunks = [];
438
+ let total = 0;
439
+ while (true) {
440
+ const { done, value } = await reader.read();
441
+ if (done) break;
442
+ total += value.byteLength;
443
+ if (total > maxBytes) {
444
+ await reader.cancel();
445
+ throw Object.assign(/* @__PURE__ */ new Error(`body exceeds ${maxBytes} decoded bytes`), { code: "TOO_LARGE" });
446
+ }
447
+ chunks.push(value);
448
+ }
449
+ const merged = new Uint8Array(total);
450
+ let offset = 0;
451
+ for (const chunk of chunks) {
452
+ merged.set(chunk, offset);
453
+ offset += chunk.byteLength;
454
+ }
455
+ return merged;
456
+ }
457
+ /** The ids a previous snapshot confirmed against one product's official list. */
458
+ function previousOfficialIds(snapshot, product) {
459
+ const ids = /* @__PURE__ */ new Set();
460
+ const view = snapshot?.products[product];
461
+ if (view === void 0) return ids;
462
+ for (const candidate of view.candidates.values()) if (candidate.state !== "catalog-only" && candidate.state !== "removed") ids.add(candidate.id);
463
+ return ids;
464
+ }
465
+ /** Extract raw provider slices for cache storage. */
466
+ function collectRawProviders(providers) {
467
+ const raw = {};
468
+ for (const [id, provider] of providers) {
469
+ const models = {};
470
+ for (const [modelId, model] of provider.models) models[modelId] = {
471
+ ...model.name === void 0 ? {} : { name: model.name },
472
+ ...model.contextWindow === void 0 ? {} : { limit: {
473
+ context: model.contextWindow,
474
+ ...model.maxOutputTokens === void 0 ? {} : { output: model.maxOutputTokens }
475
+ } },
476
+ ...model.input === void 0 ? {} : { modalities: { input: [...model.input] } },
477
+ ...model.tools === "unknown" ? {} : { tool_call: model.tools },
478
+ ...model.reasoning === "unknown" ? {} : { reasoning: model.reasoning },
479
+ ...model.reasoningEfforts === void 0 ? {} : { reasoning_options: [{
480
+ type: "effort",
481
+ values: [...model.reasoningEfforts]
482
+ }] },
483
+ ...model.cost === void 0 ? {} : { cost: model.cost },
484
+ ...model.npm === void 0 ? {} : { provider: { npm: model.npm } }
485
+ };
486
+ raw[id] = {
487
+ ...provider.npm === void 0 ? {} : { npm: provider.npm },
488
+ models
489
+ };
490
+ }
491
+ return raw;
492
+ }
493
+ /** One-line safe description of an unknown error. */
494
+ function describeError(error) {
495
+ if (error instanceof Error) return error.message;
496
+ return String(error);
497
+ }
498
+ //#endregion
499
+ export { CatalogManager };
@@ -0,0 +1,177 @@
1
+ import { PRODUCT_BY_ROUTE, ROUTE_BY_PRODUCT, describeNonReadyState } from "./normalize.js";
2
+ //#region src/commands.ts
3
+ const USAGE_REFRESH = "Usage: /opencode-refresh [all|zen|go]";
4
+ const USAGE_MODELS = "Usage: /opencode-models <zen|go> [--all]";
5
+ const USAGE_ENABLE = "Usage: /dsh-opencode — never pass the API key in the command line";
6
+ /** Whether one date stamp renders as a short local time. */
7
+ function renderTime(timestamp) {
8
+ if (timestamp === void 0) return "never";
9
+ return new Date(timestamp).toISOString().replace(/\.\d{3}Z$/, "Z");
10
+ }
11
+ /** One product's status lines. */
12
+ async function productStatus(ctx, services, product) {
13
+ const route = ROUTE_BY_PRODUCT[product];
14
+ const view = services.catalog.current?.products[product];
15
+ const facts = await services.describeCredential(route);
16
+ const lines = [
17
+ `${route} (${product}):`,
18
+ ` credential: ${facts === void 0 ? "unknown (no credentials service)" : facts.configured ? "configured" : "not configured"}`,
19
+ ` models ready: ${view?.counts.ready ?? 0}, pending: ${(view?.counts["metadata-pending"] ?? 0) + (view?.counts["unsupported-protocol"] ?? 0)}, catalog-only: ${view?.counts["catalog-only"] ?? 0}, removed: ${view?.counts.removed ?? 0}`
20
+ ];
21
+ if (view !== void 0) lines.push(` official list: ${view.officialConfirmed ? view.stale ? "stale" : "fresh" : "never confirmed"}, last success ${renderTime(services.catalog.current?.sources.get(product === "zen" ? "zen-list" : "go-list")?.lastSuccessfulAt)}`);
22
+ const sources = services.catalog.current?.sources;
23
+ if (sources !== void 0) {
24
+ for (const [id, state] of sources) if (state.lastErrorCode !== void 0) lines.push(` ${id}: last error ${state.lastErrorCode} at ${renderTime(state.lastCheckedAt)}`);
25
+ }
26
+ return lines.join("\n");
27
+ }
28
+ /** One candidate's display line for the models listing. */
29
+ function candidateLine(candidate, all) {
30
+ if (candidate.state === "ready" && !all) return ` ${candidate.id} — ${candidate.name}${candidate.api === void 0 ? "" : ` [${candidate.api}]`}`;
31
+ if (!all) return "";
32
+ const detail = candidate.state === "ready" ? `ready [${candidate.api ?? "unknown"}]` : describeNonReadyState(candidate);
33
+ return ` ${candidate.id} — ${candidate.name} (${detail})`;
34
+ }
35
+ /** Parse one product argument. */
36
+ function parseProduct(raw) {
37
+ if (raw === "zen") return "zen";
38
+ if (raw === "go") return "go";
39
+ }
40
+ /** Build the three command definitions. */
41
+ function commandDefinitions(ctx, services) {
42
+ return [
43
+ {
44
+ name: "opencode-refresh",
45
+ description: "Refresh the OpenCode Zen/Go model catalogs",
46
+ recordInput: false,
47
+ handler: async (invocation) => {
48
+ const arg = invocation.rawInput.trim();
49
+ const products = arg === "" || arg === "all" ? ["zen", "go"] : parseProduct(arg) !== void 0 ? [parseProduct(arg)] : void 0;
50
+ if (products === void 0) return {
51
+ kind: "error",
52
+ text: USAGE_REFRESH
53
+ };
54
+ if (invocation.signal.aborted) return {
55
+ kind: "success",
56
+ text: "Refresh cancelled."
57
+ };
58
+ try {
59
+ await services.catalog.refresh({
60
+ products,
61
+ signal: invocation.signal
62
+ });
63
+ } catch {}
64
+ const lines = ["Catalog refresh finished."];
65
+ for (const product of products) lines.push(await productStatus(ctx, services, product));
66
+ return {
67
+ kind: "success",
68
+ text: lines.join("\n")
69
+ };
70
+ }
71
+ },
72
+ {
73
+ name: "opencode-status",
74
+ description: "Show OpenCode live catalog and credential status",
75
+ recordInput: false,
76
+ handler: async (invocation) => {
77
+ if (invocation.rawInput.trim().length > 0) return {
78
+ kind: "error",
79
+ text: "Usage: /opencode-status"
80
+ };
81
+ const lines = ["OpenCode live catalog status:"];
82
+ lines.push(await productStatus(ctx, services, "zen"));
83
+ lines.push(await productStatus(ctx, services, "go"));
84
+ const metadata = services.catalog.current?.sources.get("models-dev");
85
+ if (metadata !== void 0) lines.push(`models.dev metadata: last success ${renderTime(metadata.lastSuccessfulAt)}${metadata.lastErrorCode === void 0 ? "" : `, last error ${metadata.lastErrorCode}`}`);
86
+ return {
87
+ kind: "success",
88
+ text: lines.join("\n")
89
+ };
90
+ }
91
+ },
92
+ {
93
+ name: "opencode-models",
94
+ description: "List OpenCode live models, including non-ready candidates with --all",
95
+ recordInput: false,
96
+ handler: async (invocation) => {
97
+ const parts = invocation.rawInput.trim().split(/\s+/).filter((part) => part.length > 0);
98
+ if (parts.length === 0 || parts.length > 2) return {
99
+ kind: "error",
100
+ text: USAGE_MODELS
101
+ };
102
+ const product = parseProduct(parts[0]);
103
+ if (product === void 0) return {
104
+ kind: "error",
105
+ text: USAGE_MODELS
106
+ };
107
+ const all = parts[1] === "--all";
108
+ if (!all && parts.length === 2) return {
109
+ kind: "error",
110
+ text: USAGE_MODELS
111
+ };
112
+ const snapshot = services.catalog.current;
113
+ if (snapshot === void 0) return {
114
+ kind: "success",
115
+ text: `No catalog data for "${product}" yet; try /opencode-refresh ${product}.`
116
+ };
117
+ const view = snapshot.products[product];
118
+ const lines = [`${ROUTE_BY_PRODUCT[product]} (${product})${all ? " — all candidates" : ` — ${view.counts.ready} ready models`}:`];
119
+ const ordered = [...view.candidates.values()].sort((left, right) => left.id.localeCompare(right.id));
120
+ for (const candidate of ordered) {
121
+ const line = candidateLine(candidate, all);
122
+ if (line.length > 0) lines.push(line);
123
+ }
124
+ if (all && view.counts.ready === 0 && ordered.length === 0) lines.push(" (no candidates published yet)");
125
+ return {
126
+ kind: "success",
127
+ text: lines.join("\n")
128
+ };
129
+ }
130
+ },
131
+ {
132
+ name: "dsh-opencode",
133
+ description: "Enable OpenCode Zen/Go immediately after the API key is stored securely",
134
+ recordInput: false,
135
+ handler: async (invocation) => {
136
+ if (invocation.rawInput.trim().length > 0) return {
137
+ kind: "error",
138
+ text: USAGE_ENABLE
139
+ };
140
+ const routes = [ROUTE_BY_PRODUCT.zen, ROUTE_BY_PRODUCT.go];
141
+ if (!(await Promise.all(routes.map(async (route) => {
142
+ return (await services.describeCredential(route))?.configured === true;
143
+ }))).some((configured) => configured)) return {
144
+ kind: "success",
145
+ text: [
146
+ "No OpenCode API key is configured yet.",
147
+ "Store it securely through the standard credentials input (the web Models page writes it),",
148
+ "then run /dsh-opencode again to refresh both catalogs and enable the routes immediately.",
149
+ "This command never accepts, records, or displays key values."
150
+ ].join("\n")
151
+ };
152
+ if (invocation.signal.aborted) return {
153
+ kind: "success",
154
+ text: "Refresh cancelled."
155
+ };
156
+ try {
157
+ await services.catalog.refresh({
158
+ products: ["zen", "go"],
159
+ signal: invocation.signal
160
+ });
161
+ } catch {}
162
+ const lines = ["OpenCode API key configured. Zen and Go are enabled:"];
163
+ for (const route of routes) lines.push(await productStatus(ctx, services, PRODUCT_BY_ROUTE[route]));
164
+ return {
165
+ kind: "success",
166
+ text: lines.join("\n")
167
+ };
168
+ }
169
+ }
170
+ ];
171
+ }
172
+ /** Register every host command and return the disposers. */
173
+ function registerCommands(ctx, services) {
174
+ return commandDefinitions(ctx, services).map((definition) => ctx.commands.register(definition));
175
+ }
176
+ //#endregion
177
+ export { commandDefinitions, registerCommands };