@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SKANL
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,208 @@
1
+ # @skanl/brambo-cli
2
+
3
+ Minimal `brambo` command-line surface, and nothing more than a binding: it parses
4
+ argv, calls [`@skanl/brambo-session`](../session/README.md), prints the typed envelope as
5
+ structured JSON and maps it to an exit code. The composition it used to own —
6
+ workspace under `<cwd>/.brambo/workspaces/<uuid>`, adapter, cancellation, cleanup —
7
+ lives in that package, so a third party can do everything this CLI does without
8
+ installing it. It reads no files itself; `eslint.config.js` forbids this package
9
+ from importing `node:fs` at all, because a capability that needs a filesystem
10
+ read is a capability that belongs in `@skanl/brambo-session` or `@skanl/brambo-environment`.
11
+
12
+ ## Install
13
+
14
+ ```sh
15
+ npm i -g @skanl/brambo-cli
16
+ ```
17
+
18
+ The binary is `brambo`. Every command below is written the way it works after
19
+ that install; inside this repository's own checkout the same commands run as
20
+ `pnpm brambo …`, which is a development convenience and not what a consumer types.
21
+
22
+ ## Usage
23
+
24
+ ```sh
25
+ brambo run "list files in this workspace"
26
+ brambo run --executor codex "list files in this workspace"
27
+ ```
28
+
29
+ Output: the `ResultEnvelope` as pretty-printed JSON on stdout.
30
+
31
+ ## Choosing an executor
32
+
33
+ Brambo ships three adapters — `claude-code`, `codex` and `opencode` — and resolves
34
+ which one runs through layered configuration, widest to narrowest:
35
+
36
+ | Layer | Source |
37
+ | ----- | ------ |
38
+ | `defaults` | brambo's built-in default, `claude-code` |
39
+ | `global` | `~/.brambo/config.json` |
40
+ | `project` | `<project>/.brambo/config.json` |
41
+ | `invocation` | `--executor <id>` (or `--executor=<id>`) |
42
+
43
+ The document is JSON. `executor` is the key this table resolves; the same file
44
+ carries the other selections brambo reads — `method` (see `brambo swap method`) and
45
+ the `workspace` subtree that `@skanl/brambo-workspace-local` and
46
+ `@skanl/brambo-workspace-git-worktree` document:
47
+
48
+ ```json
49
+ { "executor": "codex" }
50
+ ```
51
+
52
+ **Every real invocation now writes one line to stderr**, naming the selection and
53
+ the layer that decided it — `executor: codex (selected by the 'project' layer)`.
54
+ A run whose output cannot tell you which agent produced it is not a swap you can
55
+ trust. This is a behaviour change for a script that treats any stderr output as
56
+ failure; branch on the exit code instead.
57
+
58
+ A configuration document that is MISSING is simply an absent layer. One that
59
+ exists and cannot be used — unreadable, a dangling symlink, invalid JSON, not an
60
+ object, or an `executor` that is not a string — is a coded error and exits 2. It
61
+ is never a quiet fall back to the default, because running a different agent than
62
+ the one you configured is the failure this feature exists to remove. `--executor`
63
+ overrides a configuration brambo can READ; it does not rescue one it cannot.
64
+
65
+ ## Putting something in the registry
66
+
67
+ Everything `brambo init` projects comes out of the registry, and until story M4.D
68
+ there was no way to put anything in it from the binary — the store shipped, the
69
+ surface did not.
70
+
71
+ ```sh
72
+ brambo add skill commit-lint --entry-path ./skills/commit-lint/SKILL.md
73
+ brambo add mcp-server fs --command npx --arg -y --arg @modelcontextprotocol/server-filesystem
74
+ brambo list
75
+ brambo remove skill commit-lint
76
+ ```
77
+
78
+ The scope comes from the GRAMMAR, never from a flag: `brambo <verb>` is the
79
+ machine scope and `brambo project <verb> … [directory]` is a project's, exactly
80
+ like `init`, `doctor` and `remediate`. There is deliberately no `--scope agent` —
81
+ the agent scope is an in-memory map that dies with the process, so a flag for it
82
+ would accept the flag, exit 0 and persist nothing.
83
+
84
+ Which field flags a type accepts is the registry CONTRACT's answer, not this
85
+ command's: `brambo add mcp-server t --entry-path ./x` is refused with
86
+ `BRAMBO_REGISTRY_INVALID_ENTRY`, because an `mcp-server` carries a `command` and
87
+ `args`. The CLI holds no per-type table, so it cannot drift from the one the
88
+ contract already has.
89
+
90
+ Story M4.E RETIRED the `tool` type: no executor has a non-MCP location for an
91
+ identity plus an executable command, and an `mcp-server` entry already carries
92
+ exactly what a `tool` entry carried. Story M4.F retired `profile` through the
93
+ same door, on the PRD's own glossary: a Profile is a named bundle of registry
94
+ SELECTIONS carried by Bundles, so it is a container over `skill` and
95
+ `mcp-server` rather than a peer of them, and it returns designed in Epic 5.
96
+ `brambo add tool …` and `brambo add profile …` are usage errors, but a registry
97
+ written by an older build is still read, still listed, and still emptied
98
+ through the product — `brambo remove` accepts a retired type, and `brambo doctor`
99
+ prints the exact spelling for each entry it finds.
100
+
101
+ `add` registers and projects nothing; it names the command that does. Coupling
102
+ the two would make registration fail for projection reasons.
103
+
104
+ That next step is DERIVED from the same planner `brambo init` runs, never written
105
+ beside the command, and it can say that nothing takes the entry at all:
106
+
107
+ ```
108
+ $ brambo project add skill deadend --entry-path ./s.md
109
+ registered: project - skill - deadend
110
+ NOTHING TAKES IT HERE: no detected executor has a project-scope location for a
111
+ skill entry, so `brambo project init` would project it nowhere
112
+ the machine scope takes it (codex): register it with `brambo add` and project it
113
+ with `brambo init`
114
+ ```
115
+
116
+ That is a real dead end and it used to be silent: no executor has a
117
+ project-scope skills root brambo has verified, machine-scope projection cannot
118
+ see a project-scope entry, and `add` still pointed at `brambo project init`, which
119
+ exits 0 and delivers nothing. The registration still succeeds — the entry is
120
+ yours and `brambo list` shows it — but the command you are pointed at is the one
121
+ that would actually deliver it, or none, and never a command that would not.
122
+
123
+ `remove` on an entry that was not there says so and exits 1. An empty `list`
124
+ exits 0 — an empty list is a result, not a failure.
125
+
126
+ ## Leaving a state `brambo doctor` reported
127
+
128
+ `brambo doctor` reports drift and refuses to resolve it, which is correct and used
129
+ to be terminal: the only exit was hand-editing `~/.brambo/projection-ledger.json`,
130
+ the file every safety guarantee in that subsystem is stored in.
131
+
132
+ ```sh
133
+ brambo remediate adopt --executor claude-code --entry context7 # describes
134
+ brambo remediate adopt --executor claude-code --entry context7 --apply # performs
135
+ ```
136
+
137
+ One finding at a time, named by the user, and only a finding the same run just
138
+ reported — zero matches and more than one match are both refusals, and the
139
+ refusal lists what could have been named. Without `--apply` the command only
140
+ describes, computed by the code that would perform it. Nothing is ever
141
+ remediated automatically or in bulk.
142
+
143
+ | Verb | What it changes | What it lets a LATER run do |
144
+ | ---- | --------------- | --------------------------- |
145
+ | `adopt` | brambo's ledger only | brambo OWNS the location: the next `brambo init` replaces what is there, and on a skills root can remove it. Every path that becomes deletable is named in the description first |
146
+ | `release` | brambo's ledger only | nothing — the claim goes, the file is not even opened, and no later run touches it |
147
+ | `repair` | brambo's own ledger document, dropping exactly the records it cannot read | nothing to a vendor file, ever |
148
+ | `discard` | one vendor file, removing only brambo's own prior output (correction-01 C6) | nothing further |
149
+
150
+ Three of the four write nothing but brambo's own ledger — but `adopt` is an
151
+ ownership TRANSFER, and owning a location is what makes it replaceable and, on a
152
+ skills root, removable. Read the description: it says which of the two the next
153
+ `brambo init` will do and which paths it covers. A `remediate` that brambo refuses
154
+ exits 1 with `BRAMBO_PROJECTION_REMEDIATION_REFUSED` and changes nothing.
155
+
156
+ `brambo doctor` names the verb for every state that has one, so the report and the
157
+ exit are one string.
158
+
159
+ ## How much quota is left
160
+
161
+ ```sh
162
+ brambo status
163
+ ```
164
+
165
+ One row per executor, reporting what that executor published about its own usage
166
+ the last time brambo ran it — the windows the vendor NAMES, with the vendor's own
167
+ utilisation and reset values and the instant the reading was taken. Brambo
168
+ averages nothing, converts nothing, and states no "time remaining" the vendor did
169
+ not state.
170
+
171
+ `brambo status` **invokes no executor and writes nothing**. A report that spent
172
+ the quota it reports on would be unusable on exactly the day you most want it, so
173
+ the run that already paid for a reading is the one that records it, and `status`
174
+ reads the record. That is also why every row is honest about what it does not
175
+ know: an executor that publishes no usage surface says so
176
+ (`BRAMBO_USAGE_NO_SURFACE`), and one brambo has not run yet says so and names the
177
+ command that would produce a reading (`BRAMBO_USAGE_NOT_OBSERVED`). Neither is
178
+ ever shown as `0%` — a zero for something brambo never measured reads as a
179
+ measurement that was taken.
180
+
181
+ Today only `claude-code` publishes such a surface, in its own event stream. It is
182
+ a typed field the vendor emits deliberately under a documented flag, not text
183
+ scraped off a terminal.
184
+
185
+ ## Exit codes
186
+
187
+ | Code | Meaning |
188
+ | ---- | ------- |
189
+ | 0 | run completed with a status `ok` envelope |
190
+ | 1 | run returned `failed` or `cancelled` (envelope still printed) |
191
+ | 2 | usage error, invalid request, or environment failure (message on stderr) |
192
+
193
+ For `status` there are two: 0 whenever a report could be produced — an
194
+ all-absence report is still a report — and 2 only when none could be. There is no
195
+ 1, because a utilisation is not a verdict brambo gets to fail on.
196
+
197
+ For `doctor` the three narrow: 0 clean, 1 at least one finding that is a problem,
198
+ 2 no diagnosis could be produced. For `remediate`: 0 described or performed, 1
199
+ brambo refused, 2 usage or environment failure. For `remove`: 0 removed, 1 the
200
+ entry was not registered at that scope (typed absence, never a silent 0), 2 usage
201
+ or a coded registry failure.
202
+
203
+ An executor name brambo has no adapter for (`BRAMBO_EXECUTOR_NOT_FOUND`) and an
204
+ unusable configuration document (`BRAMBO_CONFIGURATION_UNUSABLE`) are both 2, and
205
+ carry distinct codes because their fixes differ: correct the name versus repair
206
+ the file.
207
+
208
+ No TUI, no streaming — progress surfaces arrive in later stories.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import { runBrambo } from '../src/run.js';
3
+ // `brambo run ... | head` must not crash: a closed stdout pipe surfaces as an
4
+ // async EPIPE error on the stream, which we treat as a graceful exit.
5
+ process.stdout?.on('error', (error) => {
6
+ if (error.code === 'EPIPE')
7
+ process.exit(0);
8
+ });
9
+ process.exitCode = await runBrambo(process.argv.slice(2));
@@ -0,0 +1,2 @@
1
+ export { runBrambo } from './run.ts';
2
+ export type { RunCommandOptions } from './run.ts';
@@ -0,0 +1 @@
1
+ export { runBrambo } from './run.js';
@@ -0,0 +1,94 @@
1
+ import type { OmittedEntry } from '@skanl/brambo-environment';
2
+ export type RegistryVerb = 'add' | 'remove' | 'list';
3
+ /** The verbs `run.ts` dispatches into this file, under both grammars. */
4
+ export declare const REGISTRY_VERBS: readonly RegistryVerb[];
5
+ export declare function isRegistryVerb(token: string | undefined): token is RegistryVerb;
6
+ export interface RegistryCommandContext {
7
+ readonly out: (line: string) => void;
8
+ readonly err: (line: string) => void;
9
+ /** The synopsis printed after a usage error; `run.ts` owns the text. */
10
+ readonly defaultUsage: string;
11
+ /** Defaults to the OS home directory, like every other command's. */
12
+ readonly homeDir: string | undefined;
13
+ /** The project directory when no positional one is given. */
14
+ readonly cwd: string | undefined;
15
+ }
16
+ /**
17
+ * The entry type, as an argv question. A missing or misspelled type is a usage
18
+ * error about the command line — the user has not named an entry yet — while
19
+ * everything about the entry ITSELF is the contract's to answer.
20
+ */
21
+ /**
22
+ * The verb a user at THIS scope can actually run.
23
+ *
24
+ * `brambo project add` with no type answered `brambo add needs an entry type`. The
25
+ * verb was right and the grammar was not: at project scope `brambo add` is a
26
+ * different command against a different registry, so the sentence named
27
+ * something that would act on the wrong one. `scope` was already a parameter at
28
+ * every one of these sites and was simply not read.
29
+ *
30
+ * Two spellings rather than one interpolation, so the printed-command invariant
31
+ * sees a real verb in each — the same shape `doctor.ts` uses for its exits.
32
+ */
33
+ export declare function verbAt(scope: 'machine' | 'project', verb: string): string;
34
+ export declare function runRegistryCommand(verb: RegistryVerb, tokens: readonly string[], scope: 'machine' | 'project', context: RegistryCommandContext): Promise<number>;
35
+ /**
36
+ * `brambo export <path>` — the machine's Registry as a portable artifact.
37
+ *
38
+ * It lives beside `add`/`remove`/`list` rather than in its own module because
39
+ * it needs `bind`, and `bind` is the trust boundary those three already share:
40
+ * one spelling of "the home directory" and a bound directory brambo never
41
+ * creates. A second binding here is how two verbs come to disagree about which
42
+ * registry they are talking about.
43
+ *
44
+ * It is NOT a RegistryVerb. Those three also carry a project-scoped spelling,
45
+ * and a project-scoped export would name a directory the destination machine
46
+ * does not have — so the global scope is the only one that can travel, and
47
+ * there is no second grammar to offer.
48
+ */
49
+ export declare function runExportCommand(tokens: readonly string[], context: RegistryCommandContext): Promise<number>;
50
+ /** What an import put in place, before the projection half runs. */
51
+ export interface ImportInstallation {
52
+ readonly path: string;
53
+ readonly homeDir: string;
54
+ readonly imported: number;
55
+ /** Entries whose `type:id` was already registered here and has been taken over. */
56
+ readonly replaced: readonly {
57
+ type: string;
58
+ id: string;
59
+ }[];
60
+ /**
61
+ * What the bundle could not carry, forwarded verbatim: FR-22's manual work.
62
+ *
63
+ * `OmittedEntry` itself rather than a structural copy of its shape. The copy
64
+ * spelled `{type, id, field}`, which is exactly the shape the `id` arm must
65
+ * NOT have — so a hand-written mirror of a type is how a second slot for the
66
+ * credential gets re-opened one package downstream.
67
+ */
68
+ readonly pending: readonly OmittedEntry[];
69
+ }
70
+ /**
71
+ * `brambo import <path>` — the install half. The caller re-projects.
72
+ *
73
+ * Split there on purpose: re-projection is `initMachine`, whose result already
74
+ * has one reporting implementation in `run.ts`, and this file may not grow a
75
+ * second one. What belongs here is the part that needs `bind` — the same trust
76
+ * boundary add/remove/list/export share.
77
+ */
78
+ export declare function runImportCommand(tokens: readonly string[], context: RegistryCommandContext): Promise<ImportInstallation | number>;
79
+ /**
80
+ * `brambo ingest [--dry-run]` — the machine's own skills AND MCP servers, into
81
+ * the registry.
82
+ *
83
+ * The binding's whole job, and the reason it is this short: argv in, one
84
+ * capability call, the outcome rendered, exit 0. Which skills roots and which
85
+ * executor configs are read, what the ownership ledger excludes, and what counts
86
+ * as a skill or a server are `ingestMachine`'s answers — a second opinion here
87
+ * would be a rule that drifts from the one the capability enforces, and the CLI
88
+ * may not touch the filesystem at all.
89
+ *
90
+ * A run that wrote nothing because there was nothing to write exits 0. That is
91
+ * the same answer `brambo list` gives for an empty registry: it is a result, not
92
+ * a failure, and a script must be able to tell it apart from a run that broke.
93
+ */
94
+ export declare function runIngestCommand(tokens: readonly string[], context: RegistryCommandContext): Promise<number>;