@lorekit/cli 1.7.1 → 1.9.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 +72 -2
- package/bin/lorekit.mjs +100 -4
- package/package.json +1 -1
- package/src/lessons-view.mjs +0 -0
- package/src/list.mjs +123 -0
- package/src/search.mjs +132 -0
- package/src/show.mjs +167 -0
- package/src/stores.mjs +49 -0
- package/src/telemetry.mjs +4 -4
package/README.md
CHANGED
|
@@ -106,6 +106,73 @@ lorekit doctor --deep # also does a write → read → delete round-trip (ne
|
|
|
106
106
|
|
|
107
107
|
Exit code is non-zero if any check fails, so it fits CI gates.
|
|
108
108
|
|
|
109
|
+
### `lorekit list` (alias `ls`)
|
|
110
|
+
|
|
111
|
+
Shows the lessons that apply to **where you are** — the scopes `deriveScope`
|
|
112
|
+
resolves for the current directory (`project::{name}`, `branch::…`, `repo::…`,
|
|
113
|
+
and `global`) — split into two clearly-labelled sections so you can see where
|
|
114
|
+
each lesson lives:
|
|
115
|
+
|
|
116
|
+
- **Offline** — the local two-tier store (`.lorekit/` in the repo + `~/.lorekit/`).
|
|
117
|
+
- **Remote** — the hosted MCP store. When no token/endpoint is configured this
|
|
118
|
+
section is a short note on how to set it up; it is **never an error** (the
|
|
119
|
+
command still exits 0 and shows your offline lessons). A network/server error
|
|
120
|
+
is likewise a per-scope warning, not a crash.
|
|
121
|
+
|
|
122
|
+
It is read-only — it never writes, deletes, or reveals archived lessons — and it
|
|
123
|
+
independently queries both stores regardless of the resolved memory mode. A
|
|
124
|
+
`LOREKIT_DENY=remote` (or `local`) ceiling suppresses that section, honoring the
|
|
125
|
+
same deny-wins privacy invariant the control model enforces for agents.
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
lorekit list # both sections, grouped by scope
|
|
129
|
+
lorekit ls # same, via the alias
|
|
130
|
+
lorekit list --scope global # narrow to a single scope
|
|
131
|
+
lorekit list --json # structured { offline, remote } payload for scripts
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`--endpoint` / `--token` override the remote connection; `--store` overrides the
|
|
135
|
+
local project-tier directory.
|
|
136
|
+
|
|
137
|
+
### `lorekit search` (alias `grep`)
|
|
138
|
+
|
|
139
|
+
Full-text search across the same applicable scopes and the same two stores as
|
|
140
|
+
`list`, rendered in the same Offline / Remote split. A lesson matches when the
|
|
141
|
+
query appears — **case-insensitively, as a literal substring** — in its **key or
|
|
142
|
+
value**:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
lorekit search sandbox # both sections, only the matching lessons
|
|
146
|
+
lorekit grep "flaky test" # same, via the alias
|
|
147
|
+
lorekit search migration --scope global
|
|
148
|
+
lorekit search build --json # { query, offline, remote } for scripts
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The query is matched with a plain substring check, **never compiled as a regex**,
|
|
152
|
+
so a term full of metacharacters (`a.*(b)`) matches those characters verbatim —
|
|
153
|
+
no injection, no surprises. It is read-only, hides archived lessons, and degrades
|
|
154
|
+
the remote section gracefully (an unconfigured remote is a note, not an error;
|
|
155
|
+
the command still exits 0). An empty query is a usage error; no matches prints a
|
|
156
|
+
friendly "no lessons match" note (exit 0). `--scope` narrows to one scope;
|
|
157
|
+
`--endpoint` / `--token` / `--store` behave as in `list`.
|
|
158
|
+
|
|
159
|
+
### `lorekit show`
|
|
160
|
+
|
|
161
|
+
Inspect **one** lesson in full — its complete, **untruncated** value plus scope,
|
|
162
|
+
key, updated date, tags, and which store(s) it lives in:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
lorekit show global prefer-guard-clauses
|
|
166
|
+
lorekit show project::widget build-flags --json
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
If the same `scope::key` exists in **both** the offline and remote stores —
|
|
170
|
+
possibly with different values — both are shown and any divergence is flagged.
|
|
171
|
+
When it lives in only one store, that copy is shown and the other is noted as
|
|
172
|
+
missing. It exits **non-zero** when the key is found in no readable store, so it
|
|
173
|
+
fits scripts. `--json` emits the full normalized record(s) and which store each
|
|
174
|
+
came from. Both a scope and a key are required (else a usage error).
|
|
175
|
+
|
|
109
176
|
### `lorekit hook`
|
|
110
177
|
|
|
111
178
|
The **shared hook engine** behind the Claude Code / Cursor / Codex plugins.
|
|
@@ -288,6 +355,8 @@ active deny constraints.
|
|
|
288
355
|
| `--no-hooks` | Skip wiring the lifecycle hooks; skill + MCP only (`install`) |
|
|
289
356
|
| `--force` | Overwrite existing skill files (`install`) |
|
|
290
357
|
| `--deep` | Write/read/delete round-trip (`doctor`) |
|
|
358
|
+
| `--json` | Machine-readable output (`list` / `search` / `show`) |
|
|
359
|
+
| `--scope <scope>` | Restrict to a single scope (`list` / `search`; default: all applicable) |
|
|
291
360
|
| `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
|
|
292
361
|
| `--event <name>` | Host hook event for `hook` (else read from the stdin payload) |
|
|
293
362
|
| `-h, --help` | Help |
|
|
@@ -372,8 +441,9 @@ forever. This reduces manual validation to a single capture pass per tool.
|
|
|
372
441
|
|
|
373
442
|
## Usage telemetry
|
|
374
443
|
|
|
375
|
-
The human-facing commands (`install`, `uninstall`, `doctor`, `
|
|
376
|
-
|
|
444
|
+
The human-facing commands (`install`, `uninstall`, `doctor`, `list`, `search`,
|
|
445
|
+
`show`, `migrate`)
|
|
446
|
+
emit one OpenTelemetry span + one counter point per run so the maintainers can see which
|
|
377
447
|
commands people use. It is zero-dependency (OTLP/JSON over `fetch`, no SDK) and
|
|
378
448
|
deliberately narrow — it carries only the command name, a bounded set of boolean
|
|
379
449
|
flags (`--global`, `--deep`, …), the CLI/runtime/OS identity, and the outcome.
|
package/bin/lorekit.mjs
CHANGED
|
@@ -6,6 +6,9 @@ import { parseArgs, log, err, c } from '../src/util.mjs';
|
|
|
6
6
|
import { install } from '../src/install.mjs';
|
|
7
7
|
import { uninstall } from '../src/uninstall.mjs';
|
|
8
8
|
import { doctor } from '../src/doctor.mjs';
|
|
9
|
+
import { list } from '../src/list.mjs';
|
|
10
|
+
import { search } from '../src/search.mjs';
|
|
11
|
+
import { show } from '../src/show.mjs';
|
|
9
12
|
import { hook } from '../src/hook.mjs';
|
|
10
13
|
import { migrate } from '../src/migrate.mjs';
|
|
11
14
|
import { mcpServer } from '../src/mcp-server.mjs';
|
|
@@ -35,6 +38,16 @@ ${c.bold('Commands')}
|
|
|
35
38
|
other servers, hooks, and settings are left untouched. Prompts
|
|
36
39
|
project vs global; --project / --global choose non-interactively.
|
|
37
40
|
doctor Verify the skill install, MCP connectivity, token, and scope.
|
|
41
|
+
list (ls) List the lessons that apply to the current directory, split into
|
|
42
|
+
an Offline section (local .lorekit/ + ~/.lorekit/) and a Remote
|
|
43
|
+
section (hosted MCP). Groups by scope (project/branch/repo/global).
|
|
44
|
+
--json for scripting, --scope <s> to narrow.
|
|
45
|
+
search Full-text search the applicable lessons across both stores and all
|
|
46
|
+
(grep) scopes (case-insensitive, literal substring over key + value),
|
|
47
|
+
rendered in the same Offline/Remote split. --json, --scope <s>.
|
|
48
|
+
show Inspect one lesson in full: its complete value, scope, key, updated
|
|
49
|
+
date, tags, and which store(s) it lives in (noting any divergence
|
|
50
|
+
when it is in both). --json. Usage: show <scope> <key>.
|
|
38
51
|
migrate Relocate a LoreKit-format local store into the current layout.
|
|
39
52
|
Dry-run by default; pass --yes to apply. Idempotent.
|
|
40
53
|
hook Hook engine for Claude Code / Cursor / Codex. Reads the host's
|
|
@@ -53,6 +66,8 @@ ${c.bold('Options')}
|
|
|
53
66
|
-t, --token <token> LoreKit token (lk_rw_* to allow writes, lk_ro_* read-only)
|
|
54
67
|
--mode <mode> Memory mode: off | local | remote (doctor override)
|
|
55
68
|
--store <path> Local project-tier store directory (default: .lorekit)
|
|
69
|
+
--json Machine-readable output (list / search / show)
|
|
70
|
+
--scope <scope> Restrict to a single scope (list / search)
|
|
56
71
|
--from <path> Source store to migrate from (migrate)
|
|
57
72
|
--to <tier> Migration destination tier: home | project (migrate;
|
|
58
73
|
default routes each entry by scope)
|
|
@@ -156,6 +171,75 @@ ${c.bold('Examples')}
|
|
|
156
171
|
npx @lorekit/cli doctor
|
|
157
172
|
npx @lorekit/cli doctor --deep
|
|
158
173
|
npx @lorekit/cli doctor --mode local
|
|
174
|
+
`,
|
|
175
|
+
list: `${c.bold('lorekit list')} — list the lessons that apply to the current directory ${c.dim('(alias: ls)')}
|
|
176
|
+
|
|
177
|
+
${c.bold('Usage')}
|
|
178
|
+
npx @lorekit/cli list [options]
|
|
179
|
+
|
|
180
|
+
Shows the lessons for the scopes that resolve for the current directory
|
|
181
|
+
(project/branch/repo/global), split into an Offline section (the local
|
|
182
|
+
.lorekit/ + ~/.lorekit/ two-tier store) and a Remote section (the hosted MCP
|
|
183
|
+
server). When no remote token/endpoint is configured the Remote section is a
|
|
184
|
+
short note on how to set it up — never an error.
|
|
185
|
+
|
|
186
|
+
${c.bold('Options')}
|
|
187
|
+
-d, --dir <path> Target project root (default: current directory)
|
|
188
|
+
--scope <scope> Restrict to a single scope (default: all applicable)
|
|
189
|
+
--json Machine-readable output
|
|
190
|
+
-e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
|
|
191
|
+
-t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
|
|
192
|
+
--store <path> Local project-tier store directory (default: .lorekit)
|
|
193
|
+
|
|
194
|
+
${c.bold('Examples')}
|
|
195
|
+
npx @lorekit/cli list
|
|
196
|
+
npx @lorekit/cli list --json
|
|
197
|
+
npx @lorekit/cli list --scope global
|
|
198
|
+
`,
|
|
199
|
+
search: `${c.bold('lorekit search')} — full-text search the applicable lessons ${c.dim('(alias: grep)')}
|
|
200
|
+
|
|
201
|
+
${c.bold('Usage')}
|
|
202
|
+
npx @lorekit/cli search <query> [options]
|
|
203
|
+
|
|
204
|
+
Searches every lesson for the current directory's scopes (project/branch/repo/
|
|
205
|
+
global) across both stores, matching the query case-insensitively as a LITERAL
|
|
206
|
+
substring of a lesson's key or value (regex metacharacters are matched verbatim,
|
|
207
|
+
never interpreted). Results are shown in the same Offline / Remote split as
|
|
208
|
+
\`list\`; an unconfigured remote degrades to a short note, never an error.
|
|
209
|
+
|
|
210
|
+
${c.bold('Options')}
|
|
211
|
+
-d, --dir <path> Target project root (default: current directory)
|
|
212
|
+
--scope <scope> Restrict to a single scope (default: all applicable)
|
|
213
|
+
--json Machine-readable output
|
|
214
|
+
-e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
|
|
215
|
+
-t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
|
|
216
|
+
--store <path> Local project-tier store directory (default: .lorekit)
|
|
217
|
+
|
|
218
|
+
${c.bold('Examples')}
|
|
219
|
+
npx @lorekit/cli search sandbox
|
|
220
|
+
npx @lorekit/cli grep "flaky test" --json
|
|
221
|
+
npx @lorekit/cli search migration --scope global
|
|
222
|
+
`,
|
|
223
|
+
show: `${c.bold('lorekit show')} — inspect one lesson in full
|
|
224
|
+
|
|
225
|
+
${c.bold('Usage')}
|
|
226
|
+
npx @lorekit/cli show <scope> <key> [options]
|
|
227
|
+
|
|
228
|
+
Prints one lesson's complete (untruncated) value, scope, key, updated date, tags,
|
|
229
|
+
and which store(s) it lives in. If the same scope::key exists in both the offline
|
|
230
|
+
and remote stores, both are shown and any divergence in their values is flagged.
|
|
231
|
+
Exits non-zero when the key is found in neither readable store.
|
|
232
|
+
|
|
233
|
+
${c.bold('Options')}
|
|
234
|
+
-d, --dir <path> Target project root (default: current directory)
|
|
235
|
+
--json Machine-readable output (the full normalized record(s))
|
|
236
|
+
-e, --endpoint <url> Remote endpoint override (else .mcp.json / LOREKIT_MCP_URL)
|
|
237
|
+
-t, --token <token> Remote token override (else .mcp.json / LOREKIT_TOKEN)
|
|
238
|
+
--store <path> Local project-tier store directory (default: .lorekit)
|
|
239
|
+
|
|
240
|
+
${c.bold('Examples')}
|
|
241
|
+
npx @lorekit/cli show global prefer-guard-clauses
|
|
242
|
+
npx @lorekit/cli show project::widget build-flags --json
|
|
159
243
|
`,
|
|
160
244
|
migrate: `${c.bold('lorekit migrate')} — relocate a LoreKit-format local store into the current layout
|
|
161
245
|
|
|
@@ -209,13 +293,19 @@ ${c.bold('Options')}
|
|
|
209
293
|
const KNOWN_FLAGS = [
|
|
210
294
|
'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
|
|
211
295
|
'from', 'to', 'apply', 'yes', 'no-hooks', 'force', 'deep', 'adapter',
|
|
212
|
-
'event', 'help', 'version',
|
|
296
|
+
'event', 'json', 'scope', 'help', 'version',
|
|
213
297
|
];
|
|
214
298
|
|
|
215
299
|
// Commands that write to disk / talk to the network on a human's behalf. These
|
|
216
300
|
// reject unknown flags; the machine-facing `hook` / `mcp` do not (they must
|
|
217
301
|
// never fail on a stray flag, and only ever receive flags we control).
|
|
218
|
-
const HUMAN_COMMANDS = new Set([
|
|
302
|
+
const HUMAN_COMMANDS = new Set([
|
|
303
|
+
'install', 'uninstall', 'doctor', 'list', 'search', 'show', 'migrate',
|
|
304
|
+
]);
|
|
305
|
+
|
|
306
|
+
// Command aliases — canonicalized before help / dispatch so `lorekit ls --help`
|
|
307
|
+
// and telemetry both resolve to the real command name.
|
|
308
|
+
const COMMAND_ALIASES = { ls: 'list', grep: 'search' };
|
|
219
309
|
|
|
220
310
|
async function main() {
|
|
221
311
|
// Load a `.env` from the current directory (if any) before anything reads the
|
|
@@ -227,11 +317,11 @@ async function main() {
|
|
|
227
317
|
const argv = process.argv.slice(2);
|
|
228
318
|
const args = parseArgs(argv, {
|
|
229
319
|
aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
|
|
230
|
-
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks'],
|
|
320
|
+
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'json'],
|
|
231
321
|
known: KNOWN_FLAGS,
|
|
232
322
|
});
|
|
233
323
|
|
|
234
|
-
const command = args._[0];
|
|
324
|
+
const command = COMMAND_ALIASES[args._[0]] || args._[0];
|
|
235
325
|
|
|
236
326
|
// Help is intercepted first — before the machine-facing hook/mcp dispatch — so
|
|
237
327
|
// `lorekit <command> --help` always documents the command (even hook/mcp)
|
|
@@ -284,6 +374,12 @@ async function main() {
|
|
|
284
374
|
return traceCommand('uninstall', args, VERSION, () => uninstall(args));
|
|
285
375
|
case 'doctor':
|
|
286
376
|
return traceCommand('doctor', args, VERSION, () => doctor(args));
|
|
377
|
+
case 'list':
|
|
378
|
+
return traceCommand('list', args, VERSION, () => list(args));
|
|
379
|
+
case 'search':
|
|
380
|
+
return traceCommand('search', args, VERSION, () => search(args));
|
|
381
|
+
case 'show':
|
|
382
|
+
return traceCommand('show', args, VERSION, () => show(args));
|
|
287
383
|
case 'migrate':
|
|
288
384
|
return traceCommand('migrate', args, VERSION, () => migrate(args));
|
|
289
385
|
default:
|
package/package.json
CHANGED
|
Binary file
|
package/src/list.mjs
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// `lorekit list` — show the lessons that apply to the current directory, split
|
|
2
|
+
// into an Offline section (the local two-tier store) and a Remote section (the
|
|
3
|
+
// hosted MCP store). Scopes searched are the ones `deriveScope` resolves for the
|
|
4
|
+
// cwd: project, branch, repo, and global.
|
|
5
|
+
//
|
|
6
|
+
// Graceful by design: when no remote token/endpoint is configured the Remote
|
|
7
|
+
// section is a short note on how to set it up — never an error (mirrors
|
|
8
|
+
// `doctor`'s tone). Human-facing, so it is wrapped in `traceCommand` by the bin.
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import process from 'node:process';
|
|
12
|
+
import { resolveProjectRoot } from './config.mjs';
|
|
13
|
+
import { deriveScope } from './scope.mjs';
|
|
14
|
+
import { loadControl } from './control.mjs';
|
|
15
|
+
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
16
|
+
import { scopeList, gather, renderSection } from './lessons-view.mjs';
|
|
17
|
+
import { log, heading, c } from './util.mjs';
|
|
18
|
+
|
|
19
|
+
// Abbreviate the user's home directory to `~` for readable paths.
|
|
20
|
+
function prettyPath(p) {
|
|
21
|
+
const home = os.homedir();
|
|
22
|
+
return p && home && p.startsWith(home) ? '~' + p.slice(home.length) : p;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// A human label for where the offline store reads from.
|
|
26
|
+
function offlineSubtitle(local, root) {
|
|
27
|
+
const home = prettyPath(local.homeDir);
|
|
28
|
+
if (!local.projectActive()) return `${home} (home tier only — no project .lorekit/ yet)`;
|
|
29
|
+
const projRel = path.relative(root, local.projectDir) || local.projectDir;
|
|
30
|
+
return `${projRel} + ${home}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function list(args) {
|
|
34
|
+
const root = resolveProjectRoot(args.dir);
|
|
35
|
+
const env = { ...process.env };
|
|
36
|
+
if (args.store) env.LOREKIT_STORE = args.store;
|
|
37
|
+
|
|
38
|
+
const scopeInfo = deriveScope(root);
|
|
39
|
+
// Default to every applicable scope; `--scope <s>` narrows to one (an explicit
|
|
40
|
+
// scope outside the applicable set is honoured — the user asked for it).
|
|
41
|
+
const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo);
|
|
42
|
+
|
|
43
|
+
const { local, remote, connection } = resolveStores(root, {
|
|
44
|
+
env,
|
|
45
|
+
endpoint: args.endpoint,
|
|
46
|
+
token: args.token,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// A `LOREKIT_DENY=remote|local` privacy/compliance ceiling (deny-wins, never
|
|
50
|
+
// overridable) suppresses that section entirely — the same invariant the
|
|
51
|
+
// agent-facing control model enforces, honored here in the human read view.
|
|
52
|
+
const control = loadControl(root, { env });
|
|
53
|
+
const deny = (mode) => control.denies.find((d) => d.mode === mode) || null;
|
|
54
|
+
const localDenied = deny('local');
|
|
55
|
+
const remoteDenied = deny('remote');
|
|
56
|
+
|
|
57
|
+
const offline = localDenied ? { groups: [], total: 0 } : await gather(local, scopes);
|
|
58
|
+
const remoteAvailable = !remoteDenied && remote.usable();
|
|
59
|
+
const remoteResult = remoteAvailable ? await gather(remote, scopes) : { groups: [], total: 0 };
|
|
60
|
+
|
|
61
|
+
const offlineSection = localDenied
|
|
62
|
+
? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
|
|
63
|
+
: { available: true, ...offline };
|
|
64
|
+
const remoteSection = remoteAvailable
|
|
65
|
+
? { available: true, ...remoteResult }
|
|
66
|
+
: {
|
|
67
|
+
available: false,
|
|
68
|
+
reason: remoteDenied
|
|
69
|
+
? `disabled by deny constraint (${remoteDenied.source})`
|
|
70
|
+
: remoteUnavailableReason(connection),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
if (args.json) {
|
|
74
|
+
log(JSON.stringify(buildJson({ root, scopes, offlineSection, remoteSection }), null, 2));
|
|
75
|
+
} else {
|
|
76
|
+
heading('LoreKit lessons');
|
|
77
|
+
log(` project: ${c.dim(root)}`);
|
|
78
|
+
log(` scopes: ${scopes.join(' → ')}`);
|
|
79
|
+
|
|
80
|
+
renderSection(
|
|
81
|
+
{ title: 'Offline', subtitle: localDenied ? undefined : offlineSubtitle(local, root) },
|
|
82
|
+
offlineSection,
|
|
83
|
+
);
|
|
84
|
+
renderSection(
|
|
85
|
+
{ title: 'Remote', subtitle: remoteAvailable ? connection.endpoint : undefined },
|
|
86
|
+
remoteSection,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
log('');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Bounded, non-PII telemetry extras (counts + a boolean) — never a scope
|
|
93
|
+
// string, path, key, or token.
|
|
94
|
+
return {
|
|
95
|
+
exitCode: 0,
|
|
96
|
+
'lorekit.cli.list.scope_count': scopes.length,
|
|
97
|
+
'lorekit.cli.list.offline_count': offline.total,
|
|
98
|
+
'lorekit.cli.list.remote_count': remoteResult.total,
|
|
99
|
+
'lorekit.cli.list.remote_available': remoteAvailable,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The `--json` payload: normalized groups per section so a script gets the same
|
|
104
|
+
// shape regardless of which store an entry came from.
|
|
105
|
+
function buildJson({ root, scopes, offlineSection, remoteSection }) {
|
|
106
|
+
return { root, scopes, offline: sectionJson(offlineSection), remote: sectionJson(remoteSection) };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function sectionJson(section) {
|
|
110
|
+
if (!section.available) {
|
|
111
|
+
return { available: false, reason: section.reason, total: 0, groups: [] };
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
available: true,
|
|
115
|
+
total: section.total,
|
|
116
|
+
groups: (section.groups || []).map((g) => ({
|
|
117
|
+
scope: g.scope,
|
|
118
|
+
error: g.error || null,
|
|
119
|
+
// `gather()` already normalized these entries; no second pass needed.
|
|
120
|
+
entries: g.entries,
|
|
121
|
+
})),
|
|
122
|
+
};
|
|
123
|
+
}
|
package/src/search.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// `lorekit search <query>` — full-text search the lessons that apply to the
|
|
2
|
+
// current directory, across BOTH stores and every applicable scope, rendered in
|
|
3
|
+
// the same Offline / Remote split as `list`. A lesson matches when the query
|
|
4
|
+
// appears (case-insensitively, as a LITERAL substring) in its key or value.
|
|
5
|
+
//
|
|
6
|
+
// Why filter `gather()` rather than call each store's `search()`: the two stores
|
|
7
|
+
// disagree on search semantics — the local store does a literal substring match,
|
|
8
|
+
// the remote store delegates to server-side FTS (ranking, stemming). To give one
|
|
9
|
+
// deterministic, spec-defined behaviour across both — and to guarantee a query
|
|
10
|
+
// full of regex metacharacters is matched verbatim — the match runs here, over
|
|
11
|
+
// the exact same `gather()` seam `list` uses, via the pure `matchesQuery` filter.
|
|
12
|
+
//
|
|
13
|
+
// Graceful by design (mirrors `list`): an unconfigured remote is a short note,
|
|
14
|
+
// never an error; a per-scope read failure is a warning, not a crash. Read-only,
|
|
15
|
+
// archived hidden. Human-facing, so the bin wraps it in `traceCommand`.
|
|
16
|
+
import process from 'node:process';
|
|
17
|
+
import { resolveProjectRoot } from './config.mjs';
|
|
18
|
+
import { deriveScope } from './scope.mjs';
|
|
19
|
+
import { loadControl } from './control.mjs';
|
|
20
|
+
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
21
|
+
import { scopeList, gather, filterGroups, renderSection } from './lessons-view.mjs';
|
|
22
|
+
import { log, err, heading, c } from './util.mjs';
|
|
23
|
+
|
|
24
|
+
export async function search(args) {
|
|
25
|
+
const root = resolveProjectRoot(args.dir);
|
|
26
|
+
const env = { ...process.env };
|
|
27
|
+
if (args.store) env.LOREKIT_STORE = args.store;
|
|
28
|
+
|
|
29
|
+
// The query is the first positional after the command name. An empty/missing
|
|
30
|
+
// query is a usage error (non-zero exit) — searching for nothing is meaningless
|
|
31
|
+
// and would otherwise degrade to `list`.
|
|
32
|
+
const query = typeof args._[1] === 'string' ? args._[1] : '';
|
|
33
|
+
if (!query.trim()) {
|
|
34
|
+
err(`${c.red('Usage:')} lorekit search <query> [--scope <s>] [--json]`);
|
|
35
|
+
err(`Provide a search term. Run ${c.cyan('lorekit search --help')} for options.`);
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const scopeInfo = deriveScope(root);
|
|
40
|
+
// Default to every applicable scope; `--scope <s>` narrows to one (an explicit
|
|
41
|
+
// scope outside the applicable set is honoured — the user asked for it).
|
|
42
|
+
const scopes = args.scope && typeof args.scope === 'string' ? [args.scope] : scopeList(scopeInfo);
|
|
43
|
+
|
|
44
|
+
const { local, remote, connection } = resolveStores(root, {
|
|
45
|
+
env,
|
|
46
|
+
endpoint: args.endpoint,
|
|
47
|
+
token: args.token,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Deny-wins section suppression, identical to `list`.
|
|
51
|
+
const control = loadControl(root, { env });
|
|
52
|
+
const deny = (mode) => control.denies.find((d) => d.mode === mode) || null;
|
|
53
|
+
const localDenied = deny('local');
|
|
54
|
+
const remoteDenied = deny('remote');
|
|
55
|
+
|
|
56
|
+
const offline = localDenied
|
|
57
|
+
? { groups: [], total: 0 }
|
|
58
|
+
: filterGroups(await gather(local, scopes), query);
|
|
59
|
+
const remoteAvailable = !remoteDenied && remote.usable();
|
|
60
|
+
const remoteResult = remoteAvailable
|
|
61
|
+
? filterGroups(await gather(remote, scopes), query)
|
|
62
|
+
: { groups: [], total: 0 };
|
|
63
|
+
|
|
64
|
+
const offlineSection = localDenied
|
|
65
|
+
? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
|
|
66
|
+
: { available: true, ...offline };
|
|
67
|
+
const remoteSection = remoteAvailable
|
|
68
|
+
? { available: true, ...remoteResult }
|
|
69
|
+
: {
|
|
70
|
+
available: false,
|
|
71
|
+
reason: remoteDenied
|
|
72
|
+
? `disabled by deny constraint (${remoteDenied.source})`
|
|
73
|
+
: remoteUnavailableReason(connection),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
if (args.json) {
|
|
77
|
+
log(JSON.stringify(buildJson({ root, query, scopes, offlineSection, remoteSection }), null, 2));
|
|
78
|
+
} else {
|
|
79
|
+
heading('LoreKit search');
|
|
80
|
+
log(` query: ${c.dim(query)}`);
|
|
81
|
+
log(` scopes: ${scopes.join(' → ')}`);
|
|
82
|
+
|
|
83
|
+
const empty = 'no lessons match';
|
|
84
|
+
renderSection({ title: 'Offline', empty }, offlineSection);
|
|
85
|
+
renderSection(
|
|
86
|
+
{ title: 'Remote', subtitle: remoteAvailable ? connection.endpoint : undefined, empty },
|
|
87
|
+
remoteSection,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
log('');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Bounded, non-PII telemetry extras — counts + a boolean, never the query, a
|
|
94
|
+
// scope string, key, path, or token.
|
|
95
|
+
return {
|
|
96
|
+
exitCode: 0,
|
|
97
|
+
'lorekit.cli.search.scope_count': scopes.length,
|
|
98
|
+
'lorekit.cli.search.offline_matches': offline.total,
|
|
99
|
+
'lorekit.cli.search.remote_matches': remoteResult.total,
|
|
100
|
+
'lorekit.cli.search.remote_available': remoteAvailable,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// The `--json` payload: `{ query, root, scopes, offline, remote }` — normalized
|
|
105
|
+
// per-scope groups per section so a script gets the same shape regardless of
|
|
106
|
+
// which store a match came from (the exact shape `list --json` emits, plus the
|
|
107
|
+
// query echoed back).
|
|
108
|
+
function buildJson({ root, query, scopes, offlineSection, remoteSection }) {
|
|
109
|
+
return {
|
|
110
|
+
query,
|
|
111
|
+
root,
|
|
112
|
+
scopes,
|
|
113
|
+
offline: sectionJson(offlineSection),
|
|
114
|
+
remote: sectionJson(remoteSection),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sectionJson(section) {
|
|
119
|
+
if (!section.available) {
|
|
120
|
+
return { available: false, reason: section.reason, total: 0, groups: [] };
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
available: true,
|
|
124
|
+
total: section.total,
|
|
125
|
+
groups: (section.groups || []).map((g) => ({
|
|
126
|
+
scope: g.scope,
|
|
127
|
+
error: g.error || null,
|
|
128
|
+
// `gather()` already normalized these entries; the filter preserved them.
|
|
129
|
+
entries: g.entries,
|
|
130
|
+
})),
|
|
131
|
+
};
|
|
132
|
+
}
|
package/src/show.mjs
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// `lorekit show <scope> <key>` — inspect ONE lesson in full: its complete
|
|
2
|
+
// (untruncated) value, scope, key, updated timestamp, tags, and which store(s)
|
|
3
|
+
// it lives in. When the same scope::key exists in BOTH the offline and remote
|
|
4
|
+
// stores — possibly with different values — both are shown and the divergence is
|
|
5
|
+
// flagged.
|
|
6
|
+
//
|
|
7
|
+
// Uses each store's real `read({scope, key})` method (both stores expose it),
|
|
8
|
+
// not a filtered `list` — a single-record lookup is what `read` is for, and it
|
|
9
|
+
// already hides archived entries. Graceful by design (mirrors `list`/`search`):
|
|
10
|
+
// an unconfigured remote is a short note, never an error. Read-only. Human-facing,
|
|
11
|
+
// so the bin wraps it in `traceCommand`.
|
|
12
|
+
import process from 'node:process';
|
|
13
|
+
import { resolveProjectRoot } from './config.mjs';
|
|
14
|
+
import { loadControl } from './control.mjs';
|
|
15
|
+
import { resolveStores, remoteUnavailableReason } from './stores.mjs';
|
|
16
|
+
import { normalizeEntry, shortDate, describeError, recordsDiverge } from './lessons-view.mjs';
|
|
17
|
+
import { log, err, heading, status, c } from './util.mjs';
|
|
18
|
+
|
|
19
|
+
// Read one scope::key from a store, normalizing the result into a small,
|
|
20
|
+
// uniform shape: { available:true, found, record } on success, or
|
|
21
|
+
// { available:true, found:false, error } when the read itself failed (network /
|
|
22
|
+
// server). A per-store read failure is captured, never thrown, so one bad store
|
|
23
|
+
// never aborts the other section.
|
|
24
|
+
async function readOne(store, scope, key) {
|
|
25
|
+
let res;
|
|
26
|
+
try {
|
|
27
|
+
res = await store.read({ scope, key });
|
|
28
|
+
} catch (e) {
|
|
29
|
+
return { available: true, found: false, record: null, error: (e && e.message) || 'error' };
|
|
30
|
+
}
|
|
31
|
+
if (!res || res.ok === false) {
|
|
32
|
+
return { available: true, found: false, record: null, error: describeError(res) };
|
|
33
|
+
}
|
|
34
|
+
const record = res.entry ? normalizeEntry(res.entry) : null;
|
|
35
|
+
return { available: true, found: Boolean(record), record, error: null };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function show(args) {
|
|
39
|
+
const root = resolveProjectRoot(args.dir);
|
|
40
|
+
const env = { ...process.env };
|
|
41
|
+
if (args.store) env.LOREKIT_STORE = args.store;
|
|
42
|
+
|
|
43
|
+
// Both positionals are required: `show <scope> <key>`.
|
|
44
|
+
const scope = typeof args._[1] === 'string' ? args._[1] : '';
|
|
45
|
+
const key = typeof args._[2] === 'string' ? args._[2] : '';
|
|
46
|
+
if (!scope || !key) {
|
|
47
|
+
err(`${c.red('Usage:')} lorekit show <scope> <key> [--json]`);
|
|
48
|
+
err(`Both a scope and a key are required. Run ${c.cyan('lorekit show --help')} for options.`);
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const { local, remote, connection } = resolveStores(root, {
|
|
53
|
+
env,
|
|
54
|
+
endpoint: args.endpoint,
|
|
55
|
+
token: args.token,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Deny-wins section suppression, identical to `list`/`search`.
|
|
59
|
+
const control = loadControl(root, { env });
|
|
60
|
+
const deny = (mode) => control.denies.find((d) => d.mode === mode) || null;
|
|
61
|
+
const localDenied = deny('local');
|
|
62
|
+
const remoteDenied = deny('remote');
|
|
63
|
+
|
|
64
|
+
const offline = localDenied
|
|
65
|
+
? { available: false, reason: `disabled by deny constraint (${localDenied.source})` }
|
|
66
|
+
: await readOne(local, scope, key);
|
|
67
|
+
|
|
68
|
+
const remoteAvailable = !remoteDenied && remote.usable();
|
|
69
|
+
const remote_ = remoteDenied
|
|
70
|
+
? { available: false, reason: `disabled by deny constraint (${remoteDenied.source})` }
|
|
71
|
+
: remoteAvailable
|
|
72
|
+
? await readOne(remote, scope, key)
|
|
73
|
+
: { available: false, reason: remoteUnavailableReason(connection) };
|
|
74
|
+
|
|
75
|
+
const foundOffline = Boolean(offline.available && offline.found);
|
|
76
|
+
const foundRemote = Boolean(remote_.available && remote_.found);
|
|
77
|
+
const diverged = foundOffline && foundRemote && recordsDiverge(offline.record, remote_.record);
|
|
78
|
+
// "Not found" is only definitive across the stores we could actually consult.
|
|
79
|
+
const found = foundOffline || foundRemote;
|
|
80
|
+
|
|
81
|
+
if (args.json) {
|
|
82
|
+
log(JSON.stringify(buildJson({ scope, key, offline, remote_, diverged }), null, 2));
|
|
83
|
+
} else {
|
|
84
|
+
heading('LoreKit lesson');
|
|
85
|
+
log(` scope: ${c.dim(scope)}`);
|
|
86
|
+
log(` key: ${c.dim(key)}`);
|
|
87
|
+
|
|
88
|
+
renderRecordSection('Offline', offline);
|
|
89
|
+
renderRecordSection(
|
|
90
|
+
'Remote',
|
|
91
|
+
remote_,
|
|
92
|
+
remoteAvailable ? connection.endpoint : undefined,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
if (diverged) {
|
|
96
|
+
log('');
|
|
97
|
+
status('warn', 'divergence', 'the offline and remote values differ');
|
|
98
|
+
}
|
|
99
|
+
if (!found) {
|
|
100
|
+
log('');
|
|
101
|
+
log(` ${c.dim(`no lesson found for ${scope}::${key} in the readable store(s)`)}`);
|
|
102
|
+
}
|
|
103
|
+
log('');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Bounded, non-PII telemetry — booleans only, never the scope string or key.
|
|
107
|
+
return {
|
|
108
|
+
exitCode: found ? 0 : 1,
|
|
109
|
+
'lorekit.cli.show.found_offline': foundOffline,
|
|
110
|
+
'lorekit.cli.show.found_remote': foundRemote,
|
|
111
|
+
'lorekit.cli.show.diverged': diverged,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Render one store's slot: an unavailable note, a "no such key here" line, or the
|
|
116
|
+
// full record (untruncated value).
|
|
117
|
+
function renderRecordSection(title, section, subtitle) {
|
|
118
|
+
heading(title);
|
|
119
|
+
if (subtitle) log(` ${c.dim(subtitle)}`);
|
|
120
|
+
|
|
121
|
+
if (!section.available) {
|
|
122
|
+
status('warn', 'unavailable', section.reason);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (section.error) {
|
|
126
|
+
status('warn', 'unreadable', section.error);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (!section.found) {
|
|
130
|
+
log(` ${c.dim('no such key in this store')}`);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const e = section.record;
|
|
135
|
+
if (e.updated) log(` ${c.dim('updated')} ${shortDate(e.updated)}`);
|
|
136
|
+
if (e.tags && e.tags.length) log(` ${c.dim('tags')} ${e.tags.join(', ')}`);
|
|
137
|
+
log(` ${c.dim('value')}`);
|
|
138
|
+
// The full, untruncated body — indented, line by line, so multi-line lessons
|
|
139
|
+
// read as written.
|
|
140
|
+
for (const line of String(e.value ?? '').split('\n')) {
|
|
141
|
+
log(` ${line}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The `--json` payload: the full normalized record(s) plus which store each came
|
|
146
|
+
// from and whether they diverge.
|
|
147
|
+
function buildJson({ scope, key, offline, remote_, diverged }) {
|
|
148
|
+
return {
|
|
149
|
+
scope,
|
|
150
|
+
key,
|
|
151
|
+
offline: recordJson(offline),
|
|
152
|
+
remote: recordJson(remote_),
|
|
153
|
+
diverged,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function recordJson(section) {
|
|
158
|
+
if (!section.available) {
|
|
159
|
+
return { available: false, reason: section.reason, found: false, record: null };
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
available: true,
|
|
163
|
+
found: Boolean(section.found),
|
|
164
|
+
record: section.record || null,
|
|
165
|
+
...(section.error ? { error: section.error } : {}),
|
|
166
|
+
};
|
|
167
|
+
}
|
package/src/stores.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Resolve BOTH memory stores at once — the offline (local two-tier) store and
|
|
2
|
+
// the remote (hosted MCP) store — independent of the active control mode. The
|
|
3
|
+
// read commands (`list`, and the stacked `search` / `show` / `stats` / `diff`)
|
|
4
|
+
// want to show whatever lives in each place side by side, so unlike
|
|
5
|
+
// `createStore(control)` (which picks exactly one by mode) this builds both.
|
|
6
|
+
//
|
|
7
|
+
// Nothing here invents scope or connection logic: it composes the existing
|
|
8
|
+
// `localStoreDirs` (two-tier dirs), `resolveProjectConnection` + `splitEndpoint`
|
|
9
|
+
// (endpoint/token from .mcp.json → global → env), and the store builders.
|
|
10
|
+
// Zero-dependency.
|
|
11
|
+
import process from 'node:process';
|
|
12
|
+
import { localStoreDirs } from './control.mjs';
|
|
13
|
+
import { resolveProjectConnection } from './config.mjs';
|
|
14
|
+
import { splitEndpoint } from './mcp.mjs';
|
|
15
|
+
import { createTwoTierStore, createRemoteStore } from './store/index.mjs';
|
|
16
|
+
|
|
17
|
+
// Build both stores for `root`. Explicit `endpoint` / `token` (e.g. from
|
|
18
|
+
// `--endpoint` / `--token` flags) take precedence over the resolved connection;
|
|
19
|
+
// `env` feeds the local-tier directory resolution ($LOREKIT_HOME / $LOREKIT_STORE).
|
|
20
|
+
// Returns { local, remote, connection } — `connection` is the resolved
|
|
21
|
+
// endpoint/token so callers can explain why remote is unusable.
|
|
22
|
+
export function resolveStores(root, { env = process.env, endpoint = null, token = null } = {}) {
|
|
23
|
+
const dirs = localStoreDirs(root, env);
|
|
24
|
+
const local = createTwoTierStore(dirs);
|
|
25
|
+
|
|
26
|
+
const conn = resolveProjectConnection(root, splitEndpoint);
|
|
27
|
+
const resolvedEndpoint = endpoint || conn.endpoint || null;
|
|
28
|
+
const resolvedToken = token || conn.token || null;
|
|
29
|
+
const remote = createRemoteStore({ endpoint: resolvedEndpoint, token: resolvedToken });
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
local,
|
|
33
|
+
remote,
|
|
34
|
+
connection: { endpoint: resolvedEndpoint, token: resolvedToken },
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// A bounded, actionable reason a remote listing is unavailable — mirrors the
|
|
39
|
+
// checks in `RemoteStore.usable()` so the note tells the user what to fix.
|
|
40
|
+
export function remoteUnavailableReason({ endpoint, token } = {}) {
|
|
41
|
+
if (!endpoint) return 'no endpoint configured — run `lorekit install` or set LOREKIT_MCP_URL';
|
|
42
|
+
if (String(endpoint).includes('<project-ref>')) return `endpoint is still a placeholder (${endpoint})`;
|
|
43
|
+
if (!token) return 'no token configured — set LOREKIT_TOKEN or run `lorekit install`';
|
|
44
|
+
// Defensive catch-all: unreachable while this mirrors `RemoteStore.usable()` —
|
|
45
|
+
// callers only reach here when the remote is unusable, so one check above has
|
|
46
|
+
// already fired. Kept as a total return (never a throw): this helper is on the
|
|
47
|
+
// graceful read path and must never break the listing.
|
|
48
|
+
return 'not configured';
|
|
49
|
+
}
|
package/src/telemetry.mjs
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
// mirrors the Edge Function's SDK-free approach (supabase/functions/_shared/
|
|
5
5
|
// otel.ts): OTLP/JSON over the global fetch (Node 18+), no @opentelemetry/*
|
|
6
6
|
// packages. One span + one counter data point per human-facing command
|
|
7
|
-
// (install / doctor /
|
|
8
|
-
// which commands people actually run.
|
|
7
|
+
// (install / uninstall / doctor / list / search / show / migrate), fired to
|
|
8
|
+
// Dash0 so the maintainers can see which commands people actually run.
|
|
9
9
|
//
|
|
10
10
|
// Privacy — this runs on end-users' machines, so it is deliberately narrow:
|
|
11
11
|
// • Opt-out honored: LOREKIT_TELEMETRY=0|off|false|no|disable, or the
|
|
@@ -34,7 +34,7 @@ const DEFAULT_DATASET = 'lorekit-cli';
|
|
|
34
34
|
|
|
35
35
|
// Flags worth counting (e.g. how many installs are --global). Bounded on
|
|
36
36
|
// purpose: only these booleans are ever attached, never free-form values.
|
|
37
|
-
const FLAG_ATTRS = ['global', 'project', 'deep', 'yes', 'force', 'no-hooks'];
|
|
37
|
+
const FLAG_ATTRS = ['global', 'project', 'deep', 'yes', 'force', 'no-hooks', 'json'];
|
|
38
38
|
|
|
39
39
|
const OFF_VALUES = new Set(['0', 'off', 'false', 'no', 'disable', 'disabled']);
|
|
40
40
|
|
|
@@ -274,7 +274,7 @@ function normalizeExitCode(result) {
|
|
|
274
274
|
* counter point. Returns the command's exit code unchanged. Telemetry failures
|
|
275
275
|
* are swallowed — the command result is never affected.
|
|
276
276
|
*
|
|
277
|
-
* @param {string} command bounded: install | doctor | migrate
|
|
277
|
+
* @param {string} command bounded: install | uninstall | doctor | list | search | show | migrate
|
|
278
278
|
* @param {object} args parsed CLI args (read for allow-listed flags only)
|
|
279
279
|
* @param {string} version CLI version (from package.json)
|
|
280
280
|
* @param {() => Promise<number>} run the command handler
|