@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,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
+ }