@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/ui/onboarding.js
CHANGED
|
@@ -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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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 =
|
|
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.
|
|
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",
|
|
Binary file
|