@membank/cli 0.1.1 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +154 -0
  2. package/dist/index.mjs +348 -146
  3. package/package.json +3 -3
package/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # @membank/cli
2
+
3
+ CLI and npx entrypoint for membank. Manages memories from the terminal and starts the MCP server for LLM harnesses.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -g @membank/cli
9
+ ```
10
+
11
+ Or use without installing:
12
+
13
+ ```bash
14
+ npx @membank/cli <command>
15
+ ```
16
+
17
+ ## Setup
18
+
19
+ Run once to configure your LLM harness:
20
+
21
+ ```bash
22
+ membank setup
23
+ ```
24
+
25
+ This auto-detects installed harnesses (Claude Code, GitHub Copilot CLI, Codex, OpenCode), writes MCP server config, installs session hooks, and downloads the embedding model (~33 MB).
26
+
27
+ Options:
28
+
29
+ ```
30
+ --harness <name> Target a specific harness instead of auto-detecting
31
+ --yes Skip confirmation prompts
32
+ --dry-run Preview changes without writing files
33
+ --json Machine-readable output
34
+ ```
35
+
36
+ Supported harnesses: `claude-code`, `copilot`, `codex`, `opencode`
37
+
38
+ ## Commands
39
+
40
+ ### `membank query <text>`
41
+
42
+ Semantic search over stored memories.
43
+
44
+ ```bash
45
+ membank query "how to run pnpm in one package"
46
+ membank query "auth decisions" --type decision --limit 5
47
+ ```
48
+
49
+ Options: `--type <type>`, `--limit <n>` (default 10)
50
+
51
+ ### `membank add <content>`
52
+
53
+ Save a new memory.
54
+
55
+ ```bash
56
+ membank add "Use --filter flag for scoped pnpm commands" --type preference --tags "pnpm,monorepo"
57
+ ```
58
+
59
+ Required: `--type <type>`
60
+ Options: `--tags <a,b,c>`, `--scope <scope>`
61
+
62
+ ### `membank list`
63
+
64
+ List stored memories.
65
+
66
+ ```bash
67
+ membank list
68
+ membank list --type correction
69
+ membank list --pinned
70
+ ```
71
+
72
+ Options: `--type <type>`, `--pinned`
73
+
74
+ ### `membank stats`
75
+
76
+ Show memory counts by type.
77
+
78
+ ```bash
79
+ membank stats
80
+ ```
81
+
82
+ ### `membank pin <id>` / `membank unpin <id>`
83
+
84
+ Pin a memory so it's always injected at session start.
85
+
86
+ ```bash
87
+ membank pin abc123
88
+ membank unpin abc123
89
+ ```
90
+
91
+ ### `membank delete <id>`
92
+
93
+ Delete a memory. Prompts for confirmation unless `--yes` is passed.
94
+
95
+ ```bash
96
+ membank delete abc123
97
+ membank delete abc123 --yes
98
+ ```
99
+
100
+ ### `membank export`
101
+
102
+ Export all memories to a JSON file.
103
+
104
+ ```bash
105
+ membank export
106
+ membank export --output my-backup.json
107
+ ```
108
+
109
+ Default filename: `membank-export-<timestamp>.json`
110
+
111
+ ### `membank import <file>`
112
+
113
+ Import memories from an export file.
114
+
115
+ ```bash
116
+ membank import membank-export-2025-01-01.json
117
+ membank import membank-export-2025-01-01.json --yes
118
+ ```
119
+
120
+ ### `membank inject`
121
+
122
+ Output session context formatted for a harness. Called automatically by session hooks — you don't normally run this directly.
123
+
124
+ ```bash
125
+ membank inject --harness claude-code --scope <project-scope>
126
+ ```
127
+
128
+ ## Global flags
129
+
130
+ ```
131
+ --json Output machine-readable JSON
132
+ --yes, -y Skip confirmation prompts
133
+ --mcp Start MCP stdio server (used by harness config)
134
+ ```
135
+
136
+ ## MCP server mode
137
+
138
+ ```bash
139
+ membank --mcp
140
+ ```
141
+
142
+ Starts the stdio MCP server. This is what harnesses connect to — `setup` writes this command into harness configs automatically.
143
+
144
+ ## Session hooks
145
+
146
+ `setup` installs two hooks:
147
+
148
+ **Session start** — calls `membank inject` to prepend pinned memories into the LLM context at the beginning of every session.
149
+
150
+ **Session stop (Claude Code only)** — prompts the LLM to review the session and call `save_memory` for any notable corrections, preferences, or decisions.
151
+
152
+ ## Requirements
153
+
154
+ - Node.js >=24
package/dist/index.mjs CHANGED
@@ -4,9 +4,9 @@ import { startServer } from "@membank/mcp";
4
4
  import { Command } from "commander";
5
5
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
+ import { homedir, tmpdir } from "node:os";
7
8
  import * as readline from "node:readline";
8
9
  import { createInterface } from "node:readline";
9
- import { homedir, tmpdir } from "node:os";
10
10
  import { execFile } from "node:child_process";
11
11
  import { promisify } from "node:util";
12
12
  import { EventEmitter } from "node:events";
@@ -225,6 +225,303 @@ async function statsCommand(formatter) {
225
225
  }
226
226
  }
227
227
  //#endregion
228
+ //#region src/setup/injection-hook-writer.ts
229
+ const defaultPathResolver$1 = { home: () => {
230
+ const h = process.env.HOME ?? process.env.USERPROFILE;
231
+ if (!h) throw new Error("Cannot determine home directory");
232
+ return h;
233
+ } };
234
+ function readJson$1(path) {
235
+ try {
236
+ return JSON.parse(readFileSync(path, "utf8"));
237
+ } catch {
238
+ return {};
239
+ }
240
+ }
241
+ function writeJsonAtomic$1(path, data) {
242
+ mkdirSync(dirname(path), { recursive: true });
243
+ const tmp = join(mkdtempSync(join(tmpdir(), "membank-hook-")), "cfg.json");
244
+ writeFileSync(tmp, JSON.stringify(data, null, 2));
245
+ renameSync(tmp, path);
246
+ }
247
+ function getHooksArray(group) {
248
+ if (typeof group !== "object" || group === null) return [];
249
+ const h = group.hooks;
250
+ return Array.isArray(h) ? h : [];
251
+ }
252
+ function findMembankHookCommand(hooks, pattern) {
253
+ for (const h of hooks) {
254
+ if (typeof h !== "object" || h === null) continue;
255
+ if ("command" in h && typeof h.command === "string" && h.command.includes(pattern)) return h.command;
256
+ if ("bash" in h && typeof h.bash === "string" && h.bash.includes(pattern)) return h.bash;
257
+ }
258
+ return "";
259
+ }
260
+ function containsMembankInject(hooks) {
261
+ return findMembankHookCommand(hooks, "@membank/cli inject") !== "";
262
+ }
263
+ function extractInjectCommand(hooks) {
264
+ return findMembankHookCommand(hooks, "@membank/cli inject");
265
+ }
266
+ const writers$1 = {
267
+ "claude-code": {
268
+ replacement: "npx @membank/cli inject --harness claude-code",
269
+ write(resolver, overwrite = false) {
270
+ const cfgPath = join(resolver.home(), ".claude", "settings.json");
271
+ const cfg = readJson$1(cfgPath);
272
+ const hooks = cfg.hooks;
273
+ const existingGroups = Array.isArray(hooks?.SessionStart) ? hooks.SessionStart : [];
274
+ const innerHooks = existingGroups.flatMap(getHooksArray);
275
+ if (!overwrite && containsMembankInject(innerHooks)) return {
276
+ status: "already-configured",
277
+ existing: extractInjectCommand(innerHooks),
278
+ replacement: this.replacement
279
+ };
280
+ const filteredGroups = overwrite ? existingGroups.filter((g) => !containsMembankInject(getHooksArray(g))) : existingGroups;
281
+ writeJsonAtomic$1(cfgPath, {
282
+ ...cfg,
283
+ hooks: {
284
+ ...hooks ?? {},
285
+ SessionStart: [...filteredGroups, {
286
+ matcher: "",
287
+ hooks: [{
288
+ type: "command",
289
+ command: this.replacement
290
+ }]
291
+ }]
292
+ }
293
+ });
294
+ return { status: "written" };
295
+ }
296
+ },
297
+ "copilot-cli": {
298
+ replacement: "npx @membank/cli inject --harness copilot-cli",
299
+ write(resolver, overwrite = false) {
300
+ const cfgPath = join(resolver.home(), ".copilot", "settings.json");
301
+ const cfg = readJson$1(cfgPath);
302
+ const hooks = cfg.hooks;
303
+ const existingHooks = Array.isArray(hooks?.sessionStart) ? hooks.sessionStart : [];
304
+ if (!overwrite && containsMembankInject(existingHooks)) return {
305
+ status: "already-configured",
306
+ existing: extractInjectCommand(existingHooks),
307
+ replacement: this.replacement
308
+ };
309
+ const filteredHooks = overwrite ? existingHooks.filter((h) => !containsMembankInject([h])) : existingHooks;
310
+ writeJsonAtomic$1(cfgPath, {
311
+ version: cfg.version ?? 1,
312
+ ...cfg,
313
+ hooks: {
314
+ ...hooks ?? {},
315
+ sessionStart: [...filteredHooks, {
316
+ type: "command",
317
+ bash: this.replacement,
318
+ timeoutSec: 30
319
+ }]
320
+ }
321
+ });
322
+ return { status: "written" };
323
+ }
324
+ },
325
+ codex: {
326
+ replacement: "npx @membank/cli inject --harness codex",
327
+ write(resolver, overwrite = false) {
328
+ const cfgPath = join(resolver.home(), ".codex", "hooks.json");
329
+ const cfg = readJson$1(cfgPath);
330
+ const hooks = cfg.hooks;
331
+ const existingGroups = Array.isArray(hooks?.SessionStart) ? hooks.SessionStart : [];
332
+ const innerHooks = existingGroups.flatMap(getHooksArray);
333
+ if (!overwrite && containsMembankInject(innerHooks)) return {
334
+ status: "already-configured",
335
+ existing: extractInjectCommand(innerHooks),
336
+ replacement: this.replacement
337
+ };
338
+ const filteredGroups = overwrite ? existingGroups.filter((g) => !containsMembankInject(getHooksArray(g))) : existingGroups;
339
+ writeJsonAtomic$1(cfgPath, {
340
+ ...cfg,
341
+ hooks: {
342
+ ...hooks ?? {},
343
+ SessionStart: [...filteredGroups, {
344
+ matcher: "",
345
+ hooks: [{
346
+ type: "command",
347
+ command: this.replacement,
348
+ timeout: 30
349
+ }]
350
+ }]
351
+ }
352
+ });
353
+ return { status: "written" };
354
+ }
355
+ },
356
+ opencode: {
357
+ replacement: "npx @membank/cli inject",
358
+ write(resolver, overwrite = false) {
359
+ const pluginPath = join(resolver.home(), ".config", "opencode", "plugins", "membank.js");
360
+ if (!overwrite && existsSync(pluginPath)) {
361
+ const existing = readFileSync(pluginPath, "utf8");
362
+ if (existing.includes("@membank/cli inject")) return {
363
+ status: "already-configured",
364
+ existing: existing.trim(),
365
+ replacement: newOpencodePlugin()
366
+ };
367
+ }
368
+ mkdirSync(dirname(pluginPath), { recursive: true });
369
+ writeFileSync(pluginPath, `${newOpencodePlugin()}\n`, "utf8");
370
+ return { status: "written" };
371
+ }
372
+ }
373
+ };
374
+ function newOpencodePlugin(includeIdle = false) {
375
+ return [
376
+ "export default {",
377
+ " hooks: {",
378
+ " \"session.start\": async ({ $ }) => {",
379
+ " return await $`npx @membank/cli inject`.text();",
380
+ " },",
381
+ ...includeIdle ? [
382
+ " \"session.idle\": async ({ $ }) => {",
383
+ " return await $`npx @membank/cli stop-hook --harness opencode`.text();",
384
+ " },"
385
+ ] : [],
386
+ " },",
387
+ "};"
388
+ ].join("\n");
389
+ }
390
+ const STOP_HOOK_PROMPT = "Review this session and consider whether any user preferences, corrections, decisions, or learnings are worth saving for future sessions. If so, use the save_memory MCP tool to store them. Be selective — only save what would genuinely help in a future conversation. Skip ephemeral task details.";
391
+ function containsMembankStopHookCmd(hooks) {
392
+ return findMembankHookCommand(hooks, "@membank/cli stop-hook") !== "";
393
+ }
394
+ function extractStopHookCmd(hooks) {
395
+ return findMembankHookCommand(hooks, "@membank/cli stop-hook");
396
+ }
397
+ function containsMembankStopPrompt(stopGroups) {
398
+ return stopGroups.some((g) => getHooksArray(g).some((h) => typeof h === "object" && h !== null && "type" in h && h.type === "prompt" && "prompt" in h && typeof h.prompt === "string" && h.prompt.includes("save_memory")));
399
+ }
400
+ function extractStopPrompt(stopGroups) {
401
+ for (const g of stopGroups) for (const h of getHooksArray(g)) if (typeof h === "object" && h !== null && "type" in h && h.type === "prompt" && "prompt" in h && typeof h.prompt === "string" && h.prompt.includes("save_memory")) return h.prompt;
402
+ return "";
403
+ }
404
+ const stopHookWriters = {
405
+ "claude-code": { write(resolver, overwrite = false) {
406
+ const cfgPath = join(resolver.home(), ".claude", "settings.json");
407
+ const cfg = readJson$1(cfgPath);
408
+ const hooks = cfg.hooks;
409
+ const existingStop = Array.isArray(hooks?.Stop) ? hooks.Stop : [];
410
+ if (!overwrite && containsMembankStopPrompt(existingStop)) return {
411
+ status: "already-configured",
412
+ existing: extractStopPrompt(existingStop),
413
+ replacement: STOP_HOOK_PROMPT
414
+ };
415
+ const filteredStop = overwrite ? existingStop.filter((g) => !containsMembankStopPrompt([g])) : existingStop;
416
+ writeJsonAtomic$1(cfgPath, {
417
+ ...cfg,
418
+ hooks: {
419
+ ...hooks ?? {},
420
+ Stop: [...filteredStop, { hooks: [{
421
+ type: "prompt",
422
+ prompt: STOP_HOOK_PROMPT
423
+ }] }]
424
+ }
425
+ });
426
+ return { status: "written" };
427
+ } },
428
+ "copilot-cli": { write(resolver, overwrite = false) {
429
+ const cfgPath = join(resolver.home(), ".copilot", "settings.json");
430
+ const cfg = readJson$1(cfgPath);
431
+ const replacement = "npx @membank/cli stop-hook --harness copilot-cli";
432
+ const hooks = cfg.hooks;
433
+ const existingStop = Array.isArray(hooks?.stop) ? hooks.stop : [];
434
+ if (!overwrite && containsMembankStopHookCmd(existingStop)) return {
435
+ status: "already-configured",
436
+ existing: extractStopHookCmd(existingStop),
437
+ replacement
438
+ };
439
+ const filteredStop = overwrite ? existingStop.filter((h) => !containsMembankStopHookCmd([h])) : existingStop;
440
+ writeJsonAtomic$1(cfgPath, {
441
+ version: cfg.version ?? 1,
442
+ ...cfg,
443
+ hooks: {
444
+ ...hooks ?? {},
445
+ stop: [...filteredStop, {
446
+ type: "command",
447
+ bash: replacement,
448
+ timeoutSec: 30
449
+ }]
450
+ }
451
+ });
452
+ return { status: "written" };
453
+ } },
454
+ codex: { write(resolver, overwrite = false) {
455
+ const cfgPath = join(resolver.home(), ".codex", "hooks.json");
456
+ const cfg = readJson$1(cfgPath);
457
+ const replacement = "npx @membank/cli stop-hook --harness codex";
458
+ const hooks = cfg.hooks;
459
+ const existingGroups = Array.isArray(hooks?.Stop) ? hooks.Stop : [];
460
+ const innerHooks = existingGroups.flatMap(getHooksArray);
461
+ if (!overwrite && containsMembankStopHookCmd(innerHooks)) return {
462
+ status: "already-configured",
463
+ existing: extractStopHookCmd(innerHooks),
464
+ replacement
465
+ };
466
+ const filteredGroups = overwrite ? existingGroups.filter((g) => !containsMembankStopHookCmd(getHooksArray(g))) : existingGroups;
467
+ writeJsonAtomic$1(cfgPath, {
468
+ ...cfg,
469
+ hooks: {
470
+ ...hooks ?? {},
471
+ Stop: [...filteredGroups, {
472
+ matcher: "",
473
+ hooks: [{
474
+ type: "command",
475
+ command: replacement,
476
+ timeout: 30
477
+ }]
478
+ }]
479
+ }
480
+ });
481
+ return { status: "written" };
482
+ } },
483
+ opencode: { write(resolver, overwrite = false) {
484
+ const pluginPath = join(resolver.home(), ".config", "opencode", "plugins", "membank.js");
485
+ if (!overwrite && existsSync(pluginPath)) {
486
+ const existing = readFileSync(pluginPath, "utf8");
487
+ if (existing.includes("@membank/cli stop-hook")) return {
488
+ status: "already-configured",
489
+ existing: existing.trim(),
490
+ replacement: newOpencodePlugin(true)
491
+ };
492
+ }
493
+ mkdirSync(dirname(pluginPath), { recursive: true });
494
+ writeFileSync(pluginPath, `${newOpencodePlugin(true)}\n`, "utf8");
495
+ return { status: "written" };
496
+ } }
497
+ };
498
+ var InjectionHookWriter = class {
499
+ #resolver;
500
+ constructor(resolver = defaultPathResolver$1) {
501
+ this.#resolver = resolver;
502
+ }
503
+ write(harness, overwrite) {
504
+ const writer = writers$1[harness];
505
+ if (!writer) return { status: "not-supported" };
506
+ return writer.write(this.#resolver, overwrite);
507
+ }
508
+ writeStopHook(harness, overwrite) {
509
+ const writer = stopHookWriters[harness];
510
+ if (!writer) return { status: "not-supported" };
511
+ return writer.write(this.#resolver, overwrite);
512
+ }
513
+ };
514
+ //#endregion
515
+ //#region src/commands/stop-hook.ts
516
+ function stopHookCommand(opts) {
517
+ const { harness } = opts;
518
+ if (harness === "copilot-cli" || harness === "codex") {
519
+ process.stdout.write(JSON.stringify({ systemMessage: STOP_HOOK_PROMPT }));
520
+ return;
521
+ }
522
+ process.stdout.write(`${STOP_HOOK_PROMPT}\n`);
523
+ }
524
+ //#endregion
228
525
  //#region src/commands/unpin.ts
229
526
  function unpinCommand(id, formatter, db) {
230
527
  const ownDb = db === void 0;
@@ -371,7 +668,7 @@ async function execFileNoThrow(cmd, args) {
371
668
  }
372
669
  //#endregion
373
670
  //#region src/setup/harness-config-writer.ts
374
- const defaultPathResolver$1 = {
671
+ const defaultPathResolver = {
375
672
  home: () => {
376
673
  const h = process.env.HOME ?? process.env.USERPROFILE;
377
674
  if (!h) throw new Error("Cannot determine home directory");
@@ -379,14 +676,14 @@ const defaultPathResolver$1 = {
379
676
  },
380
677
  cwd: () => process.cwd()
381
678
  };
382
- function readJson$1(path) {
679
+ function readJson(path) {
383
680
  try {
384
681
  return JSON.parse(readFileSync(path, "utf8"));
385
682
  } catch {
386
683
  return {};
387
684
  }
388
685
  }
389
- function writeJsonAtomic$1(path, data) {
686
+ function writeJsonAtomic(path, data) {
390
687
  mkdirSync(dirname(path), { recursive: true });
391
688
  const tmp = join(mkdtempSync(join(tmpdir(), "membank-")), "cfg.json");
392
689
  writeFileSync(tmp, JSON.stringify(data, null, 2));
@@ -403,9 +700,9 @@ const MEMBANK_NPX_ARGS = [
403
700
  "@membank/cli@latest",
404
701
  "--mcp"
405
702
  ];
406
- const writers$1 = {
703
+ const writers = {
407
704
  "claude-code": { async write(resolver, run, { overwrite = false } = {}) {
408
- const configured = hasKey(readJson$1(join(resolver.home(), ".claude.json")).mcpServers, "membank");
705
+ const configured = hasKey(readJson(join(resolver.home(), ".claude.json")).mcpServers, "membank");
409
706
  if (configured && !overwrite) return { status: "already-configured" };
410
707
  if (configured) {
411
708
  const remove = await run("claude", [
@@ -433,9 +730,9 @@ const writers$1 = {
433
730
  } },
434
731
  copilot: { async write(resolver, _run, { overwrite = false } = {}) {
435
732
  const cfgPath = join(resolver.home(), ".copilot", "mcp-config.json");
436
- const cfg = readJson$1(cfgPath);
733
+ const cfg = readJson(cfgPath);
437
734
  if (hasKey(cfg.mcpServers, "membank") && !overwrite) return { status: "already-configured" };
438
- writeJsonAtomic$1(cfgPath, {
735
+ writeJsonAtomic(cfgPath, {
439
736
  ...cfg,
440
737
  mcpServers: {
441
738
  ...cfg.mcpServers,
@@ -474,9 +771,9 @@ const writers$1 = {
474
771
  } },
475
772
  opencode: { async write(resolver, _run, { overwrite = false } = {}) {
476
773
  const cfgPath = join(resolver.home(), ".config", "opencode", "opencode.json");
477
- const cfg = readJson$1(cfgPath);
774
+ const cfg = readJson(cfgPath);
478
775
  if (hasKey(cfg.mcp, "membank") && !overwrite) return { status: "already-configured" };
479
- writeJsonAtomic$1(cfgPath, {
776
+ writeJsonAtomic(cfgPath, {
480
777
  ...cfg,
481
778
  mcp: {
482
779
  ...cfg.mcp,
@@ -493,142 +790,21 @@ const writers$1 = {
493
790
  return { status: "written" };
494
791
  } }
495
792
  };
496
- const SUPPORTED_HARNESSES = Object.keys(writers$1);
793
+ const SUPPORTED_HARNESSES = Object.keys(writers);
497
794
  var HarnessConfigWriter = class {
498
795
  #resolver;
499
796
  #run;
500
- constructor(resolver = defaultPathResolver$1, run = execFileNoThrow) {
797
+ constructor(resolver = defaultPathResolver, run = execFileNoThrow) {
501
798
  this.#resolver = resolver;
502
799
  this.#run = run;
503
800
  }
504
801
  async write(harness, { overwrite = false } = {}) {
505
- const writer = writers$1[harness];
802
+ const writer = writers[harness];
506
803
  if (!writer) throw new Error(`Unknown harness: ${harness}`);
507
804
  return writer.write(this.#resolver, this.#run, { overwrite });
508
805
  }
509
806
  };
510
807
  //#endregion
511
- //#region src/setup/injection-hook-writer.ts
512
- const defaultPathResolver = { home: () => {
513
- const h = process.env.HOME ?? process.env.USERPROFILE;
514
- if (!h) throw new Error("Cannot determine home directory");
515
- return h;
516
- } };
517
- function readJson(path) {
518
- try {
519
- return JSON.parse(readFileSync(path, "utf8"));
520
- } catch {
521
- return {};
522
- }
523
- }
524
- function writeJsonAtomic(path, data) {
525
- mkdirSync(dirname(path), { recursive: true });
526
- const tmp = join(mkdtempSync(join(tmpdir(), "membank-hook-")), "cfg.json");
527
- writeFileSync(tmp, JSON.stringify(data, null, 2));
528
- renameSync(tmp, path);
529
- }
530
- function containsMembankInject(hooks) {
531
- if (!Array.isArray(hooks)) return false;
532
- return hooks.some((h) => typeof h === "object" && h !== null && ("command" in h && typeof h.command === "string" && h.command.includes("@membank/cli inject") || "bash" in h && typeof h.bash === "string" && h.bash.includes("@membank/cli inject")));
533
- }
534
- const writers = {
535
- "claude-code": { write(resolver) {
536
- const cfgPath = join(resolver.home(), ".claude", "settings.json");
537
- const cfg = readJson(cfgPath);
538
- const hooks = cfg.hooks;
539
- const sessionStart = hooks?.SessionStart;
540
- if (Array.isArray(sessionStart) && containsMembankInject(sessionStart.flatMap((g) => Array.isArray(g.hooks) ? g.hooks : []))) return { status: "already-configured" };
541
- const existingSessionStart = Array.isArray(sessionStart) ? sessionStart : [];
542
- writeJsonAtomic(cfgPath, {
543
- ...cfg,
544
- hooks: {
545
- ...hooks ?? {},
546
- SessionStart: [...existingSessionStart, {
547
- matcher: "",
548
- hooks: [{
549
- type: "command",
550
- command: "npx @membank/cli inject --harness claude-code"
551
- }]
552
- }]
553
- }
554
- });
555
- return { status: "written" };
556
- } },
557
- "copilot-cli": { write(resolver) {
558
- const cfgPath = join(resolver.home(), ".copilot", "settings.json");
559
- const cfg = readJson(cfgPath);
560
- const hooks = cfg.hooks;
561
- const sessionStart = hooks?.sessionStart;
562
- if (Array.isArray(sessionStart) && containsMembankInject(sessionStart)) return { status: "already-configured" };
563
- const existingSessionStart = Array.isArray(sessionStart) ? sessionStart : [];
564
- writeJsonAtomic(cfgPath, {
565
- version: cfg.version ?? 1,
566
- ...cfg,
567
- hooks: {
568
- ...hooks ?? {},
569
- sessionStart: [...existingSessionStart, {
570
- type: "command",
571
- bash: "npx @membank/cli inject --harness copilot-cli",
572
- timeoutSec: 30
573
- }]
574
- }
575
- });
576
- return { status: "written" };
577
- } },
578
- codex: { write(resolver) {
579
- const cfgPath = join(resolver.home(), ".codex", "hooks.json");
580
- const cfg = readJson(cfgPath);
581
- const hooks = cfg.hooks;
582
- const sessionStart = hooks?.SessionStart;
583
- if (Array.isArray(sessionStart) && containsMembankInject(sessionStart.flatMap((g) => Array.isArray(g.hooks) ? g.hooks : []))) return { status: "already-configured" };
584
- const existingSessionStart = Array.isArray(sessionStart) ? sessionStart : [];
585
- writeJsonAtomic(cfgPath, {
586
- ...cfg,
587
- hooks: {
588
- ...hooks ?? {},
589
- SessionStart: [...existingSessionStart, {
590
- matcher: "",
591
- hooks: [{
592
- type: "command",
593
- command: "npx @membank/cli inject --harness codex",
594
- timeout: 30
595
- }]
596
- }]
597
- }
598
- });
599
- return { status: "written" };
600
- } },
601
- opencode: { write(resolver) {
602
- const pluginPath = join(resolver.home(), ".config", "opencode", "plugins", "membank.js");
603
- if (existsSync(pluginPath)) {
604
- if (readFileSync(pluginPath, "utf8").includes("@membank/cli inject")) return { status: "already-configured" };
605
- }
606
- mkdirSync(dirname(pluginPath), { recursive: true });
607
- writeFileSync(pluginPath, [
608
- "export default {",
609
- " hooks: {",
610
- " \"session.start\": async ({ $ }) => {",
611
- " return await $`npx @membank/cli inject`.text();",
612
- " },",
613
- " },",
614
- "};",
615
- ""
616
- ].join("\n"), "utf8");
617
- return { status: "written" };
618
- } }
619
- };
620
- var InjectionHookWriter = class {
621
- #resolver;
622
- constructor(resolver = defaultPathResolver) {
623
- this.#resolver = resolver;
624
- }
625
- write(harness) {
626
- const writer = writers[harness];
627
- if (!writer) return { status: "not-supported" };
628
- return writer.write(this.#resolver);
629
- }
630
- };
631
- //#endregion
632
808
  //#region src/setup/model-downloader.ts
633
809
  const MODEL_NAME = "Xenova/bge-small-en-v1.5";
634
810
  var ModelDownloadError = class extends Error {
@@ -778,6 +954,7 @@ var SetupOrchestrator = class {
778
954
  detectedHarnesses: [],
779
955
  configuredHarnesses: [],
780
956
  injectionHooksConfigured: [],
957
+ stopHooksConfigured: [],
781
958
  modelDownloaded: false
782
959
  }));
783
960
  return [];
@@ -791,7 +968,10 @@ var SetupOrchestrator = class {
791
968
  out("Planned changes (dry-run — no files written):");
792
969
  for (const h of detected) {
793
970
  out(` ⚠ ${h.name}: would write MCP config`);
794
- if (this.#hookWriter) out(` ⚠ ${h.name}: would write injection hook config`);
971
+ if (this.#hookWriter) {
972
+ out(` ⚠ ${h.name}: would write injection hook config`);
973
+ out(` ⚠ ${h.name}: would write stop hook config`);
974
+ }
795
975
  }
796
976
  out("");
797
977
  out(" ⚠ Model download: skipped (dry-run)");
@@ -858,18 +1038,11 @@ var SetupOrchestrator = class {
858
1038
  }
859
1039
  out("");
860
1040
  const injectionHooksConfigured = [];
1041
+ const stopHooksConfigured = [];
861
1042
  if (this.#hookWriter) {
862
- for (const h of detected) try {
863
- const hookResult = this.#hookWriter.write(h.name);
864
- if (hookResult.status === "not-supported") continue;
865
- if (hookResult.status === "written") {
866
- out(` ✓ ${h.name}: injection hook written`);
867
- injectionHooksConfigured.push(h.name);
868
- } else out(` ⚠ ${h.name}: injection hook already configured`);
869
- } catch (err) {
870
- const msg = err instanceof Error ? err.message : String(err);
871
- out(` ✗ ${h.name} injection hook: ${msg}`);
872
- }
1043
+ const w = this.#hookWriter;
1044
+ injectionHooksConfigured.push(...await this.#runHookLoop(detected, "injection hook", (h, ow) => w.write(h, ow), yes, out));
1045
+ stopHooksConfigured.push(...await this.#runHookLoop(detected, "stop hook", (h, ow) => w.writeStopHook(h, ow), yes, out));
873
1046
  out("");
874
1047
  }
875
1048
  let modelDownloaded = false;
@@ -883,6 +1056,7 @@ var SetupOrchestrator = class {
883
1056
  detectedHarnesses: detected.map((h) => h.name),
884
1057
  configuredHarnesses: results.filter((r) => r.status === "written").map((r) => r.harness),
885
1058
  injectionHooksConfigured,
1059
+ stopHooksConfigured,
886
1060
  modelDownloaded
887
1061
  };
888
1062
  this.#out(JSON.stringify(output));
@@ -892,6 +1066,31 @@ var SetupOrchestrator = class {
892
1066
  }
893
1067
  return results;
894
1068
  }
1069
+ async #runHookLoop(detected, label, write, yes, out) {
1070
+ const configured = [];
1071
+ for (const h of detected) try {
1072
+ const result = write(h.name);
1073
+ if (result.status === "not-supported") continue;
1074
+ if (result.status === "written") {
1075
+ out(` ✓ ${h.name}: ${label} written`);
1076
+ configured.push(h.name);
1077
+ } else {
1078
+ out(` ⚠ ${h.name}: ${label} already configured`);
1079
+ out(` Current: ${result.existing}`);
1080
+ out(` New: ${result.replacement}`);
1081
+ if (yes || await this.#prompter(` Replace ${label} for ${h.name}?`)) {
1082
+ if (write(h.name, true).status === "written") {
1083
+ out(` ✓ ${h.name}: ${label} replaced`);
1084
+ configured.push(h.name);
1085
+ }
1086
+ }
1087
+ }
1088
+ } catch (err) {
1089
+ const msg = err instanceof Error ? err.message : String(err);
1090
+ out(` ✗ ${h.name} ${label}: ${msg}`);
1091
+ }
1092
+ return configured;
1093
+ }
895
1094
  async #runModelDownload(downloader, out) {
896
1095
  out("Downloading embedding model (bge-small-en-v1.5, ~33 MB)...");
897
1096
  downloader.on?.("progress", (p) => {
@@ -1030,6 +1229,9 @@ program.command("inject").description("output session context for harness inject
1030
1229
  process.exit(2);
1031
1230
  }
1032
1231
  });
1232
+ program.command("stop-hook").description("output session-end prompt for harness stop hooks (called by hooks, not users)").option("--harness <name>", "harness name (copilot-cli, codex, opencode)").action((cmdOptions) => {
1233
+ stopHookCommand(cmdOptions);
1234
+ });
1033
1235
  program.command("setup").description("detect installed harnesses and write MCP config for each").option("--yes", "skip all confirmation prompts").option("--dry-run", "print planned changes without writing any file").option("--harness <name>", "target only the named harness (skip detection)").action(async (cmdOptions) => {
1034
1236
  const globalOpts = program.opts();
1035
1237
  const autoYes = cmdOptions.yes === true || globalOpts.yes === true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@membank/cli",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -17,8 +17,8 @@
17
17
  "@huggingface/transformers": "^4.2.0",
18
18
  "commander": "^14.0.3",
19
19
  "ora": "^9.4.0",
20
- "@membank/core": "0.1.1",
21
- "@membank/mcp": "0.1.1"
20
+ "@membank/core": "0.2.0",
21
+ "@membank/mcp": "0.2.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@types/node": "^25.6.0",