@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.
- package/CHANGELOG.md +48 -10
- package/LICENSE +21 -21
- package/README.md +8 -6
- package/dist/cli/chat-cmd.js +74 -3
- package/dist/cli/config-cmd.js +40 -1
- package/dist/cli/connect-cmd.js +42 -2
- package/dist/cli/doctor-cmd.js +85 -15
- package/dist/cli/gateway-cmd.js +69 -6
- package/dist/cli.js +53 -12
- package/dist/core/agent.js +3 -3
- package/dist/core/auth-error.js +111 -0
- package/dist/core/auth-label.js +147 -0
- package/dist/core/brigade-config.js +730 -0
- package/dist/core/cli-error.js +94 -0
- package/dist/core/config.js +182 -7
- package/dist/core/exec-approvals.js +337 -0
- package/dist/core/server.js +36 -0
- package/dist/core/system-prompt-defaults.js +221 -45
- package/dist/core/system-prompt-guidance.js +2 -0
- package/dist/core/system-prompt.js +842 -102
- package/dist/core/version.js +14 -0
- package/dist/index.js +8 -6
- package/dist/protocol.js +15 -0
- package/dist/ui/brand.js +31 -15
- package/dist/ui/chat.js +90 -11
- package/dist/ui/onboarding.js +218 -15
- package/dist/ui/terminal-cleanup.js +132 -0
- package/package.json +2 -3
- package/assets/brigade-wordmark-on-black.png +0 -0
package/dist/cli/gateway-cmd.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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 —
|
|
76
|
-
|
|
77
|
-
process.
|
|
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(() =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
|
|
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
|
}
|
package/dist/core/agent.js
CHANGED
|
@@ -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/
|
|
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/
|
|
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/
|
|
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
|