@spinabot/brigade 1.22.1 → 1.24.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.
Files changed (92) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/agent-loop.d.ts.map +1 -1
  3. package/dist/agents/agent-loop.js +73 -3
  4. package/dist/agents/agent-loop.js.map +1 -1
  5. package/dist/agents/channels/inbound-pipeline.d.ts.map +1 -1
  6. package/dist/agents/channels/inbound-pipeline.js +6 -0
  7. package/dist/agents/channels/inbound-pipeline.js.map +1 -1
  8. package/dist/agents/claude-cli/availability.d.ts +12 -0
  9. package/dist/agents/claude-cli/availability.d.ts.map +1 -0
  10. package/dist/agents/claude-cli/availability.js +79 -0
  11. package/dist/agents/claude-cli/availability.js.map +1 -0
  12. package/dist/agents/claude-cli/catalog.d.ts +91 -0
  13. package/dist/agents/claude-cli/catalog.d.ts.map +1 -0
  14. package/dist/agents/claude-cli/catalog.js +260 -0
  15. package/dist/agents/claude-cli/catalog.js.map +1 -0
  16. package/dist/agents/claude-cli/claude-config.d.ts +38 -0
  17. package/dist/agents/claude-cli/claude-config.d.ts.map +1 -0
  18. package/dist/agents/claude-cli/claude-config.js +111 -0
  19. package/dist/agents/claude-cli/claude-config.js.map +1 -0
  20. package/dist/agents/claude-cli/register.d.ts +32 -0
  21. package/dist/agents/claude-cli/register.d.ts.map +1 -0
  22. package/dist/agents/claude-cli/register.js +68 -0
  23. package/dist/agents/claude-cli/register.js.map +1 -0
  24. package/dist/agents/claude-cli/spawn.d.ts +42 -0
  25. package/dist/agents/claude-cli/spawn.d.ts.map +1 -0
  26. package/dist/agents/claude-cli/spawn.js +179 -0
  27. package/dist/agents/claude-cli/spawn.js.map +1 -0
  28. package/dist/agents/claude-cli/stream-json.d.ts +129 -0
  29. package/dist/agents/claude-cli/stream-json.d.ts.map +1 -0
  30. package/dist/agents/claude-cli/stream-json.js +84 -0
  31. package/dist/agents/claude-cli/stream-json.js.map +1 -0
  32. package/dist/agents/claude-cli/stream.d.ts +27 -0
  33. package/dist/agents/claude-cli/stream.d.ts.map +1 -0
  34. package/dist/agents/claude-cli/stream.js +380 -0
  35. package/dist/agents/claude-cli/stream.js.map +1 -0
  36. package/dist/agents/error-classifier.d.ts +2 -2
  37. package/dist/agents/error-classifier.d.ts.map +1 -1
  38. package/dist/agents/error-classifier.js +43 -0
  39. package/dist/agents/error-classifier.js.map +1 -1
  40. package/dist/agents/model-resolution.d.ts +36 -0
  41. package/dist/agents/model-resolution.d.ts.map +1 -1
  42. package/dist/agents/model-resolution.js +59 -0
  43. package/dist/agents/model-resolution.js.map +1 -1
  44. package/dist/agents/retry-policy.d.ts.map +1 -1
  45. package/dist/agents/retry-policy.js +25 -1
  46. package/dist/agents/retry-policy.js.map +1 -1
  47. package/dist/agents/tools/sessions/shared.d.ts.map +1 -1
  48. package/dist/agents/tools/sessions/shared.js +4 -0
  49. package/dist/agents/tools/sessions/shared.js.map +1 -1
  50. package/dist/auth/auth-health.d.ts +107 -1
  51. package/dist/auth/auth-health.d.ts.map +1 -1
  52. package/dist/auth/auth-health.js +245 -2
  53. package/dist/auth/auth-health.js.map +1 -1
  54. package/dist/buildstamp.json +1 -1
  55. package/dist/cli/commands/auth.js +18 -0
  56. package/dist/cli/commands/auth.js.map +1 -1
  57. package/dist/cli/commands/doctor.d.ts.map +1 -1
  58. package/dist/cli/commands/doctor.js +46 -0
  59. package/dist/cli/commands/doctor.js.map +1 -1
  60. package/dist/cli/commands/gateway.d.ts +4 -0
  61. package/dist/cli/commands/gateway.d.ts.map +1 -1
  62. package/dist/cli/commands/gateway.js +52 -6
  63. package/dist/cli/commands/gateway.js.map +1 -1
  64. package/dist/cli/commands/login.d.ts.map +1 -1
  65. package/dist/cli/commands/login.js +107 -19
  66. package/dist/cli/commands/login.js.map +1 -1
  67. package/dist/cli/commands/secrets-audit.d.ts.map +1 -1
  68. package/dist/cli/commands/secrets-audit.js +1 -0
  69. package/dist/cli/commands/secrets-audit.js.map +1 -1
  70. package/dist/core/auth-bridge.d.ts.map +1 -1
  71. package/dist/core/auth-bridge.js +16 -0
  72. package/dist/core/auth-bridge.js.map +1 -1
  73. package/dist/core/server.d.ts.map +1 -1
  74. package/dist/core/server.js +13 -0
  75. package/dist/core/server.js.map +1 -1
  76. package/dist/logging/redact.d.ts +12 -8
  77. package/dist/logging/redact.d.ts.map +1 -1
  78. package/dist/logging/redact.js +13 -8
  79. package/dist/logging/redact.js.map +1 -1
  80. package/dist/providers/catalog.d.ts +13 -0
  81. package/dist/providers/catalog.d.ts.map +1 -1
  82. package/dist/providers/catalog.js +31 -0
  83. package/dist/providers/catalog.js.map +1 -1
  84. package/dist/system-prompt/runtime-params.d.ts +5 -0
  85. package/dist/system-prompt/runtime-params.d.ts.map +1 -1
  86. package/dist/system-prompt/runtime-params.js +2 -1
  87. package/dist/system-prompt/runtime-params.js.map +1 -1
  88. package/dist/ui/onboarding.d.ts +11 -0
  89. package/dist/ui/onboarding.d.ts.map +1 -1
  90. package/dist/ui/onboarding.js +397 -6
  91. package/dist/ui/onboarding.js.map +1 -1
  92. package/package.json +2 -1
@@ -25,9 +25,12 @@ import { upsertApiKeyProfile, upsertApiKeyRefProfile, upsertOAuthProfile, upsert
25
25
  import { DEFAULT_AGENT_ID, resolveAuthProfilesPath, resolveModelsPath } from "../config/paths.js";
26
26
  import { saveConfig } from "../core/config.js";
27
27
  import { readClaudeCliLogin, readCodexCliLogin } from "../integrations/cli-login.js";
28
+ import { isClaudeCliAvailable } from "../agents/claude-cli/availability.js";
29
+ import { CLAUDE_CLI_DEFAULT_MODEL, CLAUDE_CLI_MODELS } from "../agents/claude-cli/catalog.js";
30
+ import { hasBrigadeClaudeLogin, writeBrigadeClaudeCredential } from "../agents/claude-cli/claude-config.js";
28
31
  import { writeCustomProviderToModelsJson } from "../integrations/custom-provider.js";
29
32
  import { discoverOllamaModels, writeOllamaToModelsJson } from "../integrations/ollama.js";
30
- import { findProvider, PROVIDERS, readProviderEnvKey, resolveProviderEnvVarSource, } from "../providers/catalog.js";
33
+ import { findProvider, PROVIDERS, readProviderEnvKey, resolveProviderEnvVarSource, routesToCustomProvider, } from "../providers/catalog.js";
31
34
  import { validateApiKeyOnline } from "../providers/validate-key.js";
32
35
  import { renderBrandHeader } from "./brand.js";
33
36
  import { brand, selectListTheme } from "./theme.js";
@@ -194,6 +197,19 @@ export async function runOnboarding(tui, authStorage, modelRegistry, opts = {})
194
197
  step = "model";
195
198
  continue;
196
199
  }
200
+ // claude-cli backend — no key + no browser flow: the `claude` binary
201
+ // authenticates with its OWN login. Validate the binary is installed
202
+ // and logged in; if not, guide the operator to run `claude` once. Models
203
+ // are synthesized, so nothing is written to models.json.
204
+ if (providerInfo?.id === "claude-cli") {
205
+ const result = await ensureClaudeCli(tui, authStorage);
206
+ if (result === "back") {
207
+ step = "provider";
208
+ continue;
209
+ }
210
+ step = "model";
211
+ continue;
212
+ }
197
213
  // CLI-login reuse — if the provider can adopt an already-logged-in
198
214
  // vendor CLI's token on this machine (Claude Code, Codex), offer the
199
215
  // one-keystroke "reuse this login" path FIRST. "other" means no CLI
@@ -233,7 +249,12 @@ export async function runOnboarding(tui, authStorage, modelRegistry, opts = {})
233
249
  // Custom (catalog-defined) providers — a key + a known
234
250
  // Anthropic-compatible endpoint (GLM, Kimi, Qwen, MiniMax, DeepSeek).
235
251
  // Paste the key, register the endpoint + models into models.json, done.
236
- if (providerInfo?.custom && providerInfo.baseUrl) {
252
+ // Also handles the generic "Custom (OpenAI-compatible)" entry, which
253
+ // has `custom: true` but no pre-set `baseUrl`. `ensureCustomProvider`
254
+ // prompts for the URL when it's missing. Without this, the generic
255
+ // custom path falls through to `ensureApiKey`, which never writes
256
+ // `models.json` — leaving the model unresolvable at gateway startup.
257
+ if (routesToCustomProvider(providerInfo)) {
237
258
  const r = await ensureCustomProvider(tui, authStorage, modelRegistry, providerInfo);
238
259
  if (r === "back") {
239
260
  step = "provider";
@@ -256,6 +277,17 @@ export async function runOnboarding(tui, authStorage, modelRegistry, opts = {})
256
277
  }
257
278
  // step === "model"
258
279
  renderScreen(tui, "Step 4 of 5 · Default model");
280
+ // claude-cli models are synthesized (not in the registry), so pick from the
281
+ // backend's own catalog instead of the registry-backed picker.
282
+ if (provider === "claude-cli") {
283
+ const result = await pickClaudeCliModel(tui);
284
+ if (result === "back") {
285
+ step = "provider";
286
+ continue;
287
+ }
288
+ modelId = result.modelId;
289
+ break; // model chosen — exit the wizard loop to persist + finish
290
+ }
259
291
  const result = await pickModel(tui, modelRegistry, findProvider(provider)?.providerId ?? provider);
260
292
  if (result === "back") {
261
293
  step = "provider"; // go all the way back so they can change provider too
@@ -378,10 +410,22 @@ async function pickProvider(tui) {
378
410
  const isBYO = p.custom === true && !p.baseUrl;
379
411
  let rank;
380
412
  let badge;
381
- // A subscription provider stays "log in" (browser-first, multi-account) even
382
- // when a CLI login is on disk reuse is offered as a secondary option inside
383
- // the flow. Only a pure CLI-login provider gets the rank-0 "reuse" treatment.
384
- if (cliReady && !isSubscription) {
413
+ // The claude-cli backend: it's `local+noAuth` in the catalog, but it's a
414
+ // SUBSCRIPTION path, not a local server. Rank it with the subscription tier
415
+ // and surface it at the very top when the binary is installed + logged in
416
+ // (the cleanest "no extra-usage" route). Checked BEFORE the generic
417
+ // cliReady / local branches so it doesn't get the wrong badge.
418
+ if (p.id === "claude-cli") {
419
+ const ready = isClaudeCliAvailable() && (readClaudeCliLogin() !== null || hasBrigadeClaudeLogin());
420
+ rank = ready ? 0 : 2;
421
+ badge = ready
422
+ ? "installed + signed in — subscription, no key, no extra-usage"
423
+ : "your Claude subscription — browser sign-in, no key, no extra-usage";
424
+ }
425
+ else if (cliReady && !isSubscription) {
426
+ // A subscription provider stays "log in" (browser-first, multi-account)
427
+ // even when a CLI login is on disk — reuse is offered as a secondary
428
+ // option inside the flow. Only a pure CLI-login provider gets rank-0.
385
429
  rank = 0;
386
430
  badge = "logged in — reuse, no key";
387
431
  }
@@ -970,6 +1014,269 @@ export async function ensureSubscriptionLogin(tui, authStorage, provider) {
970
1014
  return "ok";
971
1015
  }
972
1016
  }
1017
+ /** True when EITHER the operator's own `claude` login OR Brigade's managed
1018
+ * dedicated login is present — the backend can run with either. */
1019
+ function claudeCliLoggedIn() {
1020
+ return readClaudeCliLogin() !== null || hasBrigadeClaudeLogin();
1021
+ }
1022
+ /**
1023
+ * Connect the claude-cli backend end to end — no terminal, no token paste:
1024
+ * 1. Ensure the `claude` binary is installed (offer to `npm i -g` it).
1025
+ * 2. Ensure a login exists — if not, drive the SAME browser OAuth Brigade uses
1026
+ * for Claude Pro/Max and write the result into Brigade's OWN Claude config
1027
+ * dir (a dedicated grant, isolated from the operator's personal ~/.claude).
1028
+ * Turns then run on the Claude subscription via the binary (no extra-usage).
1029
+ *
1030
+ * Returns "ok" to proceed to model selection, or "back" to re-pick the provider.
1031
+ */
1032
+ export async function ensureClaudeCli(tui, authStorage) {
1033
+ while (true) {
1034
+ renderScreen(tui, "Step 3 of 5 · Connect Claude");
1035
+ // ── 1. binary present? ──
1036
+ if (!isClaudeCliAvailable({ force: true })) {
1037
+ tui.addChild(new Text(` ${brand.white("Brigade runs on your Claude subscription via the Claude Code engine.")}`, 0, 0));
1038
+ tui.addChild(new Text(brand.dim(" The `claude` command isn't installed yet. Brigade can install it for you."), 0, 0));
1039
+ tui.addChild(new Text("", 0, 0));
1040
+ const choice = new SelectList([
1041
+ { value: "install", label: "Install it now", description: "runs: npm i -g @anthropic-ai/claude-code" },
1042
+ { value: "recheck", label: "I'll install it myself — re-check", description: "" },
1043
+ ], 2, selectListTheme, { minPrimaryColumnWidth: 18, maxPrimaryColumnWidth: 24 });
1044
+ tui.addChild(choice);
1045
+ tui.setFocus(choice);
1046
+ tui.requestRender();
1047
+ let pick;
1048
+ try {
1049
+ const chosen = await new Promise((resolve, reject) => {
1050
+ choice.onSelect = (item) => resolve(item);
1051
+ choice.onCancel = () => reject(new Error("back"));
1052
+ });
1053
+ pick = chosen.value === "install" ? "install" : "recheck";
1054
+ }
1055
+ catch {
1056
+ return "back";
1057
+ }
1058
+ if (pick === "install") {
1059
+ renderScreen(tui, "Step 3 of 5 · Connect Claude");
1060
+ const loader = new CancellableLoader(tui, (s) => brand.amber(s), (s) => brand.dim(s), "Installing Claude Code (npm i -g @anthropic-ai/claude-code)…");
1061
+ tui.addChild(loader);
1062
+ tui.requestRender();
1063
+ const ok = await installClaudeCode();
1064
+ loader.stop?.();
1065
+ if (!ok) {
1066
+ renderScreen(tui, "Step 3 of 5 · Connect Claude");
1067
+ tui.addChild(new Text(` ${brand.error("✗")} ${brand.error("Install failed. Run `npm i -g @anthropic-ai/claude-code` manually, then Enter.")}`, 0, 0));
1068
+ const c = new Input();
1069
+ tui.addChild(c);
1070
+ tui.setFocus(c);
1071
+ tui.requestRender();
1072
+ try {
1073
+ await new Promise((res, rej) => {
1074
+ c.onSubmit = () => res();
1075
+ c.onEscape = () => rej(new Error("back"));
1076
+ });
1077
+ }
1078
+ catch {
1079
+ return "back";
1080
+ }
1081
+ }
1082
+ }
1083
+ continue; // re-loop: re-check install state
1084
+ }
1085
+ // ── 2. login present? ──
1086
+ if (claudeCliLoggedIn()) {
1087
+ tui.addChild(new Text(` ${brand.amber("✓")} ${brand.dim("Claude is installed and signed in.")}`, 0, 0));
1088
+ tui.addChild(new Text(brand.dim(" Turns run on your Claude subscription — no key, no extra-usage billing."), 0, 0));
1089
+ tui.addChild(new Text("", 0, 0));
1090
+ tui.addChild(new Text(brand.dim(" Enter to continue · Esc to go back"), 0, 0));
1091
+ const confirm = new Input();
1092
+ tui.addChild(confirm);
1093
+ tui.setFocus(confirm);
1094
+ tui.requestRender();
1095
+ try {
1096
+ await new Promise((resolve, reject) => {
1097
+ confirm.onSubmit = () => resolve();
1098
+ confirm.onEscape = () => reject(new Error("back"));
1099
+ });
1100
+ }
1101
+ catch {
1102
+ return "back";
1103
+ }
1104
+ return "ok";
1105
+ }
1106
+ // ── 3. no login → drive the browser OAuth ourselves, write Brigade's grant ──
1107
+ tui.addChild(new Text(` ${brand.white("Sign in to your Claude account")}`, 0, 0));
1108
+ tui.addChild(new Text(brand.dim(" We'll open your browser. Approve it there — no key, no terminal."), 0, 0));
1109
+ tui.addChild(new Text(brand.dim(" Enter to start · Esc to go back"), 0, 0));
1110
+ const start = new Input();
1111
+ tui.addChild(start);
1112
+ tui.setFocus(start);
1113
+ tui.requestRender();
1114
+ try {
1115
+ await new Promise((resolve, reject) => {
1116
+ start.onSubmit = () => resolve();
1117
+ start.onEscape = () => reject(new Error("back"));
1118
+ });
1119
+ }
1120
+ catch {
1121
+ return "back";
1122
+ }
1123
+ tui.removeChild(start);
1124
+ const result = await runClaudeBrowserLogin(tui, authStorage);
1125
+ if (result === "ok") {
1126
+ tui.addChild(new Text("", 0, 0));
1127
+ tui.addChild(new Text(` ${brand.amber("✓")} Signed in — your Claude subscription is connected.`, 0, 0));
1128
+ tui.requestRender();
1129
+ await delay(600);
1130
+ return "ok";
1131
+ }
1132
+ if (result === "back")
1133
+ return "back";
1134
+ // "retry" → loop shows the sign-in prompt again.
1135
+ }
1136
+ }
1137
+ /**
1138
+ * Install Claude Code globally via npm. Returns true on success. Best-effort +
1139
+ * bounded; surfaces nothing itself (the caller renders status).
1140
+ */
1141
+ async function installClaudeCode() {
1142
+ return await new Promise((resolve) => {
1143
+ try {
1144
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
1145
+ const child = spawn(npm, ["install", "-g", "@anthropic-ai/claude-code"], {
1146
+ stdio: "ignore",
1147
+ shell: process.platform === "win32",
1148
+ });
1149
+ const timer = setTimeout(() => {
1150
+ try {
1151
+ child.kill();
1152
+ }
1153
+ catch {
1154
+ /* already gone */
1155
+ }
1156
+ resolve(false);
1157
+ }, 180_000);
1158
+ timer.unref?.();
1159
+ child.on("close", (code) => {
1160
+ clearTimeout(timer);
1161
+ resolve(code === 0 && isClaudeCliAvailable({ force: true }));
1162
+ });
1163
+ child.on("error", () => {
1164
+ clearTimeout(timer);
1165
+ resolve(false);
1166
+ });
1167
+ }
1168
+ catch {
1169
+ resolve(false);
1170
+ }
1171
+ });
1172
+ }
1173
+ /**
1174
+ * Drive Brigade's browser OAuth for Anthropic (the same flow Claude Pro/Max
1175
+ * onboarding uses — pi-ai requests the full Claude Code scopes) and write the
1176
+ * result into Brigade's OWN managed Claude config dir, so the `claude` binary
1177
+ * authenticates + refreshes from Brigade's dedicated grant. Also mirrors the
1178
+ * credential into Brigade's anthropic profile so the HTTP path is available too.
1179
+ *
1180
+ * Returns "ok" on success, "back" on user abort, "retry" on a recoverable error.
1181
+ */
1182
+ async function runClaudeBrowserLogin(tui, authStorage) {
1183
+ const oauthProvider = getOAuthProvider("anthropic");
1184
+ if (!oauthProvider) {
1185
+ tui.addChild(new Text(` ${brand.error("✗")} ${brand.error("Browser sign-in isn't available in this build.")}`, 0, 0));
1186
+ tui.requestRender();
1187
+ await delay(1200);
1188
+ return "back";
1189
+ }
1190
+ const controller = new AbortController();
1191
+ let creds;
1192
+ try {
1193
+ creds = (await oauthProvider.login({
1194
+ onAuth: (info) => {
1195
+ tui.addChild(new Text(` ${brand.amber("→")} Opening your browser to sign in…`, 0, 0));
1196
+ openSubscriptionBrowser(info.url);
1197
+ tui.addChild(new Text("", 0, 0));
1198
+ tui.addChild(new Text(" " + brand.amber(info.url), 0, 0));
1199
+ tui.addChild(new Text(brand.dim(" If your browser didn't open, copy the link above. Paste the code here if asked."), 0, 0));
1200
+ const waiter = new CancellableLoader(tui, (s) => brand.amber(s), (s) => brand.dim(s), "Waiting for you to authorize…");
1201
+ waiter.onAbort = () => controller.abort();
1202
+ tui.addChild(waiter);
1203
+ tui.setFocus(waiter);
1204
+ tui.requestRender();
1205
+ },
1206
+ onManualCodeInput: () => new Promise((resolve, reject) => {
1207
+ tui.addChild(new Text("", 0, 0));
1208
+ tui.addChild(new Text(brand.dim(" Paste the code or redirect URL, then press Enter · Esc to cancel"), 0, 0));
1209
+ const input = new Input();
1210
+ tui.addChild(input);
1211
+ tui.setFocus(input);
1212
+ tui.requestRender();
1213
+ input.onSubmit = (value) => resolve(sanitizePastedValue(value));
1214
+ input.onEscape = () => reject(new Error("cancelled"));
1215
+ }),
1216
+ onProgress: (msg) => {
1217
+ tui.addChild(new Text(brand.dim(" " + msg), 0, 0));
1218
+ tui.requestRender();
1219
+ },
1220
+ // Anthropic's loopback flow never uses these, but the callback contract
1221
+ // requires them — provide minimal implementations so the type + runtime
1222
+ // are both satisfied.
1223
+ onDeviceCode: () => {
1224
+ /* not used by the anthropic loopback flow */
1225
+ },
1226
+ onPrompt: (p) => new Promise((resolve, reject) => {
1227
+ tui.addChild(new Text("", 0, 0));
1228
+ tui.addChild(new Text(` ${p.message}`, 0, 0));
1229
+ const input = new Input();
1230
+ tui.addChild(input);
1231
+ tui.setFocus(input);
1232
+ tui.requestRender();
1233
+ input.onSubmit = (value) => {
1234
+ const v = value.trim();
1235
+ if (!v && !p.allowEmpty)
1236
+ return;
1237
+ resolve(v);
1238
+ };
1239
+ input.onEscape = () => reject(new Error("cancelled"));
1240
+ }),
1241
+ onSelect: (p) => new Promise((resolve) => {
1242
+ const list = new SelectList(p.options.map((o) => ({ value: o.id, label: o.label })), Math.min(p.options.length, 6), selectListTheme, { minPrimaryColumnWidth: 12, maxPrimaryColumnWidth: 28 });
1243
+ tui.addChild(list);
1244
+ tui.setFocus(list);
1245
+ tui.requestRender();
1246
+ list.onSelect = (item) => resolve(item.value);
1247
+ list.onCancel = () => resolve(undefined);
1248
+ }),
1249
+ signal: controller.signal,
1250
+ }));
1251
+ }
1252
+ catch (err) {
1253
+ controller.abort();
1254
+ const reason = err instanceof Error ? err.message : String(err);
1255
+ const softCancel = /^login cancelled$/i.test(reason) || reason === "cancelled" || reason === "back";
1256
+ tui.addChild(new Text(` ${brand.error("✗")} ${brand.error(softCancel ? "Sign-in cancelled." : "Couldn't finish signing in — check your connection and try again.")}`, 0, 0));
1257
+ tui.requestRender();
1258
+ await delay(1000);
1259
+ return softCancel ? "back" : "retry";
1260
+ }
1261
+ // Write Brigade's dedicated Claude login ONLY into the managed config dir.
1262
+ // The `claude` binary owns this grant from here on: it authenticates AND
1263
+ // refreshes (rotating the refresh token) in-place. We deliberately DON'T
1264
+ // also mirror it into Brigade's anthropic auth-profile — that would create a
1265
+ // SECOND independent refresher (Brigade's HTTP-path backend) for the same
1266
+ // grant, and the two would rotate each other's refresh token to death
1267
+ // (the split-brain failure). Single grant, single owner (the binary).
1268
+ void authStorage; // intentionally unused now — kept for signature stability
1269
+ try {
1270
+ writeBrigadeClaudeCredential({ access: creds.access, refresh: creds.refresh, expires: creds.expires });
1271
+ }
1272
+ catch (err) {
1273
+ tui.addChild(new Text(` ${brand.error("✗")} ${brand.error(`Couldn't save the login: ${err.message}`)}`, 0, 0));
1274
+ tui.requestRender();
1275
+ await delay(1200);
1276
+ return "retry";
1277
+ }
1278
+ return "ok";
1279
+ }
973
1280
  /**
974
1281
  * Connect a subscription provider that ALSO has a vendor CLI login on disk
975
1282
  * (Claude Code / Codex). When such a login exists we present a choice that LEADS
@@ -1022,6 +1329,12 @@ async function ensureCliLogin(tui, authStorage, provider) {
1022
1329
  access: cred.access,
1023
1330
  refresh: cred.refresh,
1024
1331
  expires: cred.expires,
1332
+ // Borrowed from the vendor CLI's on-disk login — mark the family so
1333
+ // `adoptNewerClaudeCliLogin` keeps adopting the CLI's rotations
1334
+ // instead of refreshing (and rotating) a stale shared grant.
1335
+ ...(provider.cliLogin.read === "claude"
1336
+ ? { metadata: { importedFrom: "claude-cli" } }
1337
+ : {}),
1025
1338
  });
1026
1339
  authStorage.set(cred.provider, {
1027
1340
  type: "oauth",
@@ -1085,6 +1398,53 @@ async function ensureCliLogin(tui, authStorage, provider) {
1085
1398
  * - "back" → user pressed Esc; caller rewinds to the provider picker
1086
1399
  */
1087
1400
  async function ensureCustomProvider(tui, authStorage, modelRegistry, provider) {
1401
+ // Generic custom provider — the catalog entry has `custom: true` but no
1402
+ // pre-set `baseUrl` (it varies per user). Prompt for the URL before the
1403
+ // key-entry loop, then attach it to a shallow copy so the downstream
1404
+ // write path sees a fully resolved provider without mutating the catalog.
1405
+ if (!provider.baseUrl) {
1406
+ let urlError = null;
1407
+ while (true) {
1408
+ renderScreen(tui, `Step 3 of 5 · ${provider.name}`);
1409
+ // Render the error at the TOP of the loop (before the prompt), with the
1410
+ // single requestRender() below. Adding it AFTER requestRender lets the
1411
+ // next iteration's clear() wipe it before it ever paints — a silent
1412
+ // re-prompt with no reason shown. Mirrors the key-entry loop's lastError.
1413
+ if (urlError) {
1414
+ tui.addChild(new Text(` ${brand.error("✗")} ${brand.error(urlError)}`, 0, 0));
1415
+ tui.addChild(new Text("", 0, 0));
1416
+ }
1417
+ tui.addChild(new Text(` Enter your OpenAI-compatible base URL.`, 0, 0));
1418
+ tui.addChild(new Text(brand.dim(" Example: https://api.example.com/v1"), 0, 0));
1419
+ tui.addChild(new Text(brand.dim(" Enter to continue · Esc to go back"), 0, 0));
1420
+ tui.addChild(new Text("", 0, 0));
1421
+ const urlInput = new Input();
1422
+ tui.addChild(urlInput);
1423
+ tui.setFocus(urlInput);
1424
+ tui.requestRender();
1425
+ let rawUrl;
1426
+ try {
1427
+ rawUrl = await new Promise((resolve, reject) => {
1428
+ urlInput.onSubmit = (value) => resolve(value.trim());
1429
+ urlInput.onEscape = () => reject(new Error("back"));
1430
+ });
1431
+ }
1432
+ catch {
1433
+ return "back";
1434
+ }
1435
+ if (!rawUrl) {
1436
+ urlError = "Enter a base URL, or press Esc to go back.";
1437
+ continue;
1438
+ }
1439
+ // Minimal URL sanity check — must start with http(s)://.
1440
+ if (!/^https?:\/\//i.test(rawUrl)) {
1441
+ urlError = "URL must start with http:// or https://";
1442
+ continue;
1443
+ }
1444
+ provider = { ...provider, baseUrl: rawUrl, api: provider.api ?? "openai-completions" };
1445
+ break;
1446
+ }
1447
+ }
1088
1448
  let lastError = null;
1089
1449
  // If the operator already has this provider's key in their environment (e.g.
1090
1450
  // NVIDIA_API_KEY), OFFER it — confirm-then-validate, never silent-adopt. A
@@ -1278,6 +1638,37 @@ async function pickModel(tui, modelRegistry, providerId) {
1278
1638
  return "back";
1279
1639
  }
1280
1640
  }
1641
+ /** Model picker for the claude-cli backend — its models are synthesized (not in
1642
+ * the registry), so we present the backend's own catalog. */
1643
+ async function pickClaudeCliModel(tui) {
1644
+ const items = CLAUDE_CLI_MODELS.map((m) => ({
1645
+ value: m.id,
1646
+ label: m.id,
1647
+ description: m.name,
1648
+ }));
1649
+ // Default first = the catalog default (Sonnet), then the rest as listed.
1650
+ items.sort((a, b) => a.value === CLAUDE_CLI_DEFAULT_MODEL ? -1 : b.value === CLAUDE_CLI_DEFAULT_MODEL ? 1 : 0);
1651
+ const list = new SearchableSelectList(items, 8, selectListTheme, {
1652
+ minPrimaryColumnWidth: 26,
1653
+ maxPrimaryColumnWidth: 38,
1654
+ formatHeader: (q, matchCount, total) => brand.dim(q.length > 0
1655
+ ? ` search: ${q}▌ (${matchCount}/${total})`
1656
+ : ` ${total} models · ↑↓ move · Enter select · Esc back`),
1657
+ });
1658
+ tui.addChild(list);
1659
+ tui.setFocus(list);
1660
+ tui.requestRender();
1661
+ try {
1662
+ const chosen = await new Promise((resolve, reject) => {
1663
+ list.onSelect = (item) => resolve(item);
1664
+ list.onCancel = () => reject(new Error("back"));
1665
+ });
1666
+ return { modelId: chosen.value };
1667
+ }
1668
+ catch {
1669
+ return "back";
1670
+ }
1671
+ }
1281
1672
  function renderDone(tui, provider, modelId) {
1282
1673
  const p = findProvider(provider)?.name ?? provider;
1283
1674
  tui.addChild(new Text("", 0, 0));