@lorekit/cli 1.55.3 → 1.57.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 +54 -1
- package/bin/lorekit.mjs +84 -3
- package/package.json +1 -1
- package/src/commands/completion.mjs +68 -0
- package/src/commands/hook.mjs +47 -5
- package/src/commands/install.mjs +104 -2
- package/src/commands/obligations.mjs +135 -0
- package/src/commands/uninstall.mjs +21 -2
- package/src/commands.mjs +8 -0
- package/src/shared/completions.mjs +471 -0
- package/src/shared/mirror-pairs.mjs +86 -0
- package/src/shared/obligations-map.mjs +154 -0
- package/src/shared/obligations-pure.mjs +246 -0
- package/src/telemetry/telemetry.mjs +36 -22
package/README.md
CHANGED
|
@@ -443,6 +443,57 @@ coincidental overlaps. Any pair scoring at or above `--threshold` links (transit
|
|
|
443
443
|
into one cluster; only clusters of 2+ members are reported, each with a similarity
|
|
444
444
|
range. Cross-**store** divergence is `diff`'s job; `dedupe` looks within a store.
|
|
445
445
|
|
|
446
|
+
### `lorekit obligations`
|
|
447
|
+
|
|
448
|
+
Check a changed-file set against the **Surface-Partner Map** — a declarative
|
|
449
|
+
registry of known, path-keyed file partnerships (a mirrored module, a doc that
|
|
450
|
+
copies a claim, a generated artifact) mined from existing CI guards — and flag
|
|
451
|
+
any partner the map says the changed-set owes but doesn't contain:
|
|
452
|
+
|
|
453
|
+
```bash
|
|
454
|
+
lorekit obligations supabase/functions/_shared/audit/audit.ts # positionals
|
|
455
|
+
lorekit obligations --files packages/schemas/src/shared/tool-catalog.ts --json
|
|
456
|
+
git diff --name-only origin/main... | lorekit obligations --strict # from a real diff
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
This is a machine version of a recurring review finding: a fix to one surface
|
|
460
|
+
leaves its partner stale because the lessons documenting the partnership are
|
|
461
|
+
retrieved lexically (full-text search + recency) and rarely surface at edit
|
|
462
|
+
time for the exact file just touched. Each matched entry prints its obliged
|
|
463
|
+
partner files/actions, marks each as met (✓) or unmet (!), and cites the
|
|
464
|
+
memory `lessonKey` the partnership encodes.
|
|
465
|
+
|
|
466
|
+
An `obliges` element is a required partner path/glob, a `run:<action>`
|
|
467
|
+
advisory that is always reported but never gates `--strict` (some
|
|
468
|
+
partnerships are "regenerate this," not "edit this file"), or an "any of"
|
|
469
|
+
group satisfied by whichever of several candidates is present. `{name}` (or
|
|
470
|
+
`**/{name}`) in a `match`/`obliges` glob binds a mirrored module's relative
|
|
471
|
+
path (directories + stem, extension stripped), for the (rare) case where a
|
|
472
|
+
partner's path genuinely IS a predictable function of the source's, so one
|
|
473
|
+
entry covers every module instead of needing one per file.
|
|
474
|
+
|
|
475
|
+
The `edge-mirror`/`edge-mirror-core` entries (mcp-core ↔ the self-contained
|
|
476
|
+
Deno edge mirrors) do NOT use that glob mechanism: an edge mirror doesn't
|
|
477
|
+
reliably preserve mcp-core's directory structure (it may flatten or rename
|
|
478
|
+
it), so a symmetric-path reconstruction false-positives on exactly those
|
|
479
|
+
pairs. Instead, both entries are generated — one row per pair — from
|
|
480
|
+
`src/shared/mirror-pairs.mjs`, the single-source inventory
|
|
481
|
+
`packages/mcp-core/src/edge/edge-parity.spec.ts` also reads for its
|
|
482
|
+
byte-comparison drift guard, so the spec and this command can never disagree
|
|
483
|
+
about which files mirror which.
|
|
484
|
+
|
|
485
|
+
**Cwd-independent by design**: it matches the path STRINGS it is given
|
|
486
|
+
against the map and never reads the filesystem or resolves scope from the
|
|
487
|
+
current directory — the changed-set can come from a real `git diff`, a PR
|
|
488
|
+
file list, or by hand, from anywhere.
|
|
489
|
+
|
|
490
|
+
Exits 0 by default; `--strict` exits non-zero when any PATH obligation is
|
|
491
|
+
unmet. `--json` → `{ files, matched, unmet, ok }`. CLI-only (`native` — no MCP
|
|
492
|
+
tool, no REST route, no `tool-catalog.ts` entry): a path-matching lint utility
|
|
493
|
+
is not an operation surface. Slice 1 of a larger design — wiring a
|
|
494
|
+
`PreToolUse` hook to call this at edit time, and server-side retrieval
|
|
495
|
+
changes, are named follow-ups, not built here.
|
|
496
|
+
|
|
446
497
|
### `lorekit link` (alias `url`)
|
|
447
498
|
|
|
448
499
|
Print a shareable **dashboard deep-link URL** to stdout — nothing else, so it
|
|
@@ -996,12 +1047,14 @@ also returns their headroom against the plan's memory cap.
|
|
|
996
1047
|
| `--mcp-json` | Also write a committable project `.mcp.json` (auth via `${LOREKIT_TOKEN}`, no embedded token) for Claude Code on the web (`install`) |
|
|
997
1048
|
| `--force` | Overwrite existing skill files (`install`) |
|
|
998
1049
|
| `--deep` | Write/read/delete round-trip (`doctor`) |
|
|
999
|
-
| `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe` / `link` / `purge` / `purge-expired`) |
|
|
1050
|
+
| `--json` | Machine-readable output (`list` / `search` / `show` / `stats` / `scopes` / `diff` / `tree` / `lint` / `dedupe` / `obligations` / `link` / `purge` / `purge-expired`) |
|
|
1000
1051
|
| `--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 |
|
|
1001
1052
|
| `--key <key>` | Name the key outright (`show` / `write` / `link`) — the way to address a key that itself contains `::` |
|
|
1002
1053
|
| `--link` | Print the equivalent dashboard deep-link URL instead of running (`show` / `search` / `list` / `tree`) |
|
|
1003
1054
|
| `--base <url>` | Dashboard base URL for deep links (`link` / `--link`; else `LOREKIT_APP_URL`, default `https://lorekit.io`) |
|
|
1004
1055
|
| `--threshold <0..1>` | Duplicate-similarity cutoff (`dedupe`; default `0.8`) |
|
|
1056
|
+
| `--files <path>...` | Changed files to check (`obligations`); also accepted as positionals or newline-separated stdin |
|
|
1057
|
+
| `--strict` | Exit non-zero on any unmet obligation (`obligations`) |
|
|
1005
1058
|
| `--retention-days <1..365>` | Only purge archived memories older than this (`purge`; default `30`, derived from the tool catalog) |
|
|
1006
1059
|
| `--adapter <name>` | Host framework for `hook`: `claude` / `cursor` / `codex` |
|
|
1007
1060
|
| `--event <name>` | Host hook event for `hook` (else read from the stdin payload) |
|
package/bin/lorekit.mjs
CHANGED
|
@@ -74,6 +74,14 @@ ${c.bold('Commands')}
|
|
|
74
74
|
dedupe Find likely-duplicate memories via a zero-dep word-overlap HEURISTIC
|
|
75
75
|
(Jaccard >= threshold, not semantic), grouped into clusters per
|
|
76
76
|
store. --json, --scope <s>, --threshold <0..1>.
|
|
77
|
+
obligations Check a changed-file set against the Surface-Partner Map: known,
|
|
78
|
+
path-keyed file partnerships (a mirrored module, a doc that
|
|
79
|
+
copies a claim, a generated artifact) mined from existing CI
|
|
80
|
+
guards. Prints each matched partnership's obliged partner
|
|
81
|
+
files/actions and flags any partner NOT in the given set.
|
|
82
|
+
Cwd-independent — matches path strings, never reads the FS.
|
|
83
|
+
--files <path>..., positionals, or stdin (newline-separated).
|
|
84
|
+
--json, --strict (exit non-zero on any unmet obligation).
|
|
77
85
|
link (url) Print a shareable dashboard deep-link URL for the current context,
|
|
78
86
|
a scope, or a specific lesson (opens its detail sheet). No args
|
|
79
87
|
links to the cwd's most-specific scope. Filter flags mirror the
|
|
@@ -102,6 +110,10 @@ ${c.bold('Commands')}
|
|
|
102
110
|
resolved store (local .lorekit/ offline, or remote passthrough) so
|
|
103
111
|
.mcp.json can point at the CLI instead of mcp-remote. Speaks
|
|
104
112
|
JSON-RPC on stdin/stdout — not run by hand.
|
|
113
|
+
completion Print a shell completion script for zsh or fish. Pipe it to your
|
|
114
|
+
shell's completion dir, or let \`install --completions\` wire it for
|
|
115
|
+
you. Completes commands, per-command flags, and — from the local
|
|
116
|
+
store — scopes and scope::key addresses.
|
|
105
117
|
|
|
106
118
|
${c.bold('Options')}
|
|
107
119
|
-d, --dir <path> Target project root (default: current directory)
|
|
@@ -111,9 +123,11 @@ ${c.bold('Options')}
|
|
|
111
123
|
-t, --token <token> LoreKit token (lk_rw_* to allow writes, lk_ro_* read-only)
|
|
112
124
|
--mode <mode> Memory mode: off | local | remote (doctor override)
|
|
113
125
|
--store <path> Local project-tier store directory (default: .lorekit)
|
|
114
|
-
--json Machine-readable output (list / search / show / stats / scopes / diff / tree / lint / dedupe / link)
|
|
126
|
+
--json Machine-readable output (list / search / show / stats / scopes / diff / tree / lint / dedupe / obligations / link)
|
|
115
127
|
--scope <scope> Restrict to a single scope; a substring filter for scopes (list / search / stats / scopes / diff / tree / lint / dedupe / link)
|
|
116
128
|
On show / write it NAMES the scope, overriding the positional
|
|
129
|
+
--files <path>... Changed files to check (obligations); also accepted as positionals or newline-separated stdin
|
|
130
|
+
--strict Exit non-zero on any unmet obligation (obligations)
|
|
117
131
|
--key <key> Name the key explicitly (show / write / link) — the way to
|
|
118
132
|
address a key that itself contains \`::\`
|
|
119
133
|
--link Print the equivalent dashboard deep-link URL instead of running (show / search / list / tree)
|
|
@@ -131,6 +145,7 @@ ${c.bold('Options')}
|
|
|
131
145
|
--no-hooks Skip wiring the lifecycle hooks (install)
|
|
132
146
|
--mcp-json Also write a committable project .mcp.json for Claude Code on
|
|
133
147
|
the web — auth via \${LOREKIT_TOKEN}, no embedded secret (install)
|
|
148
|
+
--completions <s> Install shell completion: auto | zsh | fish | none (install)
|
|
134
149
|
--force Overwrite existing skill files (install)
|
|
135
150
|
--deep Do a write→read→delete round-trip (doctor)
|
|
136
151
|
--telemetry Verify the OTLP export credential works (doctor)
|
|
@@ -207,6 +222,10 @@ ${c.bold('Options')}
|
|
|
207
222
|
--no-hooks Skip wiring the lifecycle hooks (leaves existing ones alone)
|
|
208
223
|
--mcp-json Also write a committable project .mcp.json (\${LOREKIT_TOKEN} auth)
|
|
209
224
|
for Claude Code on the web — always the repo-root file
|
|
225
|
+
--completions <s> Install shell completion: auto (detect \$SHELL) | zsh | fish | none.
|
|
226
|
+
Interactive runs prompt; non-interactive ones skip it unless
|
|
227
|
+
this flag is passed. zsh adds a guarded block to ~/.zshrc; fish
|
|
228
|
+
drops a file in ~/.config/fish/completions (auto-loaded).
|
|
210
229
|
--force Overwrite existing skill files
|
|
211
230
|
-y, --yes Non-interactive; never prompt (defaults to --project, and to the
|
|
212
231
|
already-wired hooks — all on a fresh install)
|
|
@@ -218,6 +237,7 @@ ${c.bold('Examples')}
|
|
|
218
237
|
npx @lorekit/cli install --global --mcp-json --yes # local CLI + committable web config
|
|
219
238
|
npx @lorekit/cli install --hooks read-only --yes
|
|
220
239
|
npx @lorekit/cli install --no-hooks --yes
|
|
240
|
+
npx @lorekit/cli install --completions auto --yes # detect \$SHELL and wire completion
|
|
221
241
|
`,
|
|
222
242
|
uninstall: `${c.bold('lorekit uninstall')} — reverse install for the chosen scope
|
|
223
243
|
|
|
@@ -566,6 +586,41 @@ ${c.bold('Examples')}
|
|
|
566
586
|
npx @lorekit/cli dedupe
|
|
567
587
|
npx @lorekit/cli dedupe --threshold 0.6 --json
|
|
568
588
|
npx @lorekit/cli dedupe --cluster-by-key "(pr\\d+-\\d+)" --json
|
|
589
|
+
`,
|
|
590
|
+
obligations: `${c.bold('lorekit obligations')} — check a changed-file set against the Surface-Partner Map
|
|
591
|
+
|
|
592
|
+
${c.bold('Usage')}
|
|
593
|
+
npx @lorekit/cli obligations <path>... [options]
|
|
594
|
+
npx @lorekit/cli obligations --files <path>... [options]
|
|
595
|
+
git diff --name-only | npx @lorekit/cli obligations [options]
|
|
596
|
+
|
|
597
|
+
Checks a changed-file set against a declarative registry of known, path-keyed
|
|
598
|
+
file partnerships (a mirrored module, a doc that copies a claim, a generated
|
|
599
|
+
artifact) mined from existing CI guards — a machine version of the recurring
|
|
600
|
+
review finding "you fixed one surface and left its partner stale." For each
|
|
601
|
+
matched partnership it prints the obliged partner files/actions and flags any
|
|
602
|
+
partner NOT in the given changed-set, citing the memory lesson the
|
|
603
|
+
partnership encodes.
|
|
604
|
+
|
|
605
|
+
Cwd-INDEPENDENT: it matches the path STRINGS it is given against the map — it
|
|
606
|
+
never reads the filesystem or resolves scope from the current directory, so
|
|
607
|
+
the changed-set can come from anywhere (a git diff, a PR file list, by hand).
|
|
608
|
+
|
|
609
|
+
The changed-set is positionals unioned with ${c.cyan('--files')} (its single-value
|
|
610
|
+
form — extra paths after it fall through as positionals); when neither is
|
|
611
|
+
given, it falls back to stdin lines (newline-separated, trimmed, non-empty),
|
|
612
|
+
read only when stdin is piped.
|
|
613
|
+
|
|
614
|
+
${c.bold('Options')}
|
|
615
|
+
--files <path>... Changed files to check (also: positionals, stdin)
|
|
616
|
+
--strict Exit non-zero when any path obligation is unmet
|
|
617
|
+
(an advisory run: action never gates this)
|
|
618
|
+
--json Machine-readable output ({ files, matched, unmet, ok })
|
|
619
|
+
|
|
620
|
+
${c.bold('Examples')}
|
|
621
|
+
npx @lorekit/cli obligations supabase/functions/_shared/audit/audit.ts
|
|
622
|
+
npx @lorekit/cli obligations --files packages/schemas/src/shared/tool-catalog.ts --json
|
|
623
|
+
git diff --name-only origin/main... | npx @lorekit/cli obligations --strict
|
|
569
624
|
`,
|
|
570
625
|
link: `${c.bold('lorekit link')} — print a shareable dashboard deep-link URL ${c.dim('(alias: url)')}
|
|
571
626
|
|
|
@@ -811,6 +866,30 @@ Machine-facing: exposes the memory.* tools backed by the resolved store (local
|
|
|
811
866
|
.lorekit/ offline, or remote passthrough) over JSON-RPC on stdin/stdout, so
|
|
812
867
|
.mcp.json can point at the CLI instead of mcp-remote. Not run by hand.
|
|
813
868
|
|
|
869
|
+
${c.bold('Options')}
|
|
870
|
+
-d, --dir <path> Target project root (default: current directory)
|
|
871
|
+
`,
|
|
872
|
+
completion: `${c.bold('lorekit completion')} — print a shell completion script
|
|
873
|
+
|
|
874
|
+
${c.bold('Usage')}
|
|
875
|
+
lorekit completion <zsh|fish>
|
|
876
|
+
|
|
877
|
+
Prints the completion script for the given shell to stdout. It completes command
|
|
878
|
+
names and aliases, each command's own flags, and — read live from the LOCAL
|
|
879
|
+
store — scope values (\`--scope\`) and \`scope::key\` addresses (\`show\`, \`write\`,
|
|
880
|
+
\`archive\`, \`delete\`, \`restore\`, \`link\`). Dynamic completion is offline: it never
|
|
881
|
+
prompts for a token or hits the network, so a remote-only scope will not appear.
|
|
882
|
+
|
|
883
|
+
The easiest way to install it is ${c.cyan('lorekit install --completions auto')}, which
|
|
884
|
+
detects your shell and wires it up. To do it by hand:
|
|
885
|
+
|
|
886
|
+
${c.bold('zsh')}
|
|
887
|
+
lorekit completion zsh > ~/.zsh/completions/_lorekit
|
|
888
|
+
# ensure that dir is on \$fpath before \`compinit\` in ~/.zshrc
|
|
889
|
+
|
|
890
|
+
${c.bold('fish')}
|
|
891
|
+
lorekit completion fish > ~/.config/fish/completions/lorekit.fish
|
|
892
|
+
|
|
814
893
|
${c.bold('Options')}
|
|
815
894
|
-d, --dir <path> Target project root (default: current directory)
|
|
816
895
|
`,
|
|
@@ -821,7 +900,7 @@ ${c.bold('Options')}
|
|
|
821
900
|
// typo like `--gloabl` should fail loudly, not quietly fall back to --project.
|
|
822
901
|
const KNOWN_FLAGS = [
|
|
823
902
|
'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
|
|
824
|
-
'from', 'to', 'apply', 'yes', 'hooks', 'no-hooks', 'mcp-json', 'force', 'deep', 'adapter',
|
|
903
|
+
'from', 'to', 'apply', 'yes', 'hooks', 'no-hooks', 'mcp-json', 'completions', 'complete', 'force', 'deep', 'adapter',
|
|
825
904
|
'event', 'json', 'scope', 'key', 'threshold', 'help', 'version', 'telemetry',
|
|
826
905
|
'value', 'tags', 'source-agent', 'trigger', 'kind', 'host', 'ttl-days', 'clear-ttl', 'org', 'remote', 'local',
|
|
827
906
|
// `view` is accepted-and-IGNORED, not documented: the Explorer dropped the
|
|
@@ -835,6 +914,8 @@ const KNOWN_FLAGS = [
|
|
|
835
914
|
'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin',
|
|
836
915
|
// Scale-aware survey flags
|
|
837
916
|
'all', 'max', 'since', 'until', 'key-prefix', 'cluster-by-key',
|
|
917
|
+
// `obligations`
|
|
918
|
+
'files', 'strict',
|
|
838
919
|
];
|
|
839
920
|
|
|
840
921
|
async function main() {
|
|
@@ -847,7 +928,7 @@ async function main() {
|
|
|
847
928
|
const argv = process.argv.slice(2);
|
|
848
929
|
const args = parseArgs(argv, {
|
|
849
930
|
aliases: { d: 'dir', e: 'endpoint', t: 'token', y: 'yes', h: 'help', v: 'version' },
|
|
850
|
-
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'mcp-json', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all'],
|
|
931
|
+
booleans: ['yes', 'force', 'deep', 'apply', 'help', 'version', 'global', 'project', 'no-hooks', 'mcp-json', 'no-origin', 'json', 'remote', 'local', 'link', 'archived', 'clear-ttl', 'telemetry', 'all', 'strict'],
|
|
851
932
|
known: KNOWN_FLAGS,
|
|
852
933
|
});
|
|
853
934
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// `lorekit completion <zsh|fish>` — print a shell completion script.
|
|
2
|
+
//
|
|
3
|
+
// The generated scripts (see ../shared/completions.mjs) call back into this
|
|
4
|
+
// command for DYNAMIC values: `lorekit completion --complete scope` lists the
|
|
5
|
+
// scopes in the local store, `--complete key` lists `scope::key` addresses. That
|
|
6
|
+
// callback fires on every TAB, which is why `completion` is a MACHINE command in
|
|
7
|
+
// the registry — its stdout is a contract a shell parses (a script or a
|
|
8
|
+
// newline-delimited candidate list), and it is metered, not traced, exactly like
|
|
9
|
+
// `hook` / `mcp`. A span per keypress would be a firehose.
|
|
10
|
+
//
|
|
11
|
+
// Dynamic completion reads the LOCAL store only. It is deliberately offline: a
|
|
12
|
+
// TAB must be instant and must never prompt for a token or hit the network, so a
|
|
13
|
+
// scope that lives only in the remote store will not complete — a worthwhile
|
|
14
|
+
// trade for a completion that never blocks the prompt.
|
|
15
|
+
import process from 'node:process';
|
|
16
|
+
import { resolveProjectRoot } from '../shared/config.mjs';
|
|
17
|
+
import { resolveStores } from '../shared/stores.mjs';
|
|
18
|
+
import { log, err, c } from '../shared/util.mjs';
|
|
19
|
+
import { COMPLETION_SHELLS, renderCompletion } from '../shared/completions.mjs';
|
|
20
|
+
|
|
21
|
+
export async function completion(args) {
|
|
22
|
+
// Dynamic candidate mode: emit newline-delimited candidates for the shell to
|
|
23
|
+
// consume, then exit. Any failure degrades to an empty list (exit 0) so a
|
|
24
|
+
// broken store never surfaces an error at the prompt.
|
|
25
|
+
if (args.complete !== undefined) {
|
|
26
|
+
return emitCandidates(args);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const shell = typeof args._?.[1] === 'string' ? args._[1] : null;
|
|
30
|
+
if (!shell) {
|
|
31
|
+
err(`${c.red('Missing shell.')} Usage: lorekit completion <${COMPLETION_SHELLS.join('|')}>`);
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
if (!COMPLETION_SHELLS.includes(shell)) {
|
|
35
|
+
err(`${c.red(`Unsupported shell: ${shell}.`)} Supported: ${COMPLETION_SHELLS.join(', ')}`);
|
|
36
|
+
err(`Run ${c.cyan('lorekit install --completions ' + shell)} to install it, or pipe this to your shell's completion dir.`);
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
log(renderCompletion(shell));
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Emit the candidate list for `--complete <kind>`. `scope` → distinct scope
|
|
45
|
+
// strings; `key` → `scope::key` addresses. Best-effort and offline.
|
|
46
|
+
async function emitCandidates(args) {
|
|
47
|
+
const kind = typeof args.complete === 'string' ? args.complete : '';
|
|
48
|
+
try {
|
|
49
|
+
const root = resolveProjectRoot(args.dir);
|
|
50
|
+
const { local } = resolveStores(root, { env: process.env });
|
|
51
|
+
const inventory = await local.listScopes();
|
|
52
|
+
|
|
53
|
+
if (kind === 'scope') {
|
|
54
|
+
for (const { scope } of inventory) log(scope);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
if (kind === 'key') {
|
|
58
|
+
for (const { scope } of inventory) {
|
|
59
|
+
const res = await local.list({ scope });
|
|
60
|
+
for (const entry of res.entries ?? []) log(`${scope}::${entry.key}`);
|
|
61
|
+
}
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// Swallow — an unreadable store yields no candidates, never a prompt error.
|
|
66
|
+
}
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
package/src/commands/hook.mjs
CHANGED
|
@@ -36,6 +36,32 @@ import { codex } from '../adapters/codex.mjs';
|
|
|
36
36
|
|
|
37
37
|
const ADAPTERS = { claude, cursor, codex };
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Bounded health vocabulary for the hook heartbeat. The hook always exits 0, so
|
|
41
|
+
* these are the only witness that a fire did its work: `ok` (ran clean),
|
|
42
|
+
* `store_unavailable` (no usable store to read/query), `degraded` (a store
|
|
43
|
+
* lookup threw and was swallowed — the host still got its output), `crash` (an
|
|
44
|
+
* unexpected throw the outer guard caught). Kept small so `lorekit.hook.outcome`
|
|
45
|
+
* stays a low-cardinality metric label.
|
|
46
|
+
*/
|
|
47
|
+
const HOOK_OUTCOME = Object.freeze({
|
|
48
|
+
OK: 'ok',
|
|
49
|
+
STORE_UNAVAILABLE: 'store_unavailable',
|
|
50
|
+
DEGRADED: 'degraded',
|
|
51
|
+
CRASH: 'crash',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Map the mutable meter run() fills in to the bounded, non-PII counter
|
|
56
|
+
* dimensions. `event` is the host hook event (SessionStart / Stop / …), itself a
|
|
57
|
+
* small set; both labels are safe for a metric.
|
|
58
|
+
*/
|
|
59
|
+
function hookMeterAttrs(meter) {
|
|
60
|
+
const attrs = { 'lorekit.hook.outcome': meter.outcome };
|
|
61
|
+
if (meter.event) attrs['lorekit.hook.event'] = meter.event;
|
|
62
|
+
return attrs;
|
|
63
|
+
}
|
|
64
|
+
|
|
39
65
|
function readStdin() {
|
|
40
66
|
return new Promise((resolve) => {
|
|
41
67
|
let data = '';
|
|
@@ -48,15 +74,22 @@ function readStdin() {
|
|
|
48
74
|
}
|
|
49
75
|
|
|
50
76
|
export async function hook(args) {
|
|
77
|
+
// The meter run() fills in as it goes. Returned alongside the exit code so the
|
|
78
|
+
// metric-only dispatch (`meterCommand`) can count this fire with its health —
|
|
79
|
+
// the hook always exits 0, so this is the only signal a broken or degrading
|
|
80
|
+
// hook produces.
|
|
81
|
+
const meter = { event: null, outcome: HOOK_OUTCOME.OK };
|
|
51
82
|
// Guarded so any unexpected error still exits 0 (never break the host agent).
|
|
52
83
|
try {
|
|
53
|
-
|
|
84
|
+
const exitCode = await run(args, meter);
|
|
85
|
+
return { exitCode, meter: hookMeterAttrs(meter) };
|
|
54
86
|
} catch {
|
|
55
|
-
|
|
87
|
+
meter.outcome = HOOK_OUTCOME.CRASH;
|
|
88
|
+
return { exitCode: 0, meter: hookMeterAttrs(meter) };
|
|
56
89
|
}
|
|
57
90
|
}
|
|
58
91
|
|
|
59
|
-
async function run(args) {
|
|
92
|
+
async function run(args, meter) {
|
|
60
93
|
const adapter = ADAPTERS[args.adapter];
|
|
61
94
|
if (!adapter) {
|
|
62
95
|
// Unknown adapter: stay silent, don't disrupt the host.
|
|
@@ -81,6 +114,8 @@ async function run(args) {
|
|
|
81
114
|
|
|
82
115
|
if (!event) return 0;
|
|
83
116
|
|
|
117
|
+
meter.event = event;
|
|
118
|
+
|
|
84
119
|
const intent = adapter.intentFor(event);
|
|
85
120
|
if (intent === 'noop') return 0;
|
|
86
121
|
|
|
@@ -110,6 +145,8 @@ async function run(args) {
|
|
|
110
145
|
? control.hooksInstructions.SessionStart : null;
|
|
111
146
|
if (!store) {
|
|
112
147
|
// No store: emit a minimal header + instruction when present, then return.
|
|
148
|
+
// A SessionStart that cannot read lore is the highest-value failure to see.
|
|
149
|
+
meter.outcome = HOOK_OUTCOME.STORE_UNAVAILABLE;
|
|
113
150
|
if (sessionInstruction) {
|
|
114
151
|
emit(formatLessons(null, { repoScope: null }, { instruction: sessionInstruction }));
|
|
115
152
|
}
|
|
@@ -163,7 +200,7 @@ async function run(args) {
|
|
|
163
200
|
|
|
164
201
|
try {
|
|
165
202
|
const store = createStore(control);
|
|
166
|
-
if (!store) return 0;
|
|
203
|
+
if (!store) { meter.outcome = HOOK_OUTCOME.STORE_UNAVAILABLE; return 0; }
|
|
167
204
|
const lessons = await promptLessonsFromStore(store, scope, terms, {
|
|
168
205
|
// Delta only. Includes the SessionStart set, because "already shown"
|
|
169
206
|
// has to mean shown by anything — a hook that only remembered its own
|
|
@@ -183,7 +220,9 @@ async function run(args) {
|
|
|
183
220
|
}));
|
|
184
221
|
} catch {
|
|
185
222
|
// Best-effort, like every other branch: the user's turn proceeds either
|
|
186
|
-
// way, and a store hiccup must never cost them their prompt
|
|
223
|
+
// way, and a store hiccup must never cost them their prompt — but record
|
|
224
|
+
// that the lookup degraded so a store that always throws is visible.
|
|
225
|
+
meter.outcome = HOOK_OUTCOME.DEGRADED;
|
|
187
226
|
}
|
|
188
227
|
return 0;
|
|
189
228
|
}
|
|
@@ -210,6 +249,7 @@ async function run(args) {
|
|
|
210
249
|
}
|
|
211
250
|
} catch {
|
|
212
251
|
// best-effort — never break the host
|
|
252
|
+
meter.outcome = HOOK_OUTCOME.DEGRADED;
|
|
213
253
|
}
|
|
214
254
|
return 0;
|
|
215
255
|
}
|
|
@@ -224,6 +264,7 @@ async function run(args) {
|
|
|
224
264
|
let relevant = null;
|
|
225
265
|
try {
|
|
226
266
|
const store = createStore(control);
|
|
267
|
+
if (!store) meter.outcome = HOOK_OUTCOME.STORE_UNAVAILABLE;
|
|
227
268
|
if (store) {
|
|
228
269
|
// QUERY the store across the scope hierarchy for lessons matching this
|
|
229
270
|
// failure — not a post-filter of the SessionStart-injected set, which
|
|
@@ -235,6 +276,7 @@ async function run(args) {
|
|
|
235
276
|
}
|
|
236
277
|
} catch {
|
|
237
278
|
relevant = null; // never let a lesson lookup break the failure nudge
|
|
279
|
+
meter.outcome = HOOK_OUTCOME.DEGRADED;
|
|
238
280
|
}
|
|
239
281
|
const nudge = failureNudge(parsed.toolName, scope, control);
|
|
240
282
|
emit(relevant ? `${relevant}\n\n${nudge}` : nudge);
|
package/src/commands/install.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from '../shared/config.mjs';
|
|
30
30
|
import { buildRemoteUrl, splitEndpoint } from '../shared/mcp.mjs';
|
|
31
31
|
import { deriveScope } from '../shared/scope.mjs';
|
|
32
|
+
import { COMPLETION_SHELLS, detectShell, installCompletion } from '../shared/completions.mjs';
|
|
32
33
|
import { log, heading, status, select, err, c } from '../shared/util.mjs';
|
|
33
34
|
|
|
34
35
|
// The MCP server URL is fixed — there is only one hosted LoreKit endpoint.
|
|
@@ -179,6 +180,25 @@ function requestedHookMode(args) {
|
|
|
179
180
|
return null;
|
|
180
181
|
}
|
|
181
182
|
|
|
183
|
+
// Shell-completion install modes for `--completions`. `auto` detects the shell
|
|
184
|
+
// from $SHELL; a shell name installs that one; `none` skips. Kept a small closed
|
|
185
|
+
// set so a typo fails loudly rather than silently doing nothing.
|
|
186
|
+
export const COMPLETION_MODES = ['auto', 'none', ...COMPLETION_SHELLS];
|
|
187
|
+
|
|
188
|
+
// Same sentinel discipline as INVALID_HOOK_MODE: a bare or empty `--completions`
|
|
189
|
+
// is a usage error, not an absent flag, so it never silently falls through to
|
|
190
|
+
// the interactive/skip default.
|
|
191
|
+
export const INVALID_COMPLETION_MODE = '(missing value)';
|
|
192
|
+
|
|
193
|
+
// Resolve the requested completion mode from the flag, or null when it was not
|
|
194
|
+
// passed (the caller then prompts interactively / skips non-interactively).
|
|
195
|
+
export function requestedCompletionMode(args) {
|
|
196
|
+
const raw = args.completions;
|
|
197
|
+
if (typeof raw === 'string' && raw.trim()) return raw.trim().toLowerCase();
|
|
198
|
+
if (raw !== undefined && raw !== false) return INVALID_COMPLETION_MODE;
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
182
202
|
export async function install(args) {
|
|
183
203
|
const root = resolveProjectRoot(args.dir);
|
|
184
204
|
const nonInteractive = Boolean(args.yes) || !process.stdin.isTTY;
|
|
@@ -206,6 +226,21 @@ export async function install(args) {
|
|
|
206
226
|
// write step even when the scope is already fully installed.
|
|
207
227
|
const writeWebMcpJson = Boolean(args['mcp-json']);
|
|
208
228
|
|
|
229
|
+
// Validate `--completions` up front, same discipline as `--hooks`.
|
|
230
|
+
const requestedCompletion = requestedCompletionMode(args);
|
|
231
|
+
if (requestedCompletion === INVALID_COMPLETION_MODE) {
|
|
232
|
+
err(`\n --completions needs a mode. Valid modes: ${COMPLETION_MODES.join(' | ')}.`);
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
if (requestedCompletion !== null && !COMPLETION_MODES.includes(requestedCompletion)) {
|
|
236
|
+
err(`\n Unknown --completions mode "${requestedCompletion}". Valid modes: ${COMPLETION_MODES.join(' | ')}.`);
|
|
237
|
+
return 1;
|
|
238
|
+
}
|
|
239
|
+
// An explicit request to install completion (auto / zsh / fish) must reach the
|
|
240
|
+
// completion step even on an otherwise complete install. `none` is skip-only,
|
|
241
|
+
// like `--no-hooks`, so it never justifies the bypass.
|
|
242
|
+
const completionsExplicit = requestedCompletion !== null && requestedCompletion !== 'none';
|
|
243
|
+
|
|
209
244
|
heading('LoreKit install');
|
|
210
245
|
log(` project: ${c.dim(root)}`);
|
|
211
246
|
|
|
@@ -231,7 +266,7 @@ export async function install(args) {
|
|
|
231
266
|
|
|
232
267
|
const wiredEvents = installedHookEvents(root, scope);
|
|
233
268
|
|
|
234
|
-
if (currentState.isFullyInstalled && !force && !hooksFlagExplicit && !writeWebMcpJson) {
|
|
269
|
+
if (currentState.isFullyInstalled && !force && !hooksFlagExplicit && !writeWebMcpJson && !completionsExplicit) {
|
|
235
270
|
// Surface a clear, useful already-installed summary.
|
|
236
271
|
log('');
|
|
237
272
|
log(
|
|
@@ -474,6 +509,52 @@ export async function install(args) {
|
|
|
474
509
|
hooks = upsertClaudeHooks(root, scope, resolveHookRunner(), hookEvents);
|
|
475
510
|
}
|
|
476
511
|
|
|
512
|
+
// 5c. Shell completion — independent of the project/global scope above, since
|
|
513
|
+
// completion lives in the user's shell config, not a repo. Resolve the
|
|
514
|
+
// target shell, then write the script (fish auto-loads its dir; zsh gets a
|
|
515
|
+
// guarded ~/.zshrc block). Opt-in by default: an interactive run prompts
|
|
516
|
+
// for the detected shell, a non-interactive one skips unless
|
|
517
|
+
// `--completions` was passed — writing to a shell rc unprompted would be
|
|
518
|
+
// too invasive for a plain `install --yes`.
|
|
519
|
+
let completionResult = null;
|
|
520
|
+
let completionShell = null;
|
|
521
|
+
let completionSkipReason = null;
|
|
522
|
+
{
|
|
523
|
+
let mode = requestedCompletion; // 'auto' | 'zsh' | 'fish' | 'none' | null
|
|
524
|
+
if (mode === null) {
|
|
525
|
+
if (nonInteractive) {
|
|
526
|
+
mode = 'none';
|
|
527
|
+
} else {
|
|
528
|
+
const detected = detectShell(process.env);
|
|
529
|
+
if (!detected) {
|
|
530
|
+
completionSkipReason = 'shell not detected ($SHELL is not zsh or fish)';
|
|
531
|
+
mode = 'none';
|
|
532
|
+
} else {
|
|
533
|
+
log('');
|
|
534
|
+
const answer = await select(`Install ${detected} shell completion?`, [
|
|
535
|
+
{ label: 'Yes', value: 'yes', hint: `write the ${detected} completion and wire it up` },
|
|
536
|
+
{ label: 'No', value: 'no', hint: 'skip — add it later with `lorekit completion`' },
|
|
537
|
+
]);
|
|
538
|
+
mode = answer === 'yes' ? detected : 'none';
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (mode === 'auto') {
|
|
543
|
+
const detected = detectShell(process.env);
|
|
544
|
+
if (detected) completionShell = detected;
|
|
545
|
+
else completionSkipReason = 'shell not detected ($SHELL is not zsh or fish)';
|
|
546
|
+
} else if (mode === 'zsh' || mode === 'fish') {
|
|
547
|
+
completionShell = mode;
|
|
548
|
+
}
|
|
549
|
+
if (completionShell) {
|
|
550
|
+
try {
|
|
551
|
+
completionResult = installCompletion(completionShell, { home: homeDir() });
|
|
552
|
+
} catch (e) {
|
|
553
|
+
completionSkipReason = e.message;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
477
558
|
// Show global paths relative to ~ (a repo-relative path would be a mess of
|
|
478
559
|
// ../../); project paths stay repo-relative.
|
|
479
560
|
const display = (p) =>
|
|
@@ -550,6 +631,21 @@ export async function install(args) {
|
|
|
550
631
|
);
|
|
551
632
|
}
|
|
552
633
|
|
|
634
|
+
// Shell completion always lives under ~, so show it relative to home
|
|
635
|
+
// regardless of the install scope.
|
|
636
|
+
const homeDisplay = (p) => p.replace(homeDir(), '~');
|
|
637
|
+
if (completionResult) {
|
|
638
|
+
const where = completionResult.autoloaded
|
|
639
|
+
? `${homeDisplay(completionResult.file)} — auto-loaded by fish`
|
|
640
|
+
: `${homeDisplay(completionResult.file)}${completionResult.rcUpdated ? ' + ~/.zshrc block' : ''}`;
|
|
641
|
+
status('pass', `completion ${completionResult.shell}`, `${where}; open a new shell to use it`);
|
|
642
|
+
} else if (completionShell) {
|
|
643
|
+
// A shell was resolved but the write failed — surface why, don't stay silent.
|
|
644
|
+
status('warn', 'completion', completionSkipReason || 'not installed');
|
|
645
|
+
} else if (completionSkipReason) {
|
|
646
|
+
status('info', 'completion', `skipped — ${completionSkipReason}; add it with \`lorekit completion\``);
|
|
647
|
+
}
|
|
648
|
+
|
|
553
649
|
const kind = tokenKind(token);
|
|
554
650
|
if (scopeWriteOwnedByWeb) {
|
|
555
651
|
// `--project --mcp-json`: the committable web .mcp.json is the ONLY config
|
|
@@ -615,5 +711,11 @@ export async function install(args) {
|
|
|
615
711
|
// Bounded, non-PII: which of the three presets this run landed on. Counting
|
|
616
712
|
// the `--no-hooks` FLAG (as telemetry already did) says nothing about what a
|
|
617
713
|
// user picks when actually asked, which is the whole point of the prompt.
|
|
618
|
-
|
|
714
|
+
// `completions` records the shell wired (or 'none') — a bounded enum, never a
|
|
715
|
+
// path.
|
|
716
|
+
return {
|
|
717
|
+
exitCode: 0,
|
|
718
|
+
'lorekit.cli.hooks_mode': hookMode,
|
|
719
|
+
'lorekit.cli.completions': completionResult ? completionResult.shell : 'none',
|
|
720
|
+
};
|
|
619
721
|
}
|