@lorekit/cli 1.9.0 → 1.10.0
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 +40 -3
- package/bin/lorekit.mjs +62 -3
- package/package.json +1 -1
- package/src/control.mjs +13 -0
- package/src/diff.mjs +180 -0
- package/src/lessons-view.mjs +0 -0
- package/src/list.mjs +2 -5
- package/src/search.mjs +2 -5
- package/src/show.mjs +2 -5
- package/src/stats.mjs +142 -0
- package/src/telemetry.mjs +4 -3
package/README.md
CHANGED
|
@@ -173,6 +173,43 @@ missing. It exits **non-zero** when the key is found in no readable store, so it
|
|
|
173
173
|
fits scripts. `--json` emits the full normalized record(s) and which store each
|
|
174
174
|
came from. Both a scope and a key are required (else a usage error).
|
|
175
175
|
|
|
176
|
+
### `lorekit stats`
|
|
177
|
+
|
|
178
|
+
An at-a-glance overview of how many lessons apply to the current directory —
|
|
179
|
+
counted **per scope** and **per store** (Offline vs Remote), with per-store and
|
|
180
|
+
grand totals, in the same Offline / Remote split as `list`:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
lorekit stats # per-scope counts + totals for both stores
|
|
184
|
+
lorekit stats --scope global # narrow to a single scope
|
|
185
|
+
lorekit stats --json # { offline, remote } with per-scope { count } rows
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Every applicable scope prints a row (a scope with zero lessons still shows `0`,
|
|
189
|
+
which is the point of an overview). An unconfigured remote degrades to a short
|
|
190
|
+
note — never an error, always exit 0. `--endpoint` / `--token` / `--store`
|
|
191
|
+
behave as in `list`. Remote counts reflect what the hosted `memory.list` returns
|
|
192
|
+
per scope (the server's default page size); there is no cap-usage `N / limit`
|
|
193
|
+
figure because the MCP surface exposes no total-count or cap tool.
|
|
194
|
+
|
|
195
|
+
### `lorekit diff`
|
|
196
|
+
|
|
197
|
+
Compare the **offline** and **remote** stores for the applicable scopes and
|
|
198
|
+
report where they diverge, grouped by scope:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
lorekit diff # local-only / remote-only / conflicting, per scope
|
|
202
|
+
lorekit diff --scope global # narrow to a single scope
|
|
203
|
+
lorekit diff --json # { comparable, totals, groups[] } for scripts
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Three groups: **local-only** (key present offline, absent remote), **remote-only**
|
|
207
|
+
(absent offline, present remote), and **conflicting** (same `scope::key` in both,
|
|
208
|
+
but the value or tags differ). A diff needs **both** stores readable — if the
|
|
209
|
+
remote is unconfigured (or a store is denied), a meaningful diff is impossible,
|
|
210
|
+
so `diff` prints a clear note (`comparable: false` in `--json`) and exits 0
|
|
211
|
+
rather than crashing. `--endpoint` / `--token` / `--store` behave as in `list`.
|
|
212
|
+
|
|
176
213
|
### `lorekit hook`
|
|
177
214
|
|
|
178
215
|
The **shared hook engine** behind the Claude Code / Cursor / Codex plugins.
|
|
@@ -355,8 +392,8 @@ active deny constraints.
|
|
|
355
392
|
| `--no-hooks` | Skip wiring the lifecycle hooks; skill + MCP only (`install`) |
|
|
356
393
|
| `--force` | Overwrite existing skill files (`install`) |
|
|
357
394
|
| `--deep` | Write/read/delete round-trip (`doctor`) |
|
|
358
|
-
| `--json` | Machine-readable output (`list` / `search` / `show`) |
|
|
359
|
-
| `--scope <scope>` | Restrict to a single scope (`list` / `search`; default: all applicable) |
|
|
395
|
+
| `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `diff`) |
|
|
396
|
+
| `--scope <scope>` | Restrict to a single scope (`list` / `search` / `stats` / `diff`; default: all applicable) |
|
|
360
397
|
| `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
|
|
361
398
|
| `--event <name>` | Host hook event for `hook` (else read from the stdin payload) |
|
|
362
399
|
| `-h, --help` | Help |
|
|
@@ -442,7 +479,7 @@ forever. This reduces manual validation to a single capture pass per tool.
|
|
|
442
479
|
## Usage telemetry
|
|
443
480
|
|
|
444
481
|
The human-facing commands (`install`, `uninstall`, `doctor`, `list`, `search`,
|
|
445
|
-
`show`, `migrate`)
|
|
482
|
+
`show`, `stats`, `diff`, `migrate`)
|
|
446
483
|
emit one OpenTelemetry span + one counter point per run so the maintainers can see which
|
|
447
484
|
commands people use. It is zero-dependency (OTLP/JSON over `fetch`, no SDK) and
|
|
448
485
|
deliberately narrow — it carries only the command name, a bounded set of boolean
|
package/bin/lorekit.mjs
CHANGED
|
@@ -9,6 +9,8 @@ import { doctor } from '../src/doctor.mjs';
|
|
|
9
9
|
import { list } from '../src/list.mjs';
|
|
10
10
|
import { search } from '../src/search.mjs';
|
|
11
11
|
import { show } from '../src/show.mjs';
|
|
12
|
+
import { stats } from '../src/stats.mjs';
|
|
13
|
+
import { diff } from '../src/diff.mjs';
|
|
12
14
|
import { hook } from '../src/hook.mjs';
|
|
13
15
|
import { migrate } from '../src/migrate.mjs';
|
|
14
16
|
import { mcpServer } from '../src/mcp-server.mjs';
|
|
@@ -48,6 +50,12 @@ ${c.bold('Commands')}
|
|
|
48
50
|
show Inspect one lesson in full: its complete value, scope, key, updated
|
|
49
51
|
date, tags, and which store(s) it lives in (noting any divergence
|
|
50
52
|
when it is in both). --json. Usage: show <scope> <key>.
|
|
53
|
+
stats Count the applicable lessons per scope and per store (offline vs
|
|
54
|
+
remote), with per-store and grand totals, in the same Offline/
|
|
55
|
+
Remote split. --json, --scope <s>.
|
|
56
|
+
diff Compare the offline and remote stores for the applicable scopes and
|
|
57
|
+
report divergence: local-only, remote-only, and conflicting keys
|
|
58
|
+
(grouped by scope). Needs both stores readable. --json, --scope <s>.
|
|
51
59
|
migrate Relocate a LoreKit-format local store into the current layout.
|
|
52
60
|
Dry-run by default; pass --yes to apply. Idempotent.
|
|
53
61
|
hook Hook engine for Claude Code / Cursor / Codex. Reads the host's
|
|
@@ -66,8 +74,8 @@ ${c.bold('Options')}
|
|
|
66
74
|
-t, --token <token> LoreKit token (lk_rw_* to allow writes, lk_ro_* read-only)
|
|
67
75
|
--mode <mode> Memory mode: off | local | remote (doctor override)
|
|
68
76
|
--store <path> Local project-tier store directory (default: .lorekit)
|
|
69
|
-
--json Machine-readable output (list / search / show)
|
|
70
|
-
--scope <scope> Restrict to a single scope (list / search)
|
|
77
|
+
--json Machine-readable output (list / search / show / stats / diff)
|
|
78
|
+
--scope <scope> Restrict to a single scope (list / search / stats / diff)
|
|
71
79
|
--from <path> Source store to migrate from (migrate)
|
|
72
80
|
--to <tier> Migration destination tier: home | project (migrate;
|
|
73
81
|
default routes each entry by scope)
|
|
@@ -240,6 +248,53 @@ ${c.bold('Options')}
|
|
|
240
248
|
${c.bold('Examples')}
|
|
241
249
|
npx @lorekit/cli show global prefer-guard-clauses
|
|
242
250
|
npx @lorekit/cli show project::widget build-flags --json
|
|
251
|
+
`,
|
|
252
|
+
stats: `${c.bold('lorekit stats')} — count the applicable lessons per scope and per store
|
|
253
|
+
|
|
254
|
+
${c.bold('Usage')}
|
|
255
|
+
npx @lorekit/cli stats [options]
|
|
256
|
+
|
|
257
|
+
Shows how many lessons apply to the current directory's scopes (project/branch/
|
|
258
|
+
repo/global), broken down per scope and per store (Offline = the local .lorekit/
|
|
259
|
+
+ ~/.lorekit/ two-tier store; Remote = the hosted MCP server), with per-store and
|
|
260
|
+
grand totals. An unconfigured remote degrades to a short note, never an error.
|
|
261
|
+
|
|
262
|
+
${c.bold('Options')}
|
|
263
|
+
-d, --dir <path> Target project root (default: current directory)
|
|
264
|
+
--scope <scope> Restrict to a single scope (default: all applicable)
|
|
265
|
+
--json Machine-readable output
|
|
266
|
+
-e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
|
|
267
|
+
-t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
|
|
268
|
+
--store <path> Local project-tier store directory (default: .lorekit)
|
|
269
|
+
|
|
270
|
+
${c.bold('Examples')}
|
|
271
|
+
npx @lorekit/cli stats
|
|
272
|
+
npx @lorekit/cli stats --json
|
|
273
|
+
npx @lorekit/cli stats --scope global
|
|
274
|
+
`,
|
|
275
|
+
diff: `${c.bold('lorekit diff')} — compare the offline and remote stores
|
|
276
|
+
|
|
277
|
+
${c.bold('Usage')}
|
|
278
|
+
npx @lorekit/cli diff [options]
|
|
279
|
+
|
|
280
|
+
Compares the local (offline) store against the hosted (remote) store for the
|
|
281
|
+
current directory's scopes and reports where they diverge, grouped by scope:
|
|
282
|
+
local-only keys, remote-only keys, and conflicting keys (same key, different
|
|
283
|
+
value or tags). A diff needs BOTH stores readable — if the remote is
|
|
284
|
+
unconfigured or a store is denied, \`diff\` prints a clear note and exits 0.
|
|
285
|
+
|
|
286
|
+
${c.bold('Options')}
|
|
287
|
+
-d, --dir <path> Target project root (default: current directory)
|
|
288
|
+
--scope <scope> Restrict to a single scope (default: all applicable)
|
|
289
|
+
--json Machine-readable output
|
|
290
|
+
-e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
|
|
291
|
+
-t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
|
|
292
|
+
--store <path> Local project-tier store directory (default: .lorekit)
|
|
293
|
+
|
|
294
|
+
${c.bold('Examples')}
|
|
295
|
+
npx @lorekit/cli diff
|
|
296
|
+
npx @lorekit/cli diff --json
|
|
297
|
+
npx @lorekit/cli diff --scope global
|
|
243
298
|
`,
|
|
244
299
|
migrate: `${c.bold('lorekit migrate')} — relocate a LoreKit-format local store into the current layout
|
|
245
300
|
|
|
@@ -300,7 +355,7 @@ const KNOWN_FLAGS = [
|
|
|
300
355
|
// reject unknown flags; the machine-facing `hook` / `mcp` do not (they must
|
|
301
356
|
// never fail on a stray flag, and only ever receive flags we control).
|
|
302
357
|
const HUMAN_COMMANDS = new Set([
|
|
303
|
-
'install', 'uninstall', 'doctor', 'list', 'search', 'show', 'migrate',
|
|
358
|
+
'install', 'uninstall', 'doctor', 'list', 'search', 'show', 'stats', 'diff', 'migrate',
|
|
304
359
|
]);
|
|
305
360
|
|
|
306
361
|
// Command aliases — canonicalized before help / dispatch so `lorekit ls --help`
|
|
@@ -380,6 +435,10 @@ async function main() {
|
|
|
380
435
|
return traceCommand('search', args, VERSION, () => search(args));
|
|
381
436
|
case 'show':
|
|
382
437
|
return traceCommand('show', args, VERSION, () => show(args));
|
|
438
|
+
case 'stats':
|
|
439
|
+
return traceCommand('stats', args, VERSION, () => stats(args));
|
|
440
|
+
case 'diff':
|
|
441
|
+
return traceCommand('diff', args, VERSION, () => diff(args));
|
|
383
442
|
case 'migrate':
|
|
384
443
|
return traceCommand('migrate', args, VERSION, () => migrate(args));
|
|
385
444
|
default:
|
package/package.json
CHANGED
package/src/control.mjs
CHANGED
|
@@ -124,6 +124,19 @@ export function localStoreDirs(root = process.cwd(), env = process.env) {
|
|
|
124
124
|
return { home, project: projectDirFrom({ env, userConfig, repoConfig, root }) };
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// Resolve the deny-wins ceiling for the read commands: which section (offline /
|
|
128
|
+
// remote) is forbidden outright by an active `deny` constraint. A deny is never
|
|
129
|
+
// overridable (see the module header), so this is the single seam `list`,
|
|
130
|
+
// `search`, `show`, `stats`, and `diff` share instead of each re-deriving the
|
|
131
|
+
// same `control.denies.find(...)` block. Returns the matched deny object
|
|
132
|
+
// ({ mode, source }) or null per side — the `source` explains the "why" in each
|
|
133
|
+
// command's graceful note. Thin wrapper over `loadControl`, co-located with it.
|
|
134
|
+
export function resolveDenies(root, { env = process.env } = {}) {
|
|
135
|
+
const control = loadControl(root, { env });
|
|
136
|
+
const find = (mode) => control.denies.find((d) => d.mode === mode) || null;
|
|
137
|
+
return { localDenied: find('local'), remoteDenied: find('remote') };
|
|
138
|
+
}
|
|
139
|
+
|
|
127
140
|
// IO wrapper — load env + config files, derive the connection, then resolve.
|
|
128
141
|
export function loadControl(root, { env = process.env } = {}) {
|
|
129
142
|
const home = userConfigDir(env);
|
package/src/diff.mjs
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// `lorekit diff` — compare the offline (local two-tier) store against the remote
|
|
2
|
+
// (hosted MCP) store for the current directory's scopes, and report where they
|
|
3
|
+
// diverge in three groups, grouped by scope:
|
|
4
|
+
// • local-only — key present offline, absent remote;
|
|
5
|
+
// • remote-only — absent offline, present remote;
|
|
6
|
+
// • conflicting — same scope::key in both, but the value/tags differ.
|
|
7
|
+
//
|
|
8
|
+
// A diff is only meaningful when BOTH stores are readable. If either is
|
|
9
|
+
// unavailable — the remote unconfigured, a `LOREKIT_DENY` ceiling, etc. — a diff
|
|
10
|
+
// is impossible, so `diff` prints a clear note explaining which store is missing
|
|
11
|
+
// and exits 0 (never a crash). Read-only. Human-facing, so the bin wraps it in
|
|
12
|
+
// `traceCommand`.
|
|
13
|
+
import process from 'node:process';
|
|
14
|
+
import { resolveProjectRoot } from './config.mjs';
|
|
15
|
+
import { deriveScope } from './scope.mjs';
|
|
16
|
+
import { resolveDenies } from './control.mjs';
|
|
17
|
+
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
18
|
+
import { scopeList, gather, diffGroups, preview, shortDate } from './lessons-view.mjs';
|
|
19
|
+
import { log, heading, status, c } from './util.mjs';
|
|
20
|
+
|
|
21
|
+
export async function diff(args) {
|
|
22
|
+
const root = resolveProjectRoot(args.dir);
|
|
23
|
+
const env = { ...process.env };
|
|
24
|
+
if (args.store) env.LOREKIT_STORE = args.store;
|
|
25
|
+
|
|
26
|
+
const scopeInfo = deriveScope(root);
|
|
27
|
+
// Default to every applicable scope; `--scope <s>` narrows to one.
|
|
28
|
+
const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo);
|
|
29
|
+
|
|
30
|
+
const { local, remote, connection } = resolveStores(root, {
|
|
31
|
+
env,
|
|
32
|
+
endpoint: args.endpoint,
|
|
33
|
+
token: args.token,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// Deny-wins section suppression, identical to the other read commands.
|
|
37
|
+
const { localDenied, remoteDenied } = resolveDenies(root, { env });
|
|
38
|
+
|
|
39
|
+
const localAvailable = !localDenied;
|
|
40
|
+
const remoteAvailable = !remoteDenied && remote.usable();
|
|
41
|
+
|
|
42
|
+
// Explain, per store, why it is not comparable (used for the not-comparable
|
|
43
|
+
// note below). A store that IS available reports `null`.
|
|
44
|
+
const offlineReason = localDenied
|
|
45
|
+
? `disabled by deny constraint (${localDenied.source})`
|
|
46
|
+
: null;
|
|
47
|
+
const remoteReason = remoteDenied
|
|
48
|
+
? `disabled by deny constraint (${remoteDenied.source})`
|
|
49
|
+
: remoteAvailable
|
|
50
|
+
? null
|
|
51
|
+
: remoteUnavailableReason(connection);
|
|
52
|
+
|
|
53
|
+
const comparable = localAvailable && remoteAvailable;
|
|
54
|
+
|
|
55
|
+
// Only gather (and diff) when both stores are readable — a one-sided read
|
|
56
|
+
// cannot distinguish "only in the other store" from "the other store is
|
|
57
|
+
// simply unreachable".
|
|
58
|
+
const result = comparable ? diffGroups(await gather(local, scopes), await gather(remote, scopes)) : null;
|
|
59
|
+
|
|
60
|
+
if (args.json) {
|
|
61
|
+
log(JSON.stringify(buildJson({ root, scopes, comparable, offlineReason, remoteReason, result }), null, 2));
|
|
62
|
+
} else if (!comparable) {
|
|
63
|
+
renderNotComparable({ root, scopes, offlineReason, remoteReason });
|
|
64
|
+
} else {
|
|
65
|
+
renderDiff({ root, scopes, connection, remoteAvailable, result });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Bounded, non-PII telemetry extras — counts + booleans, never a scope
|
|
69
|
+
// string, key, path, or token.
|
|
70
|
+
const totals = result ? result.totals : { localOnly: 0, remoteOnly: 0, conflicting: 0 };
|
|
71
|
+
return {
|
|
72
|
+
exitCode: 0,
|
|
73
|
+
'lorekit.cli.diff.scope_count': scopes.length,
|
|
74
|
+
'lorekit.cli.diff.comparable': comparable,
|
|
75
|
+
'lorekit.cli.diff.local_only': totals.localOnly,
|
|
76
|
+
'lorekit.cli.diff.remote_only': totals.remoteOnly,
|
|
77
|
+
'lorekit.cli.diff.conflicting': totals.conflicting,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The not-comparable note: which store(s) are missing, and why a diff needs both.
|
|
82
|
+
function renderNotComparable({ root, scopes, offlineReason, remoteReason }) {
|
|
83
|
+
heading('LoreKit diff');
|
|
84
|
+
log(` project: ${c.dim(root)}`);
|
|
85
|
+
log(` scopes: ${scopes.join(' → ')}`);
|
|
86
|
+
log('');
|
|
87
|
+
if (offlineReason) status('warn', 'offline unavailable', offlineReason);
|
|
88
|
+
if (remoteReason) status('warn', 'remote unavailable', remoteReason);
|
|
89
|
+
log(
|
|
90
|
+
` ${c.dim('a diff compares the offline and remote stores side by side — both must be readable.')}`,
|
|
91
|
+
);
|
|
92
|
+
log('');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The real diff: three grouped sections. Each section groups the divergent keys
|
|
96
|
+
// by scope; a scope with nothing in a section is omitted from it.
|
|
97
|
+
function renderDiff({ root, scopes, connection, remoteAvailable, result }) {
|
|
98
|
+
heading('LoreKit diff');
|
|
99
|
+
log(` project: ${c.dim(root)}`);
|
|
100
|
+
log(` scopes: ${scopes.join(' → ')}`);
|
|
101
|
+
if (remoteAvailable) log(` remote: ${c.dim(connection.endpoint)}`);
|
|
102
|
+
|
|
103
|
+
const { groups, totals } = result;
|
|
104
|
+
|
|
105
|
+
// Surface any per-scope read errors up front — a scope that failed to read on
|
|
106
|
+
// one side can't be diffed, and its keys are neither "only" nor "conflicting".
|
|
107
|
+
const errored = groups.filter((g) => g.error);
|
|
108
|
+
if (errored.length) {
|
|
109
|
+
heading('Unreadable scopes');
|
|
110
|
+
for (const g of errored) log(` ${c.bold(g.scope)} ${c.yellow('!')} ${c.dim(g.error)}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
renderSet('Local-only', 'present offline, absent remote', groups, (g) => g.localOnly);
|
|
114
|
+
renderSet('Remote-only', 'absent offline, present remote', groups, (g) => g.remoteOnly);
|
|
115
|
+
renderConflicts(groups);
|
|
116
|
+
|
|
117
|
+
if (totals.localOnly + totals.remoteOnly + totals.conflicting === 0 && !errored.length) {
|
|
118
|
+
log('');
|
|
119
|
+
log(` ${c.dim('the offline and remote stores are in sync for the applicable scopes')}`);
|
|
120
|
+
}
|
|
121
|
+
log('');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// One "only in store X" section: group its entries by scope, key + preview per
|
|
125
|
+
// entry. Skipped entirely when no scope has any entry in it.
|
|
126
|
+
function renderSet(title, subtitle, groups, pick) {
|
|
127
|
+
const present = groups.filter((g) => pick(g).length);
|
|
128
|
+
if (!present.length) return;
|
|
129
|
+
heading(title);
|
|
130
|
+
log(` ${c.dim(subtitle)}`);
|
|
131
|
+
for (const g of present) {
|
|
132
|
+
log(` ${c.bold(g.scope)}`);
|
|
133
|
+
for (const e of pick(g)) {
|
|
134
|
+
const when = e.updated ? ` ${c.dim(`(updated ${shortDate(e.updated)})`)}` : '';
|
|
135
|
+
log(` ${c.cyan('•')} ${e.key}${when}`);
|
|
136
|
+
if (e.value) log(` ${c.dim(preview(e.value))}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The conflicting section: same key in both stores, differing value/tags. Shows
|
|
142
|
+
// a short preview of each side so the divergence is visible at a glance.
|
|
143
|
+
function renderConflicts(groups) {
|
|
144
|
+
const present = groups.filter((g) => g.conflicting.length);
|
|
145
|
+
if (!present.length) return;
|
|
146
|
+
heading('Conflicting');
|
|
147
|
+
log(` ${c.dim('same key in both stores, but the value or tags differ')}`);
|
|
148
|
+
for (const g of present) {
|
|
149
|
+
log(` ${c.bold(g.scope)}`);
|
|
150
|
+
for (const conflict of g.conflicting) {
|
|
151
|
+
log(` ${c.yellow('•')} ${conflict.key}`);
|
|
152
|
+
log(` ${c.dim('offline')} ${preview(conflict.local.value)}`);
|
|
153
|
+
log(` ${c.dim('remote ')} ${preview(conflict.remote.value)}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// The `--json` payload. When not comparable:
|
|
159
|
+
// { root, scopes, comparable:false, offline:{available,reason}, remote:{...} }
|
|
160
|
+
// When comparable:
|
|
161
|
+
// { root, scopes, comparable:true, totals, groups:[{ scope, localOnly,
|
|
162
|
+
// remoteOnly, conflicting, error }] } — the exact shape `diffGroups` returns.
|
|
163
|
+
function buildJson({ root, scopes, comparable, offlineReason, remoteReason, result }) {
|
|
164
|
+
if (!comparable) {
|
|
165
|
+
return {
|
|
166
|
+
root,
|
|
167
|
+
scopes,
|
|
168
|
+
comparable: false,
|
|
169
|
+
offline: offlineReason ? { available: false, reason: offlineReason } : { available: true },
|
|
170
|
+
remote: remoteReason ? { available: false, reason: remoteReason } : { available: true },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
root,
|
|
175
|
+
scopes,
|
|
176
|
+
comparable: true,
|
|
177
|
+
totals: result.totals,
|
|
178
|
+
groups: result.groups,
|
|
179
|
+
};
|
|
180
|
+
}
|
package/src/lessons-view.mjs
CHANGED
|
Binary file
|
package/src/list.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import path from 'node:path';
|
|
|
11
11
|
import process from 'node:process';
|
|
12
12
|
import { resolveProjectRoot } from './config.mjs';
|
|
13
13
|
import { deriveScope } from './scope.mjs';
|
|
14
|
-
import {
|
|
14
|
+
import { resolveDenies } from './control.mjs';
|
|
15
15
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
16
16
|
import { scopeList, gather, renderSection } from './lessons-view.mjs';
|
|
17
17
|
import { log, heading, c } from './util.mjs';
|
|
@@ -49,10 +49,7 @@ export async function list(args) {
|
|
|
49
49
|
// A `LOREKIT_DENY=remote|local` privacy/compliance ceiling (deny-wins, never
|
|
50
50
|
// overridable) suppresses that section entirely — the same invariant the
|
|
51
51
|
// agent-facing control model enforces, honored here in the human read view.
|
|
52
|
-
const
|
|
53
|
-
const deny = (mode) => control.denies.find((d) => d.mode === mode) || null;
|
|
54
|
-
const localDenied = deny('local');
|
|
55
|
-
const remoteDenied = deny('remote');
|
|
52
|
+
const { localDenied, remoteDenied } = resolveDenies(root, { env });
|
|
56
53
|
|
|
57
54
|
const offline = localDenied ? { groups: [], total: 0 } : await gather(local, scopes);
|
|
58
55
|
const remoteAvailable = !remoteDenied && remote.usable();
|
package/src/search.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import process from 'node:process';
|
|
17
17
|
import { resolveProjectRoot } from './config.mjs';
|
|
18
18
|
import { deriveScope } from './scope.mjs';
|
|
19
|
-
import {
|
|
19
|
+
import { resolveDenies } from './control.mjs';
|
|
20
20
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
21
21
|
import { scopeList, gather, filterGroups, renderSection } from './lessons-view.mjs';
|
|
22
22
|
import { log, err, heading, c } from './util.mjs';
|
|
@@ -48,10 +48,7 @@ export async function search(args) {
|
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
// Deny-wins section suppression, identical to `list`.
|
|
51
|
-
const
|
|
52
|
-
const deny = (mode) => control.denies.find((d) => d.mode === mode) || null;
|
|
53
|
-
const localDenied = deny('local');
|
|
54
|
-
const remoteDenied = deny('remote');
|
|
51
|
+
const { localDenied, remoteDenied } = resolveDenies(root, { env });
|
|
55
52
|
|
|
56
53
|
const offline = localDenied
|
|
57
54
|
? { groups: [], total: 0 }
|
package/src/show.mjs
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// so the bin wraps it in `traceCommand`.
|
|
12
12
|
import process from 'node:process';
|
|
13
13
|
import { resolveProjectRoot } from './config.mjs';
|
|
14
|
-
import {
|
|
14
|
+
import { resolveDenies } from './control.mjs';
|
|
15
15
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
16
16
|
import { normalizeEntry, shortDate, describeError, recordsDiverge } from './lessons-view.mjs';
|
|
17
17
|
import { log, err, heading, status, c } from './util.mjs';
|
|
@@ -56,10 +56,7 @@ export async function show(args) {
|
|
|
56
56
|
});
|
|
57
57
|
|
|
58
58
|
// Deny-wins section suppression, identical to `list`/`search`.
|
|
59
|
-
const
|
|
60
|
-
const deny = (mode) => control.denies.find((d) => d.mode === mode) || null;
|
|
61
|
-
const localDenied = deny('local');
|
|
62
|
-
const remoteDenied = deny('remote');
|
|
59
|
+
const { localDenied, remoteDenied } = resolveDenies(root, { env });
|
|
63
60
|
|
|
64
61
|
const offline = localDenied
|
|
65
62
|
? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
|
package/src/stats.mjs
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// `lorekit stats` — an at-a-glance overview of the lessons that apply to the
|
|
2
|
+
// current directory: how many live in each scope, split per store (offline vs
|
|
3
|
+
// remote), plus per-store and grand totals. Same Offline / Remote framing as
|
|
4
|
+
// `list`, but counts-only — no lesson bodies.
|
|
5
|
+
//
|
|
6
|
+
// Graceful by design (mirrors `list`/`search`/`show`): a `LOREKIT_DENY` ceiling
|
|
7
|
+
// suppresses a section, and an unconfigured remote degrades to a short note,
|
|
8
|
+
// never an error (exit 0). Read-only. Human-facing, so the bin wraps it in
|
|
9
|
+
// `traceCommand`.
|
|
10
|
+
//
|
|
11
|
+
// On cap usage: the hosted MCP surface exposes no tool that returns the user's
|
|
12
|
+
// total active-memory count or their configured cap (the cap is enforced by a
|
|
13
|
+
// DB trigger and read via `lorekit_get_limit`, neither reachable from a
|
|
14
|
+
// `memory.*` tool call), so `stats` deliberately reports only observed counts
|
|
15
|
+
// rather than guessing a `N / cap` ratio. Remote per-scope counts reflect what
|
|
16
|
+
// `memory.list` returns for each scope (server-side default page size), the
|
|
17
|
+
// same window `list` already shows.
|
|
18
|
+
import process from 'node:process';
|
|
19
|
+
import { resolveProjectRoot } from './config.mjs';
|
|
20
|
+
import { deriveScope } from './scope.mjs';
|
|
21
|
+
import { resolveDenies } from './control.mjs';
|
|
22
|
+
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
23
|
+
import { scopeList, gather, tallyGroups } from './lessons-view.mjs';
|
|
24
|
+
import { log, heading, status, c } from './util.mjs';
|
|
25
|
+
|
|
26
|
+
export async function stats(args) {
|
|
27
|
+
const root = resolveProjectRoot(args.dir);
|
|
28
|
+
const env = { ...process.env };
|
|
29
|
+
if (args.store) env.LOREKIT_STORE = args.store;
|
|
30
|
+
|
|
31
|
+
const scopeInfo = deriveScope(root);
|
|
32
|
+
// Default to every applicable scope; `--scope <s>` narrows to one (an explicit
|
|
33
|
+
// scope outside the applicable set is honoured — the user asked for it).
|
|
34
|
+
const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo);
|
|
35
|
+
|
|
36
|
+
const { local, remote, connection } = resolveStores(root, {
|
|
37
|
+
env,
|
|
38
|
+
endpoint: args.endpoint,
|
|
39
|
+
token: args.token,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Deny-wins section suppression, identical to `list`/`search`/`show`.
|
|
43
|
+
const { localDenied, remoteDenied } = resolveDenies(root, { env });
|
|
44
|
+
|
|
45
|
+
const offlineTally = localDenied ? { perScope: [], total: 0 } : tallyGroups(await gather(local, scopes));
|
|
46
|
+
const remoteAvailable = !remoteDenied && remote.usable();
|
|
47
|
+
const remoteTally = remoteAvailable
|
|
48
|
+
? tallyGroups(await gather(remote, scopes))
|
|
49
|
+
: { perScope: [], total: 0 };
|
|
50
|
+
|
|
51
|
+
const offlineSection = localDenied
|
|
52
|
+
? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
|
|
53
|
+
: { available: true, ...offlineTally };
|
|
54
|
+
const remoteSection = remoteAvailable
|
|
55
|
+
? { available: true, ...remoteTally }
|
|
56
|
+
: {
|
|
57
|
+
available: false,
|
|
58
|
+
reason: remoteDenied
|
|
59
|
+
? `disabled by deny constraint (${remoteDenied.source})`
|
|
60
|
+
: remoteUnavailableReason(connection),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
if (args.json) {
|
|
64
|
+
log(JSON.stringify(buildJson({ root, scopes, offlineSection, remoteSection }), null, 2));
|
|
65
|
+
} else {
|
|
66
|
+
heading('LoreKit stats');
|
|
67
|
+
log(` project: ${c.dim(root)}`);
|
|
68
|
+
log(` scopes: ${scopes.join(' → ')}`);
|
|
69
|
+
|
|
70
|
+
renderStatsSection({ title: 'Offline' }, offlineSection, scopes);
|
|
71
|
+
renderStatsSection(
|
|
72
|
+
{ title: 'Remote', subtitle: remoteAvailable ? connection.endpoint : undefined },
|
|
73
|
+
remoteSection,
|
|
74
|
+
scopes,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
log('');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Bounded, non-PII telemetry extras (counts + a boolean) — never a scope
|
|
81
|
+
// string, path, key, or token.
|
|
82
|
+
return {
|
|
83
|
+
exitCode: 0,
|
|
84
|
+
'lorekit.cli.stats.scope_count': scopes.length,
|
|
85
|
+
'lorekit.cli.stats.offline_count': offlineSection.available ? offlineSection.total : 0,
|
|
86
|
+
'lorekit.cli.stats.remote_count': remoteSection.available ? remoteSection.total : 0,
|
|
87
|
+
'lorekit.cli.stats.remote_available': remoteAvailable,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Render one store's count summary: an unavailable note, or a small right-
|
|
92
|
+
// aligned table of `scope count` rows plus a `total` line. Scopes with a read
|
|
93
|
+
// error show the error in place of a count so a partial read is never mistaken
|
|
94
|
+
// for an empty scope.
|
|
95
|
+
function renderStatsSection(header, section, scopes) {
|
|
96
|
+
heading(header.title);
|
|
97
|
+
if (header.subtitle) log(` ${c.dim(header.subtitle)}`);
|
|
98
|
+
|
|
99
|
+
if (!section.available) {
|
|
100
|
+
status('warn', 'unavailable', section.reason);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Index the tally by scope so every applicable scope prints a row (a scope
|
|
105
|
+
// with zero lessons still shows `0`, which is the point of an overview).
|
|
106
|
+
const byScope = new Map(section.perScope.map((s) => [s.scope, s]));
|
|
107
|
+
const width = Math.max(0, ...scopes.map((s) => s.length));
|
|
108
|
+
for (const scope of scopes) {
|
|
109
|
+
const row = byScope.get(scope) || { count: 0, error: null };
|
|
110
|
+
const label = scope.padEnd(width);
|
|
111
|
+
if (row.error) {
|
|
112
|
+
log(` ${c.bold(label)} ${c.yellow('!')} ${c.dim(row.error)}`);
|
|
113
|
+
} else {
|
|
114
|
+
log(` ${c.bold(label)} ${row.count}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
log(` ${c.dim('total'.padEnd(width))} ${c.bold(String(section.total))}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// The `--json` payload: `{ root, scopes, offline, remote }` — each store a
|
|
121
|
+
// `{ available, total, scopes: [{ scope, count, error }] }` record (or an
|
|
122
|
+
// unavailable note), so a script gets the same shape regardless of which store
|
|
123
|
+
// the counts came from.
|
|
124
|
+
function buildJson({ root, scopes, offlineSection, remoteSection }) {
|
|
125
|
+
return {
|
|
126
|
+
root,
|
|
127
|
+
scopes,
|
|
128
|
+
offline: sectionJson(offlineSection),
|
|
129
|
+
remote: sectionJson(remoteSection),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function sectionJson(section) {
|
|
134
|
+
if (!section.available) {
|
|
135
|
+
return { available: false, reason: section.reason, total: 0, scopes: [] };
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
available: true,
|
|
139
|
+
total: section.total,
|
|
140
|
+
scopes: section.perScope.map((s) => ({ scope: s.scope, count: s.count, error: s.error })),
|
|
141
|
+
};
|
|
142
|
+
}
|
package/src/telemetry.mjs
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// mirrors the Edge Function's SDK-free approach (supabase/functions/_shared/
|
|
5
5
|
// otel.ts): OTLP/JSON over the global fetch (Node 18+), no @opentelemetry/*
|
|
6
6
|
// packages. One span + one counter data point per human-facing command
|
|
7
|
-
// (install / uninstall / doctor / list / search / show /
|
|
8
|
-
// Dash0 so the maintainers can see which commands people
|
|
7
|
+
// (install / uninstall / doctor / list / search / show / stats / diff /
|
|
8
|
+
// migrate), fired to Dash0 so the maintainers can see which commands people
|
|
9
|
+
// actually run.
|
|
9
10
|
//
|
|
10
11
|
// Privacy — this runs on end-users' machines, so it is deliberately narrow:
|
|
11
12
|
// • Opt-out honored: LOREKIT_TELEMETRY=0|off|false|no|disable, or the
|
|
@@ -274,7 +275,7 @@ function normalizeExitCode(result) {
|
|
|
274
275
|
* counter point. Returns the command's exit code unchanged. Telemetry failures
|
|
275
276
|
* are swallowed — the command result is never affected.
|
|
276
277
|
*
|
|
277
|
-
* @param {string} command bounded: install | uninstall | doctor | list | search | show | migrate
|
|
278
|
+
* @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | diff | migrate
|
|
278
279
|
* @param {object} args parsed CLI args (read for allow-listed flags only)
|
|
279
280
|
* @param {string} version CLI version (from package.json)
|
|
280
281
|
* @param {() => Promise<number>} run the command handler
|