open-memex 0.4.1 → 0.5.0-alpha.4

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/init.ts CHANGED
@@ -4,6 +4,7 @@ import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { createInterface } from "node:readline/promises";
7
+ import { pathToFileURL, fileURLToPath } from "node:url";
7
8
  import { DEFAULT_CONFIG, saveConfig } from "./config.ts";
8
9
  import { projectRoot } from "./paths.ts";
9
10
 
@@ -110,41 +111,203 @@ function writeMcpJson(root: string, client: string, force: boolean): string | nu
110
111
  const dir = client === "cursor" ? path.join(root, ".cursor") : path.join(root, ".vscode");
111
112
  const file = path.join(dir, "mcp.json");
112
113
  const sectionKey = client === "cursor" ? "mcpServers" : "servers";
114
+ const { entry, durable } = stdioServerEntry(client);
115
+ const written = writeServerEntryFile(file, sectionKey, entry, force, true);
116
+ if (written) warnNonDurable(durable);
117
+ return written;
118
+ }
113
119
 
114
- let doc: Record<string, unknown> = {};
115
- if (fs.existsSync(file)) {
116
- try {
117
- doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
118
- } catch {
119
- console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
120
- return null;
121
- }
120
+ /**
121
+ * D45: user-level MCP config path — `init --global` writes here so one init
122
+ * covers all projects. `platform` and `home` are parameters (default: current)
123
+ * so tests can cover all OS layouts without mocking.
124
+ */
125
+ export function userMcpConfigPath(
126
+ client: "vscode" | "cursor",
127
+ platform: NodeJS.Platform = process.platform,
128
+ home: string = os.homedir(),
129
+ ): string {
130
+ if (client === "cursor") return path.join(home, ".cursor", "mcp.json");
131
+ if (platform === "win32") {
132
+ const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
133
+ return path.join(appData, "Code", "User", "mcp.json");
122
134
  }
123
-
124
- const section = ((doc[sectionKey] ??= {}) as Record<string, unknown>);
125
- if (section["open-memex"] && !force) {
126
- console.log(` = ${file} already configures open-memex — left as is (use --force to overwrite)`);
127
- return file;
135
+ if (platform === "darwin") {
136
+ return path.join(home, "Library", "Application Support", "Code", "User", "mcp.json");
128
137
  }
138
+ const xdg = process.env.XDG_CONFIG_HOME || path.join(home, ".config");
139
+ return path.join(xdg, "Code", "User", "mcp.json");
140
+ }
141
+
142
+ /** The open-memex server entry, same shape init writes at project level. */
143
+ function stdioServerEntry(client: string): { entry: Record<string, unknown>; durable: boolean } {
129
144
  // D17: resolve the server command at init time — a one-shot npx leaves no bin behind.
130
145
  const mc = resolveMcpCommand();
131
- section["open-memex"] =
146
+ const entry: Record<string, unknown> =
132
147
  client === "cursor"
133
148
  ? { command: mc.command, args: mc.args }
134
149
  : {
135
150
  type: "stdio",
136
151
  command: mc.command,
137
152
  args: mc.args,
153
+ // VS Code substitutes ${workspaceFolder} per window, so the server
154
+ // starts with the open project as cwd and scope resolution just works.
138
155
  cwd: "${workspaceFolder}",
139
156
  };
157
+ return { entry, durable: mc.durable };
158
+ }
159
+
160
+ /** Warn when init had to fall back to an npx-based server command. */
161
+ function warnNonDurable(durable: boolean): void {
162
+ if (durable) return;
163
+ console.log(` ! no durable \`open-memex\` on PATH (one-shot npx?) — wrote an npx-based command.`);
164
+ console.log(` For faster startup: \`npm i -g ${ALPHA_TAG}\`, then re-run \`open-memex init --force\`.`);
165
+ }
140
166
 
141
- fs.mkdirSync(dir, { recursive: true });
167
+ /**
168
+ * Pure merge of one server entry into a parsed config doc.
169
+ * Returns "added" when the entry was written, "kept" when an entry already
170
+ * existed and force was not set. Mutates `doc`.
171
+ */
172
+ export function mergeServerEntry(
173
+ doc: Record<string, unknown>,
174
+ sectionKey: string,
175
+ entry: Record<string, unknown>,
176
+ force: boolean,
177
+ ): "added" | "kept" {
178
+ const section = ((doc[sectionKey] ??= {}) as Record<string, unknown>);
179
+ if (section["open-memex"] && !force) return "kept";
180
+ section["open-memex"] = entry;
181
+ return "added";
182
+ }
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
+ * Read (or create) a JSON config file, merge the open-memex server entry, write
197
+ * it back. Existing files are merged, never clobbered; invalid JSON is left
198
+ * untouched. `mkdir` controls whether parent dirs are created (user-level
199
+ * configs) or expected to exist via the project root.
200
+ */
201
+ function writeServerEntryFile(
202
+ file: string,
203
+ sectionKey: string,
204
+ entry: Record<string, unknown>,
205
+ force: boolean,
206
+ mkdir: boolean,
207
+ ): string | null {
208
+ let doc: Record<string, unknown> = {};
209
+ if (fs.existsSync(file)) {
210
+ try {
211
+ doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
212
+ } catch {
213
+ console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
214
+ printManualEntryHint(sectionKey, entry);
215
+ return null;
216
+ }
217
+ }
218
+ const merged = mergeServerEntry(doc, sectionKey, entry, force);
219
+ if (merged === "kept") {
220
+ console.log(` = ${file} already configures open-memex — left as is (use --force to overwrite)`);
221
+ return file;
222
+ }
223
+ if (mkdir) fs.mkdirSync(path.dirname(file), { recursive: true });
142
224
  fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
143
225
  console.log(` + ${file}`);
144
- if (!mc.durable) {
145
- console.log(` ! no durable \`open-memex\` on PATH (one-shot npx?) — wrote an npx-based command.`);
146
- console.log(` For faster startup: \`npm i -g ${ALPHA_TAG}\`, then re-run \`open-memex init --force\`.`);
226
+ return file;
227
+ }
228
+
229
+ /** D45: `init --global` — one-time user-level wiring for VS Code / Cursor. */
230
+ function writeGlobalMcpJson(client: "vscode" | "cursor", force: boolean): string | null {
231
+ const file = userMcpConfigPath(client);
232
+ const sectionKey = client === "cursor" ? "mcpServers" : "servers";
233
+ const { entry, durable } = stdioServerEntry(client);
234
+ const written = writeServerEntryFile(file, sectionKey, entry, force, true);
235
+ if (written) {
236
+ warnNonDurable(durable);
237
+ const projFile = client === "cursor" ? ".cursor/mcp.json" : ".vscode/mcp.json";
238
+ console.log(` i user-level config — the open-memex MCP server now starts in every project.`);
239
+ console.log(` (a per-project ${projFile} still wins if a project defines its own)`);
240
+ }
241
+ return written;
242
+ }
243
+
244
+ /** Where this installed copy's opencode native plugin entry point lives. */
245
+ function opencodePluginUrl(): string {
246
+ // init.ts sits in <pkg>/src — the plugin entry is <pkg>/src/index.ts.
247
+ const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
248
+ return pathToFileURL(path.join(pkgRoot, "src", "index.ts")).href;
249
+ }
250
+
251
+ /**
252
+ * D46: opencode's user-level config. opencode reads `~/.config/opencode/`
253
+ * (`$XDG_CONFIG_HOME` when set) on every platform; a `plugin` entry there
254
+ * loads open-memex in every project with no per-project init.
255
+ */
256
+ export function opencodeGlobalConfigPath(home: string = os.homedir()): string {
257
+ return path.join(opencodeConfigDir(home, process.env.XDG_CONFIG_HOME), "opencode.json");
258
+ }
259
+
260
+ /**
261
+ * Pure merge of the open-memex plugin URL into a parsed opencode config doc.
262
+ * Creates the `plugin` array when missing; never duplicates the URL.
263
+ * Returns "added" when the doc changed, "kept" when already present.
264
+ */
265
+ export function mergePluginEntry(
266
+ doc: Record<string, unknown>,
267
+ url: string,
268
+ force: boolean,
269
+ ): "added" | "kept" {
270
+ let plugins = doc["plugin"];
271
+ if (!Array.isArray(plugins)) {
272
+ plugins = [] as unknown[];
273
+ doc["plugin"] = plugins;
147
274
  }
275
+ const list = plugins as unknown[];
276
+ if (list.includes(url) && !force) return "kept";
277
+ if (!list.includes(url)) list.push(url);
278
+ return "added";
279
+ }
280
+
281
+ /**
282
+ * D46: wire the native plugin at the opencode user level — the one-time
283
+ * global setup. Merges into the existing config when it parses as JSON;
284
+ * a file with comments (JSONC) is left untouched with a manual hint instead
285
+ * of being clobbered.
286
+ */
287
+ function writeOpencodeGlobalPlugin(force: boolean): string | null {
288
+ const dir = path.dirname(opencodeGlobalConfigPath());
289
+ const file =
290
+ ["opencode.jsonc", "opencode.json"]
291
+ .map((f) => path.join(dir, f))
292
+ .find((f) => fs.existsSync(f)) ?? path.join(dir, "opencode.json");
293
+ const url = opencodePluginUrl();
294
+ let doc: Record<string, unknown> = {};
295
+ if (fs.existsSync(file)) {
296
+ try {
297
+ doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
298
+ } catch {
299
+ console.log(` ! ${file} has comments or invalid JSON — left untouched, fix it manually.`);
300
+ console.log(` To enable open-memex everywhere, add "plugin": ["${url}"] to it.`);
301
+ return null;
302
+ }
303
+ }
304
+ if (mergePluginEntry(doc, url, force) === "kept") {
305
+ console.log(` = ${file} already loads the open-memex plugin — left as is (use --force to overwrite)`);
306
+ return file;
307
+ }
308
+ fs.mkdirSync(path.dirname(file), { recursive: true });
309
+ fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
310
+ console.log(` + ${file} (native plugin — works in every project, no per-project init needed)`);
148
311
  return file;
149
312
  }
150
313
 
@@ -157,6 +320,12 @@ function writeOpencodeMcpJson(root: string, force: boolean): string | null {
157
320
  doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
158
321
  } catch {
159
322
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
323
+ const mc = resolveMcpCommand();
324
+ printManualEntryHint("mcp", {
325
+ type: "local",
326
+ command: [mc.command, ...mc.args],
327
+ enabled: true,
328
+ });
160
329
  return null;
161
330
  }
162
331
  }
@@ -192,6 +361,12 @@ function writeVisualStudioMcpJson(root: string, force: boolean): string | null {
192
361
  doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
193
362
  } catch {
194
363
  console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
364
+ const mc = resolveMcpCommand();
365
+ printManualEntryHint("servers", {
366
+ type: "stdio",
367
+ command: mc.command,
368
+ args: mc.args,
369
+ });
195
370
  return null;
196
371
  }
197
372
  }
@@ -252,6 +427,7 @@ function writeInstructions(
252
427
  }
253
428
 
254
429
  export const INIT_CLIENTS = ["vscode", "cursor", "opencode", "visualstudio"] as const;
430
+ export type InitClient = (typeof INIT_CLIENTS)[number];
255
431
 
256
432
  /** Normalize --client values; accepts "visual-studio" as an alias. */
257
433
  export function normalizeClient(c: string): string {
@@ -316,20 +492,152 @@ async function promptInstructionsScope(): Promise<"personal" | "project"> {
316
492
  }
317
493
  }
318
494
 
495
+ /** D45: ask whether the MCP server config should be project-level or user-level. */
496
+ async function promptConfigLevel(): Promise<boolean> {
497
+ console.log("Where should the MCP server config live?");
498
+ console.log(" 1) this project only — .vscode/mcp.json (or .cursor/mcp.json)");
499
+ console.log(" 2) user-level — all projects, init once (VS Code / Cursor)");
500
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
501
+ try {
502
+ const ans = (await rl.question("Choice [1]: ")).trim();
503
+ return ans === "2";
504
+ } finally {
505
+ rl.close();
506
+ }
507
+ }
508
+
509
+ /** D46: injectable environment for editor detection (tests pass fakes). */
510
+ export interface DetectEnv {
511
+ pathEnv?: string;
512
+ home?: string;
513
+ platform?: NodeJS.Platform;
514
+ root?: string;
515
+ /** Overrides $XDG_CONFIG_HOME for the opencode global config dir. */
516
+ xdgConfigHome?: string;
517
+ }
518
+
519
+ function exists(p: string): boolean {
520
+ try {
521
+ return fs.existsSync(p);
522
+ } catch {
523
+ return false;
524
+ }
525
+ }
526
+
527
+ /** True when `name` resolves on PATH (honors .cmd/.exe on Windows). */
528
+ function binOnPath(name: string, pathEnv: string, platform: NodeJS.Platform): boolean {
529
+ const names = platform === "win32" ? [`${name}.cmd`, `${name}.exe`, name] : [name];
530
+ return pathEnv
531
+ .split(path.delimiter)
532
+ .filter((d) => d && !d.includes("_npx"))
533
+ .some((d) => names.some((n) => exists(path.join(d, n))));
534
+ }
535
+
536
+ function opencodeConfigDir(home: string, xdg: string | undefined): string {
537
+ return path.join(xdg || path.join(home, ".config"), "opencode");
538
+ }
539
+
540
+ /**
541
+ * D46: detect which supported editors are installed. Used when `init` runs
542
+ * without --client — one init wires every detected editor (user-level where
543
+ * the editor supports it). Visual Studio is included only when the project
544
+ * has a solution file, since VS config is solution-scoped by design.
545
+ */
546
+ export function detectInstalledClients(env: DetectEnv = {}): InitClient[] {
547
+ const platform = env.platform ?? process.platform;
548
+ const pathEnv = env.pathEnv ?? process.env.PATH ?? "";
549
+ const home = env.home ?? os.homedir();
550
+ const found: InitClient[] = [];
551
+ // VS Code: bin on PATH, well-known install location, or an existing
552
+ // user-level MCP config (a previous init counts as installed).
553
+ const vscodeInstallPaths =
554
+ platform === "win32"
555
+ ? [path.join(home, "AppData", "Local", "Programs", "Microsoft VS Code", "Code.exe")]
556
+ : platform === "darwin"
557
+ ? ["/Applications/Visual Studio Code.app"]
558
+ : ["/usr/bin/code", "/usr/share/code/bin/code", "/snap/bin/code"];
559
+ if (
560
+ binOnPath("code", pathEnv, platform) ||
561
+ vscodeInstallPaths.some(exists) ||
562
+ exists(userMcpConfigPath("vscode", platform, home))
563
+ ) {
564
+ found.push("vscode");
565
+ }
566
+ // Cursor: bin on PATH or its user config dir exists.
567
+ if (binOnPath("cursor", pathEnv, platform) || exists(path.join(home, ".cursor"))) {
568
+ found.push("cursor");
569
+ }
570
+ // opencode: bin on PATH or its global config dir exists.
571
+ const xdg = env.xdgConfigHome ?? process.env.XDG_CONFIG_HOME;
572
+ if (binOnPath("opencode", pathEnv, platform) || exists(opencodeConfigDir(home, xdg))) {
573
+ found.push("opencode");
574
+ }
575
+ // Visual Studio: solution-scoped — only when the project has a .sln.
576
+ let hasSln = false;
577
+ try {
578
+ hasSln = fs.readdirSync(env.root ?? projectRoot()).some((f) => f.toLowerCase().endsWith(".sln"));
579
+ } catch {
580
+ hasSln = false;
581
+ }
582
+ if (hasSln) found.push("visualstudio");
583
+ return found;
584
+ }
585
+
319
586
  export async function initProject(opts: {
320
587
  client?: string;
321
588
  force: boolean;
322
589
  yes: boolean;
323
590
  instructions?: string;
591
+ /** D45: write the MCP server entry to the editor's user-level config. */
592
+ global?: boolean;
324
593
  }): Promise<void> {
325
594
  const interactive = !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
326
- let client = normalizeClient(opts.client ?? "");
327
- if (client && !(INIT_CLIENTS as readonly string[]).includes(client)) {
595
+ const explicit = normalizeClient(opts.client ?? "");
596
+ if (explicit && !(INIT_CLIENTS as readonly string[]).includes(explicit)) {
328
597
  console.error(`unknown client "${opts.client}" (${INIT_CLIENTS.join("|")})`);
329
598
  process.exit(1);
330
599
  }
331
- if (!client && interactive) client = (await promptClient()) ?? "";
332
- if (!client && !interactive) client = "vscode"; // historical default for scripts / one-shot npx
600
+ // D46: no --client → auto-detect installed editors and wire them all
601
+ // (user-level where the editor supports it — init once). An explicit
602
+ // --client keeps the old single-editor behavior.
603
+ let clients: InitClient[];
604
+ let autoGlobal = false;
605
+ if (explicit) {
606
+ clients = [explicit as InitClient];
607
+ } else if (interactive) {
608
+ const detected = detectInstalledClients();
609
+ if (detected.length === 0) {
610
+ console.log(" - no supported editors detected — editor setup skipped");
611
+ clients = [];
612
+ } else {
613
+ console.log(`Detected editors: ${detected.join(", ")}`);
614
+ const all = await askBool(
615
+ "Wire up open-memex in all of them (one-time, user-level where supported)?",
616
+ true,
617
+ );
618
+ if (all) {
619
+ clients = detected;
620
+ autoGlobal = true;
621
+ } else {
622
+ const one = await promptClient();
623
+ clients = one ? [one as InitClient] : [];
624
+ }
625
+ }
626
+ } else {
627
+ clients = detectInstalledClients();
628
+ autoGlobal = true;
629
+ if (clients.length === 0) {
630
+ console.log(" - no supported editors detected — editor setup skipped");
631
+ } else {
632
+ console.log(`Detected editors: ${clients.join(", ")} — wiring all (use --client to pick one)`);
633
+ }
634
+ }
635
+ let global = !!opts.global || autoGlobal;
636
+ // The project-vs-user-level choice only applies to an explicit single
637
+ // client; auto mode is user-level by design (init once).
638
+ if (explicit && !global && interactive && (clients[0] === "vscode" || clients[0] === "cursor")) {
639
+ global = await promptConfigLevel();
640
+ }
333
641
  let scope: "personal" | "project" = "personal";
334
642
  if (opts.instructions) {
335
643
  if (opts.instructions !== "personal" && opts.instructions !== "project") {
@@ -365,15 +673,224 @@ export async function initProject(opts: {
365
673
  }
366
674
  const root = projectRoot();
367
675
  console.log(`open-memex init — project root: ${root}`);
368
- if (client) {
369
- writeMcpJson(root, client, opts.force);
676
+ for (const client of clients) {
677
+ if (client === "vscode" || client === "cursor") {
678
+ if (global) writeGlobalMcpJson(client, opts.force);
679
+ else writeMcpJson(root, client, opts.force);
680
+ } else if (client === "opencode") {
681
+ // D46: the native plugin is the one-time global setup; without --global
682
+ // (explicit single-client mode) keep the per-project plain-MCP config.
683
+ if (global) writeOpencodeGlobalPlugin(opts.force);
684
+ else writeMcpJson(root, client, opts.force);
685
+ } else if (client === "visualstudio") {
686
+ if (explicit && global) {
687
+ console.log(` - visualstudio: --global not supported — VS uses solution-level .mcp.json by design.`);
688
+ } else {
689
+ writeMcpJson(root, client, opts.force);
690
+ }
691
+ }
692
+ }
693
+ if (clients.length === 0) {
694
+ console.log(" - editor setup skipped");
695
+ } else if (clients.some((c) => c !== "opencode")) {
370
696
  // copilot-instructions.md is VS Code/Cursor-shaped; opencode as a plain MCP
371
697
  // consumer already gets the guidance from the tool descriptions (D16).
372
698
  // D22: personal scope (default) writes to the Copilot user-level location
373
699
  // so the repo stays clean for teammates without open-memex.
374
- if (client !== "opencode") writeInstructions(root, scope, client);
375
- } else {
376
- console.log(" - editor setup skipped");
700
+ writeInstructions(root, scope, clients.find((c) => c !== "opencode")!);
377
701
  }
378
702
  console.log(`\nDone. Reload your editor window to start the open-memex MCP server.`);
379
703
  }
704
+
705
+ // ---------------------------------------------------------------------------
706
+ // uninstall — the reverse of init (D48). Removes the editor wiring init wrote:
707
+ // the MCP server entry, the opencode native plugin line, and the Copilot
708
+ // instructions section. Memory data is never touched.
709
+ // ---------------------------------------------------------------------------
710
+
711
+ /**
712
+ * Pure removal of the open-memex server entry from a parsed config doc.
713
+ * Prunes the section when it becomes empty. Mutates `doc`.
714
+ */
715
+ export function removeServerEntry(
716
+ doc: Record<string, unknown>,
717
+ sectionKey: string,
718
+ ): "removed" | "absent" {
719
+ const section = doc[sectionKey];
720
+ if (!section || typeof section !== "object" || !("open-memex" in section)) return "absent";
721
+ delete (section as Record<string, unknown>)["open-memex"];
722
+ if (Object.keys(section as Record<string, unknown>).length === 0) delete doc[sectionKey];
723
+ return "removed";
724
+ }
725
+
726
+ /**
727
+ * Pure removal of open-memex plugin entries from a parsed opencode config doc.
728
+ * Matches any plugin URL containing `match` (robust against the package moving
729
+ * between installs). Prunes the `plugin` array when it becomes empty.
730
+ * Mutates `doc`.
731
+ */
732
+ export function removePluginEntry(doc: Record<string, unknown>, match: string): "removed" | "absent" {
733
+ const plugins = doc["plugin"];
734
+ if (!Array.isArray(plugins)) return "absent";
735
+ const kept = (plugins as unknown[]).filter(
736
+ (p) => !(typeof p === "string" && p.includes(match)),
737
+ );
738
+ if (kept.length === plugins.length) return "absent";
739
+ if (kept.length === 0) delete doc["plugin"];
740
+ else doc["plugin"] = kept;
741
+ return "removed";
742
+ }
743
+
744
+ /**
745
+ * Pure removal of the open-memex instructions section from a
746
+ * copilot-instructions.md body. init always appends it last (MARKER..end),
747
+ * so cutting from the marker to the end is exact; returns "" when nothing
748
+ * but the section remains so the caller can delete the file.
749
+ */
750
+ export function removeInstructionsSection(text: string): string {
751
+ const idx = text.lastIndexOf(MARKER);
752
+ if (idx < 0) return text;
753
+ const rest = text.slice(0, idx).replace(/\s+$/, "");
754
+ return rest ? rest + "\n" : "";
755
+ }
756
+
757
+ /**
758
+ * Remove the server entry from one JSON config file. Existing files are
759
+ * merged, never clobbered; invalid JSON is left untouched with the manual
760
+ * step (same D47 treatment as the init write path).
761
+ */
762
+ function removeServerEntryFile(file: string, sectionKey: string): "removed" | "absent" | null {
763
+ if (!fs.existsSync(file)) return "absent";
764
+ let doc: Record<string, unknown>;
765
+ try {
766
+ doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
767
+ } catch {
768
+ console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
769
+ console.error(` Delete the "open-memex" key under "${sectionKey}".`);
770
+ return null;
771
+ }
772
+ const res = removeServerEntry(doc, sectionKey);
773
+ if (res === "removed") {
774
+ fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
775
+ console.log(` - ${file} (open-memex entry removed)`);
776
+ }
777
+ return res;
778
+ }
779
+
780
+ /** Remove the open-memex plugin URL from one opencode user-level config file. */
781
+ function removeOpencodePluginFile(file: string): "removed" | "absent" | null {
782
+ if (!fs.existsSync(file)) return "absent";
783
+ let doc: Record<string, unknown>;
784
+ try {
785
+ doc = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
786
+ } catch {
787
+ console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
788
+ console.error(` Delete the open-memex URL from the "plugin" array.`);
789
+ return null;
790
+ }
791
+ const res = removePluginEntry(doc, "open-memex");
792
+ if (res === "removed") {
793
+ fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
794
+ console.log(` - ${file} (open-memex plugin removed)`);
795
+ }
796
+ return res;
797
+ }
798
+
799
+ /** Remove the open-memex section from one copilot-instructions.md file. */
800
+ function removeInstructionsFile(file: string): "removed" | "absent" {
801
+ if (!fs.existsSync(file)) return "absent";
802
+ const cur = fs.readFileSync(file, "utf8");
803
+ if (!cur.includes(MARKER)) return "absent";
804
+ const next = removeInstructionsSection(cur);
805
+ if (next === "") {
806
+ fs.unlinkSync(file);
807
+ console.log(` - ${file} (deleted — it only held open-memex instructions)`);
808
+ } else {
809
+ fs.writeFileSync(file, next);
810
+ console.log(` - ${file} (open-memex instructions removed)`);
811
+ }
812
+ return "removed";
813
+ }
814
+
815
+ export async function uninstallProject(opts: {
816
+ client?: string;
817
+ /** D48: only the user-level config; without it, both levels are cleaned. */
818
+ global?: boolean;
819
+ yes: boolean;
820
+ }): Promise<void> {
821
+ const interactive = !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
822
+ const explicit = normalizeClient(opts.client ?? "");
823
+ if (explicit && !(INIT_CLIENTS as readonly string[]).includes(explicit)) {
824
+ console.error(`unknown client "${opts.client}" (${INIT_CLIENTS.join("|")})`);
825
+ process.exit(1);
826
+ }
827
+ // Mirror init's client resolution: explicit --client, else auto-detect.
828
+ let clients: InitClient[];
829
+ if (explicit) {
830
+ clients = [explicit as InitClient];
831
+ } else if (interactive) {
832
+ const detected = detectInstalledClients();
833
+ if (detected.length === 0) {
834
+ console.log(" - no supported editors detected — nothing to remove");
835
+ return;
836
+ }
837
+ console.log(`Detected editors: ${detected.join(", ")}`);
838
+ const all = await askBool("Remove open-memex wiring from all of them?", true);
839
+ if (all) {
840
+ clients = detected;
841
+ } else {
842
+ const one = await promptClient();
843
+ clients = one ? [one as InitClient] : [];
844
+ }
845
+ } else {
846
+ clients = detectInstalledClients();
847
+ if (clients.length > 0) {
848
+ console.log(`Detected editors: ${clients.join(", ")} — removing from all (use --client to pick one)`);
849
+ }
850
+ }
851
+ if (clients.length === 0) {
852
+ console.log(" - nothing to remove");
853
+ return;
854
+ }
855
+ // Without --global, clean both levels: init may have written either one,
856
+ // and a leftover entry at the other level would be a surprise.
857
+ const levels = opts.global ? ["user"] : ["project", "user"];
858
+ const root = projectRoot();
859
+ console.log(`open-memex uninstall — project root: ${root}`);
860
+ let changed = 0;
861
+ const bump = (r: "removed" | "absent" | null) => {
862
+ if (r === "removed") changed++;
863
+ };
864
+ for (const client of clients) {
865
+ if (client === "vscode" || client === "cursor") {
866
+ const sectionKey = client === "cursor" ? "mcpServers" : "servers";
867
+ for (const level of levels) {
868
+ const file =
869
+ level === "user"
870
+ ? userMcpConfigPath(client)
871
+ : path.join(root, client === "cursor" ? ".cursor/mcp.json" : ".vscode/mcp.json");
872
+ bump(removeServerEntryFile(file, sectionKey));
873
+ }
874
+ } else if (client === "opencode") {
875
+ for (const level of levels) {
876
+ if (level === "user") bump(removeOpencodePluginFile(opencodeGlobalConfigPath()));
877
+ else bump(removeServerEntryFile(path.join(root, "opencode.jsonc"), "mcp"));
878
+ }
879
+ } else if (client === "visualstudio") {
880
+ // Solution-level only — --global is meaningless, same as init.
881
+ bump(removeServerEntryFile(path.join(root, ".mcp.json"), "servers"));
882
+ }
883
+ }
884
+ // Copilot instructions: init may have written personal (default) or project.
885
+ if (clients.some((c) => c !== "opencode")) {
886
+ bump(removeInstructionsFile(path.join(os.homedir(), ".copilot", "copilot-instructions.md")));
887
+ bump(removeInstructionsFile(path.join(os.homedir(), "copilot-instructions.md")));
888
+ bump(removeInstructionsFile(path.join(root, ".github", "copilot-instructions.md")));
889
+ }
890
+ console.log(
891
+ changed > 0
892
+ ? `\nDone. Removed open-memex wiring from ${changed} file(s).`
893
+ : "\nDone. Nothing to remove — no open-memex wiring found.",
894
+ );
895
+ console.log("Your memories are untouched (uninstall never deletes data).");
896
+ }