@lorekit/cli 1.8.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 CHANGED
@@ -134,6 +134,82 @@ lorekit list --json # structured { offline, remote } payload for script
134
134
  `--endpoint` / `--token` override the remote connection; `--store` overrides the
135
135
  local project-tier directory.
136
136
 
137
+ ### `lorekit search` (alias `grep`)
138
+
139
+ Full-text search across the same applicable scopes and the same two stores as
140
+ `list`, rendered in the same Offline / Remote split. A lesson matches when the
141
+ query appears — **case-insensitively, as a literal substring** — in its **key or
142
+ value**:
143
+
144
+ ```bash
145
+ lorekit search sandbox # both sections, only the matching lessons
146
+ lorekit grep "flaky test" # same, via the alias
147
+ lorekit search migration --scope global
148
+ lorekit search build --json # { query, offline, remote } for scripts
149
+ ```
150
+
151
+ The query is matched with a plain substring check, **never compiled as a regex**,
152
+ so a term full of metacharacters (`a.*(b)`) matches those characters verbatim —
153
+ no injection, no surprises. It is read-only, hides archived lessons, and degrades
154
+ the remote section gracefully (an unconfigured remote is a note, not an error;
155
+ the command still exits 0). An empty query is a usage error; no matches prints a
156
+ friendly "no lessons match" note (exit 0). `--scope` narrows to one scope;
157
+ `--endpoint` / `--token` / `--store` behave as in `list`.
158
+
159
+ ### `lorekit show`
160
+
161
+ Inspect **one** lesson in full — its complete, **untruncated** value plus scope,
162
+ key, updated date, tags, and which store(s) it lives in:
163
+
164
+ ```bash
165
+ lorekit show global prefer-guard-clauses
166
+ lorekit show project::widget build-flags --json
167
+ ```
168
+
169
+ If the same `scope::key` exists in **both** the offline and remote stores —
170
+ possibly with different values — both are shown and any divergence is flagged.
171
+ When it lives in only one store, that copy is shown and the other is noted as
172
+ missing. It exits **non-zero** when the key is found in no readable store, so it
173
+ fits scripts. `--json` emits the full normalized record(s) and which store each
174
+ came from. Both a scope and a key are required (else a usage error).
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
+
137
213
  ### `lorekit hook`
138
214
 
139
215
  The **shared hook engine** behind the Claude Code / Cursor / Codex plugins.
@@ -316,8 +392,8 @@ active deny constraints.
316
392
  | `--no-hooks` | Skip wiring the lifecycle hooks; skill + MCP only (`install`) |
317
393
  | `--force` | Overwrite existing skill files (`install`) |
318
394
  | `--deep` | Write/read/delete round-trip (`doctor`) |
319
- | `--json` | Machine-readable output (`list`) |
320
- | `--scope <scope>` | Restrict to a single scope (`list`; 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) |
321
397
  | `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
322
398
  | `--event <name>` | Host hook event for `hook` (else read from the stdin payload) |
323
399
  | `-h, --help` | Help |
@@ -402,7 +478,8 @@ forever. This reduces manual validation to a single capture pass per tool.
402
478
 
403
479
  ## Usage telemetry
404
480
 
405
- The human-facing commands (`install`, `uninstall`, `doctor`, `list`, `migrate`)
481
+ The human-facing commands (`install`, `uninstall`, `doctor`, `list`, `search`,
482
+ `show`, `stats`, `diff`, `migrate`)
406
483
  emit one OpenTelemetry span + one counter point per run so the maintainers can see which
407
484
  commands people use. It is zero-dependency (OTLP/JSON over `fetch`, no SDK) and
408
485
  deliberately narrow — it carries only the command name, a bounded set of boolean
package/bin/lorekit.mjs CHANGED
@@ -7,6 +7,10 @@ import { install } from '../src/install.mjs';
7
7
  import { uninstall } from '../src/uninstall.mjs';
8
8
  import { doctor } from '../src/doctor.mjs';
9
9
  import { list } from '../src/list.mjs';
10
+ import { search } from '../src/search.mjs';
11
+ import { show } from '../src/show.mjs';
12
+ import { stats } from '../src/stats.mjs';
13
+ import { diff } from '../src/diff.mjs';
10
14
  import { hook } from '../src/hook.mjs';
11
15
  import { migrate } from '../src/migrate.mjs';
12
16
  import { mcpServer } from '../src/mcp-server.mjs';
@@ -40,6 +44,18 @@ ${c.bold('Commands')}
40
44
  an Offline section (local .lorekit/ + ~/.lorekit/) and a Remote
41
45
  section (hosted MCP). Groups by scope (project/branch/repo/global).
42
46
  --json for scripting, --scope <s> to narrow.
47
+ search Full-text search the applicable lessons across both stores and all
48
+ (grep) scopes (case-insensitive, literal substring over key + value),
49
+ rendered in the same Offline/Remote split. --json, --scope <s>.
50
+ show Inspect one lesson in full: its complete value, scope, key, updated
51
+ date, tags, and which store(s) it lives in (noting any divergence
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>.
43
59
  migrate Relocate a LoreKit-format local store into the current layout.
44
60
  Dry-run by default; pass --yes to apply. Idempotent.
45
61
  hook Hook engine for Claude Code / Cursor / Codex. Reads the host's
@@ -58,8 +74,8 @@ ${c.bold('Options')}
58
74
  -t, --token <token> LoreKit token (lk_rw_* to allow writes, lk_ro_* read-only)
59
75
  --mode <mode> Memory mode: off | local | remote (doctor override)
60
76
  --store <path> Local project-tier store directory (default: .lorekit)
61
- --json Machine-readable output (list)
62
- --scope <scope> Restrict to a single scope (list)
77
+ --json Machine-readable output (list / search / show / stats / diff)
78
+ --scope <scope> Restrict to a single scope (list / search / stats / diff)
63
79
  --from <path> Source store to migrate from (migrate)
64
80
  --to <tier> Migration destination tier: home | project (migrate;
65
81
  default routes each entry by scope)
@@ -187,6 +203,98 @@ ${c.bold('Examples')}
187
203
  npx @lorekit/cli list
188
204
  npx @lorekit/cli list --json
189
205
  npx @lorekit/cli list --scope global
206
+ `,
207
+ search: `${c.bold('lorekit search')} — full-text search the applicable lessons ${c.dim('(alias: grep)')}
208
+
209
+ ${c.bold('Usage')}
210
+ npx @lorekit/cli search <query> [options]
211
+
212
+ Searches every lesson for the current directory's scopes (project/branch/repo/
213
+ global) across both stores, matching the query case-insensitively as a LITERAL
214
+ substring of a lesson's key or value (regex metacharacters are matched verbatim,
215
+ never interpreted). Results are shown in the same Offline / Remote split as
216
+ \`list\`; an unconfigured remote degrades to a short note, never an error.
217
+
218
+ ${c.bold('Options')}
219
+ -d, --dir <path> Target project root (default: current directory)
220
+ --scope <scope> Restrict to a single scope (default: all applicable)
221
+ --json Machine-readable output
222
+ -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
223
+ -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
224
+ --store <path> Local project-tier store directory (default: .lorekit)
225
+
226
+ ${c.bold('Examples')}
227
+ npx @lorekit/cli search sandbox
228
+ npx @lorekit/cli grep "flaky test" --json
229
+ npx @lorekit/cli search migration --scope global
230
+ `,
231
+ show: `${c.bold('lorekit show')} — inspect one lesson in full
232
+
233
+ ${c.bold('Usage')}
234
+ npx @lorekit/cli show <scope> <key> [options]
235
+
236
+ Prints one lesson's complete (untruncated) value, scope, key, updated date, tags,
237
+ and which store(s) it lives in. If the same scope::key exists in both the offline
238
+ and remote stores, both are shown and any divergence in their values is flagged.
239
+ Exits non-zero when the key is found in neither readable store.
240
+
241
+ ${c.bold('Options')}
242
+ -d, --dir <path> Target project root (default: current directory)
243
+ --json Machine-readable output (the full normalized record(s))
244
+ -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
245
+ -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
246
+ --store <path> Local project-tier store directory (default: .lorekit)
247
+
248
+ ${c.bold('Examples')}
249
+ npx @lorekit/cli show global prefer-guard-clauses
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
190
298
  `,
191
299
  migrate: `${c.bold('lorekit migrate')} — relocate a LoreKit-format local store into the current layout
192
300
 
@@ -246,11 +354,13 @@ const KNOWN_FLAGS = [
246
354
  // Commands that write to disk / talk to the network on a human's behalf. These
247
355
  // reject unknown flags; the machine-facing `hook` / `mcp` do not (they must
248
356
  // never fail on a stray flag, and only ever receive flags we control).
249
- const HUMAN_COMMANDS = new Set(['install', 'uninstall', 'doctor', 'list', 'migrate']);
357
+ const HUMAN_COMMANDS = new Set([
358
+ 'install', 'uninstall', 'doctor', 'list', 'search', 'show', 'stats', 'diff', 'migrate',
359
+ ]);
250
360
 
251
361
  // Command aliases — canonicalized before help / dispatch so `lorekit ls --help`
252
362
  // and telemetry both resolve to the real command name.
253
- const COMMAND_ALIASES = { ls: 'list' };
363
+ const COMMAND_ALIASES = { ls: 'list', grep: 'search' };
254
364
 
255
365
  async function main() {
256
366
  // Load a `.env` from the current directory (if any) before anything reads the
@@ -321,6 +431,14 @@ async function main() {
321
431
  return traceCommand('doctor', args, VERSION, () => doctor(args));
322
432
  case 'list':
323
433
  return traceCommand('list', args, VERSION, () => list(args));
434
+ case 'search':
435
+ return traceCommand('search', args, VERSION, () => search(args));
436
+ case 'show':
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));
324
442
  case 'migrate':
325
443
  return traceCommand('migrate', args, VERSION, () => migrate(args));
326
444
  default:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
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/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
+ }
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 { loadControl } from './control.mjs';
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 control = loadControl(root, { env });
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 ADDED
@@ -0,0 +1,129 @@
1
+ // `lorekit search <query>` — full-text search the lessons that apply to the
2
+ // current directory, across BOTH stores and every applicable scope, rendered in
3
+ // the same Offline / Remote split as `list`. A lesson matches when the query
4
+ // appears (case-insensitively, as a LITERAL substring) in its key or value.
5
+ //
6
+ // Why filter `gather()` rather than call each store's `search()`: the two stores
7
+ // disagree on search semantics — the local store does a literal substring match,
8
+ // the remote store delegates to server-side FTS (ranking, stemming). To give one
9
+ // deterministic, spec-defined behaviour across both — and to guarantee a query
10
+ // full of regex metacharacters is matched verbatim — the match runs here, over
11
+ // the exact same `gather()` seam `list` uses, via the pure `matchesQuery` filter.
12
+ //
13
+ // Graceful by design (mirrors `list`): an unconfigured remote is a short note,
14
+ // never an error; a per-scope read failure is a warning, not a crash. Read-only,
15
+ // archived hidden. Human-facing, so the bin wraps it in `traceCommand`.
16
+ import process from 'node:process';
17
+ import { resolveProjectRoot } from './config.mjs';
18
+ import { deriveScope } from './scope.mjs';
19
+ import { resolveDenies } from './control.mjs';
20
+ import { resolveStores, remoteUnavailableReason } from './stores.mjs';
21
+ import { scopeList, gather, filterGroups, renderSection } from './lessons-view.mjs';
22
+ import { log, err, heading, c } from './util.mjs';
23
+
24
+ export async function search(args) {
25
+ const root = resolveProjectRoot(args.dir);
26
+ const env = { ...process.env };
27
+ if (args.store) env.LOREKIT_STORE = args.store;
28
+
29
+ // The query is the first positional after the command name. An empty/missing
30
+ // query is a usage error (non-zero exit) — searching for nothing is meaningless
31
+ // and would otherwise degrade to `list`.
32
+ const query = typeof args._[1] === 'string' ? args._[1] : '';
33
+ if (!query.trim()) {
34
+ err(`${c.red('Usage:')} lorekit search <query> [--scope <s>] [--json]`);
35
+ err(`Provide a search term. Run ${c.cyan('lorekit search --help')} for options.`);
36
+ return 1;
37
+ }
38
+
39
+ const scopeInfo = deriveScope(root);
40
+ // Default to every applicable scope; `--scope <s>` narrows to one (an explicit
41
+ // scope outside the applicable set is honoured — the user asked for it).
42
+ const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo);
43
+
44
+ const { local, remote, connection } = resolveStores(root, {
45
+ env,
46
+ endpoint: args.endpoint,
47
+ token: args.token,
48
+ });
49
+
50
+ // Deny-wins section suppression, identical to `list`.
51
+ const { localDenied, remoteDenied } = resolveDenies(root, { env });
52
+
53
+ const offline = localDenied
54
+ ? { groups: [], total: 0 }
55
+ : filterGroups(await gather(local, scopes), query);
56
+ const remoteAvailable = !remoteDenied && remote.usable();
57
+ const remoteResult = remoteAvailable
58
+ ? filterGroups(await gather(remote, scopes), query)
59
+ : { groups: [], total: 0 };
60
+
61
+ const offlineSection = localDenied
62
+ ? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
63
+ : { available: true, ...offline };
64
+ const remoteSection = remoteAvailable
65
+ ? { available: true, ...remoteResult }
66
+ : {
67
+ available: false,
68
+ reason: remoteDenied
69
+ ? `disabled by deny constraint (${remoteDenied.source})`
70
+ : remoteUnavailableReason(connection),
71
+ };
72
+
73
+ if (args.json) {
74
+ log(JSON.stringify(buildJson({ root, query, scopes, offlineSection, remoteSection }), null, 2));
75
+ } else {
76
+ heading('LoreKit search');
77
+ log(` query: ${c.dim(query)}`);
78
+ log(` scopes: ${scopes.join(' → ')}`);
79
+
80
+ const empty = 'no lessons match';
81
+ renderSection({ title: 'Offline', empty }, offlineSection);
82
+ renderSection(
83
+ { title: 'Remote', subtitle: remoteAvailable ? connection.endpoint : undefined, empty },
84
+ remoteSection,
85
+ );
86
+
87
+ log('');
88
+ }
89
+
90
+ // Bounded, non-PII telemetry extras — counts + a boolean, never the query, a
91
+ // scope string, key, path, or token.
92
+ return {
93
+ exitCode: 0,
94
+ 'lorekit.cli.search.scope_count': scopes.length,
95
+ 'lorekit.cli.search.offline_matches': offline.total,
96
+ 'lorekit.cli.search.remote_matches': remoteResult.total,
97
+ 'lorekit.cli.search.remote_available': remoteAvailable,
98
+ };
99
+ }
100
+
101
+ // The `--json` payload: `{ query, root, scopes, offline, remote }` — normalized
102
+ // per-scope groups per section so a script gets the same shape regardless of
103
+ // which store a match came from (the exact shape `list --json` emits, plus the
104
+ // query echoed back).
105
+ function buildJson({ root, query, scopes, offlineSection, remoteSection }) {
106
+ return {
107
+ query,
108
+ root,
109
+ scopes,
110
+ offline: sectionJson(offlineSection),
111
+ remote: sectionJson(remoteSection),
112
+ };
113
+ }
114
+
115
+ function sectionJson(section) {
116
+ if (!section.available) {
117
+ return { available: false, reason: section.reason, total: 0, groups: [] };
118
+ }
119
+ return {
120
+ available: true,
121
+ total: section.total,
122
+ groups: (section.groups || []).map((g) => ({
123
+ scope: g.scope,
124
+ error: g.error || null,
125
+ // `gather()` already normalized these entries; the filter preserved them.
126
+ entries: g.entries,
127
+ })),
128
+ };
129
+ }
package/src/show.mjs ADDED
@@ -0,0 +1,164 @@
1
+ // `lorekit show <scope> <key>` — inspect ONE lesson in full: its complete
2
+ // (untruncated) value, scope, key, updated timestamp, tags, and which store(s)
3
+ // it lives in. When the same scope::key exists in BOTH the offline and remote
4
+ // stores — possibly with different values — both are shown and the divergence is
5
+ // flagged.
6
+ //
7
+ // Uses each store's real `read({scope, key})` method (both stores expose it),
8
+ // not a filtered `list` — a single-record lookup is what `read` is for, and it
9
+ // already hides archived entries. Graceful by design (mirrors `list`/`search`):
10
+ // an unconfigured remote is a short note, never an error. Read-only. Human-facing,
11
+ // so the bin wraps it in `traceCommand`.
12
+ import process from 'node:process';
13
+ import { resolveProjectRoot } from './config.mjs';
14
+ import { resolveDenies } from './control.mjs';
15
+ import { resolveStores, remoteUnavailableReason } from './stores.mjs';
16
+ import { normalizeEntry, shortDate, describeError, recordsDiverge } from './lessons-view.mjs';
17
+ import { log, err, heading, status, c } from './util.mjs';
18
+
19
+ // Read one scope::key from a store, normalizing the result into a small,
20
+ // uniform shape: { available:true, found, record } on success, or
21
+ // { available:true, found:false, error } when the read itself failed (network /
22
+ // server). A per-store read failure is captured, never thrown, so one bad store
23
+ // never aborts the other section.
24
+ async function readOne(store, scope, key) {
25
+ let res;
26
+ try {
27
+ res = await store.read({ scope, key });
28
+ } catch (e) {
29
+ return { available: true, found: false, record: null, error: (e && e.message) || 'error' };
30
+ }
31
+ if (!res || res.ok === false) {
32
+ return { available: true, found: false, record: null, error: describeError(res) };
33
+ }
34
+ const record = res.entry ? normalizeEntry(res.entry) : null;
35
+ return { available: true, found: Boolean(record), record, error: null };
36
+ }
37
+
38
+ export async function show(args) {
39
+ const root = resolveProjectRoot(args.dir);
40
+ const env = { ...process.env };
41
+ if (args.store) env.LOREKIT_STORE = args.store;
42
+
43
+ // Both positionals are required: `show <scope> <key>`.
44
+ const scope = typeof args._[1] === 'string' ? args._[1] : '';
45
+ const key = typeof args._[2] === 'string' ? args._[2] : '';
46
+ if (!scope || !key) {
47
+ err(`${c.red('Usage:')} lorekit show <scope> <key> [--json]`);
48
+ err(`Both a scope and a key are required. Run ${c.cyan('lorekit show --help')} for options.`);
49
+ return 1;
50
+ }
51
+
52
+ const { local, remote, connection } = resolveStores(root, {
53
+ env,
54
+ endpoint: args.endpoint,
55
+ token: args.token,
56
+ });
57
+
58
+ // Deny-wins section suppression, identical to `list`/`search`.
59
+ const { localDenied, remoteDenied } = resolveDenies(root, { env });
60
+
61
+ const offline = localDenied
62
+ ? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
63
+ : await readOne(local, scope, key);
64
+
65
+ const remoteAvailable = !remoteDenied && remote.usable();
66
+ const remote_ = remoteDenied
67
+ ? { available: false, reason: `disabled by deny constraint (${remoteDenied.source})` }
68
+ : remoteAvailable
69
+ ? await readOne(remote, scope, key)
70
+ : { available: false, reason: remoteUnavailableReason(connection) };
71
+
72
+ const foundOffline = Boolean(offline.available && offline.found);
73
+ const foundRemote = Boolean(remote_.available && remote_.found);
74
+ const diverged = foundOffline && foundRemote && recordsDiverge(offline.record, remote_.record);
75
+ // "Not found" is only definitive across the stores we could actually consult.
76
+ const found = foundOffline || foundRemote;
77
+
78
+ if (args.json) {
79
+ log(JSON.stringify(buildJson({ scope, key, offline, remote_, diverged }), null, 2));
80
+ } else {
81
+ heading('LoreKit lesson');
82
+ log(` scope: ${c.dim(scope)}`);
83
+ log(` key: ${c.dim(key)}`);
84
+
85
+ renderRecordSection('Offline', offline);
86
+ renderRecordSection(
87
+ 'Remote',
88
+ remote_,
89
+ remoteAvailable ? connection.endpoint : undefined,
90
+ );
91
+
92
+ if (diverged) {
93
+ log('');
94
+ status('warn', 'divergence', 'the offline and remote values differ');
95
+ }
96
+ if (!found) {
97
+ log('');
98
+ log(` ${c.dim(`no lesson found for ${scope}::${key} in the readable store(s)`)}`);
99
+ }
100
+ log('');
101
+ }
102
+
103
+ // Bounded, non-PII telemetry — booleans only, never the scope string or key.
104
+ return {
105
+ exitCode: found ? 0 : 1,
106
+ 'lorekit.cli.show.found_offline': foundOffline,
107
+ 'lorekit.cli.show.found_remote': foundRemote,
108
+ 'lorekit.cli.show.diverged': diverged,
109
+ };
110
+ }
111
+
112
+ // Render one store's slot: an unavailable note, a "no such key here" line, or the
113
+ // full record (untruncated value).
114
+ function renderRecordSection(title, section, subtitle) {
115
+ heading(title);
116
+ if (subtitle) log(` ${c.dim(subtitle)}`);
117
+
118
+ if (!section.available) {
119
+ status('warn', 'unavailable', section.reason);
120
+ return;
121
+ }
122
+ if (section.error) {
123
+ status('warn', 'unreadable', section.error);
124
+ return;
125
+ }
126
+ if (!section.found) {
127
+ log(` ${c.dim('no such key in this store')}`);
128
+ return;
129
+ }
130
+
131
+ const e = section.record;
132
+ if (e.updated) log(` ${c.dim('updated')} ${shortDate(e.updated)}`);
133
+ if (e.tags && e.tags.length) log(` ${c.dim('tags')} ${e.tags.join(', ')}`);
134
+ log(` ${c.dim('value')}`);
135
+ // The full, untruncated body — indented, line by line, so multi-line lessons
136
+ // read as written.
137
+ for (const line of String(e.value ?? '').split('\n')) {
138
+ log(` ${line}`);
139
+ }
140
+ }
141
+
142
+ // The `--json` payload: the full normalized record(s) plus which store each came
143
+ // from and whether they diverge.
144
+ function buildJson({ scope, key, offline, remote_, diverged }) {
145
+ return {
146
+ scope,
147
+ key,
148
+ offline: recordJson(offline),
149
+ remote: recordJson(remote_),
150
+ diverged,
151
+ };
152
+ }
153
+
154
+ function recordJson(section) {
155
+ if (!section.available) {
156
+ return { available: false, reason: section.reason, found: false, record: null };
157
+ }
158
+ return {
159
+ available: true,
160
+ found: Boolean(section.found),
161
+ record: section.record || null,
162
+ ...(section.error ? { error: section.error } : {}),
163
+ };
164
+ }
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 / doctor / migrate), fired to Dash0 so the maintainers can see
8
- // which commands people actually run.
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 | doctor | 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