@aibridge/cli 0.3.0 → 0.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.
package/README.md CHANGED
@@ -42,6 +42,7 @@ npx -y @aibridge/cli subagent --model xai-grok/grok-4.5 "summarize the architect
42
42
  | `aibridge review --model xai-grok/grok-4.5 --out review.md [--plan <plan.md>]` | You want a **different model** to pressure-test the working-tree diff against the plan contract (over-reach is a finding) — or to review the plan itself before implementing |
43
43
  | `aibridge subagent --model xai-grok/grok-4.5 "<task>"` | A self-contained task deserves a concurrent delegate, a cross-model second opinion, or a red-team pass |
44
44
  | `aibridge image-gen --model openai-codex/gpt-5.6-sol --out out.png "<prompt>"` | You need a real raster image — on a Codex, Antigravity, or Grok seat, with render verification |
45
+ | `aibridge models [--json]` | You need the exact facts for every registered model seat (accepted efforts, image format, pinned model ID) |
45
46
  | `aibridge quota` | Two-second check of every backend's remaining quota before you pipeline work |
46
47
  | `aibridge runs` | Inspect or watch past delegation runs (`~/.aibridge/runs`) |
47
48
 
@@ -63,6 +64,36 @@ Plan files — not their contents — travel between stages, so the loop is near
63
64
  - **No API keys.** Delegation runs on the backing CLIs' existing logins, each spending its own quota. (The skill treats a backend that shares your agent's own quota pool as a last resort.)
64
65
  - **Models are canonical slugs**: `<vendor>-<cli>/<model>[-<effort>]` — e.g. `xai-grok/grok-4.5`, `google-antigravity/gemini-3.6-flash`, `openai-codex/gpt-5.6-sol-high`, `anthropic-claude/opus-5`. No aliases — not short ones, and not moving vendor aliases like `opus`: every seat pins an exact model version. `aibridge <command> --help` lists every seat.
65
66
 
67
+ ## Tell your agent when to reach for it
68
+
69
+ aibridge doesn't decide when to delegate — your agent does, and left alone it will
70
+ mostly keep the work for itself. Put the routing rule in whatever instructions file
71
+ your agent already reads at the start of every session (`AGENTS.md`, `CLAUDE.md`,
72
+ `.cursorrules`, …). Something like:
73
+
74
+ ```markdown
75
+ ## Delegation gate — decide before you implement
76
+
77
+ The moment a task becomes implementation you could fully specify, say the call out
78
+ loud — **solo** or **aibridge** — plus one line of why. Delegating is the default.
79
+ Stay solo only when the edit is smaller than the spec would be, or the work needs
80
+ live judgment, your own session's tools, or tight back-and-forth. Never default to
81
+ solo silently.
82
+
83
+ Route by size and risk:
84
+
85
+ - tiny → solo
86
+ - clearly specified and self-contained → `aibridge subagent`
87
+ - large or risky → `aibridge plan` → read and approve the plan file →
88
+ `aibridge implement` → `aibridge review`
89
+
90
+ Delegated work is yours to verify: re-run the real gates before trusting a diff.
91
+ Prefer a reviewer from a different model family than whoever implemented.
92
+ ```
93
+
94
+ Tune the seats and thresholds to your own quotas. The value is that the decision is
95
+ explicit and made *before* the work starts, rather than rationalised afterwards.
96
+
66
97
  ## Requirements
67
98
 
68
99
  - **Node ≥ 24.11**
package/dist/cli.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { r as runCli, t as buildContext } from "./context-B6I4QI9z.mjs";
2
+ import { r as runCli, t as buildContext } from "./context-v_ns4n_W.mjs";
3
3
  //#region src/cli.ts
4
4
  await runCli(buildContext(process), process.argv.slice(2));
5
5
  //#endregion
@@ -10,6 +10,7 @@ import { fetchClaudeQuota } from "@aibridge/driver-claude";
10
10
  import * as codex from "@aibridge/driver-codex";
11
11
  import { fetchCodexQuota } from "@aibridge/driver-codex";
12
12
  import * as grok from "@aibridge/driver-grok";
13
+ import { fetchGrokQuota } from "@aibridge/driver-grok";
13
14
  import { runCaptured } from "@aibridge/proc";
14
15
  import { randomBytes } from "node:crypto";
15
16
  //#region src/models.ts
@@ -23,7 +24,7 @@ const MODELS = {
23
24
  "medium",
24
25
  "high"
25
26
  ],
26
- brief: "xAI Grok 4.5 via grok CLI — default for plan & review; own xAI login"
27
+ brief: "xAI Grok 4.5 via grok CLI — own xAI login; ~30 req/min, ~1k msgs/day, single-flight"
27
28
  },
28
29
  "google-antigravity/gemini-3.6-flash": {
29
30
  slug: "google-antigravity/gemini-3.6-flash",
@@ -35,7 +36,15 @@ const MODELS = {
35
36
  "high"
36
37
  ],
37
38
  defaultEffort: "high",
38
- brief: "Google Gemini 3.6 Flash via agy — default for implement, also image-gen; own Antigravity login"
39
+ brief: "Google Gemini 3.6 Flash via agy — own Antigravity login; quota shared across all Gemini tiers"
40
+ },
41
+ "google-antigravity/gemini-3.1-pro": {
42
+ slug: "google-antigravity/gemini-3.1-pro",
43
+ backend: "agy",
44
+ efforts: ["low", "high"],
45
+ backendModel: "gemini-3.1-pro",
46
+ defaultEffort: "high",
47
+ brief: "Google Gemini 3.1 Pro via agy — own Antigravity login; quota shared across all Gemini tiers"
39
48
  },
40
49
  "google-antigravity/claude-sonnet-4-6": {
41
50
  slug: "google-antigravity/claude-sonnet-4-6",
@@ -68,12 +77,36 @@ const MODELS = {
68
77
  "high",
69
78
  "xhigh"
70
79
  ],
71
- brief: "OpenAI Codex gpt-5.6-sol via codex CLI"
80
+ brief: "OpenAI gpt-5.6-sol via codex CLI — frontier agentic coding; own ChatGPT login"
72
81
  },
73
- "anthropic-claude/sonnet-5": {
74
- slug: "anthropic-claude/sonnet-5",
82
+ "openai-codex/gpt-5.6-terra": {
83
+ slug: "openai-codex/gpt-5.6-terra",
84
+ backend: "codex",
85
+ backendModel: "gpt-5.6-terra",
86
+ efforts: [
87
+ "low",
88
+ "medium",
89
+ "high",
90
+ "xhigh"
91
+ ],
92
+ brief: "OpenAI gpt-5.6-terra via codex CLI — balanced, everyday coding; own ChatGPT login"
93
+ },
94
+ "openai-codex/gpt-5.6-luna": {
95
+ slug: "openai-codex/gpt-5.6-luna",
96
+ backend: "codex",
97
+ backendModel: "gpt-5.6-luna",
98
+ efforts: [
99
+ "low",
100
+ "medium",
101
+ "high",
102
+ "xhigh"
103
+ ],
104
+ brief: "OpenAI gpt-5.6-luna via codex CLI — fast and affordable coding; own ChatGPT login"
105
+ },
106
+ "anthropic-claude/fable-5": {
107
+ slug: "anthropic-claude/fable-5",
75
108
  backend: "claude",
76
- backendModel: "claude-sonnet-5",
109
+ backendModel: "claude-fable-5",
77
110
  efforts: [
78
111
  "low",
79
112
  "medium",
@@ -81,12 +114,13 @@ const MODELS = {
81
114
  "xhigh",
82
115
  "max"
83
116
  ],
84
- brief: "Claude Sonnet 5 via claude CLI — bills your Claude subscription"
117
+ defaultEffort: "high",
118
+ brief: "Claude Fable 5 via claude CLI — hardest, longest-running work; bills the claude CLI subscription"
85
119
  },
86
120
  "anthropic-claude/opus-5": {
87
121
  slug: "anthropic-claude/opus-5",
88
122
  backend: "claude",
89
- backendModel: "claude-opus-5",
123
+ backendModel: "claude-opus-5[1m]",
90
124
  efforts: [
91
125
  "low",
92
126
  "medium",
@@ -95,12 +129,12 @@ const MODELS = {
95
129
  "max"
96
130
  ],
97
131
  defaultEffort: "high",
98
- brief: "Claude Opus 5 via claude CLI (default effort: high) bills subscription"
132
+ brief: "Claude Opus 5, 1M context via claude CLI everyday complex work; bills the claude CLI subscription"
99
133
  },
100
- "anthropic-claude/opus-5-1m": {
101
- slug: "anthropic-claude/opus-5-1m",
134
+ "anthropic-claude/sonnet-5": {
135
+ slug: "anthropic-claude/sonnet-5",
102
136
  backend: "claude",
103
- backendModel: "claude-opus-5[1m]",
137
+ backendModel: "claude-sonnet-5",
104
138
  efforts: [
105
139
  "low",
106
140
  "medium",
@@ -108,8 +142,20 @@ const MODELS = {
108
142
  "xhigh",
109
143
  "max"
110
144
  ],
111
- defaultEffort: "high",
112
- brief: "Claude Opus 5, 1M context via claude CLI — long-context work; bills subscription"
145
+ brief: "Claude Sonnet 5 via claude CLI — routine work; bills the claude CLI subscription"
146
+ },
147
+ "anthropic-claude/haiku-4-5": {
148
+ slug: "anthropic-claude/haiku-4-5",
149
+ backend: "claude",
150
+ backendModel: "claude-haiku-4-5-20251001",
151
+ efforts: [
152
+ "low",
153
+ "medium",
154
+ "high",
155
+ "xhigh",
156
+ "max"
157
+ ],
158
+ brief: "Claude Haiku 4.5 via claude CLI — quick answers; bills the claude CLI subscription"
113
159
  }
114
160
  };
115
161
  const IMAGE_GEN_FORMATS = /* @__PURE__ */ new Map([
@@ -209,6 +255,7 @@ const DRIVERS = {
209
255
  grok: {
210
256
  probe: () => grok.probe(),
211
257
  run: (task) => grok.run(task),
258
+ quota: () => grok.fetchGrokQuota(),
212
259
  generateImage: (req) => grok.generateImage(req)
213
260
  },
214
261
  codex: {
@@ -488,8 +535,17 @@ function evaluateCodexPreflight(snapshot) {
488
535
  };
489
536
  return { ok: true };
490
537
  }
538
+ function evaluateGrokPreflight(snapshot) {
539
+ if (snapshot.usedPercent !== void 0 && snapshot.usedPercent >= 100) return {
540
+ ok: false,
541
+ message: "grok credit quota exhausted",
542
+ resetAt: snapshot.periodEnd
543
+ };
544
+ return { ok: true };
545
+ }
491
546
  async function preflightModel(resolved) {
492
547
  if (resolved.spec.backend === "codex") return preflightCodex();
548
+ if (resolved.spec.backend === "grok") return preflightGrok();
493
549
  if (resolved.spec.backend !== "agy") return { ok: true };
494
550
  try {
495
551
  return evaluateAgyPreflight(await fetchAgyQuota(), backendModelId(resolved));
@@ -510,6 +566,16 @@ async function preflightCodex() {
510
566
  };
511
567
  }
512
568
  }
569
+ async function preflightGrok() {
570
+ try {
571
+ return evaluateGrokPreflight(await fetchGrokQuota());
572
+ } catch (err) {
573
+ return {
574
+ ok: true,
575
+ warning: `quota preflight failed (${err.message}); proceeding`
576
+ };
577
+ }
578
+ }
513
579
  function formatReset$1(resetTime) {
514
580
  if (!resetTime) return "-";
515
581
  const ms = new Date(resetTime).getTime() - Date.now();
@@ -744,6 +810,70 @@ const implement = buildCommand({
744
810
  }
745
811
  });
746
812
  //#endregion
813
+ //#region src/commands/models/impl.ts
814
+ const BACKEND_DISPLAY_NAMES = {
815
+ grok: "grok (Grok CLI)",
816
+ agy: "agy (Antigravity)",
817
+ codex: "codex (Codex CLI)",
818
+ claude: "claude (Claude Code CLI)"
819
+ };
820
+ function modelsImpl(flags) {
821
+ const specs = Object.values(MODELS);
822
+ if (flags.json) {
823
+ const jsonOutput = specs.map((spec) => ({
824
+ slug: spec.slug,
825
+ backend: spec.backend,
826
+ backendModel: spec.backendModel,
827
+ efforts: spec.efforts ? [...spec.efforts] : [],
828
+ defaultEffort: spec.defaultEffort ?? null,
829
+ image: imageFormatFor({
830
+ spec,
831
+ effort: void 0
832
+ }) ?? null,
833
+ brief: spec.brief
834
+ }));
835
+ this.process.stdout.write(`${JSON.stringify(jsonOutput)}\n`);
836
+ return;
837
+ }
838
+ const backends = [];
839
+ for (const spec of specs) if (!backends.includes(spec.backend)) backends.push(spec.backend);
840
+ let firstBackend = true;
841
+ for (const backend of backends) {
842
+ if (!firstBackend) this.process.stdout.write("\n");
843
+ firstBackend = false;
844
+ this.process.stdout.write(`=== ${BACKEND_DISPLAY_NAMES[backend]} ===\n`);
845
+ const backendSpecs = specs.filter((spec) => spec.backend === backend);
846
+ for (const spec of backendSpecs) {
847
+ this.process.stdout.write(` ${spec.slug}\n`);
848
+ const segments = [];
849
+ if (spec.efforts) {
850
+ const formattedEfforts = spec.efforts.map((e) => e === spec.defaultEffort ? `${e}*` : e).join(" | ");
851
+ segments.push(`efforts: ${formattedEfforts}`);
852
+ }
853
+ const img = imageFormatFor({
854
+ spec,
855
+ effort: void 0
856
+ });
857
+ segments.push(`image: ${img ?? "—"}`);
858
+ segments.push(`id: ${spec.backendModel}`);
859
+ this.process.stdout.write(` ${segments.join(" · ")}\n`);
860
+ this.process.stdout.write(` ${spec.brief}\n`);
861
+ }
862
+ }
863
+ if (specs.some((spec) => spec.defaultEffort !== void 0)) this.process.stdout.write("\n* = effort used when the slug has no -<effort> suffix\n");
864
+ }
865
+ //#endregion
866
+ //#region src/commands/models/command.ts
867
+ const models = buildCommand({
868
+ func: modelsImpl,
869
+ parameters: { flags: { json: {
870
+ kind: "boolean",
871
+ withNegated: false,
872
+ brief: "Emit the registry as JSON"
873
+ } } },
874
+ docs: { brief: "List every model seat in the registry (slug, efforts, image format)" }
875
+ });
876
+ //#endregion
747
877
  //#region src/commands/plan/impl.ts
748
878
  function countOpenQuestions(markdown) {
749
879
  const headingIdx = markdown.search(/^## Open questions[ \t]*$/m);
@@ -905,6 +1035,17 @@ function formatReset(resetTime) {
905
1035
  const rel = mins < 60 ? `${mins}m` : `${Math.floor(mins / 60)}h${mins % 60}m`;
906
1036
  return `${new Date(resetTime).toLocaleTimeString()} (in ${rel})`;
907
1037
  }
1038
+ function renderGrok(ctx, snapshot) {
1039
+ ctx.process.stdout.write("=== grok (xAI) — used this period ===\n");
1040
+ ctx.process.stdout.write(`${"PERIOD".padEnd(10)} ${"USED".padEnd(10)} RESET\n`);
1041
+ const usedPctStr = snapshot.usedPercent !== void 0 ? `${snapshot.usedPercent}%` : "?";
1042
+ const periodStr = snapshot.periodType ?? "-";
1043
+ ctx.process.stdout.write(`${periodStr.padEnd(10)} ${usedPctStr.padEnd(10)} ${formatReset(snapshot.periodEnd)}\n`);
1044
+ if (snapshot.products.length > 0) {
1045
+ const prods = snapshot.products.map((p) => `${p.product} ${p.usedPercent}%`).join(" · ");
1046
+ ctx.process.stdout.write(` ${prods}\n`);
1047
+ }
1048
+ }
908
1049
  function renderAgy(ctx, snapshot) {
909
1050
  ctx.process.stdout.write("=== agy (Antigravity) — remaining per model group ===\n");
910
1051
  for (const group of snapshot.groups) {
@@ -934,14 +1075,16 @@ function renderSection(ctx, result, title, render) {
934
1075
  else ctx.process.stdout.write(`=== ${title} ===\nunavailable: ${result.reason.message}\n`);
935
1076
  }
936
1077
  async function quotaImpl(flags) {
937
- const [agy, codex, claude] = await Promise.allSettled([
1078
+ const [grok, agy, codex, claude] = await Promise.allSettled([
1079
+ fetchGrokQuota(),
938
1080
  fetchAgyQuota(),
939
1081
  fetchCodexQuota(),
940
1082
  fetchClaudeQuota()
941
1083
  ]);
942
- const allFailed = agy.status === "rejected" && codex.status === "rejected" && claude.status === "rejected";
1084
+ const allFailed = grok.status === "rejected" && agy.status === "rejected" && codex.status === "rejected" && claude.status === "rejected";
943
1085
  if (flags.json) {
944
1086
  this.process.stdout.write(`${JSON.stringify({
1087
+ grok: grok.status === "fulfilled" ? grok.value : { error: String(grok.reason) },
945
1088
  agy: agy.status === "fulfilled" ? agy.value : { error: String(agy.reason) },
946
1089
  codex: codex.status === "fulfilled" ? codex.value : { error: String(codex.reason) },
947
1090
  claude: claude.status === "fulfilled" ? claude.value : { error: String(claude.reason) }
@@ -949,6 +1092,8 @@ async function quotaImpl(flags) {
949
1092
  if (allFailed) this.process.exitCode = 1;
950
1093
  return;
951
1094
  }
1095
+ renderSection(this, grok, "grok (xAI)", renderGrok);
1096
+ this.process.stdout.write("\n");
952
1097
  renderSection(this, agy, "agy (Antigravity)", renderAgy);
953
1098
  this.process.stdout.write("\n");
954
1099
  renderSection(this, codex, "codex (ChatGPT)", renderCodex);
@@ -964,8 +1109,10 @@ const quota = buildCommand({
964
1109
  brief: "Emit the raw snapshot as JSON"
965
1110
  } } },
966
1111
  docs: {
967
- brief: "Show agy / codex / claude quota with reset times",
1112
+ brief: "Show grok / agy / codex / claude quota with reset times",
968
1113
  fullDescription: [
1114
+ "grok: reads ~/.grok/auth.json and asks the xAI billing endpoint for the",
1115
+ "weekly credit usage percentage and per-product split.",
969
1116
  "agy: reads its cached OAuth token (~/.gemini/antigravity-cli/) and asks the",
970
1117
  "Cloud Code API for per-model remaining quota. EXHAUSTED means agy turns on",
971
1118
  "that model fail with an empty answer until the reset time.",
@@ -1390,8 +1537,8 @@ const fullDescription = [
1390
1537
  "",
1391
1538
  "Available models (canonical slug):",
1392
1539
  ...listModelHelpLines(),
1393
- "Recommended first choice: xai-grok/grok-4.5 (off-budget). The claude-backend slugs are FALLBACKS for",
1394
- "when the off-budget CLIs are quota-exhaustedthey bill your Claude subscription."
1540
+ "Recommended first choice: xai-grok/grok-4.5. Whichever seat runs on the same provider as the",
1541
+ "agent you orchestrate from is your last resort it spends the pool you are already burning."
1395
1542
  ].join("\n");
1396
1543
  const subagent = buildCommand({
1397
1544
  func: subagent$1,
@@ -1465,9 +1612,10 @@ const app = buildApplication(buildRouteMap({
1465
1612
  subagent,
1466
1613
  "image-gen": imageGen,
1467
1614
  runs,
1468
- quota
1615
+ quota,
1616
+ models
1469
1617
  },
1470
- docs: { brief: "Bridge tasks to non-Claude AI CLIs — a plan → implement → review workflow, task delegation, and image generation (codex / agy / grok seats)." }
1618
+ docs: { brief: "Bridge tasks to the other AI CLIs on this machine — a plan → implement → review workflow, task delegation, and image generation (codex / agy / grok seats)." }
1471
1619
  }), {
1472
1620
  name: "aibridge",
1473
1621
  versionInfo: { currentVersion: version },
package/dist/index.d.mts CHANGED
@@ -2,6 +2,7 @@ import { CommandContext } from "@stricli/core";
2
2
  import { AgyQuotaSnapshot } from "@aibridge/driver-agy";
3
3
  import { ClaudeQuotaSnapshot } from "@aibridge/driver-claude";
4
4
  import { CodexQuotaSnapshot } from "@aibridge/driver-codex";
5
+ import { GrokQuotaSnapshot } from "@aibridge/driver-grok";
5
6
  //#region src/context.d.ts
6
7
  interface LocalContext extends CommandContext {
7
8
  /** Full Node process — satisfies stricli WritableStreams + exitCode/env/cwd used by impls. */
@@ -21,7 +22,7 @@ declare function runCli(ctx: LocalContext, argv: readonly string[]): Promise<voi
21
22
  * Models are registered by canonical, provider-qualified slug —
22
23
  * `<vendor>-<cli>/<model>[-<effort>]`, e.g. `openai-codex/gpt-5.6-sol-high`.
23
24
  * Canonical slugs only — no short aliases, by design. That holds on both sides:
24
- * `backendModel` is a pinned model id (`claude-opus-5`), never a moving vendor
25
+ * `backendModel` is a pinned model id (`claude-sonnet-5`), never a moving vendor
25
26
  * alias (`opus`), so a seat never silently changes model under you.
26
27
  */
27
28
  type Backend = 'agy' | 'claude' | 'codex' | 'grok';
@@ -77,7 +78,7 @@ type DelegationResult = {
77
78
  readonly message: string;
78
79
  readonly exitCode: number | null;
79
80
  };
80
- type QuotaSnapshot = AgyQuotaSnapshot | CodexQuotaSnapshot | ClaudeQuotaSnapshot;
81
+ type QuotaSnapshot = AgyQuotaSnapshot | CodexQuotaSnapshot | ClaudeQuotaSnapshot | GrokQuotaSnapshot;
81
82
  interface ImageGenRequest {
82
83
  readonly prompt: string;
83
84
  readonly workDir: string;
package/dist/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { S as supportsImageGen, _ as backendModelId, a as readRunLogs, b as listModelHelpLines, c as evaluateCodexPreflight, d as renderPreflightRefusal, f as delegate, g as MODELS, h as positiveIntSeconds, i as listRuns, l as preflightCodex, m as nonEmptyPrompt, n as app, o as startRun, p as getDriver, r as runCli, s as evaluateAgyPreflight, t as buildContext, u as preflightModel, v as formatImageGenModelError, x as resolveModel, y as formatUnknownModelError } from "./context-B6I4QI9z.mjs";
1
+ import { S as supportsImageGen, _ as backendModelId, a as readRunLogs, b as listModelHelpLines, c as evaluateCodexPreflight, d as renderPreflightRefusal, f as delegate, g as MODELS, h as positiveIntSeconds, i as listRuns, l as preflightCodex, m as nonEmptyPrompt, n as app, o as startRun, p as getDriver, r as runCli, s as evaluateAgyPreflight, t as buildContext, u as preflightModel, v as formatImageGenModelError, x as resolveModel, y as formatUnknownModelError } from "./context-v_ns4n_W.mjs";
2
2
  export { MODELS, app, backendModelId, buildContext, delegate, evaluateAgyPreflight, evaluateCodexPreflight, formatImageGenModelError, formatUnknownModelError, getDriver, listModelHelpLines, listRuns, nonEmptyPrompt, positiveIntSeconds, preflightCodex, preflightModel, readRunLogs, renderPreflightRefusal, resolveModel, runCli, startRun, supportsImageGen };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aibridge/cli",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "CLI that bridges tasks to AI CLIs on your machine (plan / implement / review / subagent / image-gen)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,11 +35,11 @@
35
35
  ],
36
36
  "dependencies": {
37
37
  "@stricli/core": "1.3.0",
38
- "@aibridge/proc": "0.3.0",
39
- "@aibridge/driver-agy": "0.3.0",
40
- "@aibridge/driver-grok": "0.3.0",
41
- "@aibridge/driver-codex": "0.3.0",
42
- "@aibridge/driver-claude": "0.3.0"
38
+ "@aibridge/proc": "0.4.0",
39
+ "@aibridge/driver-grok": "0.4.0",
40
+ "@aibridge/driver-agy": "0.4.0",
41
+ "@aibridge/driver-codex": "0.4.0",
42
+ "@aibridge/driver-claude": "0.4.0"
43
43
  },
44
44
  "devDependencies": {
45
45
  "tsdown": "0.22.14"
package/src/app.ts CHANGED
@@ -2,6 +2,7 @@ import { createRequire } from 'node:module';
2
2
  import { buildApplication, buildRouteMap, run } from '@stricli/core';
3
3
  import { imageGen } from './commands/image-gen/command.ts';
4
4
  import { implement } from './commands/implement/command.ts';
5
+ import { models } from './commands/models/command.ts';
5
6
  import { plan } from './commands/plan/command.ts';
6
7
  import { quota } from './commands/quota/command.ts';
7
8
  import { review } from './commands/review/command.ts';
@@ -14,7 +15,7 @@ const require = createRequire(import.meta.url);
14
15
  const { version } = require('../package.json') as { version: string };
15
16
 
16
17
  const BRIEF =
17
- 'Bridge tasks to non-Claude AI CLIs — a plan → implement → review workflow, task delegation, and image generation (codex / agy / grok seats).';
18
+ 'Bridge tasks to the other AI CLIs on this machine — a plan → implement → review workflow, task delegation, and image generation (codex / agy / grok seats).';
18
19
 
19
20
  const routes = buildRouteMap({
20
21
  routes: {
@@ -25,6 +26,7 @@ const routes = buildRouteMap({
25
26
  'image-gen': imageGen,
26
27
  runs,
27
28
  quota,
29
+ models,
28
30
  },
29
31
  docs: {
30
32
  brief: BRIEF,
@@ -0,0 +1,18 @@
1
+ import { buildCommand } from '@stricli/core';
2
+ import modelsImpl from './impl.ts';
3
+
4
+ export const models = buildCommand({
5
+ func: modelsImpl,
6
+ parameters: {
7
+ flags: {
8
+ json: {
9
+ kind: 'boolean',
10
+ withNegated: false,
11
+ brief: 'Emit the registry as JSON',
12
+ },
13
+ },
14
+ },
15
+ docs: {
16
+ brief: 'List every model seat in the registry (slug, efforts, image format)',
17
+ },
18
+ });
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import type { LocalContext } from '../../context.ts';
3
+ import { MODELS } from '../../models.ts';
4
+ import modelsImpl from './impl.ts';
5
+
6
+ function createTestContext() {
7
+ let stdoutText = '';
8
+ const fakeProcess = {
9
+ stdout: {
10
+ write(chunk: string | Uint8Array) {
11
+ stdoutText += chunk.toString();
12
+ return true;
13
+ },
14
+ },
15
+ stderr: {
16
+ write() {
17
+ return true;
18
+ },
19
+ },
20
+ exitCode: 0,
21
+ } as unknown as NodeJS.Process;
22
+
23
+ return {
24
+ ctx: { process: fakeProcess } as LocalContext,
25
+ getStdout: () => stdoutText,
26
+ };
27
+ }
28
+
29
+ describe('modelsImpl', () => {
30
+ it('--json emits parseable JSON with one entry per key of MODELS, with seven documented fields', () => {
31
+ const { ctx, getStdout } = createTestContext();
32
+ modelsImpl.call(ctx, { json: true });
33
+
34
+ const raw = getStdout();
35
+ const data = JSON.parse(raw);
36
+
37
+ expect(Array.isArray(data)).toBe(true);
38
+ expect(data.length).toBe(Object.keys(MODELS).length);
39
+
40
+ for (const item of data) {
41
+ expect(item).toHaveProperty('slug');
42
+ expect(item).toHaveProperty('backend');
43
+ expect(item).toHaveProperty('backendModel');
44
+ expect(item).toHaveProperty('efforts');
45
+ expect(item).toHaveProperty('defaultEffort');
46
+ expect(item).toHaveProperty('image');
47
+ expect(item).toHaveProperty('brief');
48
+ expect(Object.keys(item)).toHaveLength(7);
49
+ }
50
+ });
51
+
52
+ it('reports efforts: ["low", "high"] and defaultEffort: "high" for gemini-3.1-pro in JSON', () => {
53
+ const { ctx, getStdout } = createTestContext();
54
+ modelsImpl.call(ctx, { json: true });
55
+
56
+ const data = JSON.parse(getStdout());
57
+ const geminiPro = data.find(
58
+ (item: { slug: string }) => item.slug === 'google-antigravity/gemini-3.1-pro',
59
+ );
60
+ expect(geminiPro).toBeDefined();
61
+ expect(geminiPro.efforts).toEqual(['low', 'high']);
62
+ expect(geminiPro.defaultEffort).toBe('high');
63
+ });
64
+
65
+ it('reports backendModel and image correctly for opus-5 and gpt-5.6-sol in JSON', () => {
66
+ const { ctx, getStdout } = createTestContext();
67
+ modelsImpl.call(ctx, { json: true });
68
+
69
+ const data = JSON.parse(getStdout());
70
+ const opus = data.find((item: { slug: string }) => item.slug === 'anthropic-claude/opus-5');
71
+ expect(opus).toBeDefined();
72
+ expect(opus.backendModel).toBe('claude-opus-5[1m]');
73
+ expect(opus.image).toBeNull();
74
+
75
+ const sol = data.find((item: { slug: string }) => item.slug === 'openai-codex/gpt-5.6-sol');
76
+ expect(sol).toBeDefined();
77
+ expect(sol.image).toBe('png');
78
+ });
79
+
80
+ it('human output (no --json) contains every slug in MODELS', () => {
81
+ const { ctx, getStdout } = createTestContext();
82
+ modelsImpl.call(ctx, { json: false });
83
+
84
+ const output = getStdout();
85
+ for (const slug of Object.keys(MODELS)) {
86
+ expect(output).toContain(slug);
87
+ }
88
+ });
89
+ });
@@ -0,0 +1,71 @@
1
+ import type { LocalContext } from '../../context.ts';
2
+ import { type Backend, imageFormatFor, MODELS } from '../../models.ts';
3
+
4
+ export interface ModelsFlags {
5
+ readonly json: boolean;
6
+ }
7
+
8
+ const BACKEND_DISPLAY_NAMES: Record<Backend, string> = {
9
+ grok: 'grok (Grok CLI)',
10
+ agy: 'agy (Antigravity)',
11
+ codex: 'codex (Codex CLI)',
12
+ claude: 'claude (Claude Code CLI)',
13
+ };
14
+
15
+ export default function modelsImpl(this: LocalContext, flags: ModelsFlags): void {
16
+ const specs = Object.values(MODELS);
17
+
18
+ if (flags.json) {
19
+ const jsonOutput = specs.map(spec => ({
20
+ slug: spec.slug,
21
+ backend: spec.backend,
22
+ backendModel: spec.backendModel,
23
+ efforts: spec.efforts ? [...spec.efforts] : [],
24
+ defaultEffort: spec.defaultEffort ?? null,
25
+ image: imageFormatFor({ spec, effort: undefined }) ?? null,
26
+ brief: spec.brief,
27
+ }));
28
+ this.process.stdout.write(`${JSON.stringify(jsonOutput)}\n`);
29
+ return;
30
+ }
31
+
32
+ const backends: Backend[] = [];
33
+ for (const spec of specs) {
34
+ if (!backends.includes(spec.backend)) {
35
+ backends.push(spec.backend);
36
+ }
37
+ }
38
+
39
+ let firstBackend = true;
40
+ for (const backend of backends) {
41
+ if (!firstBackend) {
42
+ this.process.stdout.write('\n');
43
+ }
44
+ firstBackend = false;
45
+
46
+ this.process.stdout.write(`=== ${BACKEND_DISPLAY_NAMES[backend]} ===\n`);
47
+ const backendSpecs = specs.filter(spec => spec.backend === backend);
48
+ for (const spec of backendSpecs) {
49
+ this.process.stdout.write(` ${spec.slug}\n`);
50
+
51
+ const segments: string[] = [];
52
+ if (spec.efforts) {
53
+ const formattedEfforts = spec.efforts
54
+ .map(e => (e === spec.defaultEffort ? `${e}*` : e))
55
+ .join(' | ');
56
+ segments.push(`efforts: ${formattedEfforts}`);
57
+ }
58
+ const img = imageFormatFor({ spec, effort: undefined });
59
+ segments.push(`image: ${img ?? '—'}`);
60
+ segments.push(`id: ${spec.backendModel}`);
61
+
62
+ this.process.stdout.write(` ${segments.join(' · ')}\n`);
63
+ this.process.stdout.write(` ${spec.brief}\n`);
64
+ }
65
+ }
66
+
67
+ const hasDefaultEffort = specs.some(spec => spec.defaultEffort !== undefined);
68
+ if (hasDefaultEffort) {
69
+ this.process.stdout.write('\n* = effort used when the slug has no -<effort> suffix\n');
70
+ }
71
+ }
@@ -2,6 +2,8 @@ import { buildCommand } from '@stricli/core';
2
2
  import quotaImpl from './impl.ts';
3
3
 
4
4
  const fullDescription = [
5
+ 'grok: reads ~/.grok/auth.json and asks the xAI billing endpoint for the',
6
+ 'weekly credit usage percentage and per-product split.',
5
7
  'agy: reads its cached OAuth token (~/.gemini/antigravity-cli/) and asks the',
6
8
  'Cloud Code API for per-model remaining quota. EXHAUSTED means agy turns on',
7
9
  'that model fail with an empty answer until the reset time.',
@@ -24,7 +26,7 @@ export const quota = buildCommand({
24
26
  },
25
27
  },
26
28
  docs: {
27
- brief: 'Show agy / codex / claude quota with reset times',
29
+ brief: 'Show grok / agy / codex / claude quota with reset times',
28
30
  fullDescription,
29
31
  },
30
32
  });
@@ -1,6 +1,7 @@
1
1
  import { type AgyQuotaSnapshot, fetchAgyQuota } from '@aibridge/driver-agy';
2
2
  import { type ClaudeQuotaSnapshot, fetchClaudeQuota } from '@aibridge/driver-claude';
3
3
  import { type CodexQuotaSnapshot, fetchCodexQuota } from '@aibridge/driver-codex';
4
+ import { fetchGrokQuota, type GrokQuotaSnapshot } from '@aibridge/driver-grok';
4
5
  import type { LocalContext } from '../../context.ts';
5
6
 
6
7
  export interface QuotaFlags {
@@ -17,6 +18,20 @@ function formatReset(resetTime: string | undefined): string {
17
18
  return `${new Date(resetTime).toLocaleTimeString()} (in ${rel})`;
18
19
  }
19
20
 
21
+ function renderGrok(ctx: LocalContext, snapshot: GrokQuotaSnapshot): void {
22
+ ctx.process.stdout.write('=== grok (xAI) — used this period ===\n');
23
+ ctx.process.stdout.write(`${'PERIOD'.padEnd(10)} ${'USED'.padEnd(10)} RESET\n`);
24
+ const usedPctStr = snapshot.usedPercent !== undefined ? `${snapshot.usedPercent}%` : '?';
25
+ const periodStr = snapshot.periodType ?? '-';
26
+ ctx.process.stdout.write(
27
+ `${periodStr.padEnd(10)} ${usedPctStr.padEnd(10)} ${formatReset(snapshot.periodEnd)}\n`,
28
+ );
29
+ if (snapshot.products.length > 0) {
30
+ const prods = snapshot.products.map(p => `${p.product} ${p.usedPercent}%`).join(' · ');
31
+ ctx.process.stdout.write(` ${prods}\n`);
32
+ }
33
+ }
34
+
20
35
  function renderAgy(ctx: LocalContext, snapshot: AgyQuotaSnapshot): void {
21
36
  ctx.process.stdout.write('=== agy (Antigravity) — remaining per model group ===\n');
22
37
  for (const group of snapshot.groups) {
@@ -75,19 +90,24 @@ function renderSection<T>(
75
90
  }
76
91
 
77
92
  export default async function quotaImpl(this: LocalContext, flags: QuotaFlags): Promise<void> {
78
- const [agy, codex, claude] = await Promise.allSettled([
93
+ const [grok, agy, codex, claude] = await Promise.allSettled([
94
+ fetchGrokQuota(),
79
95
  fetchAgyQuota(),
80
96
  fetchCodexQuota(),
81
97
  fetchClaudeQuota(),
82
98
  ]);
83
99
 
84
100
  const allFailed =
85
- agy.status === 'rejected' && codex.status === 'rejected' && claude.status === 'rejected';
101
+ grok.status === 'rejected' &&
102
+ agy.status === 'rejected' &&
103
+ codex.status === 'rejected' &&
104
+ claude.status === 'rejected';
86
105
 
87
106
  if (flags.json) {
88
107
  this.process.stdout.write(
89
108
  `${JSON.stringify(
90
109
  {
110
+ grok: grok.status === 'fulfilled' ? grok.value : { error: String(grok.reason) },
91
111
  agy: agy.status === 'fulfilled' ? agy.value : { error: String(agy.reason) },
92
112
  codex: codex.status === 'fulfilled' ? codex.value : { error: String(codex.reason) },
93
113
  claude: claude.status === 'fulfilled' ? claude.value : { error: String(claude.reason) },
@@ -100,6 +120,8 @@ export default async function quotaImpl(this: LocalContext, flags: QuotaFlags):
100
120
  return;
101
121
  }
102
122
 
123
+ renderSection(this, grok, 'grok (xAI)', renderGrok);
124
+ this.process.stdout.write('\n');
103
125
  renderSection(this, agy, 'agy (Antigravity)', renderAgy);
104
126
  this.process.stdout.write('\n');
105
127
  renderSection(this, codex, 'codex (ChatGPT)', renderCodex);
@@ -8,8 +8,8 @@ const fullDescription = [
8
8
  '',
9
9
  'Available models (canonical slug):',
10
10
  ...listModelHelpLines(),
11
- 'Recommended first choice: xai-grok/grok-4.5 (off-budget). The claude-backend slugs are FALLBACKS for',
12
- 'when the off-budget CLIs are quota-exhaustedthey bill your Claude subscription.',
11
+ 'Recommended first choice: xai-grok/grok-4.5. Whichever seat runs on the same provider as the',
12
+ 'agent you orchestrate from is your last resort it spends the pool you are already burning.',
13
13
  ].join('\n');
14
14
 
15
15
  export const subagent = buildCommand({
package/src/driver.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { AgyQuotaSnapshot } from '@aibridge/driver-agy';
2
2
  import type { ClaudeQuotaSnapshot } from '@aibridge/driver-claude';
3
3
  import type { CodexQuotaSnapshot } from '@aibridge/driver-codex';
4
+ import type { GrokQuotaSnapshot } from '@aibridge/driver-grok';
4
5
  import type { Effort } from './models.ts';
5
6
 
6
7
  export type Availability =
@@ -28,7 +29,11 @@ export type DelegationResult =
28
29
  readonly exitCode: number | null;
29
30
  };
30
31
 
31
- export type QuotaSnapshot = AgyQuotaSnapshot | CodexQuotaSnapshot | ClaudeQuotaSnapshot;
32
+ export type QuotaSnapshot =
33
+ | AgyQuotaSnapshot
34
+ | CodexQuotaSnapshot
35
+ | ClaudeQuotaSnapshot
36
+ | GrokQuotaSnapshot;
32
37
 
33
38
  export interface ImageGenRequest {
34
39
  readonly prompt: string;
package/src/drivers.ts CHANGED
@@ -15,6 +15,7 @@ const agyDriver: AgentCliDriver = {
15
15
  const grokDriver: AgentCliDriver = {
16
16
  probe: () => grok.probe(),
17
17
  run: task => grok.run(task),
18
+ quota: () => grok.fetchGrokQuota(),
18
19
  generateImage: req => grok.generateImage(req),
19
20
  };
20
21
 
package/src/models.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Models are registered by canonical, provider-qualified slug —
5
5
  * `<vendor>-<cli>/<model>[-<effort>]`, e.g. `openai-codex/gpt-5.6-sol-high`.
6
6
  * Canonical slugs only — no short aliases, by design. That holds on both sides:
7
- * `backendModel` is a pinned model id (`claude-opus-5`), never a moving vendor
7
+ * `backendModel` is a pinned model id (`claude-sonnet-5`), never a moving vendor
8
8
  * alias (`opus`), so a seat never silently changes model under you.
9
9
  */
10
10
 
@@ -31,7 +31,7 @@ export const MODELS: Record<string, ModelSpec> = {
31
31
  backend: 'grok',
32
32
  backendModel: 'grok-4.5',
33
33
  efforts: ['low', 'medium', 'high'],
34
- brief: 'xAI Grok 4.5 via grok CLI — default for plan & review; own xAI login',
34
+ brief: 'xAI Grok 4.5 via grok CLI — own xAI login; ~30 req/min, ~1k msgs/day, single-flight',
35
35
  },
36
36
  'google-antigravity/gemini-3.6-flash': {
37
37
  slug: 'google-antigravity/gemini-3.6-flash',
@@ -40,7 +40,17 @@ export const MODELS: Record<string, ModelSpec> = {
40
40
  efforts: ['low', 'medium', 'high'],
41
41
  defaultEffort: 'high',
42
42
  brief:
43
- 'Google Gemini 3.6 Flash via agy — default for implement, also image-gen; own Antigravity login',
43
+ 'Google Gemini 3.6 Flash via agy — own Antigravity login; quota shared across all Gemini tiers',
44
+ },
45
+ 'google-antigravity/gemini-3.1-pro': {
46
+ slug: 'google-antigravity/gemini-3.1-pro',
47
+ backend: 'agy',
48
+ // agy exposes only -high and -low for this class — there is no medium tier.
49
+ efforts: ['low', 'high'],
50
+ backendModel: 'gemini-3.1-pro',
51
+ defaultEffort: 'high',
52
+ brief:
53
+ 'Google Gemini 3.1 Pro via agy — own Antigravity login; quota shared across all Gemini tiers',
44
54
  },
45
55
  'google-antigravity/claude-sonnet-4-6': {
46
56
  slug: 'google-antigravity/claude-sonnet-4-6',
@@ -68,30 +78,53 @@ export const MODELS: Record<string, ModelSpec> = {
68
78
  backend: 'codex',
69
79
  backendModel: 'gpt-5.6-sol',
70
80
  efforts: ['low', 'medium', 'high', 'xhigh'],
71
- brief: 'OpenAI Codex gpt-5.6-sol via codex CLI',
81
+ brief: 'OpenAI gpt-5.6-sol via codex CLI — frontier agentic coding; own ChatGPT login',
72
82
  },
73
- 'anthropic-claude/sonnet-5': {
74
- slug: 'anthropic-claude/sonnet-5',
83
+ 'openai-codex/gpt-5.6-terra': {
84
+ slug: 'openai-codex/gpt-5.6-terra',
85
+ backend: 'codex',
86
+ backendModel: 'gpt-5.6-terra',
87
+ efforts: ['low', 'medium', 'high', 'xhigh'],
88
+ brief: 'OpenAI gpt-5.6-terra via codex CLI — balanced, everyday coding; own ChatGPT login',
89
+ },
90
+ 'openai-codex/gpt-5.6-luna': {
91
+ slug: 'openai-codex/gpt-5.6-luna',
92
+ backend: 'codex',
93
+ backendModel: 'gpt-5.6-luna',
94
+ efforts: ['low', 'medium', 'high', 'xhigh'],
95
+ brief: 'OpenAI gpt-5.6-luna via codex CLI — fast and affordable coding; own ChatGPT login',
96
+ },
97
+ 'anthropic-claude/fable-5': {
98
+ slug: 'anthropic-claude/fable-5',
75
99
  backend: 'claude',
76
- backendModel: 'claude-sonnet-5',
100
+ backendModel: 'claude-fable-5',
77
101
  efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
78
- brief: 'Claude Sonnet 5 via claude CLI — bills your Claude subscription',
102
+ defaultEffort: 'high',
103
+ brief:
104
+ 'Claude Fable 5 via claude CLI — hardest, longest-running work; bills the claude CLI subscription',
79
105
  },
80
106
  'anthropic-claude/opus-5': {
81
107
  slug: 'anthropic-claude/opus-5',
82
108
  backend: 'claude',
83
- backendModel: 'claude-opus-5',
109
+ backendModel: 'claude-opus-5[1m]',
84
110
  efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
85
111
  defaultEffort: 'high',
86
- brief: 'Claude Opus 5 via claude CLI (default effort: high) — bills subscription',
112
+ brief:
113
+ 'Claude Opus 5, 1M context via claude CLI — everyday complex work; bills the claude CLI subscription',
114
+ },
115
+ 'anthropic-claude/sonnet-5': {
116
+ slug: 'anthropic-claude/sonnet-5',
117
+ backend: 'claude',
118
+ backendModel: 'claude-sonnet-5',
119
+ efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
120
+ brief: 'Claude Sonnet 5 via claude CLI — routine work; bills the claude CLI subscription',
87
121
  },
88
- 'anthropic-claude/opus-5-1m': {
89
- slug: 'anthropic-claude/opus-5-1m',
122
+ 'anthropic-claude/haiku-4-5': {
123
+ slug: 'anthropic-claude/haiku-4-5',
90
124
  backend: 'claude',
91
- backendModel: 'claude-opus-5[1m]',
125
+ backendModel: 'claude-haiku-4-5-20251001',
92
126
  efforts: ['low', 'medium', 'high', 'xhigh', 'max'],
93
- defaultEffort: 'high',
94
- brief: 'Claude Opus 5, 1M context via claude CLI — long-context work; bills subscription',
127
+ brief: 'Claude Haiku 4.5 via claude CLI — quick answers; bills the claude CLI subscription',
95
128
  },
96
129
  } as const satisfies Record<string, ModelSpec>;
97
130
 
@@ -1,8 +1,13 @@
1
1
  import assert from 'node:assert/strict';
2
2
  import type { AgyQuotaSnapshot } from '@aibridge/driver-agy';
3
3
  import type { CodexQuotaSnapshot } from '@aibridge/driver-codex';
4
+ import type { GrokQuotaSnapshot } from '@aibridge/driver-grok';
4
5
  import { test } from 'vitest';
5
- import { evaluateAgyPreflight, evaluateCodexPreflight } from './quotaPreflight.ts';
6
+ import {
7
+ evaluateAgyPreflight,
8
+ evaluateCodexPreflight,
9
+ evaluateGrokPreflight,
10
+ } from './quotaPreflight.ts';
6
11
 
7
12
  test('evaluateAgyPreflight: exhausted model returns ok:false with resetTime', () => {
8
13
  const snapshot: AgyQuotaSnapshot = {
@@ -176,3 +181,43 @@ test('evaluateCodexPreflight: healthy (77% used) returns ok:true', () => {
176
181
 
177
182
  assert.deepEqual(result, { ok: true });
178
183
  });
184
+
185
+ test('evaluateGrokPreflight: usedPercent at 100 refuses with resetAt', () => {
186
+ const snapshot: GrokQuotaSnapshot = {
187
+ fetchedAt: '2024-01-01T12:00:00Z',
188
+ periodType: 'weekly',
189
+ periodStart: '2024-01-01T00:00:00Z',
190
+ periodEnd: '2024-01-08T00:00:00Z',
191
+ usedPercent: 100,
192
+ products: [],
193
+ onDemandUsedCents: undefined,
194
+ onDemandCapCents: undefined,
195
+ prepaidBalanceCents: undefined,
196
+ };
197
+
198
+ const result = evaluateGrokPreflight(snapshot);
199
+
200
+ assert.deepEqual(result, {
201
+ ok: false,
202
+ message: 'grok credit quota exhausted',
203
+ resetAt: '2024-01-08T00:00:00Z',
204
+ });
205
+ });
206
+
207
+ test('evaluateGrokPreflight: healthy (17% used) returns ok:true', () => {
208
+ const snapshot: GrokQuotaSnapshot = {
209
+ fetchedAt: '2024-01-01T12:00:00Z',
210
+ periodType: 'weekly',
211
+ periodStart: '2024-01-01T00:00:00Z',
212
+ periodEnd: '2024-01-08T00:00:00Z',
213
+ usedPercent: 17,
214
+ products: [],
215
+ onDemandUsedCents: undefined,
216
+ onDemandCapCents: undefined,
217
+ prepaidBalanceCents: undefined,
218
+ };
219
+
220
+ const result = evaluateGrokPreflight(snapshot);
221
+
222
+ assert.deepEqual(result, { ok: true });
223
+ });
@@ -1,5 +1,6 @@
1
1
  import { type AgyQuotaSnapshot, fetchAgyQuota, findModelQuota } from '@aibridge/driver-agy';
2
2
  import { type CodexQuotaSnapshot, fetchCodexQuota } from '@aibridge/driver-codex';
3
+ import { fetchGrokQuota, type GrokQuotaSnapshot } from '@aibridge/driver-grok';
3
4
  import { backendModelId, type ResolvedModel } from './models.ts';
4
5
 
5
6
  export type PreflightVerdict =
@@ -51,11 +52,27 @@ export function evaluateCodexPreflight(snapshot: CodexQuotaSnapshot): PreflightV
51
52
  return { ok: true };
52
53
  }
53
54
 
55
+ export function evaluateGrokPreflight(snapshot: GrokQuotaSnapshot): PreflightVerdict {
56
+ if (snapshot.usedPercent !== undefined && snapshot.usedPercent >= 100) {
57
+ return {
58
+ ok: false,
59
+ message: 'grok credit quota exhausted',
60
+ resetAt: snapshot.periodEnd,
61
+ };
62
+ }
63
+
64
+ return { ok: true };
65
+ }
66
+
54
67
  export async function preflightModel(resolved: ResolvedModel): Promise<PreflightVerdict> {
55
68
  if (resolved.spec.backend === 'codex') {
56
69
  return preflightCodex();
57
70
  }
58
71
 
72
+ if (resolved.spec.backend === 'grok') {
73
+ return preflightGrok();
74
+ }
75
+
59
76
  if (resolved.spec.backend !== 'agy') {
60
77
  return { ok: true };
61
78
  }
@@ -83,6 +100,18 @@ export async function preflightCodex(): Promise<PreflightVerdict> {
83
100
  }
84
101
  }
85
102
 
103
+ export async function preflightGrok(): Promise<PreflightVerdict> {
104
+ try {
105
+ const snapshot = await fetchGrokQuota();
106
+ return evaluateGrokPreflight(snapshot);
107
+ } catch (err) {
108
+ return {
109
+ ok: true,
110
+ warning: `quota preflight failed (${(err as Error).message}); proceeding`,
111
+ };
112
+ }
113
+ }
114
+
86
115
  function formatReset(resetTime: string | undefined): string {
87
116
  if (!resetTime) return '-';
88
117
  const ms = new Date(resetTime).getTime() - Date.now();