@skanl/brambo-cli 0.1.1

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.
@@ -0,0 +1,612 @@
1
+ import { homedir } from 'node:os';
2
+ import { REGISTRY_ENTRY_TYPES, REMOVABLE_ENTRY_TYPES, createBundle, deliveryFor, expandRegistryEntryPaths, ingestMachine, isRetiredEntryType, readBundle, scopeDirectory, storeFor, writeBundle, } from '@skanl/brambo-environment';
3
+ /** The verbs `run.ts` dispatches into this file, under both grammars. */
4
+ export const REGISTRY_VERBS = ['add', 'remove', 'list'];
5
+ export function isRegistryVerb(token) {
6
+ return REGISTRY_VERBS.includes(token);
7
+ }
8
+ /**
9
+ * The field flags, mapped ONE-TO-ONE onto the envelope field they carry.
10
+ *
11
+ * No entry type appears in this table and none may: the frozen Ask-First clause
12
+ * of this story forbids the CLI holding a per-type table of which flag belongs
13
+ * to which type, because that is a second copy of `REGISTRY_PATH_FIELDS`. A flag
14
+ * that does not suit the type is rejected by the CONTRACT, one layer down.
15
+ */
16
+ const FIELD_FLAGS = {
17
+ '--command': 'command',
18
+ '--entry-path': 'entryPath',
19
+ };
20
+ /** Repeatable, and order-preserving: `args` is a command line, not a set. */
21
+ const ARG_FLAG = '--arg';
22
+ /** The only option `brambo ingest` has; every other one is a usage error. */
23
+ const DRY_RUN_FLAG = '--dry-run';
24
+ /**
25
+ * Argv for all three verbs: the field flags, and the positionals each verb then
26
+ * reads for itself. Shared, so `remove` cannot drift into accepting an option
27
+ * `add` rejects.
28
+ */
29
+ function parseTokens(tokens) {
30
+ const positionals = [];
31
+ const args = [];
32
+ let command;
33
+ let entryPath;
34
+ let terminated = false;
35
+ const assign = (field, value) => {
36
+ if (field === 'command')
37
+ command = value;
38
+ else
39
+ entryPath = value;
40
+ };
41
+ for (let index = 0; index < tokens.length; index += 1) {
42
+ const token = tokens[index];
43
+ if (token === undefined)
44
+ continue;
45
+ // `--` ends the options, POSIX-style. Without it an id that begins with a
46
+ // dash could be registered — `ingestProviders` accepts one, and nothing in
47
+ // the envelope forbids it — and then never removed, because every spelling
48
+ // of `brambo remove mcp-server --fs` is a usage error. `brambo doctor` points
49
+ // straight at that command, so the entry had a dispatchable instruction that
50
+ // could not be run for the entry it was about.
51
+ if (!terminated && token === '--') {
52
+ terminated = true;
53
+ continue;
54
+ }
55
+ if (terminated) {
56
+ positionals.push(token);
57
+ continue;
58
+ }
59
+ const named = Object.keys(FIELD_FLAGS).find((flag) => token === flag || token.startsWith(`${flag}=`));
60
+ const flag = named ?? (token === ARG_FLAG || token.startsWith(`${ARG_FLAG}=`) ? ARG_FLAG : undefined);
61
+ if (flag !== undefined) {
62
+ // The SAME guard for both spellings: `--command=-x` reaching the entry
63
+ // while `--command -x` is refused would be two answers to one question,
64
+ // which is the shape `brambo run --executor` was already fixed for.
65
+ //
66
+ // `--arg` is the ONE exception, and it is not a relaxation of the rule but
67
+ // the rule applied to a different thing: an mcp-server's arguments are a
68
+ // command line, where `-y` is an ordinary value — `npx -y @mcp/fs` is the
69
+ // documented invocation of half the servers that exist. Refusing it would
70
+ // make the flag unable to express the case it was added for. `--help` can
71
+ // never be eaten by it, because help is answered before this parse runs.
72
+ const inline = token.startsWith(`${flag}=`);
73
+ const value = inline ? token.slice(flag.length + 1) : tokens[index + 1];
74
+ if (value === undefined || value.length === 0 || (value.startsWith('-') && flag !== ARG_FLAG)) {
75
+ return { usageError: `option '${flag}' requires a value` };
76
+ }
77
+ if (!inline)
78
+ index += 1;
79
+ if (flag === ARG_FLAG)
80
+ args.push(value);
81
+ else
82
+ assign(FIELD_FLAGS[flag], value);
83
+ continue;
84
+ }
85
+ if (token.startsWith('-'))
86
+ return { usageError: `unrecognized option '${token}'` };
87
+ positionals.push(token);
88
+ }
89
+ return {
90
+ positionals,
91
+ fields: {
92
+ ...(command === undefined ? {} : { command }),
93
+ ...(entryPath === undefined ? {} : { entryPath }),
94
+ ...(args.length === 0 ? {} : { args }),
95
+ },
96
+ };
97
+ }
98
+ function knownTypes() {
99
+ return REGISTRY_ENTRY_TYPES.join(', ');
100
+ }
101
+ /**
102
+ * The vocabulary each verb accepts, and the asymmetry is the point: `remove`
103
+ * takes a RETIRED type as well, because an entry written by an older build has
104
+ * to have an exit through the product, and `add` does not, because nothing may
105
+ * create one again. Both lists come from `@skanl/brambo-contracts` — there is still no
106
+ * table of entry types in this file.
107
+ */
108
+ function acceptedTypes(verb) {
109
+ return verb === 'remove' ? REMOVABLE_ENTRY_TYPES : REGISTRY_ENTRY_TYPES;
110
+ }
111
+ /**
112
+ * The entry type, as an argv question. A missing or misspelled type is a usage
113
+ * error about the command line — the user has not named an entry yet — while
114
+ * everything about the entry ITSELF is the contract's to answer.
115
+ */
116
+ /**
117
+ * The verb a user at THIS scope can actually run.
118
+ *
119
+ * `brambo project add` with no type answered `brambo add needs an entry type`. The
120
+ * verb was right and the grammar was not: at project scope `brambo add` is a
121
+ * different command against a different registry, so the sentence named
122
+ * something that would act on the wrong one. `scope` was already a parameter at
123
+ * every one of these sites and was simply not read.
124
+ *
125
+ * Two spellings rather than one interpolation, so the printed-command invariant
126
+ * sees a real verb in each — the same shape `doctor.ts` uses for its exits.
127
+ */
128
+ export function verbAt(scope, verb) {
129
+ return scope === 'machine' ? `brambo ${verb}` : `brambo project ${verb}`;
130
+ }
131
+ function readType(verb, token, scope) {
132
+ const accepted = acceptedTypes(verb);
133
+ if (token === undefined) {
134
+ return { usageError: `${verbAt(scope, verb)} needs an entry type: ${knownTypes()}` };
135
+ }
136
+ const found = accepted.find((candidate) => candidate === token);
137
+ if (found === undefined) {
138
+ // A retired word reaching `add` gets the sentence that is USEFUL rather than
139
+ // the generic one: the user is either upgrading from a build that had it, or
140
+ // reading documentation that did, and the entry they already have is
141
+ // removable by exactly the spelling that was just refused here.
142
+ //
143
+ // Under the GRAMMAR the user actually typed. The machine sentence reused at
144
+ // project scope asserted the entry is listed by `brambo list`, which does not
145
+ // read a project registry, and named `brambo remove`, which exits 1 for a
146
+ // project-scope entry -- a refusal that hands out a command that fails.
147
+ return {
148
+ usageError: isRetiredEntryType(token)
149
+ ? scope === 'machine'
150
+ ? `'${token}' is a RETIRED entry type; brambo has ${knownTypes()}. An existing '${token}' entry is still listed by \`brambo list\` and removed by \`brambo remove ${token} <id>\``
151
+ : `'${token}' is a RETIRED entry type; brambo has ${knownTypes()}. An existing '${token}' entry is still listed by \`brambo project list\` and removed by \`brambo project remove ${token} <id>\``
152
+ : `unknown entry type '${token}'; brambo has ${knownTypes()}`,
153
+ };
154
+ }
155
+ return found;
156
+ }
157
+ async function bind(scope, directory, context) {
158
+ // The same trust boundary `brambo init` applies, and for the same reason: these
159
+ // paths decide where brambo creates `.brambo`, and `brambo project add x y ~/typo`
160
+ // must not build the missing tree. Brambo BINDS a directory, never creates one.
161
+ // `homedir()` here and in the capability are the same call: a second spelling
162
+ // of "the home directory" is how `brambo add` and `brambo init` come to disagree
163
+ // about which registry they are talking about.
164
+ const home = await scopeDirectory('the home directory', context.homeDir ?? homedir());
165
+ const root = scope === 'machine'
166
+ ? home
167
+ : await scopeDirectory('the project directory', directory ?? context.cwd ?? process.cwd());
168
+ const store = storeFor(scope, home, root);
169
+ const registryScope = scope === 'machine' ? 'global' : 'project';
170
+ return {
171
+ scope,
172
+ registryScope,
173
+ projectCommand: scope === 'machine' ? 'brambo init' : 'brambo project init',
174
+ registryPath: store.storePath(registryScope),
175
+ homeDir: home,
176
+ projectDir: root,
177
+ store,
178
+ };
179
+ }
180
+ /** One entry as the line a human reads: its scope, its type and its id. */
181
+ function describeEntry(scope, entry) {
182
+ const fields = [
183
+ entry.command === undefined ? undefined : `command ${entry.command}`,
184
+ entry.entryPath === undefined ? undefined : `entry-path ${entry.entryPath}`,
185
+ entry.args === undefined ? undefined : `args ${entry.args.join(' ')}`,
186
+ ].filter((part) => part !== undefined);
187
+ return `${scope} · ${entry.type} · ${entry.id}${fields.length === 0 ? '' : ` (${fields.join(' · ')})`}`;
188
+ }
189
+ export async function runRegistryCommand(verb, tokens, scope, context) {
190
+ const { out, err } = context;
191
+ const parsed = parseTokens(tokens);
192
+ if ('usageError' in parsed) {
193
+ err(parsed.usageError);
194
+ err(context.defaultUsage);
195
+ return 2;
196
+ }
197
+ // `add` and `remove` name an entry, `list` names none; the project grammar
198
+ // then takes one optional directory after that, exactly like
199
+ // `brambo project init [directory]` and `brambo project remediate <verb> [directory]`.
200
+ const named = verb === 'list' ? 0 : 2;
201
+ const maxPositionals = named + (scope === 'project' ? 1 : 0);
202
+ if (parsed.positionals.length > maxPositionals) {
203
+ err(`unexpected argument '${parsed.positionals[maxPositionals]}'`);
204
+ err(context.defaultUsage);
205
+ return 2;
206
+ }
207
+ if (verb === 'list') {
208
+ if (Object.keys(parsed.fields).length > 0) {
209
+ err("'list' takes no field options; it shows every entry there is");
210
+ err(context.defaultUsage);
211
+ return 2;
212
+ }
213
+ return await runList(parsed.positionals[0], scope, context);
214
+ }
215
+ const type = readType(verb, parsed.positionals[0], scope);
216
+ if (typeof type !== 'string') {
217
+ err(type.usageError);
218
+ err(context.defaultUsage);
219
+ return 2;
220
+ }
221
+ const id = parsed.positionals[1];
222
+ if (id === undefined) {
223
+ err(`${verbAt(scope, verb)} needs the id of the ${type} entry`);
224
+ err(context.defaultUsage);
225
+ return 2;
226
+ }
227
+ if (verb === 'remove' && Object.keys(parsed.fields).length > 0) {
228
+ err("'remove' takes no field options; an entry is removed by its type and id");
229
+ err(context.defaultUsage);
230
+ return 2;
231
+ }
232
+ const bound = await bind(scope, parsed.positionals[2], context);
233
+ try {
234
+ return verb === 'add'
235
+ ? await performAdd({ type, id, ...parsed.fields }, bound, out, err)
236
+ : await performRemove(type, id, bound, out, err);
237
+ }
238
+ finally {
239
+ await bound.store.dispose();
240
+ }
241
+ }
242
+ /**
243
+ * `add` REGISTERS and does not project — it reports the command that does.
244
+ * Coupling the two would make registration fail for projection reasons, on a
245
+ * machine where the failure has nothing to do with the entry just registered.
246
+ *
247
+ * The next step it reports is DERIVED, by `deliveryFor`, from the same planner
248
+ * `brambo init` runs. It used to be a sentence written here —
249
+ * "`brambo project init` puts it into every detected executor" — and for a skill
250
+ * that sentence was false: nothing at that scope takes one, machine-scope
251
+ * projection cannot see a project-scope entry, and the entry was inert forever
252
+ * behind a command that exits 0. This binding therefore holds NO idea of which
253
+ * entry type has a location at which scope; it prints what the planner found.
254
+ */
255
+ async function performAdd(entry, bound, out, err) {
256
+ // Straight to the store: it validates through `validateRegistryEntry` and
257
+ // throws a coded `BramboError` for a bad type, an empty id, an unprojectable id
258
+ // and a field that does not belong on this type. Nothing is checked here first.
259
+ await bound.store.register(entry, bound.registryScope);
260
+ // AFTER the write, never before: the entry is registered either way, and a
261
+ // planner that cannot answer must not be able to fail the registration.
262
+ const delivery = await deliveryFor(entry, bound.scope, bound.homeDir, bound.projectDir);
263
+ out(JSON.stringify({ scope: bound.registryScope, registryPath: bound.registryPath, entry, delivery }, null, 2));
264
+ err(`registered: ${describeEntry(bound.registryScope, entry)}`);
265
+ err(`stored in '${bound.registryPath}'`);
266
+ for (const line of deliveryLines(entry, delivery, bound.scope))
267
+ err(line);
268
+ return 0;
269
+ }
270
+ /**
271
+ * The derived next step, as the lines a human reads. Every sentence here is a
272
+ * rendering of {@link EntryDelivery} — which executors the planner found for
273
+ * this entry at this scope, the targets' own refusals where it found none, and
274
+ * the other scope when that one would take it.
275
+ */
276
+ function deliveryLines(entry, delivery, scope) {
277
+ const lines = [];
278
+ if (delivery.undetermined !== undefined) {
279
+ // No claim about delivery is made, because none was established. The
280
+ // grammar fact stays true and is all that is said.
281
+ lines.push(`the entry is registered; brambo could not work out what would take it (${delivery.undetermined})`);
282
+ lines.push(`\`${delivery.command}\` is the command that projects this scope`);
283
+ return lines;
284
+ }
285
+ if (delivery.executorIds.length > 0) {
286
+ lines.push(`nothing was projected: \`${delivery.command}\` puts it into ${delivery.executorIds.join(', ')}`);
287
+ return lines;
288
+ }
289
+ // A GRAMMAR FAULT IS NOT AN ENVIRONMENT FACT, AND THE OLD BLOCK SAID IT WAS.
290
+ //
291
+ // `command` is the only executable field an mcp-server has, and
292
+ // `collectMcpEntries` skips a command-less one unconditionally — not even an
293
+ // `extensions` payload rescues it. So such an entry is inert on every
294
+ // executor, at every scope, on every machine, forever. The block printed three
295
+ // sentences about SCOPE for it ("HERE", "at the machine scope", "no other
296
+ // scope takes it either"), which reads as a local condition another scope
297
+ // might not have. None of that is true of it.
298
+ //
299
+ // And the repair is ONE command, driven: `brambo add` on an existing id updates
300
+ // it in place and exits 0, so no `brambo remove` is needed. Naming it here is
301
+ // the difference between a diagnosis and an exit.
302
+ if (entry.type === 'mcp-server' && entry.command === undefined) {
303
+ lines.push(`NOTHING TAKES IT, ANYWHERE: an mcp-server with no command renders into nothing — on every executor and at every scope, not just this one`);
304
+ lines.push(
305
+ // THE SHARPEST OF THIS CLASS. Printed at project scope, the machine
306
+ // spelling of add-with-a-command does not update this entry — it creates a
307
+ // SECOND one, in the MACHINE registry, and the sentence promising "in
308
+ // place" is what sends the user there. (Spelled out, that sentence wrapped
309
+ // across two lines and the unclosed-command guard refused it. Describe.)
310
+ `the entry is registered and stays listed by \`${verbAt(scope, 'list')}\`; give it a command with \`${verbAt(scope, 'add')} mcp-server ${entry.id} --command <c>\`, which updates this entry in place`);
311
+ return lines;
312
+ }
313
+ // The headline says only what was OBSERVED — that no target took it. It used
314
+ // to explain WHY ("no detected executor has a machine-scope location for a
315
+ // skill entry"), which conflated "no target exists for this surface" with
316
+ // "a target existed and refused THIS entry", and printed the first sentence
317
+ // seconds after codex had used exactly such a location.
318
+ lines.push(`NOTHING TAKES IT HERE: no detected executor would take this ${entry.type} entry at the ${delivery.scope} scope, so \`${delivery.command}\` would project it nowhere`);
319
+ for (const reason of delivery.reasons)
320
+ lines.push(` refused: ${reason}`);
321
+ if (delivery.reasons.length === 0) {
322
+ // Said rather than left blank: a target that skips an entry without a reason
323
+ // has given brambo nothing to pass on, and inventing one here is the failure
324
+ // the headline above was just corrected for.
325
+ lines.push(` no target said why; \`${verbAt(scope, 'doctor')}\` reports what each one would do`);
326
+ }
327
+ const elsewhere = delivery.elsewhere;
328
+ if (elsewhere === undefined) {
329
+ lines.push(`no other scope takes it either; it stays in the registry, listed by \`${verbAt(scope, 'list')}\`, and removable with \`${verbAt(scope, 'remove')}\``);
330
+ return lines;
331
+ }
332
+ // The scope that WOULD deliver it, named as the two commands that get there.
333
+ lines.push(elsewhere.scope === 'machine'
334
+ ? `the machine scope takes it (${elsewhere.executorIds.join(', ')}): register it with \`brambo add\` and project it with \`${elsewhere.command}\``
335
+ : `the project scope takes it (${elsewhere.executorIds.join(', ')}): register it with \`brambo project add\` and project it with \`${elsewhere.command}\``);
336
+ return lines;
337
+ }
338
+ /**
339
+ * `remove` on an entry that is not there is TYPED ABSENCE (AD-5): it says so and
340
+ * exits non-zero. A silent 0 would tell a script the entry is gone when the id
341
+ * was simply misspelled, which is the same class of lie as an empty diagnosis.
342
+ *
343
+ * The existence check reads the ONE scope being written, never the merged view:
344
+ * an entry shadowing from another scope would otherwise report a removal that
345
+ * did not happen — and hide a stale entry in this scope forever.
346
+ */
347
+ async function performRemove(type, id, bound, out, err) {
348
+ const present = await bound.store.get(type, id, bound.registryScope);
349
+ if (present === undefined) {
350
+ out(JSON.stringify({ scope: bound.registryScope, registryPath: bound.registryPath, removed: null, type, id }, null, 2));
351
+ err(`nothing was removed: no ${type} entry '${id}' is registered at the ${bound.registryScope} scope in '${bound.registryPath}'`);
352
+ return 1;
353
+ }
354
+ await bound.store.remove(type, id, bound.registryScope);
355
+ out(JSON.stringify({ scope: bound.registryScope, registryPath: bound.registryPath, removed: present }, null, 2));
356
+ err(`removed: ${describeEntry(bound.registryScope, present)}`);
357
+ err(`stored in '${bound.registryPath}'`);
358
+ // NOT "takes it out of every executor brambo wrote it into" — that was false,
359
+ // not merely vacuous: over a location the user has edited, `brambo init`
360
+ // answers "brambo will not remove a tree it no longer recognises" and the
361
+ // content stays. What is true is the rule brambo actually applies.
362
+ err(`nothing was projected: \`${bound.projectCommand}\` removes it from every location brambo still owns, and reports the ones it no longer recognises rather than deleting them`);
363
+ return 0;
364
+ }
365
+ /**
366
+ * Every entry, WITH the scope it came from — which is why each scope is read on
367
+ * its own rather than through the merged view every projection uses: the merge
368
+ * keeps one row per `type:id`, so the scope that produced it is exactly the fact
369
+ * it drops.
370
+ *
371
+ * An empty registry exits 0. An empty list is a result, not a failure; the
372
+ * command did look, and it says what it found.
373
+ */
374
+ async function runList(directory, scope, context) {
375
+ const { out, err } = context;
376
+ const bound = await bind(scope, directory, context);
377
+ try {
378
+ // The machine grammar has one scope and can see no other; the project
379
+ // grammar sees the project's entries over the machine's, in that order.
380
+ const scopes = scope === 'machine' ? ['global'] : ['global', 'project'];
381
+ const rows = [];
382
+ for (const registryScope of scopes) {
383
+ for (const entry of await bound.store.list(registryScope))
384
+ rows.push({ scope: registryScope, entry });
385
+ }
386
+ out(JSON.stringify({
387
+ scope: bound.registryScope,
388
+ registryPath: bound.registryPath,
389
+ entries: rows.map((row) => ({ scope: row.scope, ...row.entry })),
390
+ }, null, 2));
391
+ if (rows.length === 0) {
392
+ err(`the registry is empty; \`${verbAt(scope, 'add')} <type> <id>\` puts an entry in it`);
393
+ return 0;
394
+ }
395
+ for (const row of rows)
396
+ err(describeEntry(row.scope, row.entry));
397
+ return 0;
398
+ }
399
+ finally {
400
+ await bound.store.dispose();
401
+ }
402
+ }
403
+ /**
404
+ * `brambo export <path>` — the machine's Registry as a portable artifact.
405
+ *
406
+ * It lives beside `add`/`remove`/`list` rather than in its own module because
407
+ * it needs `bind`, and `bind` is the trust boundary those three already share:
408
+ * one spelling of "the home directory" and a bound directory brambo never
409
+ * creates. A second binding here is how two verbs come to disagree about which
410
+ * registry they are talking about.
411
+ *
412
+ * It is NOT a RegistryVerb. Those three also carry a project-scoped spelling,
413
+ * and a project-scoped export would name a directory the destination machine
414
+ * does not have — so the global scope is the only one that can travel, and
415
+ * there is no second grammar to offer.
416
+ */
417
+ export async function runExportCommand(tokens, context) {
418
+ const { out, err } = context;
419
+ const [path, ...rest] = tokens;
420
+ if (path === undefined || path.length === 0 || path.startsWith('-')) {
421
+ // The destination is REQUIRED. The binary passes no cwd, so a default would
422
+ // resolve one way under a harness that supplies one and another way for
423
+ // every real user — the defect that made `brambo project swap` exit 2 for
424
+ // everyone while its whole suite was green.
425
+ err('usage: brambo export <path>');
426
+ err(context.defaultUsage);
427
+ return 2;
428
+ }
429
+ if (rest.length > 0) {
430
+ err(`unexpected argument '${rest[0]}'`);
431
+ err(context.defaultUsage);
432
+ return 2;
433
+ }
434
+ const bound = await bind('machine', undefined, context);
435
+ try {
436
+ // `bound.homeDir` and not a second `homedir()` call: the bundle's paths have
437
+ // to be relative to the SAME home the store was bound to, or an export run
438
+ // with an injected home writes paths pointing at the real one.
439
+ const bundle = createBundle(await bound.store.list('global'), bound.homeDir);
440
+ await writeBundle(path, bundle);
441
+ out(JSON.stringify({
442
+ path,
443
+ version: bundle.version,
444
+ scope: bundle.scope,
445
+ exported: bundle.entries.length,
446
+ // Named, never counted alone: an entry that did not travel is a task
447
+ // waiting on the other machine, and a bare number is not one.
448
+ //
449
+ // Forwarded VERBATIM, and that is what makes stdout clean without a
450
+ // redaction step here: the record's `id` arm has no id to print, so
451
+ // there is nothing at this site to remember to strip.
452
+ omitted: bundle.omitted,
453
+ }, null, 2));
454
+ return 0;
455
+ }
456
+ finally {
457
+ await bound.store.dispose();
458
+ }
459
+ }
460
+ /**
461
+ * `brambo import <path>` — the install half. The caller re-projects.
462
+ *
463
+ * Split there on purpose: re-projection is `initMachine`, whose result already
464
+ * has one reporting implementation in `run.ts`, and this file may not grow a
465
+ * second one. What belongs here is the part that needs `bind` — the same trust
466
+ * boundary add/remove/list/export share.
467
+ */
468
+ export async function runImportCommand(tokens, context) {
469
+ const { err } = context;
470
+ const [path, ...rest] = tokens;
471
+ if (path === undefined || path.length === 0 || path.startsWith('-')) {
472
+ err('usage: brambo import <path>');
473
+ err(context.defaultUsage);
474
+ return 2;
475
+ }
476
+ if (rest.length > 0) {
477
+ err(`unexpected argument '${rest[0]}'`);
478
+ err(context.defaultUsage);
479
+ return 2;
480
+ }
481
+ // Read and validate BEFORE binding anything: a bundle brambo cannot read must
482
+ // not leave a `.brambo` directory behind on a machine the user was only
483
+ // trying an artifact on.
484
+ const bundle = await readBundle(path);
485
+ const bound = await bind('machine', undefined, context);
486
+ const replaced = [];
487
+ try {
488
+ for (const entry of bundle.entries) {
489
+ // Asked BEFORE registering, because `register` replaces by `type:id` and
490
+ // says nothing. A user moving devices who had already run `brambo add`
491
+ // deserves to be told which of their entries the bundle took over — the
492
+ // same rule as the omission record: brambo never overwrites in silence.
493
+ if ((await bound.store.get(entry.type, entry.id, 'global')) !== undefined) {
494
+ replaced.push({ type: entry.type, id: entry.id });
495
+ }
496
+ // EXPANDED against this machine's home before registering, and this is not
497
+ // symmetry for its own sake. `register` normalizes what it is given, and a
498
+ // bundle is ALREADY normalized — so handing it over verbatim runs the
499
+ // normalizer over `~/skills/x.ts`, whose leading `~` is the reserved
500
+ // marker, and the escape rule turns it into `~~/skills/x.ts`: a path to a
501
+ // file literally named `~/skills/x.ts`. Measured by driving the binary; a
502
+ // machine imported that way had every path field quietly wrong.
503
+ //
504
+ // The store's surface takes REAL paths — it is what `brambo add` passes and
505
+ // what `list()` returns — so import converts back into that vocabulary and
506
+ // lets the store normalize once, exactly as it does for any other write.
507
+ await bound.store.register(expandRegistryEntryPaths(entry, bound.homeDir), 'global');
508
+ }
509
+ return {
510
+ path,
511
+ homeDir: bound.homeDir,
512
+ imported: bundle.entries.length,
513
+ replaced,
514
+ pending: bundle.omitted.map((omitted) => ({ ...omitted })),
515
+ };
516
+ }
517
+ finally {
518
+ // Released before the caller re-projects: `initMachine` binds its own store
519
+ // and would contend with this one for the same lock.
520
+ await bound.store.dispose();
521
+ }
522
+ }
523
+ /**
524
+ * `brambo ingest [--dry-run]` — the machine's own skills AND MCP servers, into
525
+ * the registry.
526
+ *
527
+ * The binding's whole job, and the reason it is this short: argv in, one
528
+ * capability call, the outcome rendered, exit 0. Which skills roots and which
529
+ * executor configs are read, what the ownership ledger excludes, and what counts
530
+ * as a skill or a server are `ingestMachine`'s answers — a second opinion here
531
+ * would be a rule that drifts from the one the capability enforces, and the CLI
532
+ * may not touch the filesystem at all.
533
+ *
534
+ * A run that wrote nothing because there was nothing to write exits 0. That is
535
+ * the same answer `brambo list` gives for an empty registry: it is a result, not
536
+ * a failure, and a script must be able to tell it apart from a run that broke.
537
+ */
538
+ export async function runIngestCommand(tokens, context) {
539
+ const { out, err } = context;
540
+ const flag = tokens.find((token) => token.startsWith('-'));
541
+ if (flag !== undefined && flag !== DRY_RUN_FLAG) {
542
+ err(`unrecognized option '${flag}'`);
543
+ err(context.defaultUsage);
544
+ return 2;
545
+ }
546
+ const positional = tokens.find((token) => !token.startsWith('-'));
547
+ if (positional !== undefined) {
548
+ // Machine scope only: `ingestMachine` reads the skills roots and the
549
+ // executor configs brambo has verified for this machine, and there is no
550
+ // directory to name.
551
+ err(`unexpected argument '${positional}'`);
552
+ err(context.defaultUsage);
553
+ return 2;
554
+ }
555
+ const report = await ingestMachine({
556
+ homeDir: context.homeDir,
557
+ dryRun: tokens.includes(DRY_RUN_FLAG),
558
+ });
559
+ out(JSON.stringify({
560
+ scope: 'global',
561
+ registryPath: report.registryPath,
562
+ dryRun: report.dryRun,
563
+ roots: report.roots,
564
+ // Both halves, side by side. The mcp-server half under its own key
565
+ // rather than merged into the skills fields: a user reading `skipped`
566
+ // has to be able to tell a directory that is not a skill from a server
567
+ // brambo could not read, and one flat list answers neither question.
568
+ configPaths: report.mcpServers.configPaths,
569
+ registered: report.outcome.registered,
570
+ unchanged: report.outcome.unchanged,
571
+ ownedByBrambo: report.ownedByBrambo,
572
+ skipped: report.skipped,
573
+ mcpServers: {
574
+ ownedByBrambo: report.mcpServers.ownedByBrambo,
575
+ skipped: report.mcpServers.skipped,
576
+ dropped: report.mcpServers.dropped,
577
+ },
578
+ warnings: report.outcome.warnings,
579
+ }, null, 2));
580
+ // Each fact on its own line, because a user who ran a command wants the work
581
+ // left for them without parsing JSON for it.
582
+ for (const skip of report.skipped)
583
+ err(`skipped: ${skip.detail}`);
584
+ for (const skip of report.mcpServers.skipped) {
585
+ // A config brambo could not open is not a candidate it skipped: the servers
586
+ // in it were never seen at all, and calling that "skipped" would understate
587
+ // it. Every other kind is one entry brambo looked at and declined.
588
+ err(skip.kind === 'unreadable-config' ? `not read: ${skip.detail}` : `skipped: ${skip.detail}`);
589
+ }
590
+ // What did NOT travel, named with the file it stayed in. A key brambo cannot
591
+ // carry is still in the vendor's document doing its job; saying nothing would
592
+ // let a user believe the registry holds the whole server.
593
+ for (const drop of report.mcpServers.dropped) {
594
+ err(`'${drop.entryId}' was ingested for its command and arguments only; '${drop.keys.join("', '")}' stayed in '${drop.filePath}' because a registry mcp-server entry has nowhere to put them`);
595
+ }
596
+ for (const warning of report.outcome.warnings)
597
+ err(warning.detail);
598
+ if (report.ownedByBrambo.length > 0) {
599
+ err(`${report.ownedByBrambo.length} director(ies) in those roots were written by brambo itself and were left alone`);
600
+ }
601
+ if (report.mcpServers.ownedByBrambo.length > 0) {
602
+ err(`${report.mcpServers.ownedByBrambo.length} server(s) in those configs were written by brambo itself and were left alone`);
603
+ }
604
+ const written = report.outcome.registered.length;
605
+ err(report.dryRun
606
+ ? `${written} entr(ies) would be ingested into '${report.registryPath}'; nothing was written`
607
+ : `${written} entr(ies) ingested into '${report.registryPath}'`);
608
+ if (report.outcome.unchanged.length > 0) {
609
+ err(`${report.outcome.unchanged.length} entr(ies) were already registered and unchanged`);
610
+ }
611
+ return 0;
612
+ }
@@ -0,0 +1,26 @@
1
+ import { type InitMachineOptions } from '@skanl/brambo-environment';
2
+ import { type LogRecord, type SessionOptions } from '@skanl/brambo-session';
3
+ /**
4
+ * The seams are PICKED from the capability packages rather than redeclared: this
5
+ * package forwards them and owns none of them, so naming their types here would
6
+ * be the first step back towards composing sessions in the CLI (see the
7
+ * thin-binding pin in `test/run.test.ts`). What the CLI owns is where the output
8
+ * goes.
9
+ */
10
+ export interface RunCommandOptions extends Pick<SessionOptions, 'cwd' | 'adapterOptions' | 'createAdapter' | 'createProvider' | 'onInterrupt'>, Pick<InitMachineOptions, 'homeDir'> {
11
+ readonly stdout?: (line: string) => void;
12
+ readonly stderr?: (line: string) => void;
13
+ }
14
+ export declare const USAGE: string;
15
+ export declare function runBrambo(argv: readonly string[], options?: RunCommandOptions): Promise<number>;
16
+ /**
17
+ * One record, one line, for a human watching a run happen. Fields in the order
18
+ * the kernel writes them, each present only when the record carries it.
19
+ *
20
+ * It formats and decides nothing — the whole of cordis's `ConsoleExporter` is
21
+ * `console.log(this.render(message))` over a renderer just like this one. `at`
22
+ * is left out on purpose: the record carries a wall clock for whoever PERSISTS
23
+ * the stream, but ordering is `seq`, and a timestamp on a line scrolling past
24
+ * live is noise that carries no order the number does not already carry.
25
+ */
26
+ export declare function renderLogRecord(record: LogRecord): string;