@lorekit/cli 1.33.3 → 1.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -4
- package/bin/lorekit.mjs +52 -19
- package/package.json +1 -1
- package/skill/lorekit-groom/SKILL.md +2 -2
- package/skill/lorekit-groom/rules/grooming-pass.md +5 -0
- package/src/deeplink-pure.mjs +13 -28
- package/src/lessons-pure.mjs +136 -0
- package/src/lessons-view.mjs +48 -45
- package/src/link.mjs +12 -22
- package/src/lint.mjs +3 -3
- package/src/show.mjs +45 -22
- package/src/write.mjs +70 -24
package/README.md
CHANGED
|
@@ -231,8 +231,8 @@ Inspect **one** lesson in full — its complete, **untruncated** value plus scop
|
|
|
231
231
|
key, updated date, tags, and which store(s) it lives in:
|
|
232
232
|
|
|
233
233
|
```bash
|
|
234
|
-
lorekit show global
|
|
235
|
-
lorekit show
|
|
234
|
+
lorekit show global::prefer-guard-clauses
|
|
235
|
+
lorekit show repo::acme/widget::build-flags --json
|
|
236
236
|
```
|
|
237
237
|
|
|
238
238
|
If the same `scope::key` exists in **both** the offline and remote stores —
|
|
@@ -242,6 +242,36 @@ missing. It exits **non-zero** when the key is found in no readable store, so it
|
|
|
242
242
|
fits scripts. `--json` emits the full normalized record(s) and which store each
|
|
243
243
|
came from. Both a scope and a key are required (else a usage error).
|
|
244
244
|
|
|
245
|
+
### Addressing a memory: `<scope::key>`
|
|
246
|
+
|
|
247
|
+
`show`, `write` and `link` all take a memory the same way, and the single-token
|
|
248
|
+
`<scope::key>` form is canonical — it is exactly what `list` and `search` print
|
|
249
|
+
and what `write` echoes back, so a key copy-pasted out of any output resolves.
|
|
250
|
+
The explicit two-positional form is also accepted, and `--scope` / `--key` name
|
|
251
|
+
each half outright:
|
|
252
|
+
|
|
253
|
+
```bash
|
|
254
|
+
lorekit show repo::acme/widget::build-flags # canonical
|
|
255
|
+
lorekit show repo::acme/widget build-flags # explicit positionals
|
|
256
|
+
lorekit show --scope repo::acme/widget --key build-flags
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
The single-token form is split at the **last** `::`, and only when the left side
|
|
260
|
+
is itself a **complete valid scope** — so a multi-segment scope stays whole
|
|
261
|
+
(`repo::acme/widget::build-flags` is scope `repo::acme/widget`, key
|
|
262
|
+
`build-flags`), and a bare `repo::acme/widget` is never mis-read as scope `repo`
|
|
263
|
+
plus a bogus key. Because `::` is reserved as the scope separator, a key that
|
|
264
|
+
itself contains `::` cannot be written as one token — use `--key` for those:
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
lorekit write --scope global --key "loop::aw-lessons" "body"
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
The scope is validated before any store is touched, so a typo is rejected by
|
|
271
|
+
name (`invalid scope foo — unrecognized scope type`) instead of surfacing later
|
|
272
|
+
as a missing value or, worse, a memory quietly filed under a scope that does not
|
|
273
|
+
exist. See [scope format](../../docs/scope-format.md) for the grammar.
|
|
274
|
+
|
|
245
275
|
### `lorekit stats`
|
|
246
276
|
|
|
247
277
|
An at-a-glance overview of how many lessons apply to the current directory —
|
|
@@ -351,7 +381,10 @@ lorekit lint --json # { total, offline, remote } structured findings
|
|
|
351
381
|
|
|
352
382
|
Rules: **empty-value** (blank/whitespace-only body), **short-value** (a non-empty
|
|
353
383
|
body below a small length threshold), **untrimmed-value** (real content with
|
|
354
|
-
surrounding whitespace), **empty-key** (blank key),
|
|
384
|
+
surrounding whitespace), **empty-key** (blank key), **volatile-key** (the key
|
|
385
|
+
carries a per-sighting identifier — a run of 6+ digits such as a GitHub comment
|
|
386
|
+
id, or a `pr<n>` / `issue<n>` segment — so it never collides, never dedups, and
|
|
387
|
+
freezes `seen_count` at 1), and **malformed-scope** (e.g.
|
|
355
388
|
a single `:` where `::` is expected). `lint` **exits non-zero (1) when any issue
|
|
356
389
|
is found**, so it is usable as a CI gate (`lorekit lint || exit 1`); a clean run —
|
|
357
390
|
or one where only a store is unavailable — exits 0. The pure rule predicates live
|
|
@@ -724,7 +757,8 @@ also returns their headroom against the plan's memory cap.
|
|
|
724
757
|
| `--force` | Overwrite existing skill files (`install`) |
|
|
725
758
|
| `--deep` | Write/read/delete round-trip (`doctor`) |
|
|
726
759
|
| `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe` / `link`) |
|
|
727
|
-
| `--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 |
|
|
760
|
+
| `--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. On `show` / `write` it **names** the scope, overriding the positional |
|
|
761
|
+
| `--key <key>` | Name the key outright (`show` / `write` / `link`) — the way to address a key that itself contains `::` |
|
|
728
762
|
| `--link` | Print the equivalent dashboard deep-link URL instead of running (`show` / `search` / `list` / `tree`) |
|
|
729
763
|
| `--base <url>` | Dashboard base URL for deep links (`link` / `--link`; else `LOREKIT_APP_URL`, default `https://lorekit.io`) |
|
|
730
764
|
| `--threshold <0..1>` | Duplicate-similarity cutoff (`dedupe`; default `0.8`) |
|
package/bin/lorekit.mjs
CHANGED
|
@@ -58,10 +58,9 @@ ${c.bold('Commands')}
|
|
|
58
58
|
rendered in the same Offline/Remote split. --json, --scope <s>.
|
|
59
59
|
show Inspect one memory in full: its complete value, scope, key, updated
|
|
60
60
|
date, tags, and which store(s) it lives in (noting any divergence
|
|
61
|
-
when it is in both).
|
|
62
|
-
show <scope
|
|
63
|
-
|
|
64
|
-
write Create or update a memory. Accepts the same <scope::key> shorthand.
|
|
61
|
+
when it is in both). Takes show <scope::key> — the format list and
|
|
62
|
+
search print — or the explicit show <scope> <key>. --json.
|
|
63
|
+
write Create or update a memory, addressed the same <scope::key> way.
|
|
65
64
|
Value is a positional, --value flag, or piped stdin. Writes to the
|
|
66
65
|
remote store when configured, falling back to local. --local /
|
|
67
66
|
--remote to force.
|
|
@@ -79,8 +78,9 @@ ${c.bold('Commands')}
|
|
|
79
78
|
(resolve) hierarchy and mark, per key, which scope's memory WINS and which are
|
|
80
79
|
shadowed — the real hook-resolution order. --json, --scope <s>.
|
|
81
80
|
lint Flag low-quality memories (empty/short/untrimmed value, empty key,
|
|
82
|
-
malformed scope) across the applicable scopes and
|
|
83
|
-
non-zero when issues are found (CI gate).
|
|
81
|
+
volatile key, malformed scope) across the applicable scopes and
|
|
82
|
+
both stores. Exits non-zero when issues are found (CI gate).
|
|
83
|
+
--json, --scope <s>.
|
|
84
84
|
dedupe Find likely-duplicate memories via a zero-dep word-overlap HEURISTIC
|
|
85
85
|
(Jaccard >= threshold, not semantic), grouped into clusters per
|
|
86
86
|
store. --json, --scope <s>, --threshold <0..1>.
|
|
@@ -113,6 +113,9 @@ ${c.bold('Options')}
|
|
|
113
113
|
--store <path> Local project-tier store directory (default: .lorekit)
|
|
114
114
|
--json Machine-readable output (list / search / show / stats / scopes / diff / tree / lint / dedupe / link)
|
|
115
115
|
--scope <scope> Restrict to a single scope; a substring filter for scopes (list / search / stats / scopes / diff / tree / lint / dedupe / link)
|
|
116
|
+
On show / write it NAMES the scope, overriding the positional
|
|
117
|
+
--key <key> Name the key explicitly (show / write / link) — the way to
|
|
118
|
+
address a key that itself contains \`::\`
|
|
116
119
|
--link Print the equivalent dashboard deep-link URL instead of running (show / search / list / tree)
|
|
117
120
|
--base <url> Dashboard base URL for deep links (link / --link; else LOREKIT_APP_URL, default https://lorekit.io)
|
|
118
121
|
--threshold <0..1> Duplicate-similarity cutoff (dedupe; default 0.8)
|
|
@@ -297,16 +300,27 @@ ${c.bold('Examples')}
|
|
|
297
300
|
write: `${c.bold('lorekit write')} — create or update a memory from the CLI
|
|
298
301
|
|
|
299
302
|
${c.bold('Usage')}
|
|
300
|
-
npx @lorekit/cli write <scope> <key> <value> [options]
|
|
301
303
|
npx @lorekit/cli write <scope::key> <value> [options]
|
|
302
|
-
|
|
304
|
+
npx @lorekit/cli write <scope> <key> <value> [options]
|
|
305
|
+
npx @lorekit/cli write --scope <scope> --key <key> <value> [options]
|
|
306
|
+
echo "value" | npx @lorekit/cli write <scope::key> [options]
|
|
303
307
|
|
|
304
308
|
Creates or updates a memory (upsert — overwrites if the key exists). Value can
|
|
305
309
|
be a positional, --value, or piped stdin. Writes to the remote store when
|
|
306
310
|
configured, falling back to local.
|
|
307
311
|
|
|
312
|
+
The single-token <scope::key> form is canonical — it is the format this command
|
|
313
|
+
echoes back and the one list/search print, so it round-trips. It is split at the
|
|
314
|
+
LAST \`::\`, and only when the left side is itself a complete valid scope, so a
|
|
315
|
+
multi-segment scope stays whole: write repo::acme/widget build-flags "..." keeps
|
|
316
|
+
repo::acme/widget as the scope. The scope is validated before anything is
|
|
317
|
+
written, so a typo is rejected rather than stored. Pass --scope/--key to address
|
|
318
|
+
a key that itself contains \`::\`.
|
|
319
|
+
|
|
308
320
|
${c.bold('Options')}
|
|
309
321
|
-d, --dir <path> Target project root (default: current directory)
|
|
322
|
+
--scope <scope> Name the scope explicitly (instead of the positional)
|
|
323
|
+
--key <key> Name the key explicitly — use for a key containing \`::\`
|
|
310
324
|
--value <text> Memory value (alternative to positional / stdin)
|
|
311
325
|
--tags <a,b,c> Comma-separated tags (default: none)
|
|
312
326
|
--source-agent <n> Source agent name to record (default: none)
|
|
@@ -327,35 +341,50 @@ ${c.bold('Options')}
|
|
|
327
341
|
--store <path> Local project-tier store directory (default: .lorekit)
|
|
328
342
|
|
|
329
343
|
${c.bold('Examples')}
|
|
330
|
-
npx @lorekit/cli write global my-key "Always prefer guard clauses"
|
|
331
344
|
npx @lorekit/cli write global::my-key "Always prefer guard clauses"
|
|
332
|
-
|
|
333
|
-
npx @lorekit/cli write global my-key "
|
|
334
|
-
npx @lorekit/cli write global
|
|
345
|
+
npx @lorekit/cli write repo::acme/widget::build-flags "Use --release in CI"
|
|
346
|
+
npx @lorekit/cli write global my-key "Always prefer guard clauses"
|
|
347
|
+
cat notes.md | npx @lorekit/cli write global::my-key --tags "style,aw"
|
|
348
|
+
npx @lorekit/cli write global::my-key "body" --local
|
|
349
|
+
npx @lorekit/cli write global::my-key "body" --ttl-days 30 --remote
|
|
350
|
+
npx @lorekit/cli write --scope global --key "loop::aw-lessons" "body"
|
|
335
351
|
`,
|
|
336
352
|
show: `${c.bold('lorekit show')} — inspect one memory in full
|
|
337
353
|
|
|
338
354
|
${c.bold('Usage')}
|
|
355
|
+
npx @lorekit/cli show <scope::key> [options]
|
|
339
356
|
npx @lorekit/cli show <scope> <key> [options]
|
|
357
|
+
npx @lorekit/cli show --scope <scope> --key <key> [options]
|
|
340
358
|
|
|
341
359
|
Prints one memory's complete (untruncated) value, scope, key, updated date, tags,
|
|
342
360
|
and which store(s) it lives in. If the same scope::key exists in both the offline
|
|
343
361
|
and remote stores, both are shown and any divergence in their values is flagged.
|
|
344
362
|
Exits non-zero when the key is found in neither readable store.
|
|
345
363
|
|
|
364
|
+
The single-token <scope::key> form is canonical — it is exactly what list and
|
|
365
|
+
search print, so a key can be copy-pasted straight out of their output. It is
|
|
366
|
+
split at the LAST \`::\`, and only when the left side is itself a complete valid
|
|
367
|
+
scope, so a multi-segment scope stays whole: repo::acme/widget::build-flags is
|
|
368
|
+
scope repo::acme/widget, key build-flags. Pass --scope/--key to address a key
|
|
369
|
+
that itself contains \`::\`.
|
|
370
|
+
|
|
346
371
|
${c.bold('Options')}
|
|
347
372
|
-d, --dir <path> Target project root (default: current directory)
|
|
348
373
|
--json Machine-readable output (the full normalized record(s))
|
|
349
374
|
-e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
|
|
350
375
|
-t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
|
|
351
376
|
--store <path> Local project-tier store directory (default: .lorekit)
|
|
377
|
+
--scope <scope> Name the scope explicitly (instead of the positional)
|
|
378
|
+
--key <key> Name the key explicitly — use for a key containing \`::\`
|
|
352
379
|
--link Print this memory's dashboard deep-link URL instead of reading (with --base / --json)
|
|
353
380
|
|
|
354
381
|
${c.bold('Examples')}
|
|
355
|
-
npx @lorekit/cli show global prefer-guard-clauses
|
|
356
382
|
npx @lorekit/cli show global::prefer-guard-clauses
|
|
383
|
+
npx @lorekit/cli show repo::acme/widget::build-flags --json
|
|
384
|
+
npx @lorekit/cli show global prefer-guard-clauses
|
|
357
385
|
npx @lorekit/cli show project::widget build-flags --json
|
|
358
|
-
npx @lorekit/cli show global
|
|
386
|
+
npx @lorekit/cli show --scope global --key "loop::aw-lessons"
|
|
387
|
+
npx @lorekit/cli show global::prefer-guard-clauses --link
|
|
359
388
|
`,
|
|
360
389
|
stats: `${c.bold('lorekit stats')} — count the applicable memories per scope and per store
|
|
361
390
|
|
|
@@ -467,10 +496,12 @@ ${c.bold('Usage')}
|
|
|
467
496
|
|
|
468
497
|
Checks every memory for the current directory's scopes (project/branch/repo/
|
|
469
498
|
global), across both stores, against a small set of quality rules: empty or
|
|
470
|
-
whitespace-only value, suspiciously short value, untrimmed value, empty key,
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
499
|
+
whitespace-only value, suspiciously short value, untrimmed value, empty key, a
|
|
500
|
+
volatile per-sighting identifier in the key (a run of 6+ digits, or a \`pr<n>\` /
|
|
501
|
+
\`issue<n>\` reference), and malformed scope (e.g. a single \`:\` where \`::\` is
|
|
502
|
+
expected). Each finding names the rule it violated. Exits NON-ZERO when any issue
|
|
503
|
+
is found, so it works as a CI gate; a clean run — or one where only a store is
|
|
504
|
+
unavailable — exits 0.
|
|
474
505
|
|
|
475
506
|
${c.bold('Options')}
|
|
476
507
|
-d, --dir <path> Target project root (default: current directory)
|
|
@@ -527,6 +558,8 @@ the intended view — a raw ${c.dim('?scope=global')} would silently mean "all s
|
|
|
527
558
|
${c.bold('Options')}
|
|
528
559
|
-d, --dir <path> Target project root (default: current directory)
|
|
529
560
|
--scope <scope> Scope to link to (when no positional scope is given)
|
|
561
|
+
--key <key> Name the key explicitly — the way to link to a key
|
|
562
|
+
that itself contains \`::\`
|
|
530
563
|
--q <text> Pre-fill the Explorer search box
|
|
531
564
|
--owner <o> Ownership filter: all | personal | <orgId>
|
|
532
565
|
--tags <a,b,c> Label filter (AND across labels); comma-separated or a JSON array
|
|
@@ -599,7 +632,7 @@ ${c.bold('Options')}
|
|
|
599
632
|
const KNOWN_FLAGS = [
|
|
600
633
|
'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
|
|
601
634
|
'from', 'to', 'apply', 'yes', 'hooks', 'no-hooks', 'force', 'deep', 'adapter',
|
|
602
|
-
'event', 'json', 'scope', 'threshold', 'help', 'version', 'telemetry',
|
|
635
|
+
'event', 'json', 'scope', 'key', 'threshold', 'help', 'version', 'telemetry',
|
|
603
636
|
'value', 'tags', 'source-agent', 'trigger', 'kind', 'host', 'ttl-days', 'clear-ttl', 'org', 'remote', 'local',
|
|
604
637
|
'link', 'base', 'q', 'owner', 'range', 'view', 'archived',
|
|
605
638
|
'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
|
package/package.json
CHANGED
|
@@ -89,8 +89,8 @@ The short version is six phases; the first four are pure analysis.
|
|
|
89
89
|
`lorekit scopes` (store-wide inventory: every scope with its lesson count)
|
|
90
90
|
show where the mass is. Pick the noisiest scope to start.
|
|
91
91
|
2. **Lint** — `lorekit lint --json` flags structurally bad lessons (empty /
|
|
92
|
-
whitespace / suspiciously short / untrimmed values, empty keys,
|
|
93
|
-
scopes). These are the cheapest wins: fix or drop them first.
|
|
92
|
+
whitespace / suspiciously short / untrimmed values, empty keys, volatile
|
|
93
|
+
keys, malformed scopes). These are the cheapest wins: fix or drop them first.
|
|
94
94
|
3. **Dedupe** — `lorekit dedupe --json` clusters near-duplicate lessons. Start at
|
|
95
95
|
a high `--threshold` (e.g. `0.85`) for confident duplicates, then lower it to
|
|
96
96
|
surface looser paraphrases. Use `lorekit show <scope::key>` to read each
|
|
@@ -38,6 +38,11 @@ Findings are structural, not semantic — each names its rule:
|
|
|
38
38
|
- **empty-value / short-value / untrimmed-value** — the lesson carries little or
|
|
39
39
|
no signal, or has stray leading/trailing whitespace.
|
|
40
40
|
- **empty-key** — no key to address it by.
|
|
41
|
+
- **volatile-key** — the key carries a per-sighting identifier (a run of 6+
|
|
42
|
+
digits such as a GitHub comment id, or a `pr<n>` / `issue<n>` segment), so it
|
|
43
|
+
never collides with a later write, never dedups, and leaves `seen_count`
|
|
44
|
+
frozen at 1. Re-key it onto the structural pattern and move the identifier
|
|
45
|
+
into the body.
|
|
41
46
|
- **malformed-scope** — the scope string is invalid.
|
|
42
47
|
|
|
43
48
|
These are the cheapest wins and the least controversial, so clear them first.
|
package/src/deeplink-pure.mjs
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// Dependency-free deep-link builder for LoreKit dashboard URLs.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// path (`core/lessons.mjs`) and the `link` command / `--link` flag share
|
|
5
|
-
// without pulling in `util`/render/store code.
|
|
3
|
+
// Dependency-free on purpose (the `lessons-pure.mjs` precedent): both the hook
|
|
4
|
+
// hot path (`core/lessons.mjs`) and the `link` command / `--link` flag share
|
|
5
|
+
// this without pulling in `util`/render/store code. The ONE import is the
|
|
6
|
+
// re-export of `resolveScopeArg` & friends from `lessons-pure.mjs`, which is
|
|
7
|
+
// itself zero-import — so the property that matters (no util/render/store on
|
|
8
|
+
// the hot path) holds, while the scope grammar stays in a single module.
|
|
6
9
|
//
|
|
7
10
|
// THE governing constraint — every `/lore` Explorer param is read by the web
|
|
8
11
|
// app's `useUrlState` (`packages/web/src/lib/hooks/useUrlState.ts`) via
|
|
@@ -123,31 +126,13 @@ export function buildLessonUrl(scope, key, opts = {}) {
|
|
|
123
126
|
return buildLoreUrl(params, opts);
|
|
124
127
|
}
|
|
125
128
|
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
// scope. Splitting on the last `::` (not the first) keeps a multi-segment scope
|
|
134
|
-
// whole (`repo::owner/name::key` → scope `repo::owner/name`, key `key`); gating
|
|
135
|
-
// on a valid left side means a bare `repo::owner/name` is NOT mis-split, because
|
|
136
|
-
// its left part `repo` is not a valid scope. This is the fix for the prior
|
|
137
|
-
// first-`::` split, which turned `link repo::acme/widget` into scope="repo" plus
|
|
138
|
-
// a bogus `acme/widget` key — breaking the shorthand for EVERY non-`global`
|
|
139
|
-
// scope. A malformed arg falls through to the scope, never a fabricated key. Pure.
|
|
140
|
-
export function resolveScopeArg(arg, isScope = () => false) {
|
|
141
|
-
const s = typeof arg === 'string' ? arg.trim() : '';
|
|
142
|
-
if (!s) return { scope: null, key: null };
|
|
143
|
-
const idx = s.lastIndexOf('::');
|
|
144
|
-
if (idx !== -1) {
|
|
145
|
-
const left = s.slice(0, idx).trim();
|
|
146
|
-
const right = s.slice(idx + 2).trim();
|
|
147
|
-
if (right && isScope(left)) return { scope: left, key: right };
|
|
148
|
-
}
|
|
149
|
-
return { scope: s, key: null };
|
|
150
|
-
}
|
|
129
|
+
// `resolveScopeArg` — the validity-gated `<scope>::<key>` split — now lives in
|
|
130
|
+
// the dependency-free `lessons-pure.mjs` alongside `scopeIssue`, the validator it
|
|
131
|
+
// has to consult, and `resolveScopeKeyArgs`, the full positional/flag parser that
|
|
132
|
+
// `link`, `show` and `write` share. Re-exported here so this module stays the one
|
|
133
|
+
// import site for the `link` command and its tests. `lessons-pure.mjs` is itself
|
|
134
|
+
// zero-import, so this file remains free of the util/render/store stack.
|
|
135
|
+
export { resolveScopeArg, resolveScopeKeyArgs, isScopeString, scopeIssue } from './lessons-pure.mjs';
|
|
151
136
|
|
|
152
137
|
// ── Flag → param coercion (pure, shared by the `link` command) ────────────────
|
|
153
138
|
|
package/src/lessons-pure.mjs
CHANGED
|
@@ -63,3 +63,139 @@ export function matchesQuery(entry, query) {
|
|
|
63
63
|
const value = String(entry?.value ?? '').toLowerCase();
|
|
64
64
|
return key.includes(needle) || value.includes(needle);
|
|
65
65
|
}
|
|
66
|
+
|
|
67
|
+
// ── the scope grammar + the scope/key argument parser ─────────────────────────
|
|
68
|
+
//
|
|
69
|
+
// These three live together, in the dependency-free module, because they are ONE
|
|
70
|
+
// decision: `::` is the scope separator AND the `<scope>::<key>` shorthand's
|
|
71
|
+
// separator, so the only thing that can tell the two uses apart is whether a
|
|
72
|
+
// candidate left-hand side is itself a complete valid scope. A parser that does
|
|
73
|
+
// not consult the validator cannot be correct, and a second copy of either half
|
|
74
|
+
// is how `write`/`show` drifted from `link` in the first place.
|
|
75
|
+
|
|
76
|
+
// The canonical scope validator: returns null for a well-formed scope, or a
|
|
77
|
+
// short human reason. The grammar (docs/scope-format.md):
|
|
78
|
+
// global
|
|
79
|
+
// project::{name}
|
|
80
|
+
// repo::{owner}/{repo}
|
|
81
|
+
// branch::{owner}/{repo}::{branch}
|
|
82
|
+
// `::` is RESERVED as the segment separator, so no segment may contain it —
|
|
83
|
+
// that is what makes `resolveScopeArg`'s split decidable. Every scope type
|
|
84
|
+
// enforces it: `project` and `repo` reject a further `::` outright, and
|
|
85
|
+
// `branch` requires exactly two segments after its type.
|
|
86
|
+
export function scopeIssue(scope) {
|
|
87
|
+
const s = String(scope == null ? '' : scope);
|
|
88
|
+
if (!s) return 'empty scope';
|
|
89
|
+
if (s === 'global') return null;
|
|
90
|
+
const m = /^(project|repo|branch)::(.+)$/.exec(s);
|
|
91
|
+
if (!m) {
|
|
92
|
+
// A recognized type followed by a single ':' is the canonical malformed case.
|
|
93
|
+
if (/^(global|project|repo|branch):(?!:)/.test(s)) return 'single `:` separator (use `::`)';
|
|
94
|
+
return 'unrecognized scope type (expected global | project | repo | branch)';
|
|
95
|
+
}
|
|
96
|
+
const [, type, rest] = m;
|
|
97
|
+
if (type === 'project') {
|
|
98
|
+
return rest.includes('::') ? 'project scope takes no further `::` segment' : null;
|
|
99
|
+
}
|
|
100
|
+
if (type === 'repo') {
|
|
101
|
+
// The `::` check comes FIRST and is separate from the `owner/name` shape:
|
|
102
|
+
// `repo::owner/name::my-key` satisfies `[^/]+/[^/]+` (the key rides along in
|
|
103
|
+
// the name segment), so without it a `repo::<owner>/<name>::<key>` shorthand
|
|
104
|
+
// reads as a "valid" scope and the key is swallowed into the repo name.
|
|
105
|
+
if (rest.includes('::')) return 'repo scope takes no further `::` segment';
|
|
106
|
+
return /^[^/]+\/[^/]+$/.test(rest) ? null : 'repo scope must be `owner/name`';
|
|
107
|
+
}
|
|
108
|
+
// branch
|
|
109
|
+
const parts = rest.split('::');
|
|
110
|
+
if (parts.length !== 2 || !/^[^/]+\/[^/]+$/.test(parts[0]) || !parts[1]) {
|
|
111
|
+
return 'branch scope must be `owner/name::branch`';
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Is this string a complete, well-formed scope? The predicate form of
|
|
117
|
+
// `scopeIssue`, and the disambiguator every scope/key parse is gated on.
|
|
118
|
+
export function isScopeString(s) {
|
|
119
|
+
return scopeIssue(s) === null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Split a single `<scope>::<key>` argument, or fall back to treating the whole
|
|
123
|
+
// argument as a scope.
|
|
124
|
+
//
|
|
125
|
+
// The rule: split at the LAST `::` and take it as `<scope>::<key>` ONLY when the
|
|
126
|
+
// left side is itself a COMPLETE valid scope — otherwise the whole arg is the
|
|
127
|
+
// scope. Splitting on the last `::` (not the first) keeps a multi-segment scope
|
|
128
|
+
// whole (`repo::owner/name::key` → scope `repo::owner/name`, key `key`); gating
|
|
129
|
+
// on a valid left side means a bare `repo::owner/name` is NOT mis-split, because
|
|
130
|
+
// its left part `repo` is not a valid scope. This is the fix for the prior
|
|
131
|
+
// first-`::` split, which turned `link repo::acme/widget` into scope="repo" plus
|
|
132
|
+
// a bogus `acme/widget` key — breaking the shorthand for EVERY non-`global`
|
|
133
|
+
// scope. A malformed arg falls through to the scope, never a fabricated key.
|
|
134
|
+
//
|
|
135
|
+
// `isScope` is injected rather than closed over so the module stays trivially
|
|
136
|
+
// testable with a stub predicate; callers pass `isScopeString`.
|
|
137
|
+
export function resolveScopeArg(arg, isScope = isScopeString) {
|
|
138
|
+
const s = typeof arg === 'string' ? arg.trim() : '';
|
|
139
|
+
if (!s) return { scope: null, key: null };
|
|
140
|
+
const idx = s.lastIndexOf('::');
|
|
141
|
+
if (idx !== -1) {
|
|
142
|
+
const left = s.slice(0, idx).trim();
|
|
143
|
+
const right = s.slice(idx + 2).trim();
|
|
144
|
+
if (right && isScope(left)) return { scope: left, key: right };
|
|
145
|
+
}
|
|
146
|
+
return { scope: s, key: null };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Resolve the scope and key a command was given, from its positionals and its
|
|
150
|
+
// `--scope` / `--key` flags. THE single implementation for `write`, `show` and
|
|
151
|
+
// `link`, returning how many positionals it consumed so a command with trailing
|
|
152
|
+
// positionals (`write`'s value) knows where its own arguments start.
|
|
153
|
+
//
|
|
154
|
+
// Precedence, in order:
|
|
155
|
+
// 1. `--scope` / `--key` win outright — an explicit flag is an assertion, so
|
|
156
|
+
// no `::` split is attempted against the half it names. With BOTH flags no
|
|
157
|
+
// positional is read at all, which is the ONLY way to express a key that
|
|
158
|
+
// itself contains `::` (keys are free-form up to 512 chars).
|
|
159
|
+
// 2. A first positional that is ALREADY a complete valid scope is the scope,
|
|
160
|
+
// verbatim, and the second positional (if any) is the key. This is the
|
|
161
|
+
// unambiguous two-positional form: `write repo::owner/name my-key value`.
|
|
162
|
+
// 3. Otherwise the first positional is run through `resolveScopeArg` — the
|
|
163
|
+
// `<scope>::<key>` shorthand. It only splits when that yields a valid
|
|
164
|
+
// scope, so an unparseable argument becomes the scope and the caller's
|
|
165
|
+
// `scopeIssue` check reports it as the malformed scope it is.
|
|
166
|
+
//
|
|
167
|
+
// Checking 2 before 3 is what makes `write global::my-key "value"` (shorthand +
|
|
168
|
+
// value) and `write global my-key "value"` (scope + key + value) BOTH resolve
|
|
169
|
+
// correctly from two-and-three positionals: `global::my-key` is not a valid
|
|
170
|
+
// scope, `global` is.
|
|
171
|
+
//
|
|
172
|
+
// Returns `{ scope, key, consumed }`. `scope` is '' and `key` null when nothing
|
|
173
|
+
// was supplied; the caller decides whether that is a usage error (`write`,
|
|
174
|
+
// `show`) or a default (`link`).
|
|
175
|
+
export function resolveScopeKeyArgs(positionals = [], options = {}) {
|
|
176
|
+
const { scope: scopeFlag, key: keyFlag, isScope = isScopeString } = options;
|
|
177
|
+
const at = (i) => (typeof positionals[i] === 'string' ? positionals[i].trim() : '');
|
|
178
|
+
const fScope = typeof scopeFlag === 'string' ? scopeFlag.trim() : '';
|
|
179
|
+
const fKey = typeof keyFlag === 'string' ? keyFlag.trim() : '';
|
|
180
|
+
|
|
181
|
+
if (fScope && fKey) return { scope: fScope, key: fKey, consumed: 0 };
|
|
182
|
+
if (fScope) {
|
|
183
|
+
const key = at(0);
|
|
184
|
+
return { scope: fScope, key: key || null, consumed: key ? 1 : 0 };
|
|
185
|
+
}
|
|
186
|
+
if (fKey) {
|
|
187
|
+
const scope = at(0);
|
|
188
|
+
return { scope, key: fKey, consumed: scope ? 1 : 0 };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const first = at(0);
|
|
192
|
+
if (!first) return { scope: '', key: null, consumed: 0 };
|
|
193
|
+
|
|
194
|
+
if (isScope(first)) {
|
|
195
|
+
const key = at(1);
|
|
196
|
+
return { scope: first, key: key || null, consumed: key ? 2 : 1 };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const { scope, key } = resolveScopeArg(first, isScope);
|
|
200
|
+
return { scope: scope || '', key, consumed: 1 };
|
|
201
|
+
}
|
package/src/lessons-view.mjs
CHANGED
|
@@ -13,21 +13,19 @@ import { log, heading, status, c } from './util.mjs';
|
|
|
13
13
|
// hot path can share them without pulling in this file's `util`/render stack.
|
|
14
14
|
// Re-exported here so `search`/`tree` (and their tests) keep one import site;
|
|
15
15
|
// `matchesQuery` is also used internally by `filterGroups` below.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
return { scope, key };
|
|
30
|
-
}
|
|
16
|
+
// `scopeIssue` (the canonical scope validator) and the scope/key argument
|
|
17
|
+
// parsers live there too — the `lint` rule below and the `write`/`show`/`link`
|
|
18
|
+
// argument handling must agree on ONE scope grammar, and the parser is only
|
|
19
|
+
// decidable because it can ask the validator.
|
|
20
|
+
import {
|
|
21
|
+
resolvePrecedence,
|
|
22
|
+
matchesQuery,
|
|
23
|
+
scopeIssue,
|
|
24
|
+
isScopeString,
|
|
25
|
+
resolveScopeArg,
|
|
26
|
+
resolveScopeKeyArgs,
|
|
27
|
+
} from './lessons-pure.mjs';
|
|
28
|
+
export { resolvePrecedence, matchesQuery, scopeIssue, isScopeString, resolveScopeArg, resolveScopeKeyArgs };
|
|
31
29
|
|
|
32
30
|
// The scopes that apply to the current directory, most-specific → broadest:
|
|
33
31
|
// project, branch, repo, global. De-duplicated (a repo with no branch scope,
|
|
@@ -254,36 +252,6 @@ export function diffGroups(offline = {}, remote = {}) {
|
|
|
254
252
|
// too terse to carry a durable observation (e.g. "yes", "fixed", "todo").
|
|
255
253
|
export const MIN_VALUE_LEN = 12;
|
|
256
254
|
|
|
257
|
-
// Validate a scope string against the canonical `::`-separated format
|
|
258
|
-
// (`global`, `project::name`, `repo::owner/name`, `branch::owner/name::branch`).
|
|
259
|
-
// Returns null when valid, else a short human reason. The classic failure is a
|
|
260
|
-
// single `:` where `::` is expected (a 400 on the server). Pure — the lint
|
|
261
|
-
// `malformed-scope` rule and any future scope check share this one validator.
|
|
262
|
-
export function scopeIssue(scope) {
|
|
263
|
-
const s = String(scope == null ? '' : scope);
|
|
264
|
-
if (!s) return 'empty scope';
|
|
265
|
-
if (s === 'global') return null;
|
|
266
|
-
const m = /^(project|repo|branch)::(.+)$/.exec(s);
|
|
267
|
-
if (!m) {
|
|
268
|
-
// A recognized type followed by a single ':' is the canonical malformed case.
|
|
269
|
-
if (/^(global|project|repo|branch):(?!:)/.test(s)) return 'single `:` separator (use `::`)';
|
|
270
|
-
return 'unrecognized scope type (expected global | project | repo | branch)';
|
|
271
|
-
}
|
|
272
|
-
const [, type, rest] = m;
|
|
273
|
-
if (type === 'project') {
|
|
274
|
-
return rest.includes('::') ? 'project scope takes no further `::` segment' : null;
|
|
275
|
-
}
|
|
276
|
-
if (type === 'repo') {
|
|
277
|
-
return /^[^/]+\/[^/]+$/.test(rest) ? null : 'repo scope must be `owner/name`';
|
|
278
|
-
}
|
|
279
|
-
// branch
|
|
280
|
-
const parts = rest.split('::');
|
|
281
|
-
if (parts.length !== 2 || !/^[^/]+\/[^/]+$/.test(parts[0]) || !parts[1]) {
|
|
282
|
-
return 'branch scope must be `owner/name::branch`';
|
|
283
|
-
}
|
|
284
|
-
return null;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
255
|
// The lint rule set: each a pure predicate over a normalized entry returning a
|
|
288
256
|
// short reason string when it FIRES, or null when the entry is clean. Kept as
|
|
289
257
|
// discrete named functions so each rule is independently unit-testable and the
|
|
@@ -303,6 +271,41 @@ export const LINT_RULES = {
|
|
|
303
271
|
return v.trim() && v !== v.trim() ? 'value has leading/trailing whitespace' : null;
|
|
304
272
|
},
|
|
305
273
|
'empty-key': (e) => (String(e.key ?? '').trim() ? null : 'key is empty or whitespace-only'),
|
|
274
|
+
// A key carrying a per-sighting identifier (a comment id, a PR/issue number) is
|
|
275
|
+
// unique forever, so it never collides, so the upsert never dedups it, so
|
|
276
|
+
// `seen_count` stays frozen at 1 and the memory can never reach a recurrence
|
|
277
|
+
// threshold — a write-only record. Detection is deliberately conservative:
|
|
278
|
+
// • a run of 6+ digits (a GitHub comment id is ~10; `sha256`, `oauth2`,
|
|
279
|
+
// `wcag22`, and semantic versions are all shorter runs);
|
|
280
|
+
// • a `pr<n>` / `issue<n>` reference — the number joined by nothing, `-`, or
|
|
281
|
+
// `_` — delimited by `:`, `-`, `_`, `/`, or a string boundary, so mid-word
|
|
282
|
+
// digits (`oauth2`) never match.
|
|
283
|
+
// `volatileKeyAllow` is an embedder/test knob mirroring `short-value`'s
|
|
284
|
+
// `minValueLen` precedent — a list of substrings that exempt a key. There is
|
|
285
|
+
// no config key and no per-entry marker.
|
|
286
|
+
'volatile-key': (e, { volatileKeyAllow = [] } = {}) => {
|
|
287
|
+
const key = String(e.key ?? '');
|
|
288
|
+
if (!key.trim()) return null; // an empty key is `empty-key`'s to report.
|
|
289
|
+
// Tolerate a bare string as well as a list, so a caller passing
|
|
290
|
+
// `{ volatileKeyAllow: 'lorekit-231' }` does not silently iterate characters.
|
|
291
|
+
const allowList = Array.isArray(volatileKeyAllow) ? volatileKeyAllow : [volatileKeyAllow];
|
|
292
|
+
for (const allow of allowList) {
|
|
293
|
+
if (allow && key.includes(String(allow))) return null;
|
|
294
|
+
}
|
|
295
|
+
const digitRun = key.match(/\d{6,}/);
|
|
296
|
+
if (digitRun) {
|
|
297
|
+
return `key contains a volatile per-sighting identifier: '${digitRun[0]}' (a run of ${digitRun[0].length} digits)`;
|
|
298
|
+
}
|
|
299
|
+
// Boundary-anchored rather than split-then-match: splitting on `-` would
|
|
300
|
+
// separate `pr` from `231` and `pr-231` would slip through. The reference
|
|
301
|
+
// must start at a boundary (`:`, `-`, `_`, `/`, or the string start) and end
|
|
302
|
+
// at one, so `oauth2`/`sha256`/`wcag22` still never match.
|
|
303
|
+
const reference = key.match(/(?:^|[:\-_/])((?:pr|issue)[-_]?\d+)(?=$|[:\-_/])/i);
|
|
304
|
+
if (reference) {
|
|
305
|
+
return `key contains a volatile per-sighting identifier: '${reference[1]}' (a pr/issue number segment)`;
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
},
|
|
306
309
|
'malformed-scope': (e) => {
|
|
307
310
|
const reason = scopeIssue(e.scope);
|
|
308
311
|
return reason ? `malformed scope: ${reason}` : null;
|
package/src/link.mjs
CHANGED
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
import process from 'node:process';
|
|
18
18
|
import { resolveProjectRoot } from './config.mjs';
|
|
19
19
|
import { deriveScope } from './scope.mjs';
|
|
20
|
-
import { scopeIssue } from './lessons-view.mjs';
|
|
21
20
|
import {
|
|
22
21
|
resolveAppBase,
|
|
23
22
|
buildLoreUrl,
|
|
@@ -26,7 +25,7 @@ import {
|
|
|
26
25
|
parseViewArg,
|
|
27
26
|
parseRangeArg,
|
|
28
27
|
parseTagsArg,
|
|
29
|
-
|
|
28
|
+
resolveScopeKeyArgs,
|
|
30
29
|
surfaceFor,
|
|
31
30
|
} from './deeplink-pure.mjs';
|
|
32
31
|
import { log, err } from './util.mjs';
|
|
@@ -38,26 +37,17 @@ export async function link(args) {
|
|
|
38
37
|
const base = resolveAppBase({ base: args.base, env });
|
|
39
38
|
|
|
40
39
|
// Positionals: link [scope] [key] OR link <scope::key>. args._[0] is the
|
|
41
|
-
// command token ('link' / 'url'), so the
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
} else if (first) {
|
|
53
|
-
// One positional: disambiguate a bare scope from the `<scope>::<key>`
|
|
54
|
-
// shorthand by scope validity, not by a naive first-`::` split — otherwise
|
|
55
|
-
// `link repo::owner/name` (a valid scope) is misread as scope="repo" + a
|
|
56
|
-
// bogus key. `scopeIssue(s) === null` is the canonical "is a valid scope".
|
|
57
|
-
const resolved = resolveScopeArg(first, (s) => scopeIssue(s) === null);
|
|
58
|
-
scope = resolved.scope;
|
|
59
|
-
key = resolved.key;
|
|
60
|
-
}
|
|
40
|
+
// command token ('link' / 'url'), so the command's positionals start at 1.
|
|
41
|
+
// The disambiguation rule this command pioneered — split only when the left
|
|
42
|
+
// side is itself a valid scope — is now the shared `resolveScopeKeyArgs`,
|
|
43
|
+
// which `show` and `write` use too, so the three can no longer drift.
|
|
44
|
+
// `--scope` is applied below (positional wins) rather than through the
|
|
45
|
+
// parser, because for `link` a scope is optional and the flag is a fallback.
|
|
46
|
+
const positionals = args._.slice(1);
|
|
47
|
+
const first = typeof positionals[0] === 'string' ? positionals[0] : '';
|
|
48
|
+
const resolved = resolveScopeKeyArgs(positionals, { key: args.key });
|
|
49
|
+
let scope = resolved.scope || null;
|
|
50
|
+
let key = resolved.key;
|
|
61
51
|
// `--scope` sets the scope when no positional scope was given (consistency
|
|
62
52
|
// with the other read commands); an explicit positional always wins.
|
|
63
53
|
if (!scope && typeof args.scope === 'string' && args.scope) scope = args.scope;
|
package/src/lint.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// `lorekit lint` — flag low-quality lessons across the applicable scopes and
|
|
2
2
|
// both stores. Each finding names the rule it violated (empty/whitespace value,
|
|
3
|
-
// suspiciously short value, untrimmed value, empty key,
|
|
4
|
-
// rules are pure predicates in `lessons-view.mjs` (`LINT_RULES` /
|
|
5
|
-
// each independently unit-tested.
|
|
3
|
+
// suspiciously short value, untrimmed value, empty key, volatile key, malformed
|
|
4
|
+
// scope). The rules are pure predicates in `lessons-view.mjs` (`LINT_RULES` /
|
|
5
|
+
// `lintEntry`), each independently unit-tested.
|
|
6
6
|
//
|
|
7
7
|
// Exit convention: `lint` exits NON-ZERO (1) when any finding exists, so it is
|
|
8
8
|
// usable as a CI gate (`lorekit lint || fail`); a clean run — or a run where the
|
package/src/show.mjs
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
// `lorekit show <scope
|
|
1
|
+
// `lorekit show <scope::key>` — inspect ONE lesson in full: its complete
|
|
2
2
|
// (untruncated) value, scope, key, updated timestamp, tags, and which store(s)
|
|
3
3
|
// it lives in. When the same scope::key exists in BOTH the offline and remote
|
|
4
4
|
// stores — possibly with different values — both are shown and the divergence is
|
|
5
5
|
// flagged.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// show <scope::key> —
|
|
10
|
-
//
|
|
7
|
+
// Positional shapes accepted (see `resolveScopeKeyArgs` in `lessons-pure.mjs`
|
|
8
|
+
// for the one shared, scope-validity-gated disambiguation rule):
|
|
9
|
+
// show <scope::key> — canonical form (the same format `list` prints, so
|
|
10
|
+
// you can copy-paste a key directly from list output)
|
|
11
|
+
// show <scope> <key> — the explicit two-positional form
|
|
12
|
+
// show --scope <s> --key <k>
|
|
13
|
+
// — flags win; the only way to name a key containing `::`
|
|
11
14
|
//
|
|
12
15
|
// Uses each store's real `read({scope, key})` method (both stores expose it),
|
|
13
16
|
// not a filtered `list` — a single-record lookup is what `read` is for, and it
|
|
@@ -18,7 +21,14 @@ import process from 'node:process';
|
|
|
18
21
|
import { resolveProjectRoot } from './config.mjs';
|
|
19
22
|
import { resolveDenies } from './control.mjs';
|
|
20
23
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
21
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
normalizeEntry,
|
|
26
|
+
shortDate,
|
|
27
|
+
describeError,
|
|
28
|
+
recordsDiverge,
|
|
29
|
+
resolveScopeKeyArgs,
|
|
30
|
+
scopeIssue,
|
|
31
|
+
} from './lessons-view.mjs';
|
|
22
32
|
import { resolveAppBase } from './deeplink-pure.mjs';
|
|
23
33
|
import { emitLink } from './link.mjs';
|
|
24
34
|
import { log, err, heading, status, c } from './util.mjs';
|
|
@@ -47,27 +57,40 @@ export async function show(args) {
|
|
|
47
57
|
const env = { ...process.env };
|
|
48
58
|
if (args.store) env.LOREKIT_STORE = args.store;
|
|
49
59
|
|
|
50
|
-
//
|
|
51
|
-
// show <scope
|
|
52
|
-
// show <scope
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
// Positional shapes (all resolved by the shared, validity-gated parser):
|
|
61
|
+
// show <scope::key> — canonical shorthand, mirrors `list` output
|
|
62
|
+
// show <scope> <key> — explicit two-positional form
|
|
63
|
+
// show --scope <s> --key <k> — flags win; the escape hatch for a key
|
|
64
|
+
// containing `::`
|
|
65
|
+
const positionals = args._.slice(1);
|
|
66
|
+
const { scope, key, consumed } = resolveScopeKeyArgs(positionals, {
|
|
67
|
+
scope: args.scope,
|
|
68
|
+
key: args.key,
|
|
69
|
+
});
|
|
70
|
+
// Scope validity is checked FIRST, for the same reason as in `write`: a bad
|
|
71
|
+
// scope is the root cause, and "a key is required" is downstream noise.
|
|
72
|
+
const badScope = scope ? scopeIssue(scope) : null;
|
|
73
|
+
if (badScope) {
|
|
74
|
+
err(`${c.red('Error:')} invalid scope ${c.cyan(scope)} — ${badScope}`);
|
|
75
|
+
err(`Valid scopes: global | project::<name> | repo::<owner>/<name> | branch::<owner>/<name>::<branch>`);
|
|
76
|
+
err(`Run ${c.cyan('lorekit show --help')} for options.`);
|
|
77
|
+
return 1;
|
|
64
78
|
}
|
|
65
79
|
if (!scope || !key) {
|
|
66
|
-
err(`${c.red('Usage:')} lorekit show <scope
|
|
67
|
-
err(` lorekit show <scope
|
|
80
|
+
err(`${c.red('Usage:')} lorekit show <scope::key> [--json]`);
|
|
81
|
+
err(` lorekit show <scope> <key> [--json]`);
|
|
68
82
|
err(`Both a scope and a key are required. Run ${c.cyan('lorekit show --help')} for options.`);
|
|
69
83
|
return 1;
|
|
70
84
|
}
|
|
85
|
+
// `show` consumes every positional it is given — unlike `write`, it has no
|
|
86
|
+
// trailing value — so a leftover one means the caller's mental model differs
|
|
87
|
+
// from what was parsed. Say so instead of silently reading a different key.
|
|
88
|
+
if (positionals.length > consumed) {
|
|
89
|
+
err(`${c.red('Error:')} unexpected argument ${c.cyan(positionals[consumed])}`);
|
|
90
|
+
err(`Parsed scope ${c.cyan(scope)} and key ${c.cyan(key)} from the arguments before it.`);
|
|
91
|
+
err(`Run ${c.cyan('lorekit show --help')} for options.`);
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
71
94
|
|
|
72
95
|
// `--link` short-circuits: print the deep link that opens THIS lesson's detail
|
|
73
96
|
// sheet (`?scope=…&lesson=…`) for the current args, without touching a store.
|
package/src/write.mjs
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
|
-
// `lorekit write <scope
|
|
1
|
+
// `lorekit write <scope::key> [value]` — create or update a memory from the CLI.
|
|
2
2
|
//
|
|
3
3
|
// Two positional shapes are accepted:
|
|
4
|
-
// write <scope
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// write <scope::key> [value] — canonical form: ONE token, exactly the
|
|
5
|
+
// format `list`/`search` print and this
|
|
6
|
+
// command echoes back, so a key can be
|
|
7
|
+
// copy-pasted straight from any output
|
|
8
|
+
// write <scope> <key> [value] — the explicit two-positional form, kept
|
|
9
|
+
// because it is unambiguous by position
|
|
10
|
+
// write --scope <s> --key <k> [value]
|
|
11
|
+
// — flags win outright; the ONLY way to
|
|
12
|
+
// express a key that itself contains `::`
|
|
13
|
+
//
|
|
14
|
+
// Disambiguation is `resolveScopeKeyArgs`'s job (`lessons-pure.mjs`) and is
|
|
15
|
+
// gated on scope VALIDITY, never on a naive `::` split: `write repo::o/n k v`
|
|
16
|
+
// keeps `repo::o/n` whole because it is already a valid scope, while
|
|
17
|
+
// `write global::k v` splits because `global::k` is not. The scope is then
|
|
18
|
+
// validated with `scopeIssue` before any store is touched.
|
|
7
19
|
//
|
|
8
20
|
// When no value is supplied as a positional or via --value, the command reads
|
|
9
21
|
// the full stdin (useful for piping). In all cases the value is required — an
|
|
@@ -52,7 +64,7 @@ import { resolveProjectRoot } from './config.mjs';
|
|
|
52
64
|
import { loadControl, resolveDenies } from './control.mjs';
|
|
53
65
|
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
54
66
|
import { log, err, heading, status, c } from './util.mjs';
|
|
55
|
-
import {
|
|
67
|
+
import { resolveScopeKeyArgs, scopeIssue } from './lessons-view.mjs';
|
|
56
68
|
import { deriveOrigin, mergeOrigin } from './origin.mjs';
|
|
57
69
|
import { parseTtlDays, resolveDefaultTtlDays } from './store/ttl.mjs';
|
|
58
70
|
|
|
@@ -72,33 +84,67 @@ export async function write(args) {
|
|
|
72
84
|
const env = { ...process.env };
|
|
73
85
|
if (args.store) env.LOREKIT_STORE = args.store;
|
|
74
86
|
|
|
75
|
-
// ── Parse positionals
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
87
|
+
// ── Parse positionals ─────────────────────────────────────────────────────
|
|
88
|
+
// The shared `resolveScopeKeyArgs` decides between the shorthand and the
|
|
89
|
+
// two-positional form (and honours --scope/--key), and reports how many
|
|
90
|
+
// positionals it took so the VALUE is read from the right index in both:
|
|
91
|
+
// write <scope::key> <value> → consumed 1, value at _[2]
|
|
92
|
+
// write <scope> <key> <value> → consumed 2, value at _[3]
|
|
93
|
+
// `args._[0]` is the command token, so the command's own positionals start
|
|
94
|
+
// at index 1.
|
|
95
|
+
const positionals = args._.slice(1);
|
|
96
|
+
const { scope, key, consumed } = resolveScopeKeyArgs(positionals, {
|
|
97
|
+
scope: args.scope,
|
|
98
|
+
key: args.key,
|
|
99
|
+
});
|
|
100
|
+
const positionalValue =
|
|
101
|
+
typeof positionals[consumed] === 'string' ? positionals[consumed] : undefined;
|
|
82
102
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
103
|
+
// Validate the scope BEFORE the missing-key check, the value, or any store.
|
|
104
|
+
// Order matters: a bad scope is the ROOT cause and every other complaint is
|
|
105
|
+
// downstream noise. `write foo "asd"` parses as scope `foo` + key `asd` with
|
|
106
|
+
// no value left, and used to report "a non-empty value is required" — three
|
|
107
|
+
// steps removed from the actual mistake. The offline store accepts any
|
|
108
|
+
// string, so this is also the only thing standing between a local write and
|
|
109
|
+
// a scope the hosted API would reject with a 400. It is a PARTIAL gate, not
|
|
110
|
+
// an equivalent one: `scopeIssue` checks the segment SHAPE (`[^/]+/[^/]+`)
|
|
111
|
+
// where `packages/mcp-core/src/scope.ts` additionally restricts the CHARSET
|
|
112
|
+
// (`[\w.-]+/[\w.-]+`), so `repo::a b/c,d` and `branch::o/r::a",x` pass here
|
|
113
|
+
// and still 400 remotely. Tightening the CLI to match would also change what
|
|
114
|
+
// `lint`'s malformed-scope rule flags in existing offline stores, so it is a
|
|
115
|
+
// deliberate follow-up rather than a silent widening of this check.
|
|
116
|
+
const badScope = scope ? scopeIssue(scope) : null;
|
|
117
|
+
if (badScope) {
|
|
118
|
+
err(`${c.red('Error:')} invalid scope ${c.cyan(scope)} — ${badScope}`);
|
|
119
|
+
err(`Valid scopes: global | project::<name> | repo::<owner>/<name> | branch::<owner>/<name>::<branch>`);
|
|
120
|
+
err(`Run ${c.cyan('lorekit write --help')} for options.`);
|
|
121
|
+
return 1;
|
|
93
122
|
}
|
|
94
123
|
|
|
95
124
|
if (!scope || !key) {
|
|
96
|
-
err(`${c.red('Usage:')} lorekit write <scope> <
|
|
97
|
-
err(` lorekit write <scope
|
|
125
|
+
err(`${c.red('Usage:')} lorekit write <scope::key> <value> [options]`);
|
|
126
|
+
err(` lorekit write <scope> <key> <value> [options]`);
|
|
98
127
|
err(`Both a scope and a key are required. Run ${c.cyan('lorekit write --help')} for options.`);
|
|
99
128
|
return 1;
|
|
100
129
|
}
|
|
101
130
|
|
|
131
|
+
// Leftover positionals are an error, exactly as in `show` — the difference is
|
|
132
|
+
// only in how many this command legitimately consumes: the parser's `consumed`
|
|
133
|
+
// plus ONE for the value, and zero for the value when `--value` already named
|
|
134
|
+
// it. Without this, `--scope` (which makes the parser take one fewer
|
|
135
|
+
// positional) silently shifted everything left: `write global my-key "v"
|
|
136
|
+
// --scope global` resolved scope `global`, key `global`, value `my-key` — it
|
|
137
|
+
// stored the wrong key, dropped the value, and exited 0. A wrong write that
|
|
138
|
+
// reports success is worse than any usage error, so the leftover is named.
|
|
139
|
+
const valueSlots = typeof args.value === 'string' ? 0 : 1;
|
|
140
|
+
if (positionals.length > consumed + valueSlots) {
|
|
141
|
+
const stray = positionals[consumed + valueSlots];
|
|
142
|
+
err(`${c.red('Error:')} unexpected argument ${c.cyan(stray)}`);
|
|
143
|
+
err(`Parsed scope ${c.cyan(scope)} and key ${c.cyan(key)} from the arguments before it.`);
|
|
144
|
+
err(`Run ${c.cyan('lorekit write --help')} for options.`);
|
|
145
|
+
return 1;
|
|
146
|
+
}
|
|
147
|
+
|
|
102
148
|
// ── Resolve value: flag → positional → stdin ───────────────────────────────
|
|
103
149
|
let value;
|
|
104
150
|
if (typeof args.value === 'string') {
|