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.
- package/dist/assets/dashboard.html +1 -1
- package/dist/assets/tutor/references/focus-and-level.md +7 -0
- package/dist/claude-mem.js +9 -7
- package/dist/claude-mem.js.map +1 -1
- package/dist/cli-memory.js +786 -0
- package/dist/cli-memory.js.map +1 -0
- package/dist/cli.js +95 -773
- package/dist/cli.js.map +1 -1
- package/dist/config.js +79 -13
- package/dist/config.js.map +1 -1
- package/dist/dashboard.js +36 -14
- package/dist/dashboard.js.map +1 -1
- package/dist/db.js +33 -6
- package/dist/db.js.map +1 -1
- package/dist/hooks/capture-lib.js +55 -0
- package/dist/hooks/capture-lib.js.map +1 -0
- package/dist/hooks/capture-tool.js +1 -1
- package/dist/hooks/capture-tool.js.map +1 -1
- package/dist/hooks/commit-lib.js +255 -0
- package/dist/hooks/commit-lib.js.map +1 -0
- package/dist/hooks/lib.js +37 -5
- package/dist/hooks/lib.js.map +1 -1
- package/dist/hooks/memory-lib.js +93 -78
- package/dist/hooks/memory-lib.js.map +1 -1
- package/dist/hooks/pre-tool-gate.js +5 -10
- package/dist/hooks/pre-tool-gate.js.map +1 -1
- package/dist/hooks/prompt-submit-nudge.js +26 -1
- package/dist/hooks/prompt-submit-nudge.js.map +1 -1
- package/dist/hooks/session-start.js +13 -4
- package/dist/hooks/session-start.js.map +1 -1
- package/dist/install.js +280 -67
- package/dist/install.js.map +1 -1
- package/dist/memory/capture.js +25 -5
- package/dist/memory/capture.js.map +1 -1
- package/dist/memory/privacy.js +105 -7
- package/dist/memory/privacy.js.map +1 -1
- package/dist/memory/provider.js +13 -3
- package/dist/memory/provider.js.map +1 -1
- package/dist/memory/recall.js +18 -6
- package/dist/memory/recall.js.map +1 -1
- package/dist/memory/spool.js +105 -23
- package/dist/memory/spool.js.map +1 -1
- package/dist/memory/store.js +32 -2
- package/dist/memory/store.js.map +1 -1
- package/dist/memory/summarize.js +5 -5
- package/dist/memory/summarize.js.map +1 -1
- package/dist/memory/worker.js +71 -10
- package/dist/memory/worker.js.map +1 -1
- package/dist/migrate.js +60 -14
- package/dist/migrate.js.map +1 -1
- package/dist/paths.js +49 -0
- package/dist/paths.js.map +1 -1
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/cli/CLAUDE.md +16 -4
- package/dist/plugin/hooks/CLAUDE.md +33 -3
- package/dist/plugin/hooks/run.mjs +69 -5
- package/dist/plugin/scripts/install-git-hook.sh +114 -24
- package/dist/plugin/skills/CLAUDE.md +1 -1
- package/dist/plugin/skills/setup/SKILL.md +9 -2
- package/dist/plugin/skills/tutor/references/focus-and-level.md +7 -0
- package/dist/safe-write.js +146 -0
- package/dist/safe-write.js.map +1 -0
- package/dist/slug.js +4 -1
- package/dist/slug.js.map +1 -1
- package/dist/srs.js +33 -1
- package/dist/srs.js.map +1 -1
- package/dist/store.js +29 -20
- package/dist/store.js.map +1 -1
- package/dist/tools/config_tools.js +23 -1
- package/dist/tools/config_tools.js.map +1 -1
- package/dist/tools/get_session_quiz_plan.js +51 -30
- package/dist/tools/get_session_quiz_plan.js.map +1 -1
- package/dist/tools/log_session_concepts.js +30 -23
- package/dist/tools/log_session_concepts.js.map +1 -1
- package/dist/tools/record_attempt.js +18 -8
- package/dist/tools/record_attempt.js.map +1 -1
- package/dist/tools/types.js +29 -0
- package/dist/tools/types.js.map +1 -1
- package/dist/tools/upsert_concepts.js +12 -10
- package/dist/tools/upsert_concepts.js.map +1 -1
- package/dist/user-skill/eklavya/SKILL.md +26 -10
- 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
|
-
? [
|
|
126
|
-
: [
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
/**
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
357
|
-
const
|
|
358
|
-
const
|
|
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
|
|
404
|
+
source,
|
|
361
405
|
installLocation: marketplaceDir(),
|
|
362
|
-
lastUpdated:
|
|
406
|
+
lastUpdated: (sameMarket && previousMarket?.lastUpdated) || now,
|
|
363
407
|
autoUpdate: true,
|
|
364
408
|
};
|
|
365
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
395
|
-
const
|
|
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
|
-
|
|
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
|
|
416
|
-
*
|
|
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
|
-
|
|
422
|
-
|
|
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
|
|
432
|
-
const
|
|
433
|
-
const known =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
509
|
+
saveJson(files.installed, installed, backups);
|
|
455
510
|
}
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
521
|
+
changed = true;
|
|
467
522
|
}
|
|
468
523
|
if ('eklavya@eklavya' in enabled) {
|
|
469
524
|
delete enabled['eklavya@eklavya'];
|
|
470
525
|
settings.enabledPlugins = enabled;
|
|
471
|
-
|
|
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 =
|
|
658
|
-
|
|
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 =
|
|
669
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
795
|
-
|
|
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
|
-
|
|
801
|
-
|
|
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'));
|