@spinabot/brigade 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/LICENSE +21 -0
  3. package/README.md +154 -0
  4. package/SECURITY.md +208 -0
  5. package/assets/brigade-wordmark-on-black.png +0 -0
  6. package/assets/brigade-wordmark.png +0 -0
  7. package/brigade.mjs +96 -0
  8. package/dist/cli/chat-cmd.js +120 -0
  9. package/dist/cli/config-cmd.js +132 -0
  10. package/dist/cli/connect-cmd.js +447 -0
  11. package/dist/cli/doctor-cmd.js +317 -0
  12. package/dist/cli/gateway-cmd.js +92 -0
  13. package/dist/cli.js +287 -0
  14. package/dist/core/agent.js +1123 -0
  15. package/dist/core/config.js +80 -0
  16. package/dist/core/console-stream.js +188 -0
  17. package/dist/core/error-classifier.js +354 -0
  18. package/dist/core/event-logger.js +122 -0
  19. package/dist/core/model-caps.js +185 -0
  20. package/dist/core/provider-payload-mutators.js +517 -0
  21. package/dist/core/provider-quirks.js +285 -0
  22. package/dist/core/server.js +459 -0
  23. package/dist/core/smart-compaction.js +209 -0
  24. package/dist/core/system-prompt-defaults.js +88 -0
  25. package/dist/core/system-prompt-guidance.js +269 -0
  26. package/dist/core/system-prompt.js +884 -0
  27. package/dist/index.js +30 -0
  28. package/dist/integrations/ollama.js +140 -0
  29. package/dist/protocol.js +49 -0
  30. package/dist/providers/catalog.js +100 -0
  31. package/dist/providers/validate-key.js +197 -0
  32. package/dist/tui/client.js +263 -0
  33. package/dist/ui/brand-frames-cli.js +20 -0
  34. package/dist/ui/brand-frames.js +36 -0
  35. package/dist/ui/brand.js +402 -0
  36. package/dist/ui/chat.js +929 -0
  37. package/dist/ui/onboarding.js +400 -0
  38. package/dist/ui/theme.js +51 -0
  39. package/package.json +92 -0
@@ -0,0 +1,317 @@
1
+ /**
2
+ * `brigade doctor` — local health check. Non-interactive, exit code 0/1.
3
+ *
4
+ * Reports on the things most likely to make Brigade misbehave silently:
5
+ * - Node version matches engines requirement
6
+ * - ~/.brigade/ exists and is writable
7
+ * - config.json present + parses
8
+ * - At least one provider in the registry has a usable key
9
+ * - Today's log file is being written
10
+ * - (Optional with --gateway) Gateway port is reachable
11
+ *
12
+ * Returns exit code 0 if every check passes, 1 if any FAIL. Warnings don't
13
+ * fail the run — they print but exit 0 so doctor is safe to use in CI.
14
+ */
15
+ import * as fs from "node:fs/promises";
16
+ import * as path from "node:path";
17
+ import process from "node:process";
18
+ import { AuthStorage, ModelRegistry } from "@mariozechner/pi-coding-agent";
19
+ import chalk from "chalk";
20
+ import { BRIGADE_DIR, loadConfig } from "../core/config.js";
21
+ import { getTodayLogPath } from "../core/event-logger.js";
22
+ import { assembleSystemPrompt, getPromptDir, seedDefaultPrompts, } from "../core/system-prompt.js";
23
+ import { findProvider, PROVIDERS } from "../providers/catalog.js";
24
+ import { DEFAULT_PORT } from "../protocol.js";
25
+ /**
26
+ * Run the full set of checks. Prints a coloured summary to stderr (so the
27
+ * stdout stream stays free for any future machine-readable mode) and returns
28
+ * the structured report so tests can assert against it.
29
+ */
30
+ export async function runDoctorCommand(opts = {}) {
31
+ const checks = [];
32
+ // ── Node version (engines.node = ">=20") ────────────────────────────
33
+ const nodeMajor = Number(process.versions.node.split(".")[0]);
34
+ checks.push(nodeMajor >= 20
35
+ ? { name: "node version", status: "pass", detail: `v${process.versions.node}` }
36
+ : {
37
+ name: "node version",
38
+ status: "fail",
39
+ detail: `v${process.versions.node} — Brigade requires Node 20+`,
40
+ });
41
+ // ── ~/.brigade exists and is writable ───────────────────────────────
42
+ let brigadeDirOk = true;
43
+ try {
44
+ await fs.mkdir(BRIGADE_DIR, { recursive: true });
45
+ const probe = path.join(BRIGADE_DIR, ".doctor-probe");
46
+ await fs.writeFile(probe, "ok", "utf8");
47
+ await fs.unlink(probe);
48
+ checks.push({ name: "brigade dir", status: "pass", detail: BRIGADE_DIR });
49
+ }
50
+ catch (err) {
51
+ brigadeDirOk = false;
52
+ checks.push({
53
+ name: "brigade dir",
54
+ status: "fail",
55
+ detail: `${BRIGADE_DIR} — ${err instanceof Error ? err.message : String(err)}`,
56
+ });
57
+ }
58
+ // ── config.json parses ──────────────────────────────────────────────
59
+ if (brigadeDirOk) {
60
+ try {
61
+ const cfg = await loadConfig();
62
+ const hasDefault = !!(cfg.defaultProvider && cfg.defaultModelId);
63
+ checks.push({
64
+ name: "config.json",
65
+ status: hasDefault ? "pass" : "warn",
66
+ detail: hasDefault
67
+ ? `default: ${cfg.defaultProvider}/${cfg.defaultModelId}`
68
+ : "no default provider/model — run `brigade onboard`",
69
+ });
70
+ }
71
+ catch (err) {
72
+ checks.push({
73
+ name: "config.json",
74
+ status: "warn",
75
+ detail: `unparseable — ${err instanceof Error ? err.message : String(err)}`,
76
+ });
77
+ }
78
+ }
79
+ // ── at least one provider with a usable key ─────────────────────────
80
+ if (brigadeDirOk) {
81
+ try {
82
+ const auth = AuthStorage.create(`${BRIGADE_DIR}/auth.json`);
83
+ const registry = ModelRegistry.create(auth, `${BRIGADE_DIR}/models.json`);
84
+ const available = registry.getAvailable();
85
+ if (available.length === 0) {
86
+ checks.push({
87
+ name: "providers",
88
+ status: "fail",
89
+ detail: "no models available — run `brigade onboard`",
90
+ });
91
+ }
92
+ else {
93
+ // Group by provider for the summary line.
94
+ const byProvider = new Map();
95
+ for (const m of available) {
96
+ byProvider.set(m.provider, (byProvider.get(m.provider) ?? 0) + 1);
97
+ }
98
+ const summary = [...byProvider.entries()]
99
+ .map(([p, n]) => `${findProvider(p)?.name ?? p} (${n})`)
100
+ .join(", ");
101
+ checks.push({
102
+ name: "providers",
103
+ status: "pass",
104
+ detail: `${available.length} models · ${summary}`,
105
+ });
106
+ }
107
+ // Sanity-check api keys for each cloud provider in the catalog. We
108
+ // don't validate over the network here — that's `brigade onboard`'s
109
+ // job — but we verify the key file is readable for each configured
110
+ // provider so we catch corrupted auth.json early.
111
+ for (const p of PROVIDERS) {
112
+ if (p.noAuth)
113
+ continue;
114
+ try {
115
+ const key = await auth.getApiKey(p.id);
116
+ if (key && key.length < 16) {
117
+ checks.push({
118
+ name: `auth · ${p.id}`,
119
+ status: "warn",
120
+ detail: "key looks too short — re-run onboard if requests fail",
121
+ });
122
+ }
123
+ }
124
+ catch {
125
+ /* missing key for this provider is normal — skip */
126
+ }
127
+ }
128
+ }
129
+ catch (err) {
130
+ checks.push({
131
+ name: "providers",
132
+ status: "fail",
133
+ detail: err instanceof Error ? err.message : String(err),
134
+ });
135
+ }
136
+ }
137
+ // ── today's log path is writable ────────────────────────────────────
138
+ if (brigadeDirOk) {
139
+ const logPath = getTodayLogPath();
140
+ try {
141
+ await fs.mkdir(path.dirname(logPath), { recursive: true });
142
+ await fs.appendFile(logPath, ""); // 0-byte append — touches without changing
143
+ checks.push({ name: "log sink", status: "pass", detail: logPath });
144
+ }
145
+ catch (err) {
146
+ checks.push({
147
+ name: "log sink",
148
+ status: "warn",
149
+ detail: `${logPath} — ${err instanceof Error ? err.message : String(err)}`,
150
+ });
151
+ }
152
+ }
153
+ // ── prompt directory present + seeded ───────────────────────────────
154
+ // Brigade ships 4 default prompt layers (soul / identity / instructions /
155
+ // tools). seedDefaultPrompts is idempotent — it never overwrites existing
156
+ // files, so it's safe to call here too. We then count how many of the
157
+ // expected layers exist; missing layers are "warn", not "fail" because
158
+ // the assembler always falls back to embedded defaults.
159
+ if (brigadeDirOk) {
160
+ const promptDir = getPromptDir();
161
+ const expectedLayers = ["soul.md", "identity.md", "instructions.md", "tools.md"];
162
+ try {
163
+ await seedDefaultPrompts(promptDir);
164
+ let presentCount = 0;
165
+ for (const basename of expectedLayers) {
166
+ try {
167
+ const stat = await fs.stat(path.join(promptDir, basename));
168
+ if (stat.isFile() && stat.size > 0)
169
+ presentCount++;
170
+ }
171
+ catch {
172
+ /* missing — counted as 0 */
173
+ }
174
+ }
175
+ if (presentCount === expectedLayers.length) {
176
+ checks.push({
177
+ name: "prompts",
178
+ status: "pass",
179
+ detail: `${presentCount}/${expectedLayers.length} layers in ${promptDir}`,
180
+ });
181
+ }
182
+ else if (presentCount === 0) {
183
+ checks.push({
184
+ name: "prompts",
185
+ status: "warn",
186
+ detail: `no layers in ${promptDir} — assembler will use embedded defaults`,
187
+ });
188
+ }
189
+ else {
190
+ checks.push({
191
+ name: "prompts",
192
+ status: "warn",
193
+ detail: `${presentCount}/${expectedLayers.length} layers in ${promptDir} — missing layers fall back to embedded defaults`,
194
+ });
195
+ }
196
+ }
197
+ catch (err) {
198
+ checks.push({
199
+ name: "prompts",
200
+ status: "warn",
201
+ detail: `${promptDir} — ${err instanceof Error ? err.message : String(err)}`,
202
+ });
203
+ }
204
+ }
205
+ // ── system prompt assembles cleanly ─────────────────────────────────
206
+ // Run a dry assembly (no model bound, no tools) to verify the assembler
207
+ // can read the layered files end-to-end without throwing. Catches:
208
+ // corrupt prompt files, permission issues that escaped seedDefaultPrompts,
209
+ // and any future regressions in the assembler itself.
210
+ if (brigadeDirOk) {
211
+ try {
212
+ const assembled = await assembleSystemPrompt({ cwd: process.cwd() });
213
+ checks.push({
214
+ name: "system prompt",
215
+ status: "pass",
216
+ detail: `assembles to ${assembled.text.length.toLocaleString()} chars (${assembled.dynamicSuffix.length} dynamic)`,
217
+ });
218
+ }
219
+ catch (err) {
220
+ checks.push({
221
+ name: "system prompt",
222
+ status: "fail",
223
+ detail: `assembly failed — ${err instanceof Error ? err.message : String(err)}`,
224
+ });
225
+ }
226
+ }
227
+ // ── (optional) gateway reachability ─────────────────────────────────
228
+ if (opts.gatewayUrl) {
229
+ const reach = await probeGateway(opts.gatewayUrl);
230
+ checks.push(reach);
231
+ }
232
+ else {
233
+ checks.push({
234
+ name: "gateway",
235
+ status: "warn",
236
+ detail: `not probed (pass --gateway ws://127.0.0.1:${DEFAULT_PORT} to check)`,
237
+ });
238
+ }
239
+ // ── summary ─────────────────────────────────────────────────────────
240
+ const ok = checks.every((c) => c.status !== "fail");
241
+ for (const c of checks) {
242
+ const tag = c.status === "pass"
243
+ ? chalk.green("PASS")
244
+ : c.status === "warn"
245
+ ? chalk.yellow("WARN")
246
+ : chalk.red("FAIL");
247
+ const line = ` ${tag} ${c.name}${c.detail ? chalk.dim(` — ${c.detail}`) : ""}`;
248
+ process.stderr.write(`${line}\n`);
249
+ }
250
+ const verdict = ok
251
+ ? chalk.green("brigade is healthy")
252
+ : chalk.red("brigade has problems — see FAIL lines above");
253
+ process.stderr.write(`\n${verdict}\n`);
254
+ if (!opts.noExit) {
255
+ process.exit(ok ? 0 : 1);
256
+ }
257
+ return { ok, checks };
258
+ }
259
+ /**
260
+ * Open a one-shot WebSocket to the gateway URL and wait briefly for the
261
+ * initial state frame the server emits on connect. If we get the frame, the
262
+ * gateway is alive; if the socket never opens or the frame never arrives,
263
+ * report fail with the underlying reason.
264
+ */
265
+ async function probeGateway(url) {
266
+ const { default: WebSocket } = await import("ws");
267
+ return new Promise((resolve) => {
268
+ let ws;
269
+ const timer = setTimeout(() => {
270
+ try {
271
+ ws?.close();
272
+ }
273
+ catch {
274
+ /* ignore */
275
+ }
276
+ resolve({
277
+ name: "gateway",
278
+ status: "fail",
279
+ detail: `${url} — no response in 3s (is \`brigade gateway\` running?)`,
280
+ });
281
+ }, 3_000);
282
+ try {
283
+ ws = new WebSocket(url);
284
+ }
285
+ catch (err) {
286
+ clearTimeout(timer);
287
+ resolve({
288
+ name: "gateway",
289
+ status: "fail",
290
+ detail: `${url} — ${err instanceof Error ? err.message : String(err)}`,
291
+ });
292
+ return;
293
+ }
294
+ ws.on("open", () => {
295
+ // We're connected. The server pushes a state frame on connect, but
296
+ // for a doctor probe getting the OPEN is enough — the socket is
297
+ // reachable and the server is accepting. Close immediately.
298
+ clearTimeout(timer);
299
+ try {
300
+ ws?.close();
301
+ }
302
+ catch {
303
+ /* ignore */
304
+ }
305
+ resolve({ name: "gateway", status: "pass", detail: url });
306
+ });
307
+ ws.on("error", (err) => {
308
+ clearTimeout(timer);
309
+ resolve({
310
+ name: "gateway",
311
+ status: "fail",
312
+ detail: `${url} — ${err instanceof Error ? err.message : String(err)}`,
313
+ });
314
+ });
315
+ });
316
+ }
317
+ //# sourceMappingURL=doctor-cmd.js.map
@@ -0,0 +1,92 @@
1
+ /**
2
+ * `brigade gateway` — start the WebSocket gateway server, no TUI.
3
+ *
4
+ * Long-running headless process that owns the Pi session and broadcasts
5
+ * events to any connected clients (Brigade TUI, future web/mobile, etc.).
6
+ * One server per machine is the assumed model — bind defaults to localhost
7
+ * to avoid LAN exposure.
8
+ *
9
+ * Logging defaults to ON at `info` level — when you run `brigade gateway`
10
+ * you SEE what's happening (Pi events, WS req/res, client connect/disconnect)
11
+ * in real time. The same events also land in the JSONL log file. Use
12
+ * `--verbose` for token-level detail, or `--quiet` for a near-silent
13
+ * supervisor-friendly boot.
14
+ *
15
+ * Flags:
16
+ * --port <n> Port (default: BRIGADE_PORT env or 7777)
17
+ * --host <addr> Bind address (default: 127.0.0.1; use 0.0.0.0 for LAN)
18
+ * --verbose Bump log level to `debug` — adds message_update
19
+ * deltas (one per token) and message_start/end. Useful
20
+ * when debugging streaming or thinking blocks.
21
+ * --log-level <level> error | warn | info | debug. Overrides --verbose
22
+ * and the default. Use `warn` for retries+errors only.
23
+ * --quiet Disable the console stream entirely. Just two boot
24
+ * lines on stderr, then silence. Use under systemd /
25
+ * nohup / tmux when you want JSONL only.
26
+ */
27
+ import process from "node:process";
28
+ import { createConsoleStream } from "../core/console-stream.js";
29
+ import { startServer } from "../core/server.js";
30
+ /**
31
+ * Boot the gateway. Resolves once the port is bound. Wires SIGINT/SIGTERM
32
+ * to a clean shutdown so the listening socket is released even on Ctrl+C.
33
+ *
34
+ * Returns a stop() function so callers (tests, cli supervisor) can shut
35
+ * the server down without sending a signal.
36
+ */
37
+ export async function runGatewayCommand(opts = {}) {
38
+ // Console stream is ON by default. Operators expect to SEE what their
39
+ // daemon is doing without flipping a flag — that was the point of moving
40
+ // from `npm run server` to `brigade gateway`. Precedence:
41
+ // --quiet → no stream at all (silent supervisor mode)
42
+ // --log-level X → explicit level X
43
+ // --verbose → debug
44
+ // (default) → info
45
+ const level = opts.quiet
46
+ ? "off"
47
+ : (opts.logLevel ?? (opts.verbose ? "debug" : "info"));
48
+ const consoleStream = level === "off" ? undefined : createConsoleStream({ level });
49
+ let handle;
50
+ try {
51
+ handle = await startServer({
52
+ port: opts.port,
53
+ host: opts.host,
54
+ consoleStream,
55
+ });
56
+ }
57
+ catch (err) {
58
+ // Translate the most common boot failures into actionable messages.
59
+ // EADDRINUSE = something already on the port (often a leftover gateway
60
+ // from a prior run, or another app). Tell the user how to recover
61
+ // instead of dumping a Node stack trace.
62
+ const msg = err instanceof Error ? err.message : String(err);
63
+ const port = opts.port ?? (Number(process.env.BRIGADE_PORT) || 7777);
64
+ if (/EADDRINUSE/.test(msg)) {
65
+ process.stderr.write(`brigade-gateway: port ${port} is already in use.\n` +
66
+ ` - find the process: PowerShell> Get-NetTCPConnection -LocalPort ${port}\n` +
67
+ ` - or pick a different port: brigade gateway --port ${port + 1}\n`);
68
+ process.exit(1);
69
+ }
70
+ if (/EACCES/.test(msg)) {
71
+ process.stderr.write(`brigade-gateway: permission denied binding port ${port} (privileged ports require admin).\n` +
72
+ ` - pick an unprivileged port: brigade gateway --port 7777\n`);
73
+ process.exit(1);
74
+ }
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);
78
+ }
79
+ // Banner already printed by startServer (verbose path uses consoleStream;
80
+ // quiet path writes the two plain lines). No additional banner needed here.
81
+ // Wire signal handlers so the listening socket gets released cleanly. The
82
+ // shared chat command's SIGINT handler is process-wide; we install our own
83
+ // here because the gateway runs without the TUI and never reaches that path.
84
+ const onSignal = (sig) => {
85
+ process.stderr.write(`brigade-gateway: ${sig} received, shutting down\n`);
86
+ void handle.stop().then(() => process.exit(0));
87
+ };
88
+ process.once("SIGTERM", () => onSignal("SIGTERM"));
89
+ process.once("SIGINT", () => onSignal("SIGINT"));
90
+ return handle.stop;
91
+ }
92
+ //# sourceMappingURL=gateway-cmd.js.map