claude-mem-lite 3.6.0 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/install.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
  // claude-mem-lite Installer — Smart install/uninstall/status/doctor
3
3
 
4
4
  import { execSync, execFileSync } from 'child_process';
5
- import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync, copyFileSync, cpSync, renameSync, symlinkSync, unlinkSync, readdirSync, statSync, lstatSync } from 'fs';
5
+ import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync, mkdtempSync, copyFileSync, cpSync, renameSync, symlinkSync, unlinkSync, readdirSync, statSync, lstatSync } from 'fs';
6
6
  import { join, resolve, dirname, isAbsolute } from 'path';
7
7
  import { homedir, tmpdir } from 'os';
8
8
  import { fileURLToPath, pathToFileURL } from 'url';
@@ -41,6 +41,8 @@ import { scanPluginCacheHookPollution } from './plugin-cache-guard.mjs';
41
41
  import { SOURCE_FILES, HOOK_SCRIPT_FILES } from './source-files.mjs';
42
42
  import { probeBetterSqlite3Binding, ensureBetterSqlite3Working } from './lib/binding-probe.mjs';
43
43
  import { sweepStaleTestFixtures } from './lib/tmp-fixture-sweep.mjs';
44
+ import { acquireLock } from './lib/proc-lock.mjs';
45
+ import { atomicWriteFileSync } from './lib/atomic-write.mjs';
44
46
 
45
47
  // Re-export for backward compatibility — tests/install-hook-scripts.test.mjs
46
48
  // and any external consumers still import HOOK_SCRIPT_FILES from install.mjs.
@@ -341,711 +343,748 @@ function isPartialSparseClone(clonePath) {
341
343
 
342
344
  // ─── Install ────────────────────────────────────────────────────────────────
343
345
 
344
- async function install() {
345
- console.log('\nclaude-mem-lite installer\n');
346
346
 
347
- // Resolve dynamic imports against the installed copy at INSTALL_DIR rather
348
- // than install.mjs's own directory. Lets install.mjs run correctly from a
349
- // /tmp staging dir (repair flow, `curl | tar xz | node install.mjs install`)
350
- // where PROJECT_DIR has no node_modules but INSTALL_DIR does — step 2 below
351
- // ran `npm install --cwd INSTALL_DIR`. Pre-fix, steps 6/7 fired
352
- // "Cannot find package 'better-sqlite3' imported from /tmp/…/registry.mjs"
353
- // and silently skipped registry-DB seeding + DB health check on every repair.
354
- const importFromInstall = (rel) => import(pathToFileURL(join(INSTALL_DIR, rel)).href);
355
- const requireFromInstall = createRequire(pathToFileURL(join(INSTALL_DIR, 'package.json')).href);
356
-
357
- // 1. Install source files to ~/.claude-mem-lite/
358
- const IS_DEV = flags.has('--dev');
347
+ // Dynamic-import helpers, resolved against the installed copy at INSTALL_DIR
348
+ // (lets install.mjs run from a /tmp staging dir whose node_modules is at
349
+ // INSTALL_DIR, not the script dir). Used by the resource / db-verify / adopt steps.
350
+ const importFromInstall = (rel) => import(pathToFileURL(join(INSTALL_DIR, rel)).href);
351
+ const requireFromInstall = createRequire(pathToFileURL(join(INSTALL_DIR, 'package.json')).href);
352
+
353
+ // ─── install() step helpers (audit P1-9) ──────────────────────────────────────
354
+ function installSourceFiles(IS_DEV) {
355
+ // Auto-migrate unhidden dir (~/claude-mem-lite/ → ~/.claude-mem-lite/)
356
+ const oldUnhidden = join(homedir(), 'claude-mem-lite');
357
+ if (!existsSync(DATA_DIR) && existsSync(oldUnhidden)) {
358
+ log('Migrating ~/claude-mem-lite/ ~/.claude-mem-lite/...');
359
+ renameSync(oldUnhidden, DATA_DIR);
360
+ ok('Directory migrated');
361
+ }
359
362
 
360
- // Auto-migrate unhidden dir (~/claude-mem-lite/ ~/.claude-mem-lite/)
361
- const oldUnhidden = join(homedir(), 'claude-mem-lite');
362
- if (!existsSync(DATA_DIR) && existsSync(oldUnhidden)) {
363
- log('Migrating ~/claude-mem-lite/ → ~/.claude-mem-lite/...');
364
- renameSync(oldUnhidden, DATA_DIR);
365
- ok('Directory migrated');
366
- }
367
-
368
- if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
369
- // Under relocation the DB/managed/runtime live here, not in the code dir — create it too.
370
- if (!existsSync(MEM_DATA_DIR)) mkdirSync(MEM_DATA_DIR, { recursive: true });
371
-
372
- if (IS_DEV) {
373
- log('Dev mode creating symlinks in ~/.claude-mem-lite/...');
374
- // Symlink individual source files
375
- for (const f of SOURCE_FILES) {
376
- const target = join(PROJECT_DIR, f);
377
- const link = join(DATA_DIR, f);
378
- if (existsSync(target)) {
379
- // Ensure parent dir exists for subdir entries (e.g. 'lib/activity.mjs')
380
- const linkParent = dirname(link);
381
- if (!existsSync(linkParent)) mkdirSync(linkParent, { recursive: true });
382
- // Remove existing file/symlink before creating
383
- if (existsSync(link)) try { unlinkSync(link); } catch {}
384
- symlinkSync(target, link);
385
- }
363
+ if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
364
+ // Under relocation the DB/managed/runtime live here, not in the code dir — create it too.
365
+ if (!existsSync(MEM_DATA_DIR)) mkdirSync(MEM_DATA_DIR, { recursive: true });
366
+
367
+ if (IS_DEV) {
368
+ log('Dev mode — creating symlinks in ~/.claude-mem-lite/...');
369
+ // Symlink individual source files
370
+ for (const f of SOURCE_FILES) {
371
+ const target = join(PROJECT_DIR, f);
372
+ const link = join(DATA_DIR, f);
373
+ if (existsSync(target)) {
374
+ // Ensure parent dir exists for subdir entries (e.g. 'lib/activity.mjs')
375
+ const linkParent = dirname(link);
376
+ if (!existsSync(linkParent)) mkdirSync(linkParent, { recursive: true });
377
+ // Remove existing file/symlink before creating
378
+ if (existsSync(link)) try { unlinkSync(link); } catch {}
379
+ symlinkSync(target, link);
386
380
  }
387
- // Symlink scripts/ directory
388
- const scriptsLink = join(DATA_DIR, 'scripts');
389
- if (existsSync(scriptsLink)) try { rmSync(scriptsLink, { recursive: true, force: true }); } catch {}
390
- symlinkSync(join(PROJECT_DIR, 'scripts'), scriptsLink);
391
- // Symlink node_modules/
392
- const nmLink = join(DATA_DIR, 'node_modules');
393
- if (existsSync(nmLink)) try { rmSync(nmLink, { recursive: true, force: true }); } catch {}
394
- symlinkSync(join(PROJECT_DIR, 'node_modules'), nmLink);
395
- // Symlink registry/ directory
396
- const regLink = join(DATA_DIR, 'registry');
397
- if (existsSync(regLink)) try { rmSync(regLink, { recursive: true, force: true }); } catch {}
398
- if (existsSync(join(PROJECT_DIR, 'registry'))) {
399
- symlinkSync(join(PROJECT_DIR, 'registry'), regLink);
400
- }
401
- // commands/ is intentionally NOT linked: Claude Code reads slash commands
402
- // from the plugin cache (~/.claude/plugins/cache/<mp>/<plugin>/<ver>/commands/)
403
- // or user-level ~/.claude/commands/, never from ~/.claude-mem-lite/commands/.
404
- // Pre-v2.55 maintained a symlink/copy here that had no consumers.
405
- ok('Symlinks created in ~/.claude-mem-lite/ dev dir');
406
- } else {
407
- log('Installing to ~/.claude-mem-lite/...');
408
- const scriptsDir = join(DATA_DIR, 'scripts');
409
- if (!existsSync(scriptsDir)) mkdirSync(scriptsDir, { recursive: true });
410
- for (const f of SOURCE_FILES) {
411
- const src = join(PROJECT_DIR, f);
412
- const dst = join(DATA_DIR, f);
413
- if (existsSync(src)) {
414
- // Ensure parent dir exists for subdir entries (e.g. 'lib/activity.mjs')
415
- const dstParent = dirname(dst);
416
- if (!existsSync(dstParent)) mkdirSync(dstParent, { recursive: true });
417
- copyFileSync(src, dst);
418
- }
381
+ }
382
+ // Symlink scripts/ directory
383
+ const scriptsLink = join(DATA_DIR, 'scripts');
384
+ if (existsSync(scriptsLink)) try { rmSync(scriptsLink, { recursive: true, force: true }); } catch {}
385
+ symlinkSync(join(PROJECT_DIR, 'scripts'), scriptsLink);
386
+ // Symlink node_modules/
387
+ const nmLink = join(DATA_DIR, 'node_modules');
388
+ if (existsSync(nmLink)) try { rmSync(nmLink, { recursive: true, force: true }); } catch {}
389
+ symlinkSync(join(PROJECT_DIR, 'node_modules'), nmLink);
390
+ // Symlink registry/ directory
391
+ const regLink = join(DATA_DIR, 'registry');
392
+ if (existsSync(regLink)) try { rmSync(regLink, { recursive: true, force: true }); } catch {}
393
+ if (existsSync(join(PROJECT_DIR, 'registry'))) {
394
+ symlinkSync(join(PROJECT_DIR, 'registry'), regLink);
395
+ }
396
+ // commands/ is intentionally NOT linked: Claude Code reads slash commands
397
+ // from the plugin cache (~/.claude/plugins/cache/<mp>/<plugin>/<ver>/commands/)
398
+ // or user-level ~/.claude/commands/, never from ~/.claude-mem-lite/commands/.
399
+ // Pre-v2.55 maintained a symlink/copy here that had no consumers.
400
+ ok('Symlinks created in ~/.claude-mem-lite/ → dev dir');
401
+ } else {
402
+ log('Installing to ~/.claude-mem-lite/...');
403
+ const scriptsDir = join(DATA_DIR, 'scripts');
404
+ if (!existsSync(scriptsDir)) mkdirSync(scriptsDir, { recursive: true });
405
+ for (const f of SOURCE_FILES) {
406
+ const src = join(PROJECT_DIR, f);
407
+ const dst = join(DATA_DIR, f);
408
+ if (existsSync(src)) {
409
+ // Ensure parent dir exists for subdir entries (e.g. 'lib/activity.mjs')
410
+ const dstParent = dirname(dst);
411
+ if (!existsSync(dstParent)) mkdirSync(dstParent, { recursive: true });
412
+ copyFileSync(src, dst);
419
413
  }
420
- // Copy hook scripts (settings.json hook commands point at these — must
421
- // stay in sync with HOOK_SCRIPT_FILES manifest)
422
- copyHookScripts(join(PROJECT_DIR, 'scripts'), scriptsDir);
423
- // Ensure bash script is executable
424
- try { execFileSync('chmod', ['+x', join(scriptsDir, 'post-tool-use.sh')], { stdio: 'pipe' }); } catch {}
425
- // commands/ is intentionally NOT copied — see dev-mode branch above.
426
- // Copy registry manifest
427
- const registryDir = join(DATA_DIR, 'registry');
428
- if (!existsSync(registryDir)) mkdirSync(registryDir, { recursive: true });
429
- const manifestSrc = join(PROJECT_DIR, 'registry', 'preinstalled.json');
430
- if (existsSync(manifestSrc)) copyFileSync(manifestSrc, join(registryDir, 'preinstalled.json'));
431
- ok('Source files copied to ~/.claude-mem-lite/');
432
-
433
- // v2.48 P1-4: prune stale top-level .mjs + 0-byte .db files left behind by
434
- // prior upgrades (e.g. dispatch.mjs removed in v2.20.0, zero-byte mem.db /
435
- // memory.db / registry.db from pre-consolidation installs). Subdirs +
436
- // symlinks + non-empty DBs are always preserved.
437
- try {
438
- const pruned = pruneStaleInstallFiles(DATA_DIR, SOURCE_FILES);
439
- if (pruned.length > 0) {
440
- ok(`Pruned ${pruned.length} stale file(s): ${pruned.map(p => p.split('/').pop()).join(', ')}`);
441
- }
442
- } catch (e) { /* prune is best-effort — never block install */ void e; }
443
414
  }
415
+ // Copy hook scripts (settings.json hook commands point at these — must
416
+ // stay in sync with HOOK_SCRIPT_FILES manifest)
417
+ copyHookScripts(join(PROJECT_DIR, 'scripts'), scriptsDir);
418
+ // Ensure bash script is executable
419
+ try { execFileSync('chmod', ['+x', join(scriptsDir, 'post-tool-use.sh')], { stdio: 'pipe' }); } catch {}
420
+ // commands/ is intentionally NOT copied — see dev-mode branch above.
421
+ // Copy registry manifest
422
+ const registryDir = join(DATA_DIR, 'registry');
423
+ if (!existsSync(registryDir)) mkdirSync(registryDir, { recursive: true });
424
+ const manifestSrc = join(PROJECT_DIR, 'registry', 'preinstalled.json');
425
+ if (existsSync(manifestSrc)) copyFileSync(manifestSrc, join(registryDir, 'preinstalled.json'));
426
+ ok('Source files copied to ~/.claude-mem-lite/');
427
+
428
+ // v2.48 P1-4: prune stale top-level .mjs + 0-byte .db files left behind by
429
+ // prior upgrades (e.g. dispatch.mjs removed in v2.20.0, zero-byte mem.db /
430
+ // memory.db / registry.db from pre-consolidation installs). Subdirs +
431
+ // symlinks + non-empty DBs are always preserved.
432
+ try {
433
+ const pruned = pruneStaleInstallFiles(DATA_DIR, SOURCE_FILES);
434
+ if (pruned.length > 0) {
435
+ ok(`Pruned ${pruned.length} stale file(s): ${pruned.map(p => p.split('/').pop()).join(', ')}`);
436
+ }
437
+ } catch (e) { /* prune is best-effort — never block install */ void e; }
438
+ }
439
+ }
444
440
 
445
- // 2. npm install (skip for --dev: node_modules is symlinked)
446
- if (IS_DEV) {
447
- ok('Dependencies: using dev dir (symlinked)');
441
+ async function installDependencies(IS_DEV) {
442
+ // 2. npm install (skip for --dev: node_modules is symlinked)
443
+ if (IS_DEV) {
444
+ ok('Dependencies: using dev dir (symlinked)');
445
+ } else {
446
+ log('Ensuring dependencies installed...');
447
+ try {
448
+ // stderr inherited so users see real-time progress (network slowness,
449
+ // node-gyp compile spinner, prebuild-install fallback messages). With
450
+ // `stdio: 'pipe'` the install appeared to hang under the 5-min Bash
451
+ // timeout when better-sqlite3 had no Node v24 prebuild and had to
452
+ // compile from source — see bug audit 2026-05.
453
+ execSync(NPM_INSTALL_CMD, { cwd: INSTALL_DIR, stdio: ['ignore', 'pipe', 'inherit'] });
454
+ ok('Dependencies installed');
455
+ } catch (e) {
456
+ fail('npm install failed: ' + e.message);
457
+ process.exit(1);
458
+ }
459
+ // npm install exits 0 even when the better-sqlite3 prebuilt .node binary
460
+ // mismatches the running Node ABI (e.g. NODE_MODULE_VERSION 137 on Node v24).
461
+ // Probe and auto-rebuild before declaring success — otherwise the next
462
+ // launch FATALs with "Could not locate the bindings file".
463
+ const verify = await ensureBetterSqlite3Working(INSTALL_DIR);
464
+ if (verify.ok) {
465
+ ok(`better-sqlite3: ${verify.action}`);
448
466
  } else {
449
- log('Ensuring dependencies installed...');
450
- try {
451
- // stderr inherited so users see real-time progress (network slowness,
452
- // node-gyp compile spinner, prebuild-install fallback messages). With
453
- // `stdio: 'pipe'` the install appeared to hang under the 5-min Bash
454
- // timeout when better-sqlite3 had no Node v24 prebuild and had to
455
- // compile from source — see bug audit 2026-05.
456
- execSync(NPM_INSTALL_CMD, { cwd: INSTALL_DIR, stdio: ['ignore', 'pipe', 'inherit'] });
457
- ok('Dependencies installed');
458
- } catch (e) {
459
- fail('npm install failed: ' + e.message);
460
- process.exit(1);
461
- }
462
- // npm install exits 0 even when the better-sqlite3 prebuilt .node binary
463
- // mismatches the running Node ABI (e.g. NODE_MODULE_VERSION 137 on Node v24).
464
- // Probe and auto-rebuild before declaring success — otherwise the next
465
- // launch FATALs with "Could not locate the bindings file".
466
- const verify = await ensureBetterSqlite3Working(INSTALL_DIR);
467
- if (verify.ok) {
468
- ok(`better-sqlite3: ${verify.action}`);
469
- } else {
470
- fail(`better-sqlite3 binding unusable after rebuild: ${verify.error}`);
471
- log('Try manually: cd ' + INSTALL_DIR + ' && npm rebuild better-sqlite3 --build-from-source');
472
- process.exit(1);
473
- }
467
+ fail(`better-sqlite3 binding unusable after rebuild: ${verify.error}`);
468
+ log('Try manually: cd ' + INSTALL_DIR + ' && npm rebuild better-sqlite3 --build-from-source');
469
+ process.exit(1);
474
470
  }
471
+ }
472
+ }
475
473
 
476
- // 2b. Create global CLI symlink (claude-mem-lite command)
477
- const cliSource = join(INSTALL_DIR, 'cli.mjs');
478
- if (existsSync(cliSource)) {
479
- try { execFileSync('chmod', ['+x', cliSource], { stdio: 'pipe' }); } catch {}
480
- // Try ~/.local/bin first (user-writable, commonly on PATH)
481
- const localBin = join(homedir(), '.local', 'bin');
482
- const cliLink = join(localBin, 'claude-mem-lite');
474
+ function createCliSymlink() {
475
+ // 2b. Create global CLI symlink (claude-mem-lite command)
476
+ const cliSource = join(INSTALL_DIR, 'cli.mjs');
477
+ if (existsSync(cliSource)) {
478
+ try { execFileSync('chmod', ['+x', cliSource], { stdio: 'pipe' }); } catch {}
479
+ // Try ~/.local/bin first (user-writable, commonly on PATH)
480
+ const localBin = join(homedir(), '.local', 'bin');
481
+ const cliLink = join(localBin, 'claude-mem-lite');
482
+ try {
483
+ if (!existsSync(localBin)) mkdirSync(localBin, { recursive: true });
484
+ if (existsSync(cliLink)) unlinkSync(cliLink);
485
+ symlinkSync(cliSource, cliLink);
486
+ ok(`CLI: ${cliLink} → ${cliSource}`);
487
+ } catch {
488
+ // Fallback: try /usr/local/bin (may need sudo)
483
489
  try {
484
- if (!existsSync(localBin)) mkdirSync(localBin, { recursive: true });
485
- if (existsSync(cliLink)) unlinkSync(cliLink);
486
- symlinkSync(cliSource, cliLink);
487
- ok(`CLI: ${cliLink} → ${cliSource}`);
490
+ const globalLink = '/usr/local/bin/claude-mem-lite';
491
+ if (existsSync(globalLink)) unlinkSync(globalLink);
492
+ symlinkSync(cliSource, globalLink);
493
+ ok(`CLI: ${globalLink} → ${cliSource}`);
488
494
  } catch {
489
- // Fallback: try /usr/local/bin (may need sudo)
490
- try {
491
- const globalLink = '/usr/local/bin/claude-mem-lite';
492
- if (existsSync(globalLink)) unlinkSync(globalLink);
493
- symlinkSync(cliSource, globalLink);
494
- ok(`CLI: ${globalLink} → ${cliSource}`);
495
- } catch {
496
- warn('CLI symlink failed — run manually: ln -sf ' + cliSource + ' ~/.local/bin/claude-mem-lite');
497
- }
495
+ warn('CLI symlink failed — run manually: ln -sf ' + cliSource + ' ~/.local/bin/claude-mem-lite');
498
496
  }
499
497
  }
498
+ }
499
+ }
500
500
 
501
- // 3. Register MCP server (skip if plugin system already handles it)
502
- // Plugin MCP must stay at root .mcp.json so Claude Code registers plugin:*:mem-lite.
503
- // Duplicate registrations in practice come from old global install.mjs state
504
- // (claude mcp add) or stale marketplace copies, not from the cache root itself.
505
- // Global registration via `claude mcp add` creates a DUPLICATE mcp__mem-lite__* server.
506
- // The legacy generic name "mem" (pre-v2.78) is also purged so a user who installed in
507
- // either era ends up with a single canonical "mem-lite" registration.
508
- // Detect plugin mode: installed_plugins.json has our entry plugin handles MCP.
509
- const installedPluginsPath = join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
510
- let pluginHandlesMcp = false;
501
+ function registerMcpServer() {
502
+ // 3. Register MCP server (skip if plugin system already handles it)
503
+ // Plugin MCP must stay at root .mcp.json so Claude Code registers plugin:*:mem-lite.
504
+ // Duplicate registrations in practice come from old global install.mjs state
505
+ // (claude mcp add) or stale marketplace copies, not from the cache root itself.
506
+ // Global registration via `claude mcp add` creates a DUPLICATE mcp__mem-lite__* server.
507
+ // The legacy generic name "mem" (pre-v2.78) is also purged so a user who installed in
508
+ // either era ends up with a single canonical "mem-lite" registration.
509
+ // Detect plugin mode: installed_plugins.json has our entry → plugin handles MCP.
510
+ const installedPluginsPath = join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
511
+ let pluginHandlesMcp = false;
512
+ try {
513
+ const installed = JSON.parse(readFileSync(installedPluginsPath, 'utf8'));
514
+ pluginHandlesMcp = !!installed?.plugins?.[PLUGIN_KEY]?.length;
515
+ } catch { /* not installed via plugin system */ }
516
+
517
+ if (pluginHandlesMcp) {
518
+ log('MCP server: plugin system handles registration (skipping global)');
519
+ // Clean up stale global registrations (both legacy "mem" and current "mem-lite")
520
+ for (const name of ['mem', 'mem-lite']) {
521
+ try {
522
+ execFileSync('claude', ['mcp', 'remove', '-s', 'user', name], { stdio: 'pipe' });
523
+ ok(`Removed stale global MCP "${name}"`);
524
+ } catch {}
525
+ }
526
+ } else {
527
+ log('Registering MCP server...');
511
528
  try {
512
- const installed = JSON.parse(readFileSync(installedPluginsPath, 'utf8'));
513
- pluginHandlesMcp = !!installed?.plugins?.[PLUGIN_KEY]?.length;
514
- } catch { /* not installed via plugin system */ }
515
-
516
- if (pluginHandlesMcp) {
517
- log('MCP server: plugin system handles registration (skipping global)');
518
- // Clean up stale global registrations (both legacy "mem" and current "mem-lite")
529
+ // Purge legacy "mem" and any pre-existing "mem-lite" before re-registering
519
530
  for (const name of ['mem', 'mem-lite']) {
520
- try {
521
- execFileSync('claude', ['mcp', 'remove', '-s', 'user', name], { stdio: 'pipe' });
522
- ok(`Removed stale global MCP "${name}"`);
523
- } catch {}
524
- }
525
- } else {
526
- log('Registering MCP server...');
527
- try {
528
- // Purge legacy "mem" and any pre-existing "mem-lite" before re-registering
529
- for (const name of ['mem', 'mem-lite']) {
530
- try { execFileSync('claude', ['mcp', 'remove', '-s', 'user', name], { stdio: 'pipe' }); } catch {}
531
- try { execFileSync('claude', ['mcp', 'remove', '-s', 'project', name], { stdio: 'pipe' }); } catch {}
532
- }
533
- execFileSync('claude', ['mcp', 'add', '-s', 'user', '-t', 'stdio', 'mem-lite', '--', 'node', SERVER_PATH], { stdio: 'pipe' });
534
- ok('MCP server registered: mem-lite');
535
- } catch (e) {
536
- fail('MCP registration failed: ' + e.message);
537
- warn('Try manually: claude mcp add -s user -t stdio mem-lite -- node ' + SERVER_PATH);
531
+ try { execFileSync('claude', ['mcp', 'remove', '-s', 'user', name], { stdio: 'pipe' }); } catch {}
532
+ try { execFileSync('claude', ['mcp', 'remove', '-s', 'project', name], { stdio: 'pipe' }); } catch {}
538
533
  }
534
+ execFileSync('claude', ['mcp', 'add', '-s', 'user', '-t', 'stdio', 'mem-lite', '--', 'node', SERVER_PATH], { stdio: 'pipe' });
535
+ ok('MCP server registered: mem-lite');
536
+ } catch (e) {
537
+ fail('MCP registration failed: ' + e.message);
538
+ warn('Try manually: claude mcp add -s user -t stdio mem-lite -- node ' + SERVER_PATH);
539
539
  }
540
+ }
541
+ }
540
542
 
541
- // 3b. Deduplicate: if marketplace plugin also registers MCP + hooks,
542
- // clear them to prevent double execution. install.mjs hooks (in settings.json)
543
- // point to ~/.claude-mem-lite/ (latest code in dev mode via symlinks),
544
- // while plugin hooks use ${CLAUDE_PLUGIN_ROOT} (potentially stale marketplace copy).
545
- //
546
- // MCP dedup: Claude Code copies .mcp.json from marketplace clone → plugin cache.
547
- // Do NOT modify marketplace .mcp.json it breaks the MCP server registration chain.
548
- // Dedup is handled by skipping global `claude mcp add` when plugin system is active.
549
- const pluginDir = join(homedir(), '.claude', 'plugins', 'marketplaces', MARKETPLACE_KEY);
550
- const pluginHooksPath = join(pluginDir, 'hooks', 'hooks.json');
551
-
552
- if (existsSync(pluginDir)) {
553
- // NOTE: Do NOT clear marketplace .mcp.json — Claude Code copies from
554
- // marketplace clone plugin cache on updates. Clearing it causes the
555
- // cache .mcp.json to lose the MCP server definition, breaking plugin MCP.
556
- // Dedup is already handled by skipping global `claude mcp add` above.
557
-
558
- // Clear plugin hooks to prevent double hook execution
559
- try {
560
- if (existsSync(pluginHooksPath)) {
561
- const pluginHooks = JSON.parse(readFileSync(pluginHooksPath, 'utf8'));
562
- if (pluginHooks.hooks && Object.keys(pluginHooks.hooks).length > 0) {
563
- writeFileSync(pluginHooksPath, JSON.stringify({
564
- description: pluginHooks.description || 'claude-mem-lite hooks',
565
- _note: 'Hooks managed by install.mjs in settings.json — this file cleared to prevent duplicates',
566
- hooks: {}
567
- }, null, 2) + '\n');
568
- ok('Marketplace plugin: hooks cleared (prevents duplicate)');
569
- }
543
+ function dedupePluginCacheAndHooks() {
544
+ // 3b. Deduplicate: if marketplace plugin also registers MCP + hooks,
545
+ // clear them to prevent double execution. install.mjs hooks (in settings.json)
546
+ // point to ~/.claude-mem-lite/ (latest code in dev mode via symlinks),
547
+ // while plugin hooks use ${CLAUDE_PLUGIN_ROOT} (potentially stale marketplace copy).
548
+ //
549
+ // MCP dedup: Claude Code copies .mcp.json from marketplace clone plugin cache.
550
+ // Do NOT modify marketplace .mcp.json it breaks the MCP server registration chain.
551
+ // Dedup is handled by skipping global `claude mcp add` when plugin system is active.
552
+ const pluginDir = join(homedir(), '.claude', 'plugins', 'marketplaces', MARKETPLACE_KEY);
553
+ const pluginHooksPath = join(pluginDir, 'hooks', 'hooks.json');
554
+
555
+ if (existsSync(pluginDir)) {
556
+ // NOTE: Do NOT clear marketplace .mcp.json Claude Code copies from
557
+ // marketplace clone plugin cache on updates. Clearing it causes the
558
+ // cache .mcp.json to lose the MCP server definition, breaking plugin MCP.
559
+ // Dedup is already handled by skipping global `claude mcp add` above.
560
+
561
+ // Clear plugin hooks to prevent double hook execution
562
+ try {
563
+ if (existsSync(pluginHooksPath)) {
564
+ const pluginHooks = JSON.parse(readFileSync(pluginHooksPath, 'utf8'));
565
+ if (pluginHooks.hooks && Object.keys(pluginHooks.hooks).length > 0) {
566
+ writeFileSync(pluginHooksPath, JSON.stringify({
567
+ description: pluginHooks.description || 'claude-mem-lite hooks',
568
+ _note: 'Hooks managed by install.mjs in settings.json — this file cleared to prevent duplicates',
569
+ hooks: {}
570
+ }, null, 2) + '\n');
571
+ ok('Marketplace plugin: hooks cleared (prevents duplicate)');
570
572
  }
571
- } catch (e) { warn(`Marketplace hooks dedup: ${e.message}`); }
573
+ }
574
+ } catch (e) { warn(`Marketplace hooks dedup: ${e.message}`); }
572
575
 
573
- // Sync launch.mjs to plugin cache — ensures MCP server loads dev code via symlink detection.
574
- // ALSO clear cached hooks.json in every version dir — Claude Code runtime reads hooks from
575
- // ~/.claude/plugins/cache/<mp>/<plugin>/<ver>/hooks/hooks.json, NOT from the marketplace source.
576
- // Clearing only the marketplace source (above) leaves stale cache copies that double-register
577
- // hooks alongside install.mjs-written settings.json entries.
578
- try {
579
- const cacheBase = join(homedir(), '.claude', 'plugins', 'cache', MARKETPLACE_KEY, 'claude-mem-lite');
580
- if (existsSync(cacheBase)) {
581
- const launchSyncFiles = ['launch.mjs', 'launch-preflight.mjs'];
582
- let clearedHooks = 0;
583
- for (const ver of readdirSync(cacheBase)) {
584
- const verDir = join(cacheBase, ver);
585
-
586
- // Sync launch.mjs + its preflight companion (issue #15)
587
- if (existsSync(join(verDir, 'scripts'))) {
588
- for (const f of launchSyncFiles) {
589
- const src = join(PROJECT_DIR, 'scripts', f);
590
- if (existsSync(src)) {
591
- try { copyFileSync(src, join(verDir, 'scripts', f)); } catch { /* keep going */ }
592
- }
576
+ // Sync launch.mjs to plugin cache — ensures MCP server loads dev code via symlink detection.
577
+ // ALSO clear cached hooks.json in every version dir — Claude Code runtime reads hooks from
578
+ // ~/.claude/plugins/cache/<mp>/<plugin>/<ver>/hooks/hooks.json, NOT from the marketplace source.
579
+ // Clearing only the marketplace source (above) leaves stale cache copies that double-register
580
+ // hooks alongside install.mjs-written settings.json entries.
581
+ try {
582
+ const cacheBase = join(homedir(), '.claude', 'plugins', 'cache', MARKETPLACE_KEY, 'claude-mem-lite');
583
+ if (existsSync(cacheBase)) {
584
+ const launchSyncFiles = ['launch.mjs', 'launch-preflight.mjs'];
585
+ let clearedHooks = 0;
586
+ for (const ver of readdirSync(cacheBase)) {
587
+ const verDir = join(cacheBase, ver);
588
+
589
+ // Sync launch.mjs + its preflight companion (issue #15)
590
+ if (existsSync(join(verDir, 'scripts'))) {
591
+ for (const f of launchSyncFiles) {
592
+ const src = join(PROJECT_DIR, 'scripts', f);
593
+ if (existsSync(src)) {
594
+ try { copyFileSync(src, join(verDir, 'scripts', f)); } catch { /* keep going */ }
593
595
  }
594
596
  }
597
+ }
595
598
 
596
- // Clear cached hooks.json (runtime reads here, not marketplace source)
597
- const cachedHooksPath = join(verDir, 'hooks', 'hooks.json');
598
- if (existsSync(cachedHooksPath)) {
599
- try {
600
- const h = JSON.parse(readFileSync(cachedHooksPath, 'utf8'));
601
- if (h.hooks && Object.keys(h.hooks).length > 0) {
602
- writeFileSync(cachedHooksPath, JSON.stringify({
603
- description: h.description || 'claude-mem-lite hooks',
604
- _note: `Hooks managed by install.mjs in settings.json — cache hooks.json cleared to prevent duplicate registration (cache ver: ${ver})`,
605
- hooks: {}
606
- }, null, 2) + '\n');
607
- clearedHooks++;
608
- }
609
- } catch { /* silent — never block install on one bad cache entry */ }
610
- }
599
+ // Clear cached hooks.json (runtime reads here, not marketplace source)
600
+ const cachedHooksPath = join(verDir, 'hooks', 'hooks.json');
601
+ if (existsSync(cachedHooksPath)) {
602
+ try {
603
+ const h = JSON.parse(readFileSync(cachedHooksPath, 'utf8'));
604
+ if (h.hooks && Object.keys(h.hooks).length > 0) {
605
+ writeFileSync(cachedHooksPath, JSON.stringify({
606
+ description: h.description || 'claude-mem-lite hooks',
607
+ _note: `Hooks managed by install.mjs in settings.json — cache hooks.json cleared to prevent duplicate registration (cache ver: ${ver})`,
608
+ hooks: {}
609
+ }, null, 2) + '\n');
610
+ clearedHooks++;
611
+ }
612
+ } catch { /* silent — never block install on one bad cache entry */ }
611
613
  }
612
- const parts = ['launch.mjs synced (dev mode MCP routing)'];
613
- if (clearedHooks > 0) parts.push(`${clearedHooks} stale hooks.json cleared`);
614
- ok(`Plugin cache: ${parts.join('; ')}`);
615
614
  }
616
- } catch (e) { warn(`Plugin cache sync: ${e.message}`); }
617
- }
615
+ const parts = ['launch.mjs synced (dev mode MCP routing)'];
616
+ if (clearedHooks > 0) parts.push(`${clearedHooks} stale hooks.json cleared`);
617
+ ok(`Plugin cache: ${parts.join('; ')}`);
618
+ }
619
+ } catch (e) { warn(`Plugin cache sync: ${e.message}`); }
620
+ }
621
+ }
618
622
 
619
- // 4. Configure hooks (merge: preserve user's existing hooks, replace ours)
620
- log('Configuring hooks...');
621
- const settings = readSettings();
622
- if (clearPluginDisabledMarkerForDirectInstall(settings)) {
623
- ok('Cleared stale disabled plugin flag so install.mjs-managed hooks can run');
624
- }
625
- settings.hooks = settings.hooks || {};
626
-
627
- const SCRIPTS_PATH = join(INSTALL_DIR, 'scripts');
628
- const PREFILTER_PATH = join(SCRIPTS_PATH, 'post-tool-use.sh');
629
- // v2.84: every Node hook invocation routes through hook-launcher.mjs so an
630
- // ERR_MODULE_NOT_FOUND from a partial-install drift auto-heals via
631
- // install.mjs repair instead of permanently bricking the hook chain.
632
- const LAUNCHER_PATH = join(SCRIPTS_PATH, 'hook-launcher.mjs');
633
- const nodeHook = (entry, ...args) => `node "${LAUNCHER_PATH}" ${entry} ${args.join(' ')}`.trim();
634
-
635
- const memPostToolUse = {
636
- matcher: '*',
637
- hooks: [{
623
+ function configureHooks() {
624
+ // 4. Configure hooks (merge: preserve user's existing hooks, replace ours)
625
+ log('Configuring hooks...');
626
+ const settings = readSettings();
627
+ if (clearPluginDisabledMarkerForDirectInstall(settings)) {
628
+ ok('Cleared stale disabled plugin flag so install.mjs-managed hooks can run');
629
+ }
630
+ settings.hooks = settings.hooks || {};
631
+
632
+ const SCRIPTS_PATH = join(INSTALL_DIR, 'scripts');
633
+ const PREFILTER_PATH = join(SCRIPTS_PATH, 'post-tool-use.sh');
634
+ // v2.84: every Node hook invocation routes through hook-launcher.mjs so an
635
+ // ERR_MODULE_NOT_FOUND from a partial-install drift auto-heals via
636
+ // install.mjs repair instead of permanently bricking the hook chain.
637
+ const LAUNCHER_PATH = join(SCRIPTS_PATH, 'hook-launcher.mjs');
638
+ const nodeHook = (entry, ...args) => `node "${LAUNCHER_PATH}" ${entry} ${args.join(' ')}`.trim();
639
+
640
+ const memPostToolUse = {
641
+ matcher: '*',
642
+ hooks: [{
643
+ type: 'command',
644
+ command: `bash "${PREFILTER_PATH}"`,
645
+ timeout: 5
646
+ }]
647
+ };
648
+
649
+ const memSessionStart = {
650
+ matcher: 'startup|clear|compact',
651
+ hooks: [{
652
+ type: 'command',
653
+ command: nodeHook('hook.mjs', 'session-start'),
654
+ timeout: 10
655
+ }]
656
+ };
657
+
658
+ const memStop = {
659
+ matcher: '*',
660
+ hooks: [{
661
+ type: 'command',
662
+ command: nodeHook('hook.mjs', 'stop'),
663
+ timeout: 5
664
+ }]
665
+ };
666
+
667
+ const memUserPrompt = {
668
+ matcher: '*',
669
+ hooks: [
670
+ {
638
671
  type: 'command',
639
- command: `bash "${PREFILTER_PATH}"`,
672
+ command: nodeHook('scripts/user-prompt-search.js'),
673
+ timeout: 2
674
+ },
675
+ {
676
+ type: 'command',
677
+ command: nodeHook('hook.mjs', 'user-prompt'),
640
678
  timeout: 5
641
- }]
642
- };
643
-
644
- const memSessionStart = {
645
- matcher: 'startup|clear|compact',
646
- hooks: [{
679
+ }
680
+ ]
681
+ };
682
+
683
+ const memPreToolRecall = {
684
+ // v2.34.6: Read added to cover planning-Read (pre-Edit exploration).
685
+ // Read-path uses a tighter filter (lesson_learned required, top-1,
686
+ // 120-char truncation, silent-on-empty) — see scripts/pre-tool-recall.js.
687
+ matcher: 'Edit|Write|NotebookEdit|Read',
688
+ hooks: [
689
+ {
647
690
  type: 'command',
648
- command: nodeHook('hook.mjs', 'session-start'),
649
- timeout: 10
650
- }]
651
- };
691
+ command: nodeHook('scripts/pre-tool-recall.js'),
692
+ timeout: 3
693
+ }
694
+ ]
695
+ };
652
696
 
653
- const memStop = {
654
- matcher: '*',
655
- hooks: [{
697
+ const memPreSkillBridge = {
698
+ matcher: 'Skill',
699
+ hooks: [
700
+ {
656
701
  type: 'command',
657
- command: nodeHook('hook.mjs', 'stop'),
658
- timeout: 5
659
- }]
660
- };
661
-
662
- const memUserPrompt = {
663
- matcher: '*',
664
- hooks: [
665
- {
666
- type: 'command',
667
- command: nodeHook('scripts/user-prompt-search.js'),
668
- timeout: 2
669
- },
670
- {
671
- type: 'command',
672
- command: nodeHook('hook.mjs', 'user-prompt'),
673
- timeout: 5
674
- }
675
- ]
676
- };
677
-
678
- const memPreToolRecall = {
679
- // v2.34.6: Read added to cover planning-Read (pre-Edit exploration).
680
- // Read-path uses a tighter filter (lesson_learned required, top-1,
681
- // 120-char truncation, silent-on-empty) — see scripts/pre-tool-recall.js.
682
- matcher: 'Edit|Write|NotebookEdit|Read',
683
- hooks: [
684
- {
685
- type: 'command',
686
- command: nodeHook('scripts/pre-tool-recall.js'),
687
- timeout: 3
688
- }
689
- ]
690
- };
702
+ command: nodeHook('scripts/pre-skill-bridge.js'),
703
+ timeout: 3
704
+ }
705
+ ]
706
+ };
707
+
708
+ // Filter out existing mem hooks, then append fresh ones
709
+ // PreToolUse has two separate matchers, so we register both
710
+ const hookConfigs = {
711
+ PreToolUse: [memPreToolRecall, memPreSkillBridge],
712
+ PostToolUse: [memPostToolUse],
713
+ SessionStart: [memSessionStart],
714
+ Stop: [memStop],
715
+ UserPromptSubmit: [memUserPrompt],
716
+ };
717
+
718
+ for (const [event, configs] of Object.entries(hookConfigs)) {
719
+ const existing = Array.isArray(settings.hooks[event]) ? settings.hooks[event].filter(cfg => !isMemHook(cfg)) : [];
720
+ settings.hooks[event] = [...existing, ...configs];
721
+ }
691
722
 
692
- const memPreSkillBridge = {
693
- matcher: 'Skill',
694
- hooks: [
695
- {
696
- type: 'command',
697
- command: nodeHook('scripts/pre-skill-bridge.js'),
698
- timeout: 3
699
- }
700
- ]
701
- };
723
+ writeSettings(settings);
724
+ ok('Hooks configured (PreToolUse, PostToolUse, SessionStart, Stop, UserPromptSubmit)');
725
+ }
702
726
 
703
- // Filter out existing mem hooks, then append fresh ones
704
- // PreToolUse has two separate matchers, so we register both
705
- const hookConfigs = {
706
- PreToolUse: [memPreToolRecall, memPreSkillBridge],
707
- PostToolUse: [memPostToolUse],
708
- SessionStart: [memSessionStart],
709
- Stop: [memStop],
710
- UserPromptSubmit: [memUserPrompt],
711
- };
727
+ function backupLegacyClaudeMemData() {
728
+ // 5. Legacy ~/.claude-mem/ ~/.claude-mem-lite/ back up, don't reuse.
729
+ // The legacy DB is schema v16 (schema_versions plural) and there's no
730
+ // bridge in MIGRATIONS[] to v28. Reusing it FATALs on first launch with
731
+ // "no such column: memory_session_id". Rename to a timestamped backup
732
+ // so the new install creates a fresh v28 DB.
733
+ try {
734
+ const r = migrateLegacyClaudeMemData(OLD_DATA_DIR, MEM_DATA_DIR);
735
+ if (r.action === 'backed-up') {
736
+ ok(`Legacy ~/.claude-mem/ DB backed up to ${r.backupPath}`);
737
+ log('New v28 DB will be created on first launch (legacy schema is incompatible).');
738
+ }
739
+ } catch (e) {
740
+ warn('Legacy DB backup failed: ' + e.message);
741
+ }
712
742
 
713
- for (const [event, configs] of Object.entries(hookConfigs)) {
714
- const existing = Array.isArray(settings.hooks[event]) ? settings.hooks[event].filter(cfg => !isMemHook(cfg)) : [];
715
- settings.hooks[event] = [...existing, ...configs];
743
+ // 5b. Rename claude-mem.db claude-mem-lite.db in same directory
744
+ const oldDbInDir = join(MEM_DATA_DIR, 'claude-mem.db');
745
+ if (existsSync(oldDbInDir) && !existsSync(DB_PATH)) {
746
+ renameSync(oldDbInDir, DB_PATH);
747
+ for (const ext of ['-wal', '-shm']) {
748
+ if (existsSync(oldDbInDir + ext)) try { renameSync(oldDbInDir + ext, DB_PATH + ext); } catch {}
716
749
  }
750
+ ok('Database renamed: claude-mem.db → claude-mem-lite.db');
751
+ }
752
+ }
717
753
 
718
- writeSettings(settings);
719
- ok('Hooks configured (PreToolUse, PostToolUse, SessionStart, Stop, UserPromptSubmit)');
720
-
721
- // 5. Legacy ~/.claude-mem/ ~/.claude-mem-lite/ — back up, don't reuse.
722
- // The legacy DB is schema v16 (schema_versions plural) and there's no
723
- // bridge in MIGRATIONS[] to v28. Reusing it FATALs on first launch with
724
- // "no such column: memory_session_id". Rename to a timestamped backup
725
- // so the new install creates a fresh v28 DB.
726
- try {
727
- const r = migrateLegacyClaudeMemData(OLD_DATA_DIR, MEM_DATA_DIR);
728
- if (r.action === 'backed-up') {
729
- ok(`Legacy ~/.claude-mem/ DB backed up to ${r.backupPath}`);
730
- log('New v28 DB will be created on first launch (legacy schema is incompatible).');
754
+ async function installPreinstalledResources() {
755
+ // 6. Install pre-installed resources (skills + agents)
756
+ if (process.env.CLAUDE_MEM_SKIP_REPOS) {
757
+ ok('Skill/agent registry: skipped (CLAUDE_MEM_SKIP_REPOS)');
758
+ } else try {
759
+ const manifestPath = join(INSTALL_DIR, 'registry', 'preinstalled.json');
760
+ if (!existsSync(manifestPath)) {
761
+ // For git-clone mode, check PROJECT_DIR
762
+ const altPath = join(PROJECT_DIR, 'registry', 'preinstalled.json');
763
+ if (existsSync(altPath)) {
764
+ const registryDir = join(INSTALL_DIR, 'registry');
765
+ if (!existsSync(registryDir)) mkdirSync(registryDir, { recursive: true });
766
+ copyFileSync(altPath, manifestPath);
731
767
  }
732
- } catch (e) {
733
- warn('Legacy DB backup failed: ' + e.message);
734
768
  }
735
769
 
736
- // 5b. Rename claude-mem.db → claude-mem-lite.db in same directory
737
- const oldDbInDir = join(MEM_DATA_DIR, 'claude-mem.db');
738
- if (existsSync(oldDbInDir) && !existsSync(DB_PATH)) {
739
- renameSync(oldDbInDir, DB_PATH);
740
- for (const ext of ['-wal', '-shm']) {
741
- if (existsSync(oldDbInDir + ext)) try { renameSync(oldDbInDir + ext, DB_PATH + ext); } catch {}
742
- }
743
- ok('Database renamed: claude-mem.db → claude-mem-lite.db');
744
- }
745
-
746
- // 6. Install pre-installed resources (skills + agents)
747
- if (process.env.CLAUDE_MEM_SKIP_REPOS) {
748
- ok('Skill/agent registry: skipped (CLAUDE_MEM_SKIP_REPOS)');
749
- } else try {
750
- const manifestPath = join(INSTALL_DIR, 'registry', 'preinstalled.json');
751
- if (!existsSync(manifestPath)) {
752
- // For git-clone mode, check PROJECT_DIR
753
- const altPath = join(PROJECT_DIR, 'registry', 'preinstalled.json');
754
- if (existsSync(altPath)) {
755
- const registryDir = join(INSTALL_DIR, 'registry');
756
- if (!existsSync(registryDir)) mkdirSync(registryDir, { recursive: true });
757
- copyFileSync(altPath, manifestPath);
758
- }
759
- }
760
-
761
- if (existsSync(manifestPath)) {
762
- const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
763
- const resources = manifest.resources || [];
764
-
765
- if (resources.length > 0) {
766
- const managedDir = join(MEM_DATA_DIR, 'managed');
770
+ if (existsSync(manifestPath)) {
771
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
772
+ const resources = manifest.resources || [];
767
773
 
768
- // 6a. Git shallow clone unique repos
769
- const repos = new Map();
770
- for (const r of resources) {
771
- if (!repos.has(r.repo)) repos.set(r.repo, []);
772
- repos.get(r.repo).push(r);
773
- }
774
+ if (resources.length > 0) {
775
+ const managedDir = join(MEM_DATA_DIR, 'managed');
774
776
 
775
- let cloned = 0, updated = 0;
776
- const deadRepos = new Set(); // repos that no longer exist (404)
777
+ // 6a. Git shallow clone unique repos
778
+ const repos = new Map();
779
+ for (const r of resources) {
780
+ if (!repos.has(r.repo)) repos.set(r.repo, []);
781
+ repos.get(r.repo).push(r);
782
+ }
777
783
 
778
- const isRepoNotFound = (err) => {
779
- const msg = (err?.stderr ? err.stderr.toString() : '') + (err?.message || '');
780
- return /repository.*not found|404/i.test(msg);
784
+ let cloned = 0, updated = 0;
785
+ const deadRepos = new Set(); // repos that no longer exist (404)
786
+
787
+ const isRepoNotFound = (err) => {
788
+ const msg = (err?.stderr ? err.stderr.toString() : '') + (err?.message || '');
789
+ return /repository.*not found|404/i.test(msg);
790
+ };
791
+
792
+ for (const [repoUrl, entries] of repos) {
793
+ const repoName = repoUrl.split('/').slice(-2).join('-').replace(/[^a-zA-Z0-9._-]/g, '_');
794
+ const clonePath = join(managedDir, 'repos', repoName);
795
+ let repoReady = false;
796
+
797
+ const plan = planRepoSparsePaths(entries);
798
+ const cloneUrl = `${repoUrl.replace(/\.git$/, '')}.git`;
799
+ // Clone only what we extract: a partial (blob:none) + sparse clone fetches
800
+ // just the manifest subpaths' subtrees instead of the whole repo. Falls
801
+ // back to a plain shallow clone if partial-clone/sparse-checkout is
802
+ // unsupported (old git/server) — identical to the prior behavior.
803
+ const cloneSlim = () => {
804
+ if (plan.full) {
805
+ execFileSync('git', ['clone', '--depth', '1', cloneUrl, clonePath], { stdio: 'pipe', timeout: 30000 });
806
+ return;
807
+ }
808
+ try {
809
+ execFileSync('git', ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', cloneUrl, clonePath], { stdio: 'pipe', timeout: 30000 });
810
+ execFileSync('git', ['-C', clonePath, 'sparse-checkout', 'set', '--no-cone', ...plan.paths], { stdio: 'pipe', timeout: 30000 });
811
+ execFileSync('git', ['-C', clonePath, 'checkout'], { stdio: 'pipe', timeout: 30000 });
812
+ } catch {
813
+ try { rmSync(clonePath, { recursive: true, force: true }); } catch {}
814
+ execFileSync('git', ['clone', '--depth', '1', cloneUrl, clonePath], { stdio: 'pipe', timeout: 30000 });
815
+ }
781
816
  };
782
817
 
783
- for (const [repoUrl, entries] of repos) {
784
- const repoName = repoUrl.split('/').slice(-2).join('-').replace(/[^a-zA-Z0-9._-]/g, '_');
785
- const clonePath = join(managedDir, 'repos', repoName);
786
- let repoReady = false;
787
-
788
- const plan = planRepoSparsePaths(entries);
789
- const cloneUrl = `${repoUrl.replace(/\.git$/, '')}.git`;
790
- // Clone only what we extract: a partial (blob:none) + sparse clone fetches
791
- // just the manifest subpaths' subtrees instead of the whole repo. Falls
792
- // back to a plain shallow clone if partial-clone/sparse-checkout is
793
- // unsupported (old git/server) — identical to the prior behavior.
794
- const cloneSlim = () => {
795
- if (plan.full) {
796
- execFileSync('git', ['clone', '--depth', '1', cloneUrl, clonePath], { stdio: 'pipe', timeout: 30000 });
797
- return;
798
- }
799
- try {
800
- execFileSync('git', ['clone', '--depth', '1', '--filter=blob:none', '--no-checkout', cloneUrl, clonePath], { stdio: 'pipe', timeout: 30000 });
801
- execFileSync('git', ['-C', clonePath, 'sparse-checkout', 'set', '--no-cone', ...plan.paths], { stdio: 'pipe', timeout: 30000 });
802
- execFileSync('git', ['-C', clonePath, 'checkout'], { stdio: 'pipe', timeout: 30000 });
803
- } catch {
804
- try { rmSync(clonePath, { recursive: true, force: true }); } catch {}
805
- execFileSync('git', ['clone', '--depth', '1', cloneUrl, clonePath], { stdio: 'pipe', timeout: 30000 });
806
- }
807
- };
818
+ // Migrate a legacy full clone: drop it so the fresh-clone path below
819
+ // rebuilds it slim. managed/repos is a rebuildable cache, so this loses
820
+ // nothing and reclaims the bulk of its footprint on the next install run.
821
+ if (!plan.full && existsSync(clonePath) && !isPartialSparseClone(clonePath)) {
822
+ try { rmSync(clonePath, { recursive: true, force: true }); } catch {}
823
+ }
808
824
 
809
- // Migrate a legacy full clone: drop it so the fresh-clone path below
810
- // rebuilds it slim. managed/repos is a rebuildable cache, so this loses
811
- // nothing and reclaims the bulk of its footprint on the next install run.
812
- if (!plan.full && existsSync(clonePath) && !isPartialSparseClone(clonePath)) {
813
- try { rmSync(clonePath, { recursive: true, force: true }); } catch {}
825
+ if (!existsSync(clonePath)) {
826
+ // Fresh clone (also the rebuild path for a just-migrated legacy clone)
827
+ try {
828
+ mkdirSync(join(managedDir, 'repos'), { recursive: true });
829
+ cloneSlim();
830
+ cloned++;
831
+ repoReady = true;
832
+ } catch (err) {
833
+ if (isRepoNotFound(err)) {
834
+ deadRepos.add(repoUrl);
835
+ warn(` Repo not found (removed?): ${repoUrl}`);
836
+ } else {
837
+ warn(` Clone failed: ${repoUrl}`);
838
+ }
839
+ continue;
814
840
  }
815
-
816
- if (!existsSync(clonePath)) {
817
- // Fresh clone (also the rebuild path for a just-migrated legacy clone)
818
- try {
819
- mkdirSync(join(managedDir, 'repos'), { recursive: true });
820
- cloneSlim();
821
- cloned++;
822
- repoReady = true;
823
- } catch (err) {
824
- if (isRepoNotFound(err)) {
825
- deadRepos.add(repoUrl);
826
- warn(` Repo not found (removed?): ${repoUrl}`);
827
- } else {
828
- warn(` Clone failed: ${repoUrl}`);
829
- }
830
- continue;
841
+ } else {
842
+ // Update existing: fetch latest and fast-forward
843
+ try {
844
+ // Re-assert the sparse set so a newer manifest that adds a subpath to
845
+ // an already-slim clone checks it out (idempotent; no-op for full clones).
846
+ if (!plan.full && isPartialSparseClone(clonePath)) {
847
+ try { execFileSync('git', ['-C', clonePath, 'sparse-checkout', 'set', '--no-cone', ...plan.paths], { stdio: 'pipe', timeout: 30000 }); } catch {}
831
848
  }
832
- } else {
833
- // Update existing: fetch latest and fast-forward
834
- try {
835
- // Re-assert the sparse set so a newer manifest that adds a subpath to
836
- // an already-slim clone checks it out (idempotent; no-op for full clones).
837
- if (!plan.full && isPartialSparseClone(clonePath)) {
838
- try { execFileSync('git', ['-C', clonePath, 'sparse-checkout', 'set', '--no-cone', ...plan.paths], { stdio: 'pipe', timeout: 30000 }); } catch {}
839
- }
840
- const localHash = execFileSync('git', ['-C', clonePath, 'rev-parse', 'HEAD'], { encoding: 'utf8', stdio: 'pipe' }).trim();
841
- execFileSync('git', ['-C', clonePath, 'fetch', '--depth', '1', 'origin'], { stdio: 'pipe', timeout: 30000 });
842
- const remoteHash = execFileSync('git', ['-C', clonePath, 'rev-parse', 'FETCH_HEAD'], { encoding: 'utf8', stdio: 'pipe' }).trim();
843
- if (localHash !== remoteHash) {
844
- execFileSync('git', ['-C', clonePath, 'reset', '--hard', 'FETCH_HEAD'], { stdio: 'pipe' });
845
- updated++;
846
- repoReady = true; // needs re-copy
847
- }
848
- } catch (err) {
849
- if (isRepoNotFound(err)) {
850
- deadRepos.add(repoUrl);
851
- warn(` Repo not found (removed?): ${repoUrl} — cleaning up`);
852
- // Remove local clone
853
- try { rmSync(clonePath, { recursive: true, force: true }); } catch {}
854
- // Remove extracted resources
855
- for (const entry of entries) {
856
- const destDir = join(managedDir, entry.type === 'skill' ? 'skills' : 'agents');
857
- const destPath = join(destDir, entry.name);
858
- try { if (existsSync(destPath)) rmSync(destPath, { recursive: true, force: true }); } catch {}
859
- }
860
- continue;
861
- }
862
- // Transient failure — use existing clone as-is
849
+ const localHash = execFileSync('git', ['-C', clonePath, 'rev-parse', 'HEAD'], { encoding: 'utf8', stdio: 'pipe' }).trim();
850
+ execFileSync('git', ['-C', clonePath, 'fetch', '--depth', '1', 'origin'], { stdio: 'pipe', timeout: 30000 });
851
+ const remoteHash = execFileSync('git', ['-C', clonePath, 'rev-parse', 'FETCH_HEAD'], { encoding: 'utf8', stdio: 'pipe' }).trim();
852
+ if (localHash !== remoteHash) {
853
+ execFileSync('git', ['-C', clonePath, 'reset', '--hard', 'FETCH_HEAD'], { stdio: 'pipe' });
854
+ updated++;
855
+ repoReady = true; // needs re-copy
863
856
  }
864
- }
865
-
866
- // Copy resources to managed/skills/ or managed/agents/
867
- // Re-copy if repo was freshly cloned or updated
868
- mkdirSync(join(managedDir, 'skills'), { recursive: true });
869
- mkdirSync(join(managedDir, 'agents'), { recursive: true });
870
- for (const entry of entries) {
871
- // Path traversal guard: reject entries with '..' or absolute paths
872
- if (entry.path.includes('..') || entry.name.includes('..') ||
873
- isAbsolute(entry.path) || isAbsolute(entry.name)) continue;
874
- const srcPath = entry.path === '.' ? clonePath : join(clonePath, entry.path);
875
- const destDir = join(managedDir, entry.type === 'skill' ? 'skills' : 'agents');
876
- const destPath = join(destDir, entry.name);
877
- if (existsSync(srcPath) && (repoReady || !existsSync(destPath))) {
878
- try {
879
- if (existsSync(destPath)) rmSync(destPath, { recursive: true, force: true });
880
- cpSync(srcPath, destPath, { recursive: true });
881
- } catch {}
857
+ } catch (err) {
858
+ if (isRepoNotFound(err)) {
859
+ deadRepos.add(repoUrl);
860
+ warn(` Repo not found (removed?): ${repoUrl} cleaning up`);
861
+ // Remove local clone
862
+ try { rmSync(clonePath, { recursive: true, force: true }); } catch {}
863
+ // Remove extracted resources
864
+ for (const entry of entries) {
865
+ const destDir = join(managedDir, entry.type === 'skill' ? 'skills' : 'agents');
866
+ const destPath = join(destDir, entry.name);
867
+ try { if (existsSync(destPath)) rmSync(destPath, { recursive: true, force: true }); } catch {}
868
+ }
869
+ continue;
882
870
  }
871
+ // Transient failure — use existing clone as-is
883
872
  }
884
873
  }
885
- ok(`Repos: ${cloned} cloned, ${updated} updated, ${repos.size - deadRepos.size} active` +
886
- (deadRepos.size > 0 ? `, ${deadRepos.size} dead removed` : ''));
887
-
888
- // 6b. Init registry DB and record preinstalled entries
889
- const { ensureRegistryDb } = await importFromInstall('registry.mjs');
890
- const regDbPath = join(MEM_DATA_DIR, 'resource-registry.db');
891
- const rdb = ensureRegistryDb(regDbPath);
892
-
893
- const insertPre = rdb.prepare(`
894
- INSERT OR REPLACE INTO preinstalled (name, type, repo_url, repo_path, tags, enabled)
895
- VALUES (?, ?, ?, ?, ?, 1)
896
- `);
897
- const activeResources = deadRepos.size > 0
898
- ? resources.filter(r => !deadRepos.has(r.repo))
899
- : resources;
900
- for (const r of activeResources) {
901
- insertPre.run(r.name, r.type, r.repo, r.path, JSON.stringify(r.tags || []));
902
- }
903
874
 
904
- // Clean up DB entries for dead repos
905
- if (deadRepos.size > 0) {
906
- const delPre = rdb.prepare('DELETE FROM preinstalled WHERE repo_url = ?');
907
- const delRes = rdb.prepare('DELETE FROM resources WHERE repo_url = ?');
908
- for (const deadUrl of deadRepos) {
909
- try { delPre.run(deadUrl); } catch {}
910
- try { delRes.run(deadUrl); } catch {}
911
- }
912
- }
913
- ok(`Registry DB initialized (${activeResources.length} preinstalled entries` +
914
- (deadRepos.size > 0 ? `, ${deadRepos.size} dead repos purged` : '') + ')');
915
-
916
- // 6c. Fetch GitHub stars (best-effort, unauthenticated)
917
- log(' Fetching GitHub stars...');
918
- const starCache = new Map();
919
- for (const [repoUrl] of repos) {
920
- if (deadRepos.has(repoUrl)) continue;
921
- const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
922
- if (match) {
875
+ // Copy resources to managed/skills/ or managed/agents/
876
+ // Re-copy if repo was freshly cloned or updated
877
+ mkdirSync(join(managedDir, 'skills'), { recursive: true });
878
+ mkdirSync(join(managedDir, 'agents'), { recursive: true });
879
+ for (const entry of entries) {
880
+ // Path traversal guard: reject entries with '..' or absolute paths
881
+ if (entry.path.includes('..') || entry.name.includes('..') ||
882
+ isAbsolute(entry.path) || isAbsolute(entry.name)) continue;
883
+ const srcPath = entry.path === '.' ? clonePath : join(clonePath, entry.path);
884
+ const destDir = join(managedDir, entry.type === 'skill' ? 'skills' : 'agents');
885
+ const destPath = join(destDir, entry.name);
886
+ if (existsSync(srcPath) && (repoReady || !existsSync(destPath))) {
923
887
  try {
924
- const apiUrl = `https://api.github.com/repos/${match[1]}/${match[2]}`;
925
- const res = execFileSync('curl', ['-sf', apiUrl], { encoding: 'utf8', timeout: 10000 });
926
- const data = JSON.parse(res);
927
- if (typeof data.stargazers_count === 'number') {
928
- starCache.set(repoUrl, data.stargazers_count);
929
- }
888
+ if (existsSync(destPath)) rmSync(destPath, { recursive: true, force: true });
889
+ cpSync(srcPath, destPath, { recursive: true });
930
890
  } catch {}
931
891
  }
932
892
  }
933
- if (starCache.size > 0) ok(`Stars fetched (${starCache.size}/${repos.size} repos)`);
934
-
935
- // 6d. Scan and index resources (fallback-only, Haiku indexing deferred to first run)
936
- log(' Scanning resources...');
937
- const { scanAllResources, diffResources } = await importFromInstall('registry-scanner.mjs');
938
- const scanned = scanAllResources({ dataDir: MEM_DATA_DIR });
939
-
940
- // Attach star counts and repo URLs
941
- for (const s of scanned) {
942
- const entry = resources.find(r => r.name === s.name && r.type === s.type);
943
- if (entry) {
944
- s.repoUrl = entry.repo;
945
- s.repoStars = starCache.get(entry.repo) || 0;
946
- }
893
+ }
894
+ ok(`Repos: ${cloned} cloned, ${updated} updated, ${repos.size - deadRepos.size} active` +
895
+ (deadRepos.size > 0 ? `, ${deadRepos.size} dead removed` : ''));
896
+
897
+ // 6b. Init registry DB and record preinstalled entries
898
+ const { ensureRegistryDb } = await importFromInstall('registry.mjs');
899
+ const regDbPath = join(MEM_DATA_DIR, 'resource-registry.db');
900
+ const rdb = ensureRegistryDb(regDbPath);
901
+
902
+ const insertPre = rdb.prepare(`
903
+ INSERT OR REPLACE INTO preinstalled (name, type, repo_url, repo_path, tags, enabled)
904
+ VALUES (?, ?, ?, ?, ?, 1)
905
+ `);
906
+ const activeResources = deadRepos.size > 0
907
+ ? resources.filter(r => !deadRepos.has(r.repo))
908
+ : resources;
909
+ for (const r of activeResources) {
910
+ insertPre.run(r.name, r.type, r.repo, r.path, JSON.stringify(r.tags || []));
911
+ }
912
+
913
+ // Clean up DB entries for dead repos
914
+ if (deadRepos.size > 0) {
915
+ const delPre = rdb.prepare('DELETE FROM preinstalled WHERE repo_url = ?');
916
+ const delRes = rdb.prepare('DELETE FROM resources WHERE repo_url = ?');
917
+ for (const deadUrl of deadRepos) {
918
+ try { delPre.run(deadUrl); } catch {}
919
+ try { delRes.run(deadUrl); } catch {}
920
+ }
921
+ }
922
+ ok(`Registry DB initialized (${activeResources.length} preinstalled entries` +
923
+ (deadRepos.size > 0 ? `, ${deadRepos.size} dead repos purged` : '') + ')');
924
+
925
+ // 6c. Fetch GitHub stars (best-effort, unauthenticated)
926
+ log(' Fetching GitHub stars...');
927
+ const starCache = new Map();
928
+ for (const [repoUrl] of repos) {
929
+ if (deadRepos.has(repoUrl)) continue;
930
+ const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/);
931
+ if (match) {
932
+ try {
933
+ const apiUrl = `https://api.github.com/repos/${match[1]}/${match[2]}`;
934
+ const res = execFileSync('curl', ['-sf', apiUrl], { encoding: 'utf8', timeout: 10000 });
935
+ const data = JSON.parse(res);
936
+ if (typeof data.stargazers_count === 'number') {
937
+ starCache.set(repoUrl, data.stargazers_count);
938
+ }
939
+ } catch {}
940
+ }
941
+ }
942
+ if (starCache.size > 0) ok(`Stars fetched (${starCache.size}/${repos.size} repos)`);
943
+
944
+ // 6d. Scan and index resources (fallback-only, Haiku indexing deferred to first run)
945
+ log(' Scanning resources...');
946
+ const { scanAllResources, diffResources } = await importFromInstall('registry-scanner.mjs');
947
+ const scanned = scanAllResources({ dataDir: MEM_DATA_DIR });
948
+
949
+ // Attach star counts and repo URLs
950
+ for (const s of scanned) {
951
+ const entry = resources.find(r => r.name === s.name && r.type === s.type);
952
+ if (entry) {
953
+ s.repoUrl = entry.repo;
954
+ s.repoStars = starCache.get(entry.repo) || 0;
947
955
  }
956
+ }
948
957
 
949
- const { toIndex } = diffResources(rdb, scanned);
950
- if (toIndex.length > 0) {
951
- // Use fallback indexing at install time (no Haiku calls)
952
- // Full Haiku indexing happens on first SessionStart
953
- const { upsertResource } = await importFromInstall('registry.mjs');
954
- for (const res of toIndex) {
955
- try {
956
- const metaKey = `${res.type}:${res.name}`;
957
- const meta = RESOURCE_METADATA[metaKey];
958
- upsertResource(rdb, {
959
- name: res.name,
960
- type: res.type,
961
- status: 'active',
962
- source: res.source,
963
- repo_url: res.repoUrl || null,
964
- repo_stars: res.repoStars || 0,
965
- local_path: res.localPath,
966
- file_hash: res.fileHash,
967
- invocation_name: meta?.invocation_name || deriveInvocationName(res.name),
968
- intent_tags: meta?.intent_tags || res.name.replace(/-/g, ' '),
969
- domain_tags: meta?.domain_tags || '',
970
- trigger_patterns: meta?.trigger_patterns || `when user needs ${res.name.replace(/-/g, ' ')}`,
971
- capability_summary: meta?.capability_summary || `${res.type}: ${res.name.replace(/-/g, ' ')}`,
972
- });
973
- } catch {}
974
- }
975
- ok(`Resources registered: ${toIndex.length} indexed`);
958
+ const { toIndex } = diffResources(rdb, scanned);
959
+ if (toIndex.length > 0) {
960
+ // Use fallback indexing at install time (no Haiku calls)
961
+ // Full Haiku indexing happens on first SessionStart
962
+ const { upsertResource } = await importFromInstall('registry.mjs');
963
+ for (const res of toIndex) {
964
+ try {
965
+ const metaKey = `${res.type}:${res.name}`;
966
+ const meta = RESOURCE_METADATA[metaKey];
967
+ upsertResource(rdb, {
968
+ name: res.name,
969
+ type: res.type,
970
+ status: 'active',
971
+ source: res.source,
972
+ repo_url: res.repoUrl || null,
973
+ repo_stars: res.repoStars || 0,
974
+ local_path: res.localPath,
975
+ file_hash: res.fileHash,
976
+ invocation_name: meta?.invocation_name || deriveInvocationName(res.name),
977
+ intent_tags: meta?.intent_tags || res.name.replace(/-/g, ' '),
978
+ domain_tags: meta?.domain_tags || '',
979
+ trigger_patterns: meta?.trigger_patterns || `when user needs ${res.name.replace(/-/g, ' ')}`,
980
+ capability_summary: meta?.capability_summary || `${res.type}: ${res.name.replace(/-/g, ' ')}`,
981
+ });
982
+ } catch {}
976
983
  }
984
+ ok(`Resources registered: ${toIndex.length} indexed`);
985
+ }
977
986
 
978
- // Apply curated metadata to all known resources (fixes existing installs)
979
- reindexKnownResources(rdb);
980
- ok('Resource metadata curated (FTS5 reindexed)');
987
+ // Apply curated metadata to all known resources (fixes existing installs)
988
+ reindexKnownResources(rdb);
989
+ ok('Resource metadata curated (FTS5 reindexed)');
981
990
 
982
- // Register plugin resources (skills/agents from other plugins, no local files)
983
- const virtualCount = registerVirtualResources(rdb);
984
- if (virtualCount > 0) ok(`Plugin resources registered: ${virtualCount} virtual entries`);
991
+ // Register plugin resources (skills/agents from other plugins, no local files)
992
+ const virtualCount = registerVirtualResources(rdb);
993
+ if (virtualCount > 0) ok(`Plugin resources registered: ${virtualCount} virtual entries`);
985
994
 
986
- rdb.close();
987
- }
988
- } else {
989
- log(' No preinstalled manifest found, skipping');
995
+ rdb.close();
990
996
  }
991
- } catch (e) {
992
- warn('Resource setup: ' + e.message);
993
- log(' Skills/agents will be indexed on first use');
997
+ } else {
998
+ log(' No preinstalled manifest found, skipping');
994
999
  }
1000
+ } catch (e) {
1001
+ warn('Resource setup: ' + e.message);
1002
+ log(' Skills/agents will be indexed on first use');
1003
+ }
1004
+ }
995
1005
 
996
- // 7. Verify database
997
- if (existsSync(DB_PATH)) {
998
- try {
999
- const Database = requireFromInstall('better-sqlite3');
1000
- const db = new Database(DB_PATH, { readonly: true });
1001
- const count = db.prepare('SELECT COUNT(*) as c FROM observations').get();
1002
- db.close();
1003
- ok(`Database accessible: ${count.c} observations`);
1004
- } catch (e) {
1005
- warn('Database check failed: ' + e.message);
1006
- }
1007
- } else {
1008
- log('No existing database — will be created on first use');
1006
+ function verifyDatabase() {
1007
+ // 7. Verify database
1008
+ if (existsSync(DB_PATH)) {
1009
+ try {
1010
+ const Database = requireFromInstall('better-sqlite3');
1011
+ const db = new Database(DB_PATH, { readonly: true });
1012
+ const count = db.prepare('SELECT COUNT(*) as c FROM observations').get();
1013
+ db.close();
1014
+ ok(`Database accessible: ${count.c} observations`);
1015
+ } catch (e) {
1016
+ warn('Database check failed: ' + e.message);
1009
1017
  }
1018
+ } else {
1019
+ log('No existing database — will be created on first use');
1020
+ }
1021
+ }
1010
1022
 
1011
- // 7b. Dogfood auto-adopt (invited-memory, Phase C T13).
1012
- // Only fires when install.mjs is running from the claude-mem-lite source repo
1013
- // itself (detected via git remote match). In npm/npx flows PROJECT_DIR is a
1014
- // cache dir with no git metadata, so this is a no-op for end users.
1015
- // --no-adopt override respected.
1016
- if (!flags.has('--no-adopt')) {
1017
- try {
1018
- const remote = execFileSync('git', ['-C', PROJECT_DIR, 'config', '--get', 'remote.origin.url'], { encoding: 'utf8', stdio: 'pipe' }).trim();
1019
- const isDogfood = /github\.com[:/]sdsrss\/claude-mem-lite(\.git)?$/i.test(remote);
1020
- if (isDogfood) {
1021
- const { cmdAdopt } = await importFromInstall('adopt-cli.mjs');
1022
- cmdAdopt([]);
1023
- ok('Invited-memory: auto-adopt for claude-mem-lite dogfood repo');
1024
- }
1025
- } catch {
1026
- // Not a git repo, or git missing — silent skip (this is the normal npm path).
1023
+ async function dogfoodAutoAdopt() {
1024
+ // 7b. Dogfood auto-adopt (invited-memory, Phase C T13).
1025
+ // Only fires when install.mjs is running from the claude-mem-lite source repo
1026
+ // itself (detected via git remote match). In npm/npx flows PROJECT_DIR is a
1027
+ // cache dir with no git metadata, so this is a no-op for end users.
1028
+ // --no-adopt override respected.
1029
+ if (!flags.has('--no-adopt')) {
1030
+ try {
1031
+ const remote = execFileSync('git', ['-C', PROJECT_DIR, 'config', '--get', 'remote.origin.url'], { encoding: 'utf8', stdio: 'pipe' }).trim();
1032
+ const isDogfood = /github\.com[:/]sdsrss\/claude-mem-lite(\.git)?$/i.test(remote);
1033
+ if (isDogfood) {
1034
+ const { cmdAdopt } = await importFromInstall('adopt-cli.mjs');
1035
+ cmdAdopt([]);
1036
+ ok('Invited-memory: auto-adopt for claude-mem-lite dogfood repo');
1027
1037
  }
1038
+ } catch {
1039
+ // Not a git repo, or git missing — silent skip (this is the normal npm path).
1028
1040
  }
1041
+ }
1042
+ }
1029
1043
 
1030
- // 8. Disable old claude-mem plugin
1031
- if (settings.enabledPlugins?.['claude-mem@thedotmack'] !== undefined) {
1032
- settings.enabledPlugins['claude-mem@thedotmack'] = false;
1033
- writeSettings(settings);
1034
- ok('Old claude-mem plugin disabled');
1035
- }
1044
+ function disableOldClaudeMemPlugin() {
1045
+ const settings = readSettings();
1046
+ // 8. Disable old claude-mem plugin
1047
+ if (settings.enabledPlugins?.['claude-mem@thedotmack'] !== undefined) {
1048
+ settings.enabledPlugins['claude-mem@thedotmack'] = false;
1049
+ writeSettings(settings);
1050
+ ok('Old claude-mem plugin disabled');
1051
+ }
1052
+ }
1036
1053
 
1037
- // 9. Offer to clean old vector-db
1038
- const vectorDbPath = join(OLD_DATA_DIR, 'vector-db');
1039
- if (existsSync(vectorDbPath)) {
1040
- try {
1041
- const size = execFileSync('du', ['-sh', vectorDbPath], { encoding: 'utf8' }).trim().split('\t')[0];
1042
- warn(`Old vector-db exists (${size}). Run: rm -rf ~/.claude-mem/vector-db/`);
1043
- } catch {}
1044
- }
1054
+ function offerCleanOldVectorDb() {
1055
+ // 9. Offer to clean old vector-db
1056
+ const vectorDbPath = join(OLD_DATA_DIR, 'vector-db');
1057
+ if (existsSync(vectorDbPath)) {
1058
+ try {
1059
+ const size = execFileSync('du', ['-sh', vectorDbPath], { encoding: 'utf8' }).trim().split('\t')[0];
1060
+ warn(`Old vector-db exists (${size}). Run: rm -rf ~/.claude-mem/vector-db/`);
1061
+ } catch {}
1062
+ }
1063
+ }
1064
+
1065
+ async function install() {
1066
+ console.log('\nclaude-mem-lite installer\n');
1067
+
1068
+ // 1. Install source files to ~/.claude-mem-lite/
1069
+ const IS_DEV = flags.has('--dev');
1070
+
1071
+ installSourceFiles(IS_DEV);
1072
+ await installDependencies(IS_DEV);
1073
+ createCliSymlink();
1074
+ registerMcpServer();
1075
+ dedupePluginCacheAndHooks();
1076
+ configureHooks();
1077
+ backupLegacyClaudeMemData();
1078
+ await installPreinstalledResources();
1079
+ verifyDatabase();
1080
+ await dogfoodAutoAdopt();
1081
+ disableOldClaudeMemPlugin();
1082
+ offerCleanOldVectorDb();
1045
1083
 
1046
1084
  console.log('\n Done! Restart Claude Code to activate.\n');
1047
1085
  }
1048
1086
 
1087
+
1049
1088
  // ─── Uninstall ──────────────────────────────────────────────────────────────
1050
1089
 
1051
1090
  async function uninstall() {
@@ -1807,11 +1846,11 @@ function readSettings() {
1807
1846
  }
1808
1847
 
1809
1848
  function writeSettings(settings) {
1810
- const settingsDir = dirname(SETTINGS_PATH);
1811
- if (!existsSync(settingsDir)) mkdirSync(settingsDir, { recursive: true });
1812
- const tmp = SETTINGS_PATH + '.tmp';
1813
- writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
1814
- renameSync(tmp, SETTINGS_PATH);
1849
+ // Atomic (pid-unique temp + rename) with a one-time .bak: settings.json is the
1850
+ // user's Claude Code config. The old fixed ".tmp" name let concurrent installs
1851
+ // clobber each other's temp mid-write, and there was no recovery artifact if a
1852
+ // hook-merge bug dropped user config. atomicWriteFileSync handles dir creation.
1853
+ atomicWriteFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n', { backup: true });
1815
1854
  }
1816
1855
 
1817
1856
  // ─── Cleanup Stale Files ─────────────────────────────────────────────────────
@@ -1919,8 +1958,7 @@ async function manualUpdate() {
1919
1958
  // buggy on disk.
1920
1959
  async function repair() {
1921
1960
  console.log('\nclaude-mem-lite repair — re-syncing from latest GitHub release\n');
1922
- const stagingDir = join(tmpdir(), `claude-mem-lite-repair-${Date.now()}`);
1923
- mkdirSync(stagingDir, { recursive: true });
1961
+ const stagingDir = mkdtempSync(join(tmpdir(), 'claude-mem-lite-repair-'));
1924
1962
  try {
1925
1963
  const tarballUrl = 'https://api.github.com/repos/sdsrss/claude-mem-lite/tarball';
1926
1964
  const tarballPath = join(stagingDir, 'release.tgz');
@@ -2027,13 +2065,27 @@ function regenerateLockfile() {
2027
2065
 
2028
2066
  // ─── Main ───────────────────────────────────────────────────────────────────
2029
2067
 
2068
+ // Cross-process gate around the install write phase. repair() is intentionally
2069
+ // NOT locked here: it spawns `install.mjs install` as a child, which takes this
2070
+ // lock — locking the parent too would deadlock. A live peer (another session's
2071
+ // install/self-heal) holds it → skip rather than race into a torn install. Lock
2072
+ // path is shared with hook-update.installExtractedRelease (both env-aware).
2073
+ async function runLockedInstall() {
2074
+ const release = acquireLock(join(MEM_DATA_DIR, 'runtime', 'install.lock'));
2075
+ if (!release) {
2076
+ console.log('[install] Another install/repair is in progress — skipping to avoid a torn write.');
2077
+ return;
2078
+ }
2079
+ try { await install(); } finally { release(); }
2080
+ }
2081
+
2030
2082
  export async function main(argv = process.argv.slice(2)) {
2031
2083
  cmd = argv[0];
2032
2084
  flags = new Set(argv.slice(1));
2033
2085
 
2034
2086
  switch (cmd) {
2035
2087
  case 'install':
2036
- await install();
2088
+ await runLockedInstall();
2037
2089
  break;
2038
2090
  case 'uninstall':
2039
2091
  await uninstall();
@@ -2064,7 +2116,7 @@ export async function main(argv = process.argv.slice(2)) {
2064
2116
  default:
2065
2117
  if (IS_NPX) {
2066
2118
  // npx claude-mem-lite (no args) → auto install
2067
- await install();
2119
+ await runLockedInstall();
2068
2120
  } else {
2069
2121
  // Name the unknown token before the usage block. Pre-fix `install frobnicate`
2070
2122
  // dumped usage silently, which read like the user had typed nothing — they had