@lorekit/cli 1.33.2 → 1.33.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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 prefer-guard-clauses
235
- lorekit show project::widget build-flags --json
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 —
@@ -724,7 +754,8 @@ also returns their headroom against the plan's memory cap.
724
754
  | `--force` | Overwrite existing skill files (`install`) |
725
755
  | `--deep` | Write/read/delete round-trip (`doctor`) |
726
756
  | `--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 |
757
+ | `--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 |
758
+ | `--key <key>` | Name the key outright (`show` / `write` / `link`) — the way to address a key that itself contains `::` |
728
759
  | `--link` | Print the equivalent dashboard deep-link URL instead of running (`show` / `search` / `list` / `tree`) |
729
760
  | `--base <url>` | Dashboard base URL for deep links (`link` / `--link`; else `LOREKIT_APP_URL`, default `https://lorekit.io`) |
730
761
  | `--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). Accepts show <scope> <key> or the combined
62
- show <scope::key> shorthand (copy-paste directly from list output).
63
- --json.
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.
@@ -113,6 +112,9 @@ ${c.bold('Options')}
113
112
  --store <path> Local project-tier store directory (default: .lorekit)
114
113
  --json Machine-readable output (list / search / show / stats / scopes / diff / tree / lint / dedupe / link)
115
114
  --scope <scope> Restrict to a single scope; a substring filter for scopes (list / search / stats / scopes / diff / tree / lint / dedupe / link)
115
+ On show / write it NAMES the scope, overriding the positional
116
+ --key <key> Name the key explicitly (show / write / link) — the way to
117
+ address a key that itself contains \`::\`
116
118
  --link Print the equivalent dashboard deep-link URL instead of running (show / search / list / tree)
117
119
  --base <url> Dashboard base URL for deep links (link / --link; else LOREKIT_APP_URL, default https://lorekit.io)
118
120
  --threshold <0..1> Duplicate-similarity cutoff (dedupe; default 0.8)
@@ -297,16 +299,27 @@ ${c.bold('Examples')}
297
299
  write: `${c.bold('lorekit write')} — create or update a memory from the CLI
298
300
 
299
301
  ${c.bold('Usage')}
300
- npx @lorekit/cli write <scope> <key> <value> [options]
301
302
  npx @lorekit/cli write <scope::key> <value> [options]
302
- echo "value" | npx @lorekit/cli write <scope> <key> [options]
303
+ npx @lorekit/cli write <scope> <key> <value> [options]
304
+ npx @lorekit/cli write --scope <scope> --key <key> <value> [options]
305
+ echo "value" | npx @lorekit/cli write <scope::key> [options]
303
306
 
304
307
  Creates or updates a memory (upsert — overwrites if the key exists). Value can
305
308
  be a positional, --value, or piped stdin. Writes to the remote store when
306
309
  configured, falling back to local.
307
310
 
311
+ The single-token <scope::key> form is canonical — it is the format this command
312
+ echoes back and the one list/search print, so it round-trips. It is split at the
313
+ LAST \`::\`, and only when the left side is itself a complete valid scope, so a
314
+ multi-segment scope stays whole: write repo::acme/widget build-flags "..." keeps
315
+ repo::acme/widget as the scope. The scope is validated before anything is
316
+ written, so a typo is rejected rather than stored. Pass --scope/--key to address
317
+ a key that itself contains \`::\`.
318
+
308
319
  ${c.bold('Options')}
309
320
  -d, --dir <path> Target project root (default: current directory)
321
+ --scope <scope> Name the scope explicitly (instead of the positional)
322
+ --key <key> Name the key explicitly — use for a key containing \`::\`
310
323
  --value <text> Memory value (alternative to positional / stdin)
311
324
  --tags <a,b,c> Comma-separated tags (default: none)
312
325
  --source-agent <n> Source agent name to record (default: none)
@@ -327,35 +340,50 @@ ${c.bold('Options')}
327
340
  --store <path> Local project-tier store directory (default: .lorekit)
328
341
 
329
342
  ${c.bold('Examples')}
330
- npx @lorekit/cli write global my-key "Always prefer guard clauses"
331
343
  npx @lorekit/cli write global::my-key "Always prefer guard clauses"
332
- cat notes.md | npx @lorekit/cli write global my-key --tags "style,aw"
333
- npx @lorekit/cli write global my-key "body" --local
334
- npx @lorekit/cli write global my-key "body" --ttl-days 30 --remote
344
+ npx @lorekit/cli write repo::acme/widget::build-flags "Use --release in CI"
345
+ npx @lorekit/cli write global my-key "Always prefer guard clauses"
346
+ cat notes.md | npx @lorekit/cli write global::my-key --tags "style,aw"
347
+ npx @lorekit/cli write global::my-key "body" --local
348
+ npx @lorekit/cli write global::my-key "body" --ttl-days 30 --remote
349
+ npx @lorekit/cli write --scope global --key "loop::aw-lessons" "body"
335
350
  `,
336
351
  show: `${c.bold('lorekit show')} — inspect one memory in full
337
352
 
338
353
  ${c.bold('Usage')}
354
+ npx @lorekit/cli show <scope::key> [options]
339
355
  npx @lorekit/cli show <scope> <key> [options]
356
+ npx @lorekit/cli show --scope <scope> --key <key> [options]
340
357
 
341
358
  Prints one memory's complete (untruncated) value, scope, key, updated date, tags,
342
359
  and which store(s) it lives in. If the same scope::key exists in both the offline
343
360
  and remote stores, both are shown and any divergence in their values is flagged.
344
361
  Exits non-zero when the key is found in neither readable store.
345
362
 
363
+ The single-token <scope::key> form is canonical — it is exactly what list and
364
+ search print, so a key can be copy-pasted straight out of their output. It is
365
+ split at the LAST \`::\`, and only when the left side is itself a complete valid
366
+ scope, so a multi-segment scope stays whole: repo::acme/widget::build-flags is
367
+ scope repo::acme/widget, key build-flags. Pass --scope/--key to address a key
368
+ that itself contains \`::\`.
369
+
346
370
  ${c.bold('Options')}
347
371
  -d, --dir <path> Target project root (default: current directory)
348
372
  --json Machine-readable output (the full normalized record(s))
349
373
  -e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
350
374
  -t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
351
375
  --store <path> Local project-tier store directory (default: .lorekit)
376
+ --scope <scope> Name the scope explicitly (instead of the positional)
377
+ --key <key> Name the key explicitly — use for a key containing \`::\`
352
378
  --link Print this memory's dashboard deep-link URL instead of reading (with --base / --json)
353
379
 
354
380
  ${c.bold('Examples')}
355
- npx @lorekit/cli show global prefer-guard-clauses
356
381
  npx @lorekit/cli show global::prefer-guard-clauses
382
+ npx @lorekit/cli show repo::acme/widget::build-flags --json
383
+ npx @lorekit/cli show global prefer-guard-clauses
357
384
  npx @lorekit/cli show project::widget build-flags --json
358
- npx @lorekit/cli show global prefer-guard-clauses --link
385
+ npx @lorekit/cli show --scope global --key "loop::aw-lessons"
386
+ npx @lorekit/cli show global::prefer-guard-clauses --link
359
387
  `,
360
388
  stats: `${c.bold('lorekit stats')} — count the applicable memories per scope and per store
361
389
 
@@ -527,6 +555,8 @@ the intended view — a raw ${c.dim('?scope=global')} would silently mean "all s
527
555
  ${c.bold('Options')}
528
556
  -d, --dir <path> Target project root (default: current directory)
529
557
  --scope <scope> Scope to link to (when no positional scope is given)
558
+ --key <key> Name the key explicitly — the way to link to a key
559
+ that itself contains \`::\`
530
560
  --q <text> Pre-fill the Explorer search box
531
561
  --owner <o> Ownership filter: all | personal | <orgId>
532
562
  --tags <a,b,c> Label filter (AND across labels); comma-separated or a JSON array
@@ -599,7 +629,7 @@ ${c.bold('Options')}
599
629
  const KNOWN_FLAGS = [
600
630
  'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
601
631
  'from', 'to', 'apply', 'yes', 'hooks', 'no-hooks', 'force', 'deep', 'adapter',
602
- 'event', 'json', 'scope', 'threshold', 'help', 'version', 'telemetry',
632
+ 'event', 'json', 'scope', 'key', 'threshold', 'help', 'version', 'telemetry',
603
633
  'value', 'tags', 'source-agent', 'trigger', 'kind', 'host', 'ttl-days', 'clear-ttl', 'org', 'remote', 'local',
604
634
  'link', 'base', 'q', 'owner', 'range', 'view', 'archived',
605
635
  'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.33.2",
3
+ "version": "1.33.4",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,8 +1,11 @@
1
1
  // Dependency-free deep-link builder for LoreKit dashboard URLs.
2
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.
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
- // Resolve a SINGLE positional `link` argument into a { scope, key } pair,
127
- // disambiguating a bare scope from the `<scope>::<key>` shorthand. `isScope` is
128
- // an injected validity predicate (the caller passes a `scopeIssue`-based check),
129
- // keeping this module zero-import.
130
- //
131
- // The rule: split at the LAST `::` and take it as `<scope>::<key>` ONLY when the
132
- // left side is itself a COMPLETE valid scope — otherwise the whole arg is the
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
 
@@ -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
+ }
@@ -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
- import { resolvePrecedence, matchesQuery } from './lessons-pure.mjs';
17
- export { resolvePrecedence, matchesQuery };
18
-
19
- // Parse a combined `scope::key` string into { scope, key }, or return null when
20
- // the input contains no `::`. Uses the FIRST occurrence of `::` as the separator
21
- // so nested scopes like `branch::owner/repo::feat/x` are handled correctly.
22
- // Exported so `show`, `write`, and any future commands share one implementation.
23
- export function parseScopeKey(s) {
24
- const idx = s.indexOf('::');
25
- if (idx === -1) return null;
26
- const scope = s.slice(0, idx).trim();
27
- const key = s.slice(idx + 2).trim();
28
- if (!scope || !key) return null;
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
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
- resolveScopeArg,
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 first argument is args._[1].
42
- const first = typeof args._[1] === 'string' ? args._[1] : '';
43
- const second = typeof args._[2] === 'string' ? args._[2] : '';
44
- let scope = null;
45
- let key = null;
46
- if (first && second) {
47
- // Two positionals the first IS the scope (even one containing `::`, like
48
- // `repo::owner/name`); the second is the key. The `scope::key` shorthand is
49
- // only consulted for a single positional, below.
50
- scope = first;
51
- key = second;
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/mcp.mjs CHANGED
@@ -138,6 +138,26 @@ export function normalizeCorrelationId(raw) {
138
138
  return /^[A-Za-z0-9_\-./:#@]+$/.test(t) ? t : null;
139
139
  }
140
140
 
141
+ /**
142
+ * Normalise a deployment-environment marker restFetch attaches as
143
+ * X-LoreKit-Deployment-Environment when DEPLOYMENT_ENVIRONMENT (or
144
+ * OTEL_DEPLOYMENT_ENVIRONMENT) is set. It lets a smoke/test run tell the edge to
145
+ * report `deployment.environment.name` for that request — the edge honours only
146
+ * the synthetic `test` value, so real traffic (no env set) is never tagged and a
147
+ * caller can never relabel itself as another real environment. This is the SAME
148
+ * value `resolveDeploymentEnvironment` (packages/cli/src/telemetry.mjs) puts on
149
+ * the CLI's own resource, so one `DEPLOYMENT_ENVIRONMENT=test` marks both the CLI
150
+ * span and its downstream edge spans. Bounded + charset-restricted (fail-safe: a
151
+ * bad value is not sent). Zero-dep, so the small regex is duplicated
152
+ * intentionally, like `normalizeCorrelationId`.
153
+ */
154
+ export function normalizeRunEnvironment(raw) {
155
+ if (typeof raw !== 'string') return null;
156
+ const t = raw.trim();
157
+ if (!t || t.length > 64) return null;
158
+ return /^[A-Za-z0-9_.\-:]+$/.test(t) ? t : null;
159
+ }
160
+
141
161
  export async function restFetch(baseUrl, token, path, { method = 'GET', body, timeoutMs = 10000, traceparent } = {}) {
142
162
  const controller = new AbortController();
143
163
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -148,12 +168,21 @@ export async function restFetch(baseUrl, token, path, { method = 'GET', body, ti
148
168
  // /memories/usage?correlation_id=… can report "usage for this PR". Absent env
149
169
  // ⇒ no header ⇒ existing behaviour unchanged.
150
170
  const correlationId = normalizeCorrelationId(process.env.LOREKIT_CORRELATION_ID);
171
+ // Opt-in test-run marker: when DEPLOYMENT_ENVIRONMENT is set (a deploy/CI
172
+ // smoke sets it to `test`), tell the edge to report that
173
+ // `deployment.environment.name` for this request so Dash0 can filter synthetic
174
+ // smoke traffic apart from real usage. Absent env ⇒ no header ⇒ existing
175
+ // behaviour unchanged. The edge honours only `test` (see otel.ts).
176
+ const runEnv = normalizeRunEnvironment(
177
+ process.env.DEPLOYMENT_ENVIRONMENT ?? process.env.OTEL_DEPLOYMENT_ENVIRONMENT,
178
+ );
151
179
  const headers = {
152
180
  accept: 'application/json',
153
181
  ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
154
182
  ...(token ? { authorization: `Bearer ${token}` } : {}),
155
183
  ...(traceparent ? { traceparent } : {}),
156
184
  ...(correlationId ? { 'x-lorekit-correlation-id': correlationId } : {}),
185
+ ...(runEnv ? { 'x-lorekit-deployment-environment': runEnv } : {}),
157
186
  // Name the calling surface so usage analytics can tell a CLI read from a
158
187
  // dashboard one. Not cosmetic: `GET /memories/read-activity` EXCLUDES the
159
188
  // `dashboard` client (a human browsing lore is not consuming it), so a
package/src/show.mjs CHANGED
@@ -1,13 +1,16 @@
1
- // `lorekit show <scope> <key>` — inspect ONE lesson in full: its complete
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
- // Two positional shapes are accepted:
8
- // show <scope> <key> — classic two-positional form
9
- // show <scope::key> — combined shorthand (the same format `list` prints,
10
- // so you can copy-paste a key directly from list output)
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 { normalizeEntry, shortDate, describeError, recordsDiverge, parseScopeKey } from './lessons-view.mjs';
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
- // Two positional shapes are accepted:
51
- // show <scope> <key> classic two-positional form (backward-compatible)
52
- // show <scope::key> combined shorthand mirroring `list` output format
53
- let scope, key;
54
- const first = typeof args._[1] === 'string' ? args._[1] : '';
55
- const parsed = parseScopeKey(first);
56
- if (parsed) {
57
- // Combined scope::key — e.g. `show global::claude-mcp-registration-can-hang`
58
- scope = parsed.scope;
59
- key = parsed.key;
60
- } else {
61
- // Classic two-positional form e.g. `show global claude-mcp-registration-can-hang`
62
- scope = first;
63
- key = typeof args._[2] === 'string' ? args._[2] : '';
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> <key> [--json]`);
67
- err(` lorekit show <scope::key> [--json]`);
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> <key> [value]` — create or update a memory from the CLI.
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> <key> [value] classic two-positional form
5
- // write <scope::key> [value] — combined shorthand (the same format `list`
6
- // prints, so you can copy-paste a key directly)
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 { parseScopeKey } from './lessons-view.mjs';
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: two forms ──────────────────────────────────────────
76
- // Form A: `write scope key [value]` → _[1]=scope, _[2]=key, _[3]=value?
77
- // Form B: `write scope::key [value]` → _[1]='scope::key', _[2]=value?
78
- let scope, key, positionalValue;
79
-
80
- const first = typeof args._[1] === 'string' ? args._[1] : '';
81
- const parsed = parseScopeKey(first);
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
- if (parsed) {
84
- // Form B: combined scope::key
85
- scope = parsed.scope;
86
- key = parsed.key;
87
- positionalValue = typeof args._[2] === 'string' ? args._[2] : undefined;
88
- } else {
89
- // Form A: separate scope and key
90
- scope = first;
91
- key = typeof args._[2] === 'string' ? args._[2] : '';
92
- positionalValue = typeof args._[3] === 'string' ? args._[3] : undefined;
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> <key> [value] [options]`);
97
- err(` lorekit write <scope::key> [value] [options]`);
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') {