@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.
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/SECURITY.md +208 -0
- package/assets/brigade-wordmark-on-black.png +0 -0
- package/assets/brigade-wordmark.png +0 -0
- package/brigade.mjs +96 -0
- package/dist/cli/chat-cmd.js +120 -0
- package/dist/cli/config-cmd.js +132 -0
- package/dist/cli/connect-cmd.js +447 -0
- package/dist/cli/doctor-cmd.js +317 -0
- package/dist/cli/gateway-cmd.js +92 -0
- package/dist/cli.js +287 -0
- package/dist/core/agent.js +1123 -0
- package/dist/core/config.js +80 -0
- package/dist/core/console-stream.js +188 -0
- package/dist/core/error-classifier.js +354 -0
- package/dist/core/event-logger.js +122 -0
- package/dist/core/model-caps.js +185 -0
- package/dist/core/provider-payload-mutators.js +517 -0
- package/dist/core/provider-quirks.js +285 -0
- package/dist/core/server.js +459 -0
- package/dist/core/smart-compaction.js +209 -0
- package/dist/core/system-prompt-defaults.js +88 -0
- package/dist/core/system-prompt-guidance.js +269 -0
- package/dist/core/system-prompt.js +884 -0
- package/dist/index.js +30 -0
- package/dist/integrations/ollama.js +140 -0
- package/dist/protocol.js +49 -0
- package/dist/providers/catalog.js +100 -0
- package/dist/providers/validate-key.js +197 -0
- package/dist/tui/client.js +263 -0
- package/dist/ui/brand-frames-cli.js +20 -0
- package/dist/ui/brand-frames.js +36 -0
- package/dist/ui/brand.js +402 -0
- package/dist/ui/chat.js +929 -0
- package/dist/ui/onboarding.js +400 -0
- package/dist/ui/theme.js +51 -0
- package/package.json +92 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-run onboarding wizard.
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. Welcome screen
|
|
6
|
+
* 2. Provider picker (SelectList of curated providers)
|
|
7
|
+
* 3. API key entry (or detect existing env var)
|
|
8
|
+
* 4. Model picker (SelectList of models for that provider)
|
|
9
|
+
* 5. Persist to AuthStorage + Brigade config
|
|
10
|
+
* 6. Return chosen { provider, modelId }
|
|
11
|
+
*
|
|
12
|
+
* Uses Pi-TUI components — same components the chat UI uses, so the visual
|
|
13
|
+
* language is consistent across the app.
|
|
14
|
+
*/
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
import { getEnvApiKey, getModels } from "@mariozechner/pi-ai";
|
|
17
|
+
import { CancellableLoader, Input, SelectList, Text } from "@mariozechner/pi-tui";
|
|
18
|
+
import { BRIGADE_DIR, saveConfig } from "../core/config.js";
|
|
19
|
+
import { discoverOllamaModels, writeOllamaToModelsJson } from "../integrations/ollama.js";
|
|
20
|
+
import { findProvider, PROVIDERS } from "../providers/catalog.js";
|
|
21
|
+
import { validateApiKeyOnline } from "../providers/validate-key.js";
|
|
22
|
+
import { renderBrandHeader } from "./brand.js";
|
|
23
|
+
import { brand, selectListTheme } from "./theme.js";
|
|
24
|
+
/* ────────────────────────────── public API ────────────────────────────── */
|
|
25
|
+
/**
|
|
26
|
+
* Resolve the model list for a provider. Pi's static `getModels()` only knows
|
|
27
|
+
* built-in catalogs (Anthropic, OpenAI, Google, etc.); custom providers we
|
|
28
|
+
* register at runtime via models.json (Ollama) are exposed through
|
|
29
|
+
* `modelRegistry.getAll()`. Try the static catalog first, then fall back to
|
|
30
|
+
* the dynamic registry — that way both code paths produce the same `Model<any>[]`
|
|
31
|
+
* shape that the picker expects.
|
|
32
|
+
*/
|
|
33
|
+
function getProviderModels(modelRegistry, providerId) {
|
|
34
|
+
try {
|
|
35
|
+
const fromCatalog = getModels(providerId);
|
|
36
|
+
if (fromCatalog && fromCatalog.length > 0)
|
|
37
|
+
return fromCatalog;
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* unknown provider — fall through */
|
|
41
|
+
}
|
|
42
|
+
return modelRegistry.getAll().filter((m) => m.provider === providerId);
|
|
43
|
+
}
|
|
44
|
+
export async function runOnboarding(tui, authStorage, modelRegistry) {
|
|
45
|
+
// Tiny step state machine so each step can resolve to "ok" or "back".
|
|
46
|
+
// Esc on provider picker exits onboarding entirely; Esc on later steps
|
|
47
|
+
// just rewinds to the previous step. This is what makes invalid keys
|
|
48
|
+
// recoverable instead of a one-shot abort.
|
|
49
|
+
let step = "provider";
|
|
50
|
+
let provider = "";
|
|
51
|
+
let modelId = "";
|
|
52
|
+
while (true) {
|
|
53
|
+
if (step === "provider") {
|
|
54
|
+
renderScreen(tui, "Step 1 of 3 · Pick a provider");
|
|
55
|
+
provider = await pickProvider(tui); // throws "onboarding-cancelled" on Esc
|
|
56
|
+
step = "key";
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (step === "key") {
|
|
60
|
+
// Local providers (Ollama) skip API-key entry entirely. Instead we
|
|
61
|
+
// validate the local server is up, discover its model list, and
|
|
62
|
+
// register the provider in Pi's models.json so step 3 picks it up.
|
|
63
|
+
const providerInfo = findProvider(provider);
|
|
64
|
+
if (providerInfo?.local && providerInfo.id === "ollama") {
|
|
65
|
+
const result = await ensureLocalOllama(tui, modelRegistry, providerInfo.baseUrl ?? "http://localhost:11434");
|
|
66
|
+
if (result === "back") {
|
|
67
|
+
step = "provider";
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
step = "model";
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const result = await ensureApiKey(tui, authStorage, provider);
|
|
74
|
+
if (result === "back") {
|
|
75
|
+
step = "provider";
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
modelRegistry.refresh(); // re-evaluate available models with the new key
|
|
79
|
+
step = "model";
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
// step === "model"
|
|
83
|
+
renderScreen(tui, "Step 3 of 3 · Default model");
|
|
84
|
+
const result = await pickModel(tui, modelRegistry, provider);
|
|
85
|
+
if (result === "back") {
|
|
86
|
+
step = "provider"; // go all the way back so they can change provider too
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
modelId = result.modelId;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
await saveConfig({ defaultProvider: provider, defaultModelId: modelId });
|
|
93
|
+
renderScreen(tui, ""); // brand-only frame for the "Ready." moment
|
|
94
|
+
renderDone(tui, provider, modelId);
|
|
95
|
+
await delay(900);
|
|
96
|
+
clear(tui);
|
|
97
|
+
return { provider, modelId };
|
|
98
|
+
}
|
|
99
|
+
/* ────────────────────────── screen scaffolding ────────────────────────── */
|
|
100
|
+
/** Wipe everything, render the chunky brand header, then a sub-header line. */
|
|
101
|
+
function renderScreen(tui, subheader) {
|
|
102
|
+
clear(tui);
|
|
103
|
+
renderBrandHeader(tui);
|
|
104
|
+
if (subheader) {
|
|
105
|
+
tui.addChild(new Text(brand.dim(" " + "─".repeat(54)), 0, 0));
|
|
106
|
+
tui.addChild(new Text("", 0, 0));
|
|
107
|
+
tui.addChild(new Text(` ${brand.amber(subheader)}`, 0, 0));
|
|
108
|
+
tui.addChild(new Text("", 0, 0));
|
|
109
|
+
tui.requestRender();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/* ────────────────────────────── steps ─────────────────────────────────── */
|
|
113
|
+
async function pickProvider(tui) {
|
|
114
|
+
const items = PROVIDERS.map((p) => ({
|
|
115
|
+
value: p.id,
|
|
116
|
+
label: p.name,
|
|
117
|
+
description: p.description,
|
|
118
|
+
}));
|
|
119
|
+
const list = new SelectList(items, Math.min(items.length, 9), selectListTheme, {
|
|
120
|
+
minPrimaryColumnWidth: 18,
|
|
121
|
+
maxPrimaryColumnWidth: 22,
|
|
122
|
+
});
|
|
123
|
+
tui.addChild(list);
|
|
124
|
+
tui.setFocus(list);
|
|
125
|
+
tui.requestRender();
|
|
126
|
+
const chosen = await new Promise((resolve, reject) => {
|
|
127
|
+
list.onSelect = (item) => resolve(item);
|
|
128
|
+
list.onCancel = () => reject(new Error("onboarding-cancelled"));
|
|
129
|
+
});
|
|
130
|
+
return chosen.value;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Prompt for the API key, validate it (locally + online), persist on success.
|
|
134
|
+
* Returns:
|
|
135
|
+
* - "ok" → key is valid and saved (or already existed)
|
|
136
|
+
* - "back" → user pressed Esc; caller should rewind to the previous step
|
|
137
|
+
*
|
|
138
|
+
* Loops on validation failure: the user sees "Wrong key — <reason>" inline
|
|
139
|
+
* and re-enters the field with their previous attempt pre-filled. Esc on
|
|
140
|
+
* the input bails out of the loop with "back" instead of throwing.
|
|
141
|
+
*/
|
|
142
|
+
async function ensureApiKey(tui, authStorage, providerId) {
|
|
143
|
+
const provider = findProvider(providerId);
|
|
144
|
+
if (!provider)
|
|
145
|
+
throw new Error(`Unknown provider: ${providerId}`);
|
|
146
|
+
// Already configured? (env var OR previously stored)
|
|
147
|
+
const existing = await authStorage.getApiKey(providerId);
|
|
148
|
+
if (existing) {
|
|
149
|
+
renderScreen(tui, `Step 2 of 3 · ${provider.name}`);
|
|
150
|
+
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));
|
|
153
|
+
tui.requestRender();
|
|
154
|
+
await delay(600);
|
|
155
|
+
return "ok";
|
|
156
|
+
}
|
|
157
|
+
// Retry loop. Each iteration re-renders the screen so old error lines and
|
|
158
|
+
// stale loader frames don't pile up vertically.
|
|
159
|
+
let lastError = null;
|
|
160
|
+
while (true) {
|
|
161
|
+
renderScreen(tui, `Step 2 of 3 · Connect ${provider.name}`);
|
|
162
|
+
if (lastError) {
|
|
163
|
+
tui.addChild(new Text(` ${brand.error("✗")} ${brand.error(lastError)}`, 0, 0));
|
|
164
|
+
tui.addChild(new Text(brand.dim(" Press Enter to try again, or Esc to choose a different provider."), 0, 0));
|
|
165
|
+
tui.addChild(new Text("", 0, 0));
|
|
166
|
+
}
|
|
167
|
+
tui.addChild(new Text(` Paste your ${provider.name} API key.`, 0, 0));
|
|
168
|
+
tui.addChild(new Text(brand.dim(` We'll keep it private to this device. Need a key? ${provider.keyUrl}`), 0, 0));
|
|
169
|
+
tui.addChild(new Text(brand.dim(" Enter to continue · Esc to go back"), 0, 0));
|
|
170
|
+
tui.addChild(new Text("", 0, 0));
|
|
171
|
+
const input = new Input();
|
|
172
|
+
tui.addChild(input);
|
|
173
|
+
tui.setFocus(input);
|
|
174
|
+
tui.requestRender();
|
|
175
|
+
let key;
|
|
176
|
+
try {
|
|
177
|
+
key = await new Promise((resolve, reject) => {
|
|
178
|
+
input.onSubmit = (value) => resolve(value.trim());
|
|
179
|
+
input.onEscape = () => reject(new Error("back"));
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return "back"; // user pressed Esc — caller rewinds to provider picker
|
|
184
|
+
}
|
|
185
|
+
// Step 1: cheap local format check (length, whitespace, prefix).
|
|
186
|
+
const localCheck = validateApiKey(providerId, key);
|
|
187
|
+
if (!localCheck.ok) {
|
|
188
|
+
lastError = localCheck.reason;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
// Step 2: live online validation against the provider's /v1/models endpoint.
|
|
192
|
+
// This catches revoked / wrong-provider / typo'd keys BEFORE we persist.
|
|
193
|
+
tui.addChild(new Text("", 0, 0));
|
|
194
|
+
const loader = new CancellableLoader(tui, (s) => brand.amber(s), (s) => brand.dim(s), `Verifying with ${provider.name}…`);
|
|
195
|
+
tui.addChild(loader);
|
|
196
|
+
tui.requestRender();
|
|
197
|
+
const onlineCheck = await validateApiKeyOnline(providerId, key);
|
|
198
|
+
tui.removeChild(loader);
|
|
199
|
+
if (!onlineCheck.ok) {
|
|
200
|
+
lastError = onlineCheck.reason;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
// Step 3: only persist after both checks pass.
|
|
204
|
+
authStorage.set(providerId, { type: "api_key", key });
|
|
205
|
+
authStorage.reload();
|
|
206
|
+
const successLine = onlineCheck.modelCount
|
|
207
|
+
? `${provider.name} connected · ${onlineCheck.modelCount} model${onlineCheck.modelCount === 1 ? "" : "s"} available`
|
|
208
|
+
: `${provider.name} connected`;
|
|
209
|
+
tui.addChild(new Text(` ${brand.amber("✓")} ${successLine}.`, 0, 0));
|
|
210
|
+
if (onlineCheck.warning) {
|
|
211
|
+
tui.addChild(new Text(` ${brand.dim("Note: " + onlineCheck.warning)}`, 0, 0));
|
|
212
|
+
}
|
|
213
|
+
tui.requestRender();
|
|
214
|
+
await delay(500);
|
|
215
|
+
return "ok";
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Cheap, provider-aware sanity check on the pasted key.
|
|
220
|
+
* Catches obvious mistakes (empty, accidental newline, wrong provider's key)
|
|
221
|
+
* BEFORE we persist garbage to disk. Doesn't validate against the real API —
|
|
222
|
+
* that happens implicitly on the first model call.
|
|
223
|
+
*/
|
|
224
|
+
function validateApiKey(providerId, key) {
|
|
225
|
+
if (!key)
|
|
226
|
+
return { ok: false, reason: "Please enter an API key." };
|
|
227
|
+
if (key.length < 16)
|
|
228
|
+
return { ok: false, reason: `That looks incomplete (only ${key.length} characters). Try copying the key again.` };
|
|
229
|
+
if (/\s/.test(key))
|
|
230
|
+
return { ok: false, reason: "The key has extra spaces or line breaks. Copy just the key value." };
|
|
231
|
+
// Provider-specific prefix hints. Hard reject only when we have a stable, well-known prefix
|
|
232
|
+
// for that provider — we never want to block someone with a freshly-rotated format.
|
|
233
|
+
// For providers without a stable prefix (cerebras, mistral) we fall through to length+whitespace
|
|
234
|
+
// only, which is intentional.
|
|
235
|
+
const prefixHints = {
|
|
236
|
+
anthropic: "sk-ant-",
|
|
237
|
+
openai: "sk-",
|
|
238
|
+
google: "AIza", // Google API keys (Gemini Studio) all start with AIza
|
|
239
|
+
groq: "gsk_",
|
|
240
|
+
openrouter: "sk-or-",
|
|
241
|
+
xai: "xai-",
|
|
242
|
+
deepseek: "sk-", // DeepSeek mirrors OpenAI's prefix convention
|
|
243
|
+
};
|
|
244
|
+
const expected = prefixHints[providerId];
|
|
245
|
+
const providerName = findProvider(providerId)?.name ?? providerId;
|
|
246
|
+
if (expected && !key.startsWith(expected)) {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
reason: `That doesn't look like a ${providerName} key (${providerName} keys start with "${expected}"). Make sure you picked the right provider.`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
return { ok: true };
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Local-provider variant of `ensureApiKey`. For Ollama:
|
|
256
|
+
* 1. Ping `/api/tags` to confirm the daemon is running.
|
|
257
|
+
* 2. List the user's locally-pulled models.
|
|
258
|
+
* 3. Write the provider config (and inferred model definitions) into
|
|
259
|
+
* `~/.brigade/models.json` so Pi's `ModelRegistry` exposes them.
|
|
260
|
+
* 4. Refresh the registry so the next step's model picker can see them.
|
|
261
|
+
*
|
|
262
|
+
* If discovery fails, show a friendly error with the next step the user
|
|
263
|
+
* needs to take (start the daemon, pull a model). Esc returns "back".
|
|
264
|
+
*/
|
|
265
|
+
async function ensureLocalOllama(tui, modelRegistry, baseUrl) {
|
|
266
|
+
let lastError = null;
|
|
267
|
+
while (true) {
|
|
268
|
+
renderScreen(tui, "Step 2 of 3 · Connect Ollama");
|
|
269
|
+
if (lastError) {
|
|
270
|
+
tui.addChild(new Text(` ${brand.error("✗")} ${brand.error(lastError)}`, 0, 0));
|
|
271
|
+
tui.addChild(new Text(brand.dim(" Press Enter to try again, or Esc to choose a different provider."), 0, 0));
|
|
272
|
+
tui.addChild(new Text("", 0, 0));
|
|
273
|
+
}
|
|
274
|
+
tui.addChild(new Text(` Brigade will scan your local Ollama for available models.`, 0, 0));
|
|
275
|
+
tui.addChild(new Text(brand.dim(` Don't have Ollama yet? Get it at https://ollama.com/download`), 0, 0));
|
|
276
|
+
tui.addChild(new Text(brand.dim(" Enter to scan · Esc to go back"), 0, 0));
|
|
277
|
+
// Use an Input as a confirm-prompt — user just hits Enter (or Esc).
|
|
278
|
+
const confirm = new Input();
|
|
279
|
+
tui.addChild(confirm);
|
|
280
|
+
tui.setFocus(confirm);
|
|
281
|
+
tui.requestRender();
|
|
282
|
+
try {
|
|
283
|
+
await new Promise((resolve, reject) => {
|
|
284
|
+
confirm.onSubmit = () => resolve();
|
|
285
|
+
confirm.onEscape = () => reject(new Error("back"));
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return "back";
|
|
290
|
+
}
|
|
291
|
+
// Discover models with a loader spinner.
|
|
292
|
+
const loader = new CancellableLoader(tui, (s) => brand.amber(s), (s) => brand.dim(s), "Scanning Ollama…");
|
|
293
|
+
tui.addChild(loader);
|
|
294
|
+
tui.requestRender();
|
|
295
|
+
let discovered;
|
|
296
|
+
try {
|
|
297
|
+
discovered = await discoverOllamaModels(baseUrl);
|
|
298
|
+
}
|
|
299
|
+
catch (err) {
|
|
300
|
+
tui.removeChild(loader);
|
|
301
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
// Persist the provider entry so Pi sees the models from now on.
|
|
305
|
+
const modelsJsonPath = path.join(BRIGADE_DIR, "models.json");
|
|
306
|
+
try {
|
|
307
|
+
await writeOllamaToModelsJson(modelsJsonPath, baseUrl, discovered);
|
|
308
|
+
modelRegistry.refresh();
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
tui.removeChild(loader);
|
|
312
|
+
lastError = `Couldn't save the connection: ${err instanceof Error ? err.message : String(err)}`;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
tui.removeChild(loader);
|
|
316
|
+
tui.addChild(new Text(` ${brand.amber("✓")} Ollama connected · ${brand.white(String(discovered.length))} model${discovered.length === 1 ? "" : "s"} available.`, 0, 0));
|
|
317
|
+
tui.requestRender();
|
|
318
|
+
await delay(500);
|
|
319
|
+
return "ok";
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
async function pickModel(tui, modelRegistry, providerId) {
|
|
323
|
+
const models = getProviderModels(modelRegistry, providerId);
|
|
324
|
+
if (models.length === 0) {
|
|
325
|
+
tui.addChild(new Text(brand.dim(" Type the model name you'd like to use, then press Enter. Esc to go back."), 0, 0));
|
|
326
|
+
const input = new Input();
|
|
327
|
+
tui.addChild(input);
|
|
328
|
+
tui.setFocus(input);
|
|
329
|
+
tui.requestRender();
|
|
330
|
+
try {
|
|
331
|
+
const id = await new Promise((resolve, reject) => {
|
|
332
|
+
input.onSubmit = (value) => resolve(value.trim());
|
|
333
|
+
input.onEscape = () => reject(new Error("back"));
|
|
334
|
+
});
|
|
335
|
+
return { modelId: id };
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return "back";
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const items = models.map((m) => ({
|
|
342
|
+
value: m.id,
|
|
343
|
+
label: m.id,
|
|
344
|
+
description: describeModel(m),
|
|
345
|
+
}));
|
|
346
|
+
// Default-first ordering: reasoning > non-reasoning, then larger context first.
|
|
347
|
+
items.sort((a, b) => {
|
|
348
|
+
const ma = models.find((m) => m.id === a.value);
|
|
349
|
+
const mb = models.find((m) => m.id === b.value);
|
|
350
|
+
if (!ma || !mb)
|
|
351
|
+
return 0;
|
|
352
|
+
if (!!ma.reasoning !== !!mb.reasoning)
|
|
353
|
+
return ma.reasoning ? -1 : 1;
|
|
354
|
+
return (mb.contextWindow ?? 0) - (ma.contextWindow ?? 0);
|
|
355
|
+
});
|
|
356
|
+
const list = new SelectList(items, Math.min(items.length, 12), selectListTheme, {
|
|
357
|
+
minPrimaryColumnWidth: 26,
|
|
358
|
+
maxPrimaryColumnWidth: 38,
|
|
359
|
+
});
|
|
360
|
+
tui.addChild(list);
|
|
361
|
+
tui.setFocus(list);
|
|
362
|
+
tui.requestRender();
|
|
363
|
+
try {
|
|
364
|
+
const chosen = await new Promise((resolve, reject) => {
|
|
365
|
+
list.onSelect = (item) => resolve(item);
|
|
366
|
+
list.onCancel = () => reject(new Error("back"));
|
|
367
|
+
});
|
|
368
|
+
return { modelId: chosen.value };
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return "back";
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
function renderDone(tui, provider, modelId) {
|
|
375
|
+
const p = findProvider(provider)?.name ?? provider;
|
|
376
|
+
tui.addChild(new Text("", 0, 0));
|
|
377
|
+
tui.addChild(new Text(` ${brand.amber("●")} ${brand.white("Ready.")} ${brand.dim(`${p} · ${modelId}`)}`, 0, 0));
|
|
378
|
+
tui.addChild(new Text("", 0, 0));
|
|
379
|
+
tui.requestRender();
|
|
380
|
+
}
|
|
381
|
+
/* ────────────────────────────── helpers ───────────────────────────────── */
|
|
382
|
+
function clear(tui) {
|
|
383
|
+
for (const child of [...tui.children])
|
|
384
|
+
tui.removeChild(child);
|
|
385
|
+
tui.requestRender();
|
|
386
|
+
}
|
|
387
|
+
function describeModel(m) {
|
|
388
|
+
const parts = [];
|
|
389
|
+
if (m.reasoning)
|
|
390
|
+
parts.push("reasoning");
|
|
391
|
+
if (m.contextWindow)
|
|
392
|
+
parts.push(`${Math.round(m.contextWindow / 1000)}k ctx`);
|
|
393
|
+
if (m.cost?.input)
|
|
394
|
+
parts.push(`$${m.cost.input.toFixed(2)}/Mtok in`);
|
|
395
|
+
return parts.join(" · ");
|
|
396
|
+
}
|
|
397
|
+
function delay(ms) {
|
|
398
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
399
|
+
}
|
|
400
|
+
//# sourceMappingURL=onboarding.js.map
|
package/dist/ui/theme.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Pi-TUI themes for Brigade.
|
|
3
|
+
*
|
|
4
|
+
* One canonical place so onboarding + chat feel like the same app.
|
|
5
|
+
*/
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
export const markdownTheme = {
|
|
8
|
+
heading: (s) => chalk.bold.hex("#fbbf24")(s), // amber
|
|
9
|
+
link: (s) => chalk.hex("#60a5fa")(s),
|
|
10
|
+
linkUrl: (s) => chalk.dim(s),
|
|
11
|
+
code: (s) => chalk.hex("#fde7c4")(s),
|
|
12
|
+
codeBlock: (s) => chalk.hex("#a7f3d0")(s),
|
|
13
|
+
codeBlockBorder: (s) => chalk.dim.hex("#92400e")(s),
|
|
14
|
+
quote: (s) => chalk.italic.hex("#fde7c4")(s),
|
|
15
|
+
quoteBorder: (s) => chalk.dim.hex("#92400e")(s),
|
|
16
|
+
hr: (s) => chalk.dim(s),
|
|
17
|
+
listBullet: (s) => chalk.hex("#fbbf24")(s),
|
|
18
|
+
bold: (s) => chalk.bold(s),
|
|
19
|
+
italic: (s) => chalk.italic(s),
|
|
20
|
+
strikethrough: (s) => chalk.strikethrough(s),
|
|
21
|
+
underline: (s) => chalk.underline(s),
|
|
22
|
+
};
|
|
23
|
+
export const editorTheme = {
|
|
24
|
+
borderColor: (s) => chalk.dim(s),
|
|
25
|
+
selectList: {
|
|
26
|
+
selectedPrefix: (s) => chalk.hex("#fbbf24")(s),
|
|
27
|
+
selectedText: (s) => chalk.bold(s),
|
|
28
|
+
description: (s) => chalk.dim(s),
|
|
29
|
+
scrollInfo: (s) => chalk.dim(s),
|
|
30
|
+
noMatch: (s) => chalk.dim(s),
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
export const selectListTheme = {
|
|
34
|
+
selectedPrefix: (s) => chalk.hex("#fbbf24")(s),
|
|
35
|
+
selectedText: (s) => chalk.bold(s),
|
|
36
|
+
description: (s) => chalk.dim(s),
|
|
37
|
+
scrollInfo: (s) => chalk.dim(s),
|
|
38
|
+
noMatch: (s) => chalk.dim(" No matching option"),
|
|
39
|
+
};
|
|
40
|
+
/** Brand color helpers (used for status bars, dividers, accents). */
|
|
41
|
+
export const brand = {
|
|
42
|
+
amber: (s) => chalk.hex("#fbbf24")(s),
|
|
43
|
+
amberDeep: (s) => chalk.hex("#92400e")(s),
|
|
44
|
+
dim: (s) => chalk.dim(s),
|
|
45
|
+
white: (s) => chalk.white(s),
|
|
46
|
+
user: (s) => chalk.bold.hex("#60a5fa")(s),
|
|
47
|
+
agent: (s) => chalk.bold.hex("#fbbf24")(s),
|
|
48
|
+
tool: (s) => chalk.hex("#a7f3d0")(s),
|
|
49
|
+
error: (s) => chalk.bold.hex("#fca5a5")(s),
|
|
50
|
+
};
|
|
51
|
+
//# sourceMappingURL=theme.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@spinabot/brigade",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
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
|
+
"license": "MIT",
|
|
7
|
+
"author": "Bhasvanth Dev",
|
|
8
|
+
"homepage": "https://github.com/Bhasvanth-Dev9380/brigade#readme",
|
|
9
|
+
"bugs": {
|
|
10
|
+
"url": "https://github.com/Bhasvanth-Dev9380/brigade/issues"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/Bhasvanth-Dev9380/brigade.git"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20.0.0"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"brigade": "brigade.mjs"
|
|
21
|
+
},
|
|
22
|
+
"main": "./dist/index.js",
|
|
23
|
+
"files": [
|
|
24
|
+
"brigade.mjs",
|
|
25
|
+
"dist",
|
|
26
|
+
"!dist/**/*.map",
|
|
27
|
+
"README.md",
|
|
28
|
+
"LICENSE",
|
|
29
|
+
"CHANGELOG.md",
|
|
30
|
+
"SECURITY.md",
|
|
31
|
+
"assets/brigade-wordmark.png",
|
|
32
|
+
"assets/brigade-wordmark-on-black.png"
|
|
33
|
+
],
|
|
34
|
+
"keywords": [
|
|
35
|
+
"ai",
|
|
36
|
+
"cli",
|
|
37
|
+
"tui",
|
|
38
|
+
"llm",
|
|
39
|
+
"chat",
|
|
40
|
+
"agent",
|
|
41
|
+
"anthropic",
|
|
42
|
+
"claude",
|
|
43
|
+
"openai",
|
|
44
|
+
"gpt",
|
|
45
|
+
"gemini",
|
|
46
|
+
"ollama",
|
|
47
|
+
"openrouter",
|
|
48
|
+
"groq",
|
|
49
|
+
"xai",
|
|
50
|
+
"deepseek",
|
|
51
|
+
"mistral",
|
|
52
|
+
"cerebras",
|
|
53
|
+
"pi-sdk"
|
|
54
|
+
],
|
|
55
|
+
"scripts": {
|
|
56
|
+
"dev": "tsx src/cli.ts",
|
|
57
|
+
"chat": "tsx src/cli.ts chat",
|
|
58
|
+
"gateway": "tsx src/cli.ts gateway",
|
|
59
|
+
"connect": "tsx src/cli.ts connect",
|
|
60
|
+
"doctor": "tsx src/cli.ts doctor",
|
|
61
|
+
"onboard": "tsx src/cli.ts onboard",
|
|
62
|
+
"build": "tsc -p tsconfig.json",
|
|
63
|
+
"start": "node dist/cli.js",
|
|
64
|
+
"typecheck": "tsc --noEmit",
|
|
65
|
+
"test": "vitest run",
|
|
66
|
+
"test:watch": "vitest",
|
|
67
|
+
"format": "biome format --write .",
|
|
68
|
+
"gen:wordmark": "node scripts/gen-wordmark-svg.mjs",
|
|
69
|
+
"prepublishOnly": "npm run build",
|
|
70
|
+
"prepack": "node scripts/prepack.mjs"
|
|
71
|
+
},
|
|
72
|
+
"publishConfig": {
|
|
73
|
+
"access": "public"
|
|
74
|
+
},
|
|
75
|
+
"dependencies": {
|
|
76
|
+
"@mariozechner/pi-agent-core": "0.70.6",
|
|
77
|
+
"@mariozechner/pi-ai": "0.70.6",
|
|
78
|
+
"@mariozechner/pi-coding-agent": "0.70.6",
|
|
79
|
+
"@mariozechner/pi-tui": "0.70.6",
|
|
80
|
+
"@types/ws": "^8.18.1",
|
|
81
|
+
"cfonts": "^3.3.1",
|
|
82
|
+
"chalk": "^5.3.0",
|
|
83
|
+
"ws": "^8.20.0"
|
|
84
|
+
},
|
|
85
|
+
"devDependencies": {
|
|
86
|
+
"@biomejs/biome": "^1.9.4",
|
|
87
|
+
"@types/node": "^22.7.0",
|
|
88
|
+
"tsx": "^4.19.0",
|
|
89
|
+
"typescript": "^5.6.0",
|
|
90
|
+
"vitest": "^2.1.0"
|
|
91
|
+
}
|
|
92
|
+
}
|