@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.
@@ -15,6 +15,7 @@
15
15
  import * as path from "node:path";
16
16
  import { getEnvApiKey, getModels } from "@mariozechner/pi-ai";
17
17
  import { CancellableLoader, Input, SelectList, Text } from "@mariozechner/pi-tui";
18
+ import { loadBrigadeConfig, writeBrigadeConfig } from "../core/brigade-config.js";
18
19
  import { BRIGADE_DIR, saveConfig } from "../core/config.js";
19
20
  import { discoverOllamaModels, writeOllamaToModelsJson } from "../integrations/ollama.js";
20
21
  import { findProvider, PROVIDERS } from "../providers/catalog.js";
@@ -41,7 +42,7 @@ function getProviderModels(modelRegistry, providerId) {
41
42
  }
42
43
  return modelRegistry.getAll().filter((m) => m.provider === providerId);
43
44
  }
44
- export async function runOnboarding(tui, authStorage, modelRegistry) {
45
+ export async function runOnboarding(tui, authStorage, modelRegistry, opts = {}) {
45
46
  // Tiny step state machine so each step can resolve to "ok" or "back".
46
47
  // Esc on provider picker exits onboarding entirely; Esc on later steps
47
48
  // just rewinds to the previous step. This is what makes invalid keys
@@ -70,7 +71,7 @@ export async function runOnboarding(tui, authStorage, modelRegistry) {
70
71
  step = "model";
71
72
  continue;
72
73
  }
73
- const result = await ensureApiKey(tui, authStorage, provider);
74
+ const result = await ensureApiKey(tui, authStorage, provider, { noEnvDetect: opts.noEnvDetect });
74
75
  if (result === "back") {
75
76
  step = "provider";
76
77
  continue;
@@ -111,11 +112,28 @@ function renderScreen(tui, subheader) {
111
112
  }
112
113
  /* ────────────────────────────── steps ─────────────────────────────────── */
113
114
  async function pickProvider(tui) {
114
- const items = PROVIDERS.map((p) => ({
115
- value: p.id,
116
- label: p.name,
117
- description: p.description,
118
- }));
115
+ // Re-order providers so any with a credential the user already has —
116
+ // either an env var Pi can read, OR a noAuth provider like Ollama —
117
+ // floats to the top. Without this, PROVIDERS[0] = anthropic always wins
118
+ // the highlight, and a user with only OPENROUTER_API_KEY exported picks
119
+ // anthropic by reflex (then fails when they send a message).
120
+ const detected = [];
121
+ const undetected = [];
122
+ for (const p of PROVIDERS) {
123
+ const hasEnvKey = !!getEnvApiKey(p.id);
124
+ const noAuth = p.noAuth === true;
125
+ const item = {
126
+ value: p.id,
127
+ label: p.name,
128
+ description: hasEnvKey
129
+ ? `${p.description} · detected ${p.envVar ?? "env var"}`
130
+ : noAuth
131
+ ? `${p.description} · no auth required`
132
+ : p.description,
133
+ };
134
+ (hasEnvKey || noAuth ? detected : undetected).push(item);
135
+ }
136
+ const items = [...detected, ...undetected];
119
137
  const list = new SelectList(items, Math.min(items.length, 9), selectListTheme, {
120
138
  minPrimaryColumnWidth: 18,
121
139
  maxPrimaryColumnWidth: 22,
@@ -139,24 +157,133 @@ async function pickProvider(tui) {
139
157
  * and re-enters the field with their previous attempt pre-filled. Esc on
140
158
  * the input bails out of the loop with "back" instead of throwing.
141
159
  */
142
- async function ensureApiKey(tui, authStorage, providerId) {
160
+ export async function ensureApiKey(tui, authStorage, providerId, opts = {}) {
143
161
  const provider = findProvider(providerId);
144
162
  if (!provider)
145
163
  throw new Error(`Unknown provider: ${providerId}`);
146
164
  // Already configured? (env var OR previously stored)
147
- const existing = await authStorage.getApiKey(providerId);
165
+ // CRITICAL: an env var being PRESENT doesn't mean it WORKS — stale leftover
166
+ // values (from old experiments, exported by .bashrc, etc.) silently auto-
167
+ // completed onboarding and let users reach chat with a dead key. We now
168
+ // always run the online validation. Stored credentials get the fast accept
169
+ // only if they previously passed validation when first saved (which is
170
+ // always — see the typed-key path below — so saved keys are trustworthy
171
+ // enough to skip the re-check).
172
+ //
173
+ // `noEnvDetect` short-circuits the entire env-detection path: when true we
174
+ // pretend no credential exists, regardless of what `authStorage` returns
175
+ // from a shell env fallback. The typed-key prompt fires immediately. This
176
+ // is the enterprise / CI escape hatch — operators who want TYPED-only auth
177
+ // can flip the flag and Brigade never silently consults `$env:OPENROUTER_API_KEY`
178
+ // or its peers.
179
+ const existing = opts.noEnvDetect ? "" : await authStorage.getApiKey(providerId);
148
180
  if (existing) {
149
- renderScreen(tui, `Step 2 of 3 · ${provider.name}`);
150
181
  const fromEnv = !!getEnvApiKey(providerId);
151
- const source = fromEnv ? "your environment" : "your saved credentials";
152
- tui.addChild(new Text(` ${brand.amber("✓")} ${provider.name} is already connected (using ${brand.white(source)}).`, 0, 0));
182
+ if (!fromEnv) {
183
+ // Saved credential passed validation when first stored. Fast accept.
184
+ renderScreen(tui, `Step 2 of 3 · ${provider.name}`);
185
+ tui.addChild(new Text(` ${brand.amber("✓")} ${provider.name} is already connected (using ${brand.white("your saved credentials")}).`, 0, 0));
186
+ tui.requestRender();
187
+ await delay(600);
188
+ return "ok";
189
+ }
190
+ // Env-supplied key: ALWAYS confirm with the user before adopting it.
191
+ // Just because OPENROUTER_API_KEY (etc.) is exported in the user's
192
+ // shell does NOT mean they want this onboarding session to consume it.
193
+ // Auto-accepting silently led to the "I never typed a key, why is it
194
+ // already connected?" footgun. Default to No (defensive opt-in) so
195
+ // pressing Enter falls through to the typed-key prompt.
196
+ //
197
+ // Wording rationale: "shell environment" is portable across PowerShell,
198
+ // bash, zsh, fish, cmd, etc. The previous "(env, sk-…)" label was too
199
+ // cryptic — "env" could be read as brigade.json::env OR shell env, and
200
+ // users who had just run `Remove-Item -Recurse ~/.brigade` (or the bash
201
+ // equivalent) couldn't tell where the credential was coming from. Be
202
+ // explicit so they immediately know the key originated from THEIR shell,
203
+ // not from a stale brigade.json.
204
+ const envVar = provider.envVar ?? "the env var";
205
+ renderScreen(tui, `Step 2 of 3 · ${provider.name}`);
206
+ tui.addChild(new Text(brand.dim(` Brigade detected this key in your shell environment.`), 0, 0));
207
+ tui.addChild(new Text(brand.dim(` Picking "Yes" persists it to ~/.brigade/brigade.json (so it survives shell restarts and machine moves).`), 0, 0));
208
+ tui.addChild(new Text("", 0, 0));
209
+ tui.addChild(new Text(` ${brand.amber("?")} Use ${envVar} from your shell environment (${formatApiKeyPreview(existing)})?`, 0, 0));
210
+ tui.addChild(new Text(brand.dim(` Pick "No" to paste a different ${provider.name} key instead.`), 0, 0));
211
+ tui.addChild(new Text("", 0, 0));
212
+ const confirmList = new SelectList([
213
+ { value: "no", label: "No, let me paste a different key" },
214
+ { value: "yes", label: "Yes, use the shell-env value" },
215
+ ], 2, selectListTheme, { minPrimaryColumnWidth: 36, maxPrimaryColumnWidth: 48 });
216
+ confirmList.setSelectedIndex(0); // defensive: default = No
217
+ tui.addChild(confirmList);
218
+ tui.setFocus(confirmList);
153
219
  tui.requestRender();
154
- await delay(600);
155
- return "ok";
220
+ let confirmChoice;
221
+ try {
222
+ const chosen = await new Promise((resolve, reject) => {
223
+ confirmList.onSelect = (item) => resolve(item);
224
+ confirmList.onCancel = () => reject(new Error("back"));
225
+ });
226
+ confirmChoice = chosen.value === "yes" ? "yes" : "no";
227
+ }
228
+ catch {
229
+ return "back";
230
+ }
231
+ tui.removeChild(confirmList);
232
+ if (confirmChoice === "no") {
233
+ // User declined the env key — skip the validation path entirely
234
+ // and let them paste their own. The typed-key loop below treats
235
+ // `lastError === null` as a clean first iteration, so no stale
236
+ // error text leaks in.
237
+ renderScreen(tui, `Step 2 of 3 · Connect ${provider.name}`);
238
+ // Fall through to typed-key loop without `lastError` set.
239
+ // (Variable declared just below the env block.)
240
+ return await promptTypedKey(tui, authStorage, provider, providerId, null);
241
+ }
242
+ // User confirmed — verify the env key actually works before accepting.
243
+ tui.addChild(new Text(` ${brand.dim(`Verifying ${envVar} with ${provider.name}…`)}`, 0, 0));
244
+ const envLoader = new CancellableLoader(tui, (s) => brand.amber(s), (s) => brand.dim(s), `Verifying ${provider.name}…`);
245
+ tui.addChild(envLoader);
246
+ tui.requestRender();
247
+ const envCheck = await validateApiKeyOnline(providerId, existing);
248
+ tui.removeChild(envLoader);
249
+ if (envCheck.ok) {
250
+ // State-isolation contract: everything Brigade needs lives under
251
+ // `~/.brigade/`. The env var that fed us this key is owned by the
252
+ // user's shell, so without persisting it here, `rm -rf ~/.brigade`
253
+ // would NOT actually wipe auth — the key would sneak back in via
254
+ // env detection on the next boot. Mirror it into brigade.json::env
255
+ // so the file is the canonical home for the credential.
256
+ await persistEnvKeyToBrigadeConfig(provider, existing);
257
+ tui.addChild(new Text(` ${brand.amber("✓")} ${provider.name} is already connected (using ${brand.white("your environment")}).`, 0, 0));
258
+ tui.requestRender();
259
+ await delay(600);
260
+ return "ok";
261
+ }
262
+ // Stale env var (user confirmed but it failed validation). Don't auto-
263
+ // skip — drop into the typed-key path with the failure seeded so the
264
+ // user immediately sees WHY their env key didn't work and can paste a
265
+ // fresh one.
266
+ const staleReason = `The ${provider.envVar ?? "env var"} for ${provider.name} doesn't work: ${envCheck.reason}`;
267
+ return await promptTypedKey(tui, authStorage, provider, providerId, staleReason);
156
268
  }
269
+ return await promptTypedKey(tui, authStorage, provider, providerId, null);
270
+ }
271
+ /**
272
+ * Typed-key entry loop, extracted so both the env-confirm-no path and the
273
+ * no-env-key path can share it without duplicating the retry/validate dance.
274
+ *
275
+ * `seedError` lets the caller pre-render an error line above the input —
276
+ * used when a confirmed env key fails online validation, so the user sees
277
+ * "the env var didn't work" reason instead of a blank "paste your key" prompt.
278
+ *
279
+ * Returns:
280
+ * - "ok" → key validated and persisted
281
+ * - "back" → user pressed Esc; caller rewinds to provider picker
282
+ */
283
+ async function promptTypedKey(tui, authStorage, provider, providerId, seedError) {
157
284
  // Retry loop. Each iteration re-renders the screen so old error lines and
158
285
  // stale loader frames don't pile up vertically.
159
- let lastError = null;
286
+ let lastError = seedError;
160
287
  while (true) {
161
288
  renderScreen(tui, `Step 2 of 3 · Connect ${provider.name}`);
162
289
  if (lastError) {
@@ -203,6 +330,12 @@ async function ensureApiKey(tui, authStorage, providerId) {
203
330
  // Step 3: only persist after both checks pass.
204
331
  authStorage.set(providerId, { type: "api_key", key });
205
332
  authStorage.reload();
333
+ // Mirror the credential into brigade.json::env so the unified
334
+ // super-config remains the canonical state-isolation boundary —
335
+ // `rm -rf ~/.brigade` reliably wipes everything, including any env
336
+ // fallback Pi might pick up on a future boot. No-op for providers
337
+ // that don't have a documented env var (custom OpenAI-compatible).
338
+ await persistEnvKeyToBrigadeConfig(provider, key);
206
339
  const successLine = onlineCheck.modelCount
207
340
  ? `${provider.name} connected · ${onlineCheck.modelCount} model${onlineCheck.modelCount === 1 ? "" : "s"} available`
208
341
  : `${provider.name} connected`;
@@ -379,6 +512,76 @@ function renderDone(tui, provider, modelId) {
379
512
  tui.requestRender();
380
513
  }
381
514
  /* ────────────────────────────── helpers ───────────────────────────────── */
515
+ /**
516
+ * Render an API key in a form safe to splat to the terminal/log: shows the
517
+ * first 4 and last 4 characters separated by an ellipsis, e.g. `sk-o…2b5x`.
518
+ *
519
+ * Anything shorter than `head + tail + 4` is rejected as `<too short>` so we
520
+ * never reveal a meaningful fraction of an 8-char key. Empty/whitespace-only
521
+ * input returns `<empty>`. The user invokes this in a confirm prompt so they
522
+ * can recognize *which* key is sitting in their env without us echoing the
523
+ * whole thing into scrollback (or worse, a bug-report copy-paste).
524
+ */
525
+ export function formatApiKeyPreview(raw, opts = {}) {
526
+ const head = opts.head ?? 4;
527
+ const tail = opts.tail ?? 4;
528
+ const trimmed = (raw ?? "").trim();
529
+ if (!trimmed)
530
+ return "<empty>";
531
+ // Don't show ANY characters of a key that's too short to safely split —
532
+ // a 6-char key with head=4/tail=4 would otherwise leak the entire value.
533
+ if (trimmed.length < head + tail + 4)
534
+ return "<too short>";
535
+ return `${trimmed.slice(0, head)}…${trimmed.slice(-tail)}`;
536
+ }
537
+ /**
538
+ * Persist a validated API key into `~/.brigade/brigade.json::env.<envVar>`.
539
+ * Goes through `loadBrigadeConfig` + `writeBrigadeConfig` so writes are
540
+ * atomic + .bak-rotated; never touches the file directly.
541
+ *
542
+ * Also pokes `process.env[envVar]` so the in-process agent that's about to
543
+ * boot sees the credential without needing a restart. (The next boot will
544
+ * pick it up via `loadEnvIntoProcess` from brigade.json.)
545
+ *
546
+ * No-op when the provider has no documented env var (Ollama, custom OpenAI-
547
+ * compatible) — those are not credential-driven and shouldn't pollute the
548
+ * env block.
549
+ *
550
+ * Failure is non-fatal: a write error here would leave the user with a
551
+ * working chat session backed by `auth.json` (typed-key path) or the live
552
+ * env var (env-key path) — better to log and continue than abort the
553
+ * onboarding the user has already invested time in.
554
+ */
555
+ async function persistEnvKeyToBrigadeConfig(provider, key) {
556
+ const envVar = provider.envVar;
557
+ if (!envVar || envVar.length === 0)
558
+ return;
559
+ if (!key || key.length === 0)
560
+ return;
561
+ try {
562
+ const cfg = await loadBrigadeConfig(BRIGADE_DIR);
563
+ const env = { ...(cfg.env ?? {}) };
564
+ // Skip a redundant write if the value is already pinned correctly —
565
+ // avoids a useless .bak rotation (and a useless fsync) when the user
566
+ // re-runs onboarding with an unchanged env-supplied credential.
567
+ if (env[envVar] === key) {
568
+ process.env[envVar] = key;
569
+ return;
570
+ }
571
+ env[envVar] = key;
572
+ await writeBrigadeConfig(BRIGADE_DIR, { ...cfg, env });
573
+ // Make the credential visible to the about-to-boot agent without a
574
+ // restart. brigade.json is now the canonical source; this just
575
+ // short-circuits "wait for the next process to load it."
576
+ process.env[envVar] = key;
577
+ }
578
+ catch (err) {
579
+ // Surface to stderr but don't crash onboarding — the credential is
580
+ // already valid in the running process, so the user can keep chatting.
581
+ const msg = err instanceof Error ? err.message : String(err);
582
+ process.stderr.write(`brigade: warning: couldn't persist ${envVar} to brigade.json (${msg})\n`);
583
+ }
584
+ }
382
585
  function clear(tui) {
383
586
  for (const child of [...tui.children])
384
587
  tui.removeChild(child);
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Centralized terminal-state restoration on TUI exit.
3
+ *
4
+ * After a Brigade TUI session ends (clean exit, Ctrl+C, crash, signal,
5
+ * `process.exit`, anything), the user's shell prompt must look like Brigade
6
+ * was never there. The kitty keyboard protocol in particular is sticky: if
7
+ * the push (CSI > 1 u) isn't matched by a pop (CSI < u) on the way out,
8
+ * terminals that support it (Kitty, Ghostty, WezTerm, recent Windows
9
+ * Terminal) keep emitting key release events into the next program — which
10
+ * the shell then renders as visible garbage like `[57361;1:3u`.
11
+ *
12
+ * Why we don't rely solely on Pi-TUI's ProcessTerminal.stop():
13
+ * 1. Its kitty pop is gated on a `_kittyProtocolActive` flag that gets
14
+ * set lazily after the terminal answers the capability query. If the
15
+ * user exits between query-write and response-read, the push happens
16
+ * anyway (or rather: kitty mode is left in whatever state the terminal
17
+ * already held) and the pop is skipped.
18
+ * 2. It only runs on graceful `tui.stop()` paths. A crash that bypasses
19
+ * `tui.stop()` leaves the terminal in raw + kitty + bracketed-paste
20
+ * mode forever.
21
+ * 3. It never emits the broader safety net (focus reporting, mouse
22
+ * tracking, synchronized output, alt-screen) which other libraries
23
+ * sometimes leave on.
24
+ *
25
+ * `restoreTerminal()` is idempotent — every escape sequence here is a
26
+ * "disable" or "show", and terminals tolerate disable-when-disabled. Safe
27
+ * to wire into multiple exit paths and to call repeatedly.
28
+ *
29
+ * IMPORTANT: writes go to stderr, never stdout. stderr is the conventional
30
+ * channel for terminal control and is unbuffered + always-on; stdout might
31
+ * be redirected to a file or pipe, in which case writing escape sequences
32
+ * there would corrupt the captured output.
33
+ */
34
+ import process from "node:process";
35
+ /**
36
+ * Emit every escape sequence needed to restore the terminal to a known-good
37
+ * state. Called from every Brigade exit path (signal handlers, normal exit,
38
+ * crash, `process.on("exit")`).
39
+ */
40
+ export function restoreTerminal() {
41
+ const out = process.stderr;
42
+ try {
43
+ out.write(
44
+ // 1. Pop kitty keyboard protocol stack — THE main bug fix. Without
45
+ // this, key release events leak into the user's shell as visible
46
+ // text like `[57361;1:3u`.
47
+ "\x1b[<u" +
48
+ // 2. Disable any pushed kitty modes (extra safety; no-op if
49
+ // nothing was pushed). `>0u` clears the current flag set
50
+ // explicitly in case the pop above didn't drain it.
51
+ "\x1b[>0u" +
52
+ // 3. Disable xterm modifyOtherKeys mode (used by Pi-TUI as a
53
+ // tmux fallback when kitty isn't available).
54
+ "\x1b[>4;0m" +
55
+ // 4. Disable bracketed paste (Pi-TUI enables `?2004h` on entry).
56
+ "\x1b[?2004l" +
57
+ // 5. Show the cursor — Pi-TUI hides it on entry via `?25l`.
58
+ "\x1b[?25h" +
59
+ // 6. Disable synchronized output mode (some renderers leave
60
+ // this on across an unclean exit).
61
+ "\x1b[?2026l" +
62
+ // 7. Disable focus reporting.
63
+ "\x1b[?1004l" +
64
+ // 8. Disable every mouse tracking flavor anyone might have on.
65
+ "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l" +
66
+ // 9. Reset cursor style to terminal default (DECSCUSR 0).
67
+ "\x1b[0 q" +
68
+ // 10. Reset color / SGR attributes so the next prompt isn't
69
+ // painted in whatever color we last used.
70
+ "\x1b[0m" +
71
+ // 11. Exit alternate screen buffer LAST — biggest visual change,
72
+ // and we want every other mode reset while alt screen is
73
+ // still active so the cleanup looks tidy if anyone ever
74
+ // enables it. Brigade itself doesn't use alt screen, but
75
+ // the sequence costs nothing on terminals that aren't in it.
76
+ "\x1b[?1049l");
77
+ }
78
+ catch {
79
+ // Terminal may already be closed (EPIPE) — nothing to do.
80
+ }
81
+ }
82
+ /**
83
+ * Install a single `process.on("exit")` handler that runs `restoreTerminal()`.
84
+ * Idempotent — registers at most once per process so callers in multiple
85
+ * subcommand entry points don't stack listeners.
86
+ *
87
+ * `process.on("exit")` fires for ALL exit paths (normal return, throw,
88
+ * signal, `process.exit`, etc.) and is synchronous, which is the right shape
89
+ * for emitting a single string of bytes to stderr.
90
+ */
91
+ let exitHandlerInstalled = false;
92
+ export function installTerminalCleanupHandler() {
93
+ if (exitHandlerInstalled)
94
+ return;
95
+ exitHandlerInstalled = true;
96
+ process.on("exit", restoreTerminal);
97
+ // Signals that should also flush cleanup before we exit. `process.on("exit")`
98
+ // alone covers `process.exit()` calls but NOT signal-driven termination
99
+ // (Node defaults to dying without firing the exit event when an uncaught
100
+ // signal arrives). Wiring these explicitly guarantees cleanup before the
101
+ // terminal hands control back to the shell.
102
+ const signalHandler = (sig) => {
103
+ restoreTerminal();
104
+ // Re-raise the default behavior: exit with 128 + signal number convention.
105
+ // SIGINT → 130, SIGTERM → 143, SIGHUP → 129, SIGQUIT → 131.
106
+ const code = sig === "SIGINT"
107
+ ? 130
108
+ : sig === "SIGTERM"
109
+ ? 143
110
+ : sig === "SIGHUP"
111
+ ? 129
112
+ : sig === "SIGQUIT"
113
+ ? 131
114
+ : 1;
115
+ // Use `process.exit` rather than re-raising so the `exit` event fires
116
+ // (where restoreTerminal will run again, harmlessly, via idempotency).
117
+ process.exit(code);
118
+ };
119
+ // Use addListener (not once) so we don't disarm if any subcommand-specific
120
+ // handler also fires — but DON'T register if a listener with the same
121
+ // effective intent already exists (chat-cmd / connect-cmd install their own
122
+ // SIGINT handlers that call tui.stop() first; ours runs alongside).
123
+ //
124
+ // We only register SIGTERM and SIGHUP here. SIGINT is owned by the
125
+ // per-subcommand handler (which has UI state to tear down — abort an
126
+ // in-flight turn first, etc.); that handler calls `restoreTerminal()`
127
+ // directly before `process.exit`, and the `exit` event handler above is
128
+ // the final safety net.
129
+ process.on("SIGTERM", signalHandler);
130
+ process.on("SIGHUP", signalHandler);
131
+ }
132
+ //# sourceMappingURL=terminal-cleanup.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spinabot/brigade",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "description": "Brigade — your personal AI crew. Polished terminal CLI for Anthropic, OpenAI, Gemini, Groq, xAI, DeepSeek, Mistral, OpenRouter, Cerebras, Ollama and any OpenAI-compatible endpoint. Built on the Pi SDK.",
6
6
  "license": "MIT",
@@ -28,8 +28,7 @@
28
28
  "LICENSE",
29
29
  "CHANGELOG.md",
30
30
  "SECURITY.md",
31
- "assets/brigade-wordmark.png",
32
- "assets/brigade-wordmark-on-black.png"
31
+ "assets/brigade-wordmark.png"
33
32
  ],
34
33
  "keywords": [
35
34
  "ai",