@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
package/dist/cli.js ADDED
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Brigade — your personal AI crew.
4
+ *
5
+ * Single-binary CLI with subcommands, like `git`.
6
+ *
7
+ * brigade # = brigade chat (default)
8
+ * brigade chat # in-process TUI + Pi session
9
+ * brigade gateway # WebSocket gateway server, no TUI
10
+ * brigade connect # TUI that talks to a running gateway
11
+ * brigade onboard # provider/model setup wizard
12
+ * brigade doctor # local health check
13
+ * brigade config list|get|set|unset # ~/.brigade/config.json
14
+ * brigade --version
15
+ * brigade --help
16
+ *
17
+ * All subcommands forward unrecognised flags through to the subcommand
18
+ * handler — the dispatcher itself only knows about top-level flags.
19
+ *
20
+ * IMPORTANT: do not add an external CLI library. The argv shape is small
21
+ * enough for hand-parsing. Adding a dependency would inflate the install
22
+ * and slow `brigade --version`.
23
+ */
24
+ import process from "node:process";
25
+ import { pathToFileURL } from "node:url";
26
+ import chalk from "chalk";
27
+ import { runChatCommand } from "./cli/chat-cmd.js";
28
+ import { runConfigCommand } from "./cli/config-cmd.js";
29
+ import { runConnectCommand } from "./cli/connect-cmd.js";
30
+ import { runDoctorCommand } from "./cli/doctor-cmd.js";
31
+ import { runGatewayCommand } from "./cli/gateway-cmd.js";
32
+ import { DEFAULT_PORT } from "./protocol.js";
33
+ const VERSION = "0.1.0";
34
+ const HELP = `${chalk.bold("brigade")} — your personal AI crew
35
+
36
+ ${chalk.dim("usage:")}
37
+ brigade [subcommand] [flags]
38
+
39
+ ${chalk.dim("subcommands:")}
40
+ ${chalk.bold("chat")} start the in-process TUI (default if no subcommand)
41
+ ${chalk.bold("gateway")} run the WebSocket gateway server, no TUI
42
+ ${chalk.bold("connect")} TUI that talks to a running gateway
43
+ ${chalk.bold("onboard")} run the provider/model setup wizard
44
+ ${chalk.bold("doctor")} check the local environment is healthy
45
+ ${chalk.bold("config")} read & write ~/.brigade/config.json
46
+
47
+ ${chalk.dim("flags (all subcommands respect):")}
48
+ --help show help (per-subcommand if applicable)
49
+ --version print version
50
+
51
+ ${chalk.dim("flags (gateway):")}
52
+ --port <n> listen port (default: ${DEFAULT_PORT}; env: BRIGADE_PORT)
53
+ --host <addr> bind address (default: 127.0.0.1)
54
+ --verbose bump log level to debug (token deltas + intra-message events)
55
+ --quiet disable the live console stream (JSONL log only)
56
+ --log-level <l> error | warn | info | debug (default: info — overrides --verbose)
57
+
58
+ ${chalk.dim("flags (connect):")}
59
+ --host <addr> gateway host (default: 127.0.0.1)
60
+ --port <n> gateway port (default: ${DEFAULT_PORT})
61
+ --timeout <ms> per-request timeout (default: 60000)
62
+
63
+ ${chalk.dim("flags (doctor):")}
64
+ --gateway <url> probe a running gateway URL too
65
+
66
+ ${chalk.dim("examples:")}
67
+ brigade # start chatting
68
+ brigade gateway --port 7777 # daemon mode, terminal #1
69
+ brigade connect --port 7777 # client mode, terminal #2
70
+ brigade doctor --gateway ws://127.0.0.1:7777
71
+ brigade config set defaultModelId claude-sonnet-4-6
72
+ `;
73
+ /** Flags that NEVER take a value — listing them here prevents the next-token
74
+ * consumption trap. Add new boolean flags here when introducing them. */
75
+ export const BOOLEAN_FLAGS = new Set([
76
+ "help",
77
+ "h",
78
+ "version",
79
+ "v",
80
+ "verbose",
81
+ "quiet",
82
+ "force",
83
+ ]);
84
+ export function parseArgs(argv) {
85
+ const positional = [];
86
+ const flags = {};
87
+ let sawSeparator = false;
88
+ for (let i = 0; i < argv.length; i++) {
89
+ const tok = argv[i];
90
+ if (sawSeparator) {
91
+ positional.push(tok);
92
+ continue;
93
+ }
94
+ if (tok === "--") {
95
+ sawSeparator = true;
96
+ continue;
97
+ }
98
+ if (tok.startsWith("--")) {
99
+ const body = tok.slice(2);
100
+ const eq = body.indexOf("=");
101
+ if (eq >= 0) {
102
+ // --flag=value — explicit, never consumes the next token
103
+ flags[body.slice(0, eq)] = body.slice(eq + 1);
104
+ continue;
105
+ }
106
+ const key = body;
107
+ if (BOOLEAN_FLAGS.has(key)) {
108
+ flags[key] = true;
109
+ continue;
110
+ }
111
+ // Value flag: consume next token UNLESS it's another flag or
112
+ // missing entirely. A flag with no value is treated as `true`
113
+ // so callers that didn't expect it can still detect it.
114
+ const next = argv[i + 1];
115
+ if (next !== undefined && !next.startsWith("--") && next !== "--") {
116
+ flags[key] = next;
117
+ i++;
118
+ }
119
+ else {
120
+ flags[key] = true;
121
+ }
122
+ }
123
+ else {
124
+ positional.push(tok);
125
+ }
126
+ }
127
+ return { positional, flags };
128
+ }
129
+ /**
130
+ * The subcommand registry. Adding a new subcommand:
131
+ * 1. Build src/cli/<name>-cmd.ts exporting an async runXxxCommand(opts)
132
+ * 2. Add an entry here that maps the literal to a handler
133
+ * 3. Mention it in HELP above
134
+ */
135
+ const SUBCOMMANDS = {
136
+ chat: async () => {
137
+ await runChatCommand();
138
+ },
139
+ gateway: async ({ flags }) => {
140
+ const port = typeof flags.port === "string"
141
+ ? Number(flags.port)
142
+ : undefined;
143
+ const host = typeof flags.host === "string" ? flags.host : undefined;
144
+ const verbose = flags.verbose === true;
145
+ const quiet = flags.quiet === true;
146
+ const logLevelRaw = typeof flags["log-level"] === "string" ? flags["log-level"] : undefined;
147
+ if (port !== undefined && (!Number.isFinite(port) || port < 1 || port > 65_535)) {
148
+ process.stderr.write(chalk.red(`invalid --port value: ${flags.port}\n`));
149
+ return 2;
150
+ }
151
+ if (verbose && quiet) {
152
+ process.stderr.write(chalk.red(`--verbose and --quiet are mutually exclusive\n`));
153
+ return 2;
154
+ }
155
+ const validLevels = ["error", "warn", "info", "debug"];
156
+ if (logLevelRaw && !validLevels.includes(logLevelRaw)) {
157
+ process.stderr.write(chalk.red(`invalid --log-level value: ${logLevelRaw} (use error|warn|info|debug)\n`));
158
+ return 2;
159
+ }
160
+ const logLevel = logLevelRaw;
161
+ await runGatewayCommand({ port, host, verbose, quiet, logLevel });
162
+ // runGatewayCommand resolves once the server is listening; the process
163
+ // stays alive on its own (the WebSocketServer + http.Server hold the
164
+ // event loop). Don't return — let the process keep running until SIGINT.
165
+ await new Promise(() => { });
166
+ },
167
+ connect: async ({ flags }) => {
168
+ const host = typeof flags.host === "string" ? flags.host : undefined;
169
+ const port = typeof flags.port === "string"
170
+ ? Number(flags.port)
171
+ : undefined;
172
+ const timeout = typeof flags.timeout === "string"
173
+ ? Number(flags.timeout)
174
+ : undefined;
175
+ if (port !== undefined && (!Number.isFinite(port) || port < 1 || port > 65_535)) {
176
+ process.stderr.write(chalk.red(`invalid --port value: ${flags.port}\n`));
177
+ return 2;
178
+ }
179
+ await runConnectCommand({ host, port, requestTimeoutMs: timeout });
180
+ // Same as gateway — the TUI's stdin handler keeps the process alive.
181
+ await new Promise(() => { });
182
+ },
183
+ onboard: async () => {
184
+ // Onboarding lives inside chat-cmd's boot path today (it runs when no
185
+ // saved config is present). For an explicit `brigade onboard`, force
186
+ // the wizard by deleting the saved default before booting chat — the
187
+ // wizard re-runs and writes a fresh default.
188
+ //
189
+ // Implemented as "chat with forced re-onboarding" instead of duplicating
190
+ // the wizard call site so future onboarding changes (e.g., MCP setup)
191
+ // stay in one place.
192
+ const fs = await import("node:fs/promises");
193
+ const path = await import("node:path");
194
+ const os = await import("node:os");
195
+ const cfgPath = path.join(os.homedir(), ".brigade", "config.json");
196
+ try {
197
+ const existing = JSON.parse(await fs.readFile(cfgPath, "utf8"));
198
+ delete existing.defaultProvider;
199
+ delete existing.defaultModelId;
200
+ await fs.writeFile(cfgPath, JSON.stringify(existing, null, 2), "utf8");
201
+ }
202
+ catch {
203
+ /* missing config is fine — wizard will run regardless */
204
+ }
205
+ await runChatCommand();
206
+ },
207
+ doctor: async ({ flags }) => {
208
+ const gatewayUrl = typeof flags.gateway === "string" ? flags.gateway : undefined;
209
+ await runDoctorCommand({ gatewayUrl });
210
+ // runDoctorCommand calls process.exit on its own.
211
+ },
212
+ config: async ({ positional }) => {
213
+ // positional[0] is the subcommand (e.g. "set"); the rest are its args.
214
+ const result = await runConfigCommand(positional);
215
+ return result.exitCode;
216
+ },
217
+ };
218
+ /**
219
+ * Top-level dispatch. Exported so tests can call directly without spawning
220
+ * a subprocess.
221
+ */
222
+ export async function runCli(argv) {
223
+ const parsed = parseArgs(argv);
224
+ // Top-level flags first.
225
+ if (parsed.flags.version === true || parsed.flags.v === true) {
226
+ process.stdout.write(`brigade ${VERSION}\n`);
227
+ return 0;
228
+ }
229
+ if (parsed.flags.help === true || parsed.flags.h === true) {
230
+ process.stdout.write(HELP);
231
+ return 0;
232
+ }
233
+ // Default subcommand: chat
234
+ const sub = parsed.positional[0] ?? "chat";
235
+ // Help on a specific subcommand: `brigade <sub> --help` falls through to
236
+ // the per-subcommand handler so it can print its own usage if it wants
237
+ // to. For now they don't have rich per-subcommand help — surface the
238
+ // global one as a fallback.
239
+ if (!(sub in SUBCOMMANDS)) {
240
+ process.stderr.write(chalk.red(`unknown subcommand: ${sub}\n\n`));
241
+ process.stderr.write(HELP);
242
+ return 2;
243
+ }
244
+ // Strip the subcommand from positional before forwarding.
245
+ const handler = SUBCOMMANDS[sub];
246
+ const subArgs = {
247
+ positional: parsed.positional.slice(1),
248
+ flags: parsed.flags,
249
+ };
250
+ try {
251
+ const code = await handler(subArgs);
252
+ return typeof code === "number" ? code : 0;
253
+ }
254
+ catch (err) {
255
+ const msg = err instanceof Error ? err.message : String(err);
256
+ process.stderr.write(chalk.red(`brigade ${sub}: ${msg}\n`));
257
+ if (err instanceof Error && err.stack) {
258
+ process.stderr.write(chalk.dim(`${err.stack}\n`));
259
+ }
260
+ return 1;
261
+ }
262
+ }
263
+ /**
264
+ * Entry point — only runs when this file is invoked directly (not when
265
+ * imported by tests). Mirrors the canonical Node pattern.
266
+ */
267
+ const entry = process.argv[1] ? pathToFileURL(process.argv[1]).href : "";
268
+ if (import.meta.url === entry) {
269
+ runCli(process.argv.slice(2))
270
+ .then((code) => {
271
+ // Most subcommands keep the loop alive (gateway, connect, chat).
272
+ // Only short-lived ones (config, doctor, --help) reach this point.
273
+ if (typeof code === "number")
274
+ process.exit(code);
275
+ })
276
+ .catch((err) => {
277
+ try {
278
+ process.stdout.write("\x1b[?25h\x1b[?1049l");
279
+ }
280
+ catch {
281
+ /* terminal already gone */
282
+ }
283
+ console.error(chalk.red(`Brigade crashed: ${err instanceof Error ? err.stack ?? err.message : String(err)}`));
284
+ process.exit(1);
285
+ });
286
+ }
287
+ //# sourceMappingURL=cli.js.map