@spinabot/brigade 0.1.0 → 0.1.2

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.
@@ -25,8 +25,11 @@
25
25
  * nohup / tmux when you want JSONL only.
26
26
  */
27
27
  import process from "node:process";
28
+ import chalk from "chalk";
28
29
  import { createConsoleStream } from "../core/console-stream.js";
30
+ import { EXIT_CONFIG_ERROR, EXIT_FAILURE } from "../protocol.js";
29
31
  import { startServer } from "../core/server.js";
32
+ import { restoreTerminal } from "../ui/terminal-cleanup.js";
30
33
  /**
31
34
  * Boot the gateway. Resolves once the port is bound. Wires SIGINT/SIGTERM
32
35
  * to a clean shutdown so the listening socket is released even on Ctrl+C.
@@ -61,20 +64,72 @@ export async function runGatewayCommand(opts = {}) {
61
64
  // instead of dumping a Node stack trace.
62
65
  const msg = err instanceof Error ? err.message : String(err);
63
66
  const port = opts.port ?? (Number(process.env.BRIGADE_PORT) || 7777);
67
+ // "no saved config" is a config error — exit with sysexits 78 so
68
+ // supervisors (systemd, launchd) STOP restarting. Restarting will
69
+ // produce the exact same error until the operator runs onboarding.
70
+ //
71
+ // Interactive enhancement (TTY only): rather than dead-ending the
72
+ // developer in their terminal with "go run another command", offer
73
+ // to launch chat (which handles onboarding inline) right now. Non-
74
+ // interactive supervisors get the message + exit 78 as before, so
75
+ // systemd doesn't sit waiting for stdin that will never come.
76
+ if (/no saved config|model .+ not in registry/i.test(msg)) {
77
+ process.stderr.write(chalk.yellow(`brigade-gateway: this gateway hasn't been set up yet.\n`) +
78
+ chalk.dim(` Run ${chalk.bold("brigade chat")} to pick a provider + model, then re-launch.\n`));
79
+ if (process.stdin.isTTY && process.stdout.isTTY) {
80
+ const readline = await import("node:readline");
81
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
82
+ const answer = await new Promise((resolve) => {
83
+ rl.question(`\nLaunch ${chalk.bold("brigade chat")} now to onboard? [Y/n] `, (a) => {
84
+ rl.close();
85
+ resolve(a.trim().toLowerCase());
86
+ });
87
+ });
88
+ if (answer === "" || answer === "y" || answer === "yes") {
89
+ // Hand off to chat. It boots the TUI, runs onboarding, then
90
+ // stays in chat — the user can `/exit` and re-run gateway
91
+ // when they're ready. Don't auto-restart the gateway: chat
92
+ // may have done other work (model switch, /provider add)
93
+ // the operator wants to control.
94
+ const { runChatCommand } = await import("./chat-cmd.js");
95
+ await runChatCommand({});
96
+ // Chat owns its own exit. The gateway never started, so
97
+ // there's no teardown to run — return a no-op so the
98
+ // caller's contract holds.
99
+ return async () => { };
100
+ }
101
+ }
102
+ process.exit(EXIT_CONFIG_ERROR);
103
+ }
64
104
  if (/EADDRINUSE/.test(msg)) {
65
105
  process.stderr.write(`brigade-gateway: port ${port} is already in use.\n` +
66
106
  ` - find the process: PowerShell> Get-NetTCPConnection -LocalPort ${port}\n` +
67
107
  ` - or pick a different port: brigade gateway --port ${port + 1}\n`);
68
- process.exit(1);
108
+ process.exit(EXIT_FAILURE);
69
109
  }
70
110
  if (/EACCES/.test(msg)) {
111
+ // Privileged-port permission is a config issue (won't fix on retry).
71
112
  process.stderr.write(`brigade-gateway: permission denied binding port ${port} (privileged ports require admin).\n` +
72
113
  ` - pick an unprivileged port: brigade gateway --port 7777\n`);
73
- process.exit(1);
114
+ process.exit(EXIT_CONFIG_ERROR);
115
+ }
116
+ if (/ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(msg)) {
117
+ // Bad --host config (won't fix on retry).
118
+ process.stderr.write(`brigade-gateway: couldn't resolve host "${opts.host ?? "default"}". Check the --host value and your DNS.\n`);
119
+ process.exit(EXIT_CONFIG_ERROR);
120
+ }
121
+ if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) {
122
+ // Transient network — supervisor may retry.
123
+ process.stderr.write(`brigade-gateway: no network route to ${opts.host ?? "default"}. Check your network / firewall / VPN.\n`);
124
+ process.exit(EXIT_FAILURE);
74
125
  }
75
- // Unrecognized failure — surface the message but skip the stack.
76
- process.stderr.write(`brigade-gateway: failed to start: ${msg}\n`);
77
- process.exit(1);
126
+ // Unrecognized failure — short message line; raw `msg` only when the
127
+ // operator opts into BRIGADE_DEBUG=1.
128
+ process.stderr.write(`brigade-gateway: failed to start the server.\n`);
129
+ if (process.env.BRIGADE_DEBUG === "1") {
130
+ process.stderr.write(` (debug: ${msg})\n`);
131
+ }
132
+ process.exit(EXIT_FAILURE);
78
133
  }
79
134
  // Banner already printed by startServer (verbose path uses consoleStream;
80
135
  // quiet path writes the two plain lines). No additional banner needed here.
@@ -83,7 +138,15 @@ export async function runGatewayCommand(opts = {}) {
83
138
  // here because the gateway runs without the TUI and never reaches that path.
84
139
  const onSignal = (sig) => {
85
140
  process.stderr.write(`brigade-gateway: ${sig} received, shutting down\n`);
86
- void handle.stop().then(() => process.exit(0));
141
+ void handle.stop().then(() => {
142
+ // Gateway is headless and never enables raw mode itself, but the
143
+ // readline prompt above (the no-config Y/N) momentarily borrows the
144
+ // TTY. restoreTerminal() is idempotent and always safe — call it
145
+ // before exit so the next shell prompt is clean even if we ever
146
+ // add a richer interactive path here.
147
+ restoreTerminal();
148
+ process.exit(0);
149
+ });
87
150
  };
88
151
  process.once("SIGTERM", () => onSignal("SIGTERM"));
89
152
  process.once("SIGINT", () => onSignal("SIGINT"));
package/dist/cli.js CHANGED
@@ -29,8 +29,12 @@ import { runConfigCommand } from "./cli/config-cmd.js";
29
29
  import { runConnectCommand } from "./cli/connect-cmd.js";
30
30
  import { runDoctorCommand } from "./cli/doctor-cmd.js";
31
31
  import { runGatewayCommand } from "./cli/gateway-cmd.js";
32
+ import { installTerminalCleanupHandler, restoreTerminal } from "./ui/terminal-cleanup.js";
33
+ import { loadBrigadeConfig, loadEnvIntoProcess, migrateBrigadeConfigV1toV2, migrateLegacyConfig, } from "./core/brigade-config.js";
34
+ import { BRIGADE_DIR } from "./core/config.js";
32
35
  import { DEFAULT_PORT } from "./protocol.js";
33
- const VERSION = "0.1.0";
36
+ import { BRIGADE_CLI_VERSION } from "./core/version.js";
37
+ const VERSION = BRIGADE_CLI_VERSION;
34
38
  const HELP = `${chalk.bold("brigade")} — your personal AI crew
35
39
 
36
40
  ${chalk.dim("usage:")}
@@ -48,6 +52,9 @@ ${chalk.dim("flags (all subcommands respect):")}
48
52
  --help show help (per-subcommand if applicable)
49
53
  --version print version
50
54
 
55
+ ${chalk.dim("flags (chat / onboard):")}
56
+ --no-env-detect ignore API keys exported in the shell env (typed-key only)
57
+
51
58
  ${chalk.dim("flags (gateway):")}
52
59
  --port <n> listen port (default: ${DEFAULT_PORT}; env: BRIGADE_PORT)
53
60
  --host <addr> bind address (default: 127.0.0.1)
@@ -80,6 +87,7 @@ export const BOOLEAN_FLAGS = new Set([
80
87
  "verbose",
81
88
  "quiet",
82
89
  "force",
90
+ "no-env-detect",
83
91
  ]);
84
92
  export function parseArgs(argv) {
85
93
  const positional = [];
@@ -133,8 +141,9 @@ export function parseArgs(argv) {
133
141
  * 3. Mention it in HELP above
134
142
  */
135
143
  const SUBCOMMANDS = {
136
- chat: async () => {
137
- await runChatCommand();
144
+ chat: async ({ flags }) => {
145
+ const noEnvDetect = flags["no-env-detect"] === true;
146
+ await runChatCommand({ noEnvDetect });
138
147
  },
139
148
  gateway: async ({ flags }) => {
140
149
  const port = typeof flags.port === "string"
@@ -180,7 +189,7 @@ const SUBCOMMANDS = {
180
189
  // Same as gateway — the TUI's stdin handler keeps the process alive.
181
190
  await new Promise(() => { });
182
191
  },
183
- onboard: async () => {
192
+ onboard: async ({ flags }) => {
184
193
  // Onboarding lives inside chat-cmd's boot path today (it runs when no
185
194
  // saved config is present). For an explicit `brigade onboard`, force
186
195
  // the wizard by deleting the saved default before booting chat — the
@@ -202,7 +211,8 @@ const SUBCOMMANDS = {
202
211
  catch {
203
212
  /* missing config is fine — wizard will run regardless */
204
213
  }
205
- await runChatCommand();
214
+ const noEnvDetect = flags["no-env-detect"] === true;
215
+ await runChatCommand({ noEnvDetect });
206
216
  },
207
217
  doctor: async ({ flags }) => {
208
218
  const gatewayUrl = typeof flags.gateway === "string" ? flags.gateway : undefined;
@@ -220,6 +230,12 @@ const SUBCOMMANDS = {
220
230
  * a subprocess.
221
231
  */
222
232
  export async function runCli(argv) {
233
+ // Install the process-wide terminal-cleanup safety net the moment runCli
234
+ // is called. Idempotent — repeated invocations (tests, embedded callers)
235
+ // register the listener at most once. Covers ALL exit paths (normal
236
+ // return, throw, signal, process.exit) so kitty keyboard escape sequences
237
+ // can never leak into the user's shell after Brigade exits.
238
+ installTerminalCleanupHandler();
223
239
  const parsed = parseArgs(argv);
224
240
  // Top-level flags first.
225
241
  if (parsed.flags.version === true || parsed.flags.v === true) {
@@ -230,6 +246,28 @@ export async function runCli(argv) {
230
246
  process.stdout.write(HELP);
231
247
  return 0;
232
248
  }
249
+ // Boot init — runs once per process before any subcommand. Four stages:
250
+ // 1. Auto-migrate legacy 3-file config (auth.json + config.json + settings.json)
251
+ // to the unified brigade.json super-config. Idempotent.
252
+ // 2. Auto-migrate brigade.json from v1 → v2 super-config schema. Idempotent.
253
+ // Order matters: stage 1 may produce a v2 file directly (so this is a
254
+ // no-op for fresh installs), but a v1 file from an earlier Brigade
255
+ // build needs lifting BEFORE we read it in stage 3.
256
+ // 3. Load brigade.json + overlay its `env` block into process.env so Pi's
257
+ // auth resolution finds Brigade-managed keys. Existing shell env vars
258
+ // take priority — we never overwrite a key the user explicitly exported.
259
+ // 4. Errors here are non-fatal. The agent must always boot so the user can
260
+ // run `brigade doctor` and diagnose.
261
+ try {
262
+ await migrateLegacyConfig(BRIGADE_DIR);
263
+ await migrateBrigadeConfigV1toV2(BRIGADE_DIR);
264
+ const cfg = await loadBrigadeConfig(BRIGADE_DIR);
265
+ loadEnvIntoProcess(cfg);
266
+ }
267
+ catch (err) {
268
+ const msg = err instanceof Error ? err.message : String(err);
269
+ process.stderr.write(chalk.dim(`brigade: config init warning — ${msg}\n`));
270
+ }
233
271
  // Default subcommand: chat
234
272
  const sub = parsed.positional[0] ?? "chat";
235
273
  // Help on a specific subcommand: `brigade <sub> --help` falls through to
@@ -254,7 +292,10 @@ export async function runCli(argv) {
254
292
  catch (err) {
255
293
  const msg = err instanceof Error ? err.message : String(err);
256
294
  process.stderr.write(chalk.red(`brigade ${sub}: ${msg}\n`));
257
- if (err instanceof Error && err.stack) {
295
+ // Stack traces leak `node_modules/.../` paths into user-facing output
296
+ // — same B2B-jargon class as Pi's `/login` leak. Suppress by default;
297
+ // surface only when the operator explicitly opts in via env.
298
+ if (process.env.BRIGADE_DEBUG === "1" && err instanceof Error && err.stack) {
258
299
  process.stderr.write(chalk.dim(`${err.stack}\n`));
259
300
  }
260
301
  return 1;
@@ -270,17 +311,17 @@ if (import.meta.url === entry) {
270
311
  .then((code) => {
271
312
  // Most subcommands keep the loop alive (gateway, connect, chat).
272
313
  // Only short-lived ones (config, doctor, --help) reach this point.
314
+ restoreTerminal();
273
315
  if (typeof code === "number")
274
316
  process.exit(code);
275
317
  })
276
318
  .catch((err) => {
277
- try {
278
- process.stdout.write("\x1b[?25h\x1b[?1049l");
279
- }
280
- catch {
281
- /* terminal already gone */
319
+ restoreTerminal();
320
+ const msg = err instanceof Error ? err.message : String(err);
321
+ console.error(chalk.red(`Brigade crashed: ${msg}`));
322
+ if (process.env.BRIGADE_DEBUG === "1" && err instanceof Error && err.stack) {
323
+ console.error(chalk.dim(err.stack));
282
324
  }
283
- console.error(chalk.red(`Brigade crashed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`));
284
325
  process.exit(1);
285
326
  });
286
327
  }
@@ -22,7 +22,7 @@ import { decodeXaiToolCallArgs, downgradeOpenAIResponsesReasoningPairs, dropAnth
22
22
  import { smartCompactToolResults } from "./smart-compaction.js";
23
23
  import { refreshSessionSystemPrompt, seedDefaultPrompts } from "./system-prompt.js";
24
24
  export async function buildAgent(opts) {
25
- // Seed default prompt files into ~/.brigade/prompts/ on first boot.
25
+ // Seed default prompt files into ~/.brigade/workspace/ on first boot.
26
26
  // Idempotent: existing files are never overwritten (users own their edits).
27
27
  // Failure is non-fatal — the assembler falls back to embedded defaults
28
28
  // when files are missing or unreadable.
@@ -47,7 +47,7 @@ export async function buildAgent(opts) {
47
47
  // tools: omitted → Pi enables read/bash/edit/write by default; grep is available too
48
48
  });
49
49
  // Inject Brigade's system prompt — assembled from the layered .md files
50
- // at ~/.brigade/prompts/ (with per-cwd override). The assembler embeds
50
+ // at ~/.brigade/workspace/ (with per-cwd override). The assembler embeds
51
51
  // the BRIGADE_CACHE_BOUNDARY marker between static prefix and dynamic
52
52
  // suffix; the payload mutator (wrapStreamFnWithPayloadMutations below)
53
53
  // splits at the marker and applies Anthropic cache_control.
@@ -214,7 +214,7 @@ export async function buildAgent(opts) {
214
214
  // - Cost: ~5KB of file I/O per turn (a few ms — negligible vs the
215
215
  // 500-5000ms LLM call). Re-read happens BEFORE the first LLM call
216
216
  // of the turn, so the new prompt is in effect when Pi reads it.
217
- // - Hot reload: edits to ~/.brigade/prompts/<layer>.md take effect on
217
+ // - Hot reload: edits to ~/.brigade/workspace/<layer>.md take effect on
218
218
  // the next user turn, no restart needed.
219
219
  // - Per-model guidance refresh: a /model swap changes which family
220
220
  // guidance fires (OpenAI vs Google vs none for Anthropic). The
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Brigade-native translator for the "No API key found for X" error that
3
+ * surfaces from Pi's auth-resolution path.
4
+ *
5
+ * Pi's own message hardcodes `/login` (a Pi slash command Brigade doesn't
6
+ * have) and dumps raw `node_modules/@mariozechner/pi-coding-agent/docs/`
7
+ * paths into the user's chat. Both are direct violations of Brigade's
8
+ * B2B-grade copy rules — paying customers see the agent gesture at a
9
+ * non-existent command and at internal package paths.
10
+ *
11
+ * The replacement is a SINGLE SHORT LINE with a clear next step. Not a
12
+ * multi-line guidance block — when the user already knows what's wrong
13
+ * (their key isn't set), more text is more friction. Same pattern used by
14
+ * mature production agent platforms.
15
+ *
16
+ * Pure function. No I/O.
17
+ */
18
+ /** Pi's "No API key found for X" pattern (see auth-guidance.js). */
19
+ const NO_API_KEY_PATTERN = /^No API key found for ["']?([^"'.\s]+)["']?\.?/m;
20
+ /** Pi's stale-OAuth pattern (see agent-session.js) — fires at session
21
+ * startup when the saved credential won't authenticate. */
22
+ const OAUTH_EXPIRED_PATTERN = /^Authentication failed for ["']?([^"'.\s]+)["']?\./m;
23
+ /** Pi's OAuth token refresh failure pattern — fires mid-session when a
24
+ * stored OAuth token's refresh exchange fails (separate code path from
25
+ * the startup auth-failed error above). Both leak `/login` references. */
26
+ const OAUTH_REFRESH_PATTERN = /^OAuth token refresh failed for ["']?([^"'.\s:]+)["']?[:.]?/m;
27
+ /**
28
+ * Detect Pi's auth-failure error and return a Brigade-native single-line
29
+ * replacement. Returns `null` for any other error — caller falls through
30
+ * to the normal cleanProviderError path.
31
+ *
32
+ * Three known patterns from Pi (each a different code path; each leaks
33
+ * `/login` and may include raw `node_modules/.../docs/...` paths):
34
+ * - "No API key found for X." (auth-guidance.js — no key in storage / env)
35
+ * - "Authentication failed for X. Credentials may have expired …
36
+ * Run '/login X' to re-authenticate." (agent-session.js — startup)
37
+ * - "OAuth token refresh failed for X: …" (oauth refresh path — mid-session)
38
+ */
39
+ export function translateAuthError(raw) {
40
+ if (!raw)
41
+ return null;
42
+ const noKey = raw.match(NO_API_KEY_PATTERN);
43
+ if (noKey) {
44
+ const provider = friendlyProviderName((noKey[1] ?? "").trim());
45
+ return `⚠ Missing API key for ${provider}. Use /provider to add one, /model to switch, or run \`brigade onboard\`.`;
46
+ }
47
+ // OAuth refresh failure (mid-session) — the kernel "tried to use the
48
+ // stored credential, attempted refresh, refresh round-trip failed."
49
+ // Often transient (provider 5xx during refresh) — phrase it as such.
50
+ const refresh = raw.match(OAUTH_REFRESH_PATTERN);
51
+ if (refresh) {
52
+ const provider = friendlyProviderName((refresh[1] ?? "").trim());
53
+ return `⚠ ${provider} login refresh failed. This is often temporary — try again in a moment, or use /provider to re-authenticate.`;
54
+ }
55
+ // Hard auth failure (startup) — credential exists but the server rejected it
56
+ // (expired, revoked, wrong-scope). Distinct from refresh: this needs a
57
+ // new credential, not just a retry.
58
+ const expired = raw.match(OAUTH_EXPIRED_PATTERN);
59
+ if (expired) {
60
+ const provider = friendlyProviderName((expired[1] ?? "").trim());
61
+ return `⚠ ${provider} login was rejected (credential expired or revoked). Use /provider to add a fresh key, /model to switch, or run \`brigade onboard\`.`;
62
+ }
63
+ if (/^No models? (available|selected)/i.test(raw)) {
64
+ return "⚠ No models available yet. Use /provider to add one, or run `brigade onboard` to set up.";
65
+ }
66
+ return null;
67
+ }
68
+ /**
69
+ * Single-line response when a user types `/login` (a Pi command Brigade
70
+ * doesn't have). Same shape as the auth-error translator above.
71
+ */
72
+ export function buildLoginGuidanceMessage() {
73
+ return "⚠ Brigade doesn't use /login. Use /provider to add a provider, /model to switch, or run `brigade onboard`.";
74
+ }
75
+ /**
76
+ * Universal error-prep used by every catch site that may render a Pi error
77
+ * to the user. Pipeline:
78
+ * 1. Try the auth-error translator (handles `No API key`, OAuth expired,
79
+ * `No models available`).
80
+ * 2. Fall back to `cleanProviderError` to peel JSON wrappers from generic
81
+ * provider errors (rate limits, content policy, etc).
82
+ *
83
+ * Single helper everywhere = no chance of one catch site forgetting to
84
+ * translate auth errors. Whenever a NEW Pi error pattern needs handling,
85
+ * extend `translateAuthError` once and every catch path benefits.
86
+ */
87
+ export function friendlyError(raw, cleanProviderError) {
88
+ const translated = translateAuthError(raw);
89
+ if (translated)
90
+ return translated;
91
+ return cleanProviderError(raw);
92
+ }
93
+ const FRIENDLY_NAMES = {
94
+ anthropic: "Anthropic",
95
+ openai: "OpenAI",
96
+ openrouter: "OpenRouter",
97
+ google: "Google Gemini",
98
+ groq: "Groq",
99
+ cerebras: "Cerebras",
100
+ xai: "xAI",
101
+ deepseek: "DeepSeek",
102
+ mistral: "Mistral",
103
+ ollama: "Ollama",
104
+ };
105
+ function friendlyProviderName(id) {
106
+ if (!id)
107
+ return "the selected provider";
108
+ const lookup = FRIENDLY_NAMES[id.toLowerCase()];
109
+ return lookup ?? id;
110
+ }
111
+ //# sourceMappingURL=auth-error.js.map
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Auth-source resolver — answers "where did this provider's credential
3
+ * actually come from?" so the TUI header, doctor output, and config listing
4
+ * can show the operator a truthful provenance label.
5
+ *
6
+ * Why this matters: Pi consults a chain of sources for each provider — the
7
+ * brigade.json `env` block (loaded into `process.env` at boot via
8
+ * `loadEnvIntoProcess`), shell-supplied env vars, and (later) custom-provider
9
+ * entries in models.json. Without explicit labelling, the operator can't
10
+ * tell whether `rm -rf ~/.brigade` will wipe their auth (yes if `source:"file"`,
11
+ * no if `source:"env"` because the shell sets it independently).
12
+ *
13
+ * The resolver is a pure function — no I/O, no env mutation. Caller passes in
14
+ * the brigade.json env block and (optionally) a substitute for process.env so
15
+ * tests can drive every source/precedence combo without poking real env vars.
16
+ */
17
+ /* ─────────────────────────── provider env-var registry ─────────────────────────── */
18
+ /**
19
+ * The env vars Pi consults for each provider, in priority order. OAuth tokens
20
+ * before plain API keys (Anthropic) — same order Pi itself walks at request
21
+ * time, so the resolved label matches what the SDK is actually using.
22
+ *
23
+ * Authoritative source: Pi's `env-api-keys.js::getApiKeyEnvVars` map. Adding
24
+ * a candidate Pi DOESN'T read produces false-positive labels (doctor reports
25
+ * "configured" while every API call 401s); omitting one Pi DOES read produces
26
+ * false-negative labels (operator can't see why their working key isn't
27
+ * surfaced). Keep this in sync with `src/providers/catalog.ts` AND with Pi.
28
+ *
29
+ * Ollama has an empty list because it requires no auth — caller can treat
30
+ * `source:"none"` for ollama specially (it's not a misconfiguration).
31
+ */
32
+ export const PROVIDER_ENV_VAR_REGISTRY = {
33
+ anthropic: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
34
+ openai: ["OPENAI_API_KEY"],
35
+ openrouter: ["OPENROUTER_API_KEY"],
36
+ google: ["GEMINI_API_KEY"],
37
+ groq: ["GROQ_API_KEY"],
38
+ cerebras: ["CEREBRAS_API_KEY"],
39
+ xai: ["XAI_API_KEY"],
40
+ deepseek: ["DEEPSEEK_API_KEY"],
41
+ mistral: ["MISTRAL_API_KEY"],
42
+ ollama: [],
43
+ };
44
+ /* ─────────────────────────── resolver ─────────────────────────── */
45
+ /**
46
+ * Walk the candidate env vars for `provider` and return the highest-priority
47
+ * one that has a value somewhere. The "file vs env" distinction is decided
48
+ * by whether the value also appears in `brigadeEnvBlock`:
49
+ *
50
+ * - In brigadeEnvBlock + in processEnv with same value → "file"
51
+ * (brigade.json::env loaded the value into the process)
52
+ * - In processEnv only (or value differs from the file) → "env"
53
+ * (the shell set it; brigade.json doesn't own it)
54
+ * - In neither → continue to next candidate
55
+ *
56
+ * If no candidate matches, returns `{ source: "none", provider }`.
57
+ *
58
+ * The `models_json` source is reserved here — this function does not resolve
59
+ * it (custom-provider wiring lives in the models.json side). Callers that
60
+ * also need to consider models.json should compose a check before calling
61
+ * this.
62
+ */
63
+ export function resolveAuthLabel(params) {
64
+ const { provider } = params;
65
+ const candidates = PROVIDER_ENV_VAR_REGISTRY[provider] ?? [];
66
+ const env = params.processEnv ?? process.env;
67
+ const block = params.brigadeEnvBlock ?? {};
68
+ const appliedFromFile = params.appliedFromFile;
69
+ for (const name of candidates) {
70
+ const fromBlock = block[name];
71
+ const fromEnv = env[name];
72
+ const blockHas = typeof fromBlock === "string" && fromBlock.length > 0;
73
+ const envHas = typeof fromEnv === "string" && fromEnv.length > 0;
74
+ // Authoritative "file" path: loadEnvIntoProcess actually wrote this
75
+ // key into process.env (i.e. shell didn't already have it). Requires
76
+ // the caller to thread the applied set in. Without that signal we
77
+ // can't distinguish "file" from "shell happens to match by coincidence."
78
+ if (appliedFromFile && appliedFromFile.has(name) && envHas) {
79
+ return { source: "file", provider, storedAs: "env_block" };
80
+ }
81
+ // Caller didn't pass appliedFromFile (tests, ad-hoc callers): fall back
82
+ // to value-equality. Less accurate — a shell value matching the file
83
+ // looks like "file" — but useful as a structural signal in tests.
84
+ if (!appliedFromFile && blockHas && envHas && fromBlock === fromEnv) {
85
+ return { source: "file", provider, storedAs: "env_block" };
86
+ }
87
+ if (envHas) {
88
+ // Either shell-supplied (block doesn't have it) OR shell pre-shadowed
89
+ // the file value (loadEnvIntoProcess skipped it). Either way, what
90
+ // the SDK actually sends came from process.env, not from brigade.json
91
+ // — so `rm -rf ~/.brigade` won't touch it.
92
+ return { source: "env", provider, envVar: name };
93
+ }
94
+ // Neither has it (or only the block has it without overlay yet):
95
+ // try the next candidate.
96
+ }
97
+ return { source: "none", provider };
98
+ }
99
+ /* ─────────────────────────── formatter ─────────────────────────── */
100
+ /**
101
+ * Stringify a label for display in the TUI header / doctor / config-list.
102
+ * The strings here are the user-facing contract — UI surfaces depend on them
103
+ * so changes ripple. Snapshot-test if you change the format.
104
+ */
105
+ export function formatAuthLabel(label) {
106
+ switch (label.source) {
107
+ case "file":
108
+ return "file (~/.brigade/brigade.json)";
109
+ case "env":
110
+ return `env (${label.envVar})`;
111
+ case "models_json":
112
+ return "custom (~/.brigade/models.json)";
113
+ case "none":
114
+ return "not authenticated";
115
+ }
116
+ }
117
+ /* ─────────────────────────── enumerate configured providers ─────────────────────────── */
118
+ /**
119
+ * Walk every known provider, resolve its label, and return only those that
120
+ * are actually configured (label.source !== "none"). Used by `brigade
121
+ * doctor`'s "configured providers" section to give the operator a one-glance
122
+ * inventory of what auth they actually have.
123
+ *
124
+ * Order is preserved from PROVIDER_ENV_VAR_REGISTRY so the doctor output is
125
+ * stable across runs — operators reading the report don't get surprised by
126
+ * reordered lists between invocations.
127
+ */
128
+ export function listConfiguredProviders(params) {
129
+ const out = [];
130
+ // `params.provider` is unused by the per-provider walk — we iterate the
131
+ // registry and re-call resolveAuthLabel for each. We pull the brigadeEnvBlock
132
+ // + processEnv off `params` so callers don't have to repeat themselves.
133
+ const sharedBlock = params.brigadeEnvBlock;
134
+ const sharedProcessEnv = params.processEnv;
135
+ for (const provider of Object.keys(PROVIDER_ENV_VAR_REGISTRY)) {
136
+ const label = resolveAuthLabel({
137
+ provider,
138
+ brigadeEnvBlock: sharedBlock,
139
+ processEnv: sharedProcessEnv,
140
+ });
141
+ if (label.source !== "none") {
142
+ out.push({ provider, label });
143
+ }
144
+ }
145
+ return out;
146
+ }
147
+ //# sourceMappingURL=auth-label.js.map