@lorekit/cli 1.30.1 → 1.30.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/README.md CHANGED
@@ -78,7 +78,18 @@ In a TTY it prompts for the scope (and for `--endpoint` / `--token` if missing).
78
78
  Flags: `--project` / `--global` pick the scope non-interactively; `--yes` runs
79
79
  non-interactively (endpoint required via flag/env; scope defaults to project);
80
80
  `--force` overwrites an existing skill copy. Re-running is idempotent — the hook
81
- entries are updated in place, never duplicated.
81
+ entries are updated in place, never duplicated, and an event that somehow ended
82
+ up with **several** lorekit entries (the marketplace plugin wired on top of a CLI
83
+ install, a merged `settings.json`, a hand edit) is collapsed back to exactly one,
84
+ reported as `N duplicate(s) removed`.
85
+
86
+ That repair runs on the hook-wiring step, which a plain `lorekit install` **skips**
87
+ on an already-complete install — it short-circuits to the "already installed"
88
+ summary instead. So if your hooks are firing twice, run `lorekit install --force`,
89
+ or re-state the wiring you want with `lorekit install --hooks all` (or
90
+ `--hooks read-only`); both reach the hook step and collapse the duplicates for the
91
+ events they wire. `--hooks none` also reaches the hook step, but it is a teardown,
92
+ not a repair — it removes every lorekit hook instead of collapsing the copies.
82
93
 
83
94
  #### Choosing the hooks
84
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.30.1",
3
+ "version": "1.30.2",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/config.mjs CHANGED
@@ -92,8 +92,19 @@ export const CLAUDE_HOOK_EVENTS = ['SessionStart', 'PostToolUseFailure', 'Stop']
92
92
  // Matches a hook command that fires the lorekit engine, whether wired as a
93
93
  // global `lorekit hook …` or `npx -y @lorekit/cli hook …`. Shared by the
94
94
  // upsert (find-or-update) and remove (uninstall) paths so they agree on what
95
- // counts as "ours".
96
- export const LOREKIT_HOOK_RE = /(?:@lorekit\/cli|lorekit) hook\b/;
95
+ // counts as "ours" — a form this misses is not merely un-updated, it is
96
+ // APPENDED alongside on the next install, which is how a settings.json ends up
97
+ // firing the same hook twice.
98
+ //
99
+ // The three deliberate tolerances, each a real wiring seen in the wild:
100
+ // • a leading path or quote — `/usr/local/bin/lorekit hook`, `"…/lorekit" hook`
101
+ // • a pinned version — `npx -y @lorekit/cli@1.2.3 hook`
102
+ // • a platform extension — `lorekit.cmd hook` on Windows
103
+ // The leading boundary is REQUIRED (start of string, whitespace, a path
104
+ // separator or a quote) so an unrelated `mylorekit hook …` is somebody else's
105
+ // command and stays untouched — the previous pattern claimed it.
106
+ export const LOREKIT_HOOK_RE =
107
+ /(?:^|[\s"'`(=/\\])(?:@lorekit\/cli|lorekit)(?:@[^\s"']+)?(?:\.(?:cmd|exe|bat|ps1|mjs|js))?\s+hook\b/;
97
108
 
98
109
  // npx stages the package's own bin into an ephemeral cache dir
99
110
  // (…/_npx/<hash>/node_modules/.bin) and prepends it to PATH for the lifetime of
@@ -211,6 +222,16 @@ export function installedHookEvents(root, scope = 'project') {
211
222
  // existing lorekit hook entry per event is updated in place, never duplicated.
212
223
  // `runner` is the command prefix (e.g. 'lorekit' or 'npx -y @lorekit/cli').
213
224
  //
225
+ // CONVERGENT, not merely additive: an event carrying SEVERAL lorekit entries
226
+ // keeps exactly ONE — the first is updated in place and every further one is
227
+ // deleted (counted as `deduped`). Reconciling only the first left the extras
228
+ // firing forever, and `install --force` — the one command a user reaches for
229
+ // precisely because the wiring is wrong — could not repair the very state it is
230
+ // most often run against. Duplicates arrive from outside this function (the
231
+ // marketplace plugin wiring `npx -y @lorekit/cli hook …` over a CLI-wired bare
232
+ // `lorekit hook …`, a merged settings.json, a hand edit), so recognising them
233
+ // on write is the only place the invariant can hold.
234
+ //
214
235
  // `events` selects WHICH of CLAUDE_HOOK_EVENTS to wire (default: all of them).
215
236
  // Any lorekit entry for a CLAUDE_HOOK_EVENT *not* in the list is REMOVED — that
216
237
  // pruning is what makes a downgrade (all → read-only) an actual downgrade rather
@@ -226,6 +247,7 @@ export function upsertClaudeHooks(root, scope, runner, events = CLAUDE_HOOK_EVEN
226
247
  let updated = 0;
227
248
  let unchanged = 0;
228
249
  let removed = 0;
250
+ let deduped = 0;
229
251
 
230
252
  for (const event of CLAUDE_HOOK_EVENTS) {
231
253
  if (!wanted.has(event)) {
@@ -236,31 +258,48 @@ export function upsertClaudeHooks(root, scope, runner, events = CLAUDE_HOOK_EVEN
236
258
  if (!Array.isArray(config.hooks[event])) config.hooks[event] = [];
237
259
  const groups = config.hooks[event];
238
260
 
239
- let existing = null;
261
+ // EVERY lorekit entry for this event, in document order — not just the
262
+ // first, which is what made a duplicated file un-repairable.
263
+ const matches = [];
240
264
  for (const group of groups) {
241
- const inner = group && Array.isArray(group.hooks) ? group.hooks : [];
242
- existing = inner.find(
243
- (h) => h && typeof h.command === 'string' && LOREKIT_HOOK_RE.test(h.command),
244
- );
245
- if (existing) break;
265
+ if (!group || !Array.isArray(group.hooks)) continue;
266
+ for (const hook of group.hooks) {
267
+ if (hook && typeof hook.command === 'string' && LOREKIT_HOOK_RE.test(hook.command)) {
268
+ matches.push({ group, hook });
269
+ }
270
+ }
246
271
  }
247
272
 
248
- if (existing) {
249
- if (existing.command === command) unchanged++;
273
+ const [canonical, ...extras] = matches;
274
+ if (canonical) {
275
+ if (canonical.hook.command === command) unchanged++;
250
276
  else {
251
- existing.command = command;
277
+ canonical.hook.command = command;
252
278
  updated++;
253
279
  }
254
280
  } else {
255
281
  groups.push({ hooks: [{ type: 'command', command }] });
256
282
  added++;
257
283
  }
284
+
285
+ // Drop the surplus copies, then tidy only the groups this emptied — a group
286
+ // that arrived empty is somebody else's business, and a group still holding
287
+ // a third-party hook must survive.
288
+ const emptied = new Set();
289
+ for (const extra of extras) {
290
+ extra.group.hooks = extra.group.hooks.filter((h) => h !== extra.hook);
291
+ deduped++;
292
+ if (extra.group.hooks.length === 0) emptied.add(extra.group);
293
+ }
294
+ if (emptied.size > 0) {
295
+ config.hooks[event] = groups.filter((g) => !emptied.has(g));
296
+ }
258
297
  }
259
298
 
260
299
  if (Object.keys(config.hooks).length === 0) delete config.hooks;
261
300
 
262
301
  writeFileAtomic(file, JSON.stringify(config, null, 2) + '\n');
263
- return { file, added, updated, unchanged, removed };
302
+ return { file, added, updated, unchanged, removed, deduped };
264
303
  }
265
304
 
266
305
  // Drop every lorekit hook entry for one event from a hooks object, tidying up
package/src/install.mjs CHANGED
@@ -438,11 +438,16 @@ export async function install(args) {
438
438
  : 'none — the skills still work, but memory stays model-invoked',
439
439
  );
440
440
  } else {
441
- const n = hooks.added + hooks.updated + hooks.removed;
441
+ const n = hooks.added + hooks.updated + hooks.removed + hooks.deduped;
442
+ // `deduped` is reported separately from `removed`: removed is a wiring the
443
+ // user asked for (a downgrade), deduped is a repair of a settings file that
444
+ // was firing the same hook twice — silently fixing that would leave the
445
+ // doubled output they came here about unexplained.
442
446
  const hookParts = [
443
447
  hooks.added ? `${hooks.added} added` : '',
444
448
  hooks.updated ? `${hooks.updated} updated` : '',
445
449
  hooks.removed ? `${hooks.removed} removed` : '',
450
+ hooks.deduped ? `${hooks.deduped} duplicate(s) removed` : '',
446
451
  ].filter(Boolean);
447
452
  const hookState = n === 0 ? 'already wired' : hookParts.join(', ');
448
453
  const wired = hookEvents.length > 0 ? ` (${hookEvents.join(', ')})` : '';