@onebrain-ai/cli 2.0.1 → 2.0.3
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 +48 -0
- package/dist/onebrain +3 -3
- package/package.json +23 -1
- package/src/commands/doctor.test.ts +0 -416
- package/src/commands/doctor.ts +0 -203
- package/src/commands/init.test.ts +0 -318
- package/src/commands/init.ts +0 -477
- package/src/commands/update.test.ts +0 -413
- package/src/commands/update.ts +0 -353
- package/src/index.ts +0 -144
- package/src/internal/__snapshots__/checkpoint.test.ts.snap +0 -12
- package/src/internal/__snapshots__/orphan-scan.test.ts.snap +0 -13
- package/src/internal/__snapshots__/session-init.test.ts.snap +0 -15
- package/src/internal/checkpoint.test.ts +0 -741
- package/src/internal/checkpoint.ts +0 -427
- package/src/internal/migrate.test.ts +0 -301
- package/src/internal/migrate.ts +0 -186
- package/src/internal/orphan-scan.test.ts +0 -271
- package/src/internal/orphan-scan.ts +0 -213
- package/src/internal/qmd-reindex.test.ts +0 -117
- package/src/internal/qmd-reindex.ts +0 -44
- package/src/internal/register-hooks.test.ts +0 -343
- package/src/internal/register-hooks.ts +0 -418
- package/src/internal/session-init.test.ts +0 -318
- package/src/internal/session-init.ts +0 -264
- package/src/internal/vault-sync.test.ts +0 -419
- package/src/internal/vault-sync.ts +0 -764
- package/tests/integration/init.integration.test.ts +0 -304
- package/tests/integration/update.integration.test.ts +0 -306
- package/tsconfig.json +0 -12
|
@@ -1,418 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* register-hooks — internal command
|
|
3
|
-
*
|
|
4
|
-
* Idempotently registers OneBrain hooks, PATH, and permissions in
|
|
5
|
-
* .claude/settings.json (claude-code harness) or equivalent for other harnesses.
|
|
6
|
-
*
|
|
7
|
-
* Exit code: 0 on success, 1 on failure.
|
|
8
|
-
* TTY: uses @clack/prompts layout
|
|
9
|
-
* Non-TTY: plain text prefixed with "register-hooks:"
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
13
|
-
import { homedir } from 'node:os';
|
|
14
|
-
import { dirname, join } from 'node:path';
|
|
15
|
-
import { intro, log, outro, spinner } from '@clack/prompts';
|
|
16
|
-
import { parse as parseYaml } from 'yaml';
|
|
17
|
-
|
|
18
|
-
// ---------------------------------------------------------------------------
|
|
19
|
-
// Types
|
|
20
|
-
// ---------------------------------------------------------------------------
|
|
21
|
-
|
|
22
|
-
interface HookEntry {
|
|
23
|
-
type?: string;
|
|
24
|
-
command?: string;
|
|
25
|
-
[key: string]: unknown;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
interface HookGroup {
|
|
29
|
-
matcher?: string;
|
|
30
|
-
hooks?: HookEntry[];
|
|
31
|
-
[key: string]: unknown;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
type HooksMap = Record<string, HookGroup[]>;
|
|
35
|
-
|
|
36
|
-
interface SettingsJson {
|
|
37
|
-
permissions?: {
|
|
38
|
-
allow?: string[];
|
|
39
|
-
[key: string]: unknown;
|
|
40
|
-
};
|
|
41
|
-
hooks?: HooksMap;
|
|
42
|
-
env?: {
|
|
43
|
-
PATH?: string;
|
|
44
|
-
[key: string]: unknown;
|
|
45
|
-
};
|
|
46
|
-
[key: string]: unknown;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// ---------------------------------------------------------------------------
|
|
50
|
-
// Constants
|
|
51
|
-
// ---------------------------------------------------------------------------
|
|
52
|
-
|
|
53
|
-
const HOOK_COMMANDS: Record<string, string> = {
|
|
54
|
-
Stop: 'onebrain checkpoint stop',
|
|
55
|
-
PreCompact: 'onebrain checkpoint precompact',
|
|
56
|
-
PostCompact: 'onebrain checkpoint postcompact',
|
|
57
|
-
SessionStart: 'onebrain session-init',
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const HOOK_EVENTS = ['Stop', 'PreCompact', 'PostCompact', 'SessionStart'] as const;
|
|
61
|
-
|
|
62
|
-
const PERMISSIONS_TO_ADD = [
|
|
63
|
-
'Bash(onebrain:*)',
|
|
64
|
-
'Bash(bun install -g @onebrain-ai/cli:*)',
|
|
65
|
-
'Bash(npm install -g @onebrain-ai/cli:*)',
|
|
66
|
-
];
|
|
67
|
-
|
|
68
|
-
const BUN_BIN = join(homedir(), '.bun', 'bin');
|
|
69
|
-
const NPM_GLOBAL_BIN = join(homedir(), '.npm-global', 'bin');
|
|
70
|
-
|
|
71
|
-
const ONEBRAIN_MARKER = '# onebrain';
|
|
72
|
-
const PATH_EXPORT = 'export PATH="$HOME/.bun/bin:$HOME/.npm-global/bin:$PATH"';
|
|
73
|
-
|
|
74
|
-
// ---------------------------------------------------------------------------
|
|
75
|
-
// Helpers: settings.json read/write
|
|
76
|
-
// ---------------------------------------------------------------------------
|
|
77
|
-
|
|
78
|
-
async function readSettings(settingsPath: string): Promise<SettingsJson> {
|
|
79
|
-
try {
|
|
80
|
-
const text = await readFile(settingsPath, 'utf8');
|
|
81
|
-
return JSON.parse(text) as SettingsJson;
|
|
82
|
-
} catch (err: unknown) {
|
|
83
|
-
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return {};
|
|
84
|
-
throw err;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
async function writeSettings(settingsPath: string, settings: SettingsJson): Promise<void> {
|
|
89
|
-
await mkdir(dirname(settingsPath), { recursive: true });
|
|
90
|
-
const tmpPath = `${settingsPath}.tmp`;
|
|
91
|
-
await writeFile(tmpPath, JSON.stringify(settings, null, 4), 'utf8');
|
|
92
|
-
await rename(tmpPath, settingsPath);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
|
-
// Step 1: Register hooks (idempotent, with checkpoint-hook.sh migration)
|
|
97
|
-
// ---------------------------------------------------------------------------
|
|
98
|
-
|
|
99
|
-
type HookStatus = 'added' | 'migrated' | 'ok';
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Check whether a command is already registered under an event.
|
|
103
|
-
*/
|
|
104
|
-
function checkHookPresence(
|
|
105
|
-
groups: HookGroup[],
|
|
106
|
-
targetCmd: string,
|
|
107
|
-
): 'found' | 'migrate' | 'missing' {
|
|
108
|
-
let foundMigrate = false;
|
|
109
|
-
for (const group of groups) {
|
|
110
|
-
for (const entry of group.hooks ?? []) {
|
|
111
|
-
const cmd = entry.command ?? '';
|
|
112
|
-
if (cmd === targetCmd) return 'found';
|
|
113
|
-
if (cmd.includes('checkpoint-hook.sh')) foundMigrate = true;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return foundMigrate ? 'migrate' : 'missing';
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function applyHooks(settings: SettingsJson): Record<string, HookStatus> {
|
|
120
|
-
if (!settings.hooks) settings.hooks = {};
|
|
121
|
-
const hooks = settings.hooks;
|
|
122
|
-
const result: Record<string, HookStatus> = {};
|
|
123
|
-
|
|
124
|
-
for (const event of HOOK_EVENTS) {
|
|
125
|
-
const cmd = HOOK_COMMANDS[event];
|
|
126
|
-
if (!hooks[event]) hooks[event] = [];
|
|
127
|
-
const groups = hooks[event];
|
|
128
|
-
const presence = checkHookPresence(groups, cmd);
|
|
129
|
-
|
|
130
|
-
if (presence === 'found') {
|
|
131
|
-
result[event] = 'ok';
|
|
132
|
-
} else if (presence === 'migrate') {
|
|
133
|
-
for (const group of groups) {
|
|
134
|
-
for (const entry of group.hooks ?? []) {
|
|
135
|
-
if ((entry.command ?? '').includes('checkpoint-hook.sh')) {
|
|
136
|
-
entry.command = cmd;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
result[event] = 'migrated';
|
|
141
|
-
} else {
|
|
142
|
-
groups.push({ hooks: [{ command: cmd }] });
|
|
143
|
-
result[event] = 'added';
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
return result;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// ---------------------------------------------------------------------------
|
|
151
|
-
// Step 2: Register PATH (idempotent)
|
|
152
|
-
// ---------------------------------------------------------------------------
|
|
153
|
-
|
|
154
|
-
function applyPath(settings: SettingsJson): 'ok' | 'updated' {
|
|
155
|
-
if (!settings.env) settings.env = {};
|
|
156
|
-
|
|
157
|
-
const existing = settings.env.PATH ?? '';
|
|
158
|
-
const parts = existing ? existing.split(':') : [];
|
|
159
|
-
|
|
160
|
-
const bunForms = [BUN_BIN, '$HOME/.bun/bin', '${HOME}/.bun/bin', '~/.bun/bin'];
|
|
161
|
-
const npmForms = [
|
|
162
|
-
NPM_GLOBAL_BIN,
|
|
163
|
-
'$HOME/.npm-global/bin',
|
|
164
|
-
'${HOME}/.npm-global/bin',
|
|
165
|
-
'~/.npm-global/bin',
|
|
166
|
-
];
|
|
167
|
-
|
|
168
|
-
const missing: string[] = [];
|
|
169
|
-
if (!bunForms.some((f) => parts.includes(f))) missing.push(BUN_BIN);
|
|
170
|
-
if (!npmForms.some((f) => parts.includes(f))) missing.push(NPM_GLOBAL_BIN);
|
|
171
|
-
|
|
172
|
-
if (missing.length === 0) return 'ok';
|
|
173
|
-
|
|
174
|
-
const base = existing || '${PATH}';
|
|
175
|
-
const hasPlaceholder = base.includes('${PATH}');
|
|
176
|
-
|
|
177
|
-
if (hasPlaceholder) {
|
|
178
|
-
const withoutPlaceholder = base.replace('${PATH}', '').replace(/:+$/, '').replace(/^:+/, '');
|
|
179
|
-
const allParts = [
|
|
180
|
-
...missing,
|
|
181
|
-
...(withoutPlaceholder ? withoutPlaceholder.split(':').filter(Boolean) : []),
|
|
182
|
-
'${PATH}',
|
|
183
|
-
];
|
|
184
|
-
settings.env.PATH = allParts.join(':');
|
|
185
|
-
} else {
|
|
186
|
-
settings.env.PATH = [...missing, base].join(':');
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
return 'updated';
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// ---------------------------------------------------------------------------
|
|
193
|
-
// Step 3: Register permissions (idempotent)
|
|
194
|
-
// ---------------------------------------------------------------------------
|
|
195
|
-
|
|
196
|
-
function applyPermissions(settings: SettingsJson): string[] {
|
|
197
|
-
if (!settings.permissions) settings.permissions = {};
|
|
198
|
-
if (!settings.permissions.allow) settings.permissions.allow = [];
|
|
199
|
-
|
|
200
|
-
const allow = settings.permissions.allow;
|
|
201
|
-
const added: string[] = [];
|
|
202
|
-
|
|
203
|
-
for (const perm of PERMISSIONS_TO_ADD) {
|
|
204
|
-
if (!allow.includes(perm)) {
|
|
205
|
-
allow.push(perm);
|
|
206
|
-
added.push(perm);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
return added;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
// ---------------------------------------------------------------------------
|
|
214
|
-
// Step 4: Gemini harness (non-fatal)
|
|
215
|
-
// ---------------------------------------------------------------------------
|
|
216
|
-
|
|
217
|
-
async function registerGeminiHooks(vaultRoot: string): Promise<void> {
|
|
218
|
-
const geminiSettingsPath = join(vaultRoot, '.gemini', 'settings.json');
|
|
219
|
-
try {
|
|
220
|
-
// Only modify if the file already exists — skip non-fatally otherwise
|
|
221
|
-
const text = await readFile(geminiSettingsPath, 'utf8');
|
|
222
|
-
const settings = JSON.parse(text) as SettingsJson;
|
|
223
|
-
applyHooks(settings);
|
|
224
|
-
await writeSettings(geminiSettingsPath, settings);
|
|
225
|
-
} catch (err) {
|
|
226
|
-
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
|
227
|
-
process.stderr.write(
|
|
228
|
-
`register-hooks: gemini warning: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
229
|
-
);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// ---------------------------------------------------------------------------
|
|
235
|
-
// Step 5: Direct harness — shell profile PATH export (idempotent via marker)
|
|
236
|
-
// ---------------------------------------------------------------------------
|
|
237
|
-
|
|
238
|
-
async function registerDirectPath(): Promise<void> {
|
|
239
|
-
const home = homedir();
|
|
240
|
-
const candidates = [join(home, '.zshrc'), join(home, '.bashrc'), join(home, '.profile')];
|
|
241
|
-
|
|
242
|
-
let profilePath: string | undefined;
|
|
243
|
-
for (const candidate of candidates) {
|
|
244
|
-
try {
|
|
245
|
-
await readFile(candidate, 'utf8');
|
|
246
|
-
profilePath = candidate;
|
|
247
|
-
break;
|
|
248
|
-
} catch {
|
|
249
|
-
// Not found — try next
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
if (!profilePath) return;
|
|
254
|
-
|
|
255
|
-
const content = await readFile(profilePath, 'utf8');
|
|
256
|
-
if (content.includes(ONEBRAIN_MARKER)) return;
|
|
257
|
-
|
|
258
|
-
const updated = `${content}\n${ONEBRAIN_MARKER}\n${PATH_EXPORT}\n`;
|
|
259
|
-
const tmpPath = `${profilePath}.tmp`;
|
|
260
|
-
await writeFile(tmpPath, updated, 'utf8');
|
|
261
|
-
await rename(tmpPath, profilePath);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// ---------------------------------------------------------------------------
|
|
265
|
-
// Public types
|
|
266
|
-
// ---------------------------------------------------------------------------
|
|
267
|
-
|
|
268
|
-
export interface RegisterHooksOptions {
|
|
269
|
-
vaultDir?: string;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
export interface RegisterHooksResult {
|
|
273
|
-
ok: boolean;
|
|
274
|
-
hooks: Record<string, HookStatus>;
|
|
275
|
-
pathStatus: 'ok' | 'updated';
|
|
276
|
-
permissionsAdded: string[];
|
|
277
|
-
error?: string;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// ---------------------------------------------------------------------------
|
|
281
|
-
// Main runRegisterHooks
|
|
282
|
-
// ---------------------------------------------------------------------------
|
|
283
|
-
|
|
284
|
-
export async function runRegisterHooks(
|
|
285
|
-
opts: RegisterHooksOptions = {},
|
|
286
|
-
): Promise<RegisterHooksResult> {
|
|
287
|
-
const vaultRoot = opts.vaultDir ?? process.cwd();
|
|
288
|
-
const isTTY = process.stdout.isTTY;
|
|
289
|
-
|
|
290
|
-
// Load vault.yml to determine harness
|
|
291
|
-
let harness = 'claude-code';
|
|
292
|
-
try {
|
|
293
|
-
const vaultYmlText = await readFile(join(vaultRoot, 'vault.yml'), 'utf8');
|
|
294
|
-
const vaultYml = (parseYaml(vaultYmlText) ?? {}) as Record<string, unknown>;
|
|
295
|
-
const runtime = vaultYml.runtime as Record<string, unknown> | undefined;
|
|
296
|
-
if (runtime && typeof runtime.harness === 'string') {
|
|
297
|
-
harness = runtime.harness;
|
|
298
|
-
}
|
|
299
|
-
} catch {
|
|
300
|
-
// vault.yml missing — use default harness
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
const result: RegisterHooksResult = {
|
|
304
|
-
ok: false,
|
|
305
|
-
hooks: {},
|
|
306
|
-
pathStatus: 'ok',
|
|
307
|
-
permissionsAdded: [],
|
|
308
|
-
};
|
|
309
|
-
|
|
310
|
-
const settingsPath = join(vaultRoot, '.claude', 'settings.json');
|
|
311
|
-
|
|
312
|
-
// Output helpers
|
|
313
|
-
const note = (msg: string) => {
|
|
314
|
-
if (isTTY) {
|
|
315
|
-
log.message(msg);
|
|
316
|
-
} else {
|
|
317
|
-
process.stdout.write(`register-hooks: ${msg}\n`);
|
|
318
|
-
}
|
|
319
|
-
};
|
|
320
|
-
|
|
321
|
-
try {
|
|
322
|
-
if (isTTY) intro('OneBrain Register Hooks');
|
|
323
|
-
|
|
324
|
-
// ── Steps 1-3: Read once, apply all, write once ───────────────────────
|
|
325
|
-
const hooksSpinner = isTTY ? spinner() : null;
|
|
326
|
-
hooksSpinner?.start('Registering hooks...');
|
|
327
|
-
|
|
328
|
-
const settings = await readSettings(settingsPath);
|
|
329
|
-
result.hooks = applyHooks(settings);
|
|
330
|
-
|
|
331
|
-
hooksSpinner?.stop('Hooks registered');
|
|
332
|
-
|
|
333
|
-
if (isTTY) {
|
|
334
|
-
const hookLine = HOOK_EVENTS.map((e) => {
|
|
335
|
-
const status = result.hooks[e];
|
|
336
|
-
const icon = status === 'ok' ? '✓' : status === 'migrated' ? '↑' : '+';
|
|
337
|
-
return `${e}: ${icon}`;
|
|
338
|
-
}).join(' ');
|
|
339
|
-
note(hookLine);
|
|
340
|
-
} else {
|
|
341
|
-
const hookLine = HOOK_EVENTS.map((e) => {
|
|
342
|
-
const status = result.hooks[e];
|
|
343
|
-
const label =
|
|
344
|
-
status === 'ok' || status === 'added' || status === 'migrated' || status === 'found'
|
|
345
|
-
? 'ok'
|
|
346
|
-
: (status ?? 'ok');
|
|
347
|
-
return `${e} ${label}`;
|
|
348
|
-
}).join(' ');
|
|
349
|
-
note(hookLine);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
// ── Step 2: PATH ──────────────────────────────────────────────────────
|
|
353
|
-
const pathSpinner = isTTY ? spinner() : null;
|
|
354
|
-
pathSpinner?.start('Registering PATH...');
|
|
355
|
-
|
|
356
|
-
result.pathStatus = applyPath(settings);
|
|
357
|
-
|
|
358
|
-
pathSpinner?.stop('PATH registered');
|
|
359
|
-
|
|
360
|
-
if (isTTY) {
|
|
361
|
-
note('env.PATH in .claude/settings.json: ✓');
|
|
362
|
-
} else {
|
|
363
|
-
note('PATH ok');
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// ── Step 3: Permissions ───────────────────────────────────────────────
|
|
367
|
-
const permSpinner = isTTY ? spinner() : null;
|
|
368
|
-
permSpinner?.start('Updating permissions...');
|
|
369
|
-
|
|
370
|
-
result.permissionsAdded = applyPermissions(settings);
|
|
371
|
-
await writeSettings(settingsPath, settings);
|
|
372
|
-
|
|
373
|
-
permSpinner?.stop('Updating permissions...');
|
|
374
|
-
|
|
375
|
-
if (isTTY) {
|
|
376
|
-
for (const perm of PERMISSIONS_TO_ADD) {
|
|
377
|
-
note(`${perm}: ✓`);
|
|
378
|
-
}
|
|
379
|
-
} else {
|
|
380
|
-
note('permissions ok');
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// ── Step 4: Gemini harness (non-fatal) ────────────────────────────────
|
|
384
|
-
if (harness === 'gemini') {
|
|
385
|
-
await registerGeminiHooks(vaultRoot);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// ── Step 5: Direct harness ────────────────────────────────────────────
|
|
389
|
-
if (harness === 'direct') {
|
|
390
|
-
await registerDirectPath();
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
result.ok = true;
|
|
394
|
-
|
|
395
|
-
if (isTTY) {
|
|
396
|
-
outro('Done');
|
|
397
|
-
} else {
|
|
398
|
-
note('done');
|
|
399
|
-
}
|
|
400
|
-
} catch (err) {
|
|
401
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
402
|
-
result.error = msg;
|
|
403
|
-
process.stderr.write(`register-hooks: error: ${msg}\n`);
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
return result;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
// ---------------------------------------------------------------------------
|
|
410
|
-
// CLI entry point
|
|
411
|
-
// ---------------------------------------------------------------------------
|
|
412
|
-
|
|
413
|
-
export async function registerHooksCommand(vaultDir?: string): Promise<void> {
|
|
414
|
-
const result = await runRegisterHooks({ vaultDir });
|
|
415
|
-
if (!result.ok) {
|
|
416
|
-
process.exit(1);
|
|
417
|
-
}
|
|
418
|
-
}
|