eklavya 1.25.0 → 1.25.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/dist/assets/dashboard.html +1 -1
  2. package/dist/assets/tutor/references/focus-and-level.md +7 -0
  3. package/dist/claude-mem.js +9 -7
  4. package/dist/claude-mem.js.map +1 -1
  5. package/dist/cli-memory.js +786 -0
  6. package/dist/cli-memory.js.map +1 -0
  7. package/dist/cli.js +95 -773
  8. package/dist/cli.js.map +1 -1
  9. package/dist/config.js +79 -13
  10. package/dist/config.js.map +1 -1
  11. package/dist/dashboard.js +36 -14
  12. package/dist/dashboard.js.map +1 -1
  13. package/dist/db.js +33 -6
  14. package/dist/db.js.map +1 -1
  15. package/dist/hooks/capture-lib.js +55 -0
  16. package/dist/hooks/capture-lib.js.map +1 -0
  17. package/dist/hooks/capture-tool.js +1 -1
  18. package/dist/hooks/capture-tool.js.map +1 -1
  19. package/dist/hooks/commit-lib.js +255 -0
  20. package/dist/hooks/commit-lib.js.map +1 -0
  21. package/dist/hooks/lib.js +37 -5
  22. package/dist/hooks/lib.js.map +1 -1
  23. package/dist/hooks/memory-lib.js +93 -78
  24. package/dist/hooks/memory-lib.js.map +1 -1
  25. package/dist/hooks/pre-tool-gate.js +5 -10
  26. package/dist/hooks/pre-tool-gate.js.map +1 -1
  27. package/dist/hooks/prompt-submit-nudge.js +26 -1
  28. package/dist/hooks/prompt-submit-nudge.js.map +1 -1
  29. package/dist/hooks/session-start.js +13 -4
  30. package/dist/hooks/session-start.js.map +1 -1
  31. package/dist/install.js +280 -67
  32. package/dist/install.js.map +1 -1
  33. package/dist/memory/capture.js +25 -5
  34. package/dist/memory/capture.js.map +1 -1
  35. package/dist/memory/privacy.js +105 -7
  36. package/dist/memory/privacy.js.map +1 -1
  37. package/dist/memory/provider.js +13 -3
  38. package/dist/memory/provider.js.map +1 -1
  39. package/dist/memory/recall.js +18 -6
  40. package/dist/memory/recall.js.map +1 -1
  41. package/dist/memory/spool.js +105 -23
  42. package/dist/memory/spool.js.map +1 -1
  43. package/dist/memory/store.js +32 -2
  44. package/dist/memory/store.js.map +1 -1
  45. package/dist/memory/summarize.js +5 -5
  46. package/dist/memory/summarize.js.map +1 -1
  47. package/dist/memory/worker.js +71 -10
  48. package/dist/memory/worker.js.map +1 -1
  49. package/dist/migrate.js +60 -14
  50. package/dist/migrate.js.map +1 -1
  51. package/dist/paths.js +49 -0
  52. package/dist/paths.js.map +1 -1
  53. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  54. package/dist/plugin/cli/CLAUDE.md +16 -4
  55. package/dist/plugin/hooks/CLAUDE.md +33 -3
  56. package/dist/plugin/hooks/run.mjs +69 -5
  57. package/dist/plugin/scripts/install-git-hook.sh +114 -24
  58. package/dist/plugin/skills/CLAUDE.md +1 -1
  59. package/dist/plugin/skills/setup/SKILL.md +9 -2
  60. package/dist/plugin/skills/tutor/references/focus-and-level.md +7 -0
  61. package/dist/safe-write.js +146 -0
  62. package/dist/safe-write.js.map +1 -0
  63. package/dist/slug.js +4 -1
  64. package/dist/slug.js.map +1 -1
  65. package/dist/srs.js +33 -1
  66. package/dist/srs.js.map +1 -1
  67. package/dist/store.js +29 -20
  68. package/dist/store.js.map +1 -1
  69. package/dist/tools/config_tools.js +23 -1
  70. package/dist/tools/config_tools.js.map +1 -1
  71. package/dist/tools/get_session_quiz_plan.js +51 -30
  72. package/dist/tools/get_session_quiz_plan.js.map +1 -1
  73. package/dist/tools/log_session_concepts.js +30 -23
  74. package/dist/tools/log_session_concepts.js.map +1 -1
  75. package/dist/tools/record_attempt.js +18 -8
  76. package/dist/tools/record_attempt.js.map +1 -1
  77. package/dist/tools/types.js +29 -0
  78. package/dist/tools/types.js.map +1 -1
  79. package/dist/tools/upsert_concepts.js +12 -10
  80. package/dist/tools/upsert_concepts.js.map +1 -1
  81. package/dist/user-skill/eklavya/SKILL.md +26 -10
  82. package/package.json +1 -1
package/dist/install.js CHANGED
@@ -43,6 +43,7 @@ import { ImportError, IMPORTED_TABLES } from './memory/import.js';
43
43
  import { importOffThread } from './memory/import-worker.js';
44
44
  import { askOne, onboard } from './onboard.js';
45
45
  import { activeClaudeMemPluginIds, claudeMemDb, claudeMemDir, removeClaudeMemPlugin, retireClaudeMemDir, } from './claude-mem.js';
46
+ import { readJsonForUpdate, readJsonStrict, UnreadableFileError, writeJsonWithBackup } from './safe-write.js';
46
47
  const MIN_NODE_MAJOR = 22;
47
48
  const moduleDir = path.dirname(fileURLToPath(import.meta.url));
48
49
  /** The npm package root — `dist/` under a build, so one level up. */
@@ -120,10 +121,14 @@ function checkGit() {
120
121
  * So: check, and say something true either way.
121
122
  */
122
123
  function cliOnPath() {
124
+ return commandOnPath('eklavya');
125
+ }
126
+ /** Is `name` an executable somewhere on PATH? `doctor` asks it about `jq` and `sqlite3` too. */
127
+ export function commandOnPath(name) {
123
128
  const dirs = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
124
129
  const names = process.platform === 'win32'
125
- ? ['eklavya.cmd', 'eklavya.exe', 'eklavya.ps1', 'eklavya']
126
- : ['eklavya'];
130
+ ? [`${name}.cmd`, `${name}.exe`, `${name}.ps1`, name]
131
+ : [name];
127
132
  return dirs.some((d) => names.some((n) => {
128
133
  try {
129
134
  return fs.statSync(path.join(d, n)).isFile() || fs.lstatSync(path.join(d, n)).isSymbolicLink();
@@ -320,20 +325,49 @@ function removeSkill() {
320
325
  return true;
321
326
  }
322
327
  // --- 4. Claude Code's registries --------------------------------------------
323
- function readJson(file) {
324
- try {
325
- return JSON.parse(fs.readFileSync(file, 'utf8'));
326
- }
327
- catch {
328
- return {};
328
+ /** The three Claude Code files `register()` and `deregister()` merge into. */
329
+ function registryFiles() {
330
+ const pluginsDir = path.join(claudeHome(), 'plugins');
331
+ return {
332
+ marketplaces: path.join(pluginsDir, 'known_marketplaces.json'),
333
+ installed: path.join(pluginsDir, 'installed_plugins.json'),
334
+ settings: path.join(claudeHome(), 'settings.json'),
335
+ };
336
+ }
337
+ /**
338
+ * Stops the command, before it writes anything at all, if any file it is about
339
+ * to merge into exists but is not a JSON object.
340
+ *
341
+ * All of them up front, not each as it is reached: finding the third one broken
342
+ * after writing the first two leaves a half-registered plugin, which is its own
343
+ * mess to explain. A file somebody hand-edits (a trailing comma, a comment) is
344
+ * exactly the one with an afternoon's work in it, so it is never read as `{}`.
345
+ */
346
+ function refuseUnreadable(files) {
347
+ for (const file of files) {
348
+ const read = readJsonStrict(file);
349
+ if (read.kind !== 'invalid')
350
+ continue;
351
+ process.stderr.write(`${new UnreadableFileError(file, read.error).message}\nStopped before changing anything.\n`);
352
+ process.exit(1);
329
353
  }
330
354
  }
331
- /** Write via temp file + rename: Claude Code may be reading this mid-write. */
332
- function writeJson(file, data) {
333
- fs.mkdirSync(path.dirname(file), { recursive: true });
334
- const tmp = `${file}.tmp-${process.pid}`;
335
- fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
336
- fs.renameSync(tmp, file);
355
+ /**
356
+ * One backup per file per command. `settings.json` is written by `register`
357
+ * and again by the Claude Mem switch-off; without this the second write would
358
+ * roll `settings.json.eklavya-bak` forward onto Eklavya's own first edit.
359
+ */
360
+ const RUN = new Set();
361
+ /** Writes through `writeJsonWithBackup`, collecting each backup so the caller can say where it is. */
362
+ function saveJson(file, data, backups) {
363
+ const { backup } = writeJsonWithBackup(file, data, { run: RUN });
364
+ if (backup)
365
+ backups.push(backup);
366
+ }
367
+ /** A row per backup written, so nobody has to guess one exists or where. */
368
+ function reportBackups(backups) {
369
+ for (const backup of backups)
370
+ check(null, '', dim(`backup ${backup}`));
337
371
  }
338
372
  /**
339
373
  * Registers the plugin the way `/plugin install` would.
@@ -349,27 +383,36 @@ function writeJson(file, data) {
349
383
  * came from npm, and deliberately: that is what lets Claude Code's own
350
384
  * `/plugin update` take over afterwards. npm is the on-ramp, not the channel.
351
385
  *
386
+ * Timestamps are carried over when nothing else about an entry changed, so a
387
+ * re-run writes identical bytes and leaves each `.eklavya-bak` holding the
388
+ * file as it was before Eklavya first touched it.
389
+ *
352
390
  * ponytail: three private files, no public API. If Claude Code ever ships
353
391
  * `claude plugin install --local`, delete this and shell out to it.
354
392
  */
355
393
  function register(version) {
356
- const pluginsDir = path.join(claudeHome(), 'plugins');
357
- const marketplaces = path.join(pluginsDir, 'known_marketplaces.json');
358
- const known = readJson(marketplaces);
394
+ const files = registryFiles();
395
+ const backups = [];
396
+ const now = new Date().toISOString();
397
+ const known = readJsonForUpdate(files.marketplaces);
398
+ const previousMarket = known.eklavya;
399
+ const source = { source: 'github', repo: 'ProjectAJ14/eklavya' };
400
+ const sameMarket = JSON.stringify(previousMarket?.source) === JSON.stringify(source) &&
401
+ previousMarket?.installLocation === marketplaceDir() &&
402
+ previousMarket?.autoUpdate === true;
359
403
  known.eklavya = {
360
- source: { source: 'github', repo: 'ProjectAJ14/eklavya' },
404
+ source,
361
405
  installLocation: marketplaceDir(),
362
- lastUpdated: new Date().toISOString(),
406
+ lastUpdated: (sameMarket && previousMarket?.lastUpdated) || now,
363
407
  autoUpdate: true,
364
408
  };
365
- writeJson(marketplaces, known);
409
+ saveJson(files.marketplaces, known, backups);
366
410
  // The value here is an ARRAY, one entry per scope: a `user` install and any
367
411
  // number of `local` ones, each pinned to a project directory. Assigning a
368
412
  // fresh array would silently uninstall the plugin from every project someone
369
413
  // had added it to -- so this replaces the `user` entry and leaves the rest
370
414
  // exactly as it found them.
371
- const installedPath = path.join(pluginsDir, 'installed_plugins.json');
372
- const installed = readJson(installedPath);
415
+ const installed = readJsonForUpdate(files.installed);
373
416
  if (typeof installed.version !== 'number')
374
417
  installed.version = 2;
375
418
  const plugins = (installed.plugins ?? {});
@@ -378,7 +421,7 @@ function register(version) {
378
421
  : [];
379
422
  const otherScopes = existing.filter((entry) => entry?.scope !== 'user');
380
423
  const previousUser = existing.find((entry) => entry?.scope === 'user');
381
- const now = new Date().toISOString();
424
+ const sameUser = previousUser?.installPath === marketplaceDir() && previousUser?.version === version;
382
425
  plugins['eklavya@eklavya'] = [
383
426
  ...otherScopes,
384
427
  {
@@ -387,21 +430,35 @@ function register(version) {
387
430
  version,
388
431
  // Kept, so re-running this reads as an upgrade rather than a fresh install.
389
432
  installedAt: previousUser?.installedAt ?? now,
390
- lastUpdated: now,
433
+ lastUpdated: (sameUser && previousUser?.lastUpdated) || now,
391
434
  },
392
435
  ];
393
436
  installed.plugins = plugins;
394
- writeJson(installedPath, installed);
395
- const settingsPath = path.join(claudeHome(), 'settings.json');
396
- const settings = readJson(settingsPath);
437
+ saveJson(files.installed, installed, backups);
438
+ const settings = readJsonForUpdate(files.settings);
397
439
  const enabled = (settings.enabledPlugins ?? {});
398
440
  enabled['eklavya@eklavya'] = true;
399
441
  settings.enabledPlugins = enabled;
400
442
  composeStatusLine(settings);
401
- writeJson(settingsPath, settings);
443
+ saveJson(files.settings, settings, backups);
444
+ return backups;
445
+ }
446
+ /**
447
+ * The status bar command this installer owns, and the only one it will remove.
448
+ *
449
+ * Quoted: a home directory with a space in it (`C:\Users\Jo Doe`, common on
450
+ * Windows) split the unquoted path into two arguments. Double quotes mean the
451
+ * same thing to `sh` and to `cmd`.
452
+ */
453
+ const STATUS_LINE_COMMAND = `node "${path.join(runtimeHome(), 'node_modules', 'eklavya', 'dist', 'cli.js')}" statusline`;
454
+ /**
455
+ * True for a status line this installer wrote, in any version: quoted or the
456
+ * older unquoted form, at any runtime path. Anchored at both ends, so a line
457
+ * somebody composed around ours (`my-bar; node …/cli.js statusline`) is theirs.
458
+ */
459
+ function isOurStatusLine(command) {
460
+ return typeof command === 'string' && /^node "?[^"]+[\\/]eklavya[\\/]dist[\\/]cli\.js"? statusline$/.test(command);
402
461
  }
403
- /** The status bar command this installer owns, and the only one it will remove. */
404
- const STATUS_LINE_COMMAND = `node ${path.join(runtimeHome(), 'node_modules', 'eklavya', 'dist', 'cli.js')} statusline`;
405
462
  /**
406
463
  * Puts the dials in the status bar, and never over somebody else's.
407
464
  *
@@ -412,15 +469,14 @@ const STATUS_LINE_COMMAND = `node ${path.join(runtimeHome(), 'node_modules', 'ek
412
469
  * keeps the by-hand instructions for anyone who wants to compose the two
413
470
  * themselves.
414
471
  *
415
- * The removal in `deregister` matches on the command being exactly ours, which
416
- * is what stops an uninstall taking a line it did not write.
472
+ * The removal in `deregister` matches the same way, which is what stops an
473
+ * uninstall taking a line it did not write.
417
474
  */
418
475
  function composeStatusLine(settings) {
419
476
  const existing = settings.statusLine;
420
477
  if (existing !== undefined && existing !== null) {
421
- const command = existing?.command;
422
- // Ours already, possibly from an older runtime path: refresh it.
423
- if (typeof command === 'string' && /dist[\\/]cli\.js["']? statusline\b/.test(command)) {
478
+ // Ours already, possibly from an older runtime path or unquoted: refresh it.
479
+ if (isOurStatusLine(existing?.command)) {
424
480
  settings.statusLine = { type: 'command', command: STATUS_LINE_COMMAND, padding: 0 };
425
481
  }
426
482
  return;
@@ -428,18 +484,17 @@ function composeStatusLine(settings) {
428
484
  settings.statusLine = { type: 'command', command: STATUS_LINE_COMMAND, padding: 0 };
429
485
  }
430
486
  function deregister() {
431
- const pluginsDir = path.join(claudeHome(), 'plugins');
432
- const marketplaces = path.join(pluginsDir, 'known_marketplaces.json');
433
- const known = readJson(marketplaces);
487
+ const files = registryFiles();
488
+ const backups = [];
489
+ const known = readJsonForUpdate(files.marketplaces);
434
490
  if ('eklavya' in known) {
435
491
  delete known.eklavya;
436
- writeJson(marketplaces, known);
492
+ saveJson(files.marketplaces, known, backups);
437
493
  }
438
494
  // Symmetric with register(): drop the `user` entry this CLI owns and leave
439
495
  // project-scoped installs alone. Returns them so the caller can say they are
440
496
  // still there rather than leaving someone with a half-removed plugin.
441
- const installedPath = path.join(pluginsDir, 'installed_plugins.json');
442
- const installed = readJson(installedPath);
497
+ const installed = readJsonForUpdate(files.installed);
443
498
  const plugins = (installed.plugins ?? {});
444
499
  const existing = Array.isArray(plugins['eklavya@eklavya'])
445
500
  ? plugins['eklavya@eklavya']
@@ -451,26 +506,58 @@ function deregister() {
451
506
  else
452
507
  delete plugins['eklavya@eklavya'];
453
508
  installed.plugins = plugins;
454
- writeJson(installedPath, installed);
509
+ saveJson(files.installed, installed, backups);
455
510
  }
456
- const settingsPath = path.join(claudeHome(), 'settings.json');
457
- const settings = readJson(settingsPath);
511
+ // One write for both edits: the backup is a single rolling copy, so a second
512
+ // write would replace the developer's file with Eklavya's halfway state.
513
+ const settings = readJsonForUpdate(files.settings);
458
514
  const enabled = (settings.enabledPlugins ?? {});
459
- // Only a status line that is exactly ours. Somebody else's stays, and so
460
- // does one they composed by hand around ours -- this installer did not write
461
- // it and has no business deciding what is left of it.
462
- const line = settings.statusLine?.command;
463
- const ownsStatusLine = typeof line === 'string' && line === STATUS_LINE_COMMAND;
464
- if (ownsStatusLine) {
515
+ let changed = false;
516
+ // Only a status line that is ours. Somebody else's stays, and so does one
517
+ // they composed by hand around ours -- this installer did not write it and
518
+ // has no business deciding what is left of it.
519
+ if (isOurStatusLine(settings.statusLine?.command)) {
465
520
  delete settings.statusLine;
466
- writeJson(settingsPath, settings);
521
+ changed = true;
467
522
  }
468
523
  if ('eklavya@eklavya' in enabled) {
469
524
  delete enabled['eklavya@eklavya'];
470
525
  settings.enabledPlugins = enabled;
471
- writeJson(settingsPath, settings);
526
+ changed = true;
527
+ }
528
+ if (changed)
529
+ saveJson(files.settings, settings, backups);
530
+ return { otherScopes, backups };
531
+ }
532
+ /**
533
+ * The git `pre-commit` hook in the repository at `cwd`, if it is Eklavya's
534
+ * gate (`scripts/install-git-hook.sh` writes it between these markers). Asked
535
+ * of git rather than assumed to be `.git/hooks`, so a worktree finds the hook
536
+ * its commits actually run.
537
+ */
538
+ export function eklavyaGateHook(cwd = process.cwd()) {
539
+ const res = spawnSync('git', ['rev-parse', '--git-path', 'hooks/pre-commit'], { cwd, encoding: 'utf8' });
540
+ if (res.status !== 0 || !res.stdout)
541
+ return null;
542
+ const hook = path.resolve(cwd, res.stdout.trim());
543
+ try {
544
+ return fs.readFileSync(hook, 'utf8').includes('# >>> eklavya gate >>>') ? hook : null;
545
+ }
546
+ catch {
547
+ return null;
548
+ }
549
+ }
550
+ /**
551
+ * Whether a hook is the fail-open kind the current installer writes. It names
552
+ * the runtime copy of the gate; the old kind only ever `exec`'d one fixed path.
553
+ */
554
+ export function gateHookFailsOpen(hook) {
555
+ try {
556
+ return fs.readFileSync(hook, 'utf8').includes('dist/plugin/cli/eklavya-gate');
557
+ }
558
+ catch {
559
+ return false;
472
560
  }
473
- return otherScopes;
474
561
  }
475
562
  // --- 6. Claude Mem ---------------------------------------------------------
476
563
  function setEklavyaMemory(enabled) {
@@ -570,8 +657,9 @@ async function resolveClaudeMem(owner) {
570
657
  }
571
658
  setEklavyaMemory(true);
572
659
  if (ids.length) {
573
- const how = await spin('claude-mem', 'uninstalling the plugin…', () => removeClaudeMemPlugin(claudeHome(), ids));
660
+ const { how, backup } = await spin('claude-mem', 'uninstalling the plugin…', () => removeClaudeMemPlugin(claudeHome(), ids, RUN));
574
661
  check('ok', 'claude-mem', `plugin ${how}`);
662
+ reportBackups(backup ? [backup] : []);
575
663
  }
576
664
  // ponytail: Claude Mem's background worker, if one is up, lives until its
577
665
  // next restart; with no plugin left, nothing restarts it.
@@ -626,6 +714,9 @@ export function health() {
626
714
  });
627
715
  }
628
716
  checks.push(pluginCheck());
717
+ const versions = haveRuntime ? versionCheck() : null;
718
+ if (versions)
719
+ checks.push(versions);
629
720
  const skillFile = path.join(userSkillDir(), 'SKILL.md');
630
721
  const haveSkill = fs.existsSync(skillFile);
631
722
  checks.push({
@@ -654,8 +745,10 @@ function pluginCheck() {
654
745
  if (!fs.existsSync(dir)) {
655
746
  return { name: 'plugin', ok: false, detail: `nothing at ${dir}` };
656
747
  }
657
- const installed = readJson(path.join(claudeHome(), 'plugins', 'installed_plugins.json'));
658
- const entries = installed.plugins?.['eklavya@eklavya'];
748
+ const installed = readForCheck(registryFiles().installed);
749
+ if ('error' in installed)
750
+ return { name: 'plugin', ok: false, detail: installed.error };
751
+ const entries = installed.value.plugins?.['eklavya@eklavya'];
659
752
  const registered = Array.isArray(entries) && entries.length > 0;
660
753
  if (!registered) {
661
754
  return { name: 'plugin', ok: false, detail: `${dir} — on disk but not registered` };
@@ -665,15 +758,93 @@ function pluginCheck() {
665
758
  // missing one means something removed it, and reading that as healthy is the
666
759
  // one failure this whole command exists to prevent. Being wrong the other way
667
760
  // costs a run of an idempotent installer.
668
- const settings = readJson(path.join(claudeHome(), 'settings.json'));
669
- const enabled = settings.enabledPlugins?.['eklavya@eklavya'];
761
+ const settings = readForCheck(registryFiles().settings);
762
+ if ('error' in settings)
763
+ return { name: 'plugin', ok: false, detail: settings.error };
764
+ const enabled = settings.value.enabledPlugins?.['eklavya@eklavya'];
670
765
  if (enabled !== true) {
671
766
  return { name: 'plugin', ok: false, detail: 'registered but not enabled in settings.json' };
672
767
  }
673
768
  return { name: 'plugin', ok: true, detail: `${dir} — registered, enabled` };
674
769
  }
770
+ /**
771
+ * For a check that only reads: the object (`{}` for no file), or why it cannot
772
+ * be read. An unreadable file is named, not read as empty — "not registered"
773
+ * would send somebody to `eklavya install`, which refuses to touch it.
774
+ */
775
+ function readForCheck(file) {
776
+ const read = readJsonStrict(file);
777
+ if (read.kind === 'invalid')
778
+ return { error: `${file} is not valid JSON (${read.error}) — fix it by hand` };
779
+ return { value: read.kind === 'ok' ? read.value : {} };
780
+ }
781
+ function versionAt(file) {
782
+ const read = readJsonStrict(file);
783
+ const version = read.kind === 'ok' ? read.value.version : null;
784
+ return typeof version === 'string' ? version : null;
785
+ }
786
+ /** -1, 0 or 1 over the numeric parts of two versions; a prerelease tag is ignored. */
787
+ export function compareVersions(a, b) {
788
+ const parts = (v) => v.split('-')[0].split('.').map((n) => Number(n) || 0);
789
+ const [x, y] = [parts(a), parts(b)];
790
+ for (let i = 0; i < Math.max(x.length, y.length); i++) {
791
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
792
+ if (d !== 0)
793
+ return Math.sign(d);
794
+ }
795
+ return 0;
796
+ }
797
+ /**
798
+ * Does the runtime match the plugin Claude Code loads?
799
+ *
800
+ * They are installed by different routes — the plugin by `/plugin update` or a
801
+ * marketplace pull, the runtime by npm — so they drift, and the hooks run the
802
+ * runtime whatever the plugin says. `hooks/run.mjs` heals an older runtime in
803
+ * the background; this is the line that says whether it has.
804
+ *
805
+ * The plugin is read where Claude Code installed it (the `user` entry's
806
+ * `installPath`), which is the marketplace directory for this installer and a
807
+ * versioned cache directory for `/plugin install`. Null when either side is
808
+ * not there to read — the runtime and plugin checks already say so.
809
+ */
810
+ function versionCheck() {
811
+ const runtime = versionAt(path.join(runtimeHome(), 'node_modules', 'eklavya', 'package.json'));
812
+ const installed = readForCheck(registryFiles().installed);
813
+ const entries = 'value' in installed ? installed.value.plugins?.['eklavya@eklavya'] : null;
814
+ const user = Array.isArray(entries) ? entries.find((e) => e?.scope === 'user') : undefined;
815
+ const pluginDir = typeof user?.installPath === 'string' ? user.installPath : marketplaceDir();
816
+ const plugin = versionAt(path.join(pluginDir, '.claude-plugin', 'plugin.json'));
817
+ if (!runtime || !plugin)
818
+ return null;
819
+ const order = compareVersions(runtime, plugin);
820
+ if (order === 0)
821
+ return { name: 'versions', ok: true, detail: `runtime and plugin both ${runtime}` };
822
+ return {
823
+ name: 'versions',
824
+ ok: false,
825
+ detail: order < 0
826
+ ? `runtime ${runtime} is behind plugin ${plugin} — the next session updates it in the background`
827
+ // Never downgraded automatically: migrations only go forward, so an older
828
+ // runtime may not understand a database the newer one has already moved.
829
+ : `runtime ${runtime} is ahead of plugin ${plugin} — update the plugin: /plugin update eklavya`,
830
+ };
831
+ }
675
832
  // --- commands ---------------------------------------------------------------
676
833
  export async function install(args) {
834
+ try {
835
+ await installSteps(args);
836
+ }
837
+ catch (err) {
838
+ // The files are all checked before the first write, so this is the second
839
+ // line: one that broke mid-run (an editor saving over it) still ends in a
840
+ // sentence naming the file, not a stack trace.
841
+ if (!(err instanceof UnreadableFileError))
842
+ throw err;
843
+ process.stderr.write(`\n${err.message}\n`);
844
+ process.exit(1);
845
+ }
846
+ }
847
+ async function installSteps(args) {
677
848
  const flagAt = args.indexOf('--memory');
678
849
  const memoryFlag = flagAt < 0 ? null : args[flagAt + 1];
679
850
  if (memoryFlag !== null && memoryFlag !== 'eklavya' && memoryFlag !== 'claude-mem') {
@@ -684,6 +855,10 @@ export async function install(args) {
684
855
  heading(`eklavya install ${dim(version)}`);
685
856
  checkNode();
686
857
  check('ok', 'node', process.versions.node);
858
+ // Before the runtime, the payload or anything else: a file this run would
859
+ // merge into but cannot parse stops it with the machine exactly as it was.
860
+ const files = registryFiles();
861
+ refuseUnreadable([files.marketplaces, files.installed, files.settings, globalConfigPath()]);
687
862
  if (!args.includes('--skip-runtime')) {
688
863
  await installRuntime(version);
689
864
  verifyRuntime();
@@ -709,11 +884,12 @@ export async function install(args) {
709
884
  else
710
885
  check('skip', 'skill', dim('not in this package (skipped)'));
711
886
  }
712
- register(version);
887
+ const backups = register(version);
713
888
  // "and the Code tab" is not padding: that tab runs the same engine against
714
889
  // the same `~/.claude`, so this one registration covers it and someone who
715
890
  // only ever opens Claude Desktop should not go looking for a second install.
716
891
  check('ok', 'registered', `eklavya@eklavya ${dim('— Claude Code CLI and the Code tab in Claude Desktop')}`);
892
+ reportBackups(backups);
717
893
  // Creating the DB here rather than on first server start means `eklavya
718
894
  // doctor` and the dashboard work before Claude Code has ever been opened.
719
895
  const db = openDb();
@@ -778,11 +954,19 @@ export async function install(args) {
778
954
  export function uninstall(args) {
779
955
  const purge = args.includes('--purge');
780
956
  heading('eklavya uninstall');
781
- const otherScopes = deregister();
957
+ const files = registryFiles();
958
+ refuseUnreadable([files.marketplaces, files.installed, files.settings]);
959
+ // Looked up before the plugin directory goes: the hook `exec`s the gate
960
+ // script inside it, so once it is gone every commit in this repository fails.
961
+ const gateHook = eklavyaGateHook();
962
+ const { otherScopes, backups } = deregister();
782
963
  check('ok', 'registered', 'removed from Claude Code');
964
+ reportBackups(backups);
783
965
  // Removing the shared directory out from under a project-scoped install would
784
- // leave that project pointing at nothing, so it stays until those go too.
785
- if (otherScopes.length === 0) {
966
+ // leave that project pointing at nothing, so it stays until those go too. The
967
+ // runtime likewise: those projects' hooks run it.
968
+ const stillUsed = otherScopes.length > 0;
969
+ if (!stillUsed) {
786
970
  fs.rmSync(marketplaceDir(), { recursive: true, force: true });
787
971
  check('ok', 'plugin', 'removed');
788
972
  }
@@ -791,18 +975,47 @@ export function uninstall(args) {
791
975
  }
792
976
  if (removeSkill())
793
977
  check('ok', 'skill', 'removed');
794
- fs.rmSync(runtimeHome(), { recursive: true, force: true });
795
- check('ok', 'runtime', 'removed');
978
+ if (!stillUsed || purge) {
979
+ fs.rmSync(runtimeHome(), { recursive: true, force: true });
980
+ check('ok', 'runtime', stillUsed ? `removed ${dim('— --purge asked for everything; those projects need a reinstall')}` : 'removed');
981
+ }
982
+ else {
983
+ check('skip', 'runtime', `kept ${dim(`— those project installs run it (${runtimeHome()})`)}`);
984
+ }
796
985
  if (purge) {
797
986
  // Only ever on an explicit flag. This is everything the learner has done —
798
987
  // months of spaced repetition — and an uninstall that silently deletes it is
799
- // not an uninstall, it is data loss.
800
- fs.rmSync(eklavyaHome(), { recursive: true, force: true });
801
- check('ok', 'data', `removed ${dim(`(${eklavyaHome()})`)}`);
988
+ // not an uninstall, it is data loss. What went is listed, not summarised.
989
+ const home = eklavyaHome();
990
+ let entries = [];
991
+ try {
992
+ entries = fs.readdirSync(home).sort();
993
+ }
994
+ catch {
995
+ /* nothing there to delete */
996
+ }
997
+ fs.rmSync(home, { recursive: true, force: true });
998
+ check('ok', 'data', `deleted ${home}`);
999
+ if (entries.length)
1000
+ check(null, '', dim(entries.join(', ')));
802
1001
  }
803
1002
  else {
804
1003
  check('skip', 'data', `kept ${dbPath()} ${dim('— pass --purge to delete your learning history')}`);
805
1004
  }
1005
+ if (gateHook) {
1006
+ // Warned, never removed: it is the developer's repository, and the hook may
1007
+ // be chaining one of their own (`pre-commit.local`).
1008
+ const chained = path.join(path.dirname(gateHook), 'pre-commit.local');
1009
+ check('warn', 'git hook', `${gateHook} still runs the Eklavya commit gate`);
1010
+ // Hooks written before the gate learned to fail open `exec` a fixed path,
1011
+ // and that path is what uninstall just removed. Newer ones find no gate,
1012
+ // print a line and let the commit through.
1013
+ check(null, '', dim(gateHookFailsOpen(gateHook)
1014
+ ? 'with the gate gone it lets commits through, printing a warning each time; remove it:'
1015
+ : 'it was written by an older Eklavya and fails every commit once its script is gone; remove it:'));
1016
+ plain(` ${paint.aged(fs.existsSync(chained) ? `rm "${gateHook}" && mv "${chained}" "${gateHook}"` : `rm "${gateHook}"`)}`);
1017
+ plain(dim(' Any other repository you installed the gate in needs the same.'));
1018
+ }
806
1019
  if (otherScopes.length > 0) {
807
1020
  plain('');
808
1021
  plain(dim('Removed for your user account. These project-scoped installs remain, and'));