@lorekit/cli 1.56.0 → 1.58.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/bin/lorekit.mjs CHANGED
@@ -110,6 +110,10 @@ ${c.bold('Commands')}
110
110
  resolved store (local .lorekit/ offline, or remote passthrough) so
111
111
  .mcp.json can point at the CLI instead of mcp-remote. Speaks
112
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.
113
117
 
114
118
  ${c.bold('Options')}
115
119
  -d, --dir <path> Target project root (default: current directory)
@@ -141,6 +145,7 @@ ${c.bold('Options')}
141
145
  --no-hooks Skip wiring the lifecycle hooks (install)
142
146
  --mcp-json Also write a committable project .mcp.json for Claude Code on
143
147
  the web — auth via \${LOREKIT_TOKEN}, no embedded secret (install)
148
+ --completions <s> Install shell completion: auto | zsh | fish | none (install)
144
149
  --force Overwrite existing skill files (install)
145
150
  --deep Do a write→read→delete round-trip (doctor)
146
151
  --telemetry Verify the OTLP export credential works (doctor)
@@ -217,6 +222,10 @@ ${c.bold('Options')}
217
222
  --no-hooks Skip wiring the lifecycle hooks (leaves existing ones alone)
218
223
  --mcp-json Also write a committable project .mcp.json (\${LOREKIT_TOKEN} auth)
219
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).
220
229
  --force Overwrite existing skill files
221
230
  -y, --yes Non-interactive; never prompt (defaults to --project, and to the
222
231
  already-wired hooks — all on a fresh install)
@@ -228,6 +237,7 @@ ${c.bold('Examples')}
228
237
  npx @lorekit/cli install --global --mcp-json --yes # local CLI + committable web config
229
238
  npx @lorekit/cli install --hooks read-only --yes
230
239
  npx @lorekit/cli install --no-hooks --yes
240
+ npx @lorekit/cli install --completions auto --yes # detect \$SHELL and wire completion
231
241
  `,
232
242
  uninstall: `${c.bold('lorekit uninstall')} — reverse install for the chosen scope
233
243
 
@@ -856,6 +866,30 @@ Machine-facing: exposes the memory.* tools backed by the resolved store (local
856
866
  .lorekit/ offline, or remote passthrough) over JSON-RPC on stdin/stdout, so
857
867
  .mcp.json can point at the CLI instead of mcp-remote. Not run by hand.
858
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
+
859
893
  ${c.bold('Options')}
860
894
  -d, --dir <path> Target project root (default: current directory)
861
895
  `,
@@ -866,7 +900,7 @@ ${c.bold('Options')}
866
900
  // typo like `--gloabl` should fail loudly, not quietly fall back to --project.
867
901
  const KNOWN_FLAGS = [
868
902
  'dir', 'project', 'global', 'endpoint', 'token', 'mode', 'store',
869
- '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',
870
904
  'event', 'json', 'scope', 'key', 'threshold', 'help', 'version', 'telemetry',
871
905
  'value', 'tags', 'source-agent', 'trigger', 'kind', 'host', 'ttl-days', 'clear-ttl', 'org', 'remote', 'local',
872
906
  // `view` is accepted-and-IGNORED, not documented: the Explorer dropped the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lorekit/cli",
3
- "version": "1.56.0",
3
+ "version": "1.58.0",
4
4
  "description": "Install the LoreKit shared-memory skill and run health checks for the LoreKit MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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
+ }
@@ -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
- return await run(args);
84
+ const exitCode = await run(args, meter);
85
+ return { exitCode, meter: hookMeterAttrs(meter) };
54
86
  } catch {
55
- return 0;
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);
@@ -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
- return { exitCode: 0, 'lorekit.cli.hooks_mode': hookMode };
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
  }
@@ -13,6 +13,7 @@ import {
13
13
  removeClaudeHooks,
14
14
  homeDir,
15
15
  } from '../shared/config.mjs';
16
+ import { COMPLETION_SHELLS, removeCompletion } from '../shared/completions.mjs';
16
17
  import { log, heading, status, c } from '../shared/util.mjs';
17
18
 
18
19
  function ask(question) {
@@ -67,10 +68,20 @@ export async function uninstall(args) {
67
68
  // "nothing to remove", other servers in the file are preserved.
68
69
  const webMcp = scope === 'global' ? attempt(() => removeWebMcpServer(root)) : null;
69
70
  const hooks = attempt(() => removeClaudeHooks(root, scope));
71
+ // Shell completion is a single user-level artefact (not project/global
72
+ // scoped), so tear it down for every supported shell regardless of the chosen
73
+ // scope. Each `removeCompletion` touches only lorekit's own script file and
74
+ // guarded ~/.zshrc block, so it never disturbs a hand-written completion.
75
+ const completions = COMPLETION_SHELLS.map((shell) => ({
76
+ shell,
77
+ step: attempt(() => removeCompletion(shell, { home: homeDir() })),
78
+ }));
70
79
 
71
80
  // Global paths shown relative to ~; project paths repo-relative.
72
81
  const display = (p) =>
73
82
  scope === 'global' ? p.replace(homeDir(), '~') : path.relative(root, p) || p;
83
+ // Completion artefacts always live under ~, whatever the uninstall scope.
84
+ const homeDisplay = (p) => p.replace(homeDir(), '~');
74
85
  const mcpLabel = scope === 'global' ? '~/.claude.json' : '.mcp.json';
75
86
 
76
87
  heading('Done');
@@ -95,15 +106,23 @@ export async function uninstall(args) {
95
106
  done: (r) => `${r.removed} removed → ${display(r.file)}`,
96
107
  noop: 'no lorekit hooks — nothing to remove',
97
108
  });
109
+ for (const { shell, step } of completions) {
110
+ report(step, `completion ${shell}`, {
111
+ done: (r) => `removed → ${homeDisplay(r.file)}${r.rcUpdated ? ' + ~/.zshrc block' : ''}`,
112
+ noop: 'not installed — nothing to remove',
113
+ });
114
+ }
98
115
 
99
116
  const skillStepList = skillSteps.map((s) => s.step);
100
117
  const webSteps = webMcp ? [webMcp] : [];
101
- const failed = [...skillStepList, mcp, ...webSteps, hooks].some((s) => !s.ok);
118
+ const completionSteps = completions.map((s) => s.step);
119
+ const failed = [...skillStepList, mcp, ...webSteps, hooks, ...completionSteps].some((s) => !s.ok);
102
120
  const any =
103
121
  (skillStepList.some((s) => s.result?.removed) ||
104
122
  mcp.result?.removed ||
105
123
  webMcp?.result?.removed ||
106
- hooks.result?.removed) && true;
124
+ hooks.result?.removed ||
125
+ completionSteps.some((s) => s.result?.removed)) && true;
107
126
 
108
127
  if (failed) {
109
128
  log(`\n ${c.dim('Some items could not be removed and were left untouched — see above.')}`);
package/src/commands.mjs CHANGED
@@ -54,6 +54,7 @@ import { migrate } from './commands/migrate.mjs';
54
54
  import { bootstrap } from './commands/bootstrap.mjs';
55
55
  import { mcpServer } from './commands/mcp-server.mjs';
56
56
  import { purge, purgeExpired } from './commands/purge.mjs';
57
+ import { completion } from './commands/completion.mjs';
57
58
 
58
59
  /**
59
60
  * Every command, in the order the top-level help lists them.
@@ -90,6 +91,11 @@ export const COMMANDS = [
90
91
  { name: 'purge-expired', run: purgeExpired, traced: true, strictFlags: true, tool: 'memory.purge_expired' },
91
92
 
92
93
  // ── Machine-facing ──────────────────────────────────────────────────────────
94
+ // `completion` is machine-facing for the same reason hook/mcp are: its stdout
95
+ // is a contract a shell parses — a completion SCRIPT, or (on the `--complete`
96
+ // callback the scripts fire on every TAB) a newline-delimited candidate list.
97
+ // A span per keypress would be a firehose, so it is metered, never traced.
98
+ { name: 'completion', run: completion, traced: false, strictFlags: false, machine: true, native: 'prints shell completion scripts — stdout is a shell contract' },
93
99
  { name: 'hook', run: hook, traced: false, strictFlags: false, machine: true, native: 'host hook engine — stdout is the host\'s JSON contract' },
94
100
  { name: 'mcp', run: mcpServer, traced: false, strictFlags: false, machine: true, native: 'local stdio MCP server — stdout is JSON-RPC frames' },
95
101
  ];
@@ -0,0 +1,471 @@
1
+ // Shell completion: the ONE place the completion SURFACE is described, and the
2
+ // helpers that write / remove the generated scripts on disk.
3
+ //
4
+ // The `completion` command (src/commands/completion.mjs), `install` and
5
+ // `uninstall` all build on this module. It is deliberately dependency-light — it
6
+ // does NOT import the command registry (commands.mjs), because `install` imports
7
+ // this module and commands.mjs imports `install`, so pulling the registry in
8
+ // here would close an import cycle. The registry stays the source of truth for
9
+ // which commands EXIST; a test (test/completion.test.mjs) cross-checks that this
10
+ // spec covers every human command and references only real flags, so the two
11
+ // can never silently drift.
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { homeDir, writeFileAtomic } from './config.mjs';
15
+
16
+ // Shells we generate for. zsh and fish were the request; bash is intentionally
17
+ // absent (its completion model is fiddlier and nobody asked). Adding one is a
18
+ // new renderer plus a `completionTargets` case — nothing else changes.
19
+ export const COMPLETION_SHELLS = ['zsh', 'fish'];
20
+
21
+ // Flag metadata, shared across commands so a flag's description and value-type
22
+ // are stated once. `arg` names the value a flag takes (absent ⇒ a boolean flag
23
+ // that takes none); `complete` selects a DYNAMIC candidate source the generated
24
+ // script calls back for; `values` is a STATIC candidate list; `short` is the
25
+ // one-letter alias. Kept terse on purpose — these strings land verbatim in the
26
+ // completion scripts a user reads.
27
+ const FLAG = {
28
+ dir: { desc: 'Target project root', arg: 'dir', short: 'd' },
29
+ project: { desc: 'Install/act for this project only' },
30
+ global: { desc: 'Install/act for every project (~/.claude)' },
31
+ endpoint: { desc: 'Remote endpoint override', arg: 'url', short: 'e' },
32
+ token: { desc: 'Remote token override', arg: 'token', short: 't' },
33
+ mode: { desc: 'Override the resolved mode', arg: 'mode', values: ['off', 'local', 'remote'] },
34
+ store: { desc: 'Local project-tier store directory', arg: 'dir' },
35
+ from: { desc: 'Source store / range start', arg: 'path' },
36
+ to: { desc: 'Destination / range end', arg: 'dest' },
37
+ apply: { desc: 'Apply the migration (alias of --yes)' },
38
+ yes: { desc: 'Non-interactive; never prompt', short: 'y' },
39
+ hooks: { desc: 'Lifecycle hooks to wire', arg: 'mode', values: ['all', 'read-only', 'none'] },
40
+ 'no-hooks': { desc: 'Skip wiring the lifecycle hooks' },
41
+ 'mcp-json': { desc: 'Also write a committable project .mcp.json' },
42
+ completions: { desc: 'Install shell completion', arg: 'shell', values: ['auto', 'zsh', 'fish', 'none'] },
43
+ force: { desc: 'Overwrite / hard-delete' },
44
+ deep: { desc: 'Do a write→read→delete round-trip' },
45
+ telemetry: { desc: 'Verify the OTLP export credential works' },
46
+ json: { desc: 'Machine-readable output' },
47
+ scope: { desc: 'Restrict to / name a scope', arg: 'scope', complete: 'scope' },
48
+ key: { desc: 'Name the key explicitly', arg: 'key' },
49
+ threshold: { desc: 'Duplicate-similarity cutoff (0..1)', arg: 'n' },
50
+ 'cluster-by-key': { desc: 'Cluster by shared key capture', arg: 'regex' },
51
+ value: { desc: 'Memory value', arg: 'text' },
52
+ tags: { desc: 'Comma-separated tags', arg: 'a,b,c' },
53
+ 'source-agent': { desc: 'Source agent name to record', arg: 'name' },
54
+ trigger: { desc: 'Trigger context slug', arg: 'slug' },
55
+ 'ttl-days': { desc: 'Days until auto-expiry (1..365)', arg: 'n' },
56
+ 'clear-ttl': { desc: 'Remove any existing expiry' },
57
+ org: { desc: "Write to this org's scope (remote)", arg: 'slug' },
58
+ 'origin-repo': { desc: 'Override the provenance repository', arg: 'owner/name' },
59
+ 'origin-branch': { desc: 'Override the provenance branch', arg: 'branch' },
60
+ 'origin-commit': { desc: 'Override the provenance commit', arg: 'sha' },
61
+ 'origin-pr': { desc: 'The pull request this came out of', arg: 'n' },
62
+ 'no-origin': { desc: 'Record no provenance at all' },
63
+ remote: { desc: 'Force the remote store' },
64
+ local: { desc: 'Force the local offline store' },
65
+ link: { desc: 'Print the dashboard deep-link URL instead' },
66
+ base: { desc: 'Dashboard base URL for deep links', arg: 'url' },
67
+ q: { desc: 'Pre-fill the Explorer search box', arg: 'text' },
68
+ owner: { desc: 'Ownership filter', arg: 'owner' },
69
+ range: { desc: 'Date range as JSON', arg: 'json' },
70
+ archived: { desc: 'Include archived memories' },
71
+ 'retention-days': { desc: 'Only purge archived older than n days', arg: 'n' },
72
+ files: { desc: 'Changed files to check', arg: 'path' },
73
+ strict: { desc: 'Exit non-zero on any unmet obligation' },
74
+ };
75
+
76
+ // Every command's completion shape, in the top-level help order. `flags` lists
77
+ // the flag NAMES (keys of FLAG) a command accepts; `positional` names the kind
78
+ // of first positional argument, which drives dynamic value completion:
79
+ // 'address' → a `scope::key` (dynamic, from the local store)
80
+ // 'query' → free text (no completion)
81
+ // 'shell' → the `completion` command's zsh|fish argument
82
+ // `values` overrides a flag's static candidate list for this command only
83
+ // (migrate's `--to` is an enum here but a free date elsewhere).
84
+ const COMMANDS = [
85
+ { name: 'install', summary: 'Scaffold skills, wire the MCP server, install hooks',
86
+ flags: ['dir', 'project', 'global', 'endpoint', 'token', 'hooks', 'no-hooks', 'mcp-json', 'completions', 'force', 'yes'] },
87
+ { name: 'uninstall', summary: 'Reverse install for the chosen scope',
88
+ flags: ['dir', 'project', 'global', 'yes'] },
89
+ { name: 'doctor', summary: 'Verify the install, connectivity, token, scope',
90
+ flags: ['dir', 'mode', 'endpoint', 'token', 'store', 'deep', 'telemetry'] },
91
+ { name: 'list', summary: 'List memories for the current directory', aliases: ['ls'],
92
+ flags: ['dir', 'scope', 'json', 'endpoint', 'token', 'store', 'link', 'base'] },
93
+ { name: 'search', summary: 'Full-text search the applicable memories', aliases: ['grep'],
94
+ positional: 'query',
95
+ flags: ['dir', 'scope', 'json', 'endpoint', 'token', 'store', 'link', 'base'] },
96
+ { name: 'show', summary: 'Inspect one memory in full', positional: 'address',
97
+ flags: ['dir', 'json', 'endpoint', 'token', 'store', 'scope', 'key', 'link', 'base'] },
98
+ { name: 'stats', summary: 'Count memories per scope and store',
99
+ flags: ['dir', 'scope', 'json', 'endpoint', 'token', 'store'] },
100
+ { name: 'scopes', summary: 'Inventory every distinct scope',
101
+ flags: ['dir', 'scope', 'json', 'endpoint', 'token', 'store'] },
102
+ { name: 'diff', summary: 'Compare the offline and remote stores',
103
+ flags: ['dir', 'scope', 'json', 'endpoint', 'token', 'store'] },
104
+ { name: 'tree', summary: 'Show scope precedence and which memory wins', aliases: ['resolve'],
105
+ flags: ['dir', 'scope', 'json', 'endpoint', 'token', 'store', 'link', 'base'] },
106
+ { name: 'lint', summary: 'Flag low-quality memories (CI gate)',
107
+ flags: ['dir', 'scope', 'json', 'endpoint', 'token', 'store'] },
108
+ { name: 'dedupe', summary: 'Find likely-duplicate memories',
109
+ flags: ['dir', 'scope', 'threshold', 'cluster-by-key', 'json', 'endpoint', 'token', 'store'] },
110
+ { name: 'obligations', summary: 'Check changed files against the surface-partner map',
111
+ positional: 'path',
112
+ flags: ['files', 'strict', 'json'] },
113
+ { name: 'link', summary: 'Print a shareable dashboard deep-link URL', aliases: ['url'],
114
+ positional: 'address',
115
+ flags: ['dir', 'scope', 'key', 'q', 'owner', 'tags', 'range', 'from', 'to', 'archived', 'base', 'json'] },
116
+ { name: 'migrate', summary: 'Relocate or push a local store',
117
+ values: { to: ['home', 'project', 'remote'] },
118
+ flags: ['dir', 'from', 'to', 'apply', 'yes'] },
119
+ { name: 'bootstrap', summary: 'Apply the LoreKit schema to your own database',
120
+ flags: ['yes', 'endpoint', 'token'] },
121
+ { name: 'write', summary: 'Create or update a memory', positional: 'address',
122
+ flags: ['dir', 'scope', 'key', 'value', 'tags', 'source-agent', 'trigger', 'ttl-days', 'clear-ttl',
123
+ 'org', 'origin-repo', 'origin-branch', 'origin-commit', 'origin-pr', 'no-origin', 'remote', 'local',
124
+ 'json', 'endpoint', 'token', 'store'] },
125
+ { name: 'archive', summary: 'Hide a memory without losing it', positional: 'address',
126
+ flags: ['scope', 'key', 'remote', 'local', 'json'] },
127
+ { name: 'delete', summary: 'Archive a memory, or destroy it with --force', aliases: ['rm'],
128
+ positional: 'address',
129
+ flags: ['force', 'scope', 'key', 'remote', 'local', 'json'] },
130
+ { name: 'restore', summary: 'Bring an archived memory back', positional: 'address',
131
+ flags: ['scope', 'key', 'remote', 'local', 'json'] },
132
+ { name: 'purge', summary: 'Delete archived memories past a retention window',
133
+ flags: ['retention-days', 'yes', 'json', 'endpoint', 'token'] },
134
+ { name: 'purge-expired', summary: 'Delete every TTL-expired memory',
135
+ flags: ['yes', 'json', 'endpoint', 'token'] },
136
+ { name: 'completion', summary: 'Print a shell completion script', positional: 'shell' },
137
+ ];
138
+
139
+ // The completion spec, resolved to concrete flag metadata. Exported so the
140
+ // renderers and the parity test read the SAME structure.
141
+ export function completionSpec() {
142
+ return COMMANDS.map((cmd) => ({
143
+ name: cmd.name,
144
+ summary: cmd.summary,
145
+ aliases: cmd.aliases ?? [],
146
+ positional: cmd.positional ?? null,
147
+ flags: (cmd.flags ?? []).map((flagName) => {
148
+ const meta = FLAG[flagName];
149
+ if (!meta) throw new Error(`completionSpec: command ${cmd.name} references unknown flag ${flagName}`);
150
+ const values = cmd.values?.[flagName] ?? meta.values ?? null;
151
+ return { name: flagName, ...meta, values };
152
+ }),
153
+ }));
154
+ }
155
+
156
+ // Every command word a completion offers — canonical names AND their aliases —
157
+ // so `lorekit l<TAB>` surfaces both `list` and `ls`. Aliases inherit the
158
+ // canonical command's summary.
159
+ function commandWords(spec) {
160
+ const words = [];
161
+ for (const cmd of spec) {
162
+ words.push({ word: cmd.name, summary: cmd.summary });
163
+ for (const alias of cmd.aliases) words.push({ word: alias, summary: cmd.summary });
164
+ }
165
+ return words;
166
+ }
167
+
168
+ // Every alias-or-name that dispatches to one command, for the per-command
169
+ // `case` arm (zsh) / `__fish_seen_subcommand_from` set (fish).
170
+ const cmdWordSet = (cmd) => [cmd.name, ...cmd.aliases];
171
+
172
+ // --- zsh -------------------------------------------------------------------
173
+
174
+ // zsh optspec for one flag: `'(-x --name)'{-x,--name}'[desc]:arg:action'` when a
175
+ // short alias exists, else `'--name[desc]:arg:action'`. A boolean flag omits the
176
+ // `:arg:action` tail.
177
+ function zshFlag(flag) {
178
+ const desc = zshDesc(flag.desc);
179
+ const tail = flag.arg ? `:${flag.arg}:${zshAction(flag)}` : '';
180
+ if (flag.short) {
181
+ return `'(-${flag.short} --${flag.name})'{-${flag.short},--${flag.name}}'[${desc}]${tail}'`;
182
+ }
183
+ return `'--${flag.name}[${desc}]${tail}'`;
184
+ }
185
+
186
+ // The zsh completion ACTION for a flag's value: a dynamic helper, a static
187
+ // `(a b c)` list, file/dir completion, or nothing.
188
+ function zshAction(flag) {
189
+ if (flag.complete === 'scope') return '_lorekit_scopes';
190
+ if (flag.values) return `(${flag.values.join(' ')})`;
191
+ if (flag.arg === 'dir') return '_files -/';
192
+ if (flag.arg === 'path' || flag.arg === 'file') return '_files';
193
+ return ' ';
194
+ }
195
+
196
+ // The zsh positional-argument spec for a command, or '' when it takes none.
197
+ function zshPositional(kind) {
198
+ if (kind === 'address') return `'*::address:_lorekit_addresses'`;
199
+ if (kind === 'query') return `'*::query: '`;
200
+ if (kind === 'path') return `'*::file:_files'`;
201
+ if (kind === 'shell') return `'1:shell:(${COMPLETION_SHELLS.join(' ')})'`;
202
+ return '';
203
+ }
204
+
205
+ // zsh escaping: the description sits inside a single-quoted `[...]`, so a literal
206
+ // single quote is doubled and a `[`/`]`/`:` is backslash-escaped (they are
207
+ // optspec metacharacters). Our descriptions avoid these, but escaping keeps a
208
+ // future edit from producing a script that fails to source.
209
+ function zshDesc(s) {
210
+ return String(s).replace(/'/g, "''").replace(/[\][:]/g, '\\$&');
211
+ }
212
+
213
+ function renderZsh(spec) {
214
+ const commands = commandWords(spec)
215
+ .map((c) => ` '${c.word}:${zshDesc(c.summary)}'`)
216
+ .join('\n');
217
+
218
+ const arms = spec
219
+ .map((cmd) => {
220
+ const specs = [zshPositional(cmd.positional), ...cmd.flags.map(zshFlag)].filter(Boolean);
221
+ const body = specs.length ? `_arguments \\\n ${specs.join(' \\\n ')}` : ':';
222
+ return ` ${cmdWordSet(cmd).join('|')})\n ${body}\n ;;`;
223
+ })
224
+ .join('\n');
225
+
226
+ return `#compdef lorekit
227
+ # LoreKit CLI completion for zsh — generated by \`lorekit completion zsh\`.
228
+ # Regenerate after upgrading the CLI. See \`lorekit completion --help\`.
229
+
230
+ _lorekit() {
231
+ local -a _lk_commands
232
+ _lk_commands=(
233
+ ${commands}
234
+ )
235
+
236
+ local curcontext="$curcontext" state line
237
+ typeset -A opt_args
238
+
239
+ _arguments -C '1:command:->cmds' '*::arg:->args' && return 0
240
+
241
+ case $state in
242
+ cmds)
243
+ _describe -t commands 'lorekit command' _lk_commands
244
+ ;;
245
+ args)
246
+ case $line[1] in
247
+ ${arms}
248
+ esac
249
+ ;;
250
+ esac
251
+ }
252
+
253
+ # Dynamic candidates come from the CLI itself, so they always reflect the local
254
+ # store. Failures are swallowed — a missing token or store just yields no
255
+ # candidates, never an error at the prompt.
256
+ _lorekit_scopes() {
257
+ local -a _lk_scopes
258
+ _lk_scopes=(\${(f)"$(lorekit completion --complete scope 2>/dev/null)"})
259
+ compadd -a _lk_scopes
260
+ }
261
+
262
+ _lorekit_addresses() {
263
+ local -a _lk_addr
264
+ _lk_addr=(\${(f)"$(lorekit completion --complete key 2>/dev/null)"})
265
+ compadd -a _lk_addr
266
+ }
267
+
268
+ compdef _lorekit lorekit
269
+ `;
270
+ }
271
+
272
+ // --- fish ------------------------------------------------------------------
273
+
274
+ // fish description escaping: the value sits in a single-quoted `-d '...'`, so a
275
+ // single quote and a backslash are backslash-escaped.
276
+ function fishDesc(s) {
277
+ return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
278
+ }
279
+
280
+ // One `complete` line for a flag under a command guard.
281
+ function fishFlag(guard, flag) {
282
+ const parts = ['complete', '-c', 'lorekit', '-n', `'${guard}'`, '-l', flag.name];
283
+ if (flag.short) parts.push('-s', flag.short);
284
+ if (flag.arg) parts.push('-r'); // requires a value
285
+ if (flag.complete === 'scope') parts.push('-f', '-a', "'(lorekit completion --complete scope)'");
286
+ else if (flag.values) parts.push('-a', `'${flag.values.join(' ')}'`);
287
+ parts.push('-d', `'${fishDesc(flag.desc)}'`);
288
+ return parts.join(' ');
289
+ }
290
+
291
+ function fishPositional(guard, kind) {
292
+ if (kind === 'address') {
293
+ return `complete -c lorekit -n '${guard}' -f -a '(lorekit completion --complete key)'`;
294
+ }
295
+ if (kind === 'path') {
296
+ // Re-enable the file completion the global `complete -c lorekit -f` turned off.
297
+ return `complete -c lorekit -n '${guard}' -F`;
298
+ }
299
+ if (kind === 'shell') {
300
+ return `complete -c lorekit -n '${guard}' -f -a '${COMPLETION_SHELLS.join(' ')}'`;
301
+ }
302
+ return null;
303
+ }
304
+
305
+ function renderFish(spec) {
306
+ const lines = [
307
+ '# LoreKit CLI completion for fish — generated by `lorekit completion fish`.',
308
+ '# Install to ~/.config/fish/completions/lorekit.fish (fish auto-loads it).',
309
+ '',
310
+ '# Disable file completion by default; commands opt back in where it helps.',
311
+ 'complete -c lorekit -f',
312
+ '',
313
+ '# Subcommands (offered only before one is chosen).',
314
+ ];
315
+
316
+ for (const cmd of commandWords(spec)) {
317
+ lines.push(
318
+ `complete -c lorekit -n __fish_use_subcommand -a ${cmd.word} -d '${fishDesc(cmd.summary)}'`,
319
+ );
320
+ }
321
+
322
+ for (const cmd of spec) {
323
+ const guard = `__fish_seen_subcommand_from ${cmdWordSet(cmd).join(' ')}`;
324
+ lines.push('', `# ${cmd.name}`);
325
+ const positional = fishPositional(guard, cmd.positional);
326
+ if (positional) lines.push(positional);
327
+ for (const flag of cmd.flags) lines.push(fishFlag(guard, flag));
328
+ }
329
+
330
+ return lines.join('\n') + '\n';
331
+ }
332
+
333
+ // Render the completion script for a shell. Throws on an unknown shell so a
334
+ // caller (or a typo) fails loudly rather than writing an empty file.
335
+ export function renderCompletion(shell, spec = completionSpec()) {
336
+ if (shell === 'zsh') return renderZsh(spec);
337
+ if (shell === 'fish') return renderFish(spec);
338
+ throw new Error(`Unsupported shell: ${shell}. Supported: ${COMPLETION_SHELLS.join(', ')}`);
339
+ }
340
+
341
+ // --- shell detection + on-disk install/teardown ----------------------------
342
+
343
+ // The shell a bare `--completions auto` targets, from $SHELL. Returns a
344
+ // supported shell name or null (unknown / unsupported), so the caller can say so
345
+ // rather than guessing.
346
+ export function detectShell(env = process.env) {
347
+ const shellPath = env.SHELL || '';
348
+ const base = path.basename(shellPath);
349
+ return COMPLETION_SHELLS.includes(base) ? base : null;
350
+ }
351
+
352
+ // The zsh block appended to ~/.zshrc, wrapped in idempotent guard markers so
353
+ // re-running install never duplicates it and uninstall can remove exactly it.
354
+ // zsh — unlike fish — has no universal auto-load directory, so the file lives in
355
+ // a LoreKit-owned dir that this block adds to $fpath before compinit.
356
+ const ZSH_MARK_START = '# >>> lorekit completions >>>';
357
+ const ZSH_MARK_END = '# <<< lorekit completions <<<';
358
+
359
+ function zshBlock(dir) {
360
+ return [
361
+ ZSH_MARK_START,
362
+ '# Added by `lorekit install`. Managed block — edits here are overwritten.',
363
+ `fpath=("${dir}" $fpath)`,
364
+ 'autoload -Uz compinit && compinit',
365
+ ZSH_MARK_END,
366
+ ].join('\n');
367
+ }
368
+
369
+ // Where each shell's completion artefacts live. `file` is the script; `rcFile`
370
+ // (+ the guard block) is only used for shells with no auto-load directory.
371
+ // zsh → ~/.lorekit/completions/_lorekit, sourced via an ~/.zshrc fpath block
372
+ // fish → ~/.config/fish/completions/lorekit.fish (fish auto-loads the dir)
373
+ export function completionTargets(shell, home = homeDir()) {
374
+ if (shell === 'zsh') {
375
+ const dir = path.join(home, '.lorekit', 'completions');
376
+ return {
377
+ shell,
378
+ dir,
379
+ file: path.join(dir, '_lorekit'),
380
+ rcFile: path.join(home, '.zshrc'),
381
+ autoloaded: false,
382
+ };
383
+ }
384
+ if (shell === 'fish') {
385
+ const dir = path.join(home, '.config', 'fish', 'completions');
386
+ return { shell, dir, file: path.join(dir, 'lorekit.fish'), rcFile: null, autoloaded: true };
387
+ }
388
+ throw new Error(`Unsupported shell: ${shell}. Supported: ${COMPLETION_SHELLS.join(', ')}`);
389
+ }
390
+
391
+ // Splice the guarded zsh block into rc text: replace an existing block in place
392
+ // (so a stale fpath dir is corrected), else append it. Pure, so the idempotency
393
+ // is unit-testable without touching a real ~/.zshrc.
394
+ export function upsertGuardedBlock(rcText, block) {
395
+ const text = rcText || '';
396
+ const start = text.indexOf(ZSH_MARK_START);
397
+ if (start === -1) {
398
+ const sep = text.length === 0 || text.endsWith('\n') ? '' : '\n';
399
+ return { text: `${text}${sep}${block}\n`, changed: text.indexOf(block) === -1 };
400
+ }
401
+ const end = text.indexOf(ZSH_MARK_END, start);
402
+ if (end === -1) {
403
+ // A start marker with no end — treat the rest of the file as the block.
404
+ return { text: text.slice(0, start) + block + '\n', changed: true };
405
+ }
406
+ const before = text.slice(0, start);
407
+ const after = text.slice(end + ZSH_MARK_END.length).replace(/^\n/, '');
408
+ const next = `${before}${block}\n${after}`;
409
+ return { text: next, changed: next !== text };
410
+ }
411
+
412
+ // Remove the guarded zsh block from rc text (uninstall). Pure inverse of
413
+ // `upsertGuardedBlock`; a no-op when no block is present.
414
+ export function removeGuardedBlock(rcText) {
415
+ const text = rcText || '';
416
+ const start = text.indexOf(ZSH_MARK_START);
417
+ if (start === -1) return { text, changed: false };
418
+ const end = text.indexOf(ZSH_MARK_END, start);
419
+ const cut = end === -1 ? text.length : end + ZSH_MARK_END.length;
420
+ const before = text.slice(0, start).replace(/\n$/, '');
421
+ const after = text.slice(cut).replace(/^\n/, '');
422
+ const next = [before, after].filter(Boolean).join('\n') + (before || after ? '\n' : '');
423
+ return { text: next, changed: true };
424
+ }
425
+
426
+ // Write the completion script to disk for `shell`, wiring the ~/.zshrc block
427
+ // when the shell has no auto-load directory. Returns what happened so `install`
428
+ // can report it. `home` is injectable for tests.
429
+ export function installCompletion(shell, { home = homeDir() } = {}) {
430
+ const targets = completionTargets(shell, home);
431
+ const script = renderCompletion(shell);
432
+ fs.mkdirSync(targets.dir, { recursive: true });
433
+ writeFileAtomic(targets.file, script);
434
+
435
+ let rcUpdated = false;
436
+ if (targets.rcFile) {
437
+ const existing = fs.existsSync(targets.rcFile) ? fs.readFileSync(targets.rcFile, 'utf8') : '';
438
+ const { text, changed } = upsertGuardedBlock(existing, zshBlock(targets.dir));
439
+ if (changed) {
440
+ writeFileAtomic(targets.rcFile, text);
441
+ rcUpdated = true;
442
+ }
443
+ }
444
+
445
+ return { shell, file: targets.file, rcFile: targets.rcFile, autoloaded: targets.autoloaded, rcUpdated };
446
+ }
447
+
448
+ // Remove the completion script and any ~/.zshrc block for `shell`. Best-effort
449
+ // and idempotent — a missing file / block is reported as `removed: false`, never
450
+ // an error. `uninstall` calls this for every supported shell.
451
+ export function removeCompletion(shell, { home = homeDir() } = {}) {
452
+ const targets = completionTargets(shell, home);
453
+ let removed = false;
454
+ if (fs.existsSync(targets.file)) {
455
+ fs.rmSync(targets.file, { force: true });
456
+ removed = true;
457
+ }
458
+ let rcUpdated = false;
459
+ if (targets.rcFile && fs.existsSync(targets.rcFile)) {
460
+ const existing = fs.readFileSync(targets.rcFile, 'utf8');
461
+ const { text, changed } = removeGuardedBlock(existing);
462
+ if (changed) {
463
+ writeFileAtomic(targets.rcFile, text);
464
+ rcUpdated = true;
465
+ }
466
+ }
467
+ // `removed` reports whether ANYTHING was torn down (the script file or the rc
468
+ // block), so a caller's "nothing to remove" line is honest even in the rare
469
+ // case where only the block survived a manually-deleted file.
470
+ return { shell, file: targets.file, removed: removed || rcUpdated, fileRemoved: removed, rcUpdated };
471
+ }
@@ -1,5 +1,8 @@
1
1
  // Minimal MCP-over-HTTP (JSON-RPC 2.0) client for the LoreKit endpoint.
2
- // Zero dependencies — uses the global fetch (Node 18+).
2
+ // Zero EXTERNAL dependencies — uses the global fetch (Node 18+). Imports
3
+ // below are same-package sibling modules (`./origin.mjs`), not npm deps.
4
+
5
+ import { prNumberFromEnv, isValidRepo } from './origin.mjs';
3
6
 
4
7
  // Split a configured server URL like ".../mcp?token=lk_rw_x" into
5
8
  // { endpoint: ".../mcp", token: "lk_rw_x" }.
@@ -170,6 +173,94 @@ export function normalizeCorrelationId(raw) {
170
173
  return /^[A-Za-z0-9_\-./:#@]+$/.test(t) ? t : null;
171
174
  }
172
175
 
176
+ /**
177
+ * The bounded `session_kind` vocabulary (migration 00082) — sent via
178
+ * `X-LoreKit-Session-Kind`, validated edge-side by the CROSS-LANGUAGE twin of
179
+ * this file's derivation, `packages/mcp-core/src/telemetry/session-kind.ts`
180
+ * (`parseSessionKind`). Kept here rather than imported: this package has no
181
+ * dependency on `@lorekit/core`, and the two are guarded for behavioural
182
+ * parity by `session-kind-parity.spec.ts` rather than a byte comparison,
183
+ * which is what a cross-language pair (this `.mjs` vs that `.ts`) needs.
184
+ */
185
+ const SESSION_KINDS = ['local', 'ci', 'pr', 'unknown'];
186
+
187
+ /**
188
+ * Derive `{ correlationId, sessionKind }` from the ambient environment, for
189
+ * every call site that does not have an EXPLICIT `LOREKIT_CORRELATION_ID` —
190
+ * the caller checks that first and skips this entirely when it is set, since
191
+ * an explicit value always wins.
192
+ *
193
+ * Precedence, first match wins:
194
+ * 1. PR context — `prNumberFromEnv` (LOREKIT_PR / GITHUB_REF / GITHUB_PR_NUMBER,
195
+ * see `origin.mjs`) resolves a PR number AND a repo is known → `pr` +
196
+ * `pr:<owner>/<repo>#<n>`.
197
+ * 2. CI environment (`GITHUB_ACTIONS`/`CI`) — `ci` always; a correlation id
198
+ * of `ci:<owner>/<repo>#<run_id>` when both a repo and GITHUB_RUN_ID are
199
+ * known, otherwise no correlation id (still `ci` — the session KIND is
200
+ * known even when a stable id to group by is not).
201
+ * 3. A host-provided session id (`LOREKIT_SESSION_ID`, or the handful of
202
+ * well-known agent-host env vars below) — `local` +
203
+ * `session:<id>`. The raw id itself is never logged or stored anywhere
204
+ * beyond this derived correlation id.
205
+ * 4. Otherwise `unknown`, no correlation id — never a guess.
206
+ *
207
+ * TOTAL and fail-safe: reads only `env` (never throws on a missing/odd
208
+ * value), and every branch degrades to the next rather than throwing. A
209
+ * derived value that fails `normalizeCorrelationId`'s charset/length check is
210
+ * dropped (session_kind is still reported; only the drill-down id is not).
211
+ */
212
+ export function deriveSessionContext(env = process.env) {
213
+ const repo = isValidRepo(env.GITHUB_REPOSITORY);
214
+ const prNumber = prNumberFromEnv(env);
215
+
216
+ if (prNumber !== null && repo) {
217
+ const correlationId = normalizeCorrelationId(`pr:${repo}#${prNumber}`);
218
+ if (correlationId) return { correlationId, sessionKind: 'pr' };
219
+ }
220
+
221
+ const isCI = env.GITHUB_ACTIONS === 'true' || env.CI === 'true' || env.CI === '1';
222
+ if (isCI) {
223
+ const runId = typeof env.GITHUB_RUN_ID === 'string' ? env.GITHUB_RUN_ID.trim() : '';
224
+ if (repo && runId) {
225
+ const correlationId = normalizeCorrelationId(`ci:${repo}#${runId}`);
226
+ if (correlationId) return { correlationId, sessionKind: 'ci' };
227
+ }
228
+ return { correlationId: null, sessionKind: 'ci' };
229
+ }
230
+
231
+ // Well-known agent-host session id env vars. Best-effort: hosts differ and
232
+ // this is not an exhaustive registry, so an unrecognised host still falls
233
+ // through to `unknown` rather than fabricating an id.
234
+ const sessionId = firstNonEmptyEnv(env, ['LOREKIT_SESSION_ID', 'CLAUDE_SESSION_ID']);
235
+ if (sessionId) {
236
+ // A local session IS known even when the specific id fails the
237
+ // correlation-id charset/length check — report the kind either way, and
238
+ // let the id itself degrade to null rather than losing the whole reading.
239
+ return { correlationId: normalizeCorrelationId(`session:${sessionId}`), sessionKind: 'local' };
240
+ }
241
+
242
+ return { correlationId: null, sessionKind: 'unknown' };
243
+ }
244
+
245
+ function firstNonEmptyEnv(env, keys) {
246
+ for (const key of keys) {
247
+ const v = env[key];
248
+ if (typeof v === 'string' && v.trim() !== '') return v.trim();
249
+ }
250
+ return null;
251
+ }
252
+
253
+ /**
254
+ * Validate a `session_kind` value against the closed vocabulary. Total and
255
+ * fail-safe — mirrors `parseSessionKind`'s behaviour (never used to VALIDATE
256
+ * an incoming value here, since this process only ever sends a value it just
257
+ * derived itself, but kept as the single place the vocabulary is spelled out
258
+ * so `deriveSessionContext` and any future caller cannot drift from it).
259
+ */
260
+ export function isSessionKind(value) {
261
+ return SESSION_KINDS.includes(value);
262
+ }
263
+
173
264
  /**
174
265
  * Normalise a deployment-environment marker restFetch attaches as
175
266
  * X-LoreKit-Deployment-Environment when DEPLOYMENT_ENVIRONMENT (or
@@ -219,11 +310,17 @@ export async function restFetch(baseUrl, token, path, { method = 'GET', body, ti
219
310
  const timer = setTimeout(() => controller.abort(), timeoutMs);
220
311
  try {
221
312
  const url = `${baseUrl}${path}`;
222
- // Opt-in usage correlation: when LOREKIT_CORRELATION_ID is set (e.g. by a CI
223
- // job or a hook to a PR/session id), tag every REST call so GET
224
- // /memories/usage?correlation_id=… can report "usage for this PR". Absent env
225
- // no header existing behaviour unchanged.
226
- const correlationId = normalizeCorrelationId(process.env.LOREKIT_CORRELATION_ID);
313
+ // Usage correlation: an EXPLICIT LOREKIT_CORRELATION_ID always wins (e.g. a
314
+ // CI job or a hook hand-setting a PR/session id). Otherwise, derive one
315
+ // from the ambient environment (CI/PR/session see `deriveSessionContext`)
316
+ // so GET /memories/usage?correlation_id=… and the session_kind dimension
317
+ // are populated without anyone having to export anything by hand. Both
318
+ // stay unset only when neither an explicit value nor a derivable one
319
+ // exists (`sessionKind: 'unknown'`, no correlationId).
320
+ const explicitCorrelationId = normalizeCorrelationId(process.env.LOREKIT_CORRELATION_ID);
321
+ const derived = explicitCorrelationId ? null : deriveSessionContext(process.env);
322
+ const correlationId = explicitCorrelationId ?? derived?.correlationId ?? null;
323
+ const sessionKind = derived?.sessionKind ?? null;
227
324
  // Opt-in test-run marker: when DEPLOYMENT_ENVIRONMENT is set (a deploy/CI
228
325
  // smoke sets it to `test`), tell the edge to report that
229
326
  // `deployment.environment.name` for this request so Dash0 can filter synthetic
@@ -238,6 +335,7 @@ export async function restFetch(baseUrl, token, path, { method = 'GET', body, ti
238
335
  ...(token ? { authorization: `Bearer ${token}` } : {}),
239
336
  ...(traceparent ? { traceparent } : {}),
240
337
  ...(correlationId ? { 'x-lorekit-correlation-id': correlationId } : {}),
338
+ ...(sessionKind ? { 'x-lorekit-session-kind': sessionKind } : {}),
241
339
  ...(runEnv ? { 'x-lorekit-deployment-environment': runEnv } : {}),
242
340
  // Name the calling surface so usage analytics can tell a CLI read from a
243
341
  // dashboard one. Not cosmetic: `GET /memories/read-activity` EXCLUDES the
@@ -54,6 +54,12 @@ export const mirrorPairs = [
54
54
  { core: 'packages/mcp-core/src/webhook/github-app-jwt.ts', edge: 'supabase/functions/mcp/github-app-jwt.ts', driftChecked: true },
55
55
  { core: 'packages/mcp-core/src/telemetry/trace-context.ts', edge: 'supabase/functions/_shared/telemetry/trace-context.ts', driftChecked: true },
56
56
  { core: 'packages/mcp-core/src/rest/rest-tool-name.ts', edge: 'supabase/functions/_shared/rest/rest-tool-name.ts', driftChecked: true },
57
+ // The `X-LoreKit-Session-Kind` validator (migration 00082). Also has a
58
+ // SECOND, cross-LANGUAGE twin — the CLI's `deriveSessionContext` in
59
+ // `packages/cli/src/shared/mcp.mjs` — guarded behaviourally by
60
+ // `packages/cli/test/session-context.test.mjs`, the same split
61
+ // `lesson-rank.ts` uses below.
62
+ { core: 'packages/mcp-core/src/telemetry/session-kind.ts', edge: 'supabase/functions/_shared/telemetry/session-kind.ts', driftChecked: true },
57
63
  // Has a SECOND, cross-LANGUAGE twin no byte comparison can cover — the
58
64
  // CLI's own `lessons-pure.mjs` — guarded behaviourally by
59
65
  // `lesson-rank-parity.spec.ts` instead.
@@ -655,25 +655,28 @@ function normalizeExitCode(result) {
655
655
  * Run a MACHINE-facing command (`hook`, `mcp`) and count the invocation —
656
656
  * counter only, no span. Returns the command's exit code unchanged.
657
657
  *
658
- * The export is STARTED BEFORE the command runs and awaited after, so it
659
- * overlaps the command's own work instead of being serialized behind it. That
660
- * ordering is what makes this affordable on `hook`, which fires several times
661
- * per agent turn: by the time there is anything to await, the POST has usually
662
- * already completed, and {@link METERED_TIMEOUT_MS} caps the worst case.
658
+ * The two commands are counted at DIFFERENT times, for a reason specific to
659
+ * each:
663
660
  *
664
- * It also makes the count robust for `mcp`, which is a LONG-LIVED stdio server —
665
- * `run()` does not return until the server exits, and a killed server would
666
- * never have reported at all if the export waited for it. Since the counter
667
- * reports the invocation rather than its outcome (a machine-facing command's
668
- * verdict belongs to its stdout contract, which the host reads), there is
669
- * nothing to learn by waiting.
661
+ * `mcp` is a LONG-LIVED stdio server — `run()` does not return until the
662
+ * server exits, and a killed server would never report at all if the export
663
+ * waited for it. So its count fires BEFORE run and is awaited after, which
664
+ * also overlaps the export with the server's own startup. It carries no
665
+ * outcome: a server's verdict belongs to its stdout contract.
666
+ * `hook` is short-lived and fires several times per agent turn. It is
667
+ * counted AFTER run so the counter can carry the health dimensions run
668
+ * reports (`lorekit.hook.event` + `lorekit.hook.outcome`) — the difference
669
+ * between a healthy hook and one silently degrading (an unusable store, a
670
+ * swallowed lookup error) that a bare invocation ping cannot show. The extra
671
+ * latency is run's own duration (a hook is tens of ms); the
672
+ * {@link METERED_TIMEOUT_MS} export cap dominates either way.
670
673
  *
671
674
  * Nothing here can affect the command: the exit code is passed through
672
675
  * untouched, and every telemetry failure is swallowed.
673
676
  *
674
677
  * @param {string} command `hook` | `mcp`
675
678
  * @param {string} version CLI version
676
- * @param {() => Promise<number>} run the command handler
679
+ * @param {() => Promise<number | { exitCode?: number, meter?: object }>} run the command handler
677
680
  */
678
681
  export async function meterCommand(command, version, run) {
679
682
  let config;
@@ -688,15 +691,26 @@ export async function meterCommand(command, version, run) {
688
691
  // cost nothing at all: no identity read, no timer, no promise.
689
692
  if (!config.enabled) return normalizeExitCode(await run());
690
693
 
691
- // `.catch` attached IMMEDIATELY, before any await: an unawaited rejecting
692
- // promise is an unhandled rejection, which on a machine-facing command would
693
- // print to stderr and pollute a host's log.
694
- const pending = countInvocation(config, command, version).catch(() => {});
695
- try {
696
- return normalizeExitCode(await run());
697
- } finally {
698
- await pending;
694
+ // Long-lived server: count before run (see docblock). `.catch` attached
695
+ // IMMEDIATELY, before any await: an unawaited rejecting promise is an
696
+ // unhandled rejection, which on a machine-facing command would print to
697
+ // stderr and pollute a host's log.
698
+ if (command === 'mcp') {
699
+ const pending = countInvocation(config, command, version).catch(() => {});
700
+ try {
701
+ return normalizeExitCode(await run());
702
+ } finally {
703
+ await pending;
704
+ }
699
705
  }
706
+
707
+ // Short-lived hook: count after run so the counter carries what run reported.
708
+ // A `meter` object on the result is the health payload; anything else (a bare
709
+ // exit code) counts with no extra dimensions.
710
+ const result = await run();
711
+ const meter = (result && typeof result === 'object' && result.meter) ? result.meter : {};
712
+ await countInvocation(config, command, version, meter).catch(() => {});
713
+ return normalizeExitCode(result);
700
714
  }
701
715
 
702
716
  /**