@lorekit/cli 1.30.0 → 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 +12 -1
- package/package.json +1 -1
- package/src/config.mjs +51 -12
- package/src/deeplink-pure.mjs +12 -4
- package/src/install.mjs +6 -1
- package/src/telemetry.mjs +23 -3
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
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
(
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
249
|
-
|
|
273
|
+
const [canonical, ...extras] = matches;
|
|
274
|
+
if (canonical) {
|
|
275
|
+
if (canonical.hook.command === command) unchanged++;
|
|
250
276
|
else {
|
|
251
|
-
|
|
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/deeplink-pure.mjs
CHANGED
|
@@ -29,7 +29,15 @@ export const LORE_PARAM_DEFAULTS = {
|
|
|
29
29
|
q: '', // string search query
|
|
30
30
|
range: null, // { from, to } | null (DateRange, "YYYY-MM-DD")
|
|
31
31
|
owner: 'all', // 'all' | 'personal' | { orgId }
|
|
32
|
-
|
|
32
|
+
// Filter[] | null — the Explorer's multi-dimension filter bar (label / agent /
|
|
33
|
+
// trigger / repo / branch / pr). `null`, NOT `[]`, is the default on purpose:
|
|
34
|
+
// the app has to tell "the param is absent" from "the bar is explicitly
|
|
35
|
+
// empty", because an absent `filters` falls back to the legacy `tags`
|
|
36
|
+
// shorthand while an empty one deliberately does not. Encoding `[]` here
|
|
37
|
+
// would emit the param (it is not the default) and mean the opposite of
|
|
38
|
+
// "unfiltered".
|
|
39
|
+
filters: null,
|
|
40
|
+
tags: [], // string[] — legacy label filter (AND across labels); [] means "no filter". Still READ by the app, superseded by `filters`
|
|
33
41
|
view: 'scope', // 'scope' | 'time'
|
|
34
42
|
archived: false, // boolean
|
|
35
43
|
lesson: null, // { scope, key } | null — opens the detail sheet
|
|
@@ -37,9 +45,9 @@ export const LORE_PARAM_DEFAULTS = {
|
|
|
37
45
|
|
|
38
46
|
// A stable, readable param order (also makes URLs deterministic for tests).
|
|
39
47
|
// Mirrors the `useUrlState` call order in `LoreExplorer.tsx` (+ the `lesson`
|
|
40
|
-
// param last), so `tags`
|
|
41
|
-
// `lesson` so a lesson link reads `?scope=…&lesson=…`.
|
|
42
|
-
const PARAM_ORDER = ['scope', 'q', 'range', 'owner', 'tags', 'view', 'archived', 'lesson'];
|
|
48
|
+
// param last), so `filters` and `tags` sit between `owner` and `view`. `scope`
|
|
49
|
+
// precedes `lesson` so a lesson link reads `?scope=…&lesson=…`.
|
|
50
|
+
const PARAM_ORDER = ['scope', 'q', 'range', 'owner', 'filters', 'tags', 'view', 'archived', 'lesson'];
|
|
43
51
|
|
|
44
52
|
// Strip trailing slashes from a base URL, falling back to the default when the
|
|
45
53
|
// input is empty/absent. Pure.
|
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(', ')})` : '';
|
package/src/telemetry.mjs
CHANGED
|
@@ -483,6 +483,11 @@ function normalizeExitCode(result) {
|
|
|
483
483
|
* counter point. Returns the command's exit code unchanged. Telemetry failures
|
|
484
484
|
* are swallowed — the command result is never affected.
|
|
485
485
|
*
|
|
486
|
+
* The span carries an ERROR status only when the command CRASHED. A command
|
|
487
|
+
* that ran to completion and exited non-zero (a failing `doctor` check, a
|
|
488
|
+
* `lint` finding) reports `lorekit.cli.outcome=failure` on a span the exporter
|
|
489
|
+
* emits as STATUS_CODE_OK — never ERROR.
|
|
490
|
+
*
|
|
486
491
|
* @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | scopes | diff | tree | lint | dedupe | link | migrate
|
|
487
492
|
* @param {object} args parsed CLI args (read for allow-listed flags only)
|
|
488
493
|
* @param {string} version CLI version (from package.json)
|
|
@@ -525,6 +530,10 @@ export async function traceCommand(command, args, version, run) {
|
|
|
525
530
|
|
|
526
531
|
const startMs = Date.now();
|
|
527
532
|
let exitCode = 0;
|
|
533
|
+
// `outcome` is the command's VERDICT (ok | failure | error); `status` is the
|
|
534
|
+
// SPAN status, and only a crash sets it to error. See the note above the
|
|
535
|
+
// non-zero-exit branch below.
|
|
536
|
+
let outcome = 'ok';
|
|
528
537
|
let status = 'ok';
|
|
529
538
|
let statusMessage;
|
|
530
539
|
let extraAttrs = {};
|
|
@@ -543,11 +552,22 @@ export async function traceCommand(command, args, version, run) {
|
|
|
543
552
|
}
|
|
544
553
|
}
|
|
545
554
|
if (typeof exitCode === 'number' && exitCode !== 0) {
|
|
546
|
-
|
|
547
|
-
|
|
555
|
+
// A non-zero exit is a REPORTED VERDICT, not a fault: `doctor` exits 1
|
|
556
|
+
// because a check it ran came back failing, and `lint` exits 1 because it
|
|
557
|
+
// found what it was asked to look for. Both commands did their job. Only
|
|
558
|
+
// a crash (the catch below) is an error, so `status` stays `'ok'` here —
|
|
559
|
+
// which `buildTracePayload` emits as STATUS_CODE_OK (1), the same status
|
|
560
|
+
// a zero-exit run gets — and the verdict is carried by
|
|
561
|
+
// `lorekit.cli.outcome=failure` +
|
|
562
|
+
// `lorekit.cli.exit_code` — which keeps the CLI's error rate a measure of
|
|
563
|
+
// the CLI being broken rather than of the user's environment being
|
|
564
|
+
// unhealthy. Query the failure verdicts on those attributes, never on the
|
|
565
|
+
// span status.
|
|
566
|
+
outcome = 'failure';
|
|
548
567
|
}
|
|
549
568
|
return exitCode;
|
|
550
569
|
} catch (e) {
|
|
570
|
+
outcome = 'error';
|
|
551
571
|
status = 'error';
|
|
552
572
|
// Record only a bounded, non-PII identifier — NEVER e.message. Node fs /
|
|
553
573
|
// network error messages embed absolute paths (e.g. "ENOENT: ... open
|
|
@@ -566,7 +586,7 @@ export async function traceCommand(command, args, version, run) {
|
|
|
566
586
|
const attributes = commandAttributes({
|
|
567
587
|
command,
|
|
568
588
|
args,
|
|
569
|
-
outcome
|
|
589
|
+
outcome,
|
|
570
590
|
exitCode: typeof exitCode === 'number' ? exitCode : undefined,
|
|
571
591
|
extraAttrs,
|
|
572
592
|
});
|