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/AGENTS.md +13 -7
- package/CONTRIBUTING.md +10 -10
- package/README.md +131 -16
- package/README.zh-CN.md +117 -15
- package/dist/cli.js +48 -9
- package/dist/init.js +515 -29
- package/docs/TEST-PLAN.md +2 -0
- package/docs/V2-DESIGN.md +61 -0
- package/package.json +1 -1
- package/scripts/smoke-pure.ts +104 -0
- package/src/cli.ts +50 -9
- package/src/init.ts +544 -27
package/dist/init.js
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.js";
|
|
8
9
|
import { projectRoot } from "./paths.js";
|
|
9
10
|
const MARKER = "<!-- open-memex -->";
|
|
@@ -100,6 +101,82 @@ function writeMcpJson(root, client, force) {
|
|
|
100
101
|
const dir = client === "cursor" ? path.join(root, ".cursor") : path.join(root, ".vscode");
|
|
101
102
|
const file = path.join(dir, "mcp.json");
|
|
102
103
|
const sectionKey = client === "cursor" ? "mcpServers" : "servers";
|
|
104
|
+
const { entry, durable } = stdioServerEntry(client);
|
|
105
|
+
const written = writeServerEntryFile(file, sectionKey, entry, force, true);
|
|
106
|
+
if (written)
|
|
107
|
+
warnNonDurable(durable);
|
|
108
|
+
return written;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* D45: user-level MCP config path — `init --global` writes here so one init
|
|
112
|
+
* covers all projects. `platform` and `home` are parameters (default: current)
|
|
113
|
+
* so tests can cover all OS layouts without mocking.
|
|
114
|
+
*/
|
|
115
|
+
export function userMcpConfigPath(client, platform = process.platform, home = os.homedir()) {
|
|
116
|
+
if (client === "cursor")
|
|
117
|
+
return path.join(home, ".cursor", "mcp.json");
|
|
118
|
+
if (platform === "win32") {
|
|
119
|
+
const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
|
|
120
|
+
return path.join(appData, "Code", "User", "mcp.json");
|
|
121
|
+
}
|
|
122
|
+
if (platform === "darwin") {
|
|
123
|
+
return path.join(home, "Library", "Application Support", "Code", "User", "mcp.json");
|
|
124
|
+
}
|
|
125
|
+
const xdg = process.env.XDG_CONFIG_HOME || path.join(home, ".config");
|
|
126
|
+
return path.join(xdg, "Code", "User", "mcp.json");
|
|
127
|
+
}
|
|
128
|
+
/** The open-memex server entry, same shape init writes at project level. */
|
|
129
|
+
function stdioServerEntry(client) {
|
|
130
|
+
// D17: resolve the server command at init time — a one-shot npx leaves no bin behind.
|
|
131
|
+
const mc = resolveMcpCommand();
|
|
132
|
+
const entry = client === "cursor"
|
|
133
|
+
? { command: mc.command, args: mc.args }
|
|
134
|
+
: {
|
|
135
|
+
type: "stdio",
|
|
136
|
+
command: mc.command,
|
|
137
|
+
args: mc.args,
|
|
138
|
+
// VS Code substitutes ${workspaceFolder} per window, so the server
|
|
139
|
+
// starts with the open project as cwd and scope resolution just works.
|
|
140
|
+
cwd: "${workspaceFolder}",
|
|
141
|
+
};
|
|
142
|
+
return { entry, durable: mc.durable };
|
|
143
|
+
}
|
|
144
|
+
/** Warn when init had to fall back to an npx-based server command. */
|
|
145
|
+
function warnNonDurable(durable) {
|
|
146
|
+
if (durable)
|
|
147
|
+
return;
|
|
148
|
+
console.log(` ! no durable \`open-memex\` on PATH (one-shot npx?) — wrote an npx-based command.`);
|
|
149
|
+
console.log(` For faster startup: \`npm i -g ${ALPHA_TAG}\`, then re-run \`open-memex init --force\`.`);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Pure merge of one server entry into a parsed config doc.
|
|
153
|
+
* Returns "added" when the entry was written, "kept" when an entry already
|
|
154
|
+
* existed and force was not set. Mutates `doc`.
|
|
155
|
+
*/
|
|
156
|
+
export function mergeServerEntry(doc, sectionKey, entry, force) {
|
|
157
|
+
const section = (doc[sectionKey] ??= {});
|
|
158
|
+
if (section["open-memex"] && !force)
|
|
159
|
+
return "kept";
|
|
160
|
+
section["open-memex"] = entry;
|
|
161
|
+
return "added";
|
|
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
|
+
* Read (or create) a JSON config file, merge the open-memex server entry, write
|
|
175
|
+
* it back. Existing files are merged, never clobbered; invalid JSON is left
|
|
176
|
+
* untouched. `mkdir` controls whether parent dirs are created (user-level
|
|
177
|
+
* configs) or expected to exist via the project root.
|
|
178
|
+
*/
|
|
179
|
+
function writeServerEntryFile(file, sectionKey, entry, force, mkdir) {
|
|
103
180
|
let doc = {};
|
|
104
181
|
if (fs.existsSync(file)) {
|
|
105
182
|
try {
|
|
@@ -107,32 +184,97 @@ function writeMcpJson(root, client, force) {
|
|
|
107
184
|
}
|
|
108
185
|
catch {
|
|
109
186
|
console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
|
|
187
|
+
printManualEntryHint(sectionKey, entry);
|
|
110
188
|
return null;
|
|
111
189
|
}
|
|
112
190
|
}
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
191
|
+
const merged = mergeServerEntry(doc, sectionKey, entry, force);
|
|
192
|
+
if (merged === "kept") {
|
|
115
193
|
console.log(` = ${file} already configures open-memex — left as is (use --force to overwrite)`);
|
|
116
194
|
return file;
|
|
117
195
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
section["open-memex"] =
|
|
121
|
-
client === "cursor"
|
|
122
|
-
? { command: mc.command, args: mc.args }
|
|
123
|
-
: {
|
|
124
|
-
type: "stdio",
|
|
125
|
-
command: mc.command,
|
|
126
|
-
args: mc.args,
|
|
127
|
-
cwd: "${workspaceFolder}",
|
|
128
|
-
};
|
|
129
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
196
|
+
if (mkdir)
|
|
197
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
130
198
|
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
|
|
131
199
|
console.log(` + ${file}`);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
200
|
+
return file;
|
|
201
|
+
}
|
|
202
|
+
/** D45: `init --global` — one-time user-level wiring for VS Code / Cursor. */
|
|
203
|
+
function writeGlobalMcpJson(client, force) {
|
|
204
|
+
const file = userMcpConfigPath(client);
|
|
205
|
+
const sectionKey = client === "cursor" ? "mcpServers" : "servers";
|
|
206
|
+
const { entry, durable } = stdioServerEntry(client);
|
|
207
|
+
const written = writeServerEntryFile(file, sectionKey, entry, force, true);
|
|
208
|
+
if (written) {
|
|
209
|
+
warnNonDurable(durable);
|
|
210
|
+
const projFile = client === "cursor" ? ".cursor/mcp.json" : ".vscode/mcp.json";
|
|
211
|
+
console.log(` i user-level config — the open-memex MCP server now starts in every project.`);
|
|
212
|
+
console.log(` (a per-project ${projFile} still wins if a project defines its own)`);
|
|
213
|
+
}
|
|
214
|
+
return written;
|
|
215
|
+
}
|
|
216
|
+
/** Where this installed copy's opencode native plugin entry point lives. */
|
|
217
|
+
function opencodePluginUrl() {
|
|
218
|
+
// init.ts sits in <pkg>/src — the plugin entry is <pkg>/src/index.ts.
|
|
219
|
+
const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
220
|
+
return pathToFileURL(path.join(pkgRoot, "src", "index.ts")).href;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* D46: opencode's user-level config. opencode reads `~/.config/opencode/`
|
|
224
|
+
* (`$XDG_CONFIG_HOME` when set) on every platform; a `plugin` entry there
|
|
225
|
+
* loads open-memex in every project with no per-project init.
|
|
226
|
+
*/
|
|
227
|
+
export function opencodeGlobalConfigPath(home = os.homedir()) {
|
|
228
|
+
return path.join(opencodeConfigDir(home, process.env.XDG_CONFIG_HOME), "opencode.json");
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Pure merge of the open-memex plugin URL into a parsed opencode config doc.
|
|
232
|
+
* Creates the `plugin` array when missing; never duplicates the URL.
|
|
233
|
+
* Returns "added" when the doc changed, "kept" when already present.
|
|
234
|
+
*/
|
|
235
|
+
export function mergePluginEntry(doc, url, force) {
|
|
236
|
+
let plugins = doc["plugin"];
|
|
237
|
+
if (!Array.isArray(plugins)) {
|
|
238
|
+
plugins = [];
|
|
239
|
+
doc["plugin"] = plugins;
|
|
240
|
+
}
|
|
241
|
+
const list = plugins;
|
|
242
|
+
if (list.includes(url) && !force)
|
|
243
|
+
return "kept";
|
|
244
|
+
if (!list.includes(url))
|
|
245
|
+
list.push(url);
|
|
246
|
+
return "added";
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* D46: wire the native plugin at the opencode user level — the one-time
|
|
250
|
+
* global setup. Merges into the existing config when it parses as JSON;
|
|
251
|
+
* a file with comments (JSONC) is left untouched with a manual hint instead
|
|
252
|
+
* of being clobbered.
|
|
253
|
+
*/
|
|
254
|
+
function writeOpencodeGlobalPlugin(force) {
|
|
255
|
+
const dir = path.dirname(opencodeGlobalConfigPath());
|
|
256
|
+
const file = ["opencode.jsonc", "opencode.json"]
|
|
257
|
+
.map((f) => path.join(dir, f))
|
|
258
|
+
.find((f) => fs.existsSync(f)) ?? path.join(dir, "opencode.json");
|
|
259
|
+
const url = opencodePluginUrl();
|
|
260
|
+
let doc = {};
|
|
261
|
+
if (fs.existsSync(file)) {
|
|
262
|
+
try {
|
|
263
|
+
doc = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
console.log(` ! ${file} has comments or invalid JSON — left untouched, fix it manually.`);
|
|
267
|
+
console.log(` To enable open-memex everywhere, add "plugin": ["${url}"] to it.`);
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (mergePluginEntry(doc, url, force) === "kept") {
|
|
272
|
+
console.log(` = ${file} already loads the open-memex plugin — left as is (use --force to overwrite)`);
|
|
273
|
+
return file;
|
|
135
274
|
}
|
|
275
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
276
|
+
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
|
|
277
|
+
console.log(` + ${file} (native plugin — works in every project, no per-project init needed)`);
|
|
136
278
|
return file;
|
|
137
279
|
}
|
|
138
280
|
/** opencode MCP config: project-level opencode.jsonc, `type: "local"` + command array (v1 format). */
|
|
@@ -145,6 +287,12 @@ function writeOpencodeMcpJson(root, force) {
|
|
|
145
287
|
}
|
|
146
288
|
catch {
|
|
147
289
|
console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
|
|
290
|
+
const mc = resolveMcpCommand();
|
|
291
|
+
printManualEntryHint("mcp", {
|
|
292
|
+
type: "local",
|
|
293
|
+
command: [mc.command, ...mc.args],
|
|
294
|
+
enabled: true,
|
|
295
|
+
});
|
|
148
296
|
return null;
|
|
149
297
|
}
|
|
150
298
|
}
|
|
@@ -180,6 +328,12 @@ function writeVisualStudioMcpJson(root, force) {
|
|
|
180
328
|
}
|
|
181
329
|
catch {
|
|
182
330
|
console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
|
|
331
|
+
const mc = resolveMcpCommand();
|
|
332
|
+
printManualEntryHint("servers", {
|
|
333
|
+
type: "stdio",
|
|
334
|
+
command: mc.command,
|
|
335
|
+
args: mc.args,
|
|
336
|
+
});
|
|
183
337
|
return null;
|
|
184
338
|
}
|
|
185
339
|
}
|
|
@@ -293,17 +447,133 @@ async function promptInstructionsScope() {
|
|
|
293
447
|
rl.close();
|
|
294
448
|
}
|
|
295
449
|
}
|
|
450
|
+
/** D45: ask whether the MCP server config should be project-level or user-level. */
|
|
451
|
+
async function promptConfigLevel() {
|
|
452
|
+
console.log("Where should the MCP server config live?");
|
|
453
|
+
console.log(" 1) this project only — .vscode/mcp.json (or .cursor/mcp.json)");
|
|
454
|
+
console.log(" 2) user-level — all projects, init once (VS Code / Cursor)");
|
|
455
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
456
|
+
try {
|
|
457
|
+
const ans = (await rl.question("Choice [1]: ")).trim();
|
|
458
|
+
return ans === "2";
|
|
459
|
+
}
|
|
460
|
+
finally {
|
|
461
|
+
rl.close();
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
function exists(p) {
|
|
465
|
+
try {
|
|
466
|
+
return fs.existsSync(p);
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
/** True when `name` resolves on PATH (honors .cmd/.exe on Windows). */
|
|
473
|
+
function binOnPath(name, pathEnv, platform) {
|
|
474
|
+
const names = platform === "win32" ? [`${name}.cmd`, `${name}.exe`, name] : [name];
|
|
475
|
+
return pathEnv
|
|
476
|
+
.split(path.delimiter)
|
|
477
|
+
.filter((d) => d && !d.includes("_npx"))
|
|
478
|
+
.some((d) => names.some((n) => exists(path.join(d, n))));
|
|
479
|
+
}
|
|
480
|
+
function opencodeConfigDir(home, xdg) {
|
|
481
|
+
return path.join(xdg || path.join(home, ".config"), "opencode");
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* D46: detect which supported editors are installed. Used when `init` runs
|
|
485
|
+
* without --client — one init wires every detected editor (user-level where
|
|
486
|
+
* the editor supports it). Visual Studio is included only when the project
|
|
487
|
+
* has a solution file, since VS config is solution-scoped by design.
|
|
488
|
+
*/
|
|
489
|
+
export function detectInstalledClients(env = {}) {
|
|
490
|
+
const platform = env.platform ?? process.platform;
|
|
491
|
+
const pathEnv = env.pathEnv ?? process.env.PATH ?? "";
|
|
492
|
+
const home = env.home ?? os.homedir();
|
|
493
|
+
const found = [];
|
|
494
|
+
// VS Code: bin on PATH, well-known install location, or an existing
|
|
495
|
+
// user-level MCP config (a previous init counts as installed).
|
|
496
|
+
const vscodeInstallPaths = platform === "win32"
|
|
497
|
+
? [path.join(home, "AppData", "Local", "Programs", "Microsoft VS Code", "Code.exe")]
|
|
498
|
+
: platform === "darwin"
|
|
499
|
+
? ["/Applications/Visual Studio Code.app"]
|
|
500
|
+
: ["/usr/bin/code", "/usr/share/code/bin/code", "/snap/bin/code"];
|
|
501
|
+
if (binOnPath("code", pathEnv, platform) ||
|
|
502
|
+
vscodeInstallPaths.some(exists) ||
|
|
503
|
+
exists(userMcpConfigPath("vscode", platform, home))) {
|
|
504
|
+
found.push("vscode");
|
|
505
|
+
}
|
|
506
|
+
// Cursor: bin on PATH or its user config dir exists.
|
|
507
|
+
if (binOnPath("cursor", pathEnv, platform) || exists(path.join(home, ".cursor"))) {
|
|
508
|
+
found.push("cursor");
|
|
509
|
+
}
|
|
510
|
+
// opencode: bin on PATH or its global config dir exists.
|
|
511
|
+
const xdg = env.xdgConfigHome ?? process.env.XDG_CONFIG_HOME;
|
|
512
|
+
if (binOnPath("opencode", pathEnv, platform) || exists(opencodeConfigDir(home, xdg))) {
|
|
513
|
+
found.push("opencode");
|
|
514
|
+
}
|
|
515
|
+
// Visual Studio: solution-scoped — only when the project has a .sln.
|
|
516
|
+
let hasSln = false;
|
|
517
|
+
try {
|
|
518
|
+
hasSln = fs.readdirSync(env.root ?? projectRoot()).some((f) => f.toLowerCase().endsWith(".sln"));
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
hasSln = false;
|
|
522
|
+
}
|
|
523
|
+
if (hasSln)
|
|
524
|
+
found.push("visualstudio");
|
|
525
|
+
return found;
|
|
526
|
+
}
|
|
296
527
|
export async function initProject(opts) {
|
|
297
528
|
const interactive = !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
298
|
-
|
|
299
|
-
if (
|
|
529
|
+
const explicit = normalizeClient(opts.client ?? "");
|
|
530
|
+
if (explicit && !INIT_CLIENTS.includes(explicit)) {
|
|
300
531
|
console.error(`unknown client "${opts.client}" (${INIT_CLIENTS.join("|")})`);
|
|
301
532
|
process.exit(1);
|
|
302
533
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
534
|
+
// D46: no --client → auto-detect installed editors and wire them all
|
|
535
|
+
// (user-level where the editor supports it — init once). An explicit
|
|
536
|
+
// --client keeps the old single-editor behavior.
|
|
537
|
+
let clients;
|
|
538
|
+
let autoGlobal = false;
|
|
539
|
+
if (explicit) {
|
|
540
|
+
clients = [explicit];
|
|
541
|
+
}
|
|
542
|
+
else if (interactive) {
|
|
543
|
+
const detected = detectInstalledClients();
|
|
544
|
+
if (detected.length === 0) {
|
|
545
|
+
console.log(" - no supported editors detected — editor setup skipped");
|
|
546
|
+
clients = [];
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
console.log(`Detected editors: ${detected.join(", ")}`);
|
|
550
|
+
const all = await askBool("Wire up open-memex in all of them (one-time, user-level where supported)?", true);
|
|
551
|
+
if (all) {
|
|
552
|
+
clients = detected;
|
|
553
|
+
autoGlobal = true;
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
const one = await promptClient();
|
|
557
|
+
clients = one ? [one] : [];
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
else {
|
|
562
|
+
clients = detectInstalledClients();
|
|
563
|
+
autoGlobal = true;
|
|
564
|
+
if (clients.length === 0) {
|
|
565
|
+
console.log(" - no supported editors detected — editor setup skipped");
|
|
566
|
+
}
|
|
567
|
+
else {
|
|
568
|
+
console.log(`Detected editors: ${clients.join(", ")} — wiring all (use --client to pick one)`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
let global = !!opts.global || autoGlobal;
|
|
572
|
+
// The project-vs-user-level choice only applies to an explicit single
|
|
573
|
+
// client; auto mode is user-level by design (init once).
|
|
574
|
+
if (explicit && !global && interactive && (clients[0] === "vscode" || clients[0] === "cursor")) {
|
|
575
|
+
global = await promptConfigLevel();
|
|
576
|
+
}
|
|
307
577
|
let scope = "personal";
|
|
308
578
|
if (opts.instructions) {
|
|
309
579
|
if (opts.instructions !== "personal" && opts.instructions !== "project") {
|
|
@@ -332,17 +602,233 @@ export async function initProject(opts) {
|
|
|
332
602
|
}
|
|
333
603
|
const root = projectRoot();
|
|
334
604
|
console.log(`open-memex init — project root: ${root}`);
|
|
335
|
-
|
|
336
|
-
|
|
605
|
+
for (const client of clients) {
|
|
606
|
+
if (client === "vscode" || client === "cursor") {
|
|
607
|
+
if (global)
|
|
608
|
+
writeGlobalMcpJson(client, opts.force);
|
|
609
|
+
else
|
|
610
|
+
writeMcpJson(root, client, opts.force);
|
|
611
|
+
}
|
|
612
|
+
else if (client === "opencode") {
|
|
613
|
+
// D46: the native plugin is the one-time global setup; without --global
|
|
614
|
+
// (explicit single-client mode) keep the per-project plain-MCP config.
|
|
615
|
+
if (global)
|
|
616
|
+
writeOpencodeGlobalPlugin(opts.force);
|
|
617
|
+
else
|
|
618
|
+
writeMcpJson(root, client, opts.force);
|
|
619
|
+
}
|
|
620
|
+
else if (client === "visualstudio") {
|
|
621
|
+
if (explicit && global) {
|
|
622
|
+
console.log(` - visualstudio: --global not supported — VS uses solution-level .mcp.json by design.`);
|
|
623
|
+
}
|
|
624
|
+
else {
|
|
625
|
+
writeMcpJson(root, client, opts.force);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
if (clients.length === 0) {
|
|
630
|
+
console.log(" - editor setup skipped");
|
|
631
|
+
}
|
|
632
|
+
else if (clients.some((c) => c !== "opencode")) {
|
|
337
633
|
// copilot-instructions.md is VS Code/Cursor-shaped; opencode as a plain MCP
|
|
338
634
|
// consumer already gets the guidance from the tool descriptions (D16).
|
|
339
635
|
// D22: personal scope (default) writes to the Copilot user-level location
|
|
340
636
|
// so the repo stays clean for teammates without open-memex.
|
|
341
|
-
|
|
342
|
-
|
|
637
|
+
writeInstructions(root, scope, clients.find((c) => c !== "opencode"));
|
|
638
|
+
}
|
|
639
|
+
console.log(`\nDone. Reload your editor window to start the open-memex MCP server.`);
|
|
640
|
+
}
|
|
641
|
+
// ---------------------------------------------------------------------------
|
|
642
|
+
// uninstall — the reverse of init (D48). Removes the editor wiring init wrote:
|
|
643
|
+
// the MCP server entry, the opencode native plugin line, and the Copilot
|
|
644
|
+
// instructions section. Memory data is never touched.
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
646
|
+
/**
|
|
647
|
+
* Pure removal of the open-memex server entry from a parsed config doc.
|
|
648
|
+
* Prunes the section when it becomes empty. Mutates `doc`.
|
|
649
|
+
*/
|
|
650
|
+
export function removeServerEntry(doc, sectionKey) {
|
|
651
|
+
const section = doc[sectionKey];
|
|
652
|
+
if (!section || typeof section !== "object" || !("open-memex" in section))
|
|
653
|
+
return "absent";
|
|
654
|
+
delete section["open-memex"];
|
|
655
|
+
if (Object.keys(section).length === 0)
|
|
656
|
+
delete doc[sectionKey];
|
|
657
|
+
return "removed";
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Pure removal of open-memex plugin entries from a parsed opencode config doc.
|
|
661
|
+
* Matches any plugin URL containing `match` (robust against the package moving
|
|
662
|
+
* between installs). Prunes the `plugin` array when it becomes empty.
|
|
663
|
+
* Mutates `doc`.
|
|
664
|
+
*/
|
|
665
|
+
export function removePluginEntry(doc, match) {
|
|
666
|
+
const plugins = doc["plugin"];
|
|
667
|
+
if (!Array.isArray(plugins))
|
|
668
|
+
return "absent";
|
|
669
|
+
const kept = plugins.filter((p) => !(typeof p === "string" && p.includes(match)));
|
|
670
|
+
if (kept.length === plugins.length)
|
|
671
|
+
return "absent";
|
|
672
|
+
if (kept.length === 0)
|
|
673
|
+
delete doc["plugin"];
|
|
674
|
+
else
|
|
675
|
+
doc["plugin"] = kept;
|
|
676
|
+
return "removed";
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Pure removal of the open-memex instructions section from a
|
|
680
|
+
* copilot-instructions.md body. init always appends it last (MARKER..end),
|
|
681
|
+
* so cutting from the marker to the end is exact; returns "" when nothing
|
|
682
|
+
* but the section remains so the caller can delete the file.
|
|
683
|
+
*/
|
|
684
|
+
export function removeInstructionsSection(text) {
|
|
685
|
+
const idx = text.lastIndexOf(MARKER);
|
|
686
|
+
if (idx < 0)
|
|
687
|
+
return text;
|
|
688
|
+
const rest = text.slice(0, idx).replace(/\s+$/, "");
|
|
689
|
+
return rest ? rest + "\n" : "";
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Remove the server entry from one JSON config file. Existing files are
|
|
693
|
+
* merged, never clobbered; invalid JSON is left untouched with the manual
|
|
694
|
+
* step (same D47 treatment as the init write path).
|
|
695
|
+
*/
|
|
696
|
+
function removeServerEntryFile(file, sectionKey) {
|
|
697
|
+
if (!fs.existsSync(file))
|
|
698
|
+
return "absent";
|
|
699
|
+
let doc;
|
|
700
|
+
try {
|
|
701
|
+
doc = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
|
|
705
|
+
console.error(` Delete the "open-memex" key under "${sectionKey}".`);
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
const res = removeServerEntry(doc, sectionKey);
|
|
709
|
+
if (res === "removed") {
|
|
710
|
+
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
|
|
711
|
+
console.log(` - ${file} (open-memex entry removed)`);
|
|
712
|
+
}
|
|
713
|
+
return res;
|
|
714
|
+
}
|
|
715
|
+
/** Remove the open-memex plugin URL from one opencode user-level config file. */
|
|
716
|
+
function removeOpencodePluginFile(file) {
|
|
717
|
+
if (!fs.existsSync(file))
|
|
718
|
+
return "absent";
|
|
719
|
+
let doc;
|
|
720
|
+
try {
|
|
721
|
+
doc = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
722
|
+
}
|
|
723
|
+
catch {
|
|
724
|
+
console.error(` ! ${file} is not valid JSON — left untouched, fix it manually`);
|
|
725
|
+
console.error(` Delete the open-memex URL from the "plugin" array.`);
|
|
726
|
+
return null;
|
|
727
|
+
}
|
|
728
|
+
const res = removePluginEntry(doc, "open-memex");
|
|
729
|
+
if (res === "removed") {
|
|
730
|
+
fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
|
|
731
|
+
console.log(` - ${file} (open-memex plugin removed)`);
|
|
732
|
+
}
|
|
733
|
+
return res;
|
|
734
|
+
}
|
|
735
|
+
/** Remove the open-memex section from one copilot-instructions.md file. */
|
|
736
|
+
function removeInstructionsFile(file) {
|
|
737
|
+
if (!fs.existsSync(file))
|
|
738
|
+
return "absent";
|
|
739
|
+
const cur = fs.readFileSync(file, "utf8");
|
|
740
|
+
if (!cur.includes(MARKER))
|
|
741
|
+
return "absent";
|
|
742
|
+
const next = removeInstructionsSection(cur);
|
|
743
|
+
if (next === "") {
|
|
744
|
+
fs.unlinkSync(file);
|
|
745
|
+
console.log(` - ${file} (deleted — it only held open-memex instructions)`);
|
|
343
746
|
}
|
|
344
747
|
else {
|
|
345
|
-
|
|
748
|
+
fs.writeFileSync(file, next);
|
|
749
|
+
console.log(` - ${file} (open-memex instructions removed)`);
|
|
346
750
|
}
|
|
347
|
-
|
|
751
|
+
return "removed";
|
|
752
|
+
}
|
|
753
|
+
export async function uninstallProject(opts) {
|
|
754
|
+
const interactive = !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
|
|
755
|
+
const explicit = normalizeClient(opts.client ?? "");
|
|
756
|
+
if (explicit && !INIT_CLIENTS.includes(explicit)) {
|
|
757
|
+
console.error(`unknown client "${opts.client}" (${INIT_CLIENTS.join("|")})`);
|
|
758
|
+
process.exit(1);
|
|
759
|
+
}
|
|
760
|
+
// Mirror init's client resolution: explicit --client, else auto-detect.
|
|
761
|
+
let clients;
|
|
762
|
+
if (explicit) {
|
|
763
|
+
clients = [explicit];
|
|
764
|
+
}
|
|
765
|
+
else if (interactive) {
|
|
766
|
+
const detected = detectInstalledClients();
|
|
767
|
+
if (detected.length === 0) {
|
|
768
|
+
console.log(" - no supported editors detected — nothing to remove");
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
console.log(`Detected editors: ${detected.join(", ")}`);
|
|
772
|
+
const all = await askBool("Remove open-memex wiring from all of them?", true);
|
|
773
|
+
if (all) {
|
|
774
|
+
clients = detected;
|
|
775
|
+
}
|
|
776
|
+
else {
|
|
777
|
+
const one = await promptClient();
|
|
778
|
+
clients = one ? [one] : [];
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
else {
|
|
782
|
+
clients = detectInstalledClients();
|
|
783
|
+
if (clients.length > 0) {
|
|
784
|
+
console.log(`Detected editors: ${clients.join(", ")} — removing from all (use --client to pick one)`);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
if (clients.length === 0) {
|
|
788
|
+
console.log(" - nothing to remove");
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
// Without --global, clean both levels: init may have written either one,
|
|
792
|
+
// and a leftover entry at the other level would be a surprise.
|
|
793
|
+
const levels = opts.global ? ["user"] : ["project", "user"];
|
|
794
|
+
const root = projectRoot();
|
|
795
|
+
console.log(`open-memex uninstall — project root: ${root}`);
|
|
796
|
+
let changed = 0;
|
|
797
|
+
const bump = (r) => {
|
|
798
|
+
if (r === "removed")
|
|
799
|
+
changed++;
|
|
800
|
+
};
|
|
801
|
+
for (const client of clients) {
|
|
802
|
+
if (client === "vscode" || client === "cursor") {
|
|
803
|
+
const sectionKey = client === "cursor" ? "mcpServers" : "servers";
|
|
804
|
+
for (const level of levels) {
|
|
805
|
+
const file = level === "user"
|
|
806
|
+
? userMcpConfigPath(client)
|
|
807
|
+
: path.join(root, client === "cursor" ? ".cursor/mcp.json" : ".vscode/mcp.json");
|
|
808
|
+
bump(removeServerEntryFile(file, sectionKey));
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
else if (client === "opencode") {
|
|
812
|
+
for (const level of levels) {
|
|
813
|
+
if (level === "user")
|
|
814
|
+
bump(removeOpencodePluginFile(opencodeGlobalConfigPath()));
|
|
815
|
+
else
|
|
816
|
+
bump(removeServerEntryFile(path.join(root, "opencode.jsonc"), "mcp"));
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
else if (client === "visualstudio") {
|
|
820
|
+
// Solution-level only — --global is meaningless, same as init.
|
|
821
|
+
bump(removeServerEntryFile(path.join(root, ".mcp.json"), "servers"));
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
// Copilot instructions: init may have written personal (default) or project.
|
|
825
|
+
if (clients.some((c) => c !== "opencode")) {
|
|
826
|
+
bump(removeInstructionsFile(path.join(os.homedir(), ".copilot", "copilot-instructions.md")));
|
|
827
|
+
bump(removeInstructionsFile(path.join(os.homedir(), "copilot-instructions.md")));
|
|
828
|
+
bump(removeInstructionsFile(path.join(root, ".github", "copilot-instructions.md")));
|
|
829
|
+
}
|
|
830
|
+
console.log(changed > 0
|
|
831
|
+
? `\nDone. Removed open-memex wiring from ${changed} file(s).`
|
|
832
|
+
: "\nDone. Nothing to remove — no open-memex wiring found.");
|
|
833
|
+
console.log("Your memories are untouched (uninstall never deletes data).");
|
|
348
834
|
}
|
package/docs/TEST-PLAN.md
CHANGED
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
|
|
34
34
|
- [ ] Cursor:`open-memex mcp --print-config cursor` → 贴到 Cursor MCP 配置 → 能连上
|
|
35
35
|
- [ ] opencode:`open-memex init --client opencode` → `opencode.jsonc` 生效
|
|
36
|
+
- [ ] `open-memex init --yes`(不带 --client)→ 自动检测已装编辑器并一次全接上
|
|
37
|
+
- [ ] opencode:`open-memex init --client opencode --global` → `~/.config/opencode/opencode.json` 的 `plugin` 数组合并(带注释的 jsonc 不动、只给手动提示)
|
|
36
38
|
- [ ] Claude Code:`open-memex mcp --print-config claude` 给出的 `claude mcp add` 命令能跑通
|
|
37
39
|
|
|
38
40
|
## D. 跨机迁移(export/import 真实场景)
|