stackmem 1.0.20 → 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/dist/cli.js CHANGED
@@ -2,11 +2,11 @@
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";
9
- import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, extractEntities, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, isQualityMessage, scoreMemoryRelevance, } from "./scoring.js";
9
+ import { applyContextBudget, buildSummaryContent, clusterMemories, computeDecayScore, computeMemoryLinks, decideSaveAction, estimateTokens, extractEntities, indicatesFailedApproach, isAuthRelatedFile, isCodeFile, isQualityMessage, redactSecrets, scoreMemoryRelevance, } from "./scoring.js";
10
10
  import { supabase } from "./storage.js";
11
11
  const CONTEXT_BUDGET_TOKENS = 2000;
12
12
  const COMPRESSION_THRESHOLD = 20;
@@ -16,6 +16,13 @@ const CLI_PATH = fileURLToPath(import.meta.url);
16
16
  // hook's stripped-down shell environment.
17
17
  const DIST_CLI_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist", "cli.js");
18
18
  const DIST_INDEX_PATH = nodePath.join(nodePath.dirname(CLI_PATH), "..", "dist", "index.js");
19
+ // Written into every file/hook stackmem manages, so `cm uninstall` (and the
20
+ // hook installer's chaining check) can tell "ours, safe to remove/replace"
21
+ // apart from a user's own pre-existing file/hook with the same name.
22
+ const STACKMEM_HOOK_MARKER = "# stackmem-managed";
23
+ const CLAUDE_MD_MARKER = "the stackmem MCP server will automatically load memories for this project";
24
+ const RULES_FILE_MARKER = "# stackmem — project memory (auto-updated)";
25
+ const HOOK_BACKUP_FILENAME = "post-commit.pre-stackmem";
19
26
  const MEMORY_TYPES = ["decision", "rejection", "constraint", "discovery"];
20
27
  const TYPE_LABELS = {
21
28
  decision: "Decisions",
@@ -35,10 +42,11 @@ function usage() {
35
42
  cm inspect [project] <memory-id>
36
43
  cm compress [project]
37
44
  cm init <project> <path>
45
+ cm uninstall [project] [path]
38
46
  cm sync
39
47
  cm context [--project <name>] <task>
40
48
  cm session-end [project] [--summary <text>] [--errors <a,b>] [--files <a,b>] [--approaches <a,b>]
41
- cm doctor
49
+ cm doctor [--fix]
42
50
  cm undo [project]
43
51
  cm export [filename]
44
52
  cm import [project] <filename>
@@ -111,6 +119,17 @@ function cmdHelp() {
111
119
  exist. The post-commit hook keeps them fresh afterward by
112
120
  running 'cm sync' on every commit.
113
121
 
122
+ cm uninstall [project] [path]
123
+ Remove everything 'cm init' set up at <path> (defaults to the
124
+ current directory): the git post-commit hook (restoring whatever
125
+ hook it chained after, if any), .coding-memory/post-commit-hook.mjs,
126
+ .coding-memory/ast-index.db and the .coding-memory/ directory
127
+ itself if left empty, the .stackmem file, and CLAUDE.md /
128
+ .cursorrules / .windsurfrules — the last three only if they still
129
+ carry the stackmem marker, so hand-edited files are left alone.
130
+ Also removes the stackmem entry from ~/.claude.json's mcpServers.
131
+ [project] defaults to the .stackmem file at <path> if omitted.
132
+
114
133
  cm sync
115
134
  Regenerate .cursorrules and .windsurfrules from the project's
116
135
  current unresolved memories (decay-ranked, capped at a 2000
@@ -134,10 +153,16 @@ function cmdHelp() {
134
153
  (project, summary, errors_encountered, files_touched,
135
154
  approaches_tried) when stdin is piped rather than a terminal.
136
155
 
137
- cm doctor
156
+ cm doctor [--fix]
138
157
  Check every integration point in the current directory — backend
139
158
  connection, MCP registration, .stackmem, git hook, and memory
140
- count — and report what's working and what's broken.
159
+ count — and report what's working and what's broken. With --fix,
160
+ attempt a repair after each failing check: re-register the MCP
161
+ server, or re-install the git hook. A failed backend connection,
162
+ a missing .stackmem file, or no memories yet can't be auto-fixed
163
+ (they need a working network, 'cm init', or a commit respectively)
164
+ — those print guidance instead. Reports "Fixed: <item>" or
165
+ "Could not auto-fix: <item>" for each failing check.
141
166
 
142
167
  cm undo [project]
143
168
  Delete the most recently saved unresolved memory (and any
@@ -355,6 +380,13 @@ async function cmdSave(project, type, content, force = false) {
355
380
  if (!MEMORY_TYPES.includes(type)) {
356
381
  fail(`Invalid memory type '${type}'. Valid types: decision, constraint, discovery, rejection.`);
357
382
  }
383
+ if (content.length > 500) {
384
+ fail(`Memory content too long (${content.length} chars). Keep memories under 500 characters — they should be concise facts, not paragraphs.`);
385
+ }
386
+ // Redacted before any further use — comparisons, prompts, and storage all
387
+ // see the scrubbed text, so a secret never round-trips through a console
388
+ // prompt (confirmSupersede) or ends up persisted.
389
+ content = redactSecrets(content);
358
390
  const { data: existingRows, error: existingError } = await supabase
359
391
  .from("memories")
360
392
  .select("id, content")
@@ -462,6 +494,8 @@ async function cmdFix(project, problem, solution) {
462
494
  if (problem.trim().length === 0 || solution.trim().length === 0) {
463
495
  fail("Problem and solution cannot be empty.");
464
496
  }
497
+ problem = redactSecrets(problem);
498
+ solution = redactSecrets(solution);
465
499
  const { error } = await supabase
466
500
  .from("execution_log")
467
501
  .insert({ project, problem, solution, resolved: false, device_id: getDeviceId() });
@@ -649,8 +683,13 @@ async function cmdInspect(project, memoryId) {
649
683
  function buildHookLogicScript(cliPath) {
650
684
  return `#!/usr/bin/env node
651
685
  import { execFileSync } from "node:child_process";
652
- import { readFileSync } from "node:fs";
653
- import { join } from "node:path";
686
+ import { readFileSync, appendFileSync, mkdirSync } from "node:fs";
687
+ import { join, dirname } from "node:path";
688
+
689
+ // The hook must never block or break a commit: a slow or throwing hook body
690
+ // is worse than a skipped one. runHook() does the real work; main() races
691
+ // it against a 2s timeout and logs (rather than surfaces) any failure.
692
+ const HOOK_TIMEOUT_MS = 2000;
654
693
 
655
694
  const stackmemPath = join(process.cwd(), ".stackmem");
656
695
  let PROJECT;
@@ -662,6 +701,58 @@ try {
662
701
 
663
702
  const CLI_PATH = ${JSON.stringify(cliPath)};
664
703
 
704
+ const SECRET_PATTERNS = [
705
+ /AKIA[0-9A-Z]{16}/g,
706
+ /(api[_-]?key|apikey)\\s*[:=]\\s*["']?[\\w-]{20,}["']?/gi,
707
+ /bearer\\s+[a-zA-Z0-9\\-._~+/]+=*/gi,
708
+ /-----BEGIN [A-Z ]+PRIVATE KEY-----/g,
709
+ /^[A-Z_]+=.{8,}/gm,
710
+ ];
711
+
712
+ const HIGH_ENTROPY_RE = /[a-zA-Z0-9+/]{40,}={0,2}/g;
713
+ const HIGH_ENTROPY_MIN_BITS_PER_CHAR = 4.5;
714
+
715
+ function shannonEntropy(text) {
716
+ if (text.length === 0) return 0;
717
+ const counts = new Map();
718
+ for (const char of text) {
719
+ counts.set(char, (counts.get(char) ?? 0) + 1);
720
+ }
721
+ let entropy = 0;
722
+ for (const count of counts.values()) {
723
+ const p = count / text.length;
724
+ entropy -= p * Math.log2(p);
725
+ }
726
+ return entropy;
727
+ }
728
+
729
+ // Inlined copy of src/scoring.ts's redactSecrets — this script runs
730
+ // standalone via git's post-commit, with no import access to that module.
731
+ function redactSecrets(content) {
732
+ let result = content;
733
+ for (const pattern of SECRET_PATTERNS) {
734
+ result = result.replace(pattern, "[REDACTED]");
735
+ }
736
+ result = result.replace(HIGH_ENTROPY_RE, (match) =>
737
+ shannonEntropy(match) > HIGH_ENTROPY_MIN_BITS_PER_CHAR ? "[REDACTED]" : match
738
+ );
739
+ return result;
740
+ }
741
+
742
+ function logHookError(error) {
743
+ try {
744
+ const logPath = join(process.cwd(), ".coding-memory", "hook.log");
745
+ mkdirSync(dirname(logPath), { recursive: true });
746
+ const message = error instanceof Error ? (error.stack || error.message) : String(error);
747
+ appendFileSync(logPath, \`[\${new Date().toISOString()}] \${message}\\n\`);
748
+ } catch {
749
+ // Logging itself failed — nothing more we can safely do without
750
+ // risking the commit, so give up silently.
751
+ }
752
+ }
753
+
754
+ async function runHook() {
755
+
665
756
  function git(args) {
666
757
  return execFileSync("git", args, { encoding: "utf-8" }).trim();
667
758
  }
@@ -736,7 +827,7 @@ const commitMessage = git(["log", "-1", "--pretty=%B"]);
736
827
  // save its own commit message as a new decision memory, which changes the
737
828
  // rules-file content and triggers another sync commit, indefinitely.
738
829
  if (commitMessage.includes("[skip ci]")) {
739
- process.exit(0);
830
+ return;
740
831
  }
741
832
 
742
833
  const isFirstCommit = (() => {
@@ -786,17 +877,17 @@ for (const line of isFirstCommit ? [] : lines) {
786
877
 
787
878
  if (isNewFile) {
788
879
  if (/(auth|login|session|token)/i.test(currentFile)) {
789
- const content = \`new auth-related file: \${currentFile}\`;
880
+ const content = redactSecrets(\`new auth-related file: \${currentFile}\`);
790
881
  cmSave("discovery", content);
791
882
  savedItems.push(\`discovery: \${content}\`);
792
883
  }
793
884
  if (isConfigFile(currentFile)) {
794
- const content = \`new config: \${currentFile}\`;
885
+ const content = redactSecrets(\`new config: \${currentFile}\`);
795
886
  cmSave("constraint", content);
796
887
  savedItems.push(\`constraint: \${content}\`);
797
888
  }
798
889
  if (isSchemaFile(currentFile)) {
799
- const content = \`schema change: \${currentFile}\`;
890
+ const content = redactSecrets(\`schema change: \${currentFile}\`);
800
891
  cmSave("discovery", content);
801
892
  savedItems.push(\`discovery: \${content}\`);
802
893
  }
@@ -833,7 +924,7 @@ for (const line of isFirstCommit ? [] : lines) {
833
924
  : null;
834
925
 
835
926
  if (removedModulePath) {
836
- const content = \`removed dependency: \${removedModulePath}\`;
927
+ const content = redactSecrets(\`removed dependency: \${removedModulePath}\`);
837
928
  cmSave("rejection", content);
838
929
  savedItems.push(\`rejection: \${content}\`);
839
930
  }
@@ -841,7 +932,7 @@ for (const line of isFirstCommit ? [] : lines) {
841
932
  if (isPackageJson && inDependenciesSection) {
842
933
  const pkgMatch = codeLine.match(/"([a-z][\\w-]+)"\\s*:/);
843
934
  if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
844
- const content = \`removed dependency: \${pkgMatch[1]}\`;
935
+ const content = redactSecrets(\`removed dependency: \${pkgMatch[1]}\`);
845
936
  cmSave("rejection", content);
846
937
  savedItems.push(\`rejection: \${content}\`);
847
938
  }
@@ -857,7 +948,7 @@ for (const line of isFirstCommit ? [] : lines) {
857
948
  if (base === ".env" || base === ".env.example") {
858
949
  const envMatch = codeLine.match(/([A-Z_]{3,})=/);
859
950
  if (envMatch) {
860
- const content = \`new env var required: \${envMatch[1]}\`;
951
+ const content = redactSecrets(\`new env var required: \${envMatch[1]}\`);
861
952
  cmSave("constraint", content);
862
953
  savedItems.push(\`constraint: \${content}\`);
863
954
  }
@@ -867,7 +958,7 @@ for (const line of isFirstCommit ? [] : lines) {
867
958
  if (isPackageJson && inDependenciesSection) {
868
959
  const pkgMatch = codeLine.match(/"([a-z][\\w-]+)"\\s*:/);
869
960
  if (pkgMatch && pkgMatch[1] !== "dependencies" && pkgMatch[1] !== "devDependencies") {
870
- const content = \`added dependency: \${pkgMatch[1]}\`;
961
+ const content = redactSecrets(\`added dependency: \${pkgMatch[1]}\`);
871
962
  cmSave("decision", content);
872
963
  savedItems.push(\`decision: \${content}\`);
873
964
  }
@@ -875,24 +966,55 @@ for (const line of isFirstCommit ? [] : lines) {
875
966
 
876
967
  if (currentFile && isSchemaFile(currentFile) && /CREATE TABLE|ALTER TABLE|DROP TABLE/i.test(codeLine)) {
877
968
  const truncated = codeLine.trim().slice(0, 80);
878
- const content = \`database change: \${truncated}\`;
969
+ const content = redactSecrets(\`database change: \${truncated}\`);
879
970
  cmSave("discovery", content);
880
971
  savedItems.push(\`discovery: \${content}\`);
881
972
  }
882
973
 
883
974
  if (/^\\+\\s*(\\/\\/|#)\\s*(TODO:|FIXME:|HACK:|XXX:)/.test(line)) {
884
975
  const truncated = codeLine.trim().slice(0, 100);
885
- const content = \`open issue from commit: \${truncated}\`;
976
+ const content = redactSecrets(\`open issue from commit: \${truncated}\`);
886
977
  cmSave("discovery", content);
887
978
  savedItems.push(\`discovery: \${content}\`);
888
979
  }
889
980
  }
890
981
  }
891
982
 
892
- // Saved last, after every diff-detected memory, and only if it clears the
893
- // quality filter — diff analysis always runs regardless of commit message.
894
- if (isQualityCommitMessage(commitMessage)) {
895
- const trimmedMessage = commitMessage.trim();
983
+ // Conventional-commit routing (handled last, after every diff-detected
984
+ // memory — diff analysis always runs regardless of commit message):
985
+ // feat/fix/perf/refactor save as a decision outright (the "type:" prefix is
986
+ // itself the quality signal, so the generic filter below is bypassed);
987
+ // docs/style/test/ci/build/chore are skipped entirely; a BREAKING CHANGE
988
+ // footer always saves as a constraint, independent of (and in addition to)
989
+ // the type-based routing above; anything that isn't a recognized
990
+ // conventional-commit type at all falls back to the pre-existing
991
+ // isQualityCommitMessage filter.
992
+ const CONVENTIONAL_COMMIT_RE = /^([a-zA-Z]+)(\\([^)]*\\))?!?:\\s*(.+)/;
993
+ const CONVENTIONAL_DECISION_TYPES = new Set(["feat", "fix", "perf", "refactor"]);
994
+ const CONVENTIONAL_SKIP_TYPES = new Set(["docs", "style", "test", "ci", "build", "chore"]);
995
+ const BREAKING_CHANGE_RE = /BREAKING CHANGE:\\s*([\\s\\S]+?)(?:\\n\\n|$)/;
996
+
997
+ const trimmedCommitMessage = commitMessage.trim();
998
+ const commitFirstLine = trimmedCommitMessage.split("\\n")[0];
999
+ const conventionalMatch = commitFirstLine.match(CONVENTIONAL_COMMIT_RE);
1000
+ const conventionalType = conventionalMatch ? conventionalMatch[1].toLowerCase() : null;
1001
+
1002
+ const breakingMatch = trimmedCommitMessage.match(BREAKING_CHANGE_RE);
1003
+ if (breakingMatch) {
1004
+ const breakingContent = redactSecrets(breakingMatch[1].trim());
1005
+ const content = \`BREAKING CHANGE: \${breakingContent}\`;
1006
+ cmSave("constraint", content);
1007
+ savedItems.push(\`constraint: \${content}\`);
1008
+ }
1009
+
1010
+ if (conventionalType && CONVENTIONAL_DECISION_TYPES.has(conventionalType)) {
1011
+ const trimmedMessage = redactSecrets(trimmedCommitMessage);
1012
+ cmSave("decision", trimmedMessage);
1013
+ savedItems.push(\`decision: \${trimmedMessage}\`);
1014
+ } else if (conventionalType && CONVENTIONAL_SKIP_TYPES.has(conventionalType)) {
1015
+ // docs/style/test/ci/build/chore — skip entirely, nothing saved.
1016
+ } else if (isQualityCommitMessage(commitMessage)) {
1017
+ const trimmedMessage = redactSecrets(trimmedCommitMessage);
896
1018
  cmSave("decision", trimmedMessage);
897
1019
  savedItems.push(\`decision: \${trimmedMessage}\`);
898
1020
  }
@@ -917,10 +1039,86 @@ try {
917
1039
  } catch {
918
1040
  // Nothing to commit (rules files unchanged) — fine, ignore.
919
1041
  }
1042
+ }
1043
+
1044
+ async function main() {
1045
+ let timeoutHandle;
1046
+ try {
1047
+ await Promise.race([
1048
+ runHook(),
1049
+ new Promise((_resolve, reject) => {
1050
+ timeoutHandle = setTimeout(
1051
+ () => reject(new Error("stackmem post-commit hook exceeded " + HOOK_TIMEOUT_MS + "ms timeout")),
1052
+ HOOK_TIMEOUT_MS
1053
+ );
1054
+ }),
1055
+ ]);
1056
+ } catch (error) {
1057
+ logHookError(error);
1058
+ } finally {
1059
+ clearTimeout(timeoutHandle);
1060
+ }
1061
+ }
1062
+
1063
+ main().finally(() => process.exit(0));
920
1064
  `;
921
1065
  }
922
- function registerMcpServer(distIndexPath) {
923
- const claudeConfigPath = nodePath.join(nodeOs.homedir(), ".claude.json");
1066
+ // Installs (or re-installs) the stackmem post-commit hook at `hooksDir`. If
1067
+ // a post-commit hook already exists and wasn't written by stackmem, it's
1068
+ // preserved as a backup and chained to run before stackmem's own hook,
1069
+ // rather than being clobbered — see `uninstallHook` for how it's restored.
1070
+ function installHook(hooksDir, hookLogicPath) {
1071
+ nodeFs.mkdirSync(hooksDir, { recursive: true });
1072
+ const hookPath = nodePath.join(hooksDir, "post-commit");
1073
+ const backupPath = nodePath.join(hooksDir, HOOK_BACKUP_FILENAME);
1074
+ let chainedOriginal = false;
1075
+ if (nodeFs.existsSync(hookPath)) {
1076
+ const existingContent = nodeFs.readFileSync(hookPath, "utf-8");
1077
+ if (!existingContent.includes(STACKMEM_HOOK_MARKER)) {
1078
+ nodeFs.copyFileSync(hookPath, backupPath);
1079
+ try {
1080
+ nodeFs.chmodSync(backupPath, 0o755);
1081
+ }
1082
+ catch {
1083
+ // Best-effort — if the original wasn't executable either, leave it.
1084
+ }
1085
+ chainedOriginal = true;
1086
+ }
1087
+ }
1088
+ const chainLine = chainedOriginal
1089
+ ? `"$(dirname "$0")/${HOOK_BACKUP_FILENAME}" "$@"\n`
1090
+ : "";
1091
+ nodeFs.writeFileSync(hookPath, `#!/bin/sh\n${STACKMEM_HOOK_MARKER}\n${chainLine}exec node "${hookLogicPath}" "$@"\n`, "utf-8");
1092
+ nodeFs.chmodSync(hookPath, 0o755);
1093
+ if (chainedOriginal) {
1094
+ console.log("Existing post-commit hook found — chained it to run before stackmem's hook.");
1095
+ }
1096
+ }
1097
+ // Reverses `installHook`: if stackmem wrote the hook fresh, removes it; if
1098
+ // stackmem chained it after an existing hook, restores that original hook
1099
+ // in its place. Returns a description of what happened, or null if there
1100
+ // was no stackmem-managed hook to remove (nothing done).
1101
+ function uninstallHook(hooksDir) {
1102
+ const hookPath = nodePath.join(hooksDir, "post-commit");
1103
+ const backupPath = nodePath.join(hooksDir, HOOK_BACKUP_FILENAME);
1104
+ if (!nodeFs.existsSync(hookPath))
1105
+ return null;
1106
+ const content = nodeFs.readFileSync(hookPath, "utf-8");
1107
+ if (!content.includes(STACKMEM_HOOK_MARKER))
1108
+ return null;
1109
+ if (nodeFs.existsSync(backupPath)) {
1110
+ nodeFs.renameSync(backupPath, hookPath);
1111
+ return ".git/hooks/post-commit (restored original hook stackmem had chained)";
1112
+ }
1113
+ nodeFs.unlinkSync(hookPath);
1114
+ return ".git/hooks/post-commit";
1115
+ }
1116
+ // Overridable so tests (and `cm uninstall`'s own smoke-testing) can point
1117
+ // at a scratch file instead of the real ~/.claude.json.
1118
+ function getClaudeConfigPath() {
1119
+ return nodePath.join(nodeOs.homedir(), ".claude.json");
1120
+ }
1121
+ function registerMcpServer(distIndexPath, claudeConfigPath = getClaudeConfigPath()) {
924
1122
  let config = {};
925
1123
  if (nodeFs.existsSync(claudeConfigPath)) {
926
1124
  try {
@@ -943,6 +1141,23 @@ function registerMcpServer(distIndexPath) {
943
1141
  nodeFs.writeFileSync(claudeConfigPath, JSON.stringify(config, null, 2), "utf-8");
944
1142
  console.log("stackmem MCP server registered with Claude Code.");
945
1143
  }
1144
+ // Returns true if an entry was actually removed.
1145
+ function unregisterMcpServer(claudeConfigPath = getClaudeConfigPath()) {
1146
+ if (!nodeFs.existsSync(claudeConfigPath))
1147
+ return false;
1148
+ let config = {};
1149
+ try {
1150
+ config = JSON.parse(nodeFs.readFileSync(claudeConfigPath, "utf-8"));
1151
+ }
1152
+ catch {
1153
+ return false;
1154
+ }
1155
+ if (!config.mcpServers || !("stackmem" in config.mcpServers))
1156
+ return false;
1157
+ delete config.mcpServers.stackmem;
1158
+ nodeFs.writeFileSync(claudeConfigPath, JSON.stringify(config, null, 2), "utf-8");
1159
+ return true;
1160
+ }
946
1161
  const DEFAULT_GITIGNORE_CONTENT = `node_modules/
947
1162
  dist/
948
1163
  .env
@@ -1082,12 +1297,14 @@ function writeProjectFile(project, resolvedPath) {
1082
1297
  }
1083
1298
  // Shared by every seedMemories check below: skip the insert if this exact
1084
1299
  // content already exists (decideSaveAction's duplicate check) so re-running
1085
- // `cm init` on an already-seeded project doesn't pile up repeats.
1086
- async function trySeedMemory(project, existingMemories, type, content) {
1300
+ // `cm init` on an already-seeded project doesn't pile up repeats. Exported,
1301
+ // with an injectable client, so this duplicate-prevention guarantee is
1302
+ // unit-testable without a live Supabase connection.
1303
+ export async function trySeedMemory(project, existingMemories, type, content, client = supabase) {
1087
1304
  const decision = decideSaveAction(content, existingMemories);
1088
1305
  if (decision.action === "duplicate")
1089
1306
  return false;
1090
- const { error } = await supabase.from("memories").insert({
1307
+ const { error } = await client.from("memories").insert({
1091
1308
  project,
1092
1309
  type,
1093
1310
  content,
@@ -1214,9 +1431,7 @@ async function cmdInit(project, targetPath) {
1214
1431
  nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
1215
1432
  const hooksDir = nodePath.join(gitDir, "hooks");
1216
1433
  nodeFs.mkdirSync(hooksDir, { recursive: true });
1217
- const hookPath = nodePath.join(hooksDir, "post-commit");
1218
- nodeFs.writeFileSync(hookPath, `#!/bin/sh\nexec node "${hookLogicPath}" "$@"\n`, "utf-8");
1219
- nodeFs.chmodSync(hookPath, 0o755);
1434
+ installHook(hooksDir, hookLogicPath);
1220
1435
  console.log(`coding-memory hook installed for project ${project} at ${targetPath}`);
1221
1436
  registerMcpServer(DIST_INDEX_PATH);
1222
1437
  writeClaudeMd(project, resolvedPath);
@@ -1446,7 +1661,31 @@ async function cmdSessionEndEntry(args) {
1446
1661
  });
1447
1662
  }
1448
1663
  // --- cm doctor ---------------------------------------------------------
1449
- async function cmdDoctor() {
1664
+ // Re-reads the freshly (re)written config to confirm the MCP entry is
1665
+ // actually there now, rather than trusting registerMcpServer didn't throw.
1666
+ function isMcpRegistered(claudeConfigPath) {
1667
+ if (!nodeFs.existsSync(claudeConfigPath))
1668
+ return false;
1669
+ try {
1670
+ const config = JSON.parse(nodeFs.readFileSync(claudeConfigPath, "utf-8"));
1671
+ return Boolean(config?.mcpServers?.stackmem);
1672
+ }
1673
+ catch {
1674
+ return false;
1675
+ }
1676
+ }
1677
+ function isHookExecutable(hookPath) {
1678
+ if (!nodeFs.existsSync(hookPath))
1679
+ return false;
1680
+ try {
1681
+ nodeFs.accessSync(hookPath, nodeFs.constants.X_OK);
1682
+ return true;
1683
+ }
1684
+ catch {
1685
+ return false;
1686
+ }
1687
+ }
1688
+ async function cmdDoctor(fix) {
1450
1689
  let issues = 0;
1451
1690
  const project = readProjectFromFile();
1452
1691
  // 1. Backend connection
@@ -1457,25 +1696,29 @@ async function cmdDoctor() {
1457
1696
  else {
1458
1697
  console.log("✗ Backend connection failed — check your network");
1459
1698
  issues++;
1460
- }
1461
- // 2. MCP server registration
1462
- const claudeConfigPath = nodePath.join(nodeOs.homedir(), ".claude.json");
1463
- let mcpRegistered = false;
1464
- if (nodeFs.existsSync(claudeConfigPath)) {
1465
- try {
1466
- const config = JSON.parse(nodeFs.readFileSync(claudeConfigPath, "utf-8"));
1467
- mcpRegistered = Boolean(config?.mcpServers?.stackmem);
1468
- }
1469
- catch {
1470
- mcpRegistered = false;
1699
+ if (fix) {
1700
+ console.log("Could not auto-fix: backend connection — check your network and Supabase status, then re-run 'cm doctor'.");
1471
1701
  }
1472
1702
  }
1703
+ // 2. MCP server registration
1704
+ const claudeConfigPath = getClaudeConfigPath();
1705
+ const mcpRegistered = isMcpRegistered(claudeConfigPath);
1473
1706
  if (mcpRegistered) {
1474
1707
  console.log("✓ MCP server registered with Claude Code");
1475
1708
  }
1476
1709
  else {
1477
1710
  console.log("✗ MCP server not registered — run 'cm init <project> .'");
1478
1711
  issues++;
1712
+ if (fix) {
1713
+ registerMcpServer(DIST_INDEX_PATH, claudeConfigPath);
1714
+ if (isMcpRegistered(claudeConfigPath)) {
1715
+ console.log("Fixed: MCP server registration");
1716
+ issues--;
1717
+ }
1718
+ else {
1719
+ console.log("Could not auto-fix: MCP server registration");
1720
+ }
1721
+ }
1479
1722
  }
1480
1723
  // 3. Project file
1481
1724
  if (project) {
@@ -1484,6 +1727,9 @@ async function cmdDoctor() {
1484
1727
  else {
1485
1728
  console.log("✗ No .stackmem found — run 'cm init <project> .'");
1486
1729
  issues++;
1730
+ if (fix) {
1731
+ console.log("Could not auto-fix: .stackmem file — run 'cm init <project> .'");
1732
+ }
1487
1733
  }
1488
1734
  // 4. Git hook
1489
1735
  const gitDir = nodePath.join(process.cwd(), ".git");
@@ -1492,43 +1738,49 @@ async function cmdDoctor() {
1492
1738
  issues++;
1493
1739
  }
1494
1740
  else {
1495
- const hookPath = nodePath.join(gitDir, "hooks", "post-commit");
1496
- let hookExecutable = false;
1497
- if (nodeFs.existsSync(hookPath)) {
1498
- try {
1499
- nodeFs.accessSync(hookPath, nodeFs.constants.X_OK);
1500
- hookExecutable = true;
1501
- }
1502
- catch {
1503
- hookExecutable = false;
1504
- }
1505
- }
1506
- if (hookExecutable) {
1741
+ const hooksDir = nodePath.join(gitDir, "hooks");
1742
+ const hookPath = nodePath.join(hooksDir, "post-commit");
1743
+ if (isHookExecutable(hookPath)) {
1507
1744
  console.log("✓ Git hook installed");
1508
1745
  }
1509
1746
  else {
1510
1747
  console.log("✗ Git hook not installed — run 'cm init <project> .'");
1511
1748
  issues++;
1749
+ if (fix) {
1750
+ const codingMemoryDir = nodePath.join(process.cwd(), ".coding-memory");
1751
+ nodeFs.mkdirSync(codingMemoryDir, { recursive: true });
1752
+ const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
1753
+ nodeFs.writeFileSync(hookLogicPath, buildHookLogicScript(DIST_CLI_PATH), "utf-8");
1754
+ installHook(hooksDir, hookLogicPath);
1755
+ if (isHookExecutable(hookPath)) {
1756
+ console.log("Fixed: git hook");
1757
+ issues--;
1758
+ }
1759
+ else {
1760
+ console.log("Could not auto-fix: git hook");
1761
+ }
1762
+ }
1512
1763
  }
1513
1764
  }
1514
1765
  // 5. Memory count
1766
+ let hasMemories = false;
1515
1767
  if (project) {
1516
1768
  const { count, error } = await supabase
1517
1769
  .from("memories")
1518
1770
  .select("id", { count: "exact", head: true })
1519
1771
  .eq("project", project)
1520
1772
  .eq("resolved", false);
1521
- if (!error && (count ?? 0) > 0) {
1773
+ hasMemories = !error && (count ?? 0) > 0;
1774
+ if (hasMemories) {
1522
1775
  console.log(`✓ ${count} memories stored for "${project}"`);
1523
1776
  }
1524
- else {
1525
- console.log(`✗ No memories yet — make a commit to start capturing`);
1526
- issues++;
1527
- }
1528
1777
  }
1529
- else {
1778
+ if (!hasMemories) {
1530
1779
  console.log(`✗ No memories yet — make a commit to start capturing`);
1531
1780
  issues++;
1781
+ if (fix) {
1782
+ console.log("Could not auto-fix: memories — make a commit to start capturing");
1783
+ }
1532
1784
  }
1533
1785
  console.log("");
1534
1786
  if (issues === 0) {
@@ -1643,6 +1895,63 @@ async function cmdImport(project, filename) {
1643
1895
  }
1644
1896
  console.log(`Imported ${importedCount} memories from ${filename}`);
1645
1897
  }
1898
+ // --- cm uninstall ------------------------------------------------------
1899
+ function readProjectFromPath(dirPath) {
1900
+ const stackmemPath = nodePath.join(dirPath, STACKMEM_FILE);
1901
+ if (!nodeFs.existsSync(stackmemPath))
1902
+ return null;
1903
+ const content = nodeFs.readFileSync(stackmemPath, "utf-8").trim();
1904
+ return content.length > 0 ? content : null;
1905
+ }
1906
+ // Removes a marker-gated file (CLAUDE.md / .cursorrules / .windsurfrules)
1907
+ // only if it still carries the stackmem marker — a file the user has since
1908
+ // rewritten by hand, or never had stackmem's content to begin with, is left
1909
+ // alone rather than silently deleted.
1910
+ function removeIfMarked(filePath, marker, label) {
1911
+ if (!nodeFs.existsSync(filePath))
1912
+ return;
1913
+ const content = nodeFs.readFileSync(filePath, "utf-8");
1914
+ if (!content.includes(marker)) {
1915
+ console.log(`Skipped ${label} (no stackmem marker found — left as-is).`);
1916
+ return;
1917
+ }
1918
+ nodeFs.unlinkSync(filePath);
1919
+ console.log(`Removed ${label}`);
1920
+ }
1921
+ async function cmdUninstall(project, targetPath) {
1922
+ const gitDir = nodePath.join(targetPath, ".git");
1923
+ const hooksDir = nodePath.join(gitDir, "hooks");
1924
+ const hookResult = uninstallHook(hooksDir);
1925
+ if (hookResult)
1926
+ console.log(`Removed ${hookResult}`);
1927
+ const codingMemoryDir = nodePath.join(targetPath, ".coding-memory");
1928
+ const hookLogicPath = nodePath.join(codingMemoryDir, "post-commit-hook.mjs");
1929
+ if (nodeFs.existsSync(hookLogicPath)) {
1930
+ nodeFs.unlinkSync(hookLogicPath);
1931
+ console.log("Removed .coding-memory/post-commit-hook.mjs");
1932
+ }
1933
+ const astDbPath = nodePath.join(codingMemoryDir, "ast-index.db");
1934
+ if (nodeFs.existsSync(astDbPath)) {
1935
+ nodeFs.unlinkSync(astDbPath);
1936
+ console.log("Removed .coding-memory/ast-index.db");
1937
+ }
1938
+ if (nodeFs.existsSync(codingMemoryDir) && nodeFs.readdirSync(codingMemoryDir).length === 0) {
1939
+ nodeFs.rmdirSync(codingMemoryDir);
1940
+ console.log("Removed .coding-memory/ directory");
1941
+ }
1942
+ const stackmemPath = nodePath.join(targetPath, STACKMEM_FILE);
1943
+ if (nodeFs.existsSync(stackmemPath)) {
1944
+ nodeFs.unlinkSync(stackmemPath);
1945
+ console.log("Removed .stackmem");
1946
+ }
1947
+ removeIfMarked(nodePath.join(targetPath, "CLAUDE.md"), CLAUDE_MD_MARKER, "CLAUDE.md");
1948
+ removeIfMarked(nodePath.join(targetPath, ".cursorrules"), RULES_FILE_MARKER, ".cursorrules");
1949
+ removeIfMarked(nodePath.join(targetPath, ".windsurfrules"), RULES_FILE_MARKER, ".windsurfrules");
1950
+ if (unregisterMcpServer()) {
1951
+ console.log("Removed stackmem entry from ~/.claude.json mcpServers");
1952
+ }
1953
+ console.log(`stackmem uninstalled from ${project}.`);
1954
+ }
1646
1955
  // --- main --------------------------------------------------------------
1647
1956
  async function main() {
1648
1957
  const [, , command, ...args] = process.argv;
@@ -1776,7 +2085,8 @@ async function main() {
1776
2085
  break;
1777
2086
  }
1778
2087
  case "doctor": {
1779
- await cmdDoctor();
2088
+ const fix = args.includes("--fix");
2089
+ await cmdDoctor(fix);
1780
2090
  break;
1781
2091
  }
1782
2092
  case "undo": {
@@ -1804,6 +2114,31 @@ async function main() {
1804
2114
  await cmdImport(project, filename);
1805
2115
  break;
1806
2116
  }
2117
+ case "uninstall": {
2118
+ if (args.length > 2)
2119
+ usage();
2120
+ let explicitProject = null;
2121
+ let targetPath = process.cwd();
2122
+ if (args.length === 1) {
2123
+ if (looksLikeAPath(args[0])) {
2124
+ targetPath = args[0];
2125
+ }
2126
+ else {
2127
+ explicitProject = args[0];
2128
+ }
2129
+ }
2130
+ else if (args.length === 2) {
2131
+ [explicitProject, targetPath] = args;
2132
+ }
2133
+ const resolvedPath = nodePath.resolve(targetPath);
2134
+ const project = explicitProject ?? readProjectFromPath(resolvedPath);
2135
+ if (!project) {
2136
+ console.error(`No project specified and no .stackmem file found at ${resolvedPath}.`);
2137
+ process.exit(1);
2138
+ }
2139
+ await cmdUninstall(project, resolvedPath);
2140
+ break;
2141
+ }
1807
2142
  case "help": {
1808
2143
  cmdHelp();
1809
2144
  break;
@@ -1812,6 +2147,12 @@ async function main() {
1812
2147
  usage();
1813
2148
  }
1814
2149
  }
1815
- main().catch((error) => {
1816
- fail(error instanceof Error ? error.message : String(error));
1817
- });
2150
+ // Guards the auto-run so importing this module (e.g. from a test file, to
2151
+ // reach exported helpers like trySeedMemory) doesn't also execute main()
2152
+ // against the test runner's own argv.
2153
+ const isMainModule = process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href;
2154
+ if (isMainModule) {
2155
+ main().catch((error) => {
2156
+ fail(error instanceof Error ? error.message : String(error));
2157
+ });
2158
+ }