@lorekit/cli 1.55.3 → 1.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +54 -1
- package/bin/lorekit.mjs +84 -3
- package/package.json +1 -1
- package/src/commands/completion.mjs +68 -0
- package/src/commands/hook.mjs +47 -5
- package/src/commands/install.mjs +104 -2
- package/src/commands/obligations.mjs +135 -0
- package/src/commands/uninstall.mjs +21 -2
- package/src/commands.mjs +8 -0
- package/src/shared/completions.mjs +471 -0
- package/src/shared/mirror-pairs.mjs +86 -0
- package/src/shared/obligations-map.mjs +154 -0
- package/src/shared/obligations-pure.mjs +246 -0
- package/src/telemetry/telemetry.mjs +36 -22
|
@@ -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
|
+
];
|