@onebrain-ai/cli 2.0.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.
@@ -0,0 +1,764 @@
1
+ /**
2
+ * vault-sync — internal command
3
+ *
4
+ * Replaces vault-sync.sh + pin-to-vault.sh + clean-plugin-cache.sh.
5
+ *
6
+ * Steps (in order):
7
+ * 1. Download tarball from GitHub
8
+ * 2. Sync plugin files (critical — exit 1 on failure)
9
+ * 3. Copy root docs (non-fatal — docs are optional, skip silently on error)
10
+ * 4. Merge harness files (critical — exit 1 on failure)
11
+ * 5. Write version to vault.yml (critical)
12
+ * 6. Pin to vault (non-fatal — log stderr, continue)
13
+ * 7. Clean plugin cache (non-fatal — log stderr, continue)
14
+ *
15
+ * Exit code: 0 on success, 1 if any critical step fails.
16
+ * TTY: uses @clack/prompts spinners
17
+ * Non-TTY: plain text prefixed with "vault-sync:"
18
+ */
19
+
20
+ import {
21
+ mkdir,
22
+ mkdtemp,
23
+ readFile,
24
+ readdir,
25
+ rename,
26
+ rm,
27
+ stat,
28
+ unlink,
29
+ writeFile,
30
+ } from 'node:fs/promises';
31
+ import { homedir, tmpdir } from 'node:os';
32
+ import { dirname, join, relative } from 'node:path';
33
+ import { intro, outro, spinner } from '@clack/prompts';
34
+ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Types
38
+ // ---------------------------------------------------------------------------
39
+
40
+ export interface VaultSyncOptions {
41
+ /** Overrides vault.yml update_channel branch resolution (for tests). */
42
+ branch?: string;
43
+ /** Mock fetch for tests — defaults to globalThis.fetch. */
44
+ fetchFn?: typeof fetch;
45
+ /** Override path to installed_plugins.json (for tests). */
46
+ installedPluginsPath?: string;
47
+ /** Override path to the plugins cache dir (for tests). */
48
+ installedPluginsCacheDir?: string;
49
+ /** Override TTY detection for tests — defaults to process.stdout.isTTY. */
50
+ isTTY?: boolean;
51
+ /** Injectable unlink for tests — defaults to node:fs/promises unlink. */
52
+ unlinkFn?: typeof unlink;
53
+ }
54
+
55
+ export interface VaultSyncResult {
56
+ ok: boolean;
57
+ version: string;
58
+ branch: string;
59
+ filesAdded: number;
60
+ filesRemoved: number;
61
+ importsAdded: number;
62
+ pinSkipped: boolean;
63
+ cacheRemoved: number;
64
+ error?: string;
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Branch resolution
69
+ // ---------------------------------------------------------------------------
70
+
71
+ function resolveBranch(updateChannel: string | undefined): string {
72
+ // update_channel === 'stable' → use 'main'; anything else → 'next'
73
+ return updateChannel === 'stable' ? 'main' : 'next';
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Step 1: Download tarball
78
+ // ---------------------------------------------------------------------------
79
+
80
+ async function downloadTarball(
81
+ branch: string,
82
+ fetchFn: typeof fetch,
83
+ ): Promise<{ tarball: ArrayBuffer; tmpDir: string }> {
84
+ const url = `https://api.github.com/repos/kengio/onebrain/tarball/${branch}`;
85
+ const response = await fetchFn(url);
86
+ if (!response.ok) {
87
+ const hints: Partial<Record<number, string>> = {
88
+ 403: ' — check repo permissions or GITHUB_TOKEN',
89
+ 404: ' — repo or branch not found',
90
+ 429: ' — rate limited, wait and retry',
91
+ };
92
+ const hint = hints[response.status] ?? '';
93
+ throw new Error(`HTTP ${response.status} downloading tarball from ${url}${hint}`);
94
+ }
95
+ const tarball = await response.arrayBuffer();
96
+ const tmpDir = await mkdtemp(join(tmpdir(), 'onebrain-sync-'));
97
+ return { tarball, tmpDir };
98
+ }
99
+
100
+ /**
101
+ * Extract a .tar.gz buffer to destDir using the `tar` CLI.
102
+ * Returns the path of the top-level extracted directory.
103
+ */
104
+ async function extractTarball(tarball: ArrayBuffer, destDir: string): Promise<string> {
105
+ const tarPath = join(destDir, 'bundle.tar.gz');
106
+ await writeFile(tarPath, Buffer.from(tarball));
107
+
108
+ // Spawn tar to extract
109
+ const proc = Bun.spawn(['tar', '-xzf', tarPath, '-C', destDir], {
110
+ stdout: 'pipe',
111
+ stderr: 'pipe',
112
+ });
113
+ const exitCode = await proc.exited;
114
+ if (exitCode !== 0) {
115
+ const errText = await new Response(proc.stderr).text();
116
+ throw new Error(`tar extraction failed (exit ${exitCode}): ${errText.trim()}`);
117
+ }
118
+
119
+ // Delete the tarball file now we've extracted
120
+ await unlink(tarPath);
121
+
122
+ // Find the top-level directory (should be kengio-onebrain-<sha>/)
123
+ const entries = await readdir(destDir);
124
+ const topLevel = entries.find((e) => e !== 'bundle.tar.gz');
125
+ if (!topLevel) {
126
+ throw new Error('Extracted tarball contains no top-level directory');
127
+ }
128
+ return join(destDir, topLevel);
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Step 2: Sync plugin files
133
+ // ---------------------------------------------------------------------------
134
+
135
+ /**
136
+ * Recursively list all files under a directory (relative paths).
137
+ */
138
+ async function listFilesRecursive(dir: string): Promise<string[]> {
139
+ const results: string[] = [];
140
+ const queue = [dir];
141
+ while (queue.length > 0) {
142
+ const current = queue.pop() as string;
143
+ let entries: string[];
144
+ try {
145
+ entries = await readdir(current);
146
+ } catch {
147
+ continue;
148
+ }
149
+ for (const entry of entries) {
150
+ const fullPath = join(current, entry);
151
+ let s: Awaited<ReturnType<typeof stat>>;
152
+ try {
153
+ s = await stat(fullPath);
154
+ } catch {
155
+ continue;
156
+ }
157
+ if (s.isDirectory()) {
158
+ queue.push(fullPath);
159
+ } else {
160
+ results.push(fullPath);
161
+ }
162
+ }
163
+ }
164
+ return results;
165
+ }
166
+
167
+ async function syncPluginFiles(
168
+ extractedDir: string,
169
+ vaultRoot: string,
170
+ unlinkFn: typeof unlink = unlink,
171
+ ): Promise<{ filesAdded: number; filesRemoved: number }> {
172
+ const sourcePlugin = join(extractedDir, '.claude', 'plugins', 'onebrain');
173
+ const destPlugin = join(vaultRoot, '.claude', 'plugins', 'onebrain');
174
+
175
+ await mkdir(destPlugin, { recursive: true });
176
+
177
+ // Collect source files (relative to sourcePlugin)
178
+ const sourceFiles = await listFilesRecursive(sourcePlugin);
179
+ const sourceRelSet = new Set(sourceFiles.map((f) => relative(sourcePlugin, f)));
180
+
181
+ // Collect destination files
182
+ const destFiles = await listFilesRecursive(destPlugin);
183
+ const destRelSet = new Set(destFiles.map((f) => relative(destPlugin, f)));
184
+
185
+ // Identify stale files (in dest, not in source)
186
+ const staleRels: string[] = [];
187
+ for (const rel of destRelSet) {
188
+ if (!sourceRelSet.has(rel)) {
189
+ staleRels.push(rel);
190
+ }
191
+ }
192
+
193
+ // Copy all source files to dest
194
+ let filesAdded = 0;
195
+ for (const srcPath of sourceFiles) {
196
+ const rel = relative(sourcePlugin, srcPath);
197
+ const destPath = join(destPlugin, rel);
198
+ await mkdir(dirname(destPath), { recursive: true });
199
+ const content = await readFile(srcPath);
200
+ await writeFile(destPath, content);
201
+ filesAdded++;
202
+ }
203
+
204
+ // Remove stale files — track actual deletions only
205
+ let filesRemoved = 0;
206
+ for (const rel of staleRels) {
207
+ const destPath = join(destPlugin, rel);
208
+ try {
209
+ await unlinkFn(destPath);
210
+ filesRemoved++;
211
+ } catch {
212
+ // Non-fatal within this step — log nothing (best-effort cleanup)
213
+ }
214
+ }
215
+
216
+ return { filesAdded, filesRemoved };
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Step 3: Copy root docs
221
+ // ---------------------------------------------------------------------------
222
+
223
+ async function copyRootDocs(extractedDir: string, vaultRoot: string): Promise<void> {
224
+ const docs = ['README.md', 'CONTRIBUTING.md', 'CHANGELOG.md'];
225
+ for (const doc of docs) {
226
+ const srcPath = join(extractedDir, doc);
227
+ const destPath = join(vaultRoot, doc);
228
+ try {
229
+ const content = await readFile(srcPath);
230
+ await writeFile(destPath, content);
231
+ } catch {
232
+ // File may not exist in tarball — skip silently
233
+ }
234
+ }
235
+ }
236
+
237
+ // ---------------------------------------------------------------------------
238
+ // Step 4: Merge harness files
239
+ // ---------------------------------------------------------------------------
240
+
241
+ /**
242
+ * Merge a single harness file.
243
+ * Vault is primary. Only inject @import lines from repo that are not yet in vault.
244
+ * Returns number of imports added.
245
+ */
246
+ async function mergeHarnessFile(
247
+ extractedDir: string,
248
+ vaultRoot: string,
249
+ filename: string,
250
+ ): Promise<number> {
251
+ const srcPath = join(extractedDir, filename);
252
+ const destPath = join(vaultRoot, filename);
253
+
254
+ let repoText: string;
255
+ try {
256
+ repoText = await readFile(srcPath, 'utf8');
257
+ } catch {
258
+ return 0; // Not in tarball — nothing to do
259
+ }
260
+
261
+ let vaultText: string;
262
+ try {
263
+ vaultText = await readFile(destPath, 'utf8');
264
+ } catch {
265
+ // Vault copy doesn't exist — write repo version directly
266
+ await writeFile(destPath, repoText, 'utf8');
267
+ return repoText.split('\n').filter((l) => l.startsWith('@')).length;
268
+ }
269
+
270
+ // Find @import lines in repo not already in vault
271
+ const vaultAtSet = new Set(
272
+ vaultText
273
+ .split('\n')
274
+ .filter((l) => l.startsWith('@'))
275
+ .map((l) => l.trim()),
276
+ );
277
+
278
+ const newImports = repoText
279
+ .split('\n')
280
+ .filter((l) => l.startsWith('@') && !vaultAtSet.has(l.trim()))
281
+ .map((l) => l.trimEnd());
282
+
283
+ if (newImports.length === 0) {
284
+ return 0;
285
+ }
286
+
287
+ // Insert before the last @-line in vault (keeps structural ordering)
288
+ const vaultLines = vaultText.split('\n');
289
+ const lastAtIdx = vaultLines.reduce((acc, l, i) => (l.startsWith('@') ? i : acc), -1);
290
+
291
+ if (lastAtIdx >= 0) {
292
+ vaultLines.splice(lastAtIdx, 0, ...newImports);
293
+ } else {
294
+ vaultLines.push('', ...newImports);
295
+ }
296
+
297
+ const merged = vaultLines.join('\n');
298
+ await writeFile(destPath, merged, 'utf8');
299
+ return newImports.length;
300
+ }
301
+
302
+ async function mergeHarnessFiles(extractedDir: string, vaultRoot: string): Promise<number> {
303
+ const harnessFiles = ['CLAUDE.md', 'GEMINI.md', 'AGENTS.md'];
304
+ let totalImportsAdded = 0;
305
+ const results = await Promise.all(
306
+ harnessFiles.map((f) => mergeHarnessFile(extractedDir, vaultRoot, f)),
307
+ );
308
+ for (const n of results) {
309
+ totalImportsAdded += n;
310
+ }
311
+ return totalImportsAdded;
312
+ }
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // Step 5: Write version to vault.yml
316
+ // ---------------------------------------------------------------------------
317
+
318
+ async function updateVaultYml(
319
+ vaultRoot: string,
320
+ version: string,
321
+ updateChannel: string,
322
+ ): Promise<void> {
323
+ const vaultYmlPath = join(vaultRoot, 'vault.yml');
324
+ let text: string;
325
+ try {
326
+ text = await readFile(vaultYmlPath, 'utf8');
327
+ } catch {
328
+ // vault.yml missing — create minimal one
329
+ text = '';
330
+ }
331
+
332
+ const raw = (parseYaml(text) ?? {}) as Record<string, unknown>;
333
+ raw.onebrain_version = version;
334
+ raw.update_channel = updateChannel;
335
+
336
+ const updated = stringifyYaml(raw, { lineWidth: 0 });
337
+ const tmpPath = `${vaultYmlPath}.tmp`;
338
+ await writeFile(tmpPath, updated, 'utf8');
339
+ await rename(tmpPath, vaultYmlPath);
340
+ }
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // Step 6: Pin to vault
344
+ // ---------------------------------------------------------------------------
345
+
346
+ /**
347
+ * Read plugin.json version from the synced plugin dir.
348
+ */
349
+ async function readPluginVersion(vaultRoot: string): Promise<string> {
350
+ // plugin.json lives in .claude/plugins/onebrain/.claude-plugin/plugin.json
351
+ const pluginJsonPath = join(
352
+ vaultRoot,
353
+ '.claude',
354
+ 'plugins',
355
+ 'onebrain',
356
+ '.claude-plugin',
357
+ 'plugin.json',
358
+ );
359
+ try {
360
+ const text = await readFile(pluginJsonPath, 'utf8');
361
+ const parsed = JSON.parse(text) as Record<string, unknown>;
362
+ return typeof parsed.version === 'string' ? parsed.version : 'unknown';
363
+ } catch {
364
+ return 'unknown';
365
+ }
366
+ }
367
+
368
+ interface PinResult {
369
+ skipped: boolean;
370
+ }
371
+
372
+ async function pinToVault(
373
+ vaultRoot: string,
374
+ installedPluginsPath: string,
375
+ installedPluginsCacheDir: string | undefined,
376
+ ): Promise<PinResult> {
377
+ // Read installed_plugins.json
378
+ let text: string;
379
+ try {
380
+ text = await readFile(installedPluginsPath, 'utf8');
381
+ } catch {
382
+ return { skipped: true }; // File not found — no-op
383
+ }
384
+
385
+ let data: Record<string, unknown>;
386
+ try {
387
+ data = JSON.parse(text) as Record<string, unknown>;
388
+ } catch {
389
+ throw new Error(`installed_plugins.json is not valid JSON: ${installedPluginsPath}`);
390
+ }
391
+
392
+ const plugins = data.plugins as Record<string, unknown[]> | undefined;
393
+ if (!plugins) {
394
+ return { skipped: true };
395
+ }
396
+
397
+ // Find all onebrain@ entries
398
+ const onebrainKeys = Object.keys(plugins).filter((k) => k.startsWith('onebrain@'));
399
+ if (onebrainKeys.length === 0) {
400
+ return { skipped: true };
401
+ }
402
+
403
+ const vaultPluginDir = join(vaultRoot, '.claude', 'plugins', 'onebrain');
404
+ const pluginVersion = await readPluginVersion(vaultRoot);
405
+
406
+ // Determine cache dir: installed_plugins.json parent → plugins/ → cache/
407
+ const cacheDir = installedPluginsCacheDir ?? join(dirname(installedPluginsPath), 'cache');
408
+
409
+ // If ANY onebrain entry has source: marketplace, Claude Code owns it — skip entirely.
410
+ const hasMarketplace = onebrainKeys.some((k) => {
411
+ const entries = plugins[k] as Array<Record<string, unknown>>;
412
+ return entries.some((e) => e.source === 'marketplace');
413
+ });
414
+ if (hasMarketplace) {
415
+ return { skipped: true };
416
+ }
417
+
418
+ let changed = false;
419
+ for (const key of onebrainKeys) {
420
+ const entries = plugins[key] as Array<Record<string, unknown>>;
421
+ for (const entry of entries) {
422
+ const installPath = entry.installPath;
423
+ if (typeof installPath !== 'string') {
424
+ continue;
425
+ }
426
+
427
+ // Only rewrite if installPath is inside the cache dir
428
+ let inCache = false;
429
+ try {
430
+ // resolves any symlinks before comparison (normalize)
431
+ inCache = installPath.startsWith(`${cacheDir}/`) || installPath === cacheDir;
432
+ } catch {
433
+ inCache = false;
434
+ }
435
+
436
+ if (!inCache) {
437
+ continue;
438
+ }
439
+
440
+ entry.installPath = vaultPluginDir;
441
+ entry.version = pluginVersion;
442
+ changed = true;
443
+ }
444
+ }
445
+
446
+ if (!changed) {
447
+ return { skipped: false }; // Already pinned — no change needed
448
+ }
449
+
450
+ // Atomic write via temp file + rename
451
+ const tmpPath = `${installedPluginsPath}.tmp`;
452
+ await writeFile(tmpPath, JSON.stringify(data, null, 4), 'utf8');
453
+ // rename is atomic on POSIX
454
+ await rename(tmpPath, installedPluginsPath);
455
+
456
+ return { skipped: false };
457
+ }
458
+
459
+ // ---------------------------------------------------------------------------
460
+ // Step 7: Clean plugin cache
461
+ // ---------------------------------------------------------------------------
462
+
463
+ async function cleanPluginCache(
464
+ installedPluginsPath: string,
465
+ installedPluginsCacheDir: string | undefined,
466
+ ): Promise<number> {
467
+ const cacheDir = installedPluginsCacheDir ?? join(dirname(installedPluginsPath), 'cache');
468
+
469
+ // Check cache dir exists
470
+ try {
471
+ await stat(cacheDir);
472
+ } catch {
473
+ return 0; // No cache dir — no-op
474
+ }
475
+
476
+ // Read installed_plugins.json to find onebrain marketplace entries
477
+ const onebrainDirs: string[] = [];
478
+ try {
479
+ const text = await readFile(installedPluginsPath, 'utf8');
480
+ const data = JSON.parse(text) as Record<string, unknown>;
481
+ const plugins = data.plugins as Record<string, unknown[]> | undefined;
482
+ if (plugins) {
483
+ for (const key of Object.keys(plugins)) {
484
+ if (!key.startsWith('onebrain@')) continue;
485
+ const marketplace = key.split('@')[1] as string;
486
+ const candidate = join(cacheDir, marketplace, 'onebrain');
487
+ try {
488
+ await stat(candidate);
489
+ onebrainDirs.push(candidate);
490
+ } catch {
491
+ // Directory doesn't exist — skip
492
+ }
493
+ }
494
+ }
495
+ } catch {
496
+ // JSON parse failure or file not found — fall back to glob
497
+ }
498
+
499
+ // Fallback: glob for any cache/*/onebrain/
500
+ if (onebrainDirs.length === 0) {
501
+ try {
502
+ const marketplaceDirs = await readdir(cacheDir);
503
+ for (const mp of marketplaceDirs) {
504
+ const candidate = join(cacheDir, mp, 'onebrain');
505
+ try {
506
+ await stat(candidate);
507
+ onebrainDirs.push(candidate);
508
+ } catch {
509
+ // Not found
510
+ }
511
+ }
512
+ } catch {
513
+ return 0;
514
+ }
515
+ }
516
+
517
+ let removed = 0;
518
+ for (const pluginDir of onebrainDirs) {
519
+ let versionDirs: string[];
520
+ try {
521
+ versionDirs = await readdir(pluginDir);
522
+ } catch {
523
+ continue;
524
+ }
525
+ for (const versionDir of versionDirs) {
526
+ const fullPath = join(pluginDir, versionDir);
527
+ try {
528
+ const s = await stat(fullPath);
529
+ if (s.isDirectory()) {
530
+ await rm(fullPath, { recursive: true, force: true });
531
+ removed++;
532
+ }
533
+ } catch {
534
+ // Skip
535
+ }
536
+ }
537
+ }
538
+
539
+ return removed;
540
+ }
541
+
542
+ // ---------------------------------------------------------------------------
543
+ // Main runVaultSync function
544
+ // ---------------------------------------------------------------------------
545
+
546
+ export async function runVaultSync(
547
+ vaultRoot: string,
548
+ opts: VaultSyncOptions = {},
549
+ ): Promise<VaultSyncResult> {
550
+ const fetchFn = opts.fetchFn ?? globalThis.fetch;
551
+ const isTTY = opts.isTTY ?? process.stdout.isTTY;
552
+ const unlinkFn = opts.unlinkFn ?? unlink;
553
+
554
+ // Load vault.yml for config
555
+ let updateChannel = 'stable';
556
+ let harness = 'claude-code';
557
+ try {
558
+ const vaultYmlText = await readFile(join(vaultRoot, 'vault.yml'), 'utf8');
559
+ const vaultYml = (parseYaml(vaultYmlText) ?? {}) as Record<string, unknown>;
560
+ if (typeof vaultYml.update_channel === 'string') {
561
+ updateChannel = vaultYml.update_channel;
562
+ }
563
+ const runtime = vaultYml.runtime as Record<string, unknown> | undefined;
564
+ if (runtime && typeof runtime.harness === 'string') {
565
+ harness = runtime.harness;
566
+ }
567
+ } catch {
568
+ // vault.yml not found — use defaults
569
+ }
570
+
571
+ const branch = opts.branch ?? resolveBranch(updateChannel);
572
+ const installedPluginsPath =
573
+ opts.installedPluginsPath ?? join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
574
+ const installedPluginsCacheDir = opts.installedPluginsCacheDir;
575
+
576
+ const result: VaultSyncResult = {
577
+ ok: false,
578
+ version: 'unknown',
579
+ branch,
580
+ filesAdded: 0,
581
+ filesRemoved: 0,
582
+ importsAdded: 0,
583
+ pinSkipped: true,
584
+ cacheRemoved: 0,
585
+ };
586
+
587
+ // TTY output helpers
588
+ let s: ReturnType<typeof spinner> | null = null;
589
+
590
+ function startSpinner(msg: string) {
591
+ if (isTTY) {
592
+ s = spinner();
593
+ s.start(msg);
594
+ } else {
595
+ process.stdout.write(`vault-sync: ${msg}\n`);
596
+ }
597
+ }
598
+
599
+ function stopSpinner(msg: string) {
600
+ if (isTTY && s) {
601
+ s.stop(msg);
602
+ s = null;
603
+ }
604
+ }
605
+
606
+ if (isTTY) {
607
+ intro('OneBrain Vault Sync');
608
+ }
609
+
610
+ let tmpDir: string | null = null;
611
+
612
+ try {
613
+ // ── Step 1: Download tarball ──────────────────────────────────────────
614
+ startSpinner('Downloading tarball...');
615
+ let extractedDir: string;
616
+ try {
617
+ const dl = await downloadTarball(branch, fetchFn);
618
+ tmpDir = dl.tmpDir;
619
+ extractedDir = await extractTarball(dl.tarball, tmpDir);
620
+ } catch (err) {
621
+ stopSpinner('Download failed');
622
+ const msg = err instanceof Error ? err.message : String(err);
623
+ result.error = msg;
624
+ process.stderr.write(`vault-sync: download failed: ${msg}\n`);
625
+ return result;
626
+ }
627
+
628
+ // Read version from extracted plugin.json (before sync writes it to vault)
629
+ try {
630
+ const pjText = await readFile(
631
+ join(extractedDir, '.claude', 'plugins', 'onebrain', '.claude-plugin', 'plugin.json'),
632
+ 'utf8',
633
+ );
634
+ const pj = JSON.parse(pjText) as Record<string, unknown>;
635
+ if (typeof pj.version === 'string') {
636
+ result.version = pj.version;
637
+ }
638
+ } catch {
639
+ // Keep 'unknown'
640
+ }
641
+
642
+ stopSpinner(`kengio/onebrain@${branch} (v${result.version})`);
643
+
644
+ // ── Step 2: Sync plugin files ─────────────────────────────────────────
645
+ startSpinner('Syncing plugin files...');
646
+ try {
647
+ const { filesAdded, filesRemoved } = await syncPluginFiles(extractedDir, vaultRoot, unlinkFn);
648
+ result.filesAdded = filesAdded;
649
+ result.filesRemoved = filesRemoved;
650
+ } catch (err) {
651
+ stopSpinner('Plugin sync failed');
652
+ const msg = err instanceof Error ? err.message : String(err);
653
+ result.error = msg;
654
+ process.stderr.write(`vault-sync: plugin sync failed: ${msg}\n`);
655
+ return result;
656
+ }
657
+ stopSpinner(
658
+ `${result.filesAdded} file${result.filesAdded !== 1 ? 's' : ''} synced, ${result.filesRemoved} removed`,
659
+ );
660
+
661
+ // ── Step 3: Copy root docs (non-fatal) ───────────────────────────────
662
+ await copyRootDocs(extractedDir, vaultRoot);
663
+
664
+ // ── Step 4: Merge harness files ───────────────────────────────────────
665
+ startSpinner('Updating harness files...');
666
+ let importsAdded = 0;
667
+ try {
668
+ importsAdded = await mergeHarnessFiles(extractedDir, vaultRoot);
669
+ result.importsAdded = importsAdded;
670
+ } catch (err) {
671
+ stopSpinner('Harness merge failed');
672
+ const msg = err instanceof Error ? err.message : String(err);
673
+ result.error = msg;
674
+ process.stderr.write(`vault-sync: harness merge failed: ${msg}\n`);
675
+ return result;
676
+ }
677
+ if (importsAdded > 0) {
678
+ stopSpinner(`${importsAdded} import${importsAdded !== 1 ? 's' : ''} added`);
679
+ } else {
680
+ stopSpinner('harness files up-to-date');
681
+ }
682
+
683
+ // ── Step 5: Write version to vault.yml ───────────────────────────────
684
+ try {
685
+ await updateVaultYml(vaultRoot, result.version, updateChannel);
686
+ } catch (err) {
687
+ const msg = err instanceof Error ? err.message : String(err);
688
+ result.error = msg;
689
+ process.stderr.write(`vault-sync: vault.yml update failed: ${msg}\n`);
690
+ return result;
691
+ }
692
+
693
+ // ── Steps 6–7: Non-fatal, claude-code harness only ────────────────────
694
+ if (harness === 'claude-code') {
695
+ // Step 6: Pin to vault
696
+ startSpinner('Pinning to vault...');
697
+ try {
698
+ const pinResult = await pinToVault(
699
+ vaultRoot,
700
+ installedPluginsPath,
701
+ installedPluginsCacheDir,
702
+ );
703
+ result.pinSkipped = pinResult.skipped;
704
+ if (pinResult.skipped) {
705
+ stopSpinner('pin skipped (not found or marketplace)');
706
+ } else {
707
+ stopSpinner('installPath → .claude/plugins/onebrain');
708
+ }
709
+ } catch (err) {
710
+ const msg = err instanceof Error ? err.message : String(err);
711
+ process.stderr.write(`vault-sync: pin warning: ${msg}\n`);
712
+ result.pinSkipped = true;
713
+ stopSpinner('pin skipped (error — non-fatal)');
714
+ }
715
+
716
+ // Step 7: Clean plugin cache
717
+ startSpinner('Cleaning cache...');
718
+ try {
719
+ const cacheRemoved = await cleanPluginCache(installedPluginsPath, installedPluginsCacheDir);
720
+ result.cacheRemoved = cacheRemoved;
721
+ if (cacheRemoved > 0) {
722
+ stopSpinner(`${cacheRemoved} cached version${cacheRemoved !== 1 ? 's' : ''} removed`);
723
+ } else {
724
+ stopSpinner('no cache to clean');
725
+ }
726
+ } catch (err) {
727
+ const msg = err instanceof Error ? err.message : String(err);
728
+ process.stderr.write(`vault-sync: cache clean warning: ${msg}\n`);
729
+ stopSpinner('cache clean skipped (error — non-fatal)');
730
+ }
731
+ }
732
+
733
+ result.ok = true;
734
+
735
+ if (isTTY) {
736
+ outro(`Done — v${result.version} synced`);
737
+ } else {
738
+ process.stdout.write('vault-sync: done\n');
739
+ }
740
+ } finally {
741
+ // Clean up temp dir
742
+ if (tmpDir) {
743
+ rm(tmpDir, { recursive: true, force: true }).catch(() => {
744
+ // Non-fatal cleanup
745
+ });
746
+ }
747
+ }
748
+
749
+ return result;
750
+ }
751
+
752
+ // ---------------------------------------------------------------------------
753
+ // CLI entry point
754
+ // ---------------------------------------------------------------------------
755
+
756
+ export async function vaultSyncCommand(
757
+ vaultRoot: string,
758
+ opts: VaultSyncOptions = {},
759
+ ): Promise<void> {
760
+ const result = await runVaultSync(vaultRoot, opts);
761
+ if (!result.ok) {
762
+ process.exit(1);
763
+ }
764
+ }