@lorekit/cli 1.55.3 → 1.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,135 @@
1
+ // `lorekit obligations` — check a changed-file set against the Surface-Partner
2
+ // Map (`../shared/obligations-map.mjs`) and print any partner surface a known
3
+ // partnership obliges that is NOT itself in the changed set ("you forgot to
4
+ // sweep X"), citing the memory lesson the partnership encodes.
5
+ //
6
+ // This is a machine version of a recurring `dash0-dev` review finding: a fix
7
+ // to one surface leaves its partner stale because the lessons documenting the
8
+ // partnership are retrieved lexically and rarely surface at edit time for the
9
+ // exact file just touched. Slice 1 is a standalone CLI check — no hook
10
+ // wiring, no server changes (see the plan's Out-of-scope section).
11
+ //
12
+ // Cwd-INDEPENDENT by design (`cli-bash-cwd-resets-to-repo-root-each-call`):
13
+ // it matches the path STRINGS it is given against the map; it never reads the
14
+ // filesystem or resolves scope from the current directory, so it works the
15
+ // same regardless of where it is invoked from as long as the paths given are
16
+ // repo-relative.
17
+ //
18
+ // Changed-set resolution — positionals and `--files` are UNIONED (so both a
19
+ // bare list and the flag form work together, and `--files a b c` composes
20
+ // naturally: the parser's single-value form takes `a` as the flag's value and
21
+ // leaves `b`/`c` as positionals); stdin is read only as a FALLBACK, when
22
+ // NEITHER produced anything — the same flag → positional → stdin precedence
23
+ // `write.mjs`'s value resolution uses. This is a deliberate narrowing from a
24
+ // flat three-way union: reading stdin unconditionally means every invocation
25
+ // that already named its files explicitly still blocks on stdin closing,
26
+ // which is surprising for a caller that piped nothing, and turns any
27
+ // in-process call (e.g. this command under `node:test`, invoked directly
28
+ // rather than spawned) into a hang, since the test runner's own stdin never
29
+ // reaches EOF. De-duplicated, first-seen order preserved.
30
+ //
31
+ // 1. positionals after the command token (`obligations <path> <path> …`)
32
+ // unioned with `--files <path>`
33
+ // 2. stdin lines (trimmed, non-empty) — read ONLY when (1) is empty and
34
+ // stdin is not a TTY
35
+ //
36
+ // Exit code: 0 by default; 1 when `--strict` is given AND any path obligation
37
+ // is unmet. `run:` obliges are advisory (`met: null`) and never gate.
38
+ import process from 'node:process';
39
+ import { log, heading, c } from '../shared/util.mjs';
40
+ import { checkObligations } from '../shared/obligations-pure.mjs';
41
+ import { SURFACE_PARTNER_MAP } from '../shared/obligations-map.mjs';
42
+
43
+ // Read stdin line-by-line, trimmed, non-empty. Resolves to [] when stdin IS a
44
+ // TTY (no pipe) — the same "no pipe, no read" convention `write.mjs` uses.
45
+ function readStdinLines() {
46
+ if (process.stdin.isTTY) return Promise.resolve([]);
47
+ return new Promise((resolve) => {
48
+ const chunks = [];
49
+ process.stdin.on('data', (d) => chunks.push(d));
50
+ process.stdin.on('end', () => {
51
+ const lines = Buffer.concat(chunks)
52
+ .toString('utf8')
53
+ .split('\n')
54
+ .map((l) => l.trim())
55
+ .filter(Boolean);
56
+ resolve(lines);
57
+ });
58
+ process.stdin.resume();
59
+ });
60
+ }
61
+
62
+ // The resolved changed-set: (positionals ∪ --files), falling back to stdin
63
+ // only when that union is empty. De-duplicated, first-seen order preserved.
64
+ async function resolveChangedFiles(args) {
65
+ const positionals = args._.slice(1).filter((p) => typeof p === 'string' && p);
66
+ const flagged = typeof args.files === 'string' && args.files ? [args.files] : [];
67
+ const named = dedupe([...positionals, ...flagged]);
68
+ if (named.length > 0) return named;
69
+ return dedupe(await readStdinLines());
70
+ }
71
+
72
+ function dedupe(list) {
73
+ const seen = new Set();
74
+ const out = [];
75
+ for (const f of list) {
76
+ if (!seen.has(f)) {
77
+ seen.add(f);
78
+ out.push(f);
79
+ }
80
+ }
81
+ return out;
82
+ }
83
+
84
+ export async function obligations(args) {
85
+ const changedFiles = await resolveChangedFiles(args);
86
+ const strict = Boolean(args.strict);
87
+ const result = checkObligations({ changedFiles, map: SURFACE_PARTNER_MAP });
88
+
89
+ if (args.json) {
90
+ log(JSON.stringify({ ...result, strict }, null, 2));
91
+ } else {
92
+ render(result, changedFiles);
93
+ }
94
+
95
+ return {
96
+ exitCode: strict && result.unmet > 0 ? 1 : 0,
97
+ 'lorekit.cli.obligations.files': changedFiles.length,
98
+ 'lorekit.cli.obligations.matched': result.matched.length,
99
+ 'lorekit.cli.obligations.unmet': result.unmet,
100
+ 'lorekit.cli.obligations.strict': strict,
101
+ };
102
+ }
103
+
104
+ function render(result, changedFiles) {
105
+ heading('LoreKit obligations');
106
+ log(` files: ${c.dim(changedFiles.length ? changedFiles.join(', ') : '(none given)')}`);
107
+
108
+ if (result.matched.length === 0) {
109
+ log('');
110
+ log(` ${c.dim('no known surface-partner obligations for the given changed-set')}`);
111
+ log('');
112
+ return;
113
+ }
114
+
115
+ for (const entry of result.matched) {
116
+ log('');
117
+ log(` ${c.bold(entry.id)}${entry.guard ? c.dim(` (guard: ${entry.guard})`) : ''}`);
118
+ if (entry.note) log(` ${c.dim(entry.note)}`);
119
+ for (const o of entry.obliges) {
120
+ const mark = o.kind === 'action' ? c.cyan('•') : o.met ? c.green('✓') : c.yellow('!');
121
+ const suffix = o.kind === 'action' ? c.dim(' (advisory — run this yourself)') : '';
122
+ log(` ${mark} ${o.target}${suffix}`);
123
+ }
124
+ log(` ${c.dim(`cites: ${entry.lessonKey}`)}`);
125
+ }
126
+
127
+ log('');
128
+ if (result.unmet === 0) {
129
+ log(` ${c.green('✓')} every known path obligation is satisfied by the given changed-set`);
130
+ } else {
131
+ const plural = result.unmet === 1 ? '' : 's';
132
+ log(` ${c.yellow('!')} ${result.unmet} unmet obligation${plural} — sweep the partner${plural} above`);
133
+ }
134
+ log('');
135
+ }
@@ -13,6 +13,7 @@ import {
13
13
  removeClaudeHooks,
14
14
  homeDir,
15
15
  } from '../shared/config.mjs';
16
+ import { COMPLETION_SHELLS, removeCompletion } from '../shared/completions.mjs';
16
17
  import { log, heading, status, c } from '../shared/util.mjs';
17
18
 
18
19
  function ask(question) {
@@ -67,10 +68,20 @@ export async function uninstall(args) {
67
68
  // "nothing to remove", other servers in the file are preserved.
68
69
  const webMcp = scope === 'global' ? attempt(() => removeWebMcpServer(root)) : null;
69
70
  const hooks = attempt(() => removeClaudeHooks(root, scope));
71
+ // Shell completion is a single user-level artefact (not project/global
72
+ // scoped), so tear it down for every supported shell regardless of the chosen
73
+ // scope. Each `removeCompletion` touches only lorekit's own script file and
74
+ // guarded ~/.zshrc block, so it never disturbs a hand-written completion.
75
+ const completions = COMPLETION_SHELLS.map((shell) => ({
76
+ shell,
77
+ step: attempt(() => removeCompletion(shell, { home: homeDir() })),
78
+ }));
70
79
 
71
80
  // Global paths shown relative to ~; project paths repo-relative.
72
81
  const display = (p) =>
73
82
  scope === 'global' ? p.replace(homeDir(), '~') : path.relative(root, p) || p;
83
+ // Completion artefacts always live under ~, whatever the uninstall scope.
84
+ const homeDisplay = (p) => p.replace(homeDir(), '~');
74
85
  const mcpLabel = scope === 'global' ? '~/.claude.json' : '.mcp.json';
75
86
 
76
87
  heading('Done');
@@ -95,15 +106,23 @@ export async function uninstall(args) {
95
106
  done: (r) => `${r.removed} removed → ${display(r.file)}`,
96
107
  noop: 'no lorekit hooks — nothing to remove',
97
108
  });
109
+ for (const { shell, step } of completions) {
110
+ report(step, `completion ${shell}`, {
111
+ done: (r) => `removed → ${homeDisplay(r.file)}${r.rcUpdated ? ' + ~/.zshrc block' : ''}`,
112
+ noop: 'not installed — nothing to remove',
113
+ });
114
+ }
98
115
 
99
116
  const skillStepList = skillSteps.map((s) => s.step);
100
117
  const webSteps = webMcp ? [webMcp] : [];
101
- const failed = [...skillStepList, mcp, ...webSteps, hooks].some((s) => !s.ok);
118
+ const completionSteps = completions.map((s) => s.step);
119
+ const failed = [...skillStepList, mcp, ...webSteps, hooks, ...completionSteps].some((s) => !s.ok);
102
120
  const any =
103
121
  (skillStepList.some((s) => s.result?.removed) ||
104
122
  mcp.result?.removed ||
105
123
  webMcp?.result?.removed ||
106
- hooks.result?.removed) && true;
124
+ hooks.result?.removed ||
125
+ completionSteps.some((s) => s.result?.removed)) && true;
107
126
 
108
127
  if (failed) {
109
128
  log(`\n ${c.dim('Some items could not be removed and were left untouched — see above.')}`);
package/src/commands.mjs CHANGED
@@ -47,12 +47,14 @@ import { diff } from './commands/diff.mjs';
47
47
  import { tree } from './commands/tree.mjs';
48
48
  import { lint } from './commands/lint.mjs';
49
49
  import { dedupe } from './commands/dedupe.mjs';
50
+ import { obligations } from './commands/obligations.mjs';
50
51
  import { link } from './commands/link.mjs';
51
52
  import { hook } from './commands/hook.mjs';
52
53
  import { migrate } from './commands/migrate.mjs';
53
54
  import { bootstrap } from './commands/bootstrap.mjs';
54
55
  import { mcpServer } from './commands/mcp-server.mjs';
55
56
  import { purge, purgeExpired } from './commands/purge.mjs';
57
+ import { completion } from './commands/completion.mjs';
56
58
 
57
59
  /**
58
60
  * Every command, in the order the top-level help lists them.
@@ -77,6 +79,7 @@ export const COMMANDS = [
77
79
  { name: 'tree', run: tree, traced: true, strictFlags: true, native: 'resolves the scope hierarchy for a directory', aliases: ['resolve'] },
78
80
  { name: 'lint', run: lint, traced: true, strictFlags: true, native: 'quality pass over stored lessons' },
79
81
  { name: 'dedupe', run: dedupe, traced: true, strictFlags: true, native: 'near-duplicate detection across a scope' },
82
+ { name: 'obligations', run: obligations, traced: true, strictFlags: true, native: 'checks changed files against the surface-partner map' },
80
83
  { name: 'link', run: link, traced: true, strictFlags: true, native: 'builds a dashboard deep link', aliases: ['url'] },
81
84
  { name: 'migrate', run: migrate, traced: true, strictFlags: true, native: 'moves lore between local and remote stores' },
82
85
  { name: 'bootstrap', run: bootstrap, traced: true, strictFlags: true, native: 'seeds a fresh store from a template' },
@@ -88,6 +91,11 @@ export const COMMANDS = [
88
91
  { name: 'purge-expired', run: purgeExpired, traced: true, strictFlags: true, tool: 'memory.purge_expired' },
89
92
 
90
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' },
91
99
  { name: 'hook', run: hook, traced: false, strictFlags: false, machine: true, native: 'host hook engine — stdout is the host\'s JSON contract' },
92
100
  { name: 'mcp', run: mcpServer, traced: false, strictFlags: false, machine: true, native: 'local stdio MCP server — stdout is JSON-RPC frames' },
93
101
  ];