@lorekit/cli 1.13.0 → 1.14.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
@@ -194,6 +194,44 @@ behave as in `list`. Remote counts reflect what the hosted `memory.list` returns
194
194
  per scope (the server's default page size); there is no cap-usage `N / limit`
195
195
  figure because the MCP surface exposes no total-count or cap tool.
196
196
 
197
+ ### `lorekit scopes`
198
+
199
+ A **store-wide inventory** of every distinct scope that holds lessons, with a
200
+ lesson count per scope, in the same Offline / Remote split as the other read
201
+ commands:
202
+
203
+ ```bash
204
+ lorekit scopes # every scope in the store + a count each
205
+ lorekit scopes --scope repo:: # filter to scopes containing a substring
206
+ lorekit scopes --json # { offline, remote } with [{ scope, count }] rows
207
+ ```
208
+
209
+ Unlike `list` / `search` / `stats` / `diff` / `tree` — which are all **cwd-scoped**
210
+ (they only look at the scopes that resolve for the current directory:
211
+ project / branch / repo / global) — `scopes` enumerates **every** scope present in
212
+ the store, regardless of the current directory. That's the whole point: it lets
213
+ you see all the scopes you have lessons in, anywhere. Scopes are grouped by type
214
+ (global → project → repo → branch), then alphabetically; each store shows a
215
+ per-store total and scope count.
216
+
217
+ `--scope <s>` is a **substring filter** over the inventory (not a single-scope
218
+ selector — an inventory of one scope would be pointless).
219
+
220
+ **Offline enumeration is exact.** It walks the local two-tier store and reads
221
+ each lesson file's frontmatter `scope` string directly, rather than reverse-
222
+ mapping the on-disk directory layout (which is lossy for `project::{name}`,
223
+ stored by basename only) — so every scope is reconstructed verbatim. Lessons
224
+ present in both tiers are counted once (project shadows home, the same merge
225
+ `list` uses); archived lessons are excluded.
226
+
227
+ **Remote enumeration is not possible, and `scopes` says so honestly.** The
228
+ hosted MCP surface exposes no "list all scopes" tool — every read tool
229
+ (`memory.list` / `memory.search` / `memory.read`) *requires* a scope — so a
230
+ remote inventory can't be built. The Remote section is therefore always a short
231
+ note (never a faked listing), degrading gracefully at exit 0, the same way
232
+ `stats` omits a cap-usage figure. `--endpoint` / `--token` / `--store` behave as
233
+ in `list`.
234
+
197
235
  ### `lorekit diff`
198
236
 
199
237
  Compare the **offline** and **remote** stores for the applicable scopes and
@@ -453,8 +491,8 @@ active deny constraints.
453
491
  | `--no-hooks` | Skip wiring the lifecycle hooks; skill + MCP only (`install`) |
454
492
  | `--force` | Overwrite existing skill files (`install`) |
455
493
  | `--deep` | Write/read/delete round-trip (`doctor`) |
456
- | `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `diff` / `tree` / `lint` / `dedupe`) |
457
- | `--scope <scope>` | Restrict to a single scope (`list` / `search` / `stats` / `diff` / `tree` / `lint` / `dedupe`; default: all applicable) |
494
+ | `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe`) |
495
+ | `--scope <scope>` | Restrict to a single scope (`list` / `search` / `stats` / `diff` / `tree` / `lint` / `dedupe`; default: all applicable). For `scopes` it is a **substring filter** over the inventory |
458
496
  | `--threshold <0..1>` | Duplicate-similarity cutoff (`dedupe`; default `0.8`) |
459
497
  | `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
460
498
  | `--event <name>` | Host hook event for `hook` (else read from the stdin payload) |
@@ -548,7 +586,7 @@ forever. This reduces manual validation to a single capture pass per tool.
548
586
  ## Usage telemetry
549
587
 
550
588
  The human-facing commands (`install`, `uninstall`, `doctor`, `list`, `search`,
551
- `show`, `stats`, `diff`, `migrate`)
589
+ `show`, `stats`, `scopes`, `diff`, `migrate`)
552
590
  emit one OpenTelemetry span + one counter point per run so the maintainers can see which
553
591
  commands people use. It is zero-dependency (OTLP/JSON over `fetch`, no SDK) and
554
592
  deliberately narrow — it carries only the command name, a bounded set of boolean
package/bin/lorekit.mjs CHANGED
@@ -10,6 +10,7 @@ import { list } from '../src/list.mjs';
10
10
  import { search } from '../src/search.mjs';
11
11
  import { show } from '../src/show.mjs';
12
12
  import { stats } from '../src/stats.mjs';
13
+ import { scopes } from '../src/scopes.mjs';
13
14
  import { diff } from '../src/diff.mjs';
14
15
  import { tree } from '../src/tree.mjs';
15
16
  import { lint } from '../src/lint.mjs';
@@ -56,10 +57,14 @@ ${c.bold('Commands')}
56
57
  stats Count the applicable lessons per scope and per store (offline vs
57
58
  remote), with per-store and grand totals, in the same Offline/
58
59
  Remote split. --json, --scope <s>.
60
+ scopes Store-wide inventory of EVERY distinct scope that holds lessons,
61
+ with a lesson count per scope — not cwd-scoped like the commands
62
+ above (it lists scopes anywhere in the store). Offline is exact;
63
+ the remote can't enumerate scopes (honest note). --json, --scope <s>.
59
64
  diff Compare the offline and remote stores for the applicable scopes and
60
65
  report divergence: local-only, remote-only, and conflicting keys
61
66
  (grouped by scope). Needs both stores readable. --json, --scope <s>.
62
- tree Show the injected scopes (branch → repo → global) as a precedence
67
+ tree Show the injected scopes (project → branch → repo → global) as a precedence
63
68
  (resolve) hierarchy and mark, per key, which scope's lesson WINS and which are
64
69
  shadowed — the real hook-resolution order. --json, --scope <s>.
65
70
  lint Flag low-quality lessons (empty/short/untrimmed value, empty key,
@@ -86,8 +91,8 @@ ${c.bold('Options')}
86
91
  -t, --token <token> LoreKit token (lk_rw_* to allow writes, lk_ro_* read-only)
87
92
  --mode <mode> Memory mode: off | local | remote (doctor override)
88
93
  --store <path> Local project-tier store directory (default: .lorekit)
89
- --json Machine-readable output (list / search / show / stats / diff / tree / lint / dedupe)
90
- --scope <scope> Restrict to a single scope (list / search / stats / diff / tree / lint / dedupe)
94
+ --json Machine-readable output (list / search / show / stats / scopes / diff / tree / lint / dedupe)
95
+ --scope <scope> Restrict to a single scope; a substring filter for scopes (list / search / stats / scopes / diff / tree / lint / dedupe)
91
96
  --threshold <0..1> Duplicate-similarity cutoff (dedupe; default 0.8)
92
97
  --from <path> Source store to migrate from (migrate)
93
98
  --to <tier> Migration destination tier: home | project (migrate;
@@ -285,6 +290,35 @@ ${c.bold('Examples')}
285
290
  npx @lorekit/cli stats
286
291
  npx @lorekit/cli stats --json
287
292
  npx @lorekit/cli stats --scope global
293
+ `,
294
+ scopes: `${c.bold('lorekit scopes')} — store-wide inventory of every distinct scope
295
+
296
+ ${c.bold('Usage')}
297
+ npx @lorekit/cli scopes [options]
298
+
299
+ Lists EVERY distinct scope present in the store, with a lesson count per scope,
300
+ in the same Offline / Remote split as the other read commands. Unlike \`list\` /
301
+ \`stats\` (which only see the scopes that resolve for the current directory), this
302
+ is a full inventory — it surfaces scopes anywhere in the store, regardless of the
303
+ current directory.
304
+
305
+ Offline counts are exact: each scope is read from the lesson files' frontmatter,
306
+ not reverse-mapped from the directory layout. The Remote section is always a
307
+ short note: the hosted MCP surface has no "list all scopes" tool (every read tool
308
+ requires a scope), so a remote inventory isn't possible — never an error (exit 0).
309
+
310
+ ${c.bold('Options')}
311
+ -d, --dir <path> Target project root (default: current directory)
312
+ --scope <substr> Filter the inventory to scopes containing this substring
313
+ --json Machine-readable output
314
+ -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
315
+ -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
316
+ --store <path> Local project-tier store directory (default: .lorekit)
317
+
318
+ ${c.bold('Examples')}
319
+ npx @lorekit/cli scopes
320
+ npx @lorekit/cli scopes --json
321
+ npx @lorekit/cli scopes --scope repo::
288
322
  `,
289
323
  diff: `${c.bold('lorekit diff')} — compare the offline and remote stores
290
324
 
@@ -315,13 +349,12 @@ ${c.bold('Examples')}
315
349
  ${c.bold('Usage')}
316
350
  npx @lorekit/cli tree [options]
317
351
 
318
- Shows the scopes the hooks actually inject for the current directory — branch,
319
- repo, global, in precedence order (most-specific first) — and marks, for any key
352
+ Shows the scopes the hooks actually inject for the current directory — project,
353
+ branch, repo, global, in precedence order (most-specific first) — and marks, for any key
320
354
  present at more than one scope, which scope's lesson WINS and which are shadowed.
321
355
  This mirrors the SessionStart hook's resolution exactly (a more-specific scope
322
- overrides a broader scope's same-key lesson). Project-scope lessons are NOT
323
- injected by the hooks, so they are not shown here — browse them with \`lorekit list\`.
324
- Resolved independently per store, in the same Offline / Remote split.
356
+ overrides a broader scope's same-key lesson). Resolved independently per store,
357
+ in the same Offline / Remote split.
325
358
 
326
359
  ${c.bold('Options')}
327
360
  -d, --dir <path> Target project root (default: current directory)
@@ -443,8 +476,8 @@ const KNOWN_FLAGS = [
443
476
  // reject unknown flags; the machine-facing `hook` / `mcp` do not (they must
444
477
  // never fail on a stray flag, and only ever receive flags we control).
445
478
  const HUMAN_COMMANDS = new Set([
446
- 'install', 'uninstall', 'doctor', 'list', 'search', 'show', 'stats', 'diff',
447
- 'tree', 'lint', 'dedupe', 'migrate',
479
+ 'install', 'uninstall', 'doctor', 'list', 'search', 'show', 'stats', 'scopes',
480
+ 'diff', 'tree', 'lint', 'dedupe', 'migrate',
448
481
  ]);
449
482
 
450
483
  // Command aliases — canonicalized before help / dispatch so `lorekit ls --help`
@@ -526,6 +559,8 @@ async function main() {
526
559
  return traceCommand('show', args, VERSION, () => show(args));
527
560
  case 'stats':
528
561
  return traceCommand('stats', args, VERSION, () => stats(args));
562
+ case 'scopes':
563
+ return traceCommand('scopes', args, VERSION, () => scopes(args));
529
564
  case 'diff':
530
565
  return traceCommand('diff', args, VERSION, () => diff(args));
531
566
  case 'tree':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.13.0",
3
+ "version": "1.14.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": {
@@ -112,6 +112,51 @@ export function tallyGroups({ groups = [], total } = {}) {
112
112
  return { perScope, total: typeof total === 'number' ? total : summed };
113
113
  }
114
114
 
115
+ // ── `scopes` — store-wide scope inventory ─────────────────────────────────────
116
+
117
+ // The canonical scope TYPE of a scope string — its `::`-leading segment when it
118
+ // is one of the four recognized types, else `other` (a malformed / legacy row).
119
+ // Pure. Shared by the `scopes` inventory ordering below.
120
+ export function scopeTypeOf(scope) {
121
+ const type = String(scope == null ? '' : scope).split('::')[0];
122
+ return ['global', 'project', 'repo', 'branch'].includes(type) ? type : 'other';
123
+ }
124
+
125
+ // A stable type ordering for the inventory: broadest → most-specific, with any
126
+ // unrecognized scope last. Chosen (over count-desc) so the listing groups
127
+ // related scopes together and is deterministic run-to-run.
128
+ const SCOPE_TYPE_RANK = { global: 0, project: 1, repo: 2, branch: 3, other: 4 };
129
+
130
+ // Sort a scope inventory (`[{ scope, count }]`) into a navigable order: primary
131
+ // by scope type (global → project → repo → branch → other), secondary
132
+ // alphabetical by the full scope string. Pure — returns a new array, never
133
+ // mutating its input.
134
+ export function sortScopeInventory(list = []) {
135
+ return [...list].sort((a, b) => {
136
+ const ra = SCOPE_TYPE_RANK[scopeTypeOf(a.scope)] ?? 4;
137
+ const rb = SCOPE_TYPE_RANK[scopeTypeOf(b.scope)] ?? 4;
138
+ if (ra !== rb) return ra - rb;
139
+ return String(a.scope).localeCompare(String(b.scope));
140
+ });
141
+ }
142
+
143
+ // Narrow a scope inventory to the scopes whose string CONTAINS `needle`
144
+ // (case-insensitive substring) — the `scopes --scope <s>` filter. An empty /
145
+ // absent needle passes everything through unchanged. Pure.
146
+ export function filterScopeInventory(list = [], needle) {
147
+ if (!needle) return list;
148
+ const q = String(needle).toLowerCase();
149
+ return list.filter((s) => String(s.scope).toLowerCase().includes(q));
150
+ }
151
+
152
+ // The pure core of the `scopes` command: sort an inventory and total its counts.
153
+ // Returns `{ scopes: [{ scope, count }] (sorted), total }`. Pure.
154
+ export function summarizeScopeInventory(list = []) {
155
+ const scopes = sortScopeInventory(list);
156
+ const total = scopes.reduce((n, s) => n + (Number(s.count) || 0), 0);
157
+ return { scopes, total };
158
+ }
159
+
115
160
  // Compare two `gather()` results (offline vs remote) and classify every scope's
116
161
  // keys into three sets — the pure core of the `diff` command:
117
162
  // • localOnly — key present offline, absent remote;
@@ -337,12 +337,31 @@ function withOverrides(args, env) {
337
337
  return out;
338
338
  }
339
339
 
340
+ // A one-line human-readable readiness banner for `lorekit mcp`. Pure so it can
341
+ // be unit-tested. Printed to STDERR (never stdout — that channel carries only
342
+ // JSON-RPC frames) and only when stdin is a TTY, so a real MCP client that
343
+ // pipes to us gets a pristine, banner-free stdout AND stderr.
344
+ export function startupBanner(mode) {
345
+ return (
346
+ `lorekit mcp — local stdio MCP server ready (mode: ${mode}). ` +
347
+ 'Speaking JSON-RPC 2.0 on stdin/stdout; this is machine-facing, so no ' +
348
+ 'further output is normal. Press Ctrl-C to stop.'
349
+ );
350
+ }
351
+
340
352
  // Entrypoint for `lorekit mcp`. Resolves the store once, then serves stdio
341
353
  // until the client closes stdin. Always resolves to exit code 0.
342
- export async function mcpServer(args = {}, { env = process.env, input = process.stdin, output = process.stdout } = {}) {
354
+ export async function mcpServer(
355
+ args = {},
356
+ { env = process.env, input = process.stdin, output = process.stdout, errorOutput = process.stderr } = {},
357
+ ) {
343
358
  const root = resolveProjectRoot(args.dir);
344
359
  const control = loadControl(root, { env: withOverrides(args, env) });
345
360
  const handle = createHandler(control);
361
+ // A human who runs `lorekit mcp` in a terminal would otherwise see a silent
362
+ // hang with no sign it is alive. Reassure them on stderr — but only when
363
+ // stdin is a TTY, so a piped MCP client never sees the banner on either channel.
364
+ if (input && input.isTTY) errorOutput.write(startupBanner(control.mode) + '\n');
346
365
  await runStdio(handle, input, output);
347
366
  return 0;
348
367
  }
package/src/scopes.mjs ADDED
@@ -0,0 +1,152 @@
1
+ // `lorekit scopes` — a STORE-WIDE inventory of every distinct scope that holds
2
+ // lessons, with a per-scope lesson count, in the same Offline / Remote split as
3
+ // the other read commands.
4
+ //
5
+ // The key difference from `list` / `search` / `stats` / `diff` / `tree`: those
6
+ // are all cwd-scoped — they only look at the scopes that resolve for the current
7
+ // directory (project / branch / repo / global). `scopes` ignores the current
8
+ // directory entirely and enumerates EVERY scope present in the store, so a user
9
+ // can see all the scopes they have lessons in, anywhere.
10
+ //
11
+ // Offline enumeration is AUTHORITATIVE: it walks the local two-tier store and
12
+ // reads each lesson file's frontmatter `scope` string directly — never reverse-
13
+ // mapping the on-disk directory layout, which is lossy for `project::{name}`
14
+ // (stored by basename only) — so every scope is reconstructed exactly.
15
+ //
16
+ // Remote enumeration is NOT possible: the hosted MCP surface exposes no "list
17
+ // all scopes" tool — every read tool (memory.list / search / read) REQUIRES a
18
+ // scope — so the Remote section is always an honest note (never faked), the same
19
+ // way `stats` omits a cap-usage figure. It still degrades gracefully at exit 0.
20
+ //
21
+ // Graceful, read-only, human-facing (the bin wraps it in `traceCommand`).
22
+ // `LOREKIT_DENY` suppresses a section; `--scope <s>` filters the inventory to
23
+ // scopes whose string contains that substring; `--json` emits `{ offline,
24
+ // remote }`.
25
+ import process from 'node:process';
26
+ import { resolveProjectRoot } from './config.mjs';
27
+ import { resolveDenies } from './control.mjs';
28
+ import { resolveStores, remoteUnavailableReason } from './stores.mjs';
29
+ import { summarizeScopeInventory, filterScopeInventory } from './lessons-view.mjs';
30
+ import { log, heading, status, c } from './util.mjs';
31
+
32
+ // The honest note the Remote section shows when it isn't denied/unconfigured:
33
+ // the hosted MCP surface simply can't enumerate scopes. Exported so tests can
34
+ // assert the exact wording rather than a fragile substring.
35
+ export const REMOTE_SCOPES_UNSUPPORTED =
36
+ 'remote scope enumeration is not supported by the hosted MCP surface (memory.list requires a scope)';
37
+
38
+ export async function scopes(args) {
39
+ const root = resolveProjectRoot(args.dir);
40
+ const env = { ...process.env };
41
+ if (args.store) env.LOREKIT_STORE = args.store;
42
+
43
+ // `--scope <s>` is a substring filter over the inventory (NOT a single-scope
44
+ // selector like the cwd-scoped commands — an inventory of one scope would be
45
+ // pointless). Absent → the full inventory.
46
+ const filter = args.scope && typeof args.scope === 'string' ? args.scope : null;
47
+
48
+ const { local, remote, connection } = resolveStores(root, {
49
+ env,
50
+ endpoint: args.endpoint,
51
+ token: args.token,
52
+ });
53
+
54
+ // Deny-wins section suppression, identical to `list` / `stats` / `diff`.
55
+ const { localDenied, remoteDenied } = resolveDenies(root, { env });
56
+
57
+ // Offline: the authoritative store-wide enumeration.
58
+ let offlineSection;
59
+ if (localDenied) {
60
+ offlineSection = { available: false, reason: `disabled by deny constraint (${localDenied.source})` };
61
+ } else {
62
+ const inventory = filterScopeInventory(await local.listScopes(), filter);
63
+ offlineSection = { available: true, ...summarizeScopeInventory(inventory) };
64
+ }
65
+
66
+ // Remote: never enumerable. A deny note, an unconfigured note, or the honest
67
+ // "not supported" note — all graceful (exit 0). Order matches the other
68
+ // commands' precedence: deny first, then connectivity, then the capability.
69
+ let remoteSection;
70
+ if (remoteDenied) {
71
+ remoteSection = { available: false, reason: `disabled by deny constraint (${remoteDenied.source})` };
72
+ } else if (!remote.usable()) {
73
+ remoteSection = { available: false, reason: remoteUnavailableReason(connection) };
74
+ } else {
75
+ remoteSection = { available: false, reason: REMOTE_SCOPES_UNSUPPORTED };
76
+ }
77
+
78
+ if (args.json) {
79
+ log(JSON.stringify(buildJson({ root, filter, offlineSection, remoteSection }), null, 2));
80
+ } else {
81
+ heading('LoreKit scopes');
82
+ log(` store: ${c.dim(root)}`);
83
+ if (filter) log(` filter: ${c.dim(filter)}`);
84
+ renderScopesSection({ title: 'Offline' }, offlineSection);
85
+ renderScopesSection(
86
+ { title: 'Remote', subtitle: remote.usable() ? connection.endpoint : undefined },
87
+ remoteSection,
88
+ );
89
+ log('');
90
+ }
91
+
92
+ // Bounded, non-PII telemetry extras (counts + a boolean) — never a scope
93
+ // string, path, or token. `remote_available` is always false: the surface
94
+ // can't enumerate, and saying so is the honest signal.
95
+ return {
96
+ exitCode: 0,
97
+ 'lorekit.cli.scopes.offline_scope_count': offlineSection.available ? offlineSection.scopes.length : 0,
98
+ 'lorekit.cli.scopes.offline_total': offlineSection.available ? offlineSection.total : 0,
99
+ 'lorekit.cli.scopes.filtered': Boolean(filter),
100
+ 'lorekit.cli.scopes.remote_available': false,
101
+ };
102
+ }
103
+
104
+ // Render one store's inventory: an unavailable note, or a right-aligned
105
+ // `scope count` table plus a `total` line noting the scope count.
106
+ function renderScopesSection(header, section) {
107
+ heading(header.title);
108
+ if (header.subtitle) log(` ${c.dim(header.subtitle)}`);
109
+
110
+ if (!section.available) {
111
+ status('warn', 'unavailable', section.reason);
112
+ return;
113
+ }
114
+
115
+ if (!section.scopes.length) {
116
+ log(` ${c.dim('no scopes found — the store holds no lessons')}`);
117
+ return;
118
+ }
119
+
120
+ const width = Math.max(0, ...section.scopes.map((s) => String(s.scope).length));
121
+ for (const s of section.scopes) {
122
+ log(` ${c.bold(String(s.scope).padEnd(width))} ${s.count}`);
123
+ }
124
+ const n = section.scopes.length;
125
+ log(
126
+ ` ${c.dim('total'.padEnd(width))} ${c.bold(String(section.total))} ` +
127
+ `${c.dim(`(${n} scope${n === 1 ? '' : 's'})`)}`,
128
+ );
129
+ }
130
+
131
+ // The `--json` payload: `{ root, filter, offline, remote }` — each store a
132
+ // `{ available, total, scopes: [{ scope, count }] }` record (or an unavailable
133
+ // note), so a script gets the same shape regardless of which store answered.
134
+ function buildJson({ root, filter, offlineSection, remoteSection }) {
135
+ return {
136
+ root,
137
+ filter: filter || null,
138
+ offline: sectionJson(offlineSection),
139
+ remote: sectionJson(remoteSection),
140
+ };
141
+ }
142
+
143
+ function sectionJson(section) {
144
+ if (!section.available) {
145
+ return { available: false, reason: section.reason, total: 0, scopes: [] };
146
+ }
147
+ return {
148
+ available: true,
149
+ total: section.total,
150
+ scopes: section.scopes.map((s) => ({ scope: s.scope, count: s.count })),
151
+ };
152
+ }
@@ -204,6 +204,53 @@ class LocalStore {
204
204
  for (const scope of scopes || []) n += (await this.list({ scope })).entries.length;
205
205
  return n;
206
206
  }
207
+
208
+ // Recursively collect every parsed entry under baseDir, across ALL scopes —
209
+ // the primitive `listScopes()` builds on. Best-effort: a missing base dir or
210
+ // an unreadable file is skipped, never thrown. Reads each file's frontmatter
211
+ // (the authoritative scope string) rather than the directory it sits in.
212
+ _walkEntries() {
213
+ const out = [];
214
+ const walk = (dir) => {
215
+ let dirents;
216
+ try {
217
+ dirents = fs.readdirSync(dir, { withFileTypes: true });
218
+ } catch {
219
+ return; // missing / unreadable directory — nothing to enumerate here
220
+ }
221
+ for (const d of dirents) {
222
+ const full = path.join(dir, d.name);
223
+ if (d.isDirectory()) {
224
+ walk(full);
225
+ } else if (d.isFile() && d.name.endsWith('.md')) {
226
+ try {
227
+ const entry = parseEntry(fs.readFileSync(full, 'utf8'));
228
+ if (entry) out.push({ entry, file: full });
229
+ } catch {
230
+ // Skip an unreadable file rather than fail the whole enumeration.
231
+ }
232
+ }
233
+ }
234
+ };
235
+ walk(this.baseDir);
236
+ return out;
237
+ }
238
+
239
+ // Enumerate every distinct scope present on disk with its non-archived lesson
240
+ // count — a STORE-WIDE inventory, independent of any current directory (unlike
241
+ // list/count, which take an explicit scope set). The scope of each lesson is
242
+ // read from its frontmatter `scope` field, so the reconstructed scope string
243
+ // is EXACT — never reverse-mapped from the on-disk directory layout, which is
244
+ // lossy for `project::{name}` (stored by basename only). Returns
245
+ // `[{ scope, count }]`, unsorted.
246
+ async listScopes() {
247
+ const counts = new Map();
248
+ for (const { entry } of this._walkEntries()) {
249
+ if (entry.archived_at || !entry.scope) continue;
250
+ counts.set(entry.scope, (counts.get(entry.scope) || 0) + 1);
251
+ }
252
+ return [...counts.entries()].map(([scope, count]) => ({ scope, count }));
253
+ }
207
254
  }
208
255
 
209
256
  // A scope string is global when its type segment is `global`.
@@ -326,4 +373,23 @@ class TwoTierStore {
326
373
  for (const scope of scopes || []) n += (await this.list({ scope })).entries.length;
327
374
  return n;
328
375
  }
376
+
377
+ // Store-wide scope inventory across both tiers, de-duplicated by scope+key so
378
+ // a lesson present in both tiers is counted once — project shadows home, the
379
+ // same first-wins merge `list()` uses. Returns `[{ scope, count }]`, unsorted.
380
+ async listScopes() {
381
+ const seen = new Set(); // `${scope}\x00${key}` — dedup across tiers
382
+ const counts = new Map();
383
+ const tiers = this.projectActive() ? [this.project, this.home] : [this.home];
384
+ for (const tier of tiers) {
385
+ for (const { entry } of tier._walkEntries()) {
386
+ if (entry.archived_at || !entry.scope) continue;
387
+ const id = `${entry.scope}\x00${entry.key ?? ''}`;
388
+ if (seen.has(id)) continue;
389
+ seen.add(id);
390
+ counts.set(entry.scope, (counts.get(entry.scope) || 0) + 1);
391
+ }
392
+ }
393
+ return [...counts.entries()].map(([scope, count]) => ({ scope, count }));
394
+ }
329
395
  }
@@ -112,6 +112,14 @@ class RemoteStore {
112
112
  return { ok: true, ...payload };
113
113
  }
114
114
 
115
+ // Store-wide scope enumeration is NOT possible against the hosted MCP surface:
116
+ // every read tool (memory.list / memory.search / memory.read) REQUIRES a
117
+ // scope, and there is no "list all scopes" tool. Signal that honestly so the
118
+ // `scopes` command shows a clear note rather than faking an inventory.
119
+ async listScopes() {
120
+ return { ok: false, unsupported: true };
121
+ }
122
+
115
123
  // Connectivity probe for doctor — a transport check, not a memory op.
116
124
  async ping() {
117
125
  if (!this.usable()) return { ok: false, unusable: true };
package/src/telemetry.mjs CHANGED
@@ -4,9 +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 / stats / diff / tree /
8
- // lint / dedupe / migrate), fired to Dash0 so the maintainers can see which
9
- // commands people actually run.
7
+ // (install / uninstall / doctor / list / search / show / stats / scopes / diff /
8
+ // tree / lint / dedupe / migrate), fired to Dash0 so the maintainers can see
9
+ // which commands people actually run.
10
10
  //
11
11
  // Privacy — this runs on end-users' machines, so it is deliberately narrow:
12
12
  // • Opt-out honored: LOREKIT_TELEMETRY=0|off|false|no|disable, or the
@@ -275,7 +275,7 @@ function normalizeExitCode(result) {
275
275
  * counter point. Returns the command's exit code unchanged. Telemetry failures
276
276
  * are swallowed — the command result is never affected.
277
277
  *
278
- * @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | diff | tree | lint | dedupe | migrate
278
+ * @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | scopes | diff | tree | lint | dedupe | migrate
279
279
  * @param {object} args parsed CLI args (read for allow-listed flags only)
280
280
  * @param {string} version CLI version (from package.json)
281
281
  * @param {() => Promise<number>} run the command handler