stackmem 1.0.21 → 1.0.22

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/src/cli.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  import nodeFs from "node:fs";
3
3
  import nodeOs from "node:os";
4
4
  import nodePath from "node:path";
5
- import { fileURLToPath } from "node:url";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { createInterface } from "node:readline/promises";
7
7
  import { analyzeCodebase } from "./ast-index.js";
8
8
  import { getDeviceId } from "./device.js";
@@ -19,6 +19,7 @@ import {
19
19
  isAuthRelatedFile,
20
20
  isCodeFile,
21
21
  isQualityMessage,
22
+ redactSecrets,
22
23
  scoreMemoryRelevance,
23
24
  } from "./scoring.js";
24
25
  import { supabase } from "./storage.js";
@@ -32,6 +33,14 @@ const CLI_PATH = fileURLToPath(import.meta.url);
32
33
  const DIST_CLI_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist", "cli.js");
33
34
  const DIST_INDEX_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist", "index.js");
34
35
 
36
+ // Written into every file/hook stackmem manages, so `cm uninstall` (and the
37
+ // hook installer's chaining check) can tell "ours, safe to remove/replace"
38
+ // apart from a user's own pre-existing file/hook with the same name.
39
+ const STACKMEM_HOOK_MARKER = "# stackmem-managed";
40
+ const CLAUDE_MD_MARKER = "the stackmem MCP server will automatically load memories for this project";
41
+ const RULES_FILE_MARKER = "# stackmem — project memory (auto-updated)";
42
+ const HOOK_BACKUP_FILENAME = "post-commit.pre-stackmem";
43
+
35
44
  const MEMORY_TYPES = ["decision", "rejection", "constraint", "discovery"] as const;
36
45
  type MemoryType = (typeof MEMORY_TYPES)[number];
37
46
 
@@ -54,10 +63,11 @@ function usage(): never {
54
63
  cm inspect [project] <memory-id>
55
64
  cm compress [project]
56
65
  cm init <project> <path>
66
+ cm uninstall [project] [path]
57
67
  cm sync
58
68
  cm context [--project <name>] <task>
59
69
  cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
60
- cm doctor
70
+ cm doctor [--fix]
61
71
  cm undo [project]
62
72
  cm export [filename]
63
73
  cm import [project] <filename>
@@ -131,6 +141,17 @@ function cmdHelp() {
131
141
  exist. The post-commit hook keeps them fresh afterward by
132
142
  running 'cm sync' on every commit.
133
143
 
144
+ cm uninstall [project] [path]
145
+ Remove everything 'cm init' set up at <path> (defaults to the
146
+ current directory): the git post-commit hook (restoring whatever
147
+ hook it chained after, if any), .coding-memory/post-commit-hook.mjs,
148
+ .coding-memory/ast-index.db and the .coding-memory/ directory
149
+ itself if left empty, the .stackmem file, and CLAUDE.md /
150
+ .cursorrules / .windsurfrules — the last three only if they still
151
+ carry the stackmem marker, so hand-edited files are left alone.
152
+ Also removes the stackmem entry from ~/.claude.json's mcpServers.
153
+ [project] defaults to the .stackmem file at <path> if omitted.
154
+
134
155
  cm sync
135
156
  Regenerate .cursorrules and .windsurfrules from the project's
136
157
  current unresolved memories (decay-ranked, capped at a 2000
@@ -154,10 +175,16 @@ function cmdHelp() {
154
175
  (project, summary, errors_encountered, files_touched,
155
176
  approaches_tried) when stdin is piped rather than a terminal.
156
177
 
157
- cm doctor
178
+ cm doctor [--fix]
158
179
  Check every integration point in the current directory — backend
159
180
  connection, MCP registration, .stackmem, git hook, and memory
160
- count — and report what's working and what's broken.
181
+ count — and report what's working and what's broken. With --fix,
182
+ attempt a repair after each failing check: re-register the MCP
183
+ server, or re-install the git hook. A failed backend connection,
184
+ a missing .stackmem file, or no memories yet can't be auto-fixed
185
+ (they need a working network, 'cm init', or a commit respectively)
186
+ — those print guidance instead. Reports "Fixed: <item>" or
187
+ "Could not auto-fix: <item>" for each failing check.
161
188
 
162
189
  cm undo [project]
163
190
  Delete the most recently saved unresolved memory (and any
@@ -420,6 +447,11 @@ async function cmdSave(project: string, type: string, content: string, force = f
420
447
  );
421
448
  }
422
449
 
450
+ // Redacted before any further use — comparisons, prompts, and storage all
451
+ // see the scrubbed text, so a secret never round-trips through a console
452
+ // prompt (confirmSupersede) or ends up persisted.
453
+ content = redactSecrets(content);
454
+
423
455
  const { data: existingRows, error: existingError } = await supabase
424
456
  .from("memories")
425
457
  .select("id, content")
@@ -553,6 +585,9 @@ async function cmdFix(project: string, problem: string, solution: string) {
553
585
  fail("Problem and solution cannot be empty.");
554
586
  }
555
587
 
588
+ problem = redactSecrets(problem);
589
+ solution = redactSecrets(solution);
590
+
556
591
  const { error } = await supabase
557
592
  .from("execution_log")
558
593
  .insert({ project, problem, solution, resolved: false, device_id: getDeviceId() });
@@ -780,8 +815,13 @@ async function cmdInspect(project: string, memoryId: string) {
780
815
  function buildHookLogicScript(cliPath: string): string {
781
816
  return `#!/usr/bin/env node
782
817
  import { execFileSync } from "node:child_process";
783
- import { readFileSync } from "node:fs";
784
- import { join } from "node:path";
818
+ import { readFileSync, appendFileSync, mkdirSync } from "node:fs";
819
+ import { join, dirname } from "node:path";
820
+
821
+ // The hook must never block or break a commit: a slow or throwing hook body
822
+ // is worse than a skipped one. runHook() does the real work; main() races
823
+ // it against a 2s timeout and logs (rather than surfaces) any failure.
824
+ const HOOK_TIMEOUT_MS = 2000;
785
825
 
786
826
  const stackmemPath = join(process.cwd(), ".stackmem");
787
827
  let PROJECT;
@@ -793,6 +833,58 @@ try {
793
833
 
794
834
  const CLI_PATH = ${JSON.stringify(cliPath)};
795
835
 
836
+ const SECRET_PATTERNS = [
837
+ /AKIA[0-9A-Z]{16}/g,
838
+ /(api[_-]?key|apikey)\\s*[:=]\\s*["']?[\\w-]{20,}["']?/gi,
839
+ /bearer\\s+[a-zA-Z0-9\\-._~+/]+=*/gi,
840
+ /-----BEGIN [A-Z ]+PRIVATE KEY-----/g,
841
+ /^[A-Z_]+=.{8,}/gm,
842
+ ];
843
+
844
+ const HIGH_ENTROPY_RE = /[a-zA-Z0-9+/]{40,}={0,2}/g;
845
+ const HIGH_ENTROPY_MIN_BITS_PER_CHAR = 4.5;
846
+
847
+ function shannonEntropy(text) {
848
+ if (text.length === 0) return 0;
849
+ const counts = new Map();
850
+ for (const char of text) {
851
+ counts.set(char, (counts.get(char) ?? 0) + 1);
852
+ }
853
+ let entropy = 0;
854
+ for (const count of counts.values()) {
855
+ const p = count / text.length;
856
+ entropy -= p * Math.log2(p);
857
+ }
858
+ return entropy;
859
+ }
860
+
861
+ // Inlined copy of src/scoring.ts's redactSecrets — this script runs
862
+ // standalone via git's post-commit, with no import access to that module.
863
+ function redactSecrets(content) {
864
+ let result = content;
865
+ for (const pattern of SECRET_PATTERNS) {
866
+ result = result.replace(pattern, "[REDACTED]");
867
+ }
868
+ result = result.replace(HIGH_ENTROPY_RE, (match) =>
869
+ shannonEntropy(match) > HIGH_ENTROPY_MIN_BITS_PER_CHAR ? "[REDACTED]" : match
870
+ );
871
+ return result;
872
+ }
873
+
874
+ function logHookError(error) {
875
+ try {
876
+ const logPath = join(process.cwd(), ".coding-memory", "hook.log");
877
+ mkdirSync(dirname(logPath), { recursive: true });
878
+ const message = error instanceof Error ? (error.stack || error.message) : String(error);
879
+ appendFileSync(logPath, \`[\${new Date().toISOString()}] \${message}\\n\`);
880
+ } catch {
881
+ // Logging itself failed — nothing more we can safely do without
882
+ // risking the commit, so give up silently.
883
+ }
884
+ }
885
+
886
+ async function runHook() {
887
+
796
888
  function git(args) {
797
889
  return execFileSync("git", args, { encoding: "utf-8" }).trim();
798
890
  }
@@ -867,7 +959,7 @@ const commitMessage = git(["log", "-1", "--pretty=%B"]);
867
959
  // save its own commit message as a new decision memory, which changes the
868
960
  // rules-file content and triggers another sync commit, indefinitely.
869
961
  if (commitMessage.includes("[skip ci]")) {
870
- process.exit(0);
962
+ return;
871
963
  }
872
964
 
873
965
  const isFirstCommit = (() => {
@@ -917,17 +1009,17 @@ for (const line of isFirstCommit ? [] : lines) {
917
1009
 
918
1010
  if (isNewFile) {
919
1011
  if (/(auth|login|session|token)/i.test(currentFile)) {
920
- const content = \`new auth-related file: \${currentFile}\`;
1012
+ const content = redactSecrets(\`new auth-related file: \${currentFile}\`);
921
1013
  cmSave("discovery", content);
922
1014
  savedItems.push(\`discovery: \${content}\`);
923
1015
  }
924
1016
  if (isConfigFile(currentFile)) {
925
- const content = \`new config: \${currentFile}\`;
1017
+ const content = redactSecrets(\`new config: \${currentFile}\`);
926
1018
  cmSave("constraint", content);
927
1019
  savedItems.push(\`constraint: \${content}\`);
928
1020
  }
929
1021
  if (isSchemaFile(currentFile)) {
930
- const content = \`schema change: \${currentFile}\`;
1022
+ const content = redactSecrets(\`schema change: \${currentFile}\`);
931
1023
  cmSave("discovery", content);
932
1024
  savedItems.push(\`discovery: \${content}\`);
933
1025
  }
@@ -964,7 +1056,7 @@ for (const line of isFirstCommit ? [] : lines) {
964
1056
  : null;
965
1057
 
966
1058
  if (removedModulePath) {
967
- const content = \`removed dependency: \${removedModulePath}\`;
1059
+ const content = redactSecrets(\`removed dependency: \${removedModulePath}\`);
968
1060
  cmSave("rejection", content);
969
1061
  savedItems.push(\`rejection: \${content}\`);
970
1062
  }
@@ -972,7 +1064,7 @@ for (const line of isFirstCommit ? [] : lines) {
972
1064
  if (isPackageJson && inDependenciesSection) {
973
1065
  const pkgMatch = codeLine.match(/"([a-z][\\w-]+)"\\s*:/);
974
1066
  if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
975
- const content = \`removed dependency: \${pkgMatch[1]}\`;
1067
+ const content = redactSecrets(\`removed dependency: \${pkgMatch[1]}\`);
976
1068
  cmSave("rejection", content);
977
1069
  savedItems.push(\`rejection: \${content}\`);
978
1070
  }
@@ -988,7 +1080,7 @@ for (const line of isFirstCommit ? [] : lines) {
988
1080
  if (base === ".env" || base === ".env.example") {
989
1081
  const envMatch = codeLine.match(/([A-Z_]{3,})=/);
990
1082
  if (envMatch) {
991
- const content = \`new env var required: \${envMatch[1]}\`;
1083
+ const content = redactSecrets(\`new env var required: \${envMatch[1]}\`);
992
1084
  cmSave("constraint", content);
993
1085
  savedItems.push(\`constraint: \${content}\`);
994
1086
  }
@@ -998,7 +1090,7 @@ for (const line of isFirstCommit ? [] : lines) {
998
1090
  if (isPackageJson && inDependenciesSection) {
999
1091
  const pkgMatch = codeLine.match(/"([a-z][\\w-]+)"\\s*:/);
1000
1092
  if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
1001
- const content = \`added dependency: \${pkgMatch[1]}\`;
1093
+ const content = redactSecrets(\`added dependency: \${pkgMatch[1]}\`);
1002
1094
  cmSave("decision", content);
1003
1095
  savedItems.push(\`decision: \${content}\`);
1004
1096
  }
@@ -1006,24 +1098,55 @@ for (const line of isFirstCommit ? [] : lines) {
1006
1098
 
1007
1099
  if (currentFile && isSchemaFile(currentFile) && /CREATE TABLE|ALTER TABLE|DROP TABLE/i.test(codeLine)) {
1008
1100
  const truncated = codeLine.trim().slice(0, 80);
1009
- const content = \`database change: \${truncated}\`;
1101
+ const content = redactSecrets(\`database change: \${truncated}\`);
1010
1102
  cmSave("discovery", content);
1011
1103
  savedItems.push(\`discovery: \${content}\`);
1012
1104
  }
1013
1105
 
1014
1106
  if (/^\\+\\s*(\\/\\/|#)\\s*(TODO:|FIXME:|HACK:|XXX:)/.test(line)) {
1015
1107
  const truncated = codeLine.trim().slice(0, 100);
1016
- const content = \`open issue from commit: \${truncated}\`;
1108
+ const content = redactSecrets(\`open issue from commit: \${truncated}\`);
1017
1109
  cmSave("discovery", content);
1018
1110
  savedItems.push(\`discovery: \${content}\`);
1019
1111
  }
1020
1112
  }
1021
1113
  }
1022
1114
 
1023
- // Saved last, after every diff-detected memory, and only if it clears the
1024
- // quality filter — diff analysis always runs regardless of commit message.
1025
- if (isQualityCommitMessage(commitMessage)) {
1026
- const trimmedMessage = commitMessage.trim();
1115
+ // Conventional-commit routing (handled last, after every diff-detected
1116
+ // memory — diff analysis always runs regardless of commit message):
1117
+ // feat/fix/perf/refactor save as a decision outright (the "type:" prefix is
1118
+ // itself the quality signal, so the generic filter below is bypassed);
1119
+ // docs/style/test/ci/build/chore are skipped entirely; a BREAKING CHANGE
1120
+ // footer always saves as a constraint, independent of (and in addition to)
1121
+ // the type-based routing above; anything that isn't a recognized
1122
+ // conventional-commit type at all falls back to the pre-existing
1123
+ // isQualityCommitMessage filter.
1124
+ const CONVENTIONAL_COMMIT_RE = /^([a-zA-Z]+)(\\([^)]*\\))?!?:\\s*(.+)/;
1125
+ const CONVENTIONAL_DECISION_TYPES = new Set(["feat", "fix", "perf", "refactor"]);
1126
+ const CONVENTIONAL_SKIP_TYPES = new Set(["docs", "style", "test", "ci", "build", "chore"]);
1127
+ const BREAKING_CHANGE_RE = /BREAKING CHANGE:\\s*([\\s\\S]+?)(?:\\n\\n|$)/;
1128
+
1129
+ const trimmedCommitMessage = commitMessage.trim();
1130
+ const commitFirstLine = trimmedCommitMessage.split("\\n")[0];
1131
+ const conventionalMatch = commitFirstLine.match(CONVENTIONAL_COMMIT_RE);
1132
+ const conventionalType = conventionalMatch ? conventionalMatch[1].toLowerCase() : null;
1133
+
1134
+ const breakingMatch = trimmedCommitMessage.match(BREAKING_CHANGE_RE);
1135
+ if (breakingMatch) {
1136
+ const breakingContent = redactSecrets(breakingMatch[1].trim());
1137
+ const content = \`BREAKING CHANGE: \${breakingContent}\`;
1138
+ cmSave("constraint", content);
1139
+ savedItems.push(\`constraint: \${content}\`);
1140
+ }
1141
+
1142
+ if (conventionalType && CONVENTIONAL_DECISION_TYPES.has(conventionalType)) {
1143
+ const trimmedMessage = redactSecrets(trimmedCommitMessage);
1144
+ cmSave("decision", trimmedMessage);
1145
+ savedItems.push(\`decision: \${trimmedMessage}\`);
1146
+ } else if (conventionalType && CONVENTIONAL_SKIP_TYPES.has(conventionalType)) {
1147
+ // docs/style/test/ci/build/chore — skip entirely, nothing saved.
1148
+ } else if (isQualityCommitMessage(commitMessage)) {
1149
+ const trimmedMessage = redactSecrets(trimmedCommitMessage);
1027
1150
  cmSave("decision", trimmedMessage);
1028
1151
  savedItems.push(\`decision: \${trimmedMessage}\`);
1029
1152
  }
@@ -1048,12 +1171,100 @@ try {
1048
1171
  } catch {
1049
1172
  // Nothing to commit (rules files unchanged) — fine, ignore.
1050
1173
  }
1174
+ }
1175
+
1176
+ async function main() {
1177
+ let timeoutHandle;
1178
+ try {
1179
+ await Promise.race([
1180
+ runHook(),
1181
+ new Promise((_resolve, reject) => {
1182
+ timeoutHandle = setTimeout(
1183
+ () => reject(new Error("stackmem post-commit hook exceeded " + HOOK_TIMEOUT_MS + "ms timeout")),
1184
+ HOOK_TIMEOUT_MS
1185
+ );
1186
+ }),
1187
+ ]);
1188
+ } catch (error) {
1189
+ logHookError(error);
1190
+ } finally {
1191
+ clearTimeout(timeoutHandle);
1192
+ }
1193
+ }
1194
+
1195
+ main().finally(() => process.exit(0));
1051
1196
  `;
1052
1197
  }
1053
1198
 
1054
- function registerMcpServer(distIndexPath: string): void {
1055
- const claudeConfigPath = nodePath.join(nodeOs.homedir(), ".claude.json");
1199
+ // Installs (or re-installs) the stackmem post-commit hook at `hooksDir`. If
1200
+ // a post-commit hook already exists and wasn't written by stackmem, it's
1201
+ // preserved as a backup and chained to run before stackmem's own hook,
1202
+ // rather than being clobbered — see `uninstallHook` for how it's restored.
1203
+ function installHook(hooksDir: string, hookLogicPath: string): void {
1204
+ nodeFs.mkdirSync(hooksDir, { recursive: true });
1205
+
1206
+ const hookPath = nodePath.join(hooksDir, "post-commit");
1207
+ const backupPath = nodePath.join(hooksDir, HOOK_BACKUP_FILENAME);
1056
1208
 
1209
+ let chainedOriginal = false;
1210
+ if (nodeFs.existsSync(hookPath)) {
1211
+ const existingContent = nodeFs.readFileSync(hookPath, "utf-8");
1212
+ if (!existingContent.includes(STACKMEM_HOOK_MARKER)) {
1213
+ nodeFs.copyFileSync(hookPath, backupPath);
1214
+ try {
1215
+ nodeFs.chmodSync(backupPath, 0o755);
1216
+ } catch {
1217
+ // Best-effort — if the original wasn't executable either, leave it.
1218
+ }
1219
+ chainedOriginal = true;
1220
+ }
1221
+ }
1222
+
1223
+ const chainLine = chainedOriginal
1224
+ ? `"$(dirname "$0")/${HOOK_BACKUP_FILENAME}" "$@"\n`
1225
+ : "";
1226
+
1227
+ nodeFs.writeFileSync(
1228
+ hookPath,
1229
+ `#!/bin/sh\n${STACKMEM_HOOK_MARKER}\n${chainLine}exec node "${hookLogicPath}" "$@"\n`,
1230
+ "utf-8"
1231
+ );
1232
+ nodeFs.chmodSync(hookPath, 0o755);
1233
+
1234
+ if (chainedOriginal) {
1235
+ console.log("Existing post-commit hook found — chained it to run before stackmem's hook.");
1236
+ }
1237
+ }
1238
+
1239
+ // Reverses `installHook`: if stackmem wrote the hook fresh, removes it; if
1240
+ // stackmem chained it after an existing hook, restores that original hook
1241
+ // in its place. Returns a description of what happened, or null if there
1242
+ // was no stackmem-managed hook to remove (nothing done).
1243
+ function uninstallHook(hooksDir: string): string | null {
1244
+ const hookPath = nodePath.join(hooksDir, "post-commit");
1245
+ const backupPath = nodePath.join(hooksDir, HOOK_BACKUP_FILENAME);
1246
+
1247
+ if (!nodeFs.existsSync(hookPath)) return null;
1248
+
1249
+ const content = nodeFs.readFileSync(hookPath, "utf-8");
1250
+ if (!content.includes(STACKMEM_HOOK_MARKER)) return null;
1251
+
1252
+ if (nodeFs.existsSync(backupPath)) {
1253
+ nodeFs.renameSync(backupPath, hookPath);
1254
+ return ".git/hooks/post-commit (restored original hook stackmem had chained)";
1255
+ }
1256
+
1257
+ nodeFs.unlinkSync(hookPath);
1258
+ return ".git/hooks/post-commit";
1259
+ }
1260
+
1261
+ // Overridable so tests (and `cm uninstall`'s own smoke-testing) can point
1262
+ // at a scratch file instead of the real ~/.claude.json.
1263
+ function getClaudeConfigPath(): string {
1264
+ return nodePath.join(nodeOs.homedir(), ".claude.json");
1265
+ }
1266
+
1267
+ function registerMcpServer(distIndexPath: string, claudeConfigPath = getClaudeConfigPath()): void {
1057
1268
  let config: { mcpServers?: Record<string, unknown> } = {};
1058
1269
  if (nodeFs.existsSync(claudeConfigPath)) {
1059
1270
  try {
@@ -1080,6 +1291,24 @@ function registerMcpServer(distIndexPath: string): void {
1080
1291
  console.log("stackmem MCP server registered with Claude Code.");
1081
1292
  }
1082
1293
 
1294
+ // Returns true if an entry was actually removed.
1295
+ function unregisterMcpServer(claudeConfigPath = getClaudeConfigPath()): boolean {
1296
+ if (!nodeFs.existsSync(claudeConfigPath)) return false;
1297
+
1298
+ let config: { mcpServers?: Record<string, unknown> } = {};
1299
+ try {
1300
+ config = JSON.parse(nodeFs.readFileSync(claudeConfigPath, "utf-8"));
1301
+ } catch {
1302
+ return false;
1303
+ }
1304
+
1305
+ if (!config.mcpServers || !("stackmem" in config.mcpServers)) return false;
1306
+
1307
+ delete config.mcpServers.stackmem;
1308
+ nodeFs.writeFileSync(claudeConfigPath, JSON.stringify(config, null, 2), "utf-8");
1309
+ return true;
1310
+ }
1311
+
1083
1312
  const DEFAULT_GITIGNORE_CONTENT = `node_modules/
1084
1313
  dist/
1085
1314
  .env
@@ -1242,19 +1471,28 @@ function writeProjectFile(project: string, resolvedPath: string): void {
1242
1471
  nodeFs.writeFileSync(stackmemPath, `${project}\n`, "utf-8");
1243
1472
  }
1244
1473
 
1474
+ // Minimal shape trySeedMemory needs from a Supabase client — narrow enough
1475
+ // that tests can pass a mock without satisfying the full SupabaseClient type.
1476
+ interface MemoryInsertClient {
1477
+ from(table: string): { insert(row: unknown): PromiseLike<{ error: unknown }> };
1478
+ }
1479
+
1245
1480
  // Shared by every seedMemories check below: skip the insert if this exact
1246
1481
  // content already exists (decideSaveAction's duplicate check) so re-running
1247
- // `cm init` on an already-seeded project doesn't pile up repeats.
1248
- async function trySeedMemory(
1482
+ // `cm init` on an already-seeded project doesn't pile up repeats. Exported,
1483
+ // with an injectable client, so this duplicate-prevention guarantee is
1484
+ // unit-testable without a live Supabase connection.
1485
+ export async function trySeedMemory(
1249
1486
  project: string,
1250
1487
  existingMemories: { id: string; content: string }[],
1251
1488
  type: MemoryType,
1252
- content: string
1489
+ content: string,
1490
+ client: MemoryInsertClient = supabase
1253
1491
  ): Promise<boolean> {
1254
1492
  const decision = decideSaveAction(content, existingMemories);
1255
1493
  if (decision.action === "duplicate") return false;
1256
1494
 
1257
- const { error } = await supabase.from("memories").insert({
1495
+ const { error } = await client.from("memories").insert({
1258
1496
  project,
1259
1497
  type,
1260
1498
  content,
@@ -1393,9 +1631,7 @@ async function cmdInit(project: string, targetPath: string) {
1393
1631
  const hooksDir = nodePath.join(gitDir, "hooks");
1394
1632
  nodeFs.mkdirSync(hooksDir, { recursive: true });
1395
1633
 
1396
- const hookPath = nodePath.join(hooksDir, "post-commit");
1397
- nodeFs.writeFileSync(hookPath, `#!/bin/sh\nexec node "${hookLogicPath}" "$@"\n`, "utf-8");
1398
- nodeFs.chmodSync(hookPath, 0o755);
1634
+ installHook(hooksDir, hookLogicPath);
1399
1635
 
1400
1636
  console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
1401
1637
 
@@ -1670,7 +1906,29 @@ async function cmdSessionEndEntry(args: string[]) {
1670
1906
 
1671
1907
  // --- cm doctor ---------------------------------------------------------
1672
1908
 
1673
- async function cmdDoctor() {
1909
+ // Re-reads the freshly (re)written config to confirm the MCP entry is
1910
+ // actually there now, rather than trusting registerMcpServer didn't throw.
1911
+ function isMcpRegistered(claudeConfigPath: string): boolean {
1912
+ if (!nodeFs.existsSync(claudeConfigPath)) return false;
1913
+ try {
1914
+ const config = JSON.parse(nodeFs.readFileSync(claudeConfigPath, "utf-8"));
1915
+ return Boolean(config?.mcpServers?.stackmem);
1916
+ } catch {
1917
+ return false;
1918
+ }
1919
+ }
1920
+
1921
+ function isHookExecutable(hookPath: string): boolean {
1922
+ if (!nodeFs.existsSync(hookPath)) return false;
1923
+ try {
1924
+ nodeFs.accessSync(hookPath, nodeFs.constants.X_OK);
1925
+ return true;
1926
+ } catch {
1927
+ return false;
1928
+ }
1929
+ }
1930
+
1931
+ async function cmdDoctor(fix: boolean) {
1674
1932
  let issues = 0;
1675
1933
  const project = readProjectFromFile();
1676
1934
 
@@ -1681,24 +1939,30 @@ async function cmdDoctor() {
1681
1939
  } else {
1682
1940
  console.log("✗ Backend connection failed — check your network");
1683
1941
  issues++;
1942
+ if (fix) {
1943
+ console.log(
1944
+ "Could not auto-fix: backend connection — check your network and Supabase status, then re-run 'cm doctor'."
1945
+ );
1946
+ }
1684
1947
  }
1685
1948
 
1686
1949
  // 2. MCP server registration
1687
- const claudeConfigPath = nodePath.join(nodeOs.homedir(), ".claude.json");
1688
- let mcpRegistered = false;
1689
- if (nodeFs.existsSync(claudeConfigPath)) {
1690
- try {
1691
- const config = JSON.parse(nodeFs.readFileSync(claudeConfigPath, "utf-8"));
1692
- mcpRegistered = Boolean(config?.mcpServers?.stackmem);
1693
- } catch {
1694
- mcpRegistered = false;
1695
- }
1696
- }
1950
+ const claudeConfigPath = getClaudeConfigPath();
1951
+ const mcpRegistered = isMcpRegistered(claudeConfigPath);
1697
1952
  if (mcpRegistered) {
1698
1953
  console.log("✓ MCP server registered with Claude Code");
1699
1954
  } else {
1700
1955
  console.log("✗ MCP server not registered — run 'cm init <project> .'");
1701
1956
  issues++;
1957
+ if (fix) {
1958
+ registerMcpServer(DIST_INDEX_PATH, claudeConfigPath);
1959
+ if (isMcpRegistered(claudeConfigPath)) {
1960
+ console.log("Fixed: MCP server registration");
1961
+ issues--;
1962
+ } else {
1963
+ console.log("Could not auto-fix: MCP server registration");
1964
+ }
1965
+ }
1702
1966
  }
1703
1967
 
1704
1968
  // 3. Project file
@@ -1707,6 +1971,9 @@ async function cmdDoctor() {
1707
1971
  } else {
1708
1972
  console.log("✗ No .stackmem found — run 'cm init <project> .'");
1709
1973
  issues++;
1974
+ if (fix) {
1975
+ console.log("Could not auto-fix: .stackmem file — run 'cm init <project> .'");
1976
+ }
1710
1977
  }
1711
1978
 
1712
1979
  // 4. Git hook
@@ -1715,25 +1982,33 @@ async function cmdDoctor() {
1715
1982
  console.log("✗ No git repo found in current directory");
1716
1983
  issues++;
1717
1984
  } else {
1718
- const hookPath = nodePath.join(gitDir, "hooks", "post-commit");
1719
- let hookExecutable = false;
1720
- if (nodeFs.existsSync(hookPath)) {
1721
- try {
1722
- nodeFs.accessSync(hookPath, nodeFs.constants.X_OK);
1723
- hookExecutable = true;
1724
- } catch {
1725
- hookExecutable = false;
1726
- }
1727
- }
1728
- if (hookExecutable) {
1985
+ const hooksDir = nodePath.join(gitDir, "hooks");
1986
+ const hookPath = nodePath.join(hooksDir, "post-commit");
1987
+
1988
+ if (isHookExecutable(hookPath)) {
1729
1989
  console.log("✓ Git hook installed");
1730
1990
  } else {
1731
1991
  console.log("✗ Git hook not installed — run 'cm init <project> .'");
1732
1992
  issues++;
1993
+ if (fix) {
1994
+ const codingMemoryDir = nodePath.join(process.cwd(), ".coding-memory");
1995
+ nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
1996
+ const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
1997
+ nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
1998
+ installHook(hooksDir, hookLogicPath);
1999
+
2000
+ if (isHookExecutable(hookPath)) {
2001
+ console.log("Fixed: git hook");
2002
+ issues--;
2003
+ } else {
2004
+ console.log("Could not auto-fix: git hook");
2005
+ }
2006
+ }
1733
2007
  }
1734
2008
  }
1735
2009
 
1736
2010
  // 5. Memory count
2011
+ let hasMemories = false;
1737
2012
  if (project) {
1738
2013
  const { count, error } = await supabase
1739
2014
  .from("memories")
@@ -1741,15 +2016,17 @@ async function cmdDoctor() {
1741
2016
  .eq("project", project)
1742
2017
  .eq("resolved", false);
1743
2018
 
1744
- if (!error && (count ?? 0) > 0) {
2019
+ hasMemories = !error && (count ?? 0) > 0;
2020
+ if (hasMemories) {
1745
2021
  console.log(`✓ ${count} memories stored for "${project}"`);
1746
- } else {
1747
- console.log(`✗ No memories yet — make a commit to start capturing`);
1748
- issues++;
1749
2022
  }
1750
- } else {
2023
+ }
2024
+ if (!hasMemories) {
1751
2025
  console.log(`✗ No memories yet — make a commit to start capturing`);
1752
2026
  issues++;
2027
+ if (fix) {
2028
+ console.log("Could not auto-fix: memories — make a commit to start capturing");
2029
+ }
1753
2030
  }
1754
2031
 
1755
2032
  console.log("");
@@ -1885,6 +2162,75 @@ async function cmdImport(project: string, filename: string) {
1885
2162
  console.log(`Imported ${importedCount} memories from ${filename}`);
1886
2163
  }
1887
2164
 
2165
+ // --- cm uninstall ------------------------------------------------------
2166
+
2167
+ function readProjectFromPath(dirPath: string): string | null {
2168
+ const stackmemPath = nodePath.join(dirPath, STACKMEM_FILE);
2169
+ if (!nodeFs.existsSync(stackmemPath)) return null;
2170
+ const content = nodeFs.readFileSync(stackmemPath, "utf-8").trim();
2171
+ return content.length > 0 ? content : null;
2172
+ }
2173
+
2174
+ // Removes a marker-gated file (CLAUDE.md / .cursorrules / .windsurfrules)
2175
+ // only if it still carries the stackmem marker — a file the user has since
2176
+ // rewritten by hand, or never had stackmem's content to begin with, is left
2177
+ // alone rather than silently deleted.
2178
+ function removeIfMarked(filePath: string, marker: string, label: string): void {
2179
+ if (!nodeFs.existsSync(filePath)) return;
2180
+
2181
+ const content = nodeFs.readFileSync(filePath, "utf-8");
2182
+ if (!content.includes(marker)) {
2183
+ console.log(`Skipped ${label} (no stackmem marker found — left as-is).`);
2184
+ return;
2185
+ }
2186
+
2187
+ nodeFs.unlinkSync(filePath);
2188
+ console.log(`Removed ${label}`);
2189
+ }
2190
+
2191
+ async function cmdUninstall(project: string, targetPath: string) {
2192
+ const gitDir = nodePath.join(targetPath, ".git");
2193
+ const hooksDir = nodePath.join(gitDir, "hooks");
2194
+
2195
+ const hookResult = uninstallHook(hooksDir);
2196
+ if (hookResult) console.log(`Removed ${hookResult}`);
2197
+
2198
+ const codingMemoryDir = nodePath.join(targetPath, ".coding-memory");
2199
+
2200
+ const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
2201
+ if (nodeFs.existsSync(hookLogicPath)) {
2202
+ nodeFs.unlinkSync(hookLogicPath);
2203
+ console.log("Removed .coding-memory/post-commit-hook.mjs");
2204
+ }
2205
+
2206
+ const astDbPath = nodePath.join(codingMemoryDir, "ast-index.db");
2207
+ if (nodeFs.existsSync(astDbPath)) {
2208
+ nodeFs.unlinkSync(astDbPath);
2209
+ console.log("Removed .coding-memory/ast-index.db");
2210
+ }
2211
+
2212
+ if (nodeFs.existsSync(codingMemoryDir) && nodeFs.readdirSync(codingMemoryDir).length === 0) {
2213
+ nodeFs.rmdirSync(codingMemoryDir);
2214
+ console.log("Removed .coding-memory/ directory");
2215
+ }
2216
+
2217
+ const stackmemPath = nodePath.join(targetPath, STACKMEM_FILE);
2218
+ if (nodeFs.existsSync(stackmemPath)) {
2219
+ nodeFs.unlinkSync(stackmemPath);
2220
+ console.log("Removed .stackmem");
2221
+ }
2222
+
2223
+ removeIfMarked(nodePath.join(targetPath, "CLAUDE.md"), CLAUDE_MD_MARKER, "CLAUDE.md");
2224
+ removeIfMarked(nodePath.join(targetPath, ".cursorrules"), RULES_FILE_MARKER, ".cursorrules");
2225
+ removeIfMarked(nodePath.join(targetPath, ".windsurfrules"), RULES_FILE_MARKER, ".windsurfrules");
2226
+
2227
+ if (unregisterMcpServer()) {
2228
+ console.log("Removed stackmem entry from ~/.claude.json mcpServers");
2229
+ }
2230
+
2231
+ console.log(`stackmem uninstalled from ${project}.`);
2232
+ }
2233
+
1888
2234
  // --- main --------------------------------------------------------------
1889
2235
 
1890
2236
  async function main() {
@@ -2010,7 +2356,8 @@ async function main() {
2010
2356
  break;
2011
2357
  }
2012
2358
  case "doctor": {
2013
- await cmdDoctor();
2359
+ const fix = args.includes("--fix");
2360
+ await cmdDoctor(fix);
2014
2361
  break;
2015
2362
  }
2016
2363
  case "undo": {
@@ -2035,6 +2382,31 @@ async function main() {
2035
2382
  await cmdImport(project, filename);
2036
2383
  break;
2037
2384
  }
2385
+ case "uninstall": {
2386
+ if (args.length > 2) usage();
2387
+
2388
+ let explicitProject: string | null = null;
2389
+ let targetPath = process.cwd();
2390
+ if (args.length === 1) {
2391
+ if (looksLikeAPath(args[0])) {
2392
+ targetPath = args[0];
2393
+ } else {
2394
+ explicitProject = args[0];
2395
+ }
2396
+ } else if (args.length === 2) {
2397
+ [explicitProject, targetPath] = args;
2398
+ }
2399
+
2400
+ const resolvedPath = nodePath.resolve(targetPath);
2401
+ const project = explicitProject ?? readProjectFromPath(resolvedPath);
2402
+ if (!project) {
2403
+ console.error(`No project specified and no .stackmem file found at ${resolvedPath}.`);
2404
+ process.exit(1);
2405
+ }
2406
+
2407
+ await cmdUninstall(project, resolvedPath);
2408
+ break;
2409
+ }
2038
2410
  case "help": {
2039
2411
  cmdHelp();
2040
2412
  break;
@@ -2044,6 +2416,12 @@ async function main() {
2044
2416
  }
2045
2417
  }
2046
2418
 
2047
- main().catch((error) => {
2048
- fail(error instanceof Error ? error.message : String(error));
2049
- });
2419
+ // Guards the auto-run so importing this module (e.g. from a test file, to
2420
+ // reach exported helpers like trySeedMemory) doesn't also execute main()
2421
+ // against the test runner's own argv.
2422
+ const isMainModule = process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href;
2423
+ if (isMainModule) {
2424
+ main().catch((error) => {
2425
+ fail(error instanceof Error ? error.message : String(error));
2426
+ });
2427
+ }