@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 +45 -2
- package/bin/lorekit.mjs +61 -5
- package/package.json +1 -1
- package/src/core/lessons.mjs +22 -12
- package/src/deeplink-pure.mjs +194 -0
- package/src/doctor.mjs +50 -16
- package/src/hook.mjs +7 -1
- package/src/link.mjs +139 -0
- package/src/list.mjs +13 -0
- package/src/mcp.mjs +20 -0
- package/src/search.mjs +13 -0
- package/src/show.mjs +9 -0
- package/src/telemetry.mjs +50 -8
- package/src/tree.mjs +13 -0
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
package/src/core/lessons.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
'round-trip'
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
169
|
-
|
|
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,
|