@brainjar/cli 0.2.2 → 0.3.0
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/README.md +11 -300
- package/package.json +3 -3
- package/src/cli.ts +1 -3
- package/src/commands/init.ts +3 -4
- package/src/commands/shell.ts +3 -6
- package/src/commands/status.ts +2 -38
- package/src/paths.ts +0 -2
- package/src/seeds.ts +0 -2
- package/src/state.ts +2 -31
- package/src/sync.ts +1 -17
- package/src/commands/identity.ts +0 -276
- package/src/engines/bitwarden.ts +0 -105
- package/src/engines/index.ts +0 -12
- package/src/engines/types.ts +0 -12
package/README.md
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
[](https://www.npmjs.com/package/@brainjar/cli)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
Shape how your AI thinks —
|
|
8
|
+
Shape how your AI thinks — soul, persona, rules.
|
|
9
9
|
|
|
10
|
-
brainjar manages AI agent behavior through composable layers. Instead of one monolithic config file, you separate **what the agent sounds like** (soul), **how it works** (persona), and **what constraints it follows** (rules). Each layer is a markdown file. Mix and
|
|
10
|
+
brainjar manages AI agent behavior through composable layers. Instead of one monolithic config file, you separate **what the agent sounds like** (soul), **how it works** (persona), and **what constraints it follows** (rules). Each layer is a markdown file. Mix, match, and switch them per project, per task, or per session.
|
|
11
|
+
|
|
12
|
+
**[Documentation](https://brainjar.sh)** · **[Getting Started](https://brainjar.sh/getting-started/)**
|
|
11
13
|
|
|
12
14
|
## Quick start
|
|
13
15
|
|
|
@@ -22,272 +24,14 @@ brainjar init --default
|
|
|
22
24
|
brainjar status
|
|
23
25
|
```
|
|
24
26
|
|
|
25
|
-
That gives you a soul (craftsman), a persona (engineer), and rules (boundaries, git discipline, security) — all wired into your `CLAUDE.md` and ready to go.
|
|
26
|
-
|
|
27
27
|
## Concepts
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
### Soul — who the agent is
|
|
32
|
-
|
|
33
|
-
The soul defines personality: tone, character, standards. It's the constant across all tasks. You probably only have one or two. Think of it as the agent's voice.
|
|
34
|
-
|
|
35
|
-
### Persona — how the agent works
|
|
36
|
-
|
|
37
|
-
Personas define role and workflow. An engineer persona works differently than a reviewer or an architect. Switch personas based on what you're doing — they're the agent's job description.
|
|
38
|
-
|
|
39
|
-
Personas bundle their own rules via frontmatter:
|
|
40
|
-
|
|
41
|
-
```yaml
|
|
42
|
-
---
|
|
43
|
-
rules:
|
|
44
|
-
- default
|
|
45
|
-
- security
|
|
46
|
-
---
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
### Rules — what the agent must follow
|
|
50
|
-
|
|
51
|
-
Rules are behavioral constraints — guardrails that apply regardless of persona. Single files or multi-file packs (directories). Rules from a persona's frontmatter activate automatically when that persona is active.
|
|
52
|
-
|
|
53
|
-
### Brain — a saved configuration
|
|
54
|
-
|
|
55
|
-
A brain is a named snapshot of soul + persona + rules. Instead of switching three things separately, save your setup once and activate it in one shot. Useful for repeatable workflows like "code review" or "design session."
|
|
56
|
-
|
|
57
|
-
### How they compose
|
|
58
|
-
|
|
59
|
-
```
|
|
60
|
-
soul + persona + rules = full agent behavior
|
|
61
|
-
│ │
|
|
62
|
-
└── bundled rules ───┘ (from persona frontmatter)
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
A brain captures all three layers. `compose` assembles them into a single prompt for subagent dispatch.
|
|
66
|
-
|
|
67
|
-
## State cascade
|
|
68
|
-
|
|
69
|
-
State merges in three tiers. Each tier overrides the previous:
|
|
70
|
-
|
|
71
|
-
```
|
|
72
|
-
global → local → env
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
| Tier | Storage | When to use |
|
|
76
|
-
|------|---------|-------------|
|
|
77
|
-
| **Global** | `~/.brainjar/state.yaml` | Default behavior across all projects |
|
|
78
|
-
| **Local** | `.brainjar/state.yaml` (in project) | Per-project overrides |
|
|
79
|
-
| **Env** | `BRAINJAR_*` environment variables | Per-session or CI overrides |
|
|
80
|
-
|
|
81
|
-
```bash
|
|
82
|
-
# Global (default for all projects)
|
|
83
|
-
brainjar persona use engineer
|
|
84
|
-
|
|
85
|
-
# Local (this project only)
|
|
86
|
-
brainjar persona use planner --local
|
|
87
|
-
|
|
88
|
-
# Env (this session only)
|
|
89
|
-
BRAINJAR_PERSONA=reviewer claude
|
|
90
|
-
|
|
91
|
-
# Or use a subshell for scoped sessions
|
|
92
|
-
brainjar shell --persona reviewer --rules-add security
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
`brainjar status` shows where each setting comes from:
|
|
96
|
-
|
|
97
|
-
```
|
|
98
|
-
soul craftsman (global)
|
|
99
|
-
persona planner (local)
|
|
100
|
-
rules default (global), security (+local)
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
### Scope annotations
|
|
104
|
-
|
|
105
|
-
When you see scope labels in `status` and `rules list` output:
|
|
106
|
-
|
|
107
|
-
| Label | Meaning |
|
|
29
|
+
| Layer | Purpose |
|
|
108
30
|
|-------|---------|
|
|
109
|
-
|
|
|
110
|
-
|
|
|
111
|
-
|
|
|
112
|
-
|
|
|
113
|
-
| `(env)` | Overridden by `BRAINJAR_*` env var |
|
|
114
|
-
| `(+env)` | Added by env var |
|
|
115
|
-
| `(-env)` | Removed by env var |
|
|
116
|
-
|
|
117
|
-
### Environment variables
|
|
118
|
-
|
|
119
|
-
These env vars override all other state when set:
|
|
120
|
-
|
|
121
|
-
| Variable | Effect |
|
|
122
|
-
|----------|--------|
|
|
123
|
-
| `BRAINJAR_HOME` | Override `~/.brainjar/` location |
|
|
124
|
-
| `BRAINJAR_SOUL` | Override active soul |
|
|
125
|
-
| `BRAINJAR_PERSONA` | Override active persona |
|
|
126
|
-
| `BRAINJAR_IDENTITY` | Override active identity |
|
|
127
|
-
| `BRAINJAR_RULES_ADD` | Comma-separated rules to add |
|
|
128
|
-
| `BRAINJAR_RULES_REMOVE` | Comma-separated rules to remove |
|
|
129
|
-
|
|
130
|
-
Set to empty string to explicitly unset (e.g., `BRAINJAR_SOUL=""` removes the soul for that session).
|
|
131
|
-
|
|
132
|
-
## What it does
|
|
133
|
-
|
|
134
|
-
```
|
|
135
|
-
~/.brainjar/
|
|
136
|
-
souls/ # Voice and character — who the agent is
|
|
137
|
-
craftsman.md
|
|
138
|
-
personas/ # Role and workflow — how the agent works
|
|
139
|
-
engineer.md
|
|
140
|
-
planner.md
|
|
141
|
-
reviewer.md
|
|
142
|
-
rules/ # Constraints — what the agent must follow
|
|
143
|
-
default/ # Boundaries, context recovery, task completion
|
|
144
|
-
git-discipline.md
|
|
145
|
-
security.md
|
|
146
|
-
brains/ # Full-stack snapshots — soul + persona + rules
|
|
147
|
-
review.yaml
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
brainjar reads these markdown files, merges the active layers, and inlines them into your agent's config (`~/.claude/CLAUDE.md` or `.codex/AGENTS.md`) between `<!-- brainjar:start -->` / `<!-- brainjar:end -->` markers. Everything outside the markers is yours. Change a layer, and the agent's behavior changes on next sync.
|
|
151
|
-
|
|
152
|
-
## Layers
|
|
153
|
-
|
|
154
|
-
### Soul
|
|
155
|
-
|
|
156
|
-
```bash
|
|
157
|
-
brainjar soul create mysoul --description "Direct and rigorous"
|
|
158
|
-
brainjar soul use mysoul
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
### Persona
|
|
162
|
-
|
|
163
|
-
```bash
|
|
164
|
-
brainjar persona use planner # Design session
|
|
165
|
-
brainjar persona use engineer # Build session
|
|
166
|
-
brainjar persona use reviewer # Review session
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
### Rules
|
|
170
|
-
|
|
171
|
-
```bash
|
|
172
|
-
brainjar rules create no-delete --description "Never delete files without asking"
|
|
173
|
-
brainjar rules add no-delete
|
|
174
|
-
brainjar rules remove no-delete
|
|
175
|
-
```
|
|
176
|
-
|
|
177
|
-
### Brain
|
|
178
|
-
|
|
179
|
-
```bash
|
|
180
|
-
brainjar brain save review # Snapshot current state as a brain
|
|
181
|
-
brainjar brain use review # Activate soul + persona + rules in one shot
|
|
182
|
-
brainjar brain list # See available brains
|
|
183
|
-
brainjar brain show review # Inspect a brain's config
|
|
184
|
-
```
|
|
185
|
-
|
|
186
|
-
When to use a brain vs. switching layers individually:
|
|
187
|
-
- **Brain:** Repeatable workflow you do often (code review, design, debugging). Save once, activate in one command.
|
|
188
|
-
- **Individual layers:** Exploratory work, one-off overrides, or when you only need to change one thing.
|
|
189
|
-
|
|
190
|
-
## Subagent orchestration
|
|
191
|
-
|
|
192
|
-
Personas can spawn other personas as subagents. For example, a tech-lead persona can:
|
|
193
|
-
|
|
194
|
-
1. Spawn an **architect** subagent for design — produces a design doc
|
|
195
|
-
2. Get user approval
|
|
196
|
-
3. Implement the design itself
|
|
197
|
-
4. Spawn a **reviewer** subagent to verify — compares code against the spec
|
|
198
|
-
|
|
199
|
-
Each subagent gets the full brain context: soul + persona + rules. The `compose` command assembles the full prompt in a single call:
|
|
200
|
-
|
|
201
|
-
```bash
|
|
202
|
-
# Primary path — brain drives everything
|
|
203
|
-
brainjar compose review --task "Review the changes in src/sync.ts"
|
|
204
|
-
|
|
205
|
-
# Ad-hoc — no saved brain, specify persona directly
|
|
206
|
-
brainjar compose --persona reviewer --task "Review the changes in src/sync.ts"
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
For more granular control, use `brainjar persona show <name>` and `brainjar rules show <name>` to retrieve individual layers.
|
|
210
|
-
|
|
211
|
-
## Recipes
|
|
212
|
-
|
|
213
|
-
### Code review session
|
|
214
|
-
|
|
215
|
-
```bash
|
|
216
|
-
# Save a review brain once
|
|
217
|
-
brainjar soul use craftsman
|
|
218
|
-
brainjar persona use reviewer
|
|
219
|
-
brainjar rules add default
|
|
220
|
-
brainjar rules add security
|
|
221
|
-
brainjar brain save review
|
|
222
|
-
|
|
223
|
-
# Then activate it anytime
|
|
224
|
-
brainjar brain use review
|
|
225
|
-
|
|
226
|
-
# Or scope it to a single session
|
|
227
|
-
brainjar shell --brain review
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
### CI pipeline — enforce rules without a persona
|
|
231
|
-
|
|
232
|
-
```bash
|
|
233
|
-
# In CI, use env vars to override behavior
|
|
234
|
-
BRAINJAR_PERSONA=auditor BRAINJAR_RULES_ADD=security,compliance brainjar status --sync
|
|
235
|
-
```
|
|
236
|
-
|
|
237
|
-
### Project-specific persona
|
|
238
|
-
|
|
239
|
-
```bash
|
|
240
|
-
# In your project directory
|
|
241
|
-
brainjar persona use planner --local
|
|
242
|
-
brainjar rules add no-delete --local
|
|
243
|
-
|
|
244
|
-
# Global settings still apply — local just overrides what you specify
|
|
245
|
-
brainjar status
|
|
246
|
-
# soul craftsman (global)
|
|
247
|
-
# persona planner (local)
|
|
248
|
-
# rules default (global), no-delete (+local)
|
|
249
|
-
```
|
|
250
|
-
|
|
251
|
-
## Pack
|
|
252
|
-
|
|
253
|
-
Packs are self-contained, shareable bundles of a brain and all its layers — soul, persona, and rules. Export a brain as a pack directory, hand it to a teammate, and they import it in one command.
|
|
254
|
-
|
|
255
|
-
A pack mirrors the `~/.brainjar/` structure with a `pack.yaml` manifest at the root. No tarballs, no magic — just files you can inspect with `ls` and `cat`.
|
|
256
|
-
|
|
257
|
-
```bash
|
|
258
|
-
# Export a brain as a pack
|
|
259
|
-
brainjar pack export review # creates ./review/
|
|
260
|
-
brainjar pack export review --out /tmp # creates /tmp/review/
|
|
261
|
-
brainjar pack export review --name my-review # override pack name
|
|
262
|
-
brainjar pack export review --version 1.0.0 # set version (default: 0.1.0)
|
|
263
|
-
brainjar pack export review --author frank # set author field
|
|
264
|
-
|
|
265
|
-
# Import a pack
|
|
266
|
-
brainjar pack import ./review # import into ~/.brainjar/
|
|
267
|
-
brainjar pack import ./review --force # overwrite conflicts
|
|
268
|
-
brainjar pack import ./review --merge # rename conflicts as <name>-from-<packname>
|
|
269
|
-
brainjar pack import ./review --activate # activate the brain after import
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
On conflict (a file already exists with different content), import fails by default and lists the conflicts. Use `--force` to overwrite or `--merge` to keep both versions. Identical files are silently skipped.
|
|
273
|
-
|
|
274
|
-
## Hooks
|
|
275
|
-
|
|
276
|
-
brainjar integrates with Claude Code's hook system for automatic context injection. When hooks are installed, brainjar syncs your config on every session start — no manual `brainjar sync` needed.
|
|
277
|
-
|
|
278
|
-
```bash
|
|
279
|
-
# Install hooks (writes to ~/.claude/settings.json)
|
|
280
|
-
brainjar hooks install
|
|
281
|
-
|
|
282
|
-
# Install for this project only
|
|
283
|
-
brainjar hooks install --local
|
|
284
|
-
|
|
285
|
-
# Check hook status
|
|
286
|
-
brainjar hooks status
|
|
287
|
-
|
|
288
|
-
# Remove hooks
|
|
289
|
-
brainjar hooks remove
|
|
290
|
-
```
|
|
31
|
+
| **Soul** | Who the agent is — voice, character, standards |
|
|
32
|
+
| **Persona** | How the agent works — role, workflow, bundled rules |
|
|
33
|
+
| **Rules** | Behavioral constraints — guardrails that apply regardless of persona |
|
|
34
|
+
| **Brain** | Saved snapshot of soul + persona + rules — activate in one shot |
|
|
291
35
|
|
|
292
36
|
## Commands
|
|
293
37
|
|
|
@@ -296,52 +40,19 @@ brainjar init [--default] [--obsidian] [--backend claude|codex]
|
|
|
296
40
|
brainjar status [--sync] [--global|--local] [--short]
|
|
297
41
|
brainjar sync [--quiet]
|
|
298
42
|
brainjar compose <brain> [--task <text>]
|
|
299
|
-
brainjar compose --persona <name> [--task <text>]
|
|
300
43
|
|
|
301
44
|
brainjar brain save|use|list|show|drop
|
|
302
45
|
brainjar soul create|list|show|use|drop
|
|
303
46
|
brainjar persona create|list|show|use|drop
|
|
304
47
|
brainjar rules create|list|show|add|remove
|
|
305
48
|
|
|
306
|
-
brainjar identity create|list|show|use|drop|unlock|get|status|lock
|
|
307
49
|
brainjar pack export|import
|
|
308
50
|
brainjar hooks install|remove|status [--local]
|
|
309
|
-
brainjar shell [--brain|--soul|--persona|--
|
|
51
|
+
brainjar shell [--brain|--soul|--persona|--rules-add|--rules-remove]
|
|
310
52
|
brainjar reset [--backend claude|codex]
|
|
311
53
|
```
|
|
312
54
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
```bash
|
|
316
|
-
brainjar persona show reviewer # View a specific persona
|
|
317
|
-
brainjar soul show # View the active soul
|
|
318
|
-
brainjar rules show security # View a rule's content
|
|
319
|
-
brainjar status --short # One-liner: soul | persona | identity
|
|
320
|
-
brainjar soul show --short # Just the active soul name
|
|
321
|
-
```
|
|
322
|
-
|
|
323
|
-
## Obsidian support
|
|
324
|
-
|
|
325
|
-
`~/.brainjar/` is a folder of markdown files — it's already almost an Obsidian vault.
|
|
326
|
-
|
|
327
|
-
```bash
|
|
328
|
-
brainjar init --obsidian
|
|
329
|
-
```
|
|
330
|
-
|
|
331
|
-
Adds `.obsidian/` config that hides private files (state, identities) from the file explorer and includes templates for creating new souls, personas, and rules from within Obsidian.
|
|
332
|
-
|
|
333
|
-
## Backends
|
|
334
|
-
|
|
335
|
-
```bash
|
|
336
|
-
brainjar init --backend codex # writes ~/.codex/AGENTS.md
|
|
337
|
-
brainjar reset --backend codex
|
|
338
|
-
```
|
|
339
|
-
|
|
340
|
-
Supported: `claude` (default), `codex`
|
|
341
|
-
|
|
342
|
-
## Backup & restore
|
|
343
|
-
|
|
344
|
-
On first sync, brainjar backs up any existing config file to `CLAUDE.md.pre-brainjar`. Running `brainjar reset` removes brainjar-managed config and restores the backup.
|
|
55
|
+
See the [CLI reference](https://brainjar.sh/reference/cli/) for full details.
|
|
345
56
|
|
|
346
57
|
## Development
|
|
347
58
|
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brainjar/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Shape how your AI thinks — composable
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Shape how your AI thinks — composable soul, persona, and rules for AI agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "https://github.com/brainjar-sh/brainjar-cli.git"
|
|
10
10
|
},
|
|
11
|
-
"homepage": "https://
|
|
11
|
+
"homepage": "https://brainjar.sh",
|
|
12
12
|
"bugs": "https://github.com/brainjar-sh/brainjar-cli/issues",
|
|
13
13
|
"keywords": [
|
|
14
14
|
"ai",
|
package/src/cli.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
import { Cli } from 'incur'
|
|
3
3
|
import pkg from '../package.json'
|
|
4
4
|
import { init } from './commands/init.js'
|
|
5
|
-
import { identity } from './commands/identity.js'
|
|
6
5
|
import { soul } from './commands/soul.js'
|
|
7
6
|
import { persona } from './commands/persona.js'
|
|
8
7
|
import { rules } from './commands/rules.js'
|
|
@@ -16,7 +15,7 @@ import { hooks } from './commands/hooks.js'
|
|
|
16
15
|
import { pack } from './commands/pack.js'
|
|
17
16
|
|
|
18
17
|
Cli.create('brainjar', {
|
|
19
|
-
description: 'Shape how your AI thinks —
|
|
18
|
+
description: 'Shape how your AI thinks — soul, persona, rules',
|
|
20
19
|
version: pkg.version,
|
|
21
20
|
sync: { depth: 0 },
|
|
22
21
|
})
|
|
@@ -26,7 +25,6 @@ Cli.create('brainjar', {
|
|
|
26
25
|
.command(persona)
|
|
27
26
|
.command(rules)
|
|
28
27
|
.command(brain)
|
|
29
|
-
.command(identity)
|
|
30
28
|
.command(reset)
|
|
31
29
|
.command(shell)
|
|
32
30
|
.command(compose)
|
package/src/commands/init.ts
CHANGED
|
@@ -21,14 +21,13 @@ export const init = Cli.create('init', {
|
|
|
21
21
|
mkdir(paths.personas, { recursive: true }),
|
|
22
22
|
mkdir(paths.rules, { recursive: true }),
|
|
23
23
|
mkdir(paths.brains, { recursive: true }),
|
|
24
|
-
mkdir(paths.identities, { recursive: true }),
|
|
25
24
|
])
|
|
26
25
|
|
|
27
26
|
// Seed the default rule pack
|
|
28
27
|
await seedDefaultRule(paths.rules)
|
|
29
28
|
|
|
30
29
|
// Build .gitignore — always exclude private files, add .obsidian if vault enabled
|
|
31
|
-
const gitignoreLines = ['
|
|
30
|
+
const gitignoreLines = ['state.yaml']
|
|
32
31
|
if (c.options.obsidian) {
|
|
33
32
|
gitignoreLines.push('.obsidian/', 'templates/')
|
|
34
33
|
}
|
|
@@ -53,7 +52,7 @@ export const init = Cli.create('init', {
|
|
|
53
52
|
const result: Record<string, unknown> = {
|
|
54
53
|
created: brainjarDir,
|
|
55
54
|
backend: c.options.backend,
|
|
56
|
-
directories: ['souls/', 'personas/', 'rules/', 'brains/'
|
|
55
|
+
directories: ['souls/', 'personas/', 'rules/', 'brains/'],
|
|
57
56
|
}
|
|
58
57
|
|
|
59
58
|
if (c.options.default) {
|
|
@@ -63,7 +62,7 @@ export const init = Cli.create('init', {
|
|
|
63
62
|
result.personas = ['engineer', 'planner', 'reviewer']
|
|
64
63
|
result.next = 'Ready to go. Run `brainjar status` to see your config.'
|
|
65
64
|
} else {
|
|
66
|
-
result.next = 'Run `brainjar
|
|
65
|
+
result.next = 'Run `brainjar soul create <name>` to create your first soul.'
|
|
67
66
|
}
|
|
68
67
|
|
|
69
68
|
if (c.options.obsidian) {
|
package/src/commands/shell.ts
CHANGED
|
@@ -14,20 +14,19 @@ export const shell = Cli.create('shell', {
|
|
|
14
14
|
brain: z.string().optional().describe('Brain name — sets soul, persona, and rules from brain file'),
|
|
15
15
|
soul: z.string().optional().describe('Soul override for this session'),
|
|
16
16
|
persona: z.string().optional().describe('Persona override for this session'),
|
|
17
|
-
identity: z.string().optional().describe('Identity override for this session'),
|
|
18
17
|
'rules-add': z.string().optional().describe('Comma-separated rules to add'),
|
|
19
18
|
'rules-remove': z.string().optional().describe('Comma-separated rules to remove'),
|
|
20
19
|
}),
|
|
21
20
|
async run(c) {
|
|
22
21
|
await requireBrainjarDir()
|
|
23
22
|
|
|
24
|
-
const individualFlags = c.options.soul || c.options.persona
|
|
23
|
+
const individualFlags = c.options.soul || c.options.persona
|
|
25
24
|
|| c.options['rules-add'] || c.options['rules-remove']
|
|
26
25
|
|
|
27
26
|
if (c.options.brain && individualFlags) {
|
|
28
27
|
throw new IncurError({
|
|
29
28
|
code: 'MUTUALLY_EXCLUSIVE',
|
|
30
|
-
message: '--brain is mutually exclusive with --soul, --persona, --
|
|
29
|
+
message: '--brain is mutually exclusive with --soul, --persona, --rules-add, --rules-remove.',
|
|
31
30
|
hint: 'Use --brain alone or individual flags, not both.',
|
|
32
31
|
})
|
|
33
32
|
}
|
|
@@ -44,7 +43,6 @@ export const shell = Cli.create('shell', {
|
|
|
44
43
|
} else {
|
|
45
44
|
if (c.options.soul) envOverrides.BRAINJAR_SOUL = c.options.soul
|
|
46
45
|
if (c.options.persona) envOverrides.BRAINJAR_PERSONA = c.options.persona
|
|
47
|
-
if (c.options.identity) envOverrides.BRAINJAR_IDENTITY = c.options.identity
|
|
48
46
|
if (c.options['rules-add']) envOverrides.BRAINJAR_RULES_ADD = c.options['rules-add']
|
|
49
47
|
if (c.options['rules-remove']) envOverrides.BRAINJAR_RULES_REMOVE = c.options['rules-remove']
|
|
50
48
|
}
|
|
@@ -53,7 +51,7 @@ export const shell = Cli.create('shell', {
|
|
|
53
51
|
throw new IncurError({
|
|
54
52
|
code: 'NO_OVERRIDES',
|
|
55
53
|
message: 'No overrides specified.',
|
|
56
|
-
hint: 'Use --brain, --soul, --persona, --
|
|
54
|
+
hint: 'Use --brain, --soul, --persona, --rules-add, or --rules-remove.',
|
|
57
55
|
})
|
|
58
56
|
}
|
|
59
57
|
|
|
@@ -66,7 +64,6 @@ export const shell = Cli.create('shell', {
|
|
|
66
64
|
const labels: string[] = []
|
|
67
65
|
if (envOverrides.BRAINJAR_SOUL) labels.push(`soul: ${envOverrides.BRAINJAR_SOUL}`)
|
|
68
66
|
if (envOverrides.BRAINJAR_PERSONA) labels.push(`persona: ${envOverrides.BRAINJAR_PERSONA}`)
|
|
69
|
-
if (envOverrides.BRAINJAR_IDENTITY) labels.push(`identity: ${envOverrides.BRAINJAR_IDENTITY}`)
|
|
70
67
|
if (envOverrides.BRAINJAR_RULES_ADD) labels.push(`+rules: ${envOverrides.BRAINJAR_RULES_ADD}`)
|
|
71
68
|
if (envOverrides.BRAINJAR_RULES_REMOVE) labels.push(`-rules: ${envOverrides.BRAINJAR_RULES_REMOVE}`)
|
|
72
69
|
|
package/src/commands/status.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Cli, z } from 'incur'
|
|
2
|
-
import { readState, readLocalState, readEnvState, mergeState,
|
|
2
|
+
import { readState, readLocalState, readEnvState, mergeState, requireBrainjarDir } from '../state.js'
|
|
3
3
|
import { sync } from '../sync.js'
|
|
4
4
|
|
|
5
5
|
export const status = Cli.create('status', {
|
|
@@ -8,7 +8,7 @@ export const status = Cli.create('status', {
|
|
|
8
8
|
sync: z.boolean().default(false).describe('Regenerate config file from active layers'),
|
|
9
9
|
global: z.boolean().default(false).describe('Show only global state'),
|
|
10
10
|
local: z.boolean().default(false).describe('Show only local overrides'),
|
|
11
|
-
short: z.boolean().default(false).describe('One-line output: soul | persona
|
|
11
|
+
short: z.boolean().default(false).describe('One-line output: soul | persona'),
|
|
12
12
|
}),
|
|
13
13
|
async run(c) {
|
|
14
14
|
await requireBrainjarDir()
|
|
@@ -19,13 +19,9 @@ export const status = Cli.create('status', {
|
|
|
19
19
|
const local = await readLocalState()
|
|
20
20
|
const env = readEnvState()
|
|
21
21
|
const effective = mergeState(global, local, env)
|
|
22
|
-
const slug = effective.identity.value
|
|
23
|
-
? (await loadIdentity(effective.identity.value).catch(() => null))?.slug ?? effective.identity.value
|
|
24
|
-
: null
|
|
25
22
|
const parts = [
|
|
26
23
|
`soul: ${effective.soul.value ?? 'none'}`,
|
|
27
24
|
`persona: ${effective.persona.value ?? 'none'}`,
|
|
28
|
-
`identity: ${slug ?? 'none'}`,
|
|
29
25
|
]
|
|
30
26
|
return parts.join(' | ')
|
|
31
27
|
}
|
|
@@ -40,20 +36,10 @@ export const status = Cli.create('status', {
|
|
|
40
36
|
// --global: show only global state (v0.1 behavior)
|
|
41
37
|
if (c.options.global) {
|
|
42
38
|
const state = await readState()
|
|
43
|
-
let identityFull: Record<string, unknown> | null = null
|
|
44
|
-
if (state.identity) {
|
|
45
|
-
try {
|
|
46
|
-
const { content: _, ...id } = await loadIdentity(state.identity)
|
|
47
|
-
identityFull = id
|
|
48
|
-
} catch {
|
|
49
|
-
identityFull = { slug: state.identity, error: 'File not found' }
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
39
|
const result: Record<string, unknown> = {
|
|
53
40
|
soul: state.soul ?? null,
|
|
54
41
|
persona: state.persona ?? null,
|
|
55
42
|
rules: state.rules,
|
|
56
|
-
identity: identityFull,
|
|
57
43
|
}
|
|
58
44
|
if (synced) result.synced = synced
|
|
59
45
|
return result
|
|
@@ -66,7 +52,6 @@ export const status = Cli.create('status', {
|
|
|
66
52
|
if ('soul' in local) result.soul = local.soul
|
|
67
53
|
if ('persona' in local) result.persona = local.persona
|
|
68
54
|
if (local.rules) result.rules = local.rules
|
|
69
|
-
if ('identity' in local) result.identity = local.identity
|
|
70
55
|
if (Object.keys(result).length === 0) result.note = 'No local overrides'
|
|
71
56
|
if (synced) result.synced = synced
|
|
72
57
|
return result
|
|
@@ -78,24 +63,12 @@ export const status = Cli.create('status', {
|
|
|
78
63
|
const env = readEnvState()
|
|
79
64
|
const effective = mergeState(global, local, env)
|
|
80
65
|
|
|
81
|
-
// Resolve identity details
|
|
82
|
-
let identityFull: Record<string, unknown> | null = null
|
|
83
|
-
if (effective.identity.value) {
|
|
84
|
-
try {
|
|
85
|
-
const { content: _, ...id } = await loadIdentity(effective.identity.value)
|
|
86
|
-
identityFull = { ...id, scope: effective.identity.scope }
|
|
87
|
-
} catch {
|
|
88
|
-
identityFull = { slug: effective.identity.value, scope: effective.identity.scope, error: 'File not found' }
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
|
|
92
66
|
// Agents and explicit --format get full structured data
|
|
93
67
|
if (c.agent || c.formatExplicit) {
|
|
94
68
|
const result: Record<string, unknown> = {
|
|
95
69
|
soul: effective.soul,
|
|
96
70
|
persona: effective.persona,
|
|
97
71
|
rules: effective.rules,
|
|
98
|
-
identity: identityFull,
|
|
99
72
|
}
|
|
100
73
|
if (synced) result.synced = synced
|
|
101
74
|
return result
|
|
@@ -104,14 +77,6 @@ export const status = Cli.create('status', {
|
|
|
104
77
|
// Humans get a compact view with scope annotations
|
|
105
78
|
const fmtScope = (scope: string) => `(${scope})`
|
|
106
79
|
|
|
107
|
-
const identityLabel = identityFull
|
|
108
|
-
? identityFull.error
|
|
109
|
-
? `${effective.identity.value} (not found)`
|
|
110
|
-
: identityFull.engine
|
|
111
|
-
? `${identityFull.slug} ${fmtScope(effective.identity.scope)} (${identityFull.engine})`
|
|
112
|
-
: `${identityFull.slug} ${fmtScope(effective.identity.scope)}`
|
|
113
|
-
: null
|
|
114
|
-
|
|
115
80
|
const rulesLabel = effective.rules.length
|
|
116
81
|
? effective.rules
|
|
117
82
|
.filter(r => !r.scope.startsWith('-'))
|
|
@@ -123,7 +88,6 @@ export const status = Cli.create('status', {
|
|
|
123
88
|
soul: effective.soul.value ? `${effective.soul.value} ${fmtScope(effective.soul.scope)}` : null,
|
|
124
89
|
persona: effective.persona.value ? `${effective.persona.value} ${fmtScope(effective.persona.scope)}` : null,
|
|
125
90
|
rules: rulesLabel,
|
|
126
|
-
identity: identityLabel,
|
|
127
91
|
}
|
|
128
92
|
if (synced) result.synced = synced
|
|
129
93
|
return result
|
package/src/paths.ts
CHANGED
|
@@ -41,8 +41,6 @@ export const paths = {
|
|
|
41
41
|
get personas() { return join(getBrainjarDir(), 'personas') },
|
|
42
42
|
get rules() { return join(getBrainjarDir(), 'rules') },
|
|
43
43
|
get brains() { return join(getBrainjarDir(), 'brains') },
|
|
44
|
-
get identities() { return join(getBrainjarDir(), 'identities') },
|
|
45
|
-
get session() { return join(getBrainjarDir(), '.session') },
|
|
46
44
|
get state() { return join(getBrainjarDir(), 'state.yaml') },
|
|
47
45
|
get localState() { return join(getLocalDir(), 'state.yaml') },
|
|
48
46
|
}
|
package/src/seeds.ts
CHANGED
package/src/state.ts
CHANGED
|
@@ -73,13 +73,12 @@ export async function listAvailableRules(): Promise<string[]> {
|
|
|
73
73
|
|
|
74
74
|
export interface State {
|
|
75
75
|
backend: string | null
|
|
76
|
-
identity: string | null
|
|
77
76
|
soul: string | null
|
|
78
77
|
persona: string | null
|
|
79
78
|
rules: string[]
|
|
80
79
|
}
|
|
81
80
|
|
|
82
|
-
const DEFAULT_STATE: State = { backend: null,
|
|
81
|
+
const DEFAULT_STATE: State = { backend: null, soul: null, persona: null, rules: [] }
|
|
83
82
|
|
|
84
83
|
export async function requireBrainjarDir(): Promise<void> {
|
|
85
84
|
try {
|
|
@@ -111,20 +110,6 @@ export function stripFrontmatter(content: string): string {
|
|
|
111
110
|
return content.replace(/\r\n/g, '\n').replace(/^---\n[\s\S]*?\n---\n*/, '').trim()
|
|
112
111
|
}
|
|
113
112
|
|
|
114
|
-
export function parseIdentity(content: string) {
|
|
115
|
-
const parsed = parseYaml(content)
|
|
116
|
-
return {
|
|
117
|
-
name: parsed?.name as string | undefined,
|
|
118
|
-
email: parsed?.email as string | undefined,
|
|
119
|
-
engine: parsed?.engine as string | undefined,
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
export async function loadIdentity(slug: string) {
|
|
124
|
-
const content = await readFile(join(paths.identities, `${slug}.yaml`), 'utf-8')
|
|
125
|
-
return { slug, content, ...parseIdentity(content) }
|
|
126
|
-
}
|
|
127
|
-
|
|
128
113
|
/** Return a valid slug or null. Prevents path traversal from state.yaml. */
|
|
129
114
|
function safeName(value: unknown): string | null {
|
|
130
115
|
if (typeof value !== 'string' || !value) return null
|
|
@@ -151,7 +136,6 @@ export async function readState(): Promise<State> {
|
|
|
151
136
|
|
|
152
137
|
return {
|
|
153
138
|
backend: ((parsed as any).backend === 'claude' || (parsed as any).backend === 'codex') ? (parsed as any).backend : null,
|
|
154
|
-
identity: safeName((parsed as any).identity),
|
|
155
139
|
soul: safeName((parsed as any).soul),
|
|
156
140
|
persona: safeName((parsed as any).persona),
|
|
157
141
|
rules: Array.isArray((parsed as any).rules)
|
|
@@ -209,7 +193,6 @@ export async function withStateLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
|
209
193
|
export async function writeState(state: State): Promise<void> {
|
|
210
194
|
const doc = {
|
|
211
195
|
backend: state.backend ?? null,
|
|
212
|
-
identity: state.identity ?? null,
|
|
213
196
|
soul: state.soul ?? null,
|
|
214
197
|
persona: state.persona ?? null,
|
|
215
198
|
rules: state.rules,
|
|
@@ -223,7 +206,6 @@ export async function writeState(state: State): Promise<void> {
|
|
|
223
206
|
|
|
224
207
|
/** Local state only stores overrides. undefined = cascade, null = explicit unset. */
|
|
225
208
|
export interface LocalState {
|
|
226
|
-
identity?: string | null
|
|
227
209
|
soul?: string | null
|
|
228
210
|
persona?: string | null
|
|
229
211
|
rules?: {
|
|
@@ -240,7 +222,6 @@ export type Scope = 'global' | 'local' | '+local' | '-local' | 'env' | '+env' |
|
|
|
240
222
|
/** Effective state after merging global + local + env, with scope annotations. */
|
|
241
223
|
export interface EffectiveState {
|
|
242
224
|
backend: string | null
|
|
243
|
-
identity: { value: string | null; scope: Scope }
|
|
244
225
|
soul: { value: string | null; scope: Scope }
|
|
245
226
|
persona: { value: string | null; scope: Scope }
|
|
246
227
|
rules: { value: string; scope: Scope }[]
|
|
@@ -268,7 +249,6 @@ export async function readLocalState(): Promise<LocalState> {
|
|
|
268
249
|
const p = parsed as Record<string, unknown>
|
|
269
250
|
|
|
270
251
|
// For each layer: if key is present, include it (even if null)
|
|
271
|
-
if ('identity' in p) result.identity = p.identity === null ? null : safeName(p.identity)
|
|
272
252
|
if ('soul' in p) result.soul = p.soul === null ? null : safeName(p.soul)
|
|
273
253
|
if ('persona' in p) result.persona = p.persona === null ? null : safeName(p.persona)
|
|
274
254
|
|
|
@@ -292,7 +272,6 @@ export async function writeLocalState(local: LocalState): Promise<void> {
|
|
|
292
272
|
|
|
293
273
|
// Build a clean doc — only include keys that are present in local
|
|
294
274
|
const doc: Record<string, unknown> = {}
|
|
295
|
-
if ('identity' in local) doc.identity = local.identity ?? null
|
|
296
275
|
if ('soul' in local) doc.soul = local.soul ?? null
|
|
297
276
|
if ('persona' in local) doc.persona = local.persona ?? null
|
|
298
277
|
if (local.rules) {
|
|
@@ -313,9 +292,6 @@ export function readEnvState(extraEnv?: Record<string, string>): EnvState {
|
|
|
313
292
|
const env = extraEnv ? { ...process.env, ...extraEnv } : process.env
|
|
314
293
|
const result: EnvState = {}
|
|
315
294
|
|
|
316
|
-
if (env.BRAINJAR_IDENTITY !== undefined) {
|
|
317
|
-
result.identity = env.BRAINJAR_IDENTITY === '' ? null : safeName(env.BRAINJAR_IDENTITY)
|
|
318
|
-
}
|
|
319
295
|
if (env.BRAINJAR_SOUL !== undefined) {
|
|
320
296
|
result.soul = env.BRAINJAR_SOUL === '' ? null : safeName(env.BRAINJAR_SOUL)
|
|
321
297
|
}
|
|
@@ -353,10 +329,6 @@ function applyOverrides(
|
|
|
353
329
|
const plusScope = `+${scope}` as Scope
|
|
354
330
|
const minusScope = `-${scope}` as Scope
|
|
355
331
|
|
|
356
|
-
const identity = 'identity' in overrides
|
|
357
|
-
? { value: overrides.identity ?? null, scope: scope as Scope }
|
|
358
|
-
: base.identity
|
|
359
|
-
|
|
360
332
|
const soul = 'soul' in overrides
|
|
361
333
|
? { value: overrides.soul ?? null, scope: scope as Scope }
|
|
362
334
|
: base.soul
|
|
@@ -395,7 +367,7 @@ function applyOverrides(
|
|
|
395
367
|
}
|
|
396
368
|
}
|
|
397
369
|
|
|
398
|
-
return { backend: base.backend,
|
|
370
|
+
return { backend: base.backend, soul, persona, rules }
|
|
399
371
|
}
|
|
400
372
|
|
|
401
373
|
/** Pure merge: global → local → env, each scope overrides the previous. */
|
|
@@ -403,7 +375,6 @@ export function mergeState(global: State, local: LocalState, env?: EnvState): Ef
|
|
|
403
375
|
// Start with global as the base effective state
|
|
404
376
|
const base: EffectiveState = {
|
|
405
377
|
backend: global.backend,
|
|
406
|
-
identity: { value: global.identity, scope: 'global' },
|
|
407
378
|
soul: { value: global.soul, scope: 'global' },
|
|
408
379
|
persona: { value: global.persona, scope: 'global' },
|
|
409
380
|
rules: global.rules.map(r => ({ value: r, scope: 'global' as Scope })),
|
package/src/sync.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile, writeFile, copyFile, mkdir
|
|
1
|
+
import { readFile, writeFile, copyFile, mkdir } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import { type Backend, getBackendConfig, paths } from './paths.js'
|
|
4
4
|
import { type State, readState, readLocalState, readEnvState, mergeState, requireBrainjarDir, stripFrontmatter, resolveRuleContent } from './state.js'
|
|
@@ -40,17 +40,6 @@ async function inlineRules(rules: string[], sections: string[], warnings: string
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
async function inlineIdentity(name: string, sections: string[]) {
|
|
44
|
-
try {
|
|
45
|
-
await access(join(paths.identities, `${name}.yaml`))
|
|
46
|
-
sections.push('')
|
|
47
|
-
sections.push('## Identity')
|
|
48
|
-
sections.push('')
|
|
49
|
-
sections.push(`See ~/.brainjar/identities/${name}.yaml for active identity.`)
|
|
50
|
-
sections.push('Manage with `brainjar identity [list|use|show]`.')
|
|
51
|
-
} catch {}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
43
|
/** Extract content before, inside, and after brainjar markers. */
|
|
55
44
|
function parseMarkers(content: string): { before: string; after: string } | null {
|
|
56
45
|
const startIdx = content.indexOf(MARKER_START)
|
|
@@ -115,21 +104,16 @@ export async function sync(options?: Backend | SyncOptions) {
|
|
|
115
104
|
// But only write rules section if local state has rules overrides
|
|
116
105
|
await inlineRules(activeRules, sections, warnings)
|
|
117
106
|
}
|
|
118
|
-
if ('identity' in localState && effective.identity.value) {
|
|
119
|
-
await inlineIdentity(effective.identity.value, sections)
|
|
120
|
-
}
|
|
121
107
|
} else {
|
|
122
108
|
// Global mode: apply env overrides on top of global state, write all layers
|
|
123
109
|
const effective = mergeState(globalState, {}, envState)
|
|
124
110
|
const effectiveSoul = effective.soul.value
|
|
125
111
|
const effectivePersona = effective.persona.value
|
|
126
112
|
const effectiveRules = effective.rules.filter(r => !r.scope.startsWith('-')).map(r => r.value)
|
|
127
|
-
const effectiveIdentity = effective.identity.value
|
|
128
113
|
|
|
129
114
|
if (effectiveSoul) await inlineSoul(effectiveSoul, sections)
|
|
130
115
|
if (effectivePersona) await inlinePersona(effectivePersona, sections)
|
|
131
116
|
await inlineRules(effectiveRules, sections, warnings)
|
|
132
|
-
if (effectiveIdentity) await inlineIdentity(effectiveIdentity, sections)
|
|
133
117
|
|
|
134
118
|
// Local Overrides note (only for global config)
|
|
135
119
|
sections.push('')
|
package/src/commands/identity.ts
DELETED
|
@@ -1,276 +0,0 @@
|
|
|
1
|
-
import { Cli, z, Errors } from 'incur'
|
|
2
|
-
import { stringify as stringifyYaml } from 'yaml'
|
|
3
|
-
|
|
4
|
-
const { IncurError } = Errors
|
|
5
|
-
import { readdir, readFile, writeFile, mkdir, rm } from 'node:fs/promises'
|
|
6
|
-
import { join, basename } from 'node:path'
|
|
7
|
-
import { paths } from '../paths.js'
|
|
8
|
-
import { readState, writeState, withStateLock, readLocalState, writeLocalState, withLocalStateLock, readEnvState, mergeState, loadIdentity, parseIdentity, requireBrainjarDir, normalizeSlug } from '../state.js'
|
|
9
|
-
import { getEngine } from '../engines/index.js'
|
|
10
|
-
import { sync } from '../sync.js'
|
|
11
|
-
|
|
12
|
-
function redactSession(status: Record<string, unknown>) {
|
|
13
|
-
const { session: _, ...safe } = status as any
|
|
14
|
-
return safe
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
async function requireActiveIdentity() {
|
|
18
|
-
await requireBrainjarDir()
|
|
19
|
-
const state = await readState()
|
|
20
|
-
if (!state.identity) {
|
|
21
|
-
throw new IncurError({
|
|
22
|
-
code: 'NO_ACTIVE_IDENTITY',
|
|
23
|
-
message: 'No active identity.',
|
|
24
|
-
hint: 'Run `brainjar identity use <slug>` to activate one.',
|
|
25
|
-
})
|
|
26
|
-
}
|
|
27
|
-
return loadIdentity(state.identity)
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function requireEngine(engineName: string | undefined) {
|
|
31
|
-
if (!engineName) {
|
|
32
|
-
throw new IncurError({
|
|
33
|
-
code: 'NO_ENGINE',
|
|
34
|
-
message: 'Active identity has no engine configured.',
|
|
35
|
-
})
|
|
36
|
-
}
|
|
37
|
-
const engine = getEngine(engineName)
|
|
38
|
-
if (!engine) {
|
|
39
|
-
throw new IncurError({
|
|
40
|
-
code: 'UNKNOWN_ENGINE',
|
|
41
|
-
message: `Unknown engine: ${engineName}`,
|
|
42
|
-
hint: 'Supported engines: bitwarden',
|
|
43
|
-
})
|
|
44
|
-
}
|
|
45
|
-
return engine
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export const identity = Cli.create('identity', {
|
|
49
|
-
description: 'Manage digital identity — one active at a time',
|
|
50
|
-
})
|
|
51
|
-
.command('create', {
|
|
52
|
-
description: 'Create a new identity',
|
|
53
|
-
args: z.object({
|
|
54
|
-
slug: z.string().describe('Identity slug (e.g. personal, work)'),
|
|
55
|
-
}),
|
|
56
|
-
options: z.object({
|
|
57
|
-
name: z.string().describe('Full display name'),
|
|
58
|
-
email: z.string().describe('Email address'),
|
|
59
|
-
engine: z.literal('bitwarden').default('bitwarden').describe('Credential engine'),
|
|
60
|
-
}),
|
|
61
|
-
async run(c) {
|
|
62
|
-
await requireBrainjarDir()
|
|
63
|
-
const slug = normalizeSlug(c.args.slug, 'identity slug')
|
|
64
|
-
await mkdir(paths.identities, { recursive: true })
|
|
65
|
-
|
|
66
|
-
const content = stringifyYaml({ name: c.options.name, email: c.options.email, engine: c.options.engine })
|
|
67
|
-
|
|
68
|
-
const filePath = join(paths.identities, `${slug}.yaml`)
|
|
69
|
-
await writeFile(filePath, content)
|
|
70
|
-
|
|
71
|
-
return {
|
|
72
|
-
created: filePath,
|
|
73
|
-
identity: { slug, name: c.options.name, email: c.options.email, engine: c.options.engine },
|
|
74
|
-
next: `Run \`brainjar identity use ${slug}\` to activate.`,
|
|
75
|
-
}
|
|
76
|
-
},
|
|
77
|
-
})
|
|
78
|
-
.command('list', {
|
|
79
|
-
description: 'List available identities',
|
|
80
|
-
async run() {
|
|
81
|
-
const entries = await readdir(paths.identities).catch(() => [])
|
|
82
|
-
const identities = []
|
|
83
|
-
|
|
84
|
-
for (const file of entries.filter(f => f.endsWith('.yaml'))) {
|
|
85
|
-
const slug = basename(file, '.yaml')
|
|
86
|
-
const content = await readFile(join(paths.identities, file), 'utf-8')
|
|
87
|
-
identities.push({ slug, ...parseIdentity(content) })
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
return { identities }
|
|
91
|
-
},
|
|
92
|
-
})
|
|
93
|
-
.command('show', {
|
|
94
|
-
description: 'Show the active identity',
|
|
95
|
-
options: z.object({
|
|
96
|
-
local: z.boolean().default(false).describe('Show local identity override (if any)'),
|
|
97
|
-
short: z.boolean().default(false).describe('Print only the active identity slug'),
|
|
98
|
-
}),
|
|
99
|
-
async run(c) {
|
|
100
|
-
if (c.options.short) {
|
|
101
|
-
const global = await readState()
|
|
102
|
-
const local = await readLocalState()
|
|
103
|
-
const env = readEnvState()
|
|
104
|
-
const effective = mergeState(global, local, env)
|
|
105
|
-
return effective.identity.value ?? 'none'
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
if (c.options.local) {
|
|
109
|
-
const local = await readLocalState()
|
|
110
|
-
if (!('identity' in local)) return { active: false, scope: 'local', note: 'No local identity override (cascades from global)' }
|
|
111
|
-
if (local.identity === null) return { active: false, scope: 'local', slug: null, note: 'Explicitly unset at local scope' }
|
|
112
|
-
try {
|
|
113
|
-
const content = await readFile(join(paths.identities, `${local.identity}.yaml`), 'utf-8')
|
|
114
|
-
return { active: true, scope: 'local', slug: local.identity, ...parseIdentity(content) }
|
|
115
|
-
} catch {
|
|
116
|
-
return { active: false, scope: 'local', slug: local.identity, error: 'File not found' }
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const global = await readState()
|
|
121
|
-
const local = await readLocalState()
|
|
122
|
-
const env = readEnvState()
|
|
123
|
-
const effective = mergeState(global, local, env)
|
|
124
|
-
if (!effective.identity.value) return { active: false }
|
|
125
|
-
try {
|
|
126
|
-
const content = await readFile(join(paths.identities, `${effective.identity.value}.yaml`), 'utf-8')
|
|
127
|
-
return { active: true, slug: effective.identity.value, scope: effective.identity.scope, ...parseIdentity(content) }
|
|
128
|
-
} catch {
|
|
129
|
-
return { active: false, slug: effective.identity.value, error: 'File not found' }
|
|
130
|
-
}
|
|
131
|
-
},
|
|
132
|
-
})
|
|
133
|
-
.command('use', {
|
|
134
|
-
description: 'Activate an identity',
|
|
135
|
-
args: z.object({
|
|
136
|
-
slug: z.string().describe('Identity slug to activate'),
|
|
137
|
-
}),
|
|
138
|
-
options: z.object({
|
|
139
|
-
local: z.boolean().default(false).describe('Write to local .claude/CLAUDE.md instead of global'),
|
|
140
|
-
}),
|
|
141
|
-
async run(c) {
|
|
142
|
-
await requireBrainjarDir()
|
|
143
|
-
const slug = normalizeSlug(c.args.slug, 'identity slug')
|
|
144
|
-
const source = join(paths.identities, `${slug}.yaml`)
|
|
145
|
-
try {
|
|
146
|
-
await readFile(source, 'utf-8')
|
|
147
|
-
} catch {
|
|
148
|
-
throw new IncurError({
|
|
149
|
-
code: 'IDENTITY_NOT_FOUND',
|
|
150
|
-
message: `Identity "${slug}" not found.`,
|
|
151
|
-
hint: 'Run `brainjar identity list` to see available identities.',
|
|
152
|
-
})
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (c.options.local) {
|
|
156
|
-
await withLocalStateLock(async () => {
|
|
157
|
-
const local = await readLocalState()
|
|
158
|
-
local.identity = slug
|
|
159
|
-
await writeLocalState(local)
|
|
160
|
-
await sync({ local: true })
|
|
161
|
-
})
|
|
162
|
-
} else {
|
|
163
|
-
await withStateLock(async () => {
|
|
164
|
-
const state = await readState()
|
|
165
|
-
state.identity = slug
|
|
166
|
-
await writeState(state)
|
|
167
|
-
await sync()
|
|
168
|
-
})
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
return { activated: slug, local: c.options.local }
|
|
172
|
-
},
|
|
173
|
-
})
|
|
174
|
-
.command('drop', {
|
|
175
|
-
description: 'Deactivate the current identity',
|
|
176
|
-
options: z.object({
|
|
177
|
-
local: z.boolean().default(false).describe('Remove local identity override or deactivate global identity'),
|
|
178
|
-
}),
|
|
179
|
-
async run(c) {
|
|
180
|
-
await requireBrainjarDir()
|
|
181
|
-
if (c.options.local) {
|
|
182
|
-
await withLocalStateLock(async () => {
|
|
183
|
-
const local = await readLocalState()
|
|
184
|
-
delete local.identity
|
|
185
|
-
await writeLocalState(local)
|
|
186
|
-
await sync({ local: true })
|
|
187
|
-
})
|
|
188
|
-
} else {
|
|
189
|
-
await withStateLock(async () => {
|
|
190
|
-
const state = await readState()
|
|
191
|
-
if (!state.identity) {
|
|
192
|
-
throw new IncurError({
|
|
193
|
-
code: 'NO_ACTIVE_IDENTITY',
|
|
194
|
-
message: 'No active identity to deactivate.',
|
|
195
|
-
})
|
|
196
|
-
}
|
|
197
|
-
state.identity = null
|
|
198
|
-
await writeState(state)
|
|
199
|
-
await sync()
|
|
200
|
-
})
|
|
201
|
-
}
|
|
202
|
-
return { deactivated: true, local: c.options.local }
|
|
203
|
-
},
|
|
204
|
-
})
|
|
205
|
-
.command('unlock', {
|
|
206
|
-
description: 'Store the credential engine session token',
|
|
207
|
-
args: z.object({
|
|
208
|
-
session: z.string().optional().describe('Session token (reads from stdin if omitted)'),
|
|
209
|
-
}),
|
|
210
|
-
async run(c) {
|
|
211
|
-
let session = c.args.session
|
|
212
|
-
if (!session) {
|
|
213
|
-
if (process.stdin.isTTY) {
|
|
214
|
-
throw new IncurError({
|
|
215
|
-
code: 'NO_SESSION',
|
|
216
|
-
message: 'No session token provided.',
|
|
217
|
-
hint: 'Pipe it in: bw unlock --raw | brainjar identity unlock',
|
|
218
|
-
})
|
|
219
|
-
}
|
|
220
|
-
let data = ''
|
|
221
|
-
for await (const chunk of process.stdin) {
|
|
222
|
-
data += typeof chunk === 'string' ? chunk : chunk.toString('utf-8')
|
|
223
|
-
}
|
|
224
|
-
session = data.trim()
|
|
225
|
-
}
|
|
226
|
-
if (!session) {
|
|
227
|
-
throw new IncurError({
|
|
228
|
-
code: 'EMPTY_SESSION',
|
|
229
|
-
message: 'Session token is empty.',
|
|
230
|
-
})
|
|
231
|
-
}
|
|
232
|
-
await writeFile(paths.session, session, { mode: 0o600 })
|
|
233
|
-
return { unlocked: true, stored: paths.session }
|
|
234
|
-
},
|
|
235
|
-
})
|
|
236
|
-
.command('get', {
|
|
237
|
-
description: 'Retrieve a credential from the active identity engine',
|
|
238
|
-
args: z.object({
|
|
239
|
-
item: z.string().describe('Item name or ID to retrieve from the vault'),
|
|
240
|
-
}),
|
|
241
|
-
async run(c) {
|
|
242
|
-
const { engine: engineName } = await requireActiveIdentity()
|
|
243
|
-
const engine = requireEngine(engineName)
|
|
244
|
-
|
|
245
|
-
const status = await engine.status()
|
|
246
|
-
if (status.state !== 'unlocked') {
|
|
247
|
-
throw new IncurError({
|
|
248
|
-
code: 'ENGINE_LOCKED',
|
|
249
|
-
message: 'Credential engine is not unlocked.',
|
|
250
|
-
hint: 'operator_action' in status ? status.operator_action : undefined,
|
|
251
|
-
retryable: true,
|
|
252
|
-
})
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
return engine.get(c.args.item, status.session)
|
|
256
|
-
},
|
|
257
|
-
})
|
|
258
|
-
.command('status', {
|
|
259
|
-
description: 'Check if the credential engine session is active',
|
|
260
|
-
async run() {
|
|
261
|
-
const { name, email, engine: engineName } = await requireActiveIdentity()
|
|
262
|
-
const engine = requireEngine(engineName)
|
|
263
|
-
const engineStatus = await engine.status()
|
|
264
|
-
return { identity: { name, email, engine: engineName }, ...redactSession(engineStatus) }
|
|
265
|
-
},
|
|
266
|
-
})
|
|
267
|
-
.command('lock', {
|
|
268
|
-
description: 'Lock the credential engine session',
|
|
269
|
-
async run() {
|
|
270
|
-
const { engine: engineName } = await requireActiveIdentity()
|
|
271
|
-
const engine = requireEngine(engineName)
|
|
272
|
-
await engine.lock()
|
|
273
|
-
await rm(paths.session, { force: true })
|
|
274
|
-
return { locked: true }
|
|
275
|
-
},
|
|
276
|
-
})
|
package/src/engines/bitwarden.ts
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import { $ } from 'bun'
|
|
2
|
-
import { readFile } from 'node:fs/promises'
|
|
3
|
-
import { paths } from '../paths.js'
|
|
4
|
-
import type { CredentialEngine, EngineStatus } from './types.js'
|
|
5
|
-
|
|
6
|
-
export async function loadSession(): Promise<string | null> {
|
|
7
|
-
// Env var takes precedence, then session file
|
|
8
|
-
if (process.env.BW_SESSION) return process.env.BW_SESSION
|
|
9
|
-
try {
|
|
10
|
-
const session = (await readFile(paths.session, 'utf-8')).trim()
|
|
11
|
-
return session || null
|
|
12
|
-
} catch {
|
|
13
|
-
return null
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/** Thin shell wrapper — extracted so tests can replace it via spyOn. */
|
|
18
|
-
export const bw = {
|
|
19
|
-
async whichBw(): Promise<void> {
|
|
20
|
-
await $`which bw`.quiet()
|
|
21
|
-
},
|
|
22
|
-
|
|
23
|
-
async status(session: string | null): Promise<any> {
|
|
24
|
-
return session
|
|
25
|
-
? $`bw status`.env({ ...process.env, BW_SESSION: session }).json()
|
|
26
|
-
: $`bw status`.json()
|
|
27
|
-
},
|
|
28
|
-
|
|
29
|
-
async getItem(item: string, session: string): Promise<any> {
|
|
30
|
-
return $`bw get item ${item}`.env({ ...process.env, BW_SESSION: session }).json()
|
|
31
|
-
},
|
|
32
|
-
|
|
33
|
-
async lock(): Promise<void> {
|
|
34
|
-
await $`bw lock`.quiet()
|
|
35
|
-
},
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export const bitwarden: CredentialEngine = {
|
|
39
|
-
name: 'bitwarden',
|
|
40
|
-
|
|
41
|
-
async status(): Promise<EngineStatus> {
|
|
42
|
-
try {
|
|
43
|
-
await bw.whichBw()
|
|
44
|
-
} catch {
|
|
45
|
-
return { state: 'not_installed', install: 'npm install -g @bitwarden/cli' }
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
try {
|
|
49
|
-
const session = await loadSession()
|
|
50
|
-
const result = await bw.status(session)
|
|
51
|
-
|
|
52
|
-
if (result.status === 'unauthenticated') {
|
|
53
|
-
return {
|
|
54
|
-
state: 'unauthenticated',
|
|
55
|
-
operator_action: `bw login ${result.userEmail ?? '<email>'}`,
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (result.status === 'unlocked' && session) {
|
|
60
|
-
return { state: 'unlocked', session }
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return {
|
|
64
|
-
state: 'locked',
|
|
65
|
-
operator_action: 'Run `bw unlock` and then `brainjar identity unlock <session>`',
|
|
66
|
-
}
|
|
67
|
-
} catch {
|
|
68
|
-
return {
|
|
69
|
-
state: 'locked',
|
|
70
|
-
operator_action: 'Could not determine vault status. Run `bw unlock` and then `brainjar identity unlock <session>`',
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
|
|
75
|
-
async get(item: string, session: string) {
|
|
76
|
-
if (!item || item.length > 256 || /[\x00-\x1f]/.test(item)) {
|
|
77
|
-
return { error: `Invalid item name: "${item}"` }
|
|
78
|
-
}
|
|
79
|
-
try {
|
|
80
|
-
const result = await bw.getItem(item, session)
|
|
81
|
-
|
|
82
|
-
if (result.login?.password) {
|
|
83
|
-
return { value: result.login.password }
|
|
84
|
-
}
|
|
85
|
-
if (result.notes) {
|
|
86
|
-
return { value: result.notes }
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
return { error: `Item "${item}" found but has no password or notes.` }
|
|
90
|
-
} catch (e) {
|
|
91
|
-
const stderr = (e as any)?.stderr?.toString?.()?.trim?.()
|
|
92
|
-
const message = stderr || (e as Error).message || 'unknown error'
|
|
93
|
-
return { error: `Could not retrieve "${item}": ${message}` }
|
|
94
|
-
}
|
|
95
|
-
},
|
|
96
|
-
|
|
97
|
-
async lock() {
|
|
98
|
-
try {
|
|
99
|
-
await bw.lock()
|
|
100
|
-
} catch (e) {
|
|
101
|
-
const stderr = (e as any)?.stderr?.toString?.()?.trim?.()
|
|
102
|
-
throw new Error(`Failed to lock vault: ${stderr || (e as Error).message}`)
|
|
103
|
-
}
|
|
104
|
-
},
|
|
105
|
-
}
|
package/src/engines/index.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import type { CredentialEngine } from './types.js'
|
|
2
|
-
import { bitwarden } from './bitwarden.js'
|
|
3
|
-
|
|
4
|
-
const engines: Record<string, CredentialEngine> = {
|
|
5
|
-
bitwarden,
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export function getEngine(name: string): CredentialEngine | null {
|
|
9
|
-
return engines[name] ?? null
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export type { CredentialEngine }
|
package/src/engines/types.ts
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
export type EngineStatus =
|
|
2
|
-
| { state: 'not_installed'; install: string }
|
|
3
|
-
| { state: 'unauthenticated'; operator_action: string }
|
|
4
|
-
| { state: 'locked'; operator_action: string }
|
|
5
|
-
| { state: 'unlocked'; session: string }
|
|
6
|
-
|
|
7
|
-
export interface CredentialEngine {
|
|
8
|
-
name: string
|
|
9
|
-
status(): Promise<EngineStatus>
|
|
10
|
-
get(item: string, session: string): Promise<{ value: string } | { error: string }>
|
|
11
|
-
lock(): Promise<void>
|
|
12
|
-
}
|