opencode-cmd-provider 1.2.2 → 1.4.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.
@@ -1,5 +1,5 @@
1
1
  import { isRecord, stringValue, numberValue, recordOrEmpty } from "./converters.js";
2
- import { commandCodeErrorMessage } from "./redact.js";
2
+ import { commandCodeErrorMessage, redactCommandCodeErrorText } from "./redact.js";
3
3
  export function parseStreamEventLine(line) {
4
4
  let trimmed = line.trim();
5
5
  if (!trimmed || trimmed.startsWith(":") || trimmed.startsWith("event:"))
@@ -25,7 +25,7 @@ export function mapFinishReason(reason) {
25
25
  raw === "max_output_tokens") {
26
26
  return { unified: "length", raw };
27
27
  }
28
- if (raw === "stop")
28
+ if (raw === "stop" || raw === "end_turn" || raw === "stop_sequence")
29
29
  return { unified: "stop", raw };
30
30
  if (raw === "error")
31
31
  return { unified: "error", raw };
@@ -111,9 +111,473 @@ export function ccEventToStreamPart(event) {
111
111
  const message = commandCodeErrorMessage(event.error) ??
112
112
  commandCodeErrorMessage(event.message) ??
113
113
  "Command Code stream error";
114
- throw new Error(message);
114
+ throw new Error(redactCommandCodeErrorText(message));
115
115
  }
116
116
  default:
117
117
  return [];
118
118
  }
119
119
  }
120
+ function asRecord(value) {
121
+ return isRecord(value) ? value : undefined;
122
+ }
123
+ function extractUsageTokens(usage) {
124
+ const rec = asRecord(usage);
125
+ if (!rec)
126
+ return undefined;
127
+ // OpenAI: prompt_tokens / completion_tokens / total_tokens
128
+ // Anthropic: input_tokens / output_tokens / cache_read_input_tokens / cache_creation_input_tokens
129
+ // Generic: inputTokens / outputTokens / input_tokens etc.
130
+ const input = numberValue(rec.prompt_tokens) ??
131
+ numberValue(rec.input_tokens) ??
132
+ numberValue(rec.inputTokens) ??
133
+ numberValue(rec.promptTokens) ??
134
+ 0;
135
+ const output = numberValue(rec.completion_tokens) ??
136
+ numberValue(rec.output_tokens) ??
137
+ numberValue(rec.outputTokens) ??
138
+ numberValue(rec.completionTokens) ??
139
+ 0;
140
+ const cacheRead = numberValue(rec.cache_read_input_tokens) ??
141
+ numberValue(rec.cacheReadTokens) ??
142
+ numberValue(rec.cacheRead) ??
143
+ 0;
144
+ const cacheWrite = numberValue(rec.cache_creation_input_tokens) ??
145
+ numberValue(rec.cacheWriteTokens) ??
146
+ numberValue(rec.cacheWrite) ??
147
+ 0;
148
+ // If nothing meaningful, signal undefined so caller can fallback
149
+ if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0) {
150
+ // Could be empty usage object — still return zeroed usage for finish
151
+ // but let caller decide if usage was present at all
152
+ const hasAnyKey = "prompt_tokens" in rec ||
153
+ "input_tokens" in rec ||
154
+ "inputTokens" in rec ||
155
+ "completion_tokens" in rec ||
156
+ "output_tokens" in rec ||
157
+ "outputTokens" in rec;
158
+ if (!hasAnyKey)
159
+ return undefined;
160
+ }
161
+ return { input, output, cacheRead, cacheWrite };
162
+ }
163
+ function usageToAiSdk(usage) {
164
+ const tokens = extractUsageTokens(usage);
165
+ if (!tokens)
166
+ return undefined;
167
+ const totalInput = tokens.input;
168
+ const noCache = Math.max(0, totalInput - tokens.cacheRead - tokens.cacheWrite);
169
+ return {
170
+ inputTokens: {
171
+ total: totalInput,
172
+ noCache,
173
+ cacheRead: tokens.cacheRead,
174
+ cacheWrite: tokens.cacheWrite,
175
+ },
176
+ outputTokens: { total: tokens.output, text: tokens.output, reasoning: 0 },
177
+ };
178
+ }
179
+ function firstChoice(event) {
180
+ const choices = event.choices;
181
+ if (Array.isArray(choices) && choices.length > 0 && isRecord(choices[0]))
182
+ return choices[0];
183
+ return undefined;
184
+ }
185
+ function deltaFromChoice(choice) {
186
+ const delta = choice.delta;
187
+ return isRecord(delta) ? delta : undefined;
188
+ }
189
+ export function openAIUsageToAiSdkUsage(event) {
190
+ // event may be the full chunk or just the usage object
191
+ const usage = event.usage ?? event;
192
+ return usageToAiSdk(usage);
193
+ }
194
+ export function anthropicUsageToAiSdkUsage(event) {
195
+ const usage = event.usage ?? event;
196
+ return usageToAiSdk(usage);
197
+ }
198
+ // --- Shared stream-part constructors ---
199
+ // Single construction surface for the parts both the stateless mappers and
200
+ // the per-stream stateful parsers emit (issue #55 kept tool-call completion
201
+ // stateful; everything else stays shared).
202
+ function zeroedUsage() {
203
+ return {
204
+ inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 },
205
+ outputTokens: { total: 0, text: 0, reasoning: 0 },
206
+ };
207
+ }
208
+ function finishPart(finishReason, usage) {
209
+ return {
210
+ type: "finish",
211
+ finishReason: finishReason ?? { unified: "stop", raw: "stop" },
212
+ usage: usage ?? zeroedUsage(),
213
+ };
214
+ }
215
+ function textDeltaPart(id, delta) {
216
+ return { type: "text-delta", id: stringValue(id) ?? "text", delta };
217
+ }
218
+ function reasoningDeltaPart(id, delta) {
219
+ return { type: "reasoning-delta", id: stringValue(id) ?? "reasoning", delta };
220
+ }
221
+ // --- OpenAI Chat Completions streaming ---
222
+ export function openAIEventToStreamPart(event) {
223
+ if (!isRecord(event))
224
+ return [];
225
+ // Error handling — OpenAI errors have { error: { message, type, code } } or top-level error
226
+ if (event.error !== undefined) {
227
+ const message = commandCodeErrorMessage(event.error) ??
228
+ commandCodeErrorMessage(event) ??
229
+ "Provider stream error";
230
+ throw new Error(redactCommandCodeErrorText(message));
231
+ }
232
+ if (stringValue(event.type) === "error") {
233
+ const message = commandCodeErrorMessage(event.error) ??
234
+ commandCodeErrorMessage(event.message) ??
235
+ "Provider stream error";
236
+ throw new Error(redactCommandCodeErrorText(message));
237
+ }
238
+ // Extract usage if present (terminal chunk)
239
+ const rawUsage = event.usage;
240
+ const hasUsage = rawUsage !== undefined && rawUsage !== null;
241
+ const usage = hasUsage ? usageToAiSdk(rawUsage) : undefined;
242
+ // Determine finish reason
243
+ const choice = firstChoice(event);
244
+ const finishReasonRaw = stringValue(choice?.finish_reason) ??
245
+ stringValue(choice?.finishReason) ??
246
+ stringValue(event.finish_reason) ??
247
+ stringValue(event.finishReason);
248
+ const finishReason = finishReasonRaw ? mapFinishReason(finishReasonRaw) : undefined;
249
+ const parts = [];
250
+ // Text delta
251
+ const delta = choice ? deltaFromChoice(choice) : undefined;
252
+ if (delta) {
253
+ const content = stringValue(delta.content);
254
+ if (typeof content === "string" && content.length > 0) {
255
+ parts.push(textDeltaPart(event.id, content));
256
+ }
257
+ // Tool calls streaming — map to tool-input deltas
258
+ const toolCalls = delta.tool_calls ?? delta.toolCalls;
259
+ if (Array.isArray(toolCalls)) {
260
+ for (const tc of toolCalls) {
261
+ if (!isRecord(tc))
262
+ continue;
263
+ const id = stringValue(tc.id) ?? "";
264
+ const fn = isRecord(tc.function) ? tc.function : {};
265
+ const name = stringValue(fn.name) ?? stringValue(tc.name) ?? "";
266
+ const args = stringValue(fn.arguments) ?? "";
267
+ if (id || name || args) {
268
+ if (name)
269
+ parts.push({ type: "tool-input-start", id: id || name, toolName: name });
270
+ if (args)
271
+ parts.push({ type: "tool-input-delta", id: id || name, delta: args });
272
+ // Only emit end+call when we have a complete tool call (id + name + non-empty args that looks like JSON)
273
+ if (id && name && args) {
274
+ // Try to avoid emitting malformed fragments as complete calls
275
+ try {
276
+ JSON.parse(args);
277
+ parts.push({ type: "tool-input-end", id });
278
+ parts.push({ type: "tool-call", toolCallId: id, toolName: name, input: args });
279
+ }
280
+ catch {
281
+ // Fragment — wait for final delta to emit call; just keep delta
282
+ }
283
+ }
284
+ }
285
+ }
286
+ }
287
+ // Reasoning delta (OpenAI reasoning)
288
+ const reasoning = stringValue(delta.reasoning) ?? stringValue(delta.reasoning_content);
289
+ if (typeof reasoning === "string" && reasoning.length > 0) {
290
+ parts.push(reasoningDeltaPart(event.id, reasoning));
291
+ }
292
+ }
293
+ // If this chunk carries finish reason or usage, emit finish
294
+ if (finishReason || hasUsage) {
295
+ const finalUsage = usage ?? (hasUsage ? zeroedUsage() : undefined);
296
+ // Only emit finish if we have usage or explicit finish reason indicating completion
297
+ if (finalUsage || finishReason) {
298
+ parts.push(finishPart(finishReason, finalUsage));
299
+ }
300
+ }
301
+ return parts;
302
+ }
303
+ // --- Anthropic Messages streaming ---
304
+ export function anthropicEventToStreamPart(event) {
305
+ if (!isRecord(event))
306
+ return [];
307
+ const type = stringValue(event.type);
308
+ if (type === "error" || event.error !== undefined) {
309
+ const message = commandCodeErrorMessage(event.error) ??
310
+ commandCodeErrorMessage(event.message) ??
311
+ "Provider stream error";
312
+ throw new Error(redactCommandCodeErrorText(message));
313
+ }
314
+ // Content delta: { type: "content_block_delta", delta: { type: "text_delta", text: "..." } }
315
+ if (type === "content_block_delta") {
316
+ const delta = asRecord(event.delta);
317
+ const text = stringValue(delta?.text);
318
+ if (typeof text === "string" && text.length > 0) {
319
+ const index = numberValue(event.index) ?? 0;
320
+ const id = `text-${index}`;
321
+ return [{ type: "text-delta", id, delta: text }];
322
+ }
323
+ // Tool input delta: { type: "input_json_delta", partial_json: "..." }
324
+ const partial = stringValue(delta?.partial_json);
325
+ if (typeof partial === "string" && partial.length > 0) {
326
+ const index = numberValue(event.index) ?? 0;
327
+ const id = `tool-${index}`;
328
+ // Emit as tool-input-delta; caller may have started tool
329
+ return [{ type: "tool-input-delta", id, delta: partial }];
330
+ }
331
+ return [];
332
+ }
333
+ if (type === "content_block_start") {
334
+ const block = asRecord(event.content_block);
335
+ const blockType = stringValue(block?.type);
336
+ const index = numberValue(event.index) ?? 0;
337
+ if (blockType === "text") {
338
+ const id = `text-${index}`;
339
+ return [{ type: "text-start", id }];
340
+ }
341
+ if (blockType === "tool_use") {
342
+ const id = stringValue(block?.id) ?? `tool-${index}`;
343
+ const name = stringValue(block?.name) ?? "";
344
+ return [{ type: "tool-input-start", id, toolName: name }];
345
+ }
346
+ return [];
347
+ }
348
+ if (type === "content_block_stop") {
349
+ // Stateless codec: the STOP event carries only `index`, not the block type.
350
+ // Anthropic streams either `text` or `tool_use` blocks; the AI SDK expects
351
+ // `text-end` for the former and `tool-input-end` for the latter. Emitting
352
+ // both is noisy (previous emitted two parts) but guarantees the right end
353
+ // is seen regardless of block type, and the consumer ignores the spurious
354
+ // one per AI SDK spec. Emit a single text-end here would break tool_use
355
+ // streams that rely on tool-input-end, so we keep the conservative pair
356
+ // with an explicit note referencing provider doc streaming (message_delta
357
+ // carries the final usage/finish; per-block ends are AI SDK concerns).
358
+ const index = numberValue(event.index) ?? 0;
359
+ const idText = `text-${index}`;
360
+ const idTool = `tool-${index}`;
361
+ return [
362
+ { type: "text-end", id: idText },
363
+ { type: "tool-input-end", id: idTool },
364
+ ];
365
+ }
366
+ // Terminal message_delta: { type: "message_delta", delta: { stop_reason }, usage: { ... } }
367
+ if (type === "message_delta") {
368
+ const delta = asRecord(event.delta);
369
+ const stopReason = stringValue(delta?.stop_reason) ?? stringValue(delta?.stopReason) ?? "stop";
370
+ return [finishPart(mapFinishReason(stopReason), usageToAiSdk(event.usage))];
371
+ }
372
+ // Alternative terminal: { type: "message_stop" } without usage — emit generic finish
373
+ if (type === "message_stop") {
374
+ return [finishPart(undefined, undefined)];
375
+ }
376
+ // Ping/heartbeat or other known non-content types
377
+ if (type === "ping" || type === "message_start")
378
+ return [];
379
+ // Fallback: check for usage at top level without type (some providers send final usage as top-level)
380
+ if (event.usage !== undefined) {
381
+ const usage = usageToAiSdk(event.usage);
382
+ if (usage) {
383
+ return [
384
+ finishPart(mapFinishReason(stringValue(event.finish_reason) ?? "stop"), usage),
385
+ ];
386
+ }
387
+ }
388
+ return [];
389
+ }
390
+ export function createOpenAIStreamParser() {
391
+ const toolBuffers = new Map();
392
+ let nextIndex = 0;
393
+ // OpenAI streams finish_reason on the last content chunk, then the real
394
+ // usage on a separate trailing usage-only chunk (choices:[]). Remember the
395
+ // finish_reason here so the usage-only chunk's finish keeps the real reason
396
+ // (e.g. "length") instead of defaulting to "stop".
397
+ let lastFinishReason;
398
+ return (event) => {
399
+ if (!isRecord(event))
400
+ return [];
401
+ // Error events flow through the stateless mapper (redacted throw).
402
+ if (event.error !== undefined || stringValue(event.type) === "error") {
403
+ return openAIEventToStreamPart(event);
404
+ }
405
+ const parts = [];
406
+ const choice = firstChoice(event);
407
+ const delta = choice ? deltaFromChoice(choice) : undefined;
408
+ if (delta) {
409
+ const content = stringValue(delta.content);
410
+ if (typeof content === "string" && content.length > 0) {
411
+ parts.push(textDeltaPart(event.id, content));
412
+ }
413
+ const reasoning = stringValue(delta.reasoning) ?? stringValue(delta.reasoning_content);
414
+ if (typeof reasoning === "string" && reasoning.length > 0) {
415
+ parts.push(reasoningDeltaPart(event.id, reasoning));
416
+ }
417
+ const toolCalls = delta.tool_calls ?? delta.toolCalls;
418
+ if (Array.isArray(toolCalls)) {
419
+ for (const tc of toolCalls) {
420
+ if (!isRecord(tc))
421
+ continue;
422
+ const fn = isRecord(tc.function) ? tc.function : {};
423
+ const name = stringValue(fn.name) ?? stringValue(tc.name) ?? "";
424
+ const args = stringValue(fn.arguments) ?? "";
425
+ const id = stringValue(tc.id) ?? "";
426
+ let index = numberValue(tc.index);
427
+ if (index === undefined) {
428
+ // Fragments usually carry `index`; when absent, continue the most
429
+ // recent tool call (OpenAI includes id+name only on the first chunk).
430
+ index = toolBuffers.size > 0 ? Math.max(...toolBuffers.keys()) : nextIndex++;
431
+ }
432
+ let buffer = toolBuffers.get(index);
433
+ if (!buffer) {
434
+ buffer = { id: id || `tool-${index}`, name, input: "", started: false, emitted: false };
435
+ toolBuffers.set(index, buffer);
436
+ }
437
+ if (id)
438
+ buffer.id = id;
439
+ if (name)
440
+ buffer.name = name;
441
+ if (!buffer.started && buffer.name) {
442
+ buffer.started = true;
443
+ parts.push({ type: "tool-input-start", id: buffer.id, toolName: buffer.name });
444
+ }
445
+ if (args && !buffer.emitted) {
446
+ buffer.input += args;
447
+ parts.push({ type: "tool-input-delta", id: buffer.id, delta: args });
448
+ // Complete as soon as the accumulated arguments parse as JSON; the
449
+ // finish chunk below flushes anything that never completes.
450
+ try {
451
+ JSON.parse(buffer.input);
452
+ buffer.emitted = true;
453
+ parts.push({ type: "tool-input-end", id: buffer.id });
454
+ parts.push({
455
+ type: "tool-call",
456
+ toolCallId: buffer.id,
457
+ toolName: buffer.name,
458
+ input: buffer.input,
459
+ });
460
+ }
461
+ catch {
462
+ // fragment — keep accumulating
463
+ }
464
+ }
465
+ }
466
+ }
467
+ }
468
+ const rawUsage = event.usage;
469
+ const hasUsage = rawUsage !== undefined && rawUsage !== null;
470
+ const finishReasonRaw = stringValue(choice?.finish_reason) ??
471
+ stringValue(choice?.finishReason) ??
472
+ stringValue(event.finish_reason) ??
473
+ stringValue(event.finishReason);
474
+ const finishReason = finishReasonRaw ? mapFinishReason(finishReasonRaw) : undefined;
475
+ if (finishReason)
476
+ lastFinishReason = finishReason;
477
+ if (lastFinishReason || hasUsage) {
478
+ // Flush any tool call whose terminal args chunk never arrived.
479
+ for (const [index, buffer] of toolBuffers) {
480
+ if (buffer.started && !buffer.emitted) {
481
+ parts.push({ type: "tool-input-end", id: buffer.id });
482
+ parts.push({
483
+ type: "tool-call",
484
+ toolCallId: buffer.id,
485
+ toolName: buffer.name,
486
+ input: buffer.input,
487
+ });
488
+ }
489
+ toolBuffers.delete(index);
490
+ }
491
+ // The usage-only trailing chunk carries no finish_reason; reuse the one
492
+ // captured from the finish_reason chunk so the real reason survives.
493
+ const reason = finishReason ?? lastFinishReason;
494
+ parts.push(finishPart(reason, hasUsage ? usageToAiSdk(rawUsage) : undefined));
495
+ }
496
+ return parts;
497
+ };
498
+ }
499
+ export function createAnthropicStreamParser() {
500
+ const toolBlocks = new Map();
501
+ return (event) => {
502
+ if (!isRecord(event))
503
+ return [];
504
+ const type = stringValue(event.type);
505
+ if (type === "content_block_start") {
506
+ const block = asRecord(event.content_block);
507
+ const index = numberValue(event.index) ?? 0;
508
+ const blockType = stringValue(block?.type);
509
+ if (blockType === "tool_use") {
510
+ const id = stringValue(block?.id) ?? `tool-${index}`;
511
+ const name = stringValue(block?.name) ?? "";
512
+ toolBlocks.set(index, { id, name, input: "", started: true, emitted: false });
513
+ return [{ type: "tool-input-start", id, toolName: name }];
514
+ }
515
+ if (blockType === "text")
516
+ return [{ type: "text-start", id: `text-${index}` }];
517
+ return [];
518
+ }
519
+ if (type === "content_block_delta") {
520
+ const delta = asRecord(event.delta);
521
+ const index = numberValue(event.index) ?? 0;
522
+ const text = stringValue(delta?.text);
523
+ if (typeof text === "string" && text.length > 0) {
524
+ return [{ type: "text-delta", id: `text-${index}`, delta: text }];
525
+ }
526
+ const partial = stringValue(delta?.partial_json);
527
+ if (typeof partial === "string" && partial.length > 0) {
528
+ const block = toolBlocks.get(index);
529
+ if (block) {
530
+ if (block.emitted)
531
+ return [];
532
+ block.input += partial;
533
+ const out = [
534
+ { type: "tool-input-delta", id: block.id, delta: partial },
535
+ ];
536
+ // Complete early when a single delta already carries valid JSON;
537
+ // content_block_stop below flushes multi-delta accumulation.
538
+ try {
539
+ JSON.parse(block.input);
540
+ block.emitted = true;
541
+ out.push({ type: "tool-input-end", id: block.id });
542
+ out.push({
543
+ type: "tool-call",
544
+ toolCallId: block.id,
545
+ toolName: block.name,
546
+ input: block.input,
547
+ });
548
+ }
549
+ catch {
550
+ // fragment — keep accumulating until stop
551
+ }
552
+ return out;
553
+ }
554
+ return [{ type: "tool-input-delta", id: `tool-${index}`, delta: partial }];
555
+ }
556
+ return [];
557
+ }
558
+ if (type === "content_block_stop") {
559
+ const index = numberValue(event.index) ?? 0;
560
+ const block = toolBlocks.get(index);
561
+ if (block) {
562
+ toolBlocks.delete(index);
563
+ if (block.emitted)
564
+ return [];
565
+ return [
566
+ { type: "tool-input-end", id: block.id },
567
+ {
568
+ type: "tool-call",
569
+ toolCallId: block.id,
570
+ toolName: block.name,
571
+ input: block.input,
572
+ },
573
+ ];
574
+ }
575
+ return [{ type: "text-end", id: `text-${index}` }];
576
+ }
577
+ // Everything else (message_delta, message_stop, ping, error, …) shares the
578
+ // stateless mapper's handling.
579
+ return anthropicEventToStreamPart(event);
580
+ };
581
+ }
582
+ // Canonical stream entry points: openAIEventToStreamPart / anthropicEventToStreamPart
583
+ // and the per-stream stateful parsers createOpenAIStreamParser / createAnthropicStreamParser.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-cmd-provider",
3
- "version": "1.2.2",
3
+ "version": "1.4.0",
4
4
  "description": "Command Code provider + plugin for opencode",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -22,7 +22,7 @@
22
22
  "refresh:deals": "node scripts/refresh-deals.mjs",
23
23
  "refresh": "npm run refresh:snapshot && npm run refresh:deals -- --fixtures",
24
24
  "test": "npm run typecheck && npm run test:unit && npm run test:integration && npm run test:contract && npm run format:check",
25
- "test:unit": "tsx tests/env.test.ts && tsx tests/auth-key.test.ts && tsx tests/converters.test.ts && tsx tests/stream.test.ts && tsx tests/redact.test.ts && tsx tests/cost.test.ts && tsx tests/retry.test.ts && tsx tests/reasoning.test.ts && tsx tests/modalities.test.ts && tsx tests/snapshot.test.ts && tsx tests/refresh-snapshot.test.ts && tsx tests/oauth.test.ts && tsx tests/parse-facts.test.ts && tsx tests/parse-modalities.test.ts && tsx tests/catalog-metadata.test.ts && tsx tests/plugin-models.test.ts && tsx tests/vendor.test.ts && tsx tests/deals-enrichment.test.ts && tsx tests/plan-summary.test.ts && tsx tests/html-tables.test.ts && tsx tests/parse-docs.test.ts && tsx tests/refresh-deals.test.ts && tsx tests/tui-deals-panel.test.ts && tsx tests/plugin-install.test.ts",
25
+ "test:unit": "tsx tests/env.test.ts && tsx tests/auth-key.test.ts && tsx tests/converters.test.ts && tsx tests/stream.test.ts && tsx tests/provider-codecs.test.ts && tsx tests/provider-transport.test.ts && tsx tests/provider-parity.test.ts && tsx tests/provider-upgrade-fallback.test.ts && tsx tests/provider-zdr.test.ts && tsx tests/redact.test.ts && tsx tests/cost.test.ts && tsx tests/retry.test.ts && tsx tests/reasoning.test.ts && tsx tests/modalities.test.ts && tsx tests/snapshot.test.ts && tsx tests/refresh-snapshot.test.ts && tsx tests/oauth.test.ts && tsx tests/auth-mirror.test.ts && tsx tests/parse-facts.test.ts && tsx tests/parse-modalities.test.ts && tsx tests/catalog-metadata.test.ts && tsx tests/plugin-models.test.ts && tsx tests/vendor.test.ts && tsx tests/deals-enrichment.test.ts && tsx tests/deals-coverage.test.ts && tsx tests/plan-summary.test.ts && tsx tests/html-tables.test.ts && tsx tests/parse-docs.test.ts && tsx tests/refresh-deals.test.ts && tsx tests/tui-deals-panel.test.ts && tsx tests/plugin-install.test.ts",
26
26
  "test:integration": "tsx tests/integration-do-stream.test.ts && tsx tests/integration-do-generate.test.ts",
27
27
  "test:contract": "tsx tests/contract.test.ts",
28
28
  "test:e2e": "node tests/e2e-opencode.mjs",