@lorekit/cli 1.56.0 → 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/bin/lorekit.mjs +35 -1
- 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/uninstall.mjs +21 -2
- package/src/commands.mjs +6 -0
- package/src/shared/completions.mjs +471 -0
- package/src/telemetry/telemetry.mjs +34 -20
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
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// `lorekit completion <zsh|fish>` — print a shell completion script.
|
|
2
|
+
//
|
|
3
|
+
// The generated scripts (see ../shared/completions.mjs) call back into this
|
|
4
|
+
// command for DYNAMIC values: `lorekit completion --complete scope` lists the
|
|
5
|
+
// scopes in the local store, `--complete key` lists `scope::key` addresses. That
|
|
6
|
+
// callback fires on every TAB, which is why `completion` is a MACHINE command in
|
|
7
|
+
// the registry — its stdout is a contract a shell parses (a script or a
|
|
8
|
+
// newline-delimited candidate list), and it is metered, not traced, exactly like
|
|
9
|
+
// `hook` / `mcp`. A span per keypress would be a firehose.
|
|
10
|
+
//
|
|
11
|
+
// Dynamic completion reads the LOCAL store only. It is deliberately offline: a
|
|
12
|
+
// TAB must be instant and must never prompt for a token or hit the network, so a
|
|
13
|
+
// scope that lives only in the remote store will not complete — a worthwhile
|
|
14
|
+
// trade for a completion that never blocks the prompt.
|
|
15
|
+
import process from 'node:process';
|
|
16
|
+
import { resolveProjectRoot } from '../shared/config.mjs';
|
|
17
|
+
import { resolveStores } from '../shared/stores.mjs';
|
|
18
|
+
import { log, err, c } from '../shared/util.mjs';
|
|
19
|
+
import { COMPLETION_SHELLS, renderCompletion } from '../shared/completions.mjs';
|
|
20
|
+
|
|
21
|
+
export async function completion(args) {
|
|
22
|
+
// Dynamic candidate mode: emit newline-delimited candidates for the shell to
|
|
23
|
+
// consume, then exit. Any failure degrades to an empty list (exit 0) so a
|
|
24
|
+
// broken store never surfaces an error at the prompt.
|
|
25
|
+
if (args.complete !== undefined) {
|
|
26
|
+
return emitCandidates(args);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const shell = typeof args._?.[1] === 'string' ? args._[1] : null;
|
|
30
|
+
if (!shell) {
|
|
31
|
+
err(`${c.red('Missing shell.')} Usage: lorekit completion <${COMPLETION_SHELLS.join('|')}>`);
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
if (!COMPLETION_SHELLS.includes(shell)) {
|
|
35
|
+
err(`${c.red(`Unsupported shell: ${shell}.`)} Supported: ${COMPLETION_SHELLS.join(', ')}`);
|
|
36
|
+
err(`Run ${c.cyan('lorekit install --completions ' + shell)} to install it, or pipe this to your shell's completion dir.`);
|
|
37
|
+
return 1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
log(renderCompletion(shell));
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Emit the candidate list for `--complete <kind>`. `scope` → distinct scope
|
|
45
|
+
// strings; `key` → `scope::key` addresses. Best-effort and offline.
|
|
46
|
+
async function emitCandidates(args) {
|
|
47
|
+
const kind = typeof args.complete === 'string' ? args.complete : '';
|
|
48
|
+
try {
|
|
49
|
+
const root = resolveProjectRoot(args.dir);
|
|
50
|
+
const { local } = resolveStores(root, { env: process.env });
|
|
51
|
+
const inventory = await local.listScopes();
|
|
52
|
+
|
|
53
|
+
if (kind === 'scope') {
|
|
54
|
+
for (const { scope } of inventory) log(scope);
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
if (kind === 'key') {
|
|
58
|
+
for (const { scope } of inventory) {
|
|
59
|
+
const res = await local.list({ scope });
|
|
60
|
+
for (const entry of res.entries ?? []) log(`${scope}::${entry.key}`);
|
|
61
|
+
}
|
|
62
|
+
return 0;
|
|
63
|
+
}
|
|
64
|
+
} catch {
|
|
65
|
+
// Swallow — an unreadable store yields no candidates, never a prompt error.
|
|
66
|
+
}
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
package/src/commands/hook.mjs
CHANGED
|
@@ -36,6 +36,32 @@ import { codex } from '../adapters/codex.mjs';
|
|
|
36
36
|
|
|
37
37
|
const ADAPTERS = { claude, cursor, codex };
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Bounded health vocabulary for the hook heartbeat. The hook always exits 0, so
|
|
41
|
+
* these are the only witness that a fire did its work: `ok` (ran clean),
|
|
42
|
+
* `store_unavailable` (no usable store to read/query), `degraded` (a store
|
|
43
|
+
* lookup threw and was swallowed — the host still got its output), `crash` (an
|
|
44
|
+
* unexpected throw the outer guard caught). Kept small so `lorekit.hook.outcome`
|
|
45
|
+
* stays a low-cardinality metric label.
|
|
46
|
+
*/
|
|
47
|
+
const HOOK_OUTCOME = Object.freeze({
|
|
48
|
+
OK: 'ok',
|
|
49
|
+
STORE_UNAVAILABLE: 'store_unavailable',
|
|
50
|
+
DEGRADED: 'degraded',
|
|
51
|
+
CRASH: 'crash',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Map the mutable meter run() fills in to the bounded, non-PII counter
|
|
56
|
+
* dimensions. `event` is the host hook event (SessionStart / Stop / …), itself a
|
|
57
|
+
* small set; both labels are safe for a metric.
|
|
58
|
+
*/
|
|
59
|
+
function hookMeterAttrs(meter) {
|
|
60
|
+
const attrs = { 'lorekit.hook.outcome': meter.outcome };
|
|
61
|
+
if (meter.event) attrs['lorekit.hook.event'] = meter.event;
|
|
62
|
+
return attrs;
|
|
63
|
+
}
|
|
64
|
+
|
|
39
65
|
function readStdin() {
|
|
40
66
|
return new Promise((resolve) => {
|
|
41
67
|
let data = '';
|
|
@@ -48,15 +74,22 @@ function readStdin() {
|
|
|
48
74
|
}
|
|
49
75
|
|
|
50
76
|
export async function hook(args) {
|
|
77
|
+
// The meter run() fills in as it goes. Returned alongside the exit code so the
|
|
78
|
+
// metric-only dispatch (`meterCommand`) can count this fire with its health —
|
|
79
|
+
// the hook always exits 0, so this is the only signal a broken or degrading
|
|
80
|
+
// hook produces.
|
|
81
|
+
const meter = { event: null, outcome: HOOK_OUTCOME.OK };
|
|
51
82
|
// Guarded so any unexpected error still exits 0 (never break the host agent).
|
|
52
83
|
try {
|
|
53
|
-
|
|
84
|
+
const exitCode = await run(args, meter);
|
|
85
|
+
return { exitCode, meter: hookMeterAttrs(meter) };
|
|
54
86
|
} catch {
|
|
55
|
-
|
|
87
|
+
meter.outcome = HOOK_OUTCOME.CRASH;
|
|
88
|
+
return { exitCode: 0, meter: hookMeterAttrs(meter) };
|
|
56
89
|
}
|
|
57
90
|
}
|
|
58
91
|
|
|
59
|
-
async function run(args) {
|
|
92
|
+
async function run(args, meter) {
|
|
60
93
|
const adapter = ADAPTERS[args.adapter];
|
|
61
94
|
if (!adapter) {
|
|
62
95
|
// Unknown adapter: stay silent, don't disrupt the host.
|
|
@@ -81,6 +114,8 @@ async function run(args) {
|
|
|
81
114
|
|
|
82
115
|
if (!event) return 0;
|
|
83
116
|
|
|
117
|
+
meter.event = event;
|
|
118
|
+
|
|
84
119
|
const intent = adapter.intentFor(event);
|
|
85
120
|
if (intent === 'noop') return 0;
|
|
86
121
|
|
|
@@ -110,6 +145,8 @@ async function run(args) {
|
|
|
110
145
|
? control.hooksInstructions.SessionStart : null;
|
|
111
146
|
if (!store) {
|
|
112
147
|
// No store: emit a minimal header + instruction when present, then return.
|
|
148
|
+
// A SessionStart that cannot read lore is the highest-value failure to see.
|
|
149
|
+
meter.outcome = HOOK_OUTCOME.STORE_UNAVAILABLE;
|
|
113
150
|
if (sessionInstruction) {
|
|
114
151
|
emit(formatLessons(null, { repoScope: null }, { instruction: sessionInstruction }));
|
|
115
152
|
}
|
|
@@ -163,7 +200,7 @@ async function run(args) {
|
|
|
163
200
|
|
|
164
201
|
try {
|
|
165
202
|
const store = createStore(control);
|
|
166
|
-
if (!store) return 0;
|
|
203
|
+
if (!store) { meter.outcome = HOOK_OUTCOME.STORE_UNAVAILABLE; return 0; }
|
|
167
204
|
const lessons = await promptLessonsFromStore(store, scope, terms, {
|
|
168
205
|
// Delta only. Includes the SessionStart set, because "already shown"
|
|
169
206
|
// has to mean shown by anything — a hook that only remembered its own
|
|
@@ -183,7 +220,9 @@ async function run(args) {
|
|
|
183
220
|
}));
|
|
184
221
|
} catch {
|
|
185
222
|
// Best-effort, like every other branch: the user's turn proceeds either
|
|
186
|
-
// way, and a store hiccup must never cost them their prompt
|
|
223
|
+
// way, and a store hiccup must never cost them their prompt — but record
|
|
224
|
+
// that the lookup degraded so a store that always throws is visible.
|
|
225
|
+
meter.outcome = HOOK_OUTCOME.DEGRADED;
|
|
187
226
|
}
|
|
188
227
|
return 0;
|
|
189
228
|
}
|
|
@@ -210,6 +249,7 @@ async function run(args) {
|
|
|
210
249
|
}
|
|
211
250
|
} catch {
|
|
212
251
|
// best-effort — never break the host
|
|
252
|
+
meter.outcome = HOOK_OUTCOME.DEGRADED;
|
|
213
253
|
}
|
|
214
254
|
return 0;
|
|
215
255
|
}
|
|
@@ -224,6 +264,7 @@ async function run(args) {
|
|
|
224
264
|
let relevant = null;
|
|
225
265
|
try {
|
|
226
266
|
const store = createStore(control);
|
|
267
|
+
if (!store) meter.outcome = HOOK_OUTCOME.STORE_UNAVAILABLE;
|
|
227
268
|
if (store) {
|
|
228
269
|
// QUERY the store across the scope hierarchy for lessons matching this
|
|
229
270
|
// failure — not a post-filter of the SessionStart-injected set, which
|
|
@@ -235,6 +276,7 @@ async function run(args) {
|
|
|
235
276
|
}
|
|
236
277
|
} catch {
|
|
237
278
|
relevant = null; // never let a lesson lookup break the failure nudge
|
|
279
|
+
meter.outcome = HOOK_OUTCOME.DEGRADED;
|
|
238
280
|
}
|
|
239
281
|
const nudge = failureNudge(parsed.toolName, scope, control);
|
|
240
282
|
emit(relevant ? `${relevant}\n\n${nudge}` : nudge);
|
package/src/commands/install.mjs
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from '../shared/config.mjs';
|
|
30
30
|
import { buildRemoteUrl, splitEndpoint } from '../shared/mcp.mjs';
|
|
31
31
|
import { deriveScope } from '../shared/scope.mjs';
|
|
32
|
+
import { COMPLETION_SHELLS, detectShell, installCompletion } from '../shared/completions.mjs';
|
|
32
33
|
import { log, heading, status, select, err, c } from '../shared/util.mjs';
|
|
33
34
|
|
|
34
35
|
// The MCP server URL is fixed — there is only one hosted LoreKit endpoint.
|
|
@@ -179,6 +180,25 @@ function requestedHookMode(args) {
|
|
|
179
180
|
return null;
|
|
180
181
|
}
|
|
181
182
|
|
|
183
|
+
// Shell-completion install modes for `--completions`. `auto` detects the shell
|
|
184
|
+
// from $SHELL; a shell name installs that one; `none` skips. Kept a small closed
|
|
185
|
+
// set so a typo fails loudly rather than silently doing nothing.
|
|
186
|
+
export const COMPLETION_MODES = ['auto', 'none', ...COMPLETION_SHELLS];
|
|
187
|
+
|
|
188
|
+
// Same sentinel discipline as INVALID_HOOK_MODE: a bare or empty `--completions`
|
|
189
|
+
// is a usage error, not an absent flag, so it never silently falls through to
|
|
190
|
+
// the interactive/skip default.
|
|
191
|
+
export const INVALID_COMPLETION_MODE = '(missing value)';
|
|
192
|
+
|
|
193
|
+
// Resolve the requested completion mode from the flag, or null when it was not
|
|
194
|
+
// passed (the caller then prompts interactively / skips non-interactively).
|
|
195
|
+
export function requestedCompletionMode(args) {
|
|
196
|
+
const raw = args.completions;
|
|
197
|
+
if (typeof raw === 'string' && raw.trim()) return raw.trim().toLowerCase();
|
|
198
|
+
if (raw !== undefined && raw !== false) return INVALID_COMPLETION_MODE;
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
182
202
|
export async function install(args) {
|
|
183
203
|
const root = resolveProjectRoot(args.dir);
|
|
184
204
|
const nonInteractive = Boolean(args.yes) || !process.stdin.isTTY;
|
|
@@ -206,6 +226,21 @@ export async function install(args) {
|
|
|
206
226
|
// write step even when the scope is already fully installed.
|
|
207
227
|
const writeWebMcpJson = Boolean(args['mcp-json']);
|
|
208
228
|
|
|
229
|
+
// Validate `--completions` up front, same discipline as `--hooks`.
|
|
230
|
+
const requestedCompletion = requestedCompletionMode(args);
|
|
231
|
+
if (requestedCompletion === INVALID_COMPLETION_MODE) {
|
|
232
|
+
err(`\n --completions needs a mode. Valid modes: ${COMPLETION_MODES.join(' | ')}.`);
|
|
233
|
+
return 1;
|
|
234
|
+
}
|
|
235
|
+
if (requestedCompletion !== null && !COMPLETION_MODES.includes(requestedCompletion)) {
|
|
236
|
+
err(`\n Unknown --completions mode "${requestedCompletion}". Valid modes: ${COMPLETION_MODES.join(' | ')}.`);
|
|
237
|
+
return 1;
|
|
238
|
+
}
|
|
239
|
+
// An explicit request to install completion (auto / zsh / fish) must reach the
|
|
240
|
+
// completion step even on an otherwise complete install. `none` is skip-only,
|
|
241
|
+
// like `--no-hooks`, so it never justifies the bypass.
|
|
242
|
+
const completionsExplicit = requestedCompletion !== null && requestedCompletion !== 'none';
|
|
243
|
+
|
|
209
244
|
heading('LoreKit install');
|
|
210
245
|
log(` project: ${c.dim(root)}`);
|
|
211
246
|
|
|
@@ -231,7 +266,7 @@ export async function install(args) {
|
|
|
231
266
|
|
|
232
267
|
const wiredEvents = installedHookEvents(root, scope);
|
|
233
268
|
|
|
234
|
-
if (currentState.isFullyInstalled && !force && !hooksFlagExplicit && !writeWebMcpJson) {
|
|
269
|
+
if (currentState.isFullyInstalled && !force && !hooksFlagExplicit && !writeWebMcpJson && !completionsExplicit) {
|
|
235
270
|
// Surface a clear, useful already-installed summary.
|
|
236
271
|
log('');
|
|
237
272
|
log(
|
|
@@ -474,6 +509,52 @@ export async function install(args) {
|
|
|
474
509
|
hooks = upsertClaudeHooks(root, scope, resolveHookRunner(), hookEvents);
|
|
475
510
|
}
|
|
476
511
|
|
|
512
|
+
// 5c. Shell completion — independent of the project/global scope above, since
|
|
513
|
+
// completion lives in the user's shell config, not a repo. Resolve the
|
|
514
|
+
// target shell, then write the script (fish auto-loads its dir; zsh gets a
|
|
515
|
+
// guarded ~/.zshrc block). Opt-in by default: an interactive run prompts
|
|
516
|
+
// for the detected shell, a non-interactive one skips unless
|
|
517
|
+
// `--completions` was passed — writing to a shell rc unprompted would be
|
|
518
|
+
// too invasive for a plain `install --yes`.
|
|
519
|
+
let completionResult = null;
|
|
520
|
+
let completionShell = null;
|
|
521
|
+
let completionSkipReason = null;
|
|
522
|
+
{
|
|
523
|
+
let mode = requestedCompletion; // 'auto' | 'zsh' | 'fish' | 'none' | null
|
|
524
|
+
if (mode === null) {
|
|
525
|
+
if (nonInteractive) {
|
|
526
|
+
mode = 'none';
|
|
527
|
+
} else {
|
|
528
|
+
const detected = detectShell(process.env);
|
|
529
|
+
if (!detected) {
|
|
530
|
+
completionSkipReason = 'shell not detected ($SHELL is not zsh or fish)';
|
|
531
|
+
mode = 'none';
|
|
532
|
+
} else {
|
|
533
|
+
log('');
|
|
534
|
+
const answer = await select(`Install ${detected} shell completion?`, [
|
|
535
|
+
{ label: 'Yes', value: 'yes', hint: `write the ${detected} completion and wire it up` },
|
|
536
|
+
{ label: 'No', value: 'no', hint: 'skip — add it later with `lorekit completion`' },
|
|
537
|
+
]);
|
|
538
|
+
mode = answer === 'yes' ? detected : 'none';
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
if (mode === 'auto') {
|
|
543
|
+
const detected = detectShell(process.env);
|
|
544
|
+
if (detected) completionShell = detected;
|
|
545
|
+
else completionSkipReason = 'shell not detected ($SHELL is not zsh or fish)';
|
|
546
|
+
} else if (mode === 'zsh' || mode === 'fish') {
|
|
547
|
+
completionShell = mode;
|
|
548
|
+
}
|
|
549
|
+
if (completionShell) {
|
|
550
|
+
try {
|
|
551
|
+
completionResult = installCompletion(completionShell, { home: homeDir() });
|
|
552
|
+
} catch (e) {
|
|
553
|
+
completionSkipReason = e.message;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
477
558
|
// Show global paths relative to ~ (a repo-relative path would be a mess of
|
|
478
559
|
// ../../); project paths stay repo-relative.
|
|
479
560
|
const display = (p) =>
|
|
@@ -550,6 +631,21 @@ export async function install(args) {
|
|
|
550
631
|
);
|
|
551
632
|
}
|
|
552
633
|
|
|
634
|
+
// Shell completion always lives under ~, so show it relative to home
|
|
635
|
+
// regardless of the install scope.
|
|
636
|
+
const homeDisplay = (p) => p.replace(homeDir(), '~');
|
|
637
|
+
if (completionResult) {
|
|
638
|
+
const where = completionResult.autoloaded
|
|
639
|
+
? `${homeDisplay(completionResult.file)} — auto-loaded by fish`
|
|
640
|
+
: `${homeDisplay(completionResult.file)}${completionResult.rcUpdated ? ' + ~/.zshrc block' : ''}`;
|
|
641
|
+
status('pass', `completion ${completionResult.shell}`, `${where}; open a new shell to use it`);
|
|
642
|
+
} else if (completionShell) {
|
|
643
|
+
// A shell was resolved but the write failed — surface why, don't stay silent.
|
|
644
|
+
status('warn', 'completion', completionSkipReason || 'not installed');
|
|
645
|
+
} else if (completionSkipReason) {
|
|
646
|
+
status('info', 'completion', `skipped — ${completionSkipReason}; add it with \`lorekit completion\``);
|
|
647
|
+
}
|
|
648
|
+
|
|
553
649
|
const kind = tokenKind(token);
|
|
554
650
|
if (scopeWriteOwnedByWeb) {
|
|
555
651
|
// `--project --mcp-json`: the committable web .mcp.json is the ONLY config
|
|
@@ -615,5 +711,11 @@ export async function install(args) {
|
|
|
615
711
|
// Bounded, non-PII: which of the three presets this run landed on. Counting
|
|
616
712
|
// the `--no-hooks` FLAG (as telemetry already did) says nothing about what a
|
|
617
713
|
// user picks when actually asked, which is the whole point of the prompt.
|
|
618
|
-
|
|
714
|
+
// `completions` records the shell wired (or 'none') — a bounded enum, never a
|
|
715
|
+
// path.
|
|
716
|
+
return {
|
|
717
|
+
exitCode: 0,
|
|
718
|
+
'lorekit.cli.hooks_mode': hookMode,
|
|
719
|
+
'lorekit.cli.completions': completionResult ? completionResult.shell : 'none',
|
|
720
|
+
};
|
|
619
721
|
}
|
|
@@ -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
|
|
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
|
|
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
|
+
}
|
|
@@ -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
|
|
659
|
-
*
|
|
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
|
-
*
|
|
665
|
-
*
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
* verdict belongs to its stdout contract
|
|
669
|
-
*
|
|
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
|
-
//
|
|
692
|
-
//
|
|
693
|
-
//
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
/**
|