@lorekit/cli 1.24.1 → 1.25.1

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
@@ -308,6 +308,47 @@ coincidental overlaps. Any pair scoring at or above `--threshold` links (transit
308
308
  into one cluster; only clusters of 2+ members are reported, each with a similarity
309
309
  range. Cross-**store** divergence is `diff`'s job; `dedupe` looks within a store.
310
310
 
311
+ ### `lorekit link` (alias `url`)
312
+
313
+ Print a shareable **dashboard deep-link URL** to stdout — nothing else, so it
314
+ pipes straight into your clipboard or a PR/Slack message:
315
+
316
+ ```bash
317
+ lorekit link # link to the current repo/branch context
318
+ lorekit link | pbcopy # copy it straight to the clipboard
319
+ lorekit link global # the Explorer filtered to global scope
320
+ lorekit link repo::owner/repo prefer-guards # open one lesson's detail sheet
321
+ lorekit link global::prefer-guards --json # { url, surface, base, params }
322
+ lorekit url --q "flaky test" --owner personal # search + ownership filter
323
+ ```
324
+
325
+ With no arguments it links to the cwd's **most-specific scope** ("share what I'm
326
+ looking at"). A single argument that is a valid scope — including a `repo::…` or
327
+ `branch::…::…` scope — links to the Explorer filtered to that scope; a scope
328
+ **and** key (two positionals, or the `scope::key` shorthand) links straight to
329
+ that lesson's detail sheet. It sets **both** the `lesson` param (which opens the
330
+ sheet) and `scope` — not because scope is needed to find the lesson (the sidebar
331
+ reads one unfiltered recent set), but so the Explorer list *behind* the sheet is
332
+ filtered to the lesson's own scope. Filter flags mirror the Explorer: `--q`
333
+ (search), `--owner <all|personal|orgId>`, `--range`/`--from`/`--to`, `--archived`,
334
+ `--view <scope|time>`.
335
+
336
+ Every param is `encodeURIComponent(JSON.stringify(value))` — the exact inverse of
337
+ how the dashboard's `useUrlState` reads it back (`JSON.parse`, falling back to the
338
+ default on failure). A raw `?scope=global` would silently mean "all scopes", so
339
+ the link **must** be JSON-encoded to open the intended view. `--base <url>` (or
340
+ `LOREKIT_APP_URL`) overrides the dashboard host for self-hosted setups; the
341
+ default is `https://lorekit.io`. Read-only and network-free — it derives scopes
342
+ from git and builds a URL, never touching a store.
343
+
344
+ The **read commands take a `--link` flag** that short-circuits to print the
345
+ equivalent deep link, reusing the same builder: `show <scope::key> --link` → the
346
+ lesson link, `search foo --link` → `/lore?q="foo"` (+ scope), and `list --link` /
347
+ `tree --link` → the Explorer filtered to the **most-specific applicable scope**
348
+ (or `--scope` when given) — the dashboard filters one scope at a time, so the
349
+ multi-scope `list`/`tree` view maps to its primary scope. (The same JSON-encoded
350
+ links now back the hooks' write-confirmation and retrospective nudges.)
351
+
311
352
  ### `lorekit hook`
312
353
 
313
354
  The **shared hook engine** behind the Claude Code / Cursor / Codex plugins.
@@ -541,8 +582,10 @@ active deny constraints.
541
582
  | `--no-hooks` | Skip wiring the lifecycle hooks; skills + MCP only (`install`) |
542
583
  | `--force` | Overwrite existing skill files (`install`) |
543
584
  | `--deep` | Write/read/delete round-trip (`doctor`) |
544
- | `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe`) |
545
- | `--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 |
585
+ | `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe` / `link`) |
586
+ | `--scope <scope>` | Restrict to a single scope (`list` / `search` / `stats` / `diff` / `tree` / `lint` / `dedupe` / `link`; default: all applicable). For `scopes` it is a **substring filter** over the inventory |
587
+ | `--link` | Print the equivalent dashboard deep-link URL instead of running (`show` / `search` / `list` / `tree`) |
588
+ | `--base <url>` | Dashboard base URL for deep links (`link` / `--link`; else `LOREKIT_APP_URL`, default `https://lorekit.io`) |
546
589
  | `--threshold <0..1>` | Duplicate-similarity cutoff (`dedupe`; default `0.8`) |
547
590
  | `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
548
591
  | `--event <name>` | Host hook event for `hook` (else read from the stdin payload) |
package/bin/lorekit.mjs CHANGED
@@ -16,6 +16,7 @@ import { diff } from '../src/diff.mjs';
16
16
  import { tree } from '../src/tree.mjs';
17
17
  import { lint } from '../src/lint.mjs';
18
18
  import { dedupe } from '../src/dedupe.mjs';
19
+ import { link } from '../src/link.mjs';
19
20
  import { hook } from '../src/hook.mjs';
20
21
  import { migrate } from '../src/migrate.mjs';
21
22
  import { bootstrap } from '../src/bootstrap.mjs';
@@ -81,6 +82,12 @@ ${c.bold('Commands')}
81
82
  dedupe Find likely-duplicate memories via a zero-dep word-overlap HEURISTIC
82
83
  (Jaccard >= threshold, not semantic), grouped into clusters per
83
84
  store. --json, --scope <s>, --threshold <0..1>.
85
+ link (url) Print a shareable dashboard deep-link URL for the current context,
86
+ a scope, or a specific lesson (opens its detail sheet). No args
87
+ links to the cwd's most-specific scope. Filter flags mirror the
88
+ Explorer (--q / --owner / --range / --archived / --view); --base or
89
+ LOREKIT_APP_URL override the dashboard host. --json. Pipe it:
90
+ lorekit link | pbcopy.
84
91
  bootstrap Apply the BYOD schema to a user-supplied Supabase database.
85
92
  Only needed when using LOREKIT_STORAGE_URL / LOREKIT_STORAGE_ANON_KEY.
86
93
  See docs/byod.md for setup instructions.
@@ -102,8 +109,10 @@ ${c.bold('Options')}
102
109
  -t, --token <token> LoreKit token (lk_rw_* to allow writes, lk_ro_* read-only)
103
110
  --mode <mode> Memory mode: off | local | remote (doctor override)
104
111
  --store <path> Local project-tier store directory (default: .lorekit)
105
- --json Machine-readable output (list / search / show / stats / scopes / diff / tree / lint / dedupe)
106
- --scope <scope> Restrict to a single scope; a substring filter for scopes (list / search / stats / scopes / diff / tree / lint / dedupe)
112
+ --json Machine-readable output (list / search / show / stats / scopes / diff / tree / lint / dedupe / link)
113
+ --scope <scope> Restrict to a single scope; a substring filter for scopes (list / search / stats / scopes / diff / tree / lint / dedupe / link)
114
+ --link Print the equivalent dashboard deep-link URL instead of running (show / search / list / tree)
115
+ --base <url> Dashboard base URL for deep links (link / --link; else LOREKIT_APP_URL, default https://lorekit.io)
107
116
  --threshold <0..1> Duplicate-similarity cutoff (dedupe; default 0.8)
108
117
  --from <path> Source store to migrate from (migrate)
109
118
  --to <tier> Migration destination tier: home | project (migrate;
@@ -228,11 +237,13 @@ ${c.bold('Options')}
228
237
  -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
229
238
  -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
230
239
  --store <path> Local project-tier store directory (default: .lorekit)
240
+ --link Print the Explorer deep-link for the most-specific scope (or --scope) instead of running (with --base / --json)
231
241
 
232
242
  ${c.bold('Examples')}
233
243
  npx @lorekit/cli list
234
244
  npx @lorekit/cli list --json
235
245
  npx @lorekit/cli list --scope global
246
+ npx @lorekit/cli list --scope global --link
236
247
  `,
237
248
  search: `${c.bold('lorekit search')} — full-text search the applicable memories ${c.dim('(alias: grep)')}
238
249
 
@@ -252,11 +263,13 @@ ${c.bold('Options')}
252
263
  -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
253
264
  -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
254
265
  --store <path> Local project-tier store directory (default: .lorekit)
266
+ --link Print this view's dashboard deep-link URL instead of running (with --base / --json)
255
267
 
256
268
  ${c.bold('Examples')}
257
269
  npx @lorekit/cli search sandbox
258
270
  npx @lorekit/cli grep "flaky test" --json
259
271
  npx @lorekit/cli search migration --scope global
272
+ npx @lorekit/cli search "flaky test" --scope global --link
260
273
  `,
261
274
  write: `${c.bold('lorekit write')} — create or update a memory from the CLI
262
275
 
@@ -307,11 +320,13 @@ ${c.bold('Options')}
307
320
  -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
308
321
  -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
309
322
  --store <path> Local project-tier store directory (default: .lorekit)
323
+ --link Print this memory's dashboard deep-link URL instead of reading (with --base / --json)
310
324
 
311
325
  ${c.bold('Examples')}
312
326
  npx @lorekit/cli show global prefer-guard-clauses
313
327
  npx @lorekit/cli show global::prefer-guard-clauses
314
328
  npx @lorekit/cli show project::widget build-flags --json
329
+ npx @lorekit/cli show global prefer-guard-clauses --link
315
330
  `,
316
331
  stats: `${c.bold('lorekit stats')} — count the applicable memories per scope and per store
317
332
 
@@ -409,10 +424,12 @@ ${c.bold('Options')}
409
424
  -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
410
425
  -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
411
426
  --store <path> Local project-tier store directory (default: .lorekit)
427
+ --link Print the Explorer deep-link for the most-specific scope (or --scope) instead of running (with --base / --json)
412
428
 
413
429
  ${c.bold('Examples')}
414
430
  npx @lorekit/cli tree
415
431
  npx @lorekit/cli resolve --json
432
+ npx @lorekit/cli tree --scope global --link
416
433
  `,
417
434
  lint: `${c.bold('lorekit lint')} — flag low-quality memories across the applicable scopes
418
435
 
@@ -462,6 +479,42 @@ ${c.bold('Options')}
462
479
  ${c.bold('Examples')}
463
480
  npx @lorekit/cli dedupe
464
481
  npx @lorekit/cli dedupe --threshold 0.6 --json
482
+ `,
483
+ link: `${c.bold('lorekit link')} — print a shareable dashboard deep-link URL ${c.dim('(alias: url)')}
484
+
485
+ ${c.bold('Usage')}
486
+ npx @lorekit/cli link [scope] [key] [options]
487
+ npx @lorekit/cli link <scope::key> [options]
488
+
489
+ Prints a ${c.cyan('lorekit.io/lore')} deep link to stdout — nothing else — so it pipes
490
+ cleanly into your clipboard or a message. With no arguments it links to the
491
+ current directory's most-specific scope ("share what I'm looking at"). Given a
492
+ scope it links to the Explorer filtered to that scope; given a scope AND key (or
493
+ the ${c.cyan('scope::key')} shorthand) it links straight to that lesson's detail sheet.
494
+
495
+ Every param is JSON-encoded exactly as the dashboard reads it, so the link opens
496
+ the intended view — a raw ${c.dim('?scope=global')} would silently mean "all scopes".
497
+
498
+ ${c.bold('Options')}
499
+ -d, --dir <path> Target project root (default: current directory)
500
+ --scope <scope> Scope to link to (when no positional scope is given)
501
+ --q <text> Pre-fill the Explorer search box
502
+ --owner <o> Ownership filter: all | personal | <orgId>
503
+ --range <json> Date range as {"from":"YYYY-MM-DD","to":"YYYY-MM-DD"}
504
+ --from <date> Range start (shorthand for --range)
505
+ --to <date> Range end (shorthand for --range)
506
+ --archived Include archived memories
507
+ --view <mode> Explorer view: scope | time
508
+ --base <url> Dashboard base URL (else LOREKIT_APP_URL, default https://lorekit.io)
509
+ --json Machine-readable { url, surface, base, params }
510
+
511
+ ${c.bold('Examples')}
512
+ npx @lorekit/cli link # link to the current repo/branch context
513
+ npx @lorekit/cli link | pbcopy # copy it straight to the clipboard
514
+ npx @lorekit/cli link global # the Explorer filtered to global scope
515
+ npx @lorekit/cli link repo::owner/repo prefer-guards # open one lesson's detail sheet
516
+ npx @lorekit/cli link global::prefer-guards --json # { url, surface, base, params }
517
+ npx @lorekit/cli url --q "flaky test" --owner personal # search + ownership filter
465
518
  `,
466
519
  migrate: `${c.bold('lorekit migrate')} — relocate a LoreKit-format local store into the current layout
467
520
 
@@ -517,6 +570,7 @@ const KNOWN_FLAGS = [
517
570
  'from', 'to', 'apply', 'yes', 'no-hooks', 'force', 'deep', 'adapter',
518
571
  'event', 'json', 'scope', 'threshold', 'help', 'version',
519
572
  'value', 'tags', 'source-agent', 'trigger', 'ttl-days', 'org', 'remote', 'local',
573
+ 'link', 'base', 'q', 'owner', 'range', 'view', 'archived',
520
574
  ];
521
575
 
522
576
  // Commands that write to disk / talk to the network on a human's behalf. These
@@ -524,12 +578,12 @@ const KNOWN_FLAGS = [
524
578
  // never fail on a stray flag, and only ever receive flags we control).
525
579
  const HUMAN_COMMANDS = new Set([
526
580
  'install', 'uninstall', 'doctor', 'list', 'search', 'show', 'stats', 'scopes',
527
- 'diff', 'tree', 'lint', 'dedupe', 'migrate', 'write',
581
+ 'diff', 'tree', 'lint', 'dedupe', 'link', 'migrate', 'write',
528
582
  ]);
529
583
 
530
584
  // Command aliases — canonicalized before help / dispatch so `lorekit ls --help`
531
585
  // and telemetry both resolve to the real command name.
532
- const COMMAND_ALIASES = { ls: 'list', grep: 'search', resolve: 'tree' };
586
+ const COMMAND_ALIASES = { ls: 'list', grep: 'search', resolve: 'tree', url: 'link' };
533
587
 
534
588
  async function main() {
535
589
  // Load a `.env` from the current directory (if any) before anything reads the
@@ -541,7 +595,7 @@ async function main() {
541
595
  const argv = process.argv.slice(2);
542
596
  const args = parseArgs(argv, {
543
597
  aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
544
- booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'json', 'remote', 'local'],
598
+ booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'json', 'remote', 'local', 'link', 'archived'],
545
599
  known: KNOWN_FLAGS,
546
600
  });
547
601
 
@@ -616,6 +670,8 @@ async function main() {
616
670
  return traceCommand('lint', args, VERSION, () => lint(args));
617
671
  case 'dedupe':
618
672
  return traceCommand('dedupe', args, VERSION, () => dedupe(args));
673
+ case 'link':
674
+ return traceCommand('link', args, VERSION, () => link(args));
619
675
  case 'migrate':
620
676
  return traceCommand('migrate', args, VERSION, () => migrate(args));
621
677
  case 'bootstrap':
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.24.1",
3
+ "version": "1.25.1",
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": {
@@ -8,6 +8,11 @@ import { deriveScope } from '../scope.mjs';
8
8
  // use, so the hook can't drift from them, and the hot path never pulls in the
9
9
  // `lessons-view.mjs` render/`util` stack.
10
10
  import { resolvePrecedence, matchesQuery } from '../lessons-pure.mjs';
11
+ // The deep-link builder is the SAME pure module the `link` command and the
12
+ // `--link` flag use, so the hook's confirmation/nudge links are JSON-encoded
13
+ // correctly (a raw `?scope=global` silently means "all scopes") and can't drift
14
+ // from the command-line links.
15
+ import { loreScopeUrl, buildLessonUrl } from '../deeplink-pure.mjs';
11
16
 
12
17
  const MAX_LESSONS = 15;
13
18
  // Cap on lessons injected on a failure — a small, focused "you've seen this
@@ -176,10 +181,11 @@ function tagsHint(writeScope, { tagsDefault = [], scopeDefaults = null } = {}) {
176
181
 
177
182
  // The LoreKit web app URL for the Lore Explorer, pre-filtered to the given scope.
178
183
  // Exported so tests can assert the URL shape without re-deriving the encoding.
184
+ // Delegates to the shared `loreScopeUrl` so the scope param is JSON-encoded the
185
+ // way the dashboard reads it — the previous raw `?scope=${scope}` fell through
186
+ // `useUrlState`'s `JSON.parse` and silently filtered to ALL scopes.
179
187
  export function loreUrl(writeScope) {
180
- const base = 'https://lorekit.io/lore';
181
- if (!writeScope || writeScope === 'global') return base;
182
- return `${base}?scope=${encodeURIComponent(writeScope)}`;
188
+ return loreScopeUrl(writeScope);
183
189
  }
184
190
 
185
191
  // The retrospective nudge emitted at end-of-turn (one-shot per session).
@@ -200,16 +206,20 @@ export function retrospectiveNudge(scope, control) {
200
206
  }
201
207
 
202
208
  // Terse confirmation emitted via PostToolUse when a memory.write succeeded.
203
- // `key` is the lesson key from the tool response (may be null when the response
204
- // shape doesn't surface it). Includes a deep link to the scope's Lore Explorer
205
- // page so the user can verify immediately.
206
- export function writeConfirmation(scope, key) {
207
- const writeScope = scope.repoScope || 'global';
209
+ // `key` is the lesson key from the tool input (may be null when it isn't
210
+ // surfaced). `writtenScope` is the ACTUAL scope the write targeted (from the tool
211
+ // input) the link must point there, not at `repoScope`: a `global` (or project)
212
+ // write deep-linked to `repoScope` would open a lesson ref that doesn't exist.
213
+ // Falls back to the cwd's repo scope, then `global`, when the write scope is
214
+ // unknown. When the key is known the link opens that exact lesson's detail sheet
215
+ // (`?scope=…&lesson=…`); otherwise it filters the Explorer to the write scope —
216
+ // both JSON-encoded via the shared builder so they actually open the intended view.
217
+ export function writeConfirmation(scope, key, writtenScope) {
218
+ const target =
219
+ typeof writtenScope === 'string' && writtenScope ? writtenScope : scope.repoScope || 'global';
208
220
  const keyPart = key ? ` · ${key}` : '';
209
- const url = key
210
- ? `${loreUrl(writeScope)}&q=${encodeURIComponent(key)}`
211
- : loreUrl(writeScope);
212
- return `LoreKit: memory saved to ${writeScope}${keyPart}\nView: ${url}`;
221
+ const url = key ? buildLessonUrl(target, key) : loreScopeUrl(target);
222
+ return `LoreKit: memory saved to ${target}${keyPart}\nView: ${url}`;
213
223
  }
214
224
 
215
225
  // The nudge emitted when a tool failure is detected.
@@ -0,0 +1,194 @@
1
+ // Dependency-free deep-link builder for LoreKit dashboard URLs.
2
+ //
3
+ // Zero imports on purpose (the `lessons-pure.mjs` precedent): both the hook hot
4
+ // path (`core/lessons.mjs`) and the `link` command / `--link` flag share this
5
+ // without pulling in `util`/render/store code.
6
+ //
7
+ // THE governing constraint — every `/lore` Explorer param is read by the web
8
+ // app's `useUrlState` (`packages/web/src/lib/hooks/useUrlState.ts`) via
9
+ // `JSON.parse(searchParams.get(key))`, falling back to the param's DEFAULT on a
10
+ // parse failure and OMITTING any param whose value equals its default. So a URL
11
+ // value must be `encodeURIComponent(JSON.stringify(value))` — a JSON string,
12
+ // double-quoted for a string scope — NOT a raw token. A raw `?scope=global`
13
+ // fails `JSON.parse` and silently means "all scopes". This is the exact inverse
14
+ // of the app's read, and mirrors the production builder in
15
+ // `packages/web/src/components/dashboard/ScopeHealthCard.tsx`:
16
+ // `/lore?scope=${encodeURIComponent(JSON.stringify(health.scope))}`.
17
+
18
+ // The default dashboard base for the hosted deployment. Overridable per call
19
+ // (self-hosted setups) via `resolveAppBase` (`--base` flag / `LOREKIT_APP_URL`).
20
+ export const DEFAULT_APP_BASE = 'https://lorekit.io';
21
+
22
+ // The `/lore` Explorer param defaults, mirroring the `useUrlState` calls in
23
+ // `LoreExplorer.tsx` (+ the `lesson` param in `MemorySidebarProvider.tsx`)
24
+ // EXACTLY. A param whose value JSON-equals its default is omitted from the URL
25
+ // — the app would treat a present-but-default param as noise, and clean URLs
26
+ // match what the app itself produces.
27
+ export const LORE_PARAM_DEFAULTS = {
28
+ scope: null, // string | null — null means "all scopes"
29
+ q: '', // string search query
30
+ range: null, // { from, to } | null (DateRange, "YYYY-MM-DD")
31
+ owner: 'all', // 'all' | 'personal' | { orgId }
32
+ view: 'scope', // 'scope' | 'time'
33
+ archived: false, // boolean
34
+ lesson: null, // { scope, key } | null — opens the detail sheet
35
+ };
36
+
37
+ // A stable, readable param order (also makes URLs deterministic for tests).
38
+ // `scope` precedes `lesson` so a lesson link reads `?scope=…&lesson=…`.
39
+ const PARAM_ORDER = ['scope', 'q', 'range', 'owner', 'view', 'archived', 'lesson'];
40
+
41
+ // Strip trailing slashes from a base URL, falling back to the default when the
42
+ // input is empty/absent. Pure.
43
+ function normalizeBase(base) {
44
+ const b = String(base == null ? '' : base)
45
+ .trim()
46
+ .replace(/\/+$/, '');
47
+ return b || DEFAULT_APP_BASE;
48
+ }
49
+
50
+ // Resolve the dashboard base URL: an explicit `--base` flag wins, then the
51
+ // `LOREKIT_APP_URL` env var, then the baked-in default. Pure — `env` is passed
52
+ // in (never read from `process` here) so it stays trivially unit-testable.
53
+ export function resolveAppBase({ base, env = {} } = {}) {
54
+ const flag = typeof base === 'string' && base.trim() ? base.trim() : '';
55
+ const fromEnv =
56
+ env && typeof env.LOREKIT_APP_URL === 'string' && env.LOREKIT_APP_URL.trim()
57
+ ? env.LOREKIT_APP_URL.trim()
58
+ : '';
59
+ return normalizeBase(flag || fromEnv || DEFAULT_APP_BASE);
60
+ }
61
+
62
+ // Encode ONE param value the way `useUrlState` reads it back:
63
+ // `encodeURIComponent(JSON.stringify(value))`. The exact inverse of the app's
64
+ // `JSON.parse(searchParams.get(key))`. Pure.
65
+ export function encodeParam(value) {
66
+ return encodeURIComponent(JSON.stringify(value));
67
+ }
68
+
69
+ // Build the `/lore` query string from a params object, JSON-encoding each value
70
+ // and OMITTING any param that is `undefined` or JSON-equal to its default (so
71
+ // the URL carries only the filters that actually change the view). Pure and
72
+ // total — an unknown key in `params` is ignored (only PARAM_ORDER is emitted).
73
+ export function buildLoreQuery(params = {}) {
74
+ const parts = [];
75
+ for (const key of PARAM_ORDER) {
76
+ const value = params[key];
77
+ if (value === undefined) continue;
78
+ if (JSON.stringify(value) === JSON.stringify(LORE_PARAM_DEFAULTS[key])) continue;
79
+ parts.push(`${key}=${encodeParam(value)}`);
80
+ }
81
+ return parts.join('&');
82
+ }
83
+
84
+ // Build a full `/lore` deep link from a params object. `base` defaults to the
85
+ // hosted dashboard; pass a resolved base for self-hosted setups. Pure.
86
+ export function buildLoreUrl(params = {}, { base = DEFAULT_APP_BASE } = {}) {
87
+ const cleanBase = normalizeBase(base);
88
+ const query = buildLoreQuery(params);
89
+ return `${cleanBase}/lore${query ? `?${query}` : ''}`;
90
+ }
91
+
92
+ // A shareable link to the Explorer filtered to a scope. `null`/`''` → the bare
93
+ // `/lore` (all scopes, the default); any concrete scope — INCLUDING `global` —
94
+ // → `?scope="<scope>"`. (`global` is a real scope the app can filter to, not a
95
+ // synonym for "no filter" — only the actual default `null` is omitted.) Pure.
96
+ export function loreScopeUrl(scope, opts = {}) {
97
+ const params = scope ? { scope } : {};
98
+ return buildLoreUrl(params, opts);
99
+ }
100
+
101
+ // A shareable link that opens a specific lesson's detail sheet. Sets the
102
+ // `lesson` param (which opens the sheet) plus `scope` — NOT because scope is
103
+ // needed to RESOLVE the lesson (the sidebar's `useLoreData` reads one unfiltered
104
+ // recent set, `.limit(500)`, non-archived, so `scope` does not widen that
105
+ // lookup), but so the Explorer list BEHIND the sheet is filtered to the lesson's
106
+ // own scope, coherent with the detail view. A lesson older than that window or
107
+ // archived can still open blank — a dashboard-side limitation the link can't fix.
108
+ // `scope` is the lesson's own scope. Pure.
109
+ export function buildLessonUrl(scope, key, opts = {}) {
110
+ const params = { lesson: { scope, key } };
111
+ if (scope) params.scope = scope;
112
+ return buildLoreUrl(params, opts);
113
+ }
114
+
115
+ // Resolve a SINGLE positional `link` argument into a { scope, key } pair,
116
+ // disambiguating a bare scope from the `<scope>::<key>` shorthand. `isScope` is
117
+ // an injected validity predicate (the caller passes a `scopeIssue`-based check),
118
+ // keeping this module zero-import.
119
+ //
120
+ // The rule: split at the LAST `::` and take it as `<scope>::<key>` ONLY when the
121
+ // left side is itself a COMPLETE valid scope — otherwise the whole arg is the
122
+ // scope. Splitting on the last `::` (not the first) keeps a multi-segment scope
123
+ // whole (`repo::owner/name::key` → scope `repo::owner/name`, key `key`); gating
124
+ // on a valid left side means a bare `repo::owner/name` is NOT mis-split, because
125
+ // its left part `repo` is not a valid scope. This is the fix for the prior
126
+ // first-`::` split, which turned `link repo::acme/widget` into scope="repo" plus
127
+ // a bogus `acme/widget` key — breaking the shorthand for EVERY non-`global`
128
+ // scope. A malformed arg falls through to the scope, never a fabricated key. Pure.
129
+ export function resolveScopeArg(arg, isScope = () => false) {
130
+ const s = typeof arg === 'string' ? arg.trim() : '';
131
+ if (!s) return { scope: null, key: null };
132
+ const idx = s.lastIndexOf('::');
133
+ if (idx !== -1) {
134
+ const left = s.slice(0, idx).trim();
135
+ const right = s.slice(idx + 2).trim();
136
+ if (right && isScope(left)) return { scope: left, key: right };
137
+ }
138
+ return { scope: s, key: null };
139
+ }
140
+
141
+ // ── Flag → param coercion (pure, shared by the `link` command) ────────────────
142
+
143
+ // Coerce the `--owner` flag to an `OwnerFilter`: 'all' (default) / 'personal' /
144
+ // any other non-empty string → `{ orgId }`. Pure.
145
+ export function parseOwnerArg(owner) {
146
+ if (typeof owner !== 'string' || !owner || owner === 'all') return 'all';
147
+ if (owner === 'personal') return 'personal';
148
+ return { orgId: owner };
149
+ }
150
+
151
+ // Coerce the `--view` flag to a `ViewMode`: only 'time' is non-default; anything
152
+ // else (incl. absent/invalid) → 'scope'. Pure.
153
+ export function parseViewArg(view) {
154
+ return view === 'time' ? 'time' : 'scope';
155
+ }
156
+
157
+ // Coerce the date-range flags to a `{ from, to }` DateRange or null. `--range`
158
+ // (a JSON object string) wins; else `--from`/`--to` shorthand builds one (both
159
+ // keys always present so the shape matches the app's DateRange). A malformed
160
+ // `--range` yields null rather than throwing. Pure.
161
+ export function parseRangeArg({ range, from, to } = {}) {
162
+ if (typeof range === 'string' && range.trim()) {
163
+ try {
164
+ const v = JSON.parse(range);
165
+ if (v && typeof v === 'object') return v;
166
+ } catch {
167
+ /* malformed --range → no range */
168
+ }
169
+ return null;
170
+ }
171
+ const hasFrom = typeof from === 'string' && from;
172
+ const hasTo = typeof to === 'string' && to;
173
+ if (hasFrom || hasTo) {
174
+ return { from: hasFrom ? from : '', to: hasTo ? to : '' };
175
+ }
176
+ return null;
177
+ }
178
+
179
+ // The Explorer's most-specific applicable scope for the cwd — `readOrder`'s
180
+ // first non-global entry, or null when only `global` applies (→ a bare `/lore`).
181
+ // The single-scope representation a deep link can carry for a multi-scope view.
182
+ // Pure — takes an already-derived `deriveScope()` result.
183
+ export function mostSpecificScope({ readOrder = [] } = {}) {
184
+ return readOrder.find((s) => s && s !== 'global') ?? null;
185
+ }
186
+
187
+ // Classify a params object into a bounded surface label (non-PII — safe as a
188
+ // telemetry attribute): 'lesson' | 'search' | 'scope' | 'explorer'. Pure.
189
+ export function surfaceFor(params = {}) {
190
+ if (params.lesson) return 'lesson';
191
+ if (params.q) return 'search';
192
+ if (params.scope) return 'scope';
193
+ return 'explorer';
194
+ }
package/src/doctor.mjs CHANGED
@@ -282,14 +282,43 @@ async function deepCheckRemote(store, root, record) {
282
282
  record('fail', 'round-trip', `write failed: ${w.error ? w.error.message || w.error.code : w.networkError}`);
283
283
  return;
284
284
  }
285
- const r = await store.read({ scope: writeScope, key });
286
- const readBack = r.ok && JSON.stringify(r.entry || '').includes('round-trip');
287
- record(
288
- readBack ? 'pass' : 'warn',
289
- 'round-trip',
290
- readBack ? `wrote + read back in ${writeScope}` : 'wrote, but read-back was inconclusive',
291
- );
292
- await store.delete({ scope: writeScope, key, force: true });
285
+ // Everything after a SUCCESSFUL write runs under `finally`, because this
286
+ // probe writes to the user's REAL project (CI runs it against production on
287
+ // every deploy). A throw or an early return between the write and the delete
288
+ // leaves a synthetic row in that tenant with nothing to remove it — the read
289
+ // is a diagnostic, never a reason to abandon the row it created.
290
+ try {
291
+ const r = await store.read({ scope: writeScope, key });
292
+ const readBack = r.ok && JSON.stringify(r.entry || '').includes('round-trip');
293
+ record(
294
+ readBack ? 'pass' : 'warn',
295
+ 'round-trip',
296
+ readBack ? `wrote + read back in ${writeScope}` : 'wrote, but read-back was inconclusive',
297
+ );
298
+ } finally {
299
+ await removeProbeRow(store, writeScope, key, record);
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Delete the probe row, reporting rather than swallowing a failure.
305
+ *
306
+ * A silent `.catch` here means a synthetic row left in the user's REAL project
307
+ * with no signal anywhere — the same class of invisible leak this cleanup work
308
+ * exists to end. It is a `warn`, not a `fail`: the round-trip the user asked
309
+ * about did happen, so this must not flip doctor's verdict; it must just be
310
+ * impossible to miss. Both the thrown case and the `{ ok: false }` case are
311
+ * covered, because a REST delete reports its failure in the return value.
312
+ */
313
+ async function removeProbeRow(store, writeScope, key, record) {
314
+ try {
315
+ const d = await store.delete({ scope: writeScope, key, force: true });
316
+ if (d && d.ok === false) {
317
+ record('warn', 'round-trip cleanup', `could not remove ${writeScope}::${key} — delete it manually`);
318
+ }
319
+ } catch (err) {
320
+ record('warn', 'round-trip cleanup', `could not remove ${writeScope}::${key}: ${err && err.message ? err.message : err}`);
321
+ }
293
322
  }
294
323
 
295
324
  async function deepCheckLocal(store, scope, record) {
@@ -302,14 +331,19 @@ async function deepCheckLocal(store, scope, record) {
302
331
  tags: ['skill::lorekit-memory', 'source::doctor'],
303
332
  trigger: 'manual',
304
333
  });
305
- const r = await store.read({ scope: writeScope, key });
306
- const readBack = w.ok && r.ok && r.entry && String(r.entry.value).includes('round-trip');
307
- record(
308
- readBack ? 'pass' : 'warn',
309
- 'round-trip',
310
- readBack ? `wrote + read back in ${writeScope}` : 'write/read-back was inconclusive',
311
- );
312
- await store.delete({ scope: writeScope, key, force: true });
334
+ // Same `finally` contract as the remote probe: the local store is a real
335
+ // store too, and a half-finished probe should not leave a row in it.
336
+ try {
337
+ const r = await store.read({ scope: writeScope, key });
338
+ const readBack = w.ok && r.ok && r.entry && String(r.entry.value).includes('round-trip');
339
+ record(
340
+ readBack ? 'pass' : 'warn',
341
+ 'round-trip',
342
+ readBack ? `wrote + read back in ${writeScope}` : 'write/read-back was inconclusive',
343
+ );
344
+ } finally {
345
+ await removeProbeRow(store, writeScope, key, record);
346
+ }
313
347
  }
314
348
 
315
349
  // Returns the list of CLAUDE_HOOK_EVENTS whose lorekit hook command appears in
package/src/hook.mjs CHANGED
@@ -122,7 +122,13 @@ async function run(args) {
122
122
  const key = (parsed.toolInput && typeof parsed.toolInput.key === 'string')
123
123
  ? parsed.toolInput.key
124
124
  : null;
125
- emit(writeConfirmation(scope, key));
125
+ // The scope the write actually targeted (tool input) — the confirmation
126
+ // link must point there, not at the cwd's repo scope, or a global/project
127
+ // write would deep-link to a lesson ref that doesn't exist.
128
+ const writtenScope = (parsed.toolInput && typeof parsed.toolInput.scope === 'string' && parsed.toolInput.scope)
129
+ ? parsed.toolInput.scope
130
+ : null;
131
+ emit(writeConfirmation(scope, key, writtenScope));
126
132
  }
127
133
  } catch {
128
134
  // best-effort — never break the host
package/src/link.mjs ADDED
@@ -0,0 +1,139 @@
1
+ // `lorekit link` (alias `url`) — print a shareable dashboard deep-link URL to
2
+ // stdout for the current directory's context, a scope, or a specific lesson.
3
+ //
4
+ // Read-only and network-free: it derives scopes from git and builds a URL — it
5
+ // never talks to a store. The URL alone is written to stdout (pipeable, e.g.
6
+ // `lorekit link | pbcopy`); any advisory note goes to stderr, and only when
7
+ // stderr is a TTY, so a pipe stays clean. Human-facing, so the bin wraps it in
8
+ // `traceCommand`.
9
+ //
10
+ // lorekit link → /lore filtered to the cwd's most-specific scope
11
+ // lorekit link <scope> → /lore?scope="<scope>"
12
+ // lorekit link <scope> <key> → a link that opens that lesson's detail sheet
13
+ // lorekit link <scope::key> → same, via the copy-paste shorthand
14
+ //
15
+ // Every param is JSON-encoded per the `useUrlState` contract (see
16
+ // `deeplink-pure.mjs`) — a raw `?scope=global` would silently mean "all scopes".
17
+ import process from 'node:process';
18
+ import { resolveProjectRoot } from './config.mjs';
19
+ import { deriveScope } from './scope.mjs';
20
+ import { scopeIssue } from './lessons-view.mjs';
21
+ import {
22
+ resolveAppBase,
23
+ buildLoreUrl,
24
+ mostSpecificScope,
25
+ parseOwnerArg,
26
+ parseViewArg,
27
+ parseRangeArg,
28
+ resolveScopeArg,
29
+ surfaceFor,
30
+ } from './deeplink-pure.mjs';
31
+ import { log, err } from './util.mjs';
32
+
33
+ export async function link(args) {
34
+ const root = resolveProjectRoot(args.dir);
35
+ const env = { ...process.env };
36
+ const scopeInfo = deriveScope(root);
37
+ const base = resolveAppBase({ base: args.base, env });
38
+
39
+ // Positionals: link [scope] [key] OR link <scope::key>. args._[0] is the
40
+ // command token ('link' / 'url'), so the first argument is args._[1].
41
+ const first = typeof args._[1] === 'string' ? args._[1] : '';
42
+ const second = typeof args._[2] === 'string' ? args._[2] : '';
43
+ let scope = null;
44
+ let key = null;
45
+ if (first && second) {
46
+ // Two positionals — the first IS the scope (even one containing `::`, like
47
+ // `repo::owner/name`); the second is the key. The `scope::key` shorthand is
48
+ // only consulted for a single positional, below.
49
+ scope = first;
50
+ key = second;
51
+ } else if (first) {
52
+ // One positional: disambiguate a bare scope from the `<scope>::<key>`
53
+ // shorthand by scope validity, not by a naive first-`::` split — otherwise
54
+ // `link repo::owner/name` (a valid scope) is misread as scope="repo" + a
55
+ // bogus key. `scopeIssue(s) === null` is the canonical "is a valid scope".
56
+ const resolved = resolveScopeArg(first, (s) => scopeIssue(s) === null);
57
+ scope = resolved.scope;
58
+ key = resolved.key;
59
+ }
60
+ // `--scope` sets the scope when no positional scope was given (consistency
61
+ // with the other read commands); an explicit positional always wins.
62
+ if (!scope && typeof args.scope === 'string' && args.scope) scope = args.scope;
63
+
64
+ // Filter flags (all optional; each JSON-encoded + default-omitted downstream).
65
+ const q = typeof args.q === 'string' ? args.q : '';
66
+ const owner = parseOwnerArg(args.owner);
67
+ const view = parseViewArg(args.view);
68
+ const range = parseRangeArg(args);
69
+ const archived = Boolean(args.archived);
70
+
71
+ const gaveAnyInput =
72
+ Boolean(first) ||
73
+ (typeof args.scope === 'string' && Boolean(args.scope)) ||
74
+ Boolean(q) ||
75
+ owner !== 'all' ||
76
+ view !== 'scope' ||
77
+ range !== null ||
78
+ archived;
79
+
80
+ // Bare `lorekit link` (no scope, no lesson, no filters) → the cwd's
81
+ // most-specific scope, so it links to "what I'm looking at". Falls back to a
82
+ // bare /lore when only `global` applies.
83
+ if (!scope && !key && !gaveAnyInput) {
84
+ scope = mostSpecificScope(scopeInfo);
85
+ }
86
+
87
+ // Assemble only the non-default params (clean URL + a truthful `--json`).
88
+ const params = {};
89
+ if (scope) params.scope = scope;
90
+ if (key) params.lesson = { scope, key };
91
+ if (q) params.q = q;
92
+ if (owner !== 'all') params.owner = owner;
93
+ if (view !== 'scope') params.view = view;
94
+ if (range !== null) params.range = range;
95
+ if (archived) params.archived = true;
96
+
97
+ // UX guard: a lesson/scope link pointing at a scope the caller isn't in
98
+ // (a different repo/project) may render empty for them. Note it on stderr —
99
+ // never stdout (keeps the URL pipeable) — and only when stderr is a TTY and
100
+ // we're not emitting JSON, so scripts and pipes stay quiet.
101
+ maybeWarnScope(scope, scopeInfo, args.json);
102
+
103
+ return emitLink({ params, base, json: args.json });
104
+ }
105
+
106
+ // Emit a deep link: the URL alone on stdout (pipeable), or the structured
107
+ // `{ url, surface, base, params }` under `--json`. Returns the bounded, non-PII
108
+ // telemetry extras (the surface enum + booleans — never a scope string, key,
109
+ // query, or base URL). Shared by the `link` command AND the read commands'
110
+ // `--link` short-circuit so the two emit an identical shape. `params` is the
111
+ // already-assembled non-default param set.
112
+ export function emitLink({ params = {}, base, json }) {
113
+ const url = buildLoreUrl(params, { base });
114
+ const surface = surfaceFor(params);
115
+ if (json) {
116
+ log(JSON.stringify({ url, surface, base, params }, null, 2));
117
+ } else {
118
+ log(url);
119
+ }
120
+ return {
121
+ exitCode: 0,
122
+ 'lorekit.cli.link.surface': surface,
123
+ 'lorekit.cli.link.has_scope': Boolean(params.scope),
124
+ 'lorekit.cli.link.has_lesson': Boolean(params.lesson),
125
+ };
126
+ }
127
+
128
+ // Warn (stderr, TTY-only) when `scope` is a concrete scope the caller isn't in.
129
+ // `global` is always visible; a scope present in the cwd's `readOrder` is too.
130
+ function maybeWarnScope(scope, scopeInfo, json) {
131
+ if (json || !scope || scope === 'global') return;
132
+ if (!process.stderr.isTTY) return;
133
+ if ((scopeInfo.readOrder || []).includes(scope)) return;
134
+ err(
135
+ `note: ${scope} is not one of your current scopes ` +
136
+ `(${(scopeInfo.readOrder || []).join(', ')}); ` +
137
+ `the link may show nothing if you can't access that scope.`,
138
+ );
139
+ }
package/src/list.mjs CHANGED
@@ -14,6 +14,8 @@ import { deriveScope } from './scope.mjs';
14
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
+ import { resolveAppBase, mostSpecificScope } from './deeplink-pure.mjs';
18
+ import { emitLink } from './link.mjs';
17
19
  import { log, heading, c } from './util.mjs';
18
20
 
19
21
  // Abbreviate the user's home directory to `~` for readable paths.
@@ -40,6 +42,17 @@ export async function list(args) {
40
42
  // scope outside the applicable set is honoured — the user asked for it).
41
43
  const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo);
42
44
 
45
+ // `--link` short-circuits: print the Explorer deep link for the current
46
+ // context (the most-specific applicable scope, or `--scope`), no store reads.
47
+ if (args.link) {
48
+ const base = resolveAppBase({ base: args.base, env });
49
+ const scope =
50
+ args.scope && typeof args.scope === 'string' ? args.scope : mostSpecificScope(scopeInfo);
51
+ const params = {};
52
+ if (scope) params.scope = scope;
53
+ return emitLink({ params, base, json: args.json });
54
+ }
55
+
43
56
  const { local, remote, connection } = resolveStores(root, {
44
57
  env,
45
58
  endpoint: args.endpoint,
package/src/mcp.mjs CHANGED
@@ -124,16 +124,36 @@ export function mcpToRestBase(mcpEndpointUrl) {
124
124
  * @param {number} [opts.timeoutMs=10000]
125
125
  * @param {string} [opts.traceparent] - W3C traceparent header value
126
126
  */
127
+ /**
128
+ * Normalise a client-supplied usage correlation id (a PR ref, session id, or CI
129
+ * job id). Bounded + charset-restricted to match the server's `parseCorrelationId`
130
+ * (supabase/functions/_shared/usage-stats.ts); returns null for empty/over-long/
131
+ * out-of-charset input so a bad value is simply not sent. Zero-dep (the CLI does
132
+ * not import mcp-core), so the small regex is duplicated intentionally.
133
+ */
134
+ export function normalizeCorrelationId(raw) {
135
+ if (typeof raw !== 'string') return null;
136
+ const t = raw.trim();
137
+ if (!t || t.length > 200) return null;
138
+ return /^[A-Za-z0-9_\-./:#@]+$/.test(t) ? t : null;
139
+ }
140
+
127
141
  export async function restFetch(baseUrl, token, path, { method = 'GET', body, timeoutMs = 10000, traceparent } = {}) {
128
142
  const controller = new AbortController();
129
143
  const timer = setTimeout(() => controller.abort(), timeoutMs);
130
144
  try {
131
145
  const url = `${baseUrl}${path}`;
146
+ // Opt-in usage correlation: when LOREKIT_CORRELATION_ID is set (e.g. by a CI
147
+ // job or a hook to a PR/session id), tag every REST call so GET
148
+ // /memories/usage?correlation_id=… can report "usage for this PR". Absent env
149
+ // ⇒ no header ⇒ existing behaviour unchanged.
150
+ const correlationId = normalizeCorrelationId(process.env.LOREKIT_CORRELATION_ID);
132
151
  const headers = {
133
152
  accept: 'application/json',
134
153
  ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
135
154
  ...(token ? { authorization: `Bearer ${token}` } : {}),
136
155
  ...(traceparent ? { traceparent } : {}),
156
+ ...(correlationId ? { 'x-lorekit-correlation-id': correlationId } : {}),
137
157
  };
138
158
  const res = await fetch(url, {
139
159
  method,
package/src/search.mjs CHANGED
@@ -19,6 +19,8 @@ import { deriveScope } from './scope.mjs';
19
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
+ import { resolveAppBase, mostSpecificScope } from './deeplink-pure.mjs';
23
+ import { emitLink } from './link.mjs';
22
24
  import { log, err, heading, c } from './util.mjs';
23
25
 
24
26
  export async function search(args) {
@@ -41,6 +43,17 @@ export async function search(args) {
41
43
  // scope outside the applicable set is honoured — the user asked for it).
42
44
  const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo);
43
45
 
46
+ // `--link` short-circuits: print the Explorer deep link for this search
47
+ // (`?q=…` plus the most-specific applicable scope), without touching a store.
48
+ if (args.link) {
49
+ const base = resolveAppBase({ base: args.base, env });
50
+ const scope =
51
+ args.scope && typeof args.scope === 'string' ? args.scope : mostSpecificScope(scopeInfo);
52
+ const params = { q: query };
53
+ if (scope) params.scope = scope;
54
+ return emitLink({ params, base, json: args.json });
55
+ }
56
+
44
57
  const { local, remote, connection } = resolveStores(root, {
45
58
  env,
46
59
  endpoint: args.endpoint,
package/src/show.mjs CHANGED
@@ -19,6 +19,8 @@ import { resolveProjectRoot } from './config.mjs';
19
19
  import { resolveDenies } from './control.mjs';
20
20
  import { resolveStores, remoteUnavailableReason } from './stores.mjs';
21
21
  import { normalizeEntry, shortDate, describeError, recordsDiverge, parseScopeKey } from './lessons-view.mjs';
22
+ import { resolveAppBase } from './deeplink-pure.mjs';
23
+ import { emitLink } from './link.mjs';
22
24
  import { log, err, heading, status, c } from './util.mjs';
23
25
 
24
26
  // Read one scope::key from a store, normalizing the result into a small,
@@ -67,6 +69,13 @@ export async function show(args) {
67
69
  return 1;
68
70
  }
69
71
 
72
+ // `--link` short-circuits: print the deep link that opens THIS lesson's detail
73
+ // sheet (`?scope=…&lesson=…`) for the current args, without touching a store.
74
+ if (args.link) {
75
+ const base = resolveAppBase({ base: args.base, env });
76
+ return emitLink({ params: { scope, lesson: { scope, key } }, base, json: args.json });
77
+ }
78
+
70
79
  const { local, remote, connection } = resolveStores(root, {
71
80
  env,
72
81
  endpoint: args.endpoint,
package/src/telemetry.mjs CHANGED
@@ -5,8 +5,8 @@
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
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.
8
+ // tree / lint / dedupe / link / migrate), fired to Dash0 so the maintainers can
9
+ // see 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
@@ -38,7 +38,7 @@ const DEFAULT_DATASET = 'default';
38
38
 
39
39
  // Flags worth counting (e.g. how many installs are --global). Bounded on
40
40
  // purpose: only these booleans are ever attached, never free-form values.
41
- const FLAG_ATTRS = ['global', 'project', 'deep', 'yes', 'force', 'no-hooks', 'json'];
41
+ const FLAG_ATTRS = ['global', 'project', 'deep', 'yes', 'force', 'no-hooks', 'json', 'link'];
42
42
 
43
43
  const OFF_VALUES = new Set(['0', 'off', 'false', 'no', 'disable', 'disabled']);
44
44
 
@@ -165,16 +165,58 @@ function toOtlpAttributes(attributes) {
165
165
  return Object.entries(attributes).map(([key, value]) => ({ key, value: toOtlpValue(value) }));
166
166
  }
167
167
 
168
- function resourceAttributes(version) {
169
- return [
168
+ // OTel `os.type` / `host.arch` use their own bounded enum vocabularies, which
169
+ // are NOT identical to Node's `process.platform` / `process.arch` spellings.
170
+ // Node's `win32` / `sunos` and `x64` / `ia32` / `arm` are the ones that differ;
171
+ // emitting them verbatim produces off-registry attribute values that a Dash0 /
172
+ // OTel-native backend can't group with telemetry from other SDKs. Map the known
173
+ // divergences and pass anything already-canonical (or unknown) through.
174
+ // os.type: https://opentelemetry.io/docs/specs/semconv/registry/attributes/os/
175
+ // host.arch: https://opentelemetry.io/docs/specs/semconv/registry/attributes/host/
176
+ const OS_TYPE_BY_PLATFORM = { win32: 'windows', sunos: 'solaris' };
177
+ // Node's `process.arch` reports `ppc` for 32-bit PowerPC; the OTel `host.arch`
178
+ // registry value for it is `ppc32` (its `ppc64` spelling already matches Node).
179
+ const HOST_ARCH_BY_PROCESS_ARCH = { x64: 'amd64', ia32: 'x86', arm: 'arm32', ppc: 'ppc32' };
180
+
181
+ /** Map a Node `process.platform` value to an OTel `os.type` registry value. */
182
+ export function normalizeOsType(platform) {
183
+ return OS_TYPE_BY_PLATFORM[platform] ?? platform;
184
+ }
185
+
186
+ /** Map a Node `process.arch` value to an OTel `host.arch` registry value. */
187
+ export function normalizeHostArch(arch) {
188
+ return HOST_ARCH_BY_PROCESS_ARCH[arch] ?? arch;
189
+ }
190
+
191
+ /**
192
+ * Resolve the `deployment.environment.name` resource value, or `undefined` when
193
+ * none is set. The CLI runs on end-users' machines, so — unlike the edge/web/
194
+ * mcp-node deployments — it has no ambient environment and deliberately OMITS
195
+ * the attribute by default. It is emitted ONLY when explicitly overridden via
196
+ * `DEPLOYMENT_ENVIRONMENT` (falling back to `OTEL_DEPLOYMENT_ENVIRONMENT`) — the
197
+ * same single, env-driven knob the edge honours, which the correlated-trace
198
+ * harness (`scripts/emit-correlated-trace.mts`) uses to stamp `test`.
199
+ * @param {object} [env] defaults to process.env
200
+ */
201
+ export function resolveDeploymentEnvironment(env = process.env) {
202
+ const raw = env.DEPLOYMENT_ENVIRONMENT ?? env.OTEL_DEPLOYMENT_ENVIRONMENT;
203
+ const value = raw !== undefined ? String(raw).trim() : '';
204
+ return value || undefined;
205
+ }
206
+
207
+ function resourceAttributes(version, env = process.env) {
208
+ const attrs = [
170
209
  { key: 'service.name', value: { stringValue: 'cli' } },
171
210
  { key: 'service.namespace', value: { stringValue: 'lorekit' } },
172
211
  { key: 'service.version', value: { stringValue: String(version) } },
173
212
  { key: 'process.runtime.name', value: { stringValue: 'nodejs' } },
174
213
  { key: 'process.runtime.version', value: { stringValue: process.versions.node } },
175
- { key: 'os.type', value: { stringValue: process.platform } },
176
- { key: 'host.arch', value: { stringValue: process.arch } },
214
+ { key: 'os.type', value: { stringValue: normalizeOsType(process.platform) } },
215
+ { key: 'host.arch', value: { stringValue: normalizeHostArch(process.arch) } },
177
216
  ];
217
+ const deploymentEnv = resolveDeploymentEnvironment(env);
218
+ if (deploymentEnv) attrs.push({ key: 'deployment.environment.name', value: { stringValue: deploymentEnv } });
219
+ return attrs;
178
220
  }
179
221
 
180
222
  // ── Payload builders (pure — unit-tested) ─────────────────────────────────────
@@ -322,7 +364,7 @@ function normalizeExitCode(result) {
322
364
  * counter point. Returns the command's exit code unchanged. Telemetry failures
323
365
  * are swallowed — the command result is never affected.
324
366
  *
325
- * @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | scopes | diff | tree | lint | dedupe | migrate
367
+ * @param {string} command bounded: install | uninstall | doctor | list | search | show | stats | scopes | diff | tree | lint | dedupe | link | migrate
326
368
  * @param {object} args parsed CLI args (read for allow-listed flags only)
327
369
  * @param {string} version CLI version (from package.json)
328
370
  * @param {() => Promise<number>} run the command handler
package/src/tree.mjs CHANGED
@@ -27,6 +27,8 @@ import { resolveDenies } from './control.mjs';
27
27
  import { resolveStores, remoteUnavailableReason } from './stores.mjs';
28
28
  import { resolvePrecedence } from './lessons-pure.mjs';
29
29
  import { gather, preview, shortDate } from './lessons-view.mjs';
30
+ import { resolveAppBase, mostSpecificScope } from './deeplink-pure.mjs';
31
+ import { emitLink } from './link.mjs';
30
32
  import { log, heading, status, c } from './util.mjs';
31
33
 
32
34
  export async function tree(args) {
@@ -39,6 +41,17 @@ export async function tree(args) {
39
41
  // `--scope <s>` narrows to one (honored even outside the set — the user asked).
40
42
  const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeInfo.readOrder;
41
43
 
44
+ // `--link` short-circuits: print the Explorer deep link for the resolved
45
+ // context (the most-specific applicable scope, or `--scope`), no store reads.
46
+ if (args.link) {
47
+ const base = resolveAppBase({ base: args.base, env });
48
+ const scope =
49
+ args.scope && typeof args.scope === 'string' ? args.scope : mostSpecificScope(scopeInfo);
50
+ const params = {};
51
+ if (scope) params.scope = scope;
52
+ return emitLink({ params, base, json: args.json });
53
+ }
54
+
42
55
  const { local, remote, connection } = resolveStores(root, {
43
56
  env,
44
57
  endpoint: args.endpoint,