@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.
@@ -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
+ }
@@ -0,0 +1,86 @@
1
+ // The single-source inventory of known mcp-core ↔ edge (Deno) mirror pairs.
2
+ //
3
+ // TWO consumers read this ONE list instead of each keeping (or reconstructing)
4
+ // its own:
5
+ // - `packages/mcp-core/src/edge/edge-parity.spec.ts` — loads it by URL (the
6
+ // `lesson-rank-parity.spec.ts` cross-runtime pattern: this is a plain
7
+ // `.mjs` package outside the vitest project's tsconfig) and runs its
8
+ // byte-for-byte drift guard over every `driftChecked: true` pair.
9
+ // - `packages/cli/src/shared/obligations-map.mjs` — generates the
10
+ // `edge-mirror` / `edge-mirror-core` Surface-Partner Map rows from EVERY
11
+ // pair here (`driftChecked` included), so `lorekit obligations` reports
12
+ // the pair's ACTUAL partner.
13
+ //
14
+ // Why a flat enumerated list rather than a glob + `{name}` substitution: an
15
+ // edge mirror does not always preserve mcp-core's directory structure — e.g.
16
+ // `packages/mcp-core/src/auth/auth-token.ts` mirrors the FLAT
17
+ // `supabase/functions/mcp/auth-token.ts`, not
18
+ // `supabase/functions/mcp/auth/auth-token.ts`. A glob that assumes the same
19
+ // relative subpath on both sides reconstructs the WRONG partner path for
20
+ // every such flatten/rename and reports a real, present mirror as
21
+ // chronically unmet. Enumerating each pair's exact core/edge path from one
22
+ // inventory has no such assumption to violate.
23
+ //
24
+ // Every path is REPO-RELATIVE (not relative to this file), so both consumers
25
+ // use it directly: `edge-parity.spec.ts` joins it against the repo root,
26
+ // and `obligations-map.mjs`/`checkObligations` match it against changed-file
27
+ // path strings verbatim.
28
+ //
29
+ // `driftChecked: true` — both files are import-free, so `edge-parity.spec.ts`
30
+ // also runs its whole-file byte comparison over the
31
+ // pair (stripped of comments/blank lines).
32
+ // `driftChecked: false` — a REAL partner for `lorekit obligations` purposes,
33
+ // but excluded from that byte comparison because the
34
+ // edge copy is not import-free (it carries Deno-only
35
+ // types/APIs the mcp-core copy has no counterpart
36
+ // for — see each entry's note below).
37
+ //
38
+ // Deliberately NOT included: `supabase/functions/mcp/cursor.ts` ↔
39
+ // `supabase/functions/_shared/api/paginate.ts` (guarded by edge-parity.spec.ts's
40
+ // separate "cursor mirror parity" block). Both sides live under
41
+ // `supabase/functions/`, so it is an edge↔edge mirror, not the mcp-core↔edge
42
+ // partnership this inventory and the `edge-mirror`/`edge-mirror-core` map
43
+ // entries model.
44
+ export const mirrorPairs = [
45
+ { core: 'packages/mcp-core/src/auth/auth-token.ts', edge: 'supabase/functions/mcp/auth-token.ts', driftChecked: true },
46
+ { core: 'packages/mcp-core/src/limits/created-at.ts', edge: 'supabase/functions/_shared/limits/created-at.ts', driftChecked: true },
47
+ { core: 'packages/mcp-core/src/limits/ttl.ts', edge: 'supabase/functions/mcp/ttl.ts', driftChecked: true },
48
+ { core: 'packages/mcp-core/src/limits/ttl-defaults.ts', edge: 'supabase/functions/mcp/ttl-defaults.ts', driftChecked: true },
49
+ { core: 'packages/mcp-core/src/provenance/origin.ts', edge: 'supabase/functions/_shared/provenance/origin.ts', driftChecked: true },
50
+ { core: 'packages/mcp-core/src/webhook/webhook-secret-select.ts', edge: 'supabase/functions/mcp/webhook-secret-select.ts', driftChecked: true },
51
+ { core: 'packages/mcp-core/src/auth/tenant-scope.ts', edge: 'supabase/functions/_shared/auth/tenant-scope.ts', driftChecked: true },
52
+ { core: 'packages/mcp-core/src/auth/org-permissions.ts', edge: 'supabase/functions/mcp/org-permissions.ts', driftChecked: true },
53
+ { core: 'packages/mcp-core/src/webhook/webhook-installation.ts', edge: 'supabase/functions/mcp/webhook-installation.ts', driftChecked: true },
54
+ { core: 'packages/mcp-core/src/webhook/github-app-jwt.ts', edge: 'supabase/functions/mcp/github-app-jwt.ts', driftChecked: true },
55
+ { core: 'packages/mcp-core/src/telemetry/trace-context.ts', edge: 'supabase/functions/_shared/telemetry/trace-context.ts', driftChecked: true },
56
+ { core: 'packages/mcp-core/src/rest/rest-tool-name.ts', edge: 'supabase/functions/_shared/rest/rest-tool-name.ts', driftChecked: true },
57
+ // Has a SECOND, cross-LANGUAGE twin no byte comparison can cover — the
58
+ // CLI's own `lessons-pure.mjs` — guarded behaviourally by
59
+ // `lesson-rank-parity.spec.ts` instead.
60
+ { core: 'packages/mcp-core/src/ranking/lesson-rank.ts', edge: 'supabase/functions/_shared/ranking/lesson-rank.ts', driftChecked: true },
61
+ { core: 'packages/mcp-core/src/ranking/outcome-signal.ts', edge: 'supabase/functions/_shared/ranking/outcome-signal.ts', driftChecked: true },
62
+ { core: 'packages/mcp-core/src/provenance/embedding.ts', edge: 'supabase/functions/_shared/embedding/embedding.ts', driftChecked: true },
63
+ { core: 'packages/mcp-core/src/audit/rest-audit-actor.ts', edge: 'supabase/functions/_shared/audit/rest-audit-actor.ts', driftChecked: true },
64
+ { core: 'packages/mcp-core/src/rest/rest-response-outcome.ts', edge: 'supabase/functions/_shared/rest/rest-response-outcome.ts', driftChecked: true },
65
+ { core: 'packages/mcp-core/src/limits/dry-run.ts', edge: 'supabase/functions/_shared/limits/dry-run.ts', driftChecked: true },
66
+ { core: 'packages/mcp-core/src/telemetry/usage-stats.ts', edge: 'supabase/functions/_shared/telemetry/usage-stats.ts', driftChecked: true },
67
+ { core: 'packages/mcp-core/src/limits/expiring-window.ts', edge: 'supabase/functions/_shared/limits/expiring-window.ts', driftChecked: true },
68
+ { core: 'packages/mcp-core/src/rest/cors-origins.ts', edge: 'supabase/functions/_shared/api/cors-origins.ts', driftChecked: true },
69
+ { core: 'packages/mcp-core/src/scope/scope-type-attribute.ts', edge: 'supabase/functions/_shared/scope/scope-type-attribute.ts', driftChecked: true },
70
+ { core: 'packages/mcp-core/src/auth/account-wide-tools.ts', edge: 'supabase/functions/_shared/auth/account-wide-tools.ts', driftChecked: true },
71
+ { core: 'packages/mcp-core/src/telemetry/io-ledger.ts', edge: 'supabase/functions/_shared/telemetry/io-ledger.ts', driftChecked: true },
72
+ { core: 'packages/mcp-core/src/telemetry/db-query-metrics.ts', edge: 'supabase/functions/_shared/telemetry/db-query-metrics.ts', driftChecked: true },
73
+ // Excluded from the byte-comparison drift check: the edge copy types the
74
+ // client as `ReturnType<typeof createClient>` off an `npm:` specifier where
75
+ // mcp-core imports a typed `SupabaseClient`, and additionally carries
76
+ // `recordAuditDeferred` (a Deno-only `EdgeRuntime.waitUntil` API with no
77
+ // mcp-core counterpart), so a whole-file comparison does not apply. Still a
78
+ // real partner — this is the AC-1 example.
79
+ { core: 'packages/mcp-core/src/audit/audit.ts', edge: 'supabase/functions/_shared/audit/audit.ts', driftChecked: false },
80
+ // Excluded from the byte-comparison drift check for the same reason as
81
+ // `limits.ts` generally (see edge-parity.spec.ts): the edge copy pulls in
82
+ // Deno-specific imports, so a whole-file source comparison does not apply.
83
+ // Its shared pure logic is exercised behaviourally by `limits.spec.ts` on
84
+ // the mcp-core copy. Still a real partner.
85
+ { core: 'packages/mcp-core/src/limits/limits.ts', edge: 'supabase/functions/mcp/limits.ts', driftChecked: false },
86
+ ];