@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 +34 -3
- package/bin/lorekit.mjs +43 -13
- package/package.json +1 -1
- package/src/deeplink-pure.mjs +13 -28
- package/src/lessons-pure.mjs +136 -0
- package/src/lessons-view.mjs +13 -45
- package/src/link.mjs +12 -22
- package/src/mcp.mjs +29 -0
- 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 —
|
|
@@ -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).
|
|
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.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
333
|
-
npx @lorekit/cli write global my-key "
|
|
334
|
-
npx @lorekit/cli write global
|
|
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
|
|
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
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
|
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/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
|
|
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') {
|