open-memex 0.5.0-alpha.2 → 0.5.0-alpha.5

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/AGENTS.md CHANGED
@@ -64,6 +64,13 @@ previews keyword capture without writing, `open-memex doctor` runs health checks
64
64
  (node version, config, scope resolution, storage writability, MCP handshake).
65
65
  `open-memex init` with no --client auto-detects and wires every installed editor
66
66
  (`--yes` skips, scripts never prompt); `open-memex config set <key> <value>` edits settings after install.
67
+ `open-memex uninstall [--client vscode|cursor|opencode|visualstudio] [--global] [--yes]`
68
+ reverses init — removes the MCP server entry / opencode plugin line / Copilot
69
+ instructions section; memory data never touched (D48); no --client → auto-detect
70
+ with an interactive confirm, explicit --client never prompts; `--yes` only skips
71
+ that confirm; `--global` limits cleanup to user-level. Empty/whitespace-only
72
+ config files parse as `{}` and are safely populated (D49); non-JSON (JSONC)
73
+ files are left untouched with a printed manual snippet (D47).
67
74
  The published `open-memex` bin points at `dist/cli.js` (compiled at publish time).
68
75
  From a source checkout, `npm run cli` / `npm run mcp` still run `src/` directly
69
76
  with type-stripping — no build step needed for development.
package/README.md CHANGED
@@ -90,7 +90,7 @@ personal scope: this machine only — never synced, never enters a repo.
90
90
  npm install -g open-memex
91
91
  ```
92
92
 
93
- This installs the `0.4.0` stable release.
93
+ This installs the `0.4.1` stable release.
94
94
 
95
95
  **Alpha** (bleeding edge, for testers) — the `alpha` tag:
96
96
 
@@ -114,10 +114,10 @@ npx -y open-memex <command> # e.g. npx -y open-memex init --client vscod
114
114
  npx -y open-memex@alpha <command> # alpha line, no install
115
115
  ```
116
116
 
117
- **From source** (bleeding edge, `V2-dev-p2` branch):
117
+ **From source** (bleeding edge, `main` branch):
118
118
 
119
119
  ```sh
120
- git clone -b V2-dev-p2 https://github.com/stoneskin/open-memex.git
120
+ git clone -b main https://github.com/stoneskin/open-memex.git
121
121
  cd open-memex
122
122
  npm install
123
123
  node --experimental-strip-types src/cli.ts <command>
@@ -158,7 +158,7 @@ Run from your **project root** (so the project scope resolves to this repo):
158
158
 
159
159
  ```sh
160
160
  open-memex init --yes
161
- # …or without a global install:
161
+ # …or without installing the package first:
162
162
  npx -y open-memex init --yes
163
163
  ```
164
164
 
@@ -167,16 +167,25 @@ With no `--client`, `init` **detects your installed editors and wires them all**
167
167
  native plugin), so one init covers every project. Visual Studio joins in when the
168
168
  project has a solution file. Prefer to pick a single editor? Pass `--client`:
169
169
 
170
+ > **Two different "globals" — don't mix them up.**
171
+ > - `npm install -g open-memex` installs the *package* globally: it puts the
172
+ > `open-memex` command on your PATH.
173
+ > - `init --global` writes the *editor config* at user level instead of the
174
+ > project: init once, the wiring works in every project. It works the same
175
+ > whether the package was installed globally or run via npx.
176
+
170
177
  **VS Code** (Copilot):
171
178
 
172
179
  ```sh
173
180
  open-memex init --client vscode
174
- # …or without a global install:
181
+ # …or without installing the package first:
175
182
  npx -y open-memex init --client vscode
176
183
  ```
177
184
 
178
- Writes `.vscode/mcp.json` and user-level Copilot instructions, then reload the
179
- window and confirm the `open-memex` server is started in Copilot Chat's MCP panel.
185
+ Wires the project-level `.vscode/mcp.json` and user-level Copilot instructions,
186
+ then reload the window and confirm the `open-memex` server is started in Copilot
187
+ Chat's MCP panel. On a TTY, `init` asks whether the MCP config should be
188
+ per-project or user-level instead of guessing; `--global` forces user-level.
180
189
 
181
190
  **Cursor:**
182
191
 
@@ -184,7 +193,9 @@ window and confirm the `open-memex` server is started in Copilot Chat's MCP pane
184
193
  open-memex init --client cursor
185
194
  ```
186
195
 
187
- Writes `.cursor/mcp.json` and user-level Copilot instructions.
196
+ Same shape as VS Code: project-level `.cursor/mcp.json` by default, user-level
197
+ with `--global` (or when `init` asks on a TTY), plus user-level Copilot
198
+ instructions.
188
199
 
189
200
  **One-time setup for all projects (VS Code / Cursor):**
190
201
 
@@ -198,7 +209,8 @@ Writes the server entry to the editor's *user-level* MCP config
198
209
  `~/.config/Code/User/mcp.json` on Linux; `~/.cursor/mcp.json` for Cursor)
199
210
  instead of the project — init once, the server starts in every project.
200
211
  A per-project `.vscode/mcp.json` still wins if a project defines its own.
201
- (On a TTY, `init` asks whether the config should be per-project or user-level.)
212
+ If the user-level file has comments in it (VS Code accepts JSONC), `init` leaves
213
+ the file untouched and prints the exact snippet to paste in by hand.
202
214
 
203
215
  **opencode** (native plugin — recommended):
204
216
 
@@ -243,6 +255,17 @@ above works too.
243
255
  `open-memex mcp --print-config` as a starting point (`[mcp_servers]` in
244
256
  `config.toml`, or `codex mcp add`).
245
257
 
258
+ **Remove the wiring:**
259
+
260
+ ```sh
261
+ open-memex uninstall --yes
262
+ ```
263
+
264
+ Reverses `init` — removes the MCP server entry, the opencode plugin line, and
265
+ the open-memex section of the Copilot instructions. With no `--client` it cleans
266
+ up every detected editor (project-level and user-level wiring); `--global`
267
+ limits the cleanup to user-level. Your memories are never touched.
268
+
246
269
  `init` notes:
247
270
 
248
271
  - With no `--client`, `init` auto-detects installed editors (VS Code via `code`
@@ -437,7 +460,9 @@ Settable keys: `maxProjectMemories`, `maxProfileItems`, `injectOnFirstTurn`,
437
460
  Setup & health:
438
461
 
439
462
  ```sh
440
- open-memex init [--client vscode|cursor|opencode|visualstudio] [--force] [--yes]
463
+ open-memex init [--client vscode|cursor|opencode|visualstudio]
464
+ [--instructions personal|project] [--global] [--force] [--yes]
465
+ open-memex uninstall [--client vscode|cursor|opencode|visualstudio] [--global] [--yes]
441
466
  open-memex config # print effective config
442
467
  open-memex config set <key> <value> # change a setting
443
468
  open-memex doctor # environment health check
@@ -607,6 +632,52 @@ sharing, ACL, or compliance needs demand it.
607
632
 
608
633
  Design details: [docs/V2-DESIGN.md](./docs/V2-DESIGN.md) (append-only decision log).
609
634
 
635
+ ## FAQ
636
+
637
+ **Do I need to initialize open-memex for each project after installing?**
638
+ Two layers. The data layer needs nothing — there is no per-project init:
639
+ the data dir is created on demand and the project scope is derived
640
+ automatically from your cwd's git remote or path, so memories are
641
+ namespaced per project with zero setup. The editor wiring takes one step:
642
+ run `open-memex init` with no arguments and it auto-detects every editor
643
+ you have installed (VS Code, Cursor, opencode — plus Visual Studio when the
644
+ project has a `.sln`) and wires them all, user-level wherever the editor
645
+ supports it, so one init covers every project. Prefer a single editor?
646
+ `open-memex init --client <vscode|cursor|opencode|visualstudio>`. Prefer to
647
+ force user-level for VS Code / Cursor? Add `--global`. The Copilot memory
648
+ instructions default to user-level (`~/.copilot/`), which is global.
649
+
650
+ **Does opencode need `init`?**
651
+ Two paths. Recommended: `open-memex init --client opencode --global` —
652
+ it merges the native open-memex plugin into
653
+ `~/.config/opencode/opencode.json` for you. One-time setup, applies to all
654
+ projects, and additionally enables keyword auto-capture and first-turn
655
+ memory injection. Prefer to do it by hand? Add
656
+ `"plugin": ["file:///absolute/path/to/open-memex/src/index.ts"]`
657
+ (the installed package's path) to that file instead. As a plain MCP
658
+ consumer: `open-memex init --client opencode` writes a project-level
659
+ `opencode.jsonc` (no hooks). If your user-level config has comments, `init`
660
+ leaves it untouched and prints the manual step.
661
+
662
+ **VS Code — run `init` once, or per project?**
663
+ Once. Plain `open-memex init` auto-detects VS Code and writes the MCP server
664
+ entry to VS Code's user-level `mcp.json`
665
+ (`%APPDATA%/Code/User/mcp.json` on Windows,
666
+ `~/Library/Application Support/Code/User/mcp.json` on macOS,
667
+ `~/.config/Code/User/mcp.json` on Linux), so the server starts in every
668
+ project. A per-project `.vscode/mcp.json` still wins when present, and the
669
+ entry keeps `cwd=${workspaceFolder}` so project-scope resolution keeps
670
+ working per window. If your user-level `mcp.json` has comments (VS Code
671
+ accepts JSONC), `init` leaves it alone and prints the exact snippet to add
672
+ by hand.
673
+
674
+ **How do I remove the editor wiring?**
675
+ `open-memex uninstall` reverses `init`: it removes the MCP server entry,
676
+ the opencode plugin line, and the open-memex section of the Copilot
677
+ instructions. With no `--client` it cleans up every detected editor;
678
+ `--global` limits the cleanup to user-level wiring. Your memories are
679
+ never touched.
680
+
610
681
  ## License
611
682
 
612
683
  [Apache-2.0](./LICENSE)
package/README.zh-CN.md CHANGED
@@ -87,7 +87,7 @@ personal scope:只属于这台机器——永不同步,永远进不了仓库
87
87
  npm install -g open-memex
88
88
  ```
89
89
 
90
- 安装的是 `0.4.0` 正式版。
90
+ 安装的是 `0.4.1` 正式版。
91
91
 
92
92
  **Alpha 版**(最新开发版,给测试者)——`alpha` 标签:
93
93
 
@@ -111,10 +111,10 @@ npx -y open-memex <命令> # 例如 npx -y open-memex init --client vsc
111
111
  npx -y open-memex@alpha <命令> # alpha 线,免安装
112
112
  ```
113
113
 
114
- **从源码安装**(最新开发版,`V2-dev-p2` 分支):
114
+ **从源码安装**(最新开发版,`main` 分支):
115
115
 
116
116
  ```sh
117
- git clone -b V2-dev-p2 https://github.com/stoneskin/open-memex.git
117
+ git clone -b main https://github.com/stoneskin/open-memex.git
118
118
  cd open-memex
119
119
  npm install
120
120
  node --experimental-strip-types src/cli.ts <命令>
@@ -153,7 +153,7 @@ Windows 下被进程加载的 DLL 是锁死的:如果 open-memex MCP server
153
153
 
154
154
  ```sh
155
155
  open-memex init --yes
156
- # ……没装全局包的话:
156
+ # ……没先装包的话:
157
157
  npx -y open-memex init --yes
158
158
  ```
159
159
 
@@ -162,16 +162,24 @@ npx -y open-memex init --yes
162
162
  一次 init,所有项目通用;项目里有 solution 文件时 Visual Studio 也会一起配。
163
163
  想只配某一个编辑器?加 `--client`:
164
164
 
165
+ > **两个"全局"不是一回事,别搞混。**
166
+ > - `npm install -g open-memex` 是把*包*装到全局:让 `open-memex` 命令出现在
167
+ > 你的 PATH 里。
168
+ > - `init --global` 是把*编辑器配置*写到用户级而不是项目里:init 一次,
169
+ > 每个项目都生效。不管包是全局安装的还是用 npx 临时跑的,效果一样。
170
+
165
171
  **VS Code**(Copilot):
166
172
 
167
173
  ```sh
168
174
  open-memex init --client vscode
169
- # ……没装全局包的话:
175
+ # ……没先装包的话:
170
176
  npx -y open-memex init --client vscode
171
177
  ```
172
178
 
173
- 自动写 `.vscode/mcp.json` 和用户级 Copilot instructions,然后重新加载窗口,
174
- 在 Copilot Chat 的 MCP 面板里确认 `open-memex` server 已启动。
179
+ 配项目级 `.vscode/mcp.json` 和用户级 Copilot instructions,然后重新加载窗口,
180
+ 在 Copilot Chat 的 MCP 面板里确认 `open-memex` server 已启动。终端交互模式下
181
+ `init` 会问你要配到项目级还是用户级,而不是替你猜;`--global` 直接强制
182
+ 用户级。
175
183
 
176
184
  **Cursor:**
177
185
 
@@ -179,7 +187,9 @@ npx -y open-memex init --client vscode
179
187
  open-memex init --client cursor
180
188
  ```
181
189
 
182
- 自动写 `.cursor/mcp.json` 和用户级 Copilot instructions。
190
+ 和 VS Code 一个套路:默认写项目级 `.cursor/mcp.json`,`--global`
191
+ (或终端里 `init` 问你时选用户级)就写用户级,外加用户级 Copilot
192
+ instructions。
183
193
 
184
194
  **一次配置、所有项目通用(VS Code / Cursor):**
185
195
 
@@ -193,7 +203,8 @@ open-memex init --client vscode --global --yes
193
203
  `~/.config/Code/User/mcp.json`;Cursor 是 `~/.cursor/mcp.json`),
194
204
  而不是写到项目里——init 一次,每个项目打开自动启动 server。
195
205
  如果某个项目自己定义了 `.vscode/mcp.json`,项目级的优先。
196
- (终端交互模式下,`init` 会问你配到项目级还是用户级。)
206
+ 如果用户级文件里带注释(VS Code 接受 JSONC),`init` 不会碰这个文件,
207
+ 只打印可直接手贴的配置片段。
197
208
 
198
209
  **opencode**(原生插件——推荐):
199
210
 
@@ -236,6 +247,16 @@ Visual Studio 也会自动发现 `.vscode/mcp.json` 和 `.cursor/mcp.json`,
236
247
  **Codex:** 暂无 `init` 客户端——以 `open-memex mcp --print-config` 为起点手动添加
237
248
  (`config.toml` 的 `[mcp_servers]`,或 `codex mcp add`)。
238
249
 
250
+ **拆掉接线:**
251
+
252
+ ```sh
253
+ open-memex uninstall --yes
254
+ ```
255
+
256
+ `init` 的逆操作——删掉 MCP server 条目、opencode 插件行和 Copilot
257
+ instructions 里的 open-memex 段。不带 `--client` 时把检测到的编辑器全清掉
258
+ (项目级和用户级都清);`--global` 只清用户级。你的记忆数据永远不会被碰。
259
+
239
260
  `init` 说明:
240
261
 
241
262
  - Copilot 记忆 instructions 默认写到**用户级**
@@ -416,7 +437,9 @@ open-memex config set maxProjectMemories 12
416
437
  安装与健康检查:
417
438
 
418
439
  ```sh
419
- open-memex init [--client vscode|cursor|opencode|visualstudio] [--force] [--yes]
440
+ open-memex init [--client vscode|cursor|opencode|visualstudio]
441
+ [--instructions personal|project] [--global] [--force] [--yes]
442
+ open-memex uninstall [--client vscode|cursor|opencode|visualstudio] [--global] [--yes]
420
443
  open-memex config # 打印生效配置
421
444
  open-memex config set <key> <value> # 改设置
422
445
  open-memex doctor # 环境健康检查
@@ -572,6 +595,43 @@ ACL 或合规需求出现时才做。
572
595
 
573
596
  设计细节:[docs/V2-DESIGN.md](./docs/V2-DESIGN.md)(append-only 决策日志)。
574
597
 
598
+ ## 常见问题(FAQ)
599
+
600
+ **装完之后,每个项目都要初始化 open-memex 吗(像其他 app 那样)?**
601
+ 分两层。数据层不需要——没有 per-project 初始化的概念:数据目录按需自动创建,
602
+ project scope 从当前目录的 git remote 或路径自动推导,记忆天然按项目隔离,
603
+ 零配置。编辑器接线只需要一步:直接跑 `open-memex init`(不带参数),它会自动
604
+ 检测你装好的编辑器(VS Code、Cursor、opencode;项目里有 `.sln` 时还有 Visual
605
+ Studio)并一次全接上——支持用户级配置的编辑器就写用户级,一次 init 所有项目
606
+ 通用。只想接某一个编辑器?用
607
+ `open-memex init --client <vscode|cursor|opencode|visualstudio>`。
608
+ 想给 VS Code / Cursor 强制写用户级?加 `--global`。
609
+ Copilot 记忆指令默认写用户级(`~/.copilot/`),那个是全局的。
610
+
611
+ **opencode 需要跑 `init` 吗?**
612
+ 两条路。推荐:`open-memex init --client opencode --global`——自动把原生插件
613
+ 合并进 `~/.config/opencode/opencode.json`。一次配置,所有项目生效,还多拿
614
+ 关键词自动捕获和首轮记忆注入。想手写?往那个文件里加
615
+ `"plugin": ["file:///absolute/path/to/open-memex/src/index.ts"]`
616
+ (填 open-memex 的实际安装路径)就行。当纯 MCP 用:
617
+ `open-memex init --client opencode` 写项目级 `opencode.jsonc`(无 hooks)。
618
+ 如果你的用户级配置里带注释,`init` 不会碰它,只打印手动步骤。
619
+
620
+ **VS Code 呢——跑一次就行,还是每个项目都要跑?**
621
+ 跑一次就行。直接 `open-memex init` 会自动检测到 VS Code,把 MCP server 写进
622
+ VS Code 的用户级 `mcp.json`(Windows:`%APPDATA%/Code/User/mcp.json`;
623
+ macOS:`~/Library/Application Support/Code/User/mcp.json`;
624
+ Linux:`~/.config/Code/User/mcp.json`),每个项目打开 server 都在。
625
+ 项目里如果有 `.vscode/mcp.json` 仍然优先;entry 里保留了
626
+ `cwd=${workspaceFolder}`,project scope 按窗口照常工作。
627
+ 如果你的用户级 `mcp.json` 带注释(VS Code 接受 JSONC),`init` 不会碰它,
628
+ 只打印可直接手贴的配置片段。
629
+
630
+ **怎么拆掉编辑器接线?**
631
+ `open-memex uninstall` 就是 `init` 的逆操作:删掉 MCP server 条目、opencode
632
+ 插件行和 Copilot instructions 里的 open-memex 段。不带 `--client` 时把检测
633
+ 到的编辑器全清掉;`--global` 只清用户级。你的记忆数据永远不会被碰。
634
+
575
635
  ## 许可证
576
636
 
577
637
  [Apache-2.0](./LICENSE)
package/dist/cli.js CHANGED
@@ -266,6 +266,22 @@ Examples:
266
266
  open-memex init
267
267
  open-memex init --client vscode --global --yes
268
268
  open-memex init --client cursor --yes`,
269
+ uninstall: `Remove the editor wiring that \`init\` wrote: the MCP server entry,
270
+ the opencode native plugin line, and the Copilot instructions section.
271
+ Your memories are never touched.
272
+
273
+ Usage: open-memex uninstall [--client vscode|cursor|opencode|visualstudio]
274
+ [--global] [--yes]
275
+
276
+ Flags:
277
+ --client editor to unwire (default: auto-detect all installed editors)
278
+ --global only the user-level config; without it, both project-level and
279
+ user-level wiring are removed (init may have written either)
280
+ --yes accept all defaults, never prompt
281
+
282
+ Examples:
283
+ open-memex uninstall
284
+ open-memex uninstall --client vscode --yes`,
269
285
  config: `Show config, or set a key.
270
286
 
271
287
  Usage: open-memex config [set <key> <value>]
@@ -318,6 +334,8 @@ Usage:
318
334
  open-memex mcp [--print-config vscode|cursor|claude|opencode|visualstudio]
319
335
  open-memex init [--client vscode|cursor|opencode|visualstudio]
320
336
  [--instructions personal|project] [--force] [--yes]
337
+ open-memex uninstall [--client vscode|cursor|opencode|visualstudio]
338
+ [--global] [--yes]
321
339
  open-memex config [set <key> <value>]
322
340
  open-memex capture --dry-run "text"
323
341
  open-memex doctor
@@ -547,6 +565,18 @@ async function main() {
547
565
  });
548
566
  return;
549
567
  }
568
+ // `uninstall` reverses `init` — removes the editor wiring; never touches data.
569
+ // No DB needed (pure file operation, like init).
570
+ if (cmd === "uninstall") {
571
+ const flags = parseFlags(rest);
572
+ const { uninstallProject } = await import("./init.js");
573
+ await uninstallProject({
574
+ client: flags["client"],
575
+ global: flags["global"] === "true",
576
+ yes: flags["yes"] === "true",
577
+ });
578
+ return;
579
+ }
550
580
  // `doctor` runs environment health checks — no DB needed (it self-contains).
551
581
  if (cmd === "doctor") {
552
582
  const { runDoctor } = await import("./doctor.js");
package/dist/init.js CHANGED
@@ -160,6 +160,39 @@ export function mergeServerEntry(doc, sectionKey, entry, force) {
160
160
  section["open-memex"] = entry;
161
161
  return "added";
162
162
  }
163
+ /**
164
+ * D46-followup: when a config file isn't valid JSON we leave it untouched —
165
+ * but "fix it manually" alone isn't helpful. Print the exact snippet the
166
+ * user needs to add by hand (same idea as the opencode --global hint).
167
+ */
168
+ export function printManualEntryHint(sectionKey, entry) {
169
+ console.error(` Add this under "${sectionKey}":`);
170
+ const pretty = JSON.stringify({ "open-memex": entry }, null, 2).replace(/\n/g, "\n ");
171
+ console.error(` ${pretty}`);
172
+ }
173
+ /**
174
+ * Parse a JSON config file's raw text for merging. An empty or
175
+ * whitespace-only file counts as `{}` — there is nothing to preserve, so
176
+ * writers can safely populate it (an empty mcp.json is a normal first-run
177
+ * state, e.g. created by the editor, not a corrupt file). Returns null when
178
+ * the content is genuinely unparseable (e.g. JSONC comments) or not an
179
+ * object, in which case the caller leaves the file untouched and prints the
180
+ * manual step.
181
+ */
182
+ export function parseJsonConfig(raw) {
183
+ if (raw.trim() === "")
184
+ return {};
185
+ let doc;
186
+ try {
187
+ doc = JSON.parse(raw);
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ if (!doc || typeof doc !== "object" || Array.isArray(doc))
193
+ return null;
194
+ return doc;
195
+ }
163
196
  /**
164
197
  * Read (or create) a JSON config file, merge the open-memex server entry, write
165
198
  * it back. Existing files are merged, never clobbered; invalid JSON is left
@@ -169,13 +202,13 @@ export function mergeServerEntry(doc, sectionKey, entry, force) {
169
202
  function writeServerEntryFile(file, sectionKey, entry, force, mkdir) {
170
203
  let doc = {};
171
204
  if (fs.existsSync(file)) {
172
- try {
173
- doc = JSON.parse(fs.readFileSync(file, "utf8"));
174
- }
175
- catch {
205
+ const parsed = parseJsonConfig(fs.readFileSync(file, "utf8"));
206
+ if (parsed === null) {
176
207
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
208
+ printManualEntryHint(sectionKey, entry);
177
209
  return null;
178
210
  }
211
+ doc = parsed;
179
212
  }
180
213
  const merged = mergeServerEntry(doc, sectionKey, entry, force);
181
214
  if (merged === "kept") {
@@ -271,13 +304,18 @@ function writeOpencodeMcpJson(root, force) {
271
304
  const file = path.join(root, "opencode.jsonc");
272
305
  let doc = {};
273
306
  if (fs.existsSync(file)) {
274
- try {
275
- doc = JSON.parse(fs.readFileSync(file, "utf8"));
276
- }
277
- catch {
307
+ const parsed = parseJsonConfig(fs.readFileSync(file, "utf8"));
308
+ if (parsed === null) {
278
309
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
310
+ const mc = resolveMcpCommand();
311
+ printManualEntryHint("mcp", {
312
+ type: "local",
313
+ command: [mc.command, ...mc.args],
314
+ enabled: true,
315
+ });
279
316
  return null;
280
317
  }
318
+ doc = parsed;
281
319
  }
282
320
  const section = (doc["mcp"] ??= {});
283
321
  if (section["open-memex"] && !force) {
@@ -306,11 +344,15 @@ function writeVisualStudioMcpJson(root, force) {
306
344
  const file = path.join(root, ".mcp.json");
307
345
  let doc = {};
308
346
  if (fs.existsSync(file)) {
309
- try {
310
- doc = JSON.parse(fs.readFileSync(file, "utf8"));
311
- }
312
- catch {
347
+ const parsed = parseJsonConfig(fs.readFileSync(file, "utf8"));
348
+ if (parsed === null) {
313
349
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
350
+ const mc = resolveMcpCommand();
351
+ printManualEntryHint("servers", {
352
+ type: "stdio",
353
+ command: mc.command,
354
+ args: mc.args,
355
+ });
314
356
  return null;
315
357
  }
316
358
  }
@@ -615,3 +657,191 @@ export async function initProject(opts) {
615
657
  }
616
658
  console.log(`\nDone. Reload your editor window to start the open-memex MCP server.`);
617
659
  }
660
+ // ---------------------------------------------------------------------------
661
+ // uninstall — the reverse of init (D48). Removes the editor wiring init wrote:
662
+ // the MCP server entry, the opencode native plugin line, and the Copilot
663
+ // instructions section. Memory data is never touched.
664
+ // ---------------------------------------------------------------------------
665
+ /**
666
+ * Pure removal of the open-memex server entry from a parsed config doc.
667
+ * Prunes the section when it becomes empty. Mutates `doc`.
668
+ */
669
+ export function removeServerEntry(doc, sectionKey) {
670
+ const section = doc[sectionKey];
671
+ if (!section || typeof section !== "object" || !("open-memex" in section))
672
+ return "absent";
673
+ delete section["open-memex"];
674
+ if (Object.keys(section).length === 0)
675
+ delete doc[sectionKey];
676
+ return "removed";
677
+ }
678
+ /**
679
+ * Pure removal of open-memex plugin entries from a parsed opencode config doc.
680
+ * Matches any plugin URL containing `match` (robust against the package moving
681
+ * between installs). Prunes the `plugin` array when it becomes empty.
682
+ * Mutates `doc`.
683
+ */
684
+ export function removePluginEntry(doc, match) {
685
+ const plugins = doc["plugin"];
686
+ if (!Array.isArray(plugins))
687
+ return "absent";
688
+ const kept = plugins.filter((p) => !(typeof p === "string" && p.includes(match)));
689
+ if (kept.length === plugins.length)
690
+ return "absent";
691
+ if (kept.length === 0)
692
+ delete doc["plugin"];
693
+ else
694
+ doc["plugin"] = kept;
695
+ return "removed";
696
+ }
697
+ /**
698
+ * Pure removal of the open-memex instructions section from a
699
+ * copilot-instructions.md body. init always appends it last (MARKER..end),
700
+ * so cutting from the marker to the end is exact; returns "" when nothing
701
+ * but the section remains so the caller can delete the file.
702
+ */
703
+ export function removeInstructionsSection(text) {
704
+ const idx = text.lastIndexOf(MARKER);
705
+ if (idx < 0)
706
+ return text;
707
+ const rest = text.slice(0, idx).replace(/\s+$/, "");
708
+ return rest ? rest + "\n" : "";
709
+ }
710
+ /**
711
+ * Remove the server entry from one JSON config file. Existing files are
712
+ * merged, never clobbered; invalid JSON is left untouched with the manual
713
+ * step (same D47 treatment as the init write path).
714
+ */
715
+ function removeServerEntryFile(file, sectionKey) {
716
+ if (!fs.existsSync(file))
717
+ return "absent";
718
+ const doc = parseJsonConfig(fs.readFileSync(file, "utf8"));
719
+ if (doc === null) {
720
+ console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
721
+ console.error(` Delete the "open-memex" key under "${sectionKey}".`);
722
+ return null;
723
+ }
724
+ const res = removeServerEntry(doc, sectionKey);
725
+ if (res === "removed") {
726
+ fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
727
+ console.log(` - ${file} (open-memex entry removed)`);
728
+ }
729
+ return res;
730
+ }
731
+ /** Remove the open-memex plugin URL from one opencode user-level config file. */
732
+ function removeOpencodePluginFile(file) {
733
+ if (!fs.existsSync(file))
734
+ return "absent";
735
+ const doc = parseJsonConfig(fs.readFileSync(file, "utf8"));
736
+ if (doc === null) {
737
+ console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
738
+ console.error(` Delete the open-memex URL from the "plugin" array.`);
739
+ return null;
740
+ }
741
+ const res = removePluginEntry(doc, "open-memex");
742
+ if (res === "removed") {
743
+ fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
744
+ console.log(` - ${file} (open-memex plugin removed)`);
745
+ }
746
+ return res;
747
+ }
748
+ /** Remove the open-memex section from one copilot-instructions.md file. */
749
+ function removeInstructionsFile(file) {
750
+ if (!fs.existsSync(file))
751
+ return "absent";
752
+ const cur = fs.readFileSync(file, "utf8");
753
+ if (!cur.includes(MARKER))
754
+ return "absent";
755
+ const next = removeInstructionsSection(cur);
756
+ if (next === "") {
757
+ fs.unlinkSync(file);
758
+ console.log(` - ${file} (deleted — it only held open-memex instructions)`);
759
+ }
760
+ else {
761
+ fs.writeFileSync(file, next);
762
+ console.log(` - ${file} (open-memex instructions removed)`);
763
+ }
764
+ return "removed";
765
+ }
766
+ export async function uninstallProject(opts) {
767
+ const interactive = !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
768
+ const explicit = normalizeClient(opts.client ?? "");
769
+ if (explicit && !INIT_CLIENTS.includes(explicit)) {
770
+ console.error(`unknown client "${opts.client}" (${INIT_CLIENTS.join("|")})`);
771
+ process.exit(1);
772
+ }
773
+ // Mirror init's client resolution: explicit --client, else auto-detect.
774
+ let clients;
775
+ if (explicit) {
776
+ clients = [explicit];
777
+ }
778
+ else if (interactive) {
779
+ const detected = detectInstalledClients();
780
+ if (detected.length === 0) {
781
+ console.log(" - no supported editors detected — nothing to remove");
782
+ return;
783
+ }
784
+ console.log(`Detected editors: ${detected.join(", ")}`);
785
+ const all = await askBool("Remove open-memex wiring from all of them?", true);
786
+ if (all) {
787
+ clients = detected;
788
+ }
789
+ else {
790
+ const one = await promptClient();
791
+ clients = one ? [one] : [];
792
+ }
793
+ }
794
+ else {
795
+ clients = detectInstalledClients();
796
+ if (clients.length > 0) {
797
+ console.log(`Detected editors: ${clients.join(", ")} — removing from all (use --client to pick one)`);
798
+ }
799
+ }
800
+ if (clients.length === 0) {
801
+ console.log(" - nothing to remove");
802
+ return;
803
+ }
804
+ // Without --global, clean both levels: init may have written either one,
805
+ // and a leftover entry at the other level would be a surprise.
806
+ const levels = opts.global ? ["user"] : ["project", "user"];
807
+ const root = projectRoot();
808
+ console.log(`open-memex uninstall — project root: ${root}`);
809
+ let changed = 0;
810
+ const bump = (r) => {
811
+ if (r === "removed")
812
+ changed++;
813
+ };
814
+ for (const client of clients) {
815
+ if (client === "vscode" || client === "cursor") {
816
+ const sectionKey = client === "cursor" ? "mcpServers" : "servers";
817
+ for (const level of levels) {
818
+ const file = level === "user"
819
+ ? userMcpConfigPath(client)
820
+ : path.join(root, client === "cursor" ? ".cursor/mcp.json" : ".vscode/mcp.json");
821
+ bump(removeServerEntryFile(file, sectionKey));
822
+ }
823
+ }
824
+ else if (client === "opencode") {
825
+ for (const level of levels) {
826
+ if (level === "user")
827
+ bump(removeOpencodePluginFile(opencodeGlobalConfigPath()));
828
+ else
829
+ bump(removeServerEntryFile(path.join(root, "opencode.jsonc"), "mcp"));
830
+ }
831
+ }
832
+ else if (client === "visualstudio") {
833
+ // Solution-level only — --global is meaningless, same as init.
834
+ bump(removeServerEntryFile(path.join(root, ".mcp.json"), "servers"));
835
+ }
836
+ }
837
+ // Copilot instructions: init may have written personal (default) or project.
838
+ if (clients.some((c) => c !== "opencode")) {
839
+ bump(removeInstructionsFile(path.join(os.homedir(), ".copilot", "copilot-instructions.md")));
840
+ bump(removeInstructionsFile(path.join(os.homedir(), "copilot-instructions.md")));
841
+ bump(removeInstructionsFile(path.join(root, ".github", "copilot-instructions.md")));
842
+ }
843
+ console.log(changed > 0
844
+ ? `\nDone. Removed open-memex wiring from ${changed} file(s).`
845
+ : "\nDone. Nothing to remove — no open-memex wiring found.");
846
+ console.log("Your memories are untouched (uninstall never deletes data).");
847
+ }
package/docs/V2-DESIGN.md CHANGED
@@ -930,6 +930,43 @@ requirement: personal data never touches third-party services). Benchmarks to tr
930
930
  should not have to learn `--client` to get started. The help text already
931
931
  promised "default: auto-detect"; D46 makes the code keep that promise.
932
932
  Approved 2026-09-29.*
933
+ - **D47** — when `init` refuses to touch a config file that isn't valid JSON
934
+ (usually JSONC with comments — VS Code / Cursor / opencode all accept them),
935
+ it now prints the exact snippet to add by hand: the section key plus the
936
+ `"open-memex"` entry, pretty-printed. Covers all three MCP-writing paths —
937
+ vscode/cursor (user + project level), opencode project-level `opencode.jsonc`,
938
+ and Visual Studio `.mcp.json` — matching the manual hint the opencode
939
+ `--global` plugin path already printed (D46). The file is still never
940
+ rewritten; the hint just makes "fix it manually" actionable. Triggered by
941
+ Stone's real Windows run: his user-level `mcp.json` had comments, init
942
+ correctly left it alone, but the old message gave him nothing to paste.
943
+ *Rationale: a refusal without a remedy is a dead end; the entry shape is
944
+ already computed, so printing it costs nothing. Approved 2026-09-29.*
945
+ - **D48** — new `open-memex uninstall` command, the reverse of `init`
946
+ (0.5.0-alpha.4). Removes the MCP server entry / opencode native plugin line /
947
+ Copilot instructions section that `init` wrote; memory data is never touched.
948
+ Client resolution mirrors `init` (explicit `--client`, else auto-detect with
949
+ an interactive confirm). Without `--global` it cleans both project-level and
950
+ user-level files — init may have written either, and a leftover entry at the
951
+ other level would be a surprise; `--global` restricts to user-level. Empty
952
+ sections/arrays are pruned; an instructions file that only held the
953
+ open-memex section is deleted. Non-JSON (JSONC) configs are left untouched
954
+ with the manual step, same D47 treatment as the init write path.
955
+ *Rationale: Stone asked "有 uninstall 吗" while testing init on Windows —
956
+ every write deserves an undo. Approved 2026-09-29.*
957
+ - **D49** — empty config files are no longer treated as corrupt (0.5.0-alpha.5).
958
+ Stone's real Windows run: his user-level `mcp.json` existed but was empty,
959
+ and `init` refused it with "not valid JSON — left untouched", because
960
+ `JSON.parse("")` throws. New `parseJsonConfig` helper: empty or
961
+ whitespace-only content parses as `{}` — there is nothing to preserve, so
962
+ writers safely populate it; genuinely unparseable content (JSONC comments)
963
+ or non-objects still return null and keep the D47 leave-untouched + manual
964
+ hint behavior. Applied to all five config file touch points: the three init
965
+ writers (vscode/cursor MCP, opencode `opencode.jsonc`, Visual Studio
966
+ `.mcp.json`) and the two uninstall removers (empty file = nothing to remove).
967
+ *Rationale: an empty file is the safest write target, not a corrupt file;
968
+ refusing it sent the user down a manual path for no reason. Triggered by
969
+ Stone's report 2026-09-29.*
933
970
 
934
971
  ## Open Questions
935
972
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-memex",
3
- "version": "0.5.0-alpha.2",
3
+ "version": "0.5.0-alpha.5",
4
4
  "description": "Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -8,7 +8,7 @@ import { DEFAULT_CONFIG } from "../src/config.ts";
8
8
  import { resolveProjectScope, resolveCwdScope, PERSONAL_SCOPE } from "../src/scope.ts";
9
9
  import { cjkIndexText, cjkQueryExpr, hasCjk } from "../src/retrieve/cjk.ts";
10
10
  import { contentHash, similarity, NEAR_DUP_THRESHOLD } from "../src/store/lifecycle.ts";
11
- import { userMcpConfigPath, mergeServerEntry, detectInstalledClients, mergePluginEntry, opencodeGlobalConfigPath } from "../src/init.ts";
11
+ import { userMcpConfigPath, mergeServerEntry, detectInstalledClients, mergePluginEntry, opencodeGlobalConfigPath, printManualEntryHint, parseJsonConfig, removeServerEntry, removePluginEntry, removeInstructionsSection } from "../src/init.ts";
12
12
 
13
13
  let fails = 0;
14
14
  function ok(name: string, cond: boolean, info?: unknown) {
@@ -422,6 +422,49 @@ ok("plugin merge force on dup adds nothing twice", mergePluginEntry(pdoc, "file:
422
422
  const pdoc2: Record<string, unknown> = { plugin: ["file:///other"], theme: "dark" };
423
423
  ok("plugin merge preserves siblings", mergePluginEntry(pdoc2, "file:///x", false) === "added"
424
424
  && (pdoc2["plugin"] as unknown[]).length === 2 && pdoc2["theme"] === "dark");
425
+ // printManualEntryHint: the invalid-JSON bail-out must show the exact snippet to add by hand.
426
+ const hintLines: string[] = [];
427
+ const origErr = console.error;
428
+ console.error = (msg?: unknown) => { hintLines.push(String(msg)); };
429
+ printManualEntryHint("servers", { type: "stdio", command: "open-memex", args: ["mcp"] });
430
+ console.error = origErr;
431
+ const hintText = hintLines.join("\n");
432
+ ok("manual hint names the section", hintText.includes('"servers"'));
433
+ ok("manual hint contains the entry", hintText.includes('"open-memex"') && hintText.includes("stdio"));
434
+ // parseJsonConfig: empty/whitespace-only files are empty docs (safe to populate);
435
+ // genuinely unparseable content (JSONC comments) or non-objects yield null.
436
+ ok("empty file → {}", JSON.stringify(parseJsonConfig("")) === "{}");
437
+ ok("whitespace-only file → {}", JSON.stringify(parseJsonConfig(" \n\t ")) === "{}");
438
+ ok("valid JSON parses", (parseJsonConfig('{"servers":{}}') as Record<string, unknown>)["servers"] !== undefined);
439
+ ok("JSONC comments → null", parseJsonConfig('{\n // a comment\n}') === null);
440
+ ok("trailing garbage → null", parseJsonConfig('{} trailing') === null);
441
+ ok("array → null", parseJsonConfig("[1,2]") === null);
442
+ ok("scalar → null", parseJsonConfig("42") === null);
443
+ // removeServerEntry: pure removal semantics.
444
+ const rdoc: Record<string, unknown> = { servers: { "open-memex": { command: "x" }, other: { command: "y" } }, untouched: 1 };
445
+ ok("remove deletes the entry", removeServerEntry(rdoc, "servers") === "removed"
446
+ && !("open-memex" in (rdoc["servers"] as Record<string, unknown>))
447
+ && "other" in (rdoc["servers"] as Record<string, unknown>));
448
+ ok("remove absent when entry already gone", removeServerEntry(rdoc, "servers") === "absent"
449
+ && "servers" in rdoc); // sibling kept, section kept
450
+ const rdoc2: Record<string, unknown> = { servers: { "open-memex": { command: "x" } } };
451
+ ok("remove prunes empty section", removeServerEntry(rdoc2, "servers") === "removed" && !("servers" in rdoc2));
452
+ ok("remove absent when no entry", removeServerEntry({ servers: {} }, "servers") === "absent");
453
+ ok("remove absent when no section", removeServerEntry({}, "servers") === "absent");
454
+ // removePluginEntry: removes any open-memex URL, prunes empty array.
455
+ const rpdoc: Record<string, unknown> = { plugin: ["file:///x/open-memex/src/index.ts", "other-plugin"], theme: "dark" };
456
+ ok("plugin remove by substring", removePluginEntry(rpdoc, "open-memex") === "removed"
457
+ && JSON.stringify(rpdoc["plugin"]) === JSON.stringify(["other-plugin"]));
458
+ const rpdoc3: Record<string, unknown> = { plugin: ["file:///open-memex/y"] };
459
+ ok("plugin remove prunes empty array", removePluginEntry(rpdoc3, "open-memex") === "removed" && !("plugin" in rpdoc3));
460
+ ok("plugin remove absent", removePluginEntry({ plugin: ["other"] }, "open-memex") === "absent");
461
+ // removeInstructionsSection: cuts MARKER..end, "" when nothing remains.
462
+ ok("instructions remove keeps prior content",
463
+ removeInstructionsSection("# mine\n\n<!-- open-memex -->\n# OpenMemex memory\n") === "# mine\n");
464
+ ok("instructions remove returns empty when wholesale",
465
+ removeInstructionsSection("<!-- open-memex -->\n# OpenMemex memory\n") === "");
466
+ ok("instructions remove no-op without marker",
467
+ removeInstructionsSection("# mine\n") === "# mine\n");
425
468
  // detectInstalledClients with a fully fake env.
426
469
  const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "memex-bin-"));
427
470
  fs.writeFileSync(path.join(binDir, "code"), "#!/bin/sh\n");
package/src/cli.ts CHANGED
@@ -296,6 +296,23 @@ Examples:
296
296
  open-memex init --client vscode --global --yes
297
297
  open-memex init --client cursor --yes`,
298
298
 
299
+ uninstall: `Remove the editor wiring that \`init\` wrote: the MCP server entry,
300
+ the opencode native plugin line, and the Copilot instructions section.
301
+ Your memories are never touched.
302
+
303
+ Usage: open-memex uninstall [--client vscode|cursor|opencode|visualstudio]
304
+ [--global] [--yes]
305
+
306
+ Flags:
307
+ --client editor to unwire (default: auto-detect all installed editors)
308
+ --global only the user-level config; without it, both project-level and
309
+ user-level wiring are removed (init may have written either)
310
+ --yes accept all defaults, never prompt
311
+
312
+ Examples:
313
+ open-memex uninstall
314
+ open-memex uninstall --client vscode --yes`,
315
+
299
316
  config: `Show config, or set a key.
300
317
 
301
318
  Usage: open-memex config [set <key> <value>]
@@ -351,6 +368,8 @@ Usage:
351
368
  open-memex mcp [--print-config vscode|cursor|claude|opencode|visualstudio]
352
369
  open-memex init [--client vscode|cursor|opencode|visualstudio]
353
370
  [--instructions personal|project] [--force] [--yes]
371
+ open-memex uninstall [--client vscode|cursor|opencode|visualstudio]
372
+ [--global] [--yes]
354
373
  open-memex config [set <key> <value>]
355
374
  open-memex capture --dry-run "text"
356
375
  open-memex doctor
@@ -617,6 +636,19 @@ async function main() {
617
636
  return;
618
637
  }
619
638
 
639
+ // `uninstall` reverses `init` — removes the editor wiring; never touches data.
640
+ // No DB needed (pure file operation, like init).
641
+ if (cmd === "uninstall") {
642
+ const flags = parseFlags(rest);
643
+ const { uninstallProject } = await import("./init.ts");
644
+ await uninstallProject({
645
+ client: flags["client"],
646
+ global: flags["global"] === "true",
647
+ yes: flags["yes"] === "true",
648
+ });
649
+ return;
650
+ }
651
+
620
652
  // `doctor` runs environment health checks — no DB needed (it self-contains).
621
653
  if (cmd === "doctor") {
622
654
  const { runDoctor } = await import("./doctor.ts");
package/src/init.ts CHANGED
@@ -181,6 +181,38 @@ export function mergeServerEntry(
181
181
  return "added";
182
182
  }
183
183
 
184
+ /**
185
+ * D46-followup: when a config file isn't valid JSON we leave it untouched —
186
+ * but "fix it manually" alone isn't helpful. Print the exact snippet the
187
+ * user needs to add by hand (same idea as the opencode --global hint).
188
+ */
189
+ export function printManualEntryHint(sectionKey: string, entry: Record<string, unknown>): void {
190
+ console.error(` Add this under "${sectionKey}":`);
191
+ const pretty = JSON.stringify({ "open-memex": entry }, null, 2).replace(/\n/g, "\n ");
192
+ console.error(` ${pretty}`);
193
+ }
194
+
195
+ /**
196
+ * Parse a JSON config file's raw text for merging. An empty or
197
+ * whitespace-only file counts as `{}` — there is nothing to preserve, so
198
+ * writers can safely populate it (an empty mcp.json is a normal first-run
199
+ * state, e.g. created by the editor, not a corrupt file). Returns null when
200
+ * the content is genuinely unparseable (e.g. JSONC comments) or not an
201
+ * object, in which case the caller leaves the file untouched and prints the
202
+ * manual step.
203
+ */
204
+ export function parseJsonConfig(raw: string): Record<string, unknown> | null {
205
+ if (raw.trim() === "") return {};
206
+ let doc: unknown;
207
+ try {
208
+ doc = JSON.parse(raw);
209
+ } catch {
210
+ return null;
211
+ }
212
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) return null;
213
+ return doc as Record<string, unknown>;
214
+ }
215
+
184
216
  /**
185
217
  * Read (or create) a JSON config file, merge the open-memex server entry, write
186
218
  * it back. Existing files are merged, never clobbered; invalid JSON is left
@@ -196,12 +228,13 @@ function writeServerEntryFile(
196
228
  ): string | null {
197
229
  let doc: Record<string, unknown> = {};
198
230
  if (fs.existsSync(file)) {
199
- try {
200
- doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
201
- } catch {
231
+ const parsed = parseJsonConfig(fs.readFileSync(file, "utf8"));
232
+ if (parsed === null) {
202
233
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
234
+ printManualEntryHint(sectionKey, entry);
203
235
  return null;
204
236
  }
237
+ doc = parsed;
205
238
  }
206
239
  const merged = mergeServerEntry(doc, sectionKey, entry, force);
207
240
  if (merged === "kept") {
@@ -304,12 +337,18 @@ function writeOpencodeMcpJson(root: string, force: boolean): string | null {
304
337
  const file = path.join(root, "opencode.jsonc");
305
338
  let doc: Record<string, unknown> = {};
306
339
  if (fs.existsSync(file)) {
307
- try {
308
- doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
309
- } catch {
340
+ const parsed = parseJsonConfig(fs.readFileSync(file, "utf8"));
341
+ if (parsed === null) {
310
342
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
343
+ const mc = resolveMcpCommand();
344
+ printManualEntryHint("mcp", {
345
+ type: "local",
346
+ command: [mc.command, ...mc.args],
347
+ enabled: true,
348
+ });
311
349
  return null;
312
350
  }
351
+ doc = parsed;
313
352
  }
314
353
  const section = ((doc["mcp"] ??= {}) as Record<string, unknown>);
315
354
  if (section["open-memex"] && !force) {
@@ -339,10 +378,15 @@ function writeVisualStudioMcpJson(root: string, force: boolean): string | null {
339
378
  const file = path.join(root, ".mcp.json");
340
379
  let doc: Record<string, unknown> = {};
341
380
  if (fs.existsSync(file)) {
342
- try {
343
- doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
344
- } catch {
381
+ const parsed = parseJsonConfig(fs.readFileSync(file, "utf8"));
382
+ if (parsed === null) {
345
383
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
384
+ const mc = resolveMcpCommand();
385
+ printManualEntryHint("servers", {
386
+ type: "stdio",
387
+ command: mc.command,
388
+ args: mc.args,
389
+ });
346
390
  return null;
347
391
  }
348
392
  }
@@ -677,3 +721,192 @@ export async function initProject(opts: {
677
721
  }
678
722
  console.log(`\nDone. Reload your editor window to start the open-memex MCP server.`);
679
723
  }
724
+
725
+ // ---------------------------------------------------------------------------
726
+ // uninstall — the reverse of init (D48). Removes the editor wiring init wrote:
727
+ // the MCP server entry, the opencode native plugin line, and the Copilot
728
+ // instructions section. Memory data is never touched.
729
+ // ---------------------------------------------------------------------------
730
+
731
+ /**
732
+ * Pure removal of the open-memex server entry from a parsed config doc.
733
+ * Prunes the section when it becomes empty. Mutates `doc`.
734
+ */
735
+ export function removeServerEntry(
736
+ doc: Record<string, unknown>,
737
+ sectionKey: string,
738
+ ): "removed" | "absent" {
739
+ const section = doc[sectionKey];
740
+ if (!section || typeof section !== "object" || !("open-memex" in section)) return "absent";
741
+ delete (section as Record<string, unknown>)["open-memex"];
742
+ if (Object.keys(section as Record<string, unknown>).length === 0) delete doc[sectionKey];
743
+ return "removed";
744
+ }
745
+
746
+ /**
747
+ * Pure removal of open-memex plugin entries from a parsed opencode config doc.
748
+ * Matches any plugin URL containing `match` (robust against the package moving
749
+ * between installs). Prunes the `plugin` array when it becomes empty.
750
+ * Mutates `doc`.
751
+ */
752
+ export function removePluginEntry(doc: Record<string, unknown>, match: string): "removed" | "absent" {
753
+ const plugins = doc["plugin"];
754
+ if (!Array.isArray(plugins)) return "absent";
755
+ const kept = (plugins as unknown[]).filter(
756
+ (p) => !(typeof p === "string" && p.includes(match)),
757
+ );
758
+ if (kept.length === plugins.length) return "absent";
759
+ if (kept.length === 0) delete doc["plugin"];
760
+ else doc["plugin"] = kept;
761
+ return "removed";
762
+ }
763
+
764
+ /**
765
+ * Pure removal of the open-memex instructions section from a
766
+ * copilot-instructions.md body. init always appends it last (MARKER..end),
767
+ * so cutting from the marker to the end is exact; returns "" when nothing
768
+ * but the section remains so the caller can delete the file.
769
+ */
770
+ export function removeInstructionsSection(text: string): string {
771
+ const idx = text.lastIndexOf(MARKER);
772
+ if (idx < 0) return text;
773
+ const rest = text.slice(0, idx).replace(/\s+$/, "");
774
+ return rest ? rest + "\n" : "";
775
+ }
776
+
777
+ /**
778
+ * Remove the server entry from one JSON config file. Existing files are
779
+ * merged, never clobbered; invalid JSON is left untouched with the manual
780
+ * step (same D47 treatment as the init write path).
781
+ */
782
+ function removeServerEntryFile(file: string, sectionKey: string): "removed" | "absent" | null {
783
+ if (!fs.existsSync(file)) return "absent";
784
+ const doc = parseJsonConfig(fs.readFileSync(file, "utf8"));
785
+ if (doc === null) {
786
+ console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
787
+ console.error(` Delete the "open-memex" key under "${sectionKey}".`);
788
+ return null;
789
+ }
790
+ const res = removeServerEntry(doc, sectionKey);
791
+ if (res === "removed") {
792
+ fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
793
+ console.log(` - ${file} (open-memex entry removed)`);
794
+ }
795
+ return res;
796
+ }
797
+
798
+ /** Remove the open-memex plugin URL from one opencode user-level config file. */
799
+ function removeOpencodePluginFile(file: string): "removed" | "absent" | null {
800
+ if (!fs.existsSync(file)) return "absent";
801
+ const doc = parseJsonConfig(fs.readFileSync(file, "utf8"));
802
+ if (doc === null) {
803
+ console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
804
+ console.error(` Delete the open-memex URL from the "plugin" array.`);
805
+ return null;
806
+ }
807
+ const res = removePluginEntry(doc, "open-memex");
808
+ if (res === "removed") {
809
+ fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
810
+ console.log(` - ${file} (open-memex plugin removed)`);
811
+ }
812
+ return res;
813
+ }
814
+
815
+ /** Remove the open-memex section from one copilot-instructions.md file. */
816
+ function removeInstructionsFile(file: string): "removed" | "absent" {
817
+ if (!fs.existsSync(file)) return "absent";
818
+ const cur = fs.readFileSync(file, "utf8");
819
+ if (!cur.includes(MARKER)) return "absent";
820
+ const next = removeInstructionsSection(cur);
821
+ if (next === "") {
822
+ fs.unlinkSync(file);
823
+ console.log(` - ${file} (deleted — it only held open-memex instructions)`);
824
+ } else {
825
+ fs.writeFileSync(file, next);
826
+ console.log(` - ${file} (open-memex instructions removed)`);
827
+ }
828
+ return "removed";
829
+ }
830
+
831
+ export async function uninstallProject(opts: {
832
+ client?: string;
833
+ /** D48: only the user-level config; without it, both levels are cleaned. */
834
+ global?: boolean;
835
+ yes: boolean;
836
+ }): Promise<void> {
837
+ const interactive = !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
838
+ const explicit = normalizeClient(opts.client ?? "");
839
+ if (explicit && !(INIT_CLIENTS as readonly string[]).includes(explicit)) {
840
+ console.error(`unknown client "${opts.client}" (${INIT_CLIENTS.join("|")})`);
841
+ process.exit(1);
842
+ }
843
+ // Mirror init's client resolution: explicit --client, else auto-detect.
844
+ let clients: InitClient[];
845
+ if (explicit) {
846
+ clients = [explicit as InitClient];
847
+ } else if (interactive) {
848
+ const detected = detectInstalledClients();
849
+ if (detected.length === 0) {
850
+ console.log(" - no supported editors detected — nothing to remove");
851
+ return;
852
+ }
853
+ console.log(`Detected editors: ${detected.join(", ")}`);
854
+ const all = await askBool("Remove open-memex wiring from all of them?", true);
855
+ if (all) {
856
+ clients = detected;
857
+ } else {
858
+ const one = await promptClient();
859
+ clients = one ? [one as InitClient] : [];
860
+ }
861
+ } else {
862
+ clients = detectInstalledClients();
863
+ if (clients.length > 0) {
864
+ console.log(`Detected editors: ${clients.join(", ")} — removing from all (use --client to pick one)`);
865
+ }
866
+ }
867
+ if (clients.length === 0) {
868
+ console.log(" - nothing to remove");
869
+ return;
870
+ }
871
+ // Without --global, clean both levels: init may have written either one,
872
+ // and a leftover entry at the other level would be a surprise.
873
+ const levels = opts.global ? ["user"] : ["project", "user"];
874
+ const root = projectRoot();
875
+ console.log(`open-memex uninstall — project root: ${root}`);
876
+ let changed = 0;
877
+ const bump = (r: "removed" | "absent" | null) => {
878
+ if (r === "removed") changed++;
879
+ };
880
+ for (const client of clients) {
881
+ if (client === "vscode" || client === "cursor") {
882
+ const sectionKey = client === "cursor" ? "mcpServers" : "servers";
883
+ for (const level of levels) {
884
+ const file =
885
+ level === "user"
886
+ ? userMcpConfigPath(client)
887
+ : path.join(root, client === "cursor" ? ".cursor/mcp.json" : ".vscode/mcp.json");
888
+ bump(removeServerEntryFile(file, sectionKey));
889
+ }
890
+ } else if (client === "opencode") {
891
+ for (const level of levels) {
892
+ if (level === "user") bump(removeOpencodePluginFile(opencodeGlobalConfigPath()));
893
+ else bump(removeServerEntryFile(path.join(root, "opencode.jsonc"), "mcp"));
894
+ }
895
+ } else if (client === "visualstudio") {
896
+ // Solution-level only — --global is meaningless, same as init.
897
+ bump(removeServerEntryFile(path.join(root, ".mcp.json"), "servers"));
898
+ }
899
+ }
900
+ // Copilot instructions: init may have written personal (default) or project.
901
+ if (clients.some((c) => c !== "opencode")) {
902
+ bump(removeInstructionsFile(path.join(os.homedir(), ".copilot", "copilot-instructions.md")));
903
+ bump(removeInstructionsFile(path.join(os.homedir(), "copilot-instructions.md")));
904
+ bump(removeInstructionsFile(path.join(root, ".github", "copilot-instructions.md")));
905
+ }
906
+ console.log(
907
+ changed > 0
908
+ ? `\nDone. Removed open-memex wiring from ${changed} file(s).`
909
+ : "\nDone. Nothing to remove — no open-memex wiring found.",
910
+ );
911
+ console.log("Your memories are untouched (uninstall never deletes data).");
912
+ }