@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
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brigade super-config.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth at `<BRIGADE_DIR>/brigade.json`. Replaces the legacy
|
|
5
|
+
* three-file layout (`auth.json`, `config.json`, `settings.json`) with a
|
|
6
|
+
* versioned, schema-validated document plus rotating backups and atomic
|
|
7
|
+
* writes. Boot must never fail on a corrupt file — recovery walks the
|
|
8
|
+
* backup chain and ultimately falls through to a fresh empty config.
|
|
9
|
+
*
|
|
10
|
+
* v2 schema (current). The block names mirror the reference super-config
|
|
11
|
+
* shape so future Phase-2/3 features (auth profiles for non-key providers,
|
|
12
|
+
* multi-agent rosters, plugins, skills, channels, gateway control UI) just
|
|
13
|
+
* add nested fields rather than reshaping the document. The migration from
|
|
14
|
+
* v1 → v2 reorganises three things:
|
|
15
|
+
*
|
|
16
|
+
* 1. settings.defaultProvider + settings.defaultModelId
|
|
17
|
+
* → agents.defaults.model.primary (composed as "<provider>/<modelId>")
|
|
18
|
+
* 2. settings.fallbackProvider + settings.fallbackModelId
|
|
19
|
+
* → agents.defaults.model.fallbacks[0]
|
|
20
|
+
* 3. settings.installedAt
|
|
21
|
+
* → meta.installedAt
|
|
22
|
+
*
|
|
23
|
+
* settings.compaction and settings.thinkingLevel stay under `settings` —
|
|
24
|
+
* Brigade-private namespace until later primitives give them a real home.
|
|
25
|
+
*/
|
|
26
|
+
import { chmodSync, closeSync, copyFileSync, fsyncSync, openSync, renameSync, writeSync } from "node:fs";
|
|
27
|
+
import * as fs from "node:fs/promises";
|
|
28
|
+
import * as path from "node:path";
|
|
29
|
+
import { Type } from "typebox";
|
|
30
|
+
import { Check, Errors } from "typebox/value";
|
|
31
|
+
import { BRIGADE_CLI_VERSION } from "./version.js";
|
|
32
|
+
export const BRIGADE_CONFIG_SCHEMA_VERSION = 2;
|
|
33
|
+
export const BRIGADE_CONFIG_FILENAME = "brigade.json";
|
|
34
|
+
export const BACKUP_DEPTH = 4;
|
|
35
|
+
const FILE_MODE = 0o600;
|
|
36
|
+
/* ───────────────────────────── v2 schema ──────────────────────────────── */
|
|
37
|
+
const TOOL_FILTER_SCHEMA = Type.Object({
|
|
38
|
+
alsoAllow: Type.Optional(Type.Array(Type.String())),
|
|
39
|
+
deny: Type.Optional(Type.Array(Type.String())),
|
|
40
|
+
});
|
|
41
|
+
const IDENTITY_SCHEMA = Type.Object({
|
|
42
|
+
name: Type.Optional(Type.String()),
|
|
43
|
+
emoji: Type.Optional(Type.String()),
|
|
44
|
+
});
|
|
45
|
+
// Profile METADATA only — never the secret value. Key material lives in
|
|
46
|
+
// Pi's auth.json (or in the env block as the canonical state-isolation home).
|
|
47
|
+
const AUTH_PROFILE_SCHEMA = Type.Object({
|
|
48
|
+
provider: Type.String(),
|
|
49
|
+
mode: Type.Union([Type.Literal("api_key"), Type.Literal("oauth"), Type.Literal("token")]),
|
|
50
|
+
email: Type.Optional(Type.String()),
|
|
51
|
+
displayName: Type.Optional(Type.String()),
|
|
52
|
+
});
|
|
53
|
+
const AUTH_SCHEMA = Type.Object({
|
|
54
|
+
profiles: Type.Optional(Type.Record(Type.String(), AUTH_PROFILE_SCHEMA)),
|
|
55
|
+
});
|
|
56
|
+
// Object form chosen so adding `fallbacks` later is additive (not a reshape).
|
|
57
|
+
// `primary` is "<provider>/<modelId>" — first-slash-split on read.
|
|
58
|
+
const AGENT_MODEL_SCHEMA = Type.Object({
|
|
59
|
+
primary: Type.Optional(Type.String()),
|
|
60
|
+
fallbacks: Type.Optional(Type.Array(Type.String())),
|
|
61
|
+
});
|
|
62
|
+
const AGENT_DEFAULTS_SCHEMA = Type.Object({
|
|
63
|
+
model: Type.Optional(AGENT_MODEL_SCHEMA),
|
|
64
|
+
subagents: Type.Optional(Type.Object({
|
|
65
|
+
allowAgents: Type.Optional(Type.Array(Type.String())),
|
|
66
|
+
})),
|
|
67
|
+
});
|
|
68
|
+
// `name` is OPTIONAL by design — it mirrors the virtual-default-agent pattern
|
|
69
|
+
// in mature personal-AI-crew agents, where the entry is keyed by `id` (the
|
|
70
|
+
// routing key, e.g. "main") and the human-readable name is derived later from
|
|
71
|
+
// the persona layer (BOOTSTRAP / identity command writes `identity.name`, or
|
|
72
|
+
// the user sets a top-level `name` explicitly). Requiring `name` upfront would
|
|
73
|
+
// force callers to invent a placeholder string at scaffold time, which is
|
|
74
|
+
// exactly the back-door the v1→v2 migration was leaking the product brand
|
|
75
|
+
// through.
|
|
76
|
+
const AGENT_ENTRY_SCHEMA = Type.Object({
|
|
77
|
+
id: Type.String(),
|
|
78
|
+
name: Type.Optional(Type.String()),
|
|
79
|
+
identity: Type.Optional(IDENTITY_SCHEMA),
|
|
80
|
+
workspace: Type.Optional(Type.String()),
|
|
81
|
+
agentDir: Type.Optional(Type.String()),
|
|
82
|
+
tools: Type.Optional(TOOL_FILTER_SCHEMA),
|
|
83
|
+
model: Type.Optional(AGENT_MODEL_SCHEMA),
|
|
84
|
+
});
|
|
85
|
+
const AGENTS_SCHEMA = Type.Object({
|
|
86
|
+
defaults: Type.Optional(AGENT_DEFAULTS_SCHEMA),
|
|
87
|
+
list: Type.Optional(Type.Array(AGENT_ENTRY_SCHEMA)),
|
|
88
|
+
});
|
|
89
|
+
const PLUGIN_ENTRY_SCHEMA = Type.Object({
|
|
90
|
+
enabled: Type.Optional(Type.Boolean()),
|
|
91
|
+
config: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
92
|
+
});
|
|
93
|
+
const PLUGINS_SCHEMA = Type.Object({
|
|
94
|
+
enabled: Type.Optional(Type.Boolean()),
|
|
95
|
+
allow: Type.Optional(Type.Array(Type.String())),
|
|
96
|
+
deny: Type.Optional(Type.Array(Type.String())),
|
|
97
|
+
entries: Type.Optional(Type.Record(Type.String(), PLUGIN_ENTRY_SCHEMA)),
|
|
98
|
+
});
|
|
99
|
+
const SKILL_ENTRY_SCHEMA = Type.Object({
|
|
100
|
+
enabled: Type.Optional(Type.Boolean()),
|
|
101
|
+
config: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
|
|
102
|
+
});
|
|
103
|
+
const SKILLS_SCHEMA = Type.Object({
|
|
104
|
+
entries: Type.Optional(Type.Record(Type.String(), SKILL_ENTRY_SCHEMA)),
|
|
105
|
+
});
|
|
106
|
+
const CHANNELS_SCHEMA = Type.Record(Type.String(), Type.Record(Type.String(), Type.Unknown()));
|
|
107
|
+
const GATEWAY_AUTH_SCHEMA = Type.Object({
|
|
108
|
+
mode: Type.Optional(Type.Union([Type.Literal("none"), Type.Literal("token"), Type.Literal("password")])),
|
|
109
|
+
token: Type.Optional(Type.String()),
|
|
110
|
+
password: Type.Optional(Type.String()),
|
|
111
|
+
});
|
|
112
|
+
const GATEWAY_HTTP_SCHEMA = Type.Object({
|
|
113
|
+
endpoints: Type.Optional(Type.Record(Type.String(), Type.Object({
|
|
114
|
+
enabled: Type.Optional(Type.Boolean()),
|
|
115
|
+
}))),
|
|
116
|
+
});
|
|
117
|
+
const GATEWAY_CONTROL_UI_SCHEMA = Type.Object({
|
|
118
|
+
allowedOrigins: Type.Optional(Type.Array(Type.String())),
|
|
119
|
+
});
|
|
120
|
+
const GATEWAY_RELOAD_SCHEMA = Type.Object({
|
|
121
|
+
mode: Type.Optional(Type.Union([Type.Literal("off"), Type.Literal("hot"), Type.Literal("manual")])),
|
|
122
|
+
});
|
|
123
|
+
const GATEWAY_SCHEMA = Type.Object({
|
|
124
|
+
mode: Type.Optional(Type.Union([Type.Literal("local"), Type.Literal("remote")])),
|
|
125
|
+
port: Type.Optional(Type.Number()),
|
|
126
|
+
auth: Type.Optional(GATEWAY_AUTH_SCHEMA),
|
|
127
|
+
http: Type.Optional(GATEWAY_HTTP_SCHEMA),
|
|
128
|
+
controlUi: Type.Optional(GATEWAY_CONTROL_UI_SCHEMA),
|
|
129
|
+
reload: Type.Optional(GATEWAY_RELOAD_SCHEMA),
|
|
130
|
+
});
|
|
131
|
+
const WIZARD_SCHEMA = Type.Object({
|
|
132
|
+
lastRunAt: Type.Optional(Type.String()),
|
|
133
|
+
lastRunVersion: Type.Optional(Type.String()),
|
|
134
|
+
});
|
|
135
|
+
const META_SCHEMA = Type.Object({
|
|
136
|
+
lastTouchedVersion: Type.Optional(Type.String()),
|
|
137
|
+
lastTouchedAt: Type.Optional(Type.String()),
|
|
138
|
+
installedAt: Type.Optional(Type.String()),
|
|
139
|
+
});
|
|
140
|
+
// Brigade-private namespace for knobs that have no v2 home in the reference
|
|
141
|
+
// shape yet. compaction/thinkingLevel stay here until the relevant primitive
|
|
142
|
+
// (Primitive #2/#4 etc.) gives them a real home in agents.* or similar.
|
|
143
|
+
//
|
|
144
|
+
// fallbackProvider/fallbackModelId/installedAt are DROPPED at v2 — they're
|
|
145
|
+
// moved to agents.defaults.model.fallbacks and meta.installedAt. The
|
|
146
|
+
// migration in `migrateBrigadeConfigV1toV2` performs the lift; in-flight
|
|
147
|
+
// readers fall back here transparently.
|
|
148
|
+
//
|
|
149
|
+
// defaultProvider/defaultModelId are PARTIALLY dropped: when both are
|
|
150
|
+
// present, they're consolidated into agents.defaults.model.primary. They
|
|
151
|
+
// remain accepted in the v2 schema solely as transient in-flight scratch
|
|
152
|
+
// for partial-write CLI flows (`brigade config set defaultProvider X`
|
|
153
|
+
// without a subsequent `set defaultModelId Y`), since
|
|
154
|
+
// agents.defaults.model.primary requires both halves to form a valid
|
|
155
|
+
// "<provider>/<modelId>" ref. saveConfig MUST clear them once primary is
|
|
156
|
+
// composed.
|
|
157
|
+
const SETTINGS_SCHEMA = Type.Object({
|
|
158
|
+
compaction: Type.Optional(Type.Object({ enabled: Type.Optional(Type.Boolean()) })),
|
|
159
|
+
thinkingLevel: Type.Optional(Type.String()),
|
|
160
|
+
defaultProvider: Type.Optional(Type.String()),
|
|
161
|
+
defaultModelId: Type.Optional(Type.String()),
|
|
162
|
+
});
|
|
163
|
+
export const BrigadeConfigSchema = Type.Object({
|
|
164
|
+
$schema: Type.Optional(Type.String()),
|
|
165
|
+
version: Type.Literal(2),
|
|
166
|
+
meta: Type.Optional(META_SCHEMA),
|
|
167
|
+
wizard: Type.Optional(WIZARD_SCHEMA),
|
|
168
|
+
env: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
169
|
+
auth: Type.Optional(AUTH_SCHEMA),
|
|
170
|
+
agents: Type.Optional(AGENTS_SCHEMA),
|
|
171
|
+
plugins: Type.Optional(PLUGINS_SCHEMA),
|
|
172
|
+
skills: Type.Optional(SKILLS_SCHEMA),
|
|
173
|
+
channels: Type.Optional(CHANNELS_SCHEMA),
|
|
174
|
+
gateway: Type.Optional(GATEWAY_SCHEMA),
|
|
175
|
+
settings: Type.Optional(SETTINGS_SCHEMA),
|
|
176
|
+
});
|
|
177
|
+
/* ────────────────────────── v1 schema (legacy) ────────────────────────── */
|
|
178
|
+
/**
|
|
179
|
+
* v1 schema kept around solely for migration validation. Never written; only
|
|
180
|
+
* matched against on disk to detect "this is a pre-v2 file we should lift."
|
|
181
|
+
*/
|
|
182
|
+
const BRIGADE_CONFIG_V1_SCHEMA = Type.Object({
|
|
183
|
+
version: Type.Literal(1),
|
|
184
|
+
env: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
185
|
+
settings: Type.Optional(Type.Object({
|
|
186
|
+
defaultProvider: Type.Optional(Type.String()),
|
|
187
|
+
defaultModelId: Type.Optional(Type.String()),
|
|
188
|
+
fallbackProvider: Type.Optional(Type.String()),
|
|
189
|
+
fallbackModelId: Type.Optional(Type.String()),
|
|
190
|
+
installedAt: Type.Optional(Type.String()),
|
|
191
|
+
thinkingLevel: Type.Optional(Type.String()),
|
|
192
|
+
compaction: Type.Optional(Type.Object({ enabled: Type.Optional(Type.Boolean()) })),
|
|
193
|
+
})),
|
|
194
|
+
});
|
|
195
|
+
export class BrigadeConfigValidationError extends Error {
|
|
196
|
+
errors;
|
|
197
|
+
constructor(message, errors) {
|
|
198
|
+
super(message);
|
|
199
|
+
this.name = "BrigadeConfigValidationError";
|
|
200
|
+
this.errors = errors;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function configPath(brigadeDir) {
|
|
204
|
+
return path.join(brigadeDir, BRIGADE_CONFIG_FILENAME);
|
|
205
|
+
}
|
|
206
|
+
function backupPath(brigadeDir, slot) {
|
|
207
|
+
const base = `${configPath(brigadeDir)}.bak`;
|
|
208
|
+
return slot === 0 ? base : `${base}.${slot}`;
|
|
209
|
+
}
|
|
210
|
+
function backupChain(brigadeDir) {
|
|
211
|
+
const chain = [];
|
|
212
|
+
for (let slot = 0; slot <= BACKUP_DEPTH; slot++) {
|
|
213
|
+
chain.push(backupPath(brigadeDir, slot));
|
|
214
|
+
}
|
|
215
|
+
return chain;
|
|
216
|
+
}
|
|
217
|
+
function emptyConfig() {
|
|
218
|
+
return { version: BRIGADE_CONFIG_SCHEMA_VERSION };
|
|
219
|
+
}
|
|
220
|
+
function isoTimestampForFilename() {
|
|
221
|
+
return new Date().toISOString().replace(/[:]/g, "-");
|
|
222
|
+
}
|
|
223
|
+
function collectErrors(value) {
|
|
224
|
+
const issues = [];
|
|
225
|
+
for (const err of Errors(BrigadeConfigSchema, value)) {
|
|
226
|
+
issues.push({
|
|
227
|
+
path: typeof err.instancePath === "string" ? err.instancePath : "",
|
|
228
|
+
message: typeof err.message === "string" ? err.message : "validation error",
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
return issues;
|
|
232
|
+
}
|
|
233
|
+
async function tryReadJson(p) {
|
|
234
|
+
const buf = await fs.readFile(p, "utf8");
|
|
235
|
+
return JSON.parse(buf);
|
|
236
|
+
}
|
|
237
|
+
async function safeRename(from, to) {
|
|
238
|
+
try {
|
|
239
|
+
await fs.rename(from, to);
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
const code = err?.code;
|
|
243
|
+
if (code === "ENOENT")
|
|
244
|
+
return;
|
|
245
|
+
throw err;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async function safeRemove(p) {
|
|
249
|
+
try {
|
|
250
|
+
await fs.rm(p, { force: true });
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
/* ignore */
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
async function exists(p) {
|
|
257
|
+
try {
|
|
258
|
+
await fs.stat(p);
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
async function rotateBackups(brigadeDir) {
|
|
266
|
+
// Rotate before write so a crash mid-write leaves bak as the last known good.
|
|
267
|
+
const current = configPath(brigadeDir);
|
|
268
|
+
if (!(await exists(current)))
|
|
269
|
+
return;
|
|
270
|
+
for (let slot = BACKUP_DEPTH; slot >= 1; slot--) {
|
|
271
|
+
const from = backupPath(brigadeDir, slot - 1);
|
|
272
|
+
const to = backupPath(brigadeDir, slot);
|
|
273
|
+
if (!(await exists(from)))
|
|
274
|
+
continue;
|
|
275
|
+
await safeRemove(to);
|
|
276
|
+
await safeRename(from, to);
|
|
277
|
+
}
|
|
278
|
+
await safeRemove(backupPath(brigadeDir, 0));
|
|
279
|
+
await safeRename(current, backupPath(brigadeDir, 0));
|
|
280
|
+
}
|
|
281
|
+
function atomicWriteSync(filePath, data) {
|
|
282
|
+
const tmp = `${filePath}.tmp`;
|
|
283
|
+
const fd = openSync(tmp, "w", FILE_MODE);
|
|
284
|
+
try {
|
|
285
|
+
writeSync(fd, data);
|
|
286
|
+
fsyncSync(fd);
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
closeSync(fd);
|
|
290
|
+
}
|
|
291
|
+
renameSync(tmp, filePath);
|
|
292
|
+
try {
|
|
293
|
+
chmodSync(filePath, FILE_MODE);
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
/* ignore — Windows / restricted FS */
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
function snapshotCorrupted(brigadeDir) {
|
|
300
|
+
const src = configPath(brigadeDir);
|
|
301
|
+
const target = `${src}.clobbered.${isoTimestampForFilename()}`;
|
|
302
|
+
try {
|
|
303
|
+
renameSync(src, target);
|
|
304
|
+
return target;
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Scrub the legacy `agents.list[i].name = "Brigade"` value that pre-v0.1.0
|
|
312
|
+
* onboarding wrote to disk. The product brand was being injected into the
|
|
313
|
+
* persona slot through a back door — once the seed entry exists with that
|
|
314
|
+
* name, every downstream display surface that reads `agents.list[i].name`
|
|
315
|
+
* surfaces "Brigade" as the agent's own name, which is exactly the leak the
|
|
316
|
+
* `name`-removal fix was meant to prevent.
|
|
317
|
+
*
|
|
318
|
+
* Strategy: if any list entry has `name === "Brigade"` (the literal product
|
|
319
|
+
* brand), drop the `name` field entirely. Schema marks `name` optional so a
|
|
320
|
+
* scrubbed entry remains valid; downstream code falls back to whatever the
|
|
321
|
+
* user picks via the BOOTSTRAP / identity command (which writes IDENTITY.md).
|
|
322
|
+
*
|
|
323
|
+
* Returns true when at least one entry was modified, so the caller can
|
|
324
|
+
* persist the cleaned config back to disk + emit a one-line audit message.
|
|
325
|
+
* Idempotent: a second pass over an already-clean config does nothing.
|
|
326
|
+
*/
|
|
327
|
+
function scrubLegacyAgentName(cfg) {
|
|
328
|
+
const list = cfg.agents?.list;
|
|
329
|
+
if (!list || list.length === 0)
|
|
330
|
+
return false;
|
|
331
|
+
let changed = false;
|
|
332
|
+
for (const entry of list) {
|
|
333
|
+
if (entry && entry.name === "Brigade") {
|
|
334
|
+
delete entry.name;
|
|
335
|
+
changed = true;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return changed;
|
|
339
|
+
}
|
|
340
|
+
export async function loadBrigadeConfig(brigadeDir) {
|
|
341
|
+
const main = configPath(brigadeDir);
|
|
342
|
+
if (await exists(main)) {
|
|
343
|
+
try {
|
|
344
|
+
const raw = await tryReadJson(main);
|
|
345
|
+
if (Check(BrigadeConfigSchema, raw)) {
|
|
346
|
+
const cfg = raw;
|
|
347
|
+
// Migrate-on-read scrub: drop legacy product-brand seed in
|
|
348
|
+
// agents.list[i].name. See `scrubLegacyAgentName` for rationale.
|
|
349
|
+
// Persist the cleaned config back so the next read finds nothing
|
|
350
|
+
// to scrub (idempotent), and so other readers + the brigade.json
|
|
351
|
+
// file on disk both reflect the same cleaned state.
|
|
352
|
+
if (scrubLegacyAgentName(cfg)) {
|
|
353
|
+
try {
|
|
354
|
+
await writeBrigadeConfig(brigadeDir, cfg);
|
|
355
|
+
process.stderr.write("brigade: scrubbed legacy agents.list[i].name='Brigade' from brigade.json (auto-cleanup of pre-v0.1.0 leak)\n");
|
|
356
|
+
}
|
|
357
|
+
catch {
|
|
358
|
+
// Don't block boot if the write fails — the in-memory
|
|
359
|
+
// config is already clean for this process; the next
|
|
360
|
+
// successful write from any code path will persist it.
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return cfg;
|
|
364
|
+
}
|
|
365
|
+
// Parsed but failed schema — treat as corrupt: snapshot, walk backups.
|
|
366
|
+
snapshotCorrupted(brigadeDir);
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
snapshotCorrupted(brigadeDir);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
for (let slot = 0; slot <= BACKUP_DEPTH; slot++) {
|
|
373
|
+
const candidate = backupPath(brigadeDir, slot);
|
|
374
|
+
if (!(await exists(candidate)))
|
|
375
|
+
continue;
|
|
376
|
+
try {
|
|
377
|
+
const raw = await tryReadJson(candidate);
|
|
378
|
+
if (Check(BrigadeConfigSchema, raw)) {
|
|
379
|
+
const cfg = raw;
|
|
380
|
+
// Same scrub on the recovered backup. Don't try to write here —
|
|
381
|
+
// recovery already implies the main file was bad; we'd rather
|
|
382
|
+
// hand back a clean in-memory copy and let the next normal
|
|
383
|
+
// write path persist the scrub.
|
|
384
|
+
scrubLegacyAgentName(cfg);
|
|
385
|
+
return cfg;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
/* try the next slot */
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
process.stderr.write("brigade: warning: brigade.json corrupt, all backups exhausted, starting fresh\n");
|
|
393
|
+
return emptyConfig();
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Strip leaf properties whose value is `undefined`, recursively. JSON.stringify
|
|
397
|
+
* already drops them at the top level, but the migration builds nested objects
|
|
398
|
+
* (`{ agents: { defaults: { model: { primary: undefined } } } }`) where the
|
|
399
|
+
* `undefined` would mean the inner objects survive serialization as empty `{}`
|
|
400
|
+
* — noisy and hostile to grep. Clean both empty objects AND undefined leaves.
|
|
401
|
+
*/
|
|
402
|
+
function stripUndefined(value) {
|
|
403
|
+
if (Array.isArray(value)) {
|
|
404
|
+
return value
|
|
405
|
+
.map((v) => stripUndefined(v))
|
|
406
|
+
.filter((v) => v !== undefined);
|
|
407
|
+
}
|
|
408
|
+
if (value && typeof value === "object") {
|
|
409
|
+
const out = {};
|
|
410
|
+
for (const [k, v] of Object.entries(value)) {
|
|
411
|
+
if (v === undefined)
|
|
412
|
+
continue;
|
|
413
|
+
const cleaned = stripUndefined(v);
|
|
414
|
+
// Drop empty plain objects so we don't litter the file with `{}` leaves.
|
|
415
|
+
// Arrays may legitimately be empty (explicit "I want no fallbacks") so
|
|
416
|
+
// they're preserved — only object-shaped emptiness gets pruned.
|
|
417
|
+
if (cleaned !== null &&
|
|
418
|
+
typeof cleaned === "object" &&
|
|
419
|
+
!Array.isArray(cleaned) &&
|
|
420
|
+
Object.keys(cleaned).length === 0) {
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
out[k] = cleaned;
|
|
424
|
+
}
|
|
425
|
+
return out;
|
|
426
|
+
}
|
|
427
|
+
return value;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Stamp meta.lastTouchedVersion + lastTouchedAt unconditionally on every write
|
|
431
|
+
* so `~/.brigade/brigade.json` always reflects which Brigade build last
|
|
432
|
+
* mutated it. Preserves existing meta fields (notably installedAt) by merging.
|
|
433
|
+
*/
|
|
434
|
+
function applyMetaTouch(cfg) {
|
|
435
|
+
const meta = { ...(cfg.meta ?? {}) };
|
|
436
|
+
meta.lastTouchedVersion = BRIGADE_CLI_VERSION;
|
|
437
|
+
meta.lastTouchedAt = new Date().toISOString();
|
|
438
|
+
return { ...cfg, meta };
|
|
439
|
+
}
|
|
440
|
+
export async function writeBrigadeConfig(brigadeDir, cfg) {
|
|
441
|
+
const stamped = applyMetaTouch(cfg);
|
|
442
|
+
if (!Check(BrigadeConfigSchema, stamped)) {
|
|
443
|
+
const issues = collectErrors(stamped);
|
|
444
|
+
throw new BrigadeConfigValidationError(`brigade.json failed schema validation (${issues.length} issue${issues.length === 1 ? "" : "s"})`, issues);
|
|
445
|
+
}
|
|
446
|
+
await fs.mkdir(brigadeDir, { recursive: true });
|
|
447
|
+
await rotateBackups(brigadeDir);
|
|
448
|
+
const serialized = `${JSON.stringify(stamped, null, 2)}\n`;
|
|
449
|
+
atomicWriteSync(configPath(brigadeDir), serialized);
|
|
450
|
+
}
|
|
451
|
+
/**
|
|
452
|
+
* Compose a "<provider>/<modelId>" model ref string. Avoid double-prefixing
|
|
453
|
+
* when the modelId already starts with "<provider>/" (e.g. OpenRouter ids
|
|
454
|
+
* like "openrouter/auto" — joining naively would give "openrouter/openrouter/auto"
|
|
455
|
+
* which is technically what the user typed, but the cleaner stored form is
|
|
456
|
+
* "openrouter/auto"). First-slash-split on read still recovers the right
|
|
457
|
+
* provider either way, but skip the redundant prefix when we can.
|
|
458
|
+
*/
|
|
459
|
+
function composeModelRef(p, m) {
|
|
460
|
+
if (!p || !m)
|
|
461
|
+
return undefined;
|
|
462
|
+
return m.startsWith(`${p}/`) ? m : `${p}/${m}`;
|
|
463
|
+
}
|
|
464
|
+
function composeFallbacks(p, m) {
|
|
465
|
+
const ref = composeModelRef(p, m);
|
|
466
|
+
return ref ? [ref] : undefined;
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Migrate a v1 Brigade super-config to the v2 reference-aligned shape.
|
|
470
|
+
* Idempotent — already-v2 configs short-circuit. Side effect: writes a
|
|
471
|
+
* `<configPath>.migrated-v1-<ISO-ts>.bak` snapshot before write so the
|
|
472
|
+
* migration is auditable + reversible.
|
|
473
|
+
*
|
|
474
|
+
* Behaviour:
|
|
475
|
+
* - missing brigade.json → no-op (returns {migrated:false})
|
|
476
|
+
* - version === 2 → no-op (already migrated)
|
|
477
|
+
* - version > 2 → no-op + stderr warn (don't trash a future config)
|
|
478
|
+
* - version === 1 (or absent) → snapshot + lift + write v2
|
|
479
|
+
* - corrupt v1 (schema-fails) → no-op + stderr warn (don't trash bad data)
|
|
480
|
+
*/
|
|
481
|
+
export async function migrateBrigadeConfigV1toV2(brigadeDir) {
|
|
482
|
+
const cfgPath = configPath(brigadeDir);
|
|
483
|
+
if (!(await exists(cfgPath)))
|
|
484
|
+
return { migrated: false };
|
|
485
|
+
let raw;
|
|
486
|
+
try {
|
|
487
|
+
raw = await tryReadJson(cfgPath);
|
|
488
|
+
}
|
|
489
|
+
catch {
|
|
490
|
+
// Corrupt JSON — leave it alone. loadBrigadeConfig's recovery path
|
|
491
|
+
// will deal with it on next read; we don't want to mask the corruption
|
|
492
|
+
// or destroy a file the user might still be able to hand-recover.
|
|
493
|
+
process.stderr.write("brigade: warning: brigade.json is unparseable; v1→v2 migration skipped\n");
|
|
494
|
+
return { migrated: false };
|
|
495
|
+
}
|
|
496
|
+
const versionField = (raw && typeof raw === "object")
|
|
497
|
+
? raw.version
|
|
498
|
+
: undefined;
|
|
499
|
+
if (versionField === 2)
|
|
500
|
+
return { migrated: false };
|
|
501
|
+
if (typeof versionField === "number" && versionField > 2) {
|
|
502
|
+
process.stderr.write(`brigade: warning: brigade.json is version ${versionField} (newer than this build); skipping migration\n`);
|
|
503
|
+
return { migrated: false };
|
|
504
|
+
}
|
|
505
|
+
// Treat absent version as v1 best-effort. Validate against the v1 schema
|
|
506
|
+
// to confirm the shape is recognisable; if it isn't, leave the file alone.
|
|
507
|
+
let v1;
|
|
508
|
+
if (versionField === undefined) {
|
|
509
|
+
// Synthesise a version field so the v1 schema check passes; we already
|
|
510
|
+
// know there's no version, so it's v0/v1-shaped at best.
|
|
511
|
+
const candidate = { ...raw, version: 1 };
|
|
512
|
+
if (!Check(BRIGADE_CONFIG_V1_SCHEMA, candidate)) {
|
|
513
|
+
process.stderr.write("brigade: warning: brigade.json has no version and doesn't match the v1 shape; migration skipped\n");
|
|
514
|
+
return { migrated: false };
|
|
515
|
+
}
|
|
516
|
+
v1 = candidate;
|
|
517
|
+
}
|
|
518
|
+
else if (versionField === 1) {
|
|
519
|
+
if (!Check(BRIGADE_CONFIG_V1_SCHEMA, raw)) {
|
|
520
|
+
process.stderr.write("brigade: warning: brigade.json claims version 1 but failed schema validation; migration skipped\n");
|
|
521
|
+
return { migrated: false };
|
|
522
|
+
}
|
|
523
|
+
v1 = raw;
|
|
524
|
+
}
|
|
525
|
+
else {
|
|
526
|
+
// Some other version literal (0, negative, non-number). Skip — too risky
|
|
527
|
+
// to lift heuristically.
|
|
528
|
+
process.stderr.write(`brigade: warning: brigade.json has unexpected version ${String(versionField)}; migration skipped\n`);
|
|
529
|
+
return { migrated: false };
|
|
530
|
+
}
|
|
531
|
+
// Snapshot before write so the migration is reversible. Use copyFile (not
|
|
532
|
+
// rename) because writeBrigadeConfig will overwrite the original — we want
|
|
533
|
+
// to keep both the new v2 file AND the original v1 backup side-by-side.
|
|
534
|
+
const snapshotPath = `${cfgPath}.migrated-v1-${isoTimestampForFilename()}.bak`;
|
|
535
|
+
try {
|
|
536
|
+
copyFileSync(cfgPath, snapshotPath);
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
540
|
+
process.stderr.write(`brigade: warning: couldn't snapshot v1 config before migration (${msg}); aborting migration\n`);
|
|
541
|
+
return { migrated: false };
|
|
542
|
+
}
|
|
543
|
+
const v2 = stripUndefined({
|
|
544
|
+
version: 2,
|
|
545
|
+
env: v1.env,
|
|
546
|
+
meta: {
|
|
547
|
+
installedAt: v1.settings?.installedAt,
|
|
548
|
+
// lastTouchedVersion + lastTouchedAt stamped by writeBrigadeConfig
|
|
549
|
+
},
|
|
550
|
+
agents: {
|
|
551
|
+
defaults: {
|
|
552
|
+
model: {
|
|
553
|
+
primary: composeModelRef(v1.settings?.defaultProvider, v1.settings?.defaultModelId),
|
|
554
|
+
fallbacks: composeFallbacks(v1.settings?.fallbackProvider, v1.settings?.fallbackModelId),
|
|
555
|
+
},
|
|
556
|
+
},
|
|
557
|
+
// Deliberately DO NOT seed `agents.list` here. This matches the
|
|
558
|
+
// virtual-default-agent pattern used in mature personal-AI-crew
|
|
559
|
+
// agents: when `agents.list` is absent, the runtime synthesizes a
|
|
560
|
+
// virtual entry keyed by the well-known "main" id. Writing a stub
|
|
561
|
+
// like `{ id: "main", name: "<product>" }` would leak the product
|
|
562
|
+
// brand into the persona slot through a back door — the `name`
|
|
563
|
+
// field is reserved for whatever the user picks via the BOOTSTRAP
|
|
564
|
+
// conversation (which writes IDENTITY.md) or an explicit identity
|
|
565
|
+
// command. Leaving the list undefined keeps that slot clean.
|
|
566
|
+
},
|
|
567
|
+
settings: {
|
|
568
|
+
compaction: v1.settings?.compaction,
|
|
569
|
+
thinkingLevel: v1.settings?.thinkingLevel,
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
await writeBrigadeConfig(brigadeDir, v2);
|
|
573
|
+
return { migrated: true, snapshotPath };
|
|
574
|
+
}
|
|
575
|
+
async function readLegacyJson(p) {
|
|
576
|
+
if (!(await exists(p)))
|
|
577
|
+
return undefined;
|
|
578
|
+
try {
|
|
579
|
+
const raw = await fs.readFile(p, "utf8");
|
|
580
|
+
const parsed = JSON.parse(raw);
|
|
581
|
+
if (parsed && typeof parsed === "object")
|
|
582
|
+
return parsed;
|
|
583
|
+
return undefined;
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
return undefined;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
export async function migrateLegacyConfig(brigadeDir) {
|
|
590
|
+
// Idempotent guard — a valid super-config means the migration already ran.
|
|
591
|
+
const main = configPath(brigadeDir);
|
|
592
|
+
if (await exists(main)) {
|
|
593
|
+
try {
|
|
594
|
+
const existing = await tryReadJson(main);
|
|
595
|
+
if (Check(BrigadeConfigSchema, existing)) {
|
|
596
|
+
return { migrated: false, movedFiles: [] };
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
catch {
|
|
600
|
+
/* corrupted — fall through to attempt migration */
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
const authPath = path.join(brigadeDir, "auth.json");
|
|
604
|
+
const configFile = path.join(brigadeDir, "config.json");
|
|
605
|
+
const settingsPath = path.join(brigadeDir, "settings.json");
|
|
606
|
+
const legacyAuth = await readLegacyJson(authPath);
|
|
607
|
+
const legacyConfig = await readLegacyJson(configFile);
|
|
608
|
+
const legacySettings = await readLegacyJson(settingsPath);
|
|
609
|
+
if (!legacyAuth && !legacyConfig && !legacySettings) {
|
|
610
|
+
return { migrated: false, movedFiles: [] };
|
|
611
|
+
}
|
|
612
|
+
// Build directly into v2 shape — skip the intermediate v1 hop. Otherwise
|
|
613
|
+
// every fresh install would write v1 first, then immediately migrate to v2
|
|
614
|
+
// on the next call, doubling the .bak chain churn for no benefit.
|
|
615
|
+
const merged = stripUndefined({
|
|
616
|
+
version: BRIGADE_CONFIG_SCHEMA_VERSION,
|
|
617
|
+
env: legacyAuth && Object.keys(legacyAuth).length > 0
|
|
618
|
+
? Object.fromEntries(Object.entries(legacyAuth).filter(([, v]) => typeof v === "string"))
|
|
619
|
+
: undefined,
|
|
620
|
+
meta: {
|
|
621
|
+
installedAt: typeof legacyConfig?.installedAt === "string"
|
|
622
|
+
? legacyConfig.installedAt
|
|
623
|
+
: undefined,
|
|
624
|
+
},
|
|
625
|
+
agents: legacyConfig
|
|
626
|
+
? {
|
|
627
|
+
defaults: {
|
|
628
|
+
model: {
|
|
629
|
+
primary: composeModelRef(legacyConfig.defaultProvider, legacyConfig.defaultModelId),
|
|
630
|
+
fallbacks: composeFallbacks(legacyConfig.fallbackProvider, legacyConfig.fallbackModelId),
|
|
631
|
+
},
|
|
632
|
+
},
|
|
633
|
+
// `agents.list` deliberately omitted — runtime synthesizes the
|
|
634
|
+
// virtual default agent keyed by "main" when the list is
|
|
635
|
+
// absent (see migrateBrigadeConfigV1toV2 for the same
|
|
636
|
+
// rationale). Avoids leaking the product name into the
|
|
637
|
+
// persona slot.
|
|
638
|
+
}
|
|
639
|
+
: undefined,
|
|
640
|
+
settings: {
|
|
641
|
+
compaction: legacySettings?.compaction && typeof legacySettings.compaction === "object"
|
|
642
|
+
? typeof legacySettings.compaction.enabled === "boolean"
|
|
643
|
+
? { enabled: legacySettings.compaction.enabled }
|
|
644
|
+
: undefined
|
|
645
|
+
: undefined,
|
|
646
|
+
thinkingLevel: typeof legacyConfig?.thinkingLevel === "string"
|
|
647
|
+
? legacyConfig.thinkingLevel
|
|
648
|
+
: undefined,
|
|
649
|
+
},
|
|
650
|
+
});
|
|
651
|
+
await writeBrigadeConfig(brigadeDir, merged);
|
|
652
|
+
const moved = [];
|
|
653
|
+
const ts = isoTimestampForFilename();
|
|
654
|
+
for (const legacyPath of [authPath, configFile, settingsPath]) {
|
|
655
|
+
if (!(await exists(legacyPath)))
|
|
656
|
+
continue;
|
|
657
|
+
const target = `${legacyPath}.migrated-${ts}`;
|
|
658
|
+
try {
|
|
659
|
+
await fs.rename(legacyPath, target);
|
|
660
|
+
moved.push(target);
|
|
661
|
+
}
|
|
662
|
+
catch {
|
|
663
|
+
/* leave the legacy file in place if rename fails */
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return { migrated: true, movedFiles: moved };
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* Module-level snapshot of which env-block keys were ACTUALLY written into
|
|
670
|
+
* `process.env` by the most recent `loadEnvIntoProcess` call. Auth-source
|
|
671
|
+
* resolution reads this so it can distinguish "value came from brigade.json"
|
|
672
|
+
* from "value happens to match a shell env var" — without this set, value-
|
|
673
|
+
* equality would falsely report "file" when the shell pre-populated the
|
|
674
|
+
* variable, and `rm -rf ~/.brigade` would NOT actually wipe the auth.
|
|
675
|
+
*
|
|
676
|
+
* Reset on every `loadEnvIntoProcess` invocation, including the empty-config
|
|
677
|
+
* path, so stale entries from a previous boot can't bleed into a new one.
|
|
678
|
+
*/
|
|
679
|
+
let _appliedFromFile = new Set();
|
|
680
|
+
export function getAppliedEnvKeys() {
|
|
681
|
+
return _appliedFromFile;
|
|
682
|
+
}
|
|
683
|
+
export function loadEnvIntoProcess(cfg) {
|
|
684
|
+
const result = { applied: [], skipped: [] };
|
|
685
|
+
const env = cfg.env;
|
|
686
|
+
if (!env) {
|
|
687
|
+
_appliedFromFile = new Set();
|
|
688
|
+
return result;
|
|
689
|
+
}
|
|
690
|
+
for (const [key, value] of Object.entries(env)) {
|
|
691
|
+
// Shell-supplied env wins so users aren't surprised by config overriding it.
|
|
692
|
+
if (Object.prototype.hasOwnProperty.call(process.env, key)) {
|
|
693
|
+
result.skipped.push(key);
|
|
694
|
+
continue;
|
|
695
|
+
}
|
|
696
|
+
process.env[key] = value;
|
|
697
|
+
result.applied.push(key);
|
|
698
|
+
}
|
|
699
|
+
_appliedFromFile = new Set(result.applied);
|
|
700
|
+
return result;
|
|
701
|
+
}
|
|
702
|
+
/* ──────────────────────── helpers exported for callers ────────────────── */
|
|
703
|
+
/**
|
|
704
|
+
* First-slash-split a "<provider>/<modelId>" model ref into its component
|
|
705
|
+
* parts. Used by config readers that need to recover provider+modelId from
|
|
706
|
+
* `agents.defaults.model.primary` (or `.fallbacks[i]`). Documented semantics:
|
|
707
|
+
* the FIRST `/` is the provider/modelId separator, so multi-slash modelIds
|
|
708
|
+
* (e.g. `openrouter/openrouter/auto` → provider="openrouter",
|
|
709
|
+
* modelId="openrouter/auto") survive intact.
|
|
710
|
+
*/
|
|
711
|
+
export function parseModelRef(ref) {
|
|
712
|
+
if (!ref || ref.length === 0)
|
|
713
|
+
return undefined;
|
|
714
|
+
const slash = ref.indexOf("/");
|
|
715
|
+
if (slash <= 0 || slash === ref.length - 1)
|
|
716
|
+
return undefined;
|
|
717
|
+
return {
|
|
718
|
+
provider: ref.slice(0, slash),
|
|
719
|
+
modelId: ref.slice(slash + 1),
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
723
|
+
* Inverse of `parseModelRef`. Exposed so callers (config-cmd, onboarding,
|
|
724
|
+
* server, chat) can compose a model ref consistently and avoid double-
|
|
725
|
+
* prefixing OpenRouter-style ids that already include the provider segment.
|
|
726
|
+
*/
|
|
727
|
+
export function composeModelRefExternal(provider, modelId) {
|
|
728
|
+
return composeModelRef(provider, modelId);
|
|
729
|
+
}
|
|
730
|
+
//# sourceMappingURL=brigade-config.js.map
|