@brainjar/cli 0.2.3 → 0.4.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.
Files changed (40) hide show
  1. package/README.md +11 -10
  2. package/package.json +2 -2
  3. package/src/api-types.ts +155 -0
  4. package/src/cli.ts +5 -3
  5. package/src/client.ts +157 -0
  6. package/src/commands/brain.ts +99 -113
  7. package/src/commands/compose.ts +17 -116
  8. package/src/commands/init.ts +66 -42
  9. package/src/commands/migrate.ts +61 -0
  10. package/src/commands/pack.ts +1 -5
  11. package/src/commands/persona.ts +97 -145
  12. package/src/commands/rules.ts +71 -174
  13. package/src/commands/server.ts +212 -0
  14. package/src/commands/shell.ts +55 -51
  15. package/src/commands/soul.ts +75 -110
  16. package/src/commands/status.ts +37 -78
  17. package/src/commands/sync.ts +0 -2
  18. package/src/config.ts +125 -0
  19. package/src/daemon.ts +404 -0
  20. package/src/errors.ts +172 -0
  21. package/src/migrate.ts +247 -0
  22. package/src/pack.ts +149 -428
  23. package/src/paths.ts +1 -8
  24. package/src/seeds.ts +62 -105
  25. package/src/state.ts +12 -397
  26. package/src/sync.ts +61 -102
  27. package/src/version-check.ts +137 -0
  28. package/src/brain.ts +0 -69
  29. package/src/commands/identity.ts +0 -276
  30. package/src/engines/bitwarden.ts +0 -105
  31. package/src/engines/index.ts +0 -12
  32. package/src/engines/types.ts +0 -12
  33. package/src/hooks.test.ts +0 -132
  34. package/src/pack.test.ts +0 -472
  35. package/src/seeds/templates/persona.md +0 -19
  36. package/src/seeds/templates/rule.md +0 -11
  37. package/src/seeds/templates/soul.md +0 -20
  38. /package/src/seeds/rules/{default/boundaries.md → boundaries.md} +0 -0
  39. /package/src/seeds/rules/{default/context-recovery.md → context-recovery.md} +0 -0
  40. /package/src/seeds/rules/{default/task-completion.md → task-completion.md} +0 -0
@@ -1,102 +1,107 @@
1
1
  import { Cli, z, Errors } from 'incur'
2
+ import { randomUUID } from 'node:crypto'
2
3
 
3
4
  const { IncurError } = Errors
5
+ import { ErrorCode, createError } from '../errors.js'
4
6
  import { spawn } from 'node:child_process'
5
- import { access } from 'node:fs/promises'
6
- import { requireBrainjarDir } from '../state.js'
7
+ import { putState } from '../state.js'
7
8
  import { sync } from '../sync.js'
8
- import { getLocalDir } from '../paths.js'
9
- import { readBrain } from '../brain.js'
9
+ import { getApi } from '../client.js'
10
+ import type { ApiBrain } from '../api-types.js'
10
11
 
11
12
  export const shell = Cli.create('shell', {
12
- description: 'Spawn a subshell with BRAINJAR_* env vars set',
13
+ description: 'Spawn a subshell with session-scoped state overrides',
13
14
  options: z.object({
14
- brain: z.string().optional().describe('Brain name — sets soul, persona, and rules from brain file'),
15
+ brain: z.string().optional().describe('Brain name — sets soul, persona, and rules from brain'),
15
16
  soul: z.string().optional().describe('Soul override for this session'),
16
17
  persona: z.string().optional().describe('Persona override for this session'),
17
- identity: z.string().optional().describe('Identity override for this session'),
18
18
  'rules-add': z.string().optional().describe('Comma-separated rules to add'),
19
19
  'rules-remove': z.string().optional().describe('Comma-separated rules to remove'),
20
20
  }),
21
21
  async run(c) {
22
- await requireBrainjarDir()
23
-
24
- const individualFlags = c.options.soul || c.options.persona || c.options.identity
22
+ const individualFlags = c.options.soul || c.options.persona
25
23
  || c.options['rules-add'] || c.options['rules-remove']
26
24
 
27
25
  if (c.options.brain && individualFlags) {
28
- throw new IncurError({
29
- code: 'MUTUALLY_EXCLUSIVE',
30
- message: '--brain is mutually exclusive with --soul, --persona, --identity, --rules-add, --rules-remove.',
26
+ throw createError(ErrorCode.MUTUALLY_EXCLUSIVE, {
27
+ message: '--brain is mutually exclusive with --soul, --persona, --rules-add, --rules-remove.',
31
28
  hint: 'Use --brain alone or individual flags, not both.',
32
29
  })
33
30
  }
34
31
 
35
- const envOverrides: Record<string, string> = {}
32
+ if (!c.options.brain && !individualFlags) {
33
+ throw createError(ErrorCode.NO_OVERRIDES, {
34
+ message: 'No overrides specified.',
35
+ })
36
+ }
37
+
38
+ // Create a unique session ID
39
+ const sessionId = randomUUID()
40
+ const api = await getApi({ session: sessionId })
41
+
42
+ // Build session-scoped state mutation
43
+ const mutation: Record<string, unknown> = {}
44
+ const labels: string[] = []
36
45
 
37
46
  if (c.options.brain) {
38
- const config = await readBrain(c.options.brain)
39
- envOverrides.BRAINJAR_SOUL = config.soul
40
- envOverrides.BRAINJAR_PERSONA = config.persona
41
- if (config.rules.length > 0) {
42
- envOverrides.BRAINJAR_RULES_ADD = config.rules.join(',')
43
- }
47
+ const config = await api.get<ApiBrain>(`/api/v1/brains/${c.options.brain}`)
48
+ mutation.soul_slug = config.soul_slug
49
+ mutation.persona_slug = config.persona_slug
50
+ mutation.rule_slugs = config.rule_slugs
51
+ labels.push(`brain: ${c.options.brain}`)
44
52
  } else {
45
- if (c.options.soul) envOverrides.BRAINJAR_SOUL = c.options.soul
46
- if (c.options.persona) envOverrides.BRAINJAR_PERSONA = c.options.persona
47
- if (c.options.identity) envOverrides.BRAINJAR_IDENTITY = c.options.identity
48
- if (c.options['rules-add']) envOverrides.BRAINJAR_RULES_ADD = c.options['rules-add']
49
- if (c.options['rules-remove']) envOverrides.BRAINJAR_RULES_REMOVE = c.options['rules-remove']
53
+ if (c.options.soul) {
54
+ mutation.soul_slug = c.options.soul
55
+ labels.push(`soul: ${c.options.soul}`)
56
+ }
57
+ if (c.options.persona) {
58
+ mutation.persona_slug = c.options.persona
59
+ labels.push(`persona: ${c.options.persona}`)
60
+ }
61
+ if (c.options['rules-add']) {
62
+ mutation.rules_to_add = c.options['rules-add'].split(',').map(s => s.trim())
63
+ labels.push(`+rules: ${c.options['rules-add']}`)
64
+ }
65
+ if (c.options['rules-remove']) {
66
+ mutation.rules_to_remove = c.options['rules-remove'].split(',').map(s => s.trim())
67
+ labels.push(`-rules: ${c.options['rules-remove']}`)
68
+ }
50
69
  }
51
70
 
52
- if (Object.keys(envOverrides).length === 0) {
53
- throw new IncurError({
54
- code: 'NO_OVERRIDES',
55
- message: 'No overrides specified.',
56
- hint: 'Use --brain, --soul, --persona, --identity, --rules-add, or --rules-remove.',
57
- })
58
- }
71
+ // Apply session-scoped state on server
72
+ await putState(api, mutation)
59
73
 
60
- // Sync with env overrides passed explicitly (no process.env mutation)
61
- const hasLocal = await access(getLocalDir()).then(() => true, () => false)
62
- await sync({ envOverrides })
63
- if (hasLocal) await sync({ local: true, envOverrides })
74
+ // Sync CLAUDE.md with session state
75
+ await sync({ api })
64
76
 
65
77
  // Print active config banner
66
- const labels: string[] = []
67
- if (envOverrides.BRAINJAR_SOUL) labels.push(`soul: ${envOverrides.BRAINJAR_SOUL}`)
68
- if (envOverrides.BRAINJAR_PERSONA) labels.push(`persona: ${envOverrides.BRAINJAR_PERSONA}`)
69
- if (envOverrides.BRAINJAR_IDENTITY) labels.push(`identity: ${envOverrides.BRAINJAR_IDENTITY}`)
70
- if (envOverrides.BRAINJAR_RULES_ADD) labels.push(`+rules: ${envOverrides.BRAINJAR_RULES_ADD}`)
71
- if (envOverrides.BRAINJAR_RULES_REMOVE) labels.push(`-rules: ${envOverrides.BRAINJAR_RULES_REMOVE}`)
72
-
73
78
  if (!c.agent) {
74
79
  const banner = `[brainjar] ${labels.join(' | ')}`
75
80
  process.stderr.write(`${banner}\n`)
76
81
  process.stderr.write(`${'─'.repeat(banner.length)}\n`)
77
82
  }
78
83
 
79
- // Spawn subshell with overrides
84
+ // Spawn subshell with session ID so nested brainjar commands use the session
80
85
  const userShell = process.env.SHELL || '/bin/sh'
81
86
  const child = spawn(userShell, [], {
82
87
  stdio: 'inherit',
83
- env: { ...process.env, ...envOverrides },
88
+ env: { ...process.env, BRAINJAR_SESSION: sessionId },
84
89
  })
85
90
 
86
91
  return new Promise((resolve, reject) => {
87
92
  child.on('exit', async (code) => {
88
- // Re-sync without env overrides to restore config
93
+ // Clear session state and re-sync to restore
89
94
  let syncWarning: string | undefined
90
95
  try {
91
- await sync()
92
- if (hasLocal) await sync({ local: true })
96
+ const cleanApi = await getApi()
97
+ await sync({ api: cleanApi })
93
98
  } catch (err) {
94
99
  syncWarning = `Re-sync on exit failed: ${(err as Error).message}`
95
100
  }
96
101
 
97
102
  const result = {
98
103
  shell: userShell,
99
- env: envOverrides,
104
+ session: sessionId,
100
105
  exitCode: code ?? 0,
101
106
  ...(syncWarning ? { warning: syncWarning } : {}),
102
107
  }
@@ -109,8 +114,7 @@ export const shell = Cli.create('shell', {
109
114
  }
110
115
  })
111
116
  child.on('error', (err) => {
112
- reject(new IncurError({
113
- code: 'SHELL_ERROR',
117
+ reject(createError(ErrorCode.SHELL_ERROR, {
114
118
  message: `Failed to spawn shell: ${err.message}`,
115
119
  }))
116
120
  })
@@ -1,11 +1,12 @@
1
1
  import { Cli, z, Errors } from 'incur'
2
+ import { basename } from 'node:path'
2
3
 
3
4
  const { IncurError } = Errors
4
- import { readdir, readFile, writeFile, access } from 'node:fs/promises'
5
- import { join, basename } from 'node:path'
6
- import { paths } from '../paths.js'
7
- import { readState, writeState, withStateLock, readLocalState, writeLocalState, withLocalStateLock, readEnvState, mergeState, requireBrainjarDir, stripFrontmatter, normalizeSlug } from '../state.js'
5
+ import { ErrorCode, createError } from '../errors.js'
6
+ import { normalizeSlug, getEffectiveState, putState } from '../state.js'
8
7
  import { sync } from '../sync.js'
8
+ import { getApi } from '../client.js'
9
+ import type { ApiSoul, ApiSoulList } from '../api-types.js'
9
10
 
10
11
  export const soul = Cli.create('soul', {
11
12
  description: 'Manage soul — personality and values for the agent',
@@ -13,25 +14,22 @@ export const soul = Cli.create('soul', {
13
14
  .command('create', {
14
15
  description: 'Create a new soul',
15
16
  args: z.object({
16
- name: z.string().describe('Soul name (will be used as filename)'),
17
+ name: z.string().describe('Soul name'),
17
18
  }),
18
19
  options: z.object({
19
20
  description: z.string().optional().describe('One-line description of the soul'),
20
21
  }),
21
22
  async run(c) {
22
- await requireBrainjarDir()
23
23
  const name = normalizeSlug(c.args.name, 'soul name')
24
- const dest = join(paths.souls, `${name}.md`)
24
+ const api = await getApi()
25
25
 
26
+ // Check if it already exists
26
27
  try {
27
- await access(dest)
28
- throw new IncurError({
29
- code: 'SOUL_EXISTS',
30
- message: `Soul "${name}" already exists.`,
31
- hint: 'Choose a different name or edit the existing file.',
32
- })
28
+ await api.get<ApiSoul>(`/api/v1/souls/${name}`)
29
+ throw createError(ErrorCode.SOUL_EXISTS, { params: [name] })
33
30
  } catch (e) {
34
- if (e instanceof IncurError) throw e
31
+ if (e instanceof IncurError && e.code === ErrorCode.SOUL_EXISTS) throw e
32
+ if (e instanceof IncurError && e.code !== ErrorCode.NOT_FOUND) throw e
35
33
  }
36
34
 
37
35
  const lines: string[] = []
@@ -43,27 +41,26 @@ export const soul = Cli.create('soul', {
43
41
  }
44
42
 
45
43
  const content = lines.join('\n')
46
- await writeFile(dest, content)
44
+ await api.put<ApiSoul>(`/api/v1/souls/${name}`, { content })
47
45
 
48
46
  if (c.agent || c.formatExplicit) {
49
- return { created: dest, name, template: content }
47
+ return { created: name, name, template: content }
50
48
  }
51
49
 
52
50
  return {
53
- created: dest,
51
+ created: name,
54
52
  name,
55
53
  template: `\n${content}`,
56
- next: `Edit ${dest} to flesh out your soul, then run \`brainjar soul use ${name}\` to activate.`,
54
+ next: `Run \`brainjar soul show ${name}\` to view, then \`brainjar soul use ${name}\` to activate.`,
57
55
  }
58
56
  },
59
57
  })
60
58
  .command('list', {
61
59
  description: 'List available souls',
62
60
  async run() {
63
- await requireBrainjarDir()
64
- const entries = await readdir(paths.souls).catch(() => [])
65
- const souls = entries.filter(f => f.endsWith('.md')).map(f => basename(f, '.md'))
66
- return { souls }
61
+ const api = await getApi()
62
+ const result = await api.get<ApiSoulList>('/api/v1/souls')
63
+ return { souls: result.souls.map(s => s.slug) }
67
64
  },
68
65
  })
69
66
  .command('show', {
@@ -72,136 +69,104 @@ export const soul = Cli.create('soul', {
72
69
  name: z.string().optional().describe('Soul name to show (defaults to active soul)'),
73
70
  }),
74
71
  options: z.object({
75
- local: z.boolean().default(false).describe('Show local soul override (if any)'),
72
+ project: z.boolean().default(false).describe('Show project soul override (if any)'),
76
73
  short: z.boolean().default(false).describe('Print only the active soul name'),
77
74
  }),
78
75
  async run(c) {
79
- await requireBrainjarDir()
76
+ const api = await getApi()
80
77
 
81
78
  if (c.options.short) {
82
79
  if (c.args.name) return c.args.name
83
- const global = await readState()
84
- const local = await readLocalState()
85
- const env = readEnvState()
86
- const effective = mergeState(global, local, env)
87
- return effective.soul.value ?? 'none'
80
+ const state = await getEffectiveState(api)
81
+ return state.soul ?? 'none'
88
82
  }
89
83
 
90
- // If a specific name was given, show that soul directly
91
84
  if (c.args.name) {
92
85
  const name = normalizeSlug(c.args.name, 'soul name')
93
86
  try {
94
- const raw = await readFile(join(paths.souls, `${name}.md`), 'utf-8')
95
- const content = stripFrontmatter(raw)
96
- const title = content.split('\n').find(l => l.startsWith('# '))?.replace('# ', '') ?? null
97
- return { name, title, content }
98
- } catch {
99
- throw new IncurError({
100
- code: 'SOUL_NOT_FOUND',
101
- message: `Soul "${name}" not found.`,
102
- hint: 'Run `brainjar soul list` to see available souls.',
103
- })
87
+ const soul = await api.get<ApiSoul>(`/api/v1/souls/${name}`)
88
+ return { name, title: soul.title, content: soul.content }
89
+ } catch (e) {
90
+ if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
91
+ throw createError(ErrorCode.SOUL_NOT_FOUND, { params: [name] })
92
+ }
93
+ throw e
104
94
  }
105
95
  }
106
96
 
107
- if (c.options.local) {
108
- const local = await readLocalState()
109
- if (!('soul' in local)) return { active: false, scope: 'local', note: 'No local soul override (cascades from global)' }
110
- if (local.soul === null) return { active: false, scope: 'local', name: null, note: 'Explicitly unset at local scope' }
97
+ if (c.options.project) {
98
+ const state = await api.get<import('../api-types.js').ApiStateOverride>('/api/v1/state/override', {
99
+ project: basename(process.cwd()),
100
+ })
101
+ if (state.soul_slug === undefined) return { active: false, scope: 'project', note: 'No project soul override (cascades from workspace)' }
102
+ if (state.soul_slug === null) return { active: false, scope: 'project', name: null, note: 'Explicitly unset at project scope' }
111
103
  try {
112
- const raw = await readFile(join(paths.souls, `${local.soul}.md`), 'utf-8')
113
- const content = stripFrontmatter(raw)
114
- const title = content.split('\n').find(l => l.startsWith('# '))?.replace('# ', '') ?? null
115
- return { active: true, scope: 'local', name: local.soul, title, content }
104
+ const soul = await api.get<ApiSoul>(`/api/v1/souls/${state.soul_slug}`)
105
+ return { active: true, scope: 'project', name: state.soul_slug, title: soul.title, content: soul.content }
116
106
  } catch {
117
- return { active: false, scope: 'local', name: local.soul, error: 'File not found' }
107
+ return { active: false, scope: 'project', name: state.soul_slug, error: 'Not found on server' }
118
108
  }
119
109
  }
120
110
 
121
- const global = await readState()
122
- const local = await readLocalState()
123
- const env = readEnvState()
124
- const effective = mergeState(global, local, env)
125
- if (!effective.soul.value) return { active: false }
111
+ const state = await getEffectiveState(api)
112
+ if (!state.soul) return { active: false }
126
113
  try {
127
- const raw = await readFile(join(paths.souls, `${effective.soul.value}.md`), 'utf-8')
128
- const content = stripFrontmatter(raw)
129
- const title = content.split('\n').find(l => l.startsWith('# '))?.replace('# ', '') ?? null
130
- return { active: true, name: effective.soul.value, scope: effective.soul.scope, title, content }
114
+ const soul = await api.get<ApiSoul>(`/api/v1/souls/${state.soul}`)
115
+ return { active: true, name: state.soul, title: soul.title, content: soul.content }
131
116
  } catch {
132
- return { active: false, name: effective.soul.value, error: 'File not found' }
117
+ return { active: false, name: state.soul, error: 'Not found on server' }
133
118
  }
134
119
  },
135
120
  })
136
121
  .command('use', {
137
122
  description: 'Activate a soul',
138
123
  args: z.object({
139
- name: z.string().describe('Soul name (filename without .md in ~/.brainjar/souls/)'),
124
+ name: z.string().describe('Soul name'),
140
125
  }),
141
126
  options: z.object({
142
- local: z.boolean().default(false).describe('Write to local .claude/CLAUDE.md instead of global'),
127
+ project: z.boolean().default(false).describe('Apply at project scope'),
143
128
  }),
144
129
  async run(c) {
145
- await requireBrainjarDir()
146
130
  const name = normalizeSlug(c.args.name, 'soul name')
147
- const source = join(paths.souls, `${name}.md`)
131
+ const api = await getApi()
132
+
133
+ // Validate it exists on server
148
134
  try {
149
- await readFile(source, 'utf-8')
150
- } catch {
151
- throw new IncurError({
152
- code: 'SOUL_NOT_FOUND',
153
- message: `Soul "${name}" not found.`,
154
- hint: 'Run `brainjar soul list` to see available souls.',
155
- })
135
+ await api.get<ApiSoul>(`/api/v1/souls/${name}`)
136
+ } catch (e) {
137
+ if (e instanceof IncurError && e.code === ErrorCode.NOT_FOUND) {
138
+ throw createError(ErrorCode.SOUL_NOT_FOUND, { params: [name] })
139
+ }
140
+ throw e
156
141
  }
157
142
 
158
- if (c.options.local) {
159
- await withLocalStateLock(async () => {
160
- const local = await readLocalState()
161
- local.soul = name
162
- await writeLocalState(local)
163
- await sync({ local: true })
164
- })
165
- } else {
166
- await withStateLock(async () => {
167
- const state = await readState()
168
- state.soul = name
169
- await writeState(state)
170
- await sync()
171
- })
172
- }
143
+ const mutationOpts = c.options.project
144
+ ? { project: basename(process.cwd()) }
145
+ : undefined
146
+ await putState(api, { soul_slug: name }, mutationOpts)
147
+
148
+ await sync({ api })
149
+ if (c.options.project) await sync({ api, project: true })
173
150
 
174
- return { activated: name, local: c.options.local }
151
+ return { activated: name, project: c.options.project }
175
152
  },
176
153
  })
177
154
  .command('drop', {
178
155
  description: 'Deactivate the current soul',
179
156
  options: z.object({
180
- local: z.boolean().default(false).describe('Remove local soul override or deactivate global soul'),
157
+ project: z.boolean().default(false).describe('Remove project soul override or deactivate workspace soul'),
181
158
  }),
182
159
  async run(c) {
183
- await requireBrainjarDir()
184
- if (c.options.local) {
185
- await withLocalStateLock(async () => {
186
- const local = await readLocalState()
187
- delete local.soul
188
- await writeLocalState(local)
189
- await sync({ local: true })
190
- })
191
- } else {
192
- await withStateLock(async () => {
193
- const state = await readState()
194
- if (!state.soul) {
195
- throw new IncurError({
196
- code: 'NO_ACTIVE_SOUL',
197
- message: 'No active soul to deactivate.',
198
- })
199
- }
200
- state.soul = null
201
- await writeState(state)
202
- await sync()
203
- })
204
- }
205
- return { deactivated: true, local: c.options.local }
160
+ const api = await getApi()
161
+
162
+ const mutationOpts = c.options.project
163
+ ? { project: basename(process.cwd()) }
164
+ : undefined
165
+ await putState(api, { soul_slug: null }, mutationOpts)
166
+
167
+ await sync({ api })
168
+ if (c.options.project) await sync({ api, project: true })
169
+
170
+ return { deactivated: true, project: c.options.project }
206
171
  },
207
172
  })
@@ -1,31 +1,27 @@
1
1
  import { Cli, z } from 'incur'
2
- import { readState, readLocalState, readEnvState, mergeState, loadIdentity, requireBrainjarDir } from '../state.js'
2
+ import { basename } from 'node:path'
3
+ import { getEffectiveState } from '../state.js'
3
4
  import { sync } from '../sync.js'
5
+ import { getApi } from '../client.js'
6
+ import type { ApiStateOverride } from '../api-types.js'
4
7
 
5
8
  export const status = Cli.create('status', {
6
9
  description: 'Show active brain configuration',
7
10
  options: z.object({
8
11
  sync: z.boolean().default(false).describe('Regenerate config file from active layers'),
9
- global: z.boolean().default(false).describe('Show only global state'),
10
- local: z.boolean().default(false).describe('Show only local overrides'),
11
- short: z.boolean().default(false).describe('One-line output: soul | persona | identity'),
12
+ workspace: z.boolean().default(false).describe('Show only workspace state'),
13
+ project: z.boolean().default(false).describe('Show only project overrides'),
14
+ short: z.boolean().default(false).describe('One-line output: soul | persona'),
12
15
  }),
13
16
  async run(c) {
14
- await requireBrainjarDir()
17
+ const api = await getApi()
15
18
 
16
19
  // --short: compact one-liner for scripts/statuslines
17
20
  if (c.options.short) {
18
- const global = await readState()
19
- const local = await readLocalState()
20
- const env = readEnvState()
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
21
+ const state = await getEffectiveState(api)
25
22
  const parts = [
26
- `soul: ${effective.soul.value ?? 'none'}`,
27
- `persona: ${effective.persona.value ?? 'none'}`,
28
- `identity: ${slug ?? 'none'}`,
23
+ `soul: ${state.soul ?? 'none'}`,
24
+ `persona: ${state.persona ?? 'none'}`,
29
25
  ]
30
26
  return parts.join(' | ')
31
27
  }
@@ -33,97 +29,60 @@ export const status = Cli.create('status', {
33
29
  // Sync if requested
34
30
  let synced: Record<string, unknown> | undefined
35
31
  if (c.options.sync) {
36
- const syncResult = await sync()
32
+ const syncResult = await sync({ api })
37
33
  synced = { written: syncResult.written, warnings: syncResult.warnings }
38
34
  }
39
35
 
40
- // --global: show only global state (v0.1 behavior)
41
- if (c.options.global) {
42
- 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
- }
36
+ // --workspace: show only workspace-level override
37
+ if (c.options.workspace) {
38
+ const override = await api.get<ApiStateOverride>('/api/v1/state/override')
52
39
  const result: Record<string, unknown> = {
53
- soul: state.soul ?? null,
54
- persona: state.persona ?? null,
55
- rules: state.rules,
56
- identity: identityFull,
40
+ soul: override.soul_slug ?? null,
41
+ persona: override.persona_slug ?? null,
42
+ rules: override.rule_slugs ?? [],
57
43
  }
58
44
  if (synced) result.synced = synced
59
45
  return result
60
46
  }
61
47
 
62
- // --local: show only local overrides
63
- if (c.options.local) {
64
- const local = await readLocalState()
48
+ // --project: show only project-level overrides
49
+ if (c.options.project) {
50
+ const override = await api.get<ApiStateOverride>('/api/v1/state/override', {
51
+ project: basename(process.cwd()),
52
+ })
65
53
  const result: Record<string, unknown> = {}
66
- if ('soul' in local) result.soul = local.soul
67
- if ('persona' in local) result.persona = local.persona
68
- if (local.rules) result.rules = local.rules
69
- if ('identity' in local) result.identity = local.identity
70
- if (Object.keys(result).length === 0) result.note = 'No local overrides'
54
+ if (override.soul_slug !== undefined) result.soul = override.soul_slug
55
+ if (override.persona_slug !== undefined) result.persona = override.persona_slug
56
+ if (override.rules_to_add?.length) result.rules_to_add = override.rules_to_add
57
+ if (override.rules_to_remove?.length) result.rules_to_remove = override.rules_to_remove
58
+ if (Object.keys(result).length === 0) result.note = 'No project overrides'
71
59
  if (synced) result.synced = synced
72
60
  return result
73
61
  }
74
62
 
75
63
  // Default: effective state with scope annotations
76
- const global = await readState()
77
- const local = await readLocalState()
78
- const env = readEnvState()
79
- const effective = mergeState(global, local, env)
80
-
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
- }
64
+ const state = await getEffectiveState(api)
91
65
 
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
- soul: effective.soul,
96
- persona: effective.persona,
97
- rules: effective.rules,
98
- identity: identityFull,
69
+ soul: state.soul,
70
+ persona: state.persona,
71
+ rules: state.rules,
99
72
  }
100
73
  if (synced) result.synced = synced
101
74
  return result
102
75
  }
103
76
 
104
- // Humans get a compact view with scope annotations
105
- const fmtScope = (scope: string) => `(${scope})`
106
-
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
- const rulesLabel = effective.rules.length
116
- ? effective.rules
117
- .filter(r => !r.scope.startsWith('-'))
118
- .map(r => `${r.value} ${fmtScope(r.scope)}`)
119
- .join(', ')
77
+ // Humans get a compact view
78
+ const rulesLabel = state.rules.length
79
+ ? state.rules.join(', ')
120
80
  : null
121
81
 
122
82
  const result: Record<string, unknown> = {
123
- soul: effective.soul.value ? `${effective.soul.value} ${fmtScope(effective.soul.scope)}` : null,
124
- persona: effective.persona.value ? `${effective.persona.value} ${fmtScope(effective.persona.scope)}` : null,
83
+ soul: state.soul ?? null,
84
+ persona: state.persona ?? null,
125
85
  rules: rulesLabel,
126
- identity: identityLabel,
127
86
  }
128
87
  if (synced) result.synced = synced
129
88
  return result
@@ -1,6 +1,5 @@
1
1
  import { Cli, z } from 'incur'
2
2
  import { sync as runSync } from '../sync.js'
3
- import { requireBrainjarDir } from '../state.js'
4
3
 
5
4
  export const sync = Cli.create('sync', {
6
5
  description: 'Regenerate config file from active layers',
@@ -8,7 +7,6 @@ export const sync = Cli.create('sync', {
8
7
  quiet: z.boolean().default(false).describe('Suppress output (for use in hooks)'),
9
8
  }),
10
9
  async run(c) {
11
- await requireBrainjarDir()
12
10
  const result = await runSync()
13
11
  if (c.options.quiet) return
14
12
  return result