@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.
- package/LICENSE +21 -0
- package/README.md +208 -0
- package/dist/bin/brambo.d.ts +2 -0
- package/dist/bin/brambo.js +9 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +1 -0
- package/dist/src/registry-commands.d.ts +94 -0
- package/dist/src/registry-commands.js +612 -0
- package/dist/src/run.d.ts +26 -0
- package/dist/src/run.js +1157 -0
- package/dist/src/swap-command.d.ts +28 -0
- package/dist/src/swap-command.js +225 -0
- package/package.json +59 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The nouns `swap` takes. Both are selections brambo holds about ITSELF, which is
|
|
3
|
+
* why they share a verb: neither is a registry entry, and neither reaches an
|
|
4
|
+
* executor's own configuration.
|
|
5
|
+
*
|
|
6
|
+
* What differs is how an id is CHECKED. An executor id names one of a closed
|
|
7
|
+
* catalogue, so the catalogue answers. A method is named by a module specifier
|
|
8
|
+
* and there is no catalogue to answer with — brambo has no installed-methods list
|
|
9
|
+
* in v1 (PRD §6.2 places methodologies post-v1) — so the only honest check is to
|
|
10
|
+
* LOAD it. That is why the branch below exists and why FR-28's "listing
|
|
11
|
+
* available methods" is renegotiated in this story's spec rather than faked.
|
|
12
|
+
*/
|
|
13
|
+
export declare const SWAP_NOUNS: readonly ['executor', 'method'];
|
|
14
|
+
export type SwapNoun = (typeof SWAP_NOUNS)[number];
|
|
15
|
+
export interface SwapCommandOptions {
|
|
16
|
+
readonly homeDir?: string;
|
|
17
|
+
readonly cwd?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `brambo swap <noun> <id>`, machine scope, and its `project` twin.
|
|
21
|
+
*
|
|
22
|
+
* The order is deliberate: VALIDATE, then write, then re-resolve. Validation
|
|
23
|
+
* goes through the same function the RUN path uses — `resolveExecutor` for an
|
|
24
|
+
* executor, `resolveMethod` for a method — so a refusal here is byte-identical
|
|
25
|
+
* to the one the user would have hit later, rather than a second opinion that
|
|
26
|
+
* drifts from it.
|
|
27
|
+
*/
|
|
28
|
+
export declare function runSwap(tokens: readonly string[], scope: 'machine' | 'project', err: (line: string) => void, usage: string, options?: SwapCommandOptions): Promise<number>;
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { resolve, sep as SEP } from 'node:path';
|
|
3
|
+
import { scopeDirectory, setConfigValue } from '@skanl/brambo-environment';
|
|
4
|
+
import { verbAt } from './registry-commands.js';
|
|
5
|
+
import { resolveExecutor, resolveMethod } from '@skanl/brambo-session';
|
|
6
|
+
// `brambo swap <noun> <id>` — the verb that WRITES a selection.
|
|
7
|
+
//
|
|
8
|
+
// Everything brambo selects has been readable and unwritable: `brambo run --help`
|
|
9
|
+
// names the two documents the executor selection comes from and the product had
|
|
10
|
+
// no way to put a value in either. `swap` is the PRD's own word for changing an
|
|
11
|
+
// active thing (§6.1's CLI list, and FR-28's `brambo swap method`), so this is a
|
|
12
|
+
// verb Story 5.4 extends with a second NOUN rather than a second verb.
|
|
13
|
+
//
|
|
14
|
+
// Thin, like every other binding here: the write is `@skanl/brambo-environment`'s, the
|
|
15
|
+
// id check and the effective selection are `@skanl/brambo-session`'s. This file parses
|
|
16
|
+
// argv, orders the two calls and prints. It decides nothing.
|
|
17
|
+
/**
|
|
18
|
+
* The nouns `swap` takes. Both are selections brambo holds about ITSELF, which is
|
|
19
|
+
* why they share a verb: neither is a registry entry, and neither reaches an
|
|
20
|
+
* executor's own configuration.
|
|
21
|
+
*
|
|
22
|
+
* What differs is how an id is CHECKED. An executor id names one of a closed
|
|
23
|
+
* catalogue, so the catalogue answers. A method is named by a module specifier
|
|
24
|
+
* and there is no catalogue to answer with — brambo has no installed-methods list
|
|
25
|
+
* in v1 (PRD §6.2 places methodologies post-v1) — so the only honest check is to
|
|
26
|
+
* LOAD it. That is why the branch below exists and why FR-28's "listing
|
|
27
|
+
* available methods" is renegotiated in this story's spec rather than faked.
|
|
28
|
+
*/
|
|
29
|
+
export const SWAP_NOUNS = ['executor', 'method'];
|
|
30
|
+
/**
|
|
31
|
+
* A predicate rather than a cast at the use site. `Array.includes` does not
|
|
32
|
+
* narrow, and the difference matters here: the noun is handed straight to
|
|
33
|
+
* `setConfigValue`, whose `key` is the published allowlist type. A cast would
|
|
34
|
+
* let a third noun added to `SWAP_NOUNS` reach the writer without anybody adding
|
|
35
|
+
* it to that allowlist too — which is the one thing the allowlist exists to stop.
|
|
36
|
+
*/
|
|
37
|
+
function isSwapNoun(value) {
|
|
38
|
+
return value !== undefined && SWAP_NOUNS.includes(value);
|
|
39
|
+
}
|
|
40
|
+
/** The layer each scope's document composes into, for the override report below. */
|
|
41
|
+
const LAYER_FOR_SCOPE = { machine: 'global', project: 'project' };
|
|
42
|
+
function describe(error) {
|
|
43
|
+
const code = error?.code;
|
|
44
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
45
|
+
return typeof code === 'string' ? `${code}: ${message}` : message;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* `brambo swap <noun> <id>`, machine scope, and its `project` twin.
|
|
49
|
+
*
|
|
50
|
+
* The order is deliberate: VALIDATE, then write, then re-resolve. Validation
|
|
51
|
+
* goes through the same function the RUN path uses — `resolveExecutor` for an
|
|
52
|
+
* executor, `resolveMethod` for a method — so a refusal here is byte-identical
|
|
53
|
+
* to the one the user would have hit later, rather than a second opinion that
|
|
54
|
+
* drifts from it.
|
|
55
|
+
*/
|
|
56
|
+
export async function runSwap(tokens, scope, err, usage, options = {}) {
|
|
57
|
+
const noun = tokens[0];
|
|
58
|
+
if (!isSwapNoun(noun)) {
|
|
59
|
+
err(`${verbAt(scope, 'swap')} needs one of: ${SWAP_NOUNS.join(', ')}`);
|
|
60
|
+
err(usage);
|
|
61
|
+
return 2;
|
|
62
|
+
}
|
|
63
|
+
const requested = tokens[1];
|
|
64
|
+
if (requested === undefined || requested.trim().length === 0) {
|
|
65
|
+
err(`${verbAt(scope, 'swap')} ${noun} needs the id to select`);
|
|
66
|
+
err(usage);
|
|
67
|
+
return 2;
|
|
68
|
+
}
|
|
69
|
+
// The machine scope has no positional after the id; the project scope has
|
|
70
|
+
// exactly one, its directory — the same shape every other `project` verb has.
|
|
71
|
+
const extra = tokens.slice(2);
|
|
72
|
+
if (extra.length > (scope === 'project' ? 1 : 0)) {
|
|
73
|
+
err(usage);
|
|
74
|
+
return 2;
|
|
75
|
+
}
|
|
76
|
+
const id = requested.trim();
|
|
77
|
+
// Defaulted HERE rather than left to each callee: `resolveExecutor` falls back
|
|
78
|
+
// to `homedir()` on its own and `setConfigValue` does not, so leaving it
|
|
79
|
+
// undefined would validate against one home directory and write into another.
|
|
80
|
+
const homeDir = options.homeDir ?? homedir();
|
|
81
|
+
// `process.cwd()` for the same reason `homeDir` is defaulted above, and it is
|
|
82
|
+
// not hypothetical: `resolveExecutor` falls back to it and `setConfigValue`
|
|
83
|
+
// refuses without one, so leaving it undefined made `brambo project swap` exit
|
|
84
|
+
// 2 for every real user while the suite — which always passes a `cwd` — stayed
|
|
85
|
+
// green. Defaulted HERE so the validation and the write see one directory.
|
|
86
|
+
const requestedDir = (scope === 'project' ? extra[0] : undefined) ?? options.cwd ?? process.cwd();
|
|
87
|
+
// BRAMBO BINDS A PROJECT, IT DOES NOT CREATE ONE — and this verb was the one
|
|
88
|
+
// that did not honour it. `scopeDirectory` is what `project init`, `add`,
|
|
89
|
+
// `list`, `doctor` and `remove` all pass their directory through;
|
|
90
|
+
// `@skanl/brambo-environment`'s own index calls it "the trust boundary that
|
|
91
|
+
// keeps a project verb from building a tree brambo was asked to bind rather
|
|
92
|
+
// than create". This file took `extra[0]` raw, so driven side by side:
|
|
93
|
+
//
|
|
94
|
+
// project init ./nope exit 2, nothing created
|
|
95
|
+
// project swap ./nope exit 0, CREATED ./nope/.brambo/
|
|
96
|
+
// project swap ../../../../ESCAPE exit 0, wrote outside the tree entirely
|
|
97
|
+
//
|
|
98
|
+
// The refusal its siblings print — "brambo binds an existing directory and
|
|
99
|
+
// never creates one" — was a guarantee one verb did not keep. It also resolves
|
|
100
|
+
// the path, so what is reported afterwards is absolute like every sibling's
|
|
101
|
+
// rather than the relative string the user typed, which is what made a typo
|
|
102
|
+
// impossible to locate.
|
|
103
|
+
let projectDir;
|
|
104
|
+
try {
|
|
105
|
+
projectDir =
|
|
106
|
+
scope === 'project' ? await scopeDirectory('the project directory', requestedDir) : requestedDir;
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
err(describe(error));
|
|
110
|
+
return 2;
|
|
111
|
+
}
|
|
112
|
+
// WHAT THIS VALIDATES MUST MEAN THE SAME THING WHERE IT IS STORED.
|
|
113
|
+
//
|
|
114
|
+
// `projectDir` above is cwd for the MACHINE scope too, so `swap method
|
|
115
|
+
// ./mine.mjs` run from a project validated THAT project's file and then wrote
|
|
116
|
+
// the raw './mine.mjs' into the HOME document — where `runSession` resolves it
|
|
117
|
+
// against whatever directory the next run stands in. Driven, with a control: a
|
|
118
|
+
// directory carrying only a `mine.mjs` and NO `.brambo` config had that module's
|
|
119
|
+
// top-level code RUN; the same directory with an empty HOME did not. A wildcard
|
|
120
|
+
// over every repository on the machine.
|
|
121
|
+
//
|
|
122
|
+
// THE FIRST FIX HERE WAS A REFUSAL, AND A REFUSAL WAS THE WRONG SHAPE. It made
|
|
123
|
+
// the run-time guard's own advice — "name the module by ABSOLUTE path in your
|
|
124
|
+
// own machine document" — cost the user a path they had to spell themselves,
|
|
125
|
+
// while brambo was standing in the directory that resolves it. A refusal that
|
|
126
|
+
// one line of resolution removes is a refusal that spares the implementer.
|
|
127
|
+
//
|
|
128
|
+
// So it is resolved HERE, where the user is standing and can be shown what was
|
|
129
|
+
// kept, and the value validated below is the value that is stored. The RUN-TIME
|
|
130
|
+
// guard stays: a relative specifier can still reach a machine document by hand
|
|
131
|
+
// or from an older build, and there it names no file.
|
|
132
|
+
const RELATIVE_PREFIXES = ['./', '../', '.' + SEP, '..' + SEP];
|
|
133
|
+
const resolvedFrom = noun === 'method' && scope === 'machine' && RELATIVE_PREFIXES.some((prefix) => id.startsWith(prefix))
|
|
134
|
+
? id
|
|
135
|
+
: undefined;
|
|
136
|
+
const selection = resolvedFrom === undefined ? id : resolve(projectDir, id);
|
|
137
|
+
try {
|
|
138
|
+
// Nothing is written before this returns, for either noun: a selection brambo
|
|
139
|
+
// cannot honour must cost no byte on disk, the same rule `runSession`
|
|
140
|
+
// applies before it makes a workspace directory.
|
|
141
|
+
if (noun === 'executor') {
|
|
142
|
+
// Throws BRAMBO_EXECUTOR_NOT_FOUND naming every available id.
|
|
143
|
+
await resolveExecutor({ executorId: selection, homeDir, projectDir });
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
// Loading IS the check. It also means `brambo swap method` fails at the
|
|
147
|
+
// moment the user can still fix it, rather than at the next `brambo run`
|
|
148
|
+
// when they have moved on — the same reason the executor id is resolved
|
|
149
|
+
// here instead of trusted.
|
|
150
|
+
await resolveMethod(selection, projectDir);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
err(describe(error));
|
|
155
|
+
return 2;
|
|
156
|
+
}
|
|
157
|
+
let written;
|
|
158
|
+
try {
|
|
159
|
+
written = await setConfigValue({
|
|
160
|
+
scope,
|
|
161
|
+
homeDir,
|
|
162
|
+
projectDir,
|
|
163
|
+
key: noun,
|
|
164
|
+
value: selection,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
err(describe(error));
|
|
169
|
+
return 2;
|
|
170
|
+
}
|
|
171
|
+
// A METHOD IN THE PROJECT DOCUMENT IS A RECOMMENDATION, NOT A SELECTION, AND
|
|
172
|
+
// THE VERB HAS TO SAY WHICH. Every `project swap method` writes a value no run
|
|
173
|
+
// mounts, while printing `selected:`.
|
|
174
|
+
//
|
|
175
|
+
// M30.D MADE THE SENTENCE SHORTER BY MAKING THE PRODUCT BETTER. This message
|
|
176
|
+
// used to have to warn that the write BREAKS `brambo run` in the directory,
|
|
177
|
+
// because the refusal was fatal and a project key stopped the run whatever else
|
|
178
|
+
// was configured. `seedExecutorConfig` now declines the key at admission and
|
|
179
|
+
// says so, so the write costs the project nothing and the adoption path is one
|
|
180
|
+
// command instead of two.
|
|
181
|
+
//
|
|
182
|
+
// The WRITE is not the defect and is not removed: row E4 of `spec-m25a…`
|
|
183
|
+
// freezes it and M5.D row 6 designed it. Deleting a designed, frozen behaviour
|
|
184
|
+
// to fix a printed word is the worse trade. The word is what changes.
|
|
185
|
+
//
|
|
186
|
+
// EXECUTOR IS DELIBERATELY UNCHANGED. Nothing refuses a project-layer
|
|
187
|
+
// executor, so `selected:` is true there, and hedging both would trade one
|
|
188
|
+
// false sentence for another.
|
|
189
|
+
const where = `'${selection}' in '${written.filePath}'`;
|
|
190
|
+
if (noun === 'method' && scope === 'project') {
|
|
191
|
+
err(`recommended: ${where} — a note for whoever clones this project, NOT a selection. brambo never mounts a method a project directory names, so runs here say they declined it and use the machine's. To select it for yourself: \`brambo swap method ${id}\` from this directory.`);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
const from = resolvedFrom === undefined ? '' : ` (resolved from '${resolvedFrom}' here)`;
|
|
195
|
+
err(written.previous === selection
|
|
196
|
+
? `already selected: ${where}${from}`
|
|
197
|
+
: `selected: ${where}${from}${written.previous === undefined ? '' : ` (was '${written.previous}')`}`);
|
|
198
|
+
}
|
|
199
|
+
// THE HALF THAT IS NOT THE FILE WRITE. Writing the machine document while the
|
|
200
|
+
// project one names something else changes nothing a run will do, and a
|
|
201
|
+
// command that stopped at "selected" would be telling the user it had done
|
|
202
|
+
// something it had not. So the effective selection is resolved again, with no
|
|
203
|
+
// override, and reported whenever it is not what was just written.
|
|
204
|
+
// The effective-selection report is EXECUTOR-only, and that asymmetry is
|
|
205
|
+
// deliberate rather than an omission: resolving the effective method would
|
|
206
|
+
// mean importing and ACTIVATING it here, in a process whose whole job is to
|
|
207
|
+
// write a string. A swap that ran a methodology's onActivate as a side effect
|
|
208
|
+
// of being configured is a side effect nobody asked for.
|
|
209
|
+
if (noun !== 'executor')
|
|
210
|
+
return 0;
|
|
211
|
+
try {
|
|
212
|
+
const effective = await resolveExecutor({ homeDir, projectDir });
|
|
213
|
+
if (effective.executorId !== id || effective.layer !== LAYER_FOR_SCOPE[scope]) {
|
|
214
|
+
err(`the effective selection is still '${effective.executorId}', decided by the '${effective.layer}' layer, which is narrower than the '${LAYER_FOR_SCOPE[scope]}' layer just written`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
// The write DID happen and is reported above. This is a second, separate
|
|
219
|
+
// fact: brambo can no longer say what a run would select, which is a problem
|
|
220
|
+
// even though the requested change landed.
|
|
221
|
+
err(`the selection was written, but brambo could not resolve what a run would now use: ${describe(error)}`);
|
|
222
|
+
return 2;
|
|
223
|
+
}
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@skanl/brambo-cli",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "The `brambo` command-line surface: argv, JSON output and exit codes, and nothing more.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai-agent",
|
|
7
|
+
"brambo",
|
|
8
|
+
"cli",
|
|
9
|
+
"mcp",
|
|
10
|
+
"skills",
|
|
11
|
+
"developer-tools"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/SKANL/brambo#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/SKANL/brambo/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/SKANL/brambo.git",
|
|
20
|
+
"directory": "packages/cli"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"type": "module",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=20"
|
|
29
|
+
},
|
|
30
|
+
"bin": {
|
|
31
|
+
"brambo": "./dist/bin/brambo.js"
|
|
32
|
+
},
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"brambo-source": "./src/index.ts",
|
|
36
|
+
"types": "./dist/src/index.d.ts",
|
|
37
|
+
"default": "./dist/src/index.js"
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"@skanl/brambo-environment": "0.1.1",
|
|
42
|
+
"@skanl/brambo-session": "0.1.1"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@skanl/brambo-contracts": "0.1.1",
|
|
46
|
+
"@types/node": "^24.13.3",
|
|
47
|
+
"typescript": "~7.0.2",
|
|
48
|
+
"vitest": "^4.1.11"
|
|
49
|
+
},
|
|
50
|
+
"files": [
|
|
51
|
+
"dist"
|
|
52
|
+
],
|
|
53
|
+
"scripts": {
|
|
54
|
+
"typecheck": "tsc --noEmit",
|
|
55
|
+
"test": "vitest run",
|
|
56
|
+
"lint": "eslint .",
|
|
57
|
+
"build": "node ../../scripts/clean-dist.mjs && tsc -p tsconfig.build.json"
|
|
58
|
+
}
|
|
59
|
+
}
|