oc-auth-switcher 0.8.0 → 0.9.1

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/dist/cli.js CHANGED
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
3
 
4
+ // src/cli.ts
5
+ import { createHash } from "crypto";
6
+
4
7
  // src/accounts.ts
5
8
  import fs from "fs";
6
9
  import path2 from "path";
@@ -203,11 +206,12 @@ async function refreshAccountToken(refreshTokenValue) {
203
206
 
204
207
  // src/state.ts
205
208
  var EMPTY_METRIC = { utilization: 0, reset: 0, status: "" };
209
+ var EMPTY_MODEL_METRIC = { utilization: null, reset: 0, status: "" };
206
210
  var EMPTY_USAGE = {
207
211
  session5h: { ...EMPTY_METRIC },
208
212
  weekly7d: { ...EMPTY_METRIC },
209
- weekly7dSonnet: { ...EMPTY_METRIC },
210
- weekly7dFable: { ...EMPTY_METRIC },
213
+ weekly7dSonnet: { ...EMPTY_MODEL_METRIC },
214
+ weekly7dFable: { ...EMPTY_MODEL_METRIC },
211
215
  rejected: { ...EMPTY_METRIC }
212
216
  };
213
217
  function defaultState() {
@@ -260,6 +264,20 @@ function loadState(stateFile = STATE_FILE) {
260
264
  function saveState(state) {
261
265
  return safeWriteJSON(STATE_FILE, state);
262
266
  }
267
+ async function saveStateMerged(state, accountsToMerge) {
268
+ const onDisk = loadState();
269
+ for (const name of accountsToMerge) {
270
+ if (state.usage[name]) {
271
+ onDisk.usage[name] = state.usage[name];
272
+ }
273
+ if (state.authFailures[name] !== undefined) {
274
+ onDisk.authFailures[name] = state.authFailures[name];
275
+ } else {
276
+ delete onDisk.authFailures[name];
277
+ }
278
+ }
279
+ await safeWriteJSON(STATE_FILE, onDisk);
280
+ }
263
281
  function getThresholds(config) {
264
282
  if (typeof config.threshold === "number") {
265
283
  return {
@@ -312,6 +330,178 @@ function ensureAccountsInState(state, accountNames) {
312
330
  }
313
331
  }
314
332
  }
333
+ function updateUsageFromHeaders(state, accountName, headers) {
334
+ if (!state.usage[accountName]) {
335
+ state.usage[accountName] = {
336
+ session5h: { ...EMPTY_METRIC },
337
+ weekly7d: { ...EMPTY_METRIC },
338
+ weekly7dSonnet: { ...EMPTY_METRIC },
339
+ weekly7dFable: { ...EMPTY_METRIC },
340
+ rejected: { ...EMPTY_METRIC }
341
+ };
342
+ }
343
+ const usage = state.usage[accountName];
344
+ let updated = false;
345
+ const metricFamilies = [
346
+ {
347
+ key: "session5h",
348
+ prefix: "anthropic-ratelimit-unified-5h"
349
+ },
350
+ {
351
+ key: "weekly7d",
352
+ prefix: "anthropic-ratelimit-unified-7d"
353
+ },
354
+ {
355
+ key: "weekly7dSonnet",
356
+ prefix: "anthropic-ratelimit-unified-7d_sonnet"
357
+ },
358
+ {
359
+ key: "weekly7dFable",
360
+ prefix: "anthropic-ratelimit-unified-7d_fable"
361
+ }
362
+ ];
363
+ for (const { key, prefix } of metricFamilies) {
364
+ const utilHeader = headers.get(`${prefix}-utilization`);
365
+ const resetHeader = headers.get(`${prefix}-reset`);
366
+ const statusHeader = headers.get(`${prefix}-status`);
367
+ if (utilHeader !== null || resetHeader !== null || statusHeader !== null) {
368
+ if (utilHeader !== null) {
369
+ const val = parseFloat(utilHeader);
370
+ if (!isNaN(val)) {
371
+ usage[key].utilization = val;
372
+ updated = true;
373
+ }
374
+ }
375
+ if (resetHeader !== null) {
376
+ const val = Number(resetHeader);
377
+ if (!isNaN(val)) {
378
+ usage[key].reset = val;
379
+ updated = true;
380
+ } else {
381
+ const parsed = Date.parse(resetHeader);
382
+ if (!isNaN(parsed)) {
383
+ usage[key].reset = parsed / 1000;
384
+ updated = true;
385
+ }
386
+ }
387
+ }
388
+ if (statusHeader !== null) {
389
+ usage[key].status = statusHeader;
390
+ updated = true;
391
+ }
392
+ }
393
+ }
394
+ for (const [headerName, headerValue] of headers.entries()) {
395
+ const normalizedName = headerName.toLowerCase();
396
+ const statusMatch = normalizedName.match(/^anthropic-ratelimit-unified-(.+)-status$/);
397
+ if (!statusMatch || !quotaWindowScope(statusMatch[1]) || headerValue.toLowerCase() !== "rejected") {
398
+ continue;
399
+ }
400
+ const prefix = normalizedName.slice(0, -"-status".length);
401
+ const resetHeader = headers.get(`${prefix}-reset`) ?? headers.get("anthropic-ratelimit-unified-reset");
402
+ let reset = resetHeader ? Number(resetHeader) : NaN;
403
+ if (isNaN(reset) && resetHeader) {
404
+ const parsed = Date.parse(resetHeader);
405
+ if (!isNaN(parsed))
406
+ reset = parsed / 1000;
407
+ }
408
+ if (!Number.isFinite(reset) || reset <= 0) {
409
+ const retryAfterHeader = headers.get("retry-after");
410
+ const retryAfter = retryAfterHeader === null ? NaN : Number(retryAfterHeader);
411
+ reset = Date.now() / 1000 + (Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : REJECTION_FALLBACK_SECONDS);
412
+ }
413
+ usage.rejected = {
414
+ utilization: 1,
415
+ reset: Math.max(usage.rejected.reset, reset),
416
+ status: "rejected",
417
+ prefix
418
+ };
419
+ updated = true;
420
+ }
421
+ if (updated) {
422
+ usage.timestamp = new Date().toISOString();
423
+ }
424
+ return updated;
425
+ }
426
+ async function fetchOAuthUsage(accessToken) {
427
+ try {
428
+ const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
429
+ method: "GET",
430
+ headers: {
431
+ authorization: `Bearer ${accessToken}`,
432
+ "anthropic-version": "2023-06-01",
433
+ "user-agent": "claude-cli/2.1.87 (external, cli)"
434
+ }
435
+ });
436
+ if (!response.ok) {
437
+ const body = await response.text().catch(() => "");
438
+ return { ok: false, error: `HTTP ${response.status}: ${body.slice(0, 100)}` };
439
+ }
440
+ const data = await response.json();
441
+ return { ok: true, data };
442
+ } catch (err) {
443
+ return {
444
+ ok: false,
445
+ error: err instanceof Error ? err.message : String(err)
446
+ };
447
+ }
448
+ }
449
+ function mapModelDisplayNameToField(displayName) {
450
+ const normalized = displayName.toLowerCase();
451
+ if (normalized === "fable")
452
+ return "weekly7dFable";
453
+ if (normalized === "sonnet")
454
+ return "weekly7dSonnet";
455
+ return;
456
+ }
457
+ function isoToEpochSeconds(iso) {
458
+ if (!iso)
459
+ return 0;
460
+ const parsed = Date.parse(iso);
461
+ return isNaN(parsed) ? 0 : parsed / 1000;
462
+ }
463
+ function percentToFraction(percent) {
464
+ return percent / 100;
465
+ }
466
+ function updateUsageFromOAuthEndpoint(state, accountName, response) {
467
+ if (!state.usage[accountName]) {
468
+ state.usage[accountName] = {
469
+ session5h: { ...EMPTY_METRIC },
470
+ weekly7d: { ...EMPTY_METRIC },
471
+ weekly7dSonnet: { ...EMPTY_MODEL_METRIC },
472
+ weekly7dFable: { ...EMPTY_MODEL_METRIC },
473
+ rejected: { ...EMPTY_METRIC }
474
+ };
475
+ }
476
+ const usage = state.usage[accountName];
477
+ let updated = false;
478
+ const warnings = [];
479
+ const limits = response.limits ?? [];
480
+ for (const limit of limits) {
481
+ if (limit.kind !== "weekly_scoped")
482
+ continue;
483
+ if (!limit.scope?.model?.display_name)
484
+ continue;
485
+ const displayName = limit.scope.model.display_name;
486
+ const field = mapModelDisplayNameToField(displayName);
487
+ if (!field) {
488
+ warnings.push(`unknown model "${displayName}" in weekly_scoped limit`);
489
+ continue;
490
+ }
491
+ const utilization = percentToFraction(limit.percent);
492
+ const reset = isoToEpochSeconds(limit.resets_at);
493
+ usage[field] = {
494
+ utilization,
495
+ reset,
496
+ status: limit.severity || ""
497
+ };
498
+ updated = true;
499
+ }
500
+ if (updated) {
501
+ usage.timestamp = new Date().toISOString();
502
+ }
503
+ return { updated, warnings };
504
+ }
315
505
 
316
506
  // src/rotation.ts
317
507
  function isTemporarilyUnavailable(state, accountName) {
@@ -364,7 +554,7 @@ function isRejectionRelevant(prefix, modelFamily) {
364
554
  function metricEntries(usage, thresholds, modelFamily) {
365
555
  return Object.keys(METRIC_MODEL_FAMILY).filter((key) => isMetricRelevant(key, modelFamily)).map((key) => ({
366
556
  name: key,
367
- util: usage[key].utilization,
557
+ util: usage[key].utilization ?? 0,
368
558
  threshold: thresholds[key]
369
559
  }));
370
560
  }
@@ -434,6 +624,9 @@ function selectAccount(accounts, state, model) {
434
624
  }
435
625
  return { account: current, switched: false };
436
626
  }
627
+ function markAuthFailure(state, accountName) {
628
+ state.authFailures[accountName] = Date.now() + AUTH_FAILURE_COOLDOWN;
629
+ }
437
630
  function clearAuthFailure(state, accountName) {
438
631
  delete state.authFailures[accountName];
439
632
  }
@@ -493,7 +686,7 @@ function tokenExpiryDescription(account, nowMs) {
493
686
  function deriveAccountHealth(account, usage, thresholds, cooldownUntil, nowMs = Date.now()) {
494
687
  const availability = Object.fromEntries(MODEL_FAMILIES.map((family) => [family, true]));
495
688
  const reasons = [];
496
- const metrics = usage ? Object.keys(METRIC_MODEL_FAMILY).filter((metric) => thresholds[metric] > 0).map((metric) => ({
689
+ const metrics = usage ? Object.keys(METRIC_MODEL_FAMILY).filter((metric) => thresholds[metric] > 0).filter((metric) => usage[metric].utilization !== null).map((metric) => ({
497
690
  metric,
498
691
  utilization: usage[metric].utilization,
499
692
  threshold: thresholds[metric],
@@ -688,6 +881,13 @@ function progressBar(value, threshold, width = 30) {
688
881
  const label = `${(value * 100).toFixed(1)}%`;
689
882
  return `${bar} ${color}${label}${RESET}`;
690
883
  }
884
+ function formatMetric(value, threshold) {
885
+ if (value === null) {
886
+ const emptyBar = DIM + "\u2591".repeat(30) + RESET;
887
+ return `${emptyBar} ${DIM}n/a${RESET}`;
888
+ }
889
+ return progressBar(value, threshold);
890
+ }
691
891
  function resetSuffix(metric, now) {
692
892
  if (!metric.reset || metric.reset * 1000 <= now)
693
893
  return "";
@@ -854,6 +1054,145 @@ async function refreshExpiredAccounts(data, state) {
854
1054
  }
855
1055
  }
856
1056
  }
1057
+ var PROBE_CONCURRENCY = 3;
1058
+ var PROBE_API_URL = "https://api.anthropic.com/v1/messages?beta=true";
1059
+ var PROBE_MODELS = [
1060
+ "claude-sonnet-4-5",
1061
+ "claude-opus-4-5"
1062
+ ];
1063
+ var PROBE_BETA_HEADERS = "oauth-2025-04-20,interleaved-thinking-2025-05-14";
1064
+ var PROBE_USER_AGENT = "claude-cli/2.1.87 (external, cli)";
1065
+ var PROBE_VERSION = "2023-06-01";
1066
+ var CLAUDE_CODE_VERSION = "2.1.87";
1067
+ var CLAUDE_CODE_IDENTITY = "You are a Claude agent, built on Anthropic's Claude Agent SDK.";
1068
+ var CCH_SALT = "59cf53e54c78";
1069
+ var CCH_POSITIONS = [4, 7, 20];
1070
+ function computeCCH(messageText) {
1071
+ return createHash("sha256").update(messageText).digest("hex").slice(0, 5);
1072
+ }
1073
+ function computeVersionSuffix(messageText) {
1074
+ const chars = CCH_POSITIONS.map((i) => messageText[i] || "0").join("");
1075
+ return createHash("sha256").update(`${CCH_SALT}${chars}${CLAUDE_CODE_VERSION}`).digest("hex").slice(0, 3);
1076
+ }
1077
+ function buildBillingHeader(messageText) {
1078
+ const suffix = computeVersionSuffix(messageText);
1079
+ const cch = computeCCH(messageText);
1080
+ return "x-anthropic-billing-header: " + `cc_version=${CLAUDE_CODE_VERSION}.${suffix}; ` + `cc_entrypoint=sdk-cli; ` + `cch=${cch};`;
1081
+ }
1082
+ async function fetchAndApplyOAuthUsage(account, state, result) {
1083
+ const oauthResult = await fetchOAuthUsage(account.access);
1084
+ if (!oauthResult.ok) {
1085
+ result.oauthUsageWarnings = [`oauth usage: ${oauthResult.error}`];
1086
+ return;
1087
+ }
1088
+ const { warnings } = updateUsageFromOAuthEndpoint(state, account.name, oauthResult.data);
1089
+ if (warnings.length > 0) {
1090
+ result.oauthUsageWarnings = warnings;
1091
+ }
1092
+ }
1093
+ async function probeAccount(account, state) {
1094
+ const result = { name: account.name, success: false };
1095
+ if (!account.access || account.expires <= Date.now()) {
1096
+ if (!account.refresh) {
1097
+ result.error = "missing refresh token";
1098
+ return result;
1099
+ }
1100
+ const refreshResult = await refreshAccountToken(account.refresh);
1101
+ if (!refreshResult.ok || !refreshResult.access) {
1102
+ result.error = `token refresh failed: ${refreshResult.error || "unknown"}`;
1103
+ markAuthFailure(state, account.name);
1104
+ return result;
1105
+ }
1106
+ account.access = refreshResult.access;
1107
+ account.refresh = refreshResult.refresh;
1108
+ account.expires = refreshResult.expires;
1109
+ await updateAccountTokens(account.name, refreshResult.access, refreshResult.refresh, refreshResult.expires);
1110
+ clearAuthFailure(state, account.name);
1111
+ }
1112
+ let lastStatus = 0;
1113
+ let lastBody = "";
1114
+ for (const model of PROBE_MODELS) {
1115
+ try {
1116
+ const messageText = ".";
1117
+ const billingHeader = buildBillingHeader(messageText);
1118
+ const response = await fetch(PROBE_API_URL, {
1119
+ method: "POST",
1120
+ headers: {
1121
+ authorization: `Bearer ${account.access}`,
1122
+ "anthropic-beta": PROBE_BETA_HEADERS,
1123
+ "anthropic-version": PROBE_VERSION,
1124
+ "user-agent": PROBE_USER_AGENT,
1125
+ "content-type": "application/json"
1126
+ },
1127
+ body: JSON.stringify({
1128
+ model,
1129
+ max_tokens: 1,
1130
+ system: [
1131
+ { type: "text", text: billingHeader },
1132
+ { type: "text", text: CLAUDE_CODE_IDENTITY }
1133
+ ],
1134
+ messages: [{ role: "user", content: messageText }]
1135
+ })
1136
+ });
1137
+ lastStatus = response.status;
1138
+ const updated = updateUsageFromHeaders(state, account.name, response.headers);
1139
+ if (response.status === 401 || response.status === 403) {
1140
+ result.error = `auth error ${response.status}`;
1141
+ markAuthFailure(state, account.name);
1142
+ return result;
1143
+ }
1144
+ if (response.status === 429 && updated) {
1145
+ result.success = true;
1146
+ result.model = model;
1147
+ clearAuthFailure(state, account.name);
1148
+ await fetchAndApplyOAuthUsage(account, state, result);
1149
+ return result;
1150
+ }
1151
+ if (response.ok || updated) {
1152
+ result.success = true;
1153
+ result.model = model;
1154
+ clearAuthFailure(state, account.name);
1155
+ await fetchAndApplyOAuthUsage(account, state, result);
1156
+ return result;
1157
+ }
1158
+ lastBody = await response.text().catch(() => "");
1159
+ if (response.status === 400 && lastBody.includes("model")) {
1160
+ continue;
1161
+ }
1162
+ } catch (err) {
1163
+ result.error = err instanceof Error ? err.message : String(err);
1164
+ return result;
1165
+ }
1166
+ }
1167
+ result.error = `HTTP ${lastStatus}: ${lastBody.slice(0, 100)}`;
1168
+ return result;
1169
+ }
1170
+ async function probeAllAccounts(accounts) {
1171
+ const state = loadState();
1172
+ const results = [];
1173
+ const probeErrors = [];
1174
+ for (let i = 0;i < accounts.length; i += PROBE_CONCURRENCY) {
1175
+ const batch = accounts.slice(i, i + PROBE_CONCURRENCY);
1176
+ const batchResults = await Promise.allSettled(batch.map((account) => probeAccount(account, state)));
1177
+ for (const settled of batchResults) {
1178
+ if (settled.status === "fulfilled") {
1179
+ results.push(settled.value);
1180
+ if (!settled.value.success && settled.value.error) {
1181
+ probeErrors.push(`${settled.value.name}: ${settled.value.error}`);
1182
+ }
1183
+ } else {
1184
+ probeErrors.push(`unexpected error: ${settled.reason}`);
1185
+ }
1186
+ }
1187
+ }
1188
+ await saveStateMerged(state, accounts.map((a) => a.name));
1189
+ if (probeErrors.length > 0) {
1190
+ for (const err of probeErrors) {
1191
+ console.log(` ${YELLOW}Warning: probe failed for ${err}${RESET}`);
1192
+ }
1193
+ }
1194
+ return { state, results };
1195
+ }
857
1196
  async function cmdUsage(args) {
858
1197
  const watch = args.includes("--watch") || args.includes("-w");
859
1198
  const showUsage = async () => {
@@ -887,10 +1226,10 @@ ${BOLD}${CYAN}=== Auth Switcher Usage Dashboard ===${RESET}
887
1226
  const tag = isActive ? `${GREEN} [ACTIVE]${RESET}` : isCooling ? `${RED} [COOLDOWN]${RESET}` : "";
888
1227
  console.log(` ${BOLD}${account.name}${RESET}${tag}`);
889
1228
  if (usage) {
890
- console.log(` 5h session: ${progressBar(usage.session5h.utilization, thresholds.session5h)}${resetSuffix(usage.session5h, now)}`);
891
- console.log(` 7d weekly: ${progressBar(usage.weekly7d.utilization, thresholds.weekly7d)}${resetSuffix(usage.weekly7d, now)}`);
892
- console.log(` 7d sonnet: ${progressBar(usage.weekly7dSonnet.utilization, thresholds.weekly7dSonnet)}${resetSuffix(usage.weekly7dSonnet, now)}`);
893
- console.log(` 7d fable: ${progressBar(usage.weekly7dFable.utilization, thresholds.weekly7dFable)}${resetSuffix(usage.weekly7dFable, now)}`);
1229
+ console.log(` 5h session: ${formatMetric(usage.session5h.utilization, thresholds.session5h)}${resetSuffix(usage.session5h, now)}`);
1230
+ console.log(` 7d weekly: ${formatMetric(usage.weekly7d.utilization, thresholds.weekly7d)}${resetSuffix(usage.weekly7d, now)}`);
1231
+ console.log(` 7d sonnet: ${formatMetric(usage.weekly7dSonnet.utilization, thresholds.weekly7dSonnet)}${resetSuffix(usage.weekly7dSonnet, now)}`);
1232
+ console.log(` 7d fable: ${formatMetric(usage.weekly7dFable.utilization, thresholds.weekly7dFable)}${resetSuffix(usage.weekly7dFable, now)}`);
894
1233
  const rejection = deriveAccountHealth(account, usage, thresholds, state.authFailures[account.name], now).reasons.find((reason) => reason.kind === "rejection");
895
1234
  if (rejection) {
896
1235
  const reset = rejection.reset ? ` \u2014 resets ${formatResetTime(rejection.reset, now)}` : "";
@@ -909,6 +1248,18 @@ ${BOLD}${CYAN}=== Auth Switcher Usage Dashboard ===${RESET}
909
1248
  console.log();
910
1249
  }
911
1250
  };
1251
+ const accountsData = loadAccounts();
1252
+ if (accountsData.accounts.length > 0) {
1253
+ process.stdout.write(`${DIM} Probing ${accountsData.accounts.length} account(s) for current usage...${RESET}`);
1254
+ const { results } = await probeAllAccounts(accountsData.accounts);
1255
+ const successCount = results.filter((r) => r.success).length;
1256
+ const models = [...new Set(results.filter((r) => r.model).map((r) => r.model))];
1257
+ process.stdout.write(`\r\x1B[K`);
1258
+ if (successCount < accountsData.accounts.length) {
1259
+ console.log(`${DIM} Probed ${successCount}/${accountsData.accounts.length} accounts successfully${models.length ? ` (${models.join(", ")})` : ""}${RESET}
1260
+ `);
1261
+ }
1262
+ }
912
1263
  await showUsage();
913
1264
  if (watch) {
914
1265
  console.log(`${DIM}Refreshing every 5 seconds. Press Ctrl+C to stop.${RESET}
package/dist/index.js CHANGED
@@ -571,11 +571,12 @@ async function refreshAccountToken(refreshTokenValue) {
571
571
 
572
572
  // src/state.ts
573
573
  var EMPTY_METRIC = { utilization: 0, reset: 0, status: "" };
574
+ var EMPTY_MODEL_METRIC = { utilization: null, reset: 0, status: "" };
574
575
  var EMPTY_USAGE = {
575
576
  session5h: { ...EMPTY_METRIC },
576
577
  weekly7d: { ...EMPTY_METRIC },
577
- weekly7dSonnet: { ...EMPTY_METRIC },
578
- weekly7dFable: { ...EMPTY_METRIC },
578
+ weekly7dSonnet: { ...EMPTY_MODEL_METRIC },
579
+ weekly7dFable: { ...EMPTY_MODEL_METRIC },
579
580
  rejected: { ...EMPTY_METRIC }
580
581
  };
581
582
  function defaultState() {
@@ -825,7 +826,7 @@ function isRejectionRelevant(prefix, modelFamily) {
825
826
  function metricEntries(usage, thresholds, modelFamily) {
826
827
  return Object.keys(METRIC_MODEL_FAMILY).filter((key) => isMetricRelevant(key, modelFamily)).map((key) => ({
827
828
  name: key,
828
- util: usage[key].utilization,
829
+ util: usage[key].utilization ?? 0,
829
830
  threshold: thresholds[key]
830
831
  }));
831
832
  }
@@ -1228,6 +1229,6 @@ var AuthSwitcherPlugin = async ({ client }) => {
1228
1229
  };
1229
1230
  var src_default = AuthSwitcherPlugin;
1230
1231
  export {
1231
- src_default as default,
1232
- AuthSwitcherPlugin
1232
+ AuthSwitcherPlugin,
1233
+ src_default as default
1233
1234
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-auth-switcher",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "OpenCode auth plugin for multi-account Anthropic Claude Max rotation with automatic failover.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -17,7 +17,7 @@
17
17
  "@opencode-ai/plugin": "*"
18
18
  },
19
19
  "dependencies": {
20
- "@ex-machina/opencode-anthropic-auth": "1.8.1"
20
+ "@ex-machina/opencode-anthropic-auth": "1.8.2"
21
21
  },
22
22
  "devDependencies": {
23
23
  "@opencode-ai/plugin": "latest",