open-memex 0.4.0 → 0.5.0-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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,72 @@ 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
+ * Read (or create) a JSON config file, merge the open-memex server entry, write
165
+ * it back. Existing files are merged, never clobbered; invalid JSON is left
166
+ * untouched. `mkdir` controls whether parent dirs are created (user-level
167
+ * configs) or expected to exist via the project root.
168
+ */
169
+ function writeServerEntryFile(file, sectionKey, entry, force, mkdir) {
103
170
  let doc = {};
104
171
  if (fs.existsSync(file)) {
105
172
  try {
@@ -110,29 +177,93 @@ function writeMcpJson(root, client, force) {
110
177
  return null;
111
178
  }
112
179
  }
113
- const section = (doc[sectionKey] ??= {});
114
- if (section["open-memex"] && !force) {
180
+ const merged = mergeServerEntry(doc, sectionKey, entry, force);
181
+ if (merged === "kept") {
115
182
  console.log(` = ${file} already configures open-memex — left as is (use --force to overwrite)`);
116
183
  return file;
117
184
  }
118
- // D17: resolve the server command at init time — a one-shot npx leaves no bin behind.
119
- const mc = resolveMcpCommand();
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 });
185
+ if (mkdir)
186
+ fs.mkdirSync(path.dirname(file), { recursive: true });
130
187
  fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
131
188
  console.log(` + ${file}`);
132
- if (!mc.durable) {
133
- console.log(` ! no durable \`open-memex\` on PATH (one-shot npx?) — wrote an npx-based command.`);
134
- console.log(` For faster startup: \`npm i -g ${ALPHA_TAG}\`, then re-run \`open-memex init --force\`.`);
189
+ return file;
190
+ }
191
+ /** D45: `init --global` — one-time user-level wiring for VS Code / Cursor. */
192
+ function writeGlobalMcpJson(client, force) {
193
+ const file = userMcpConfigPath(client);
194
+ const sectionKey = client === "cursor" ? "mcpServers" : "servers";
195
+ const { entry, durable } = stdioServerEntry(client);
196
+ const written = writeServerEntryFile(file, sectionKey, entry, force, true);
197
+ if (written) {
198
+ warnNonDurable(durable);
199
+ const projFile = client === "cursor" ? ".cursor/mcp.json" : ".vscode/mcp.json";
200
+ console.log(` i user-level config — the open-memex MCP server now starts in every project.`);
201
+ console.log(` (a per-project ${projFile} still wins if a project defines its own)`);
202
+ }
203
+ return written;
204
+ }
205
+ /** Where this installed copy's opencode native plugin entry point lives. */
206
+ function opencodePluginUrl() {
207
+ // init.ts sits in <pkg>/src — the plugin entry is <pkg>/src/index.ts.
208
+ const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
209
+ return pathToFileURL(path.join(pkgRoot, "src", "index.ts")).href;
210
+ }
211
+ /**
212
+ * D46: opencode's user-level config. opencode reads `~/.config/opencode/`
213
+ * (`$XDG_CONFIG_HOME` when set) on every platform; a `plugin` entry there
214
+ * loads open-memex in every project with no per-project init.
215
+ */
216
+ export function opencodeGlobalConfigPath(home = os.homedir()) {
217
+ return path.join(opencodeConfigDir(home, process.env.XDG_CONFIG_HOME), "opencode.json");
218
+ }
219
+ /**
220
+ * Pure merge of the open-memex plugin URL into a parsed opencode config doc.
221
+ * Creates the `plugin` array when missing; never duplicates the URL.
222
+ * Returns "added" when the doc changed, "kept" when already present.
223
+ */
224
+ export function mergePluginEntry(doc, url, force) {
225
+ let plugins = doc["plugin"];
226
+ if (!Array.isArray(plugins)) {
227
+ plugins = [];
228
+ doc["plugin"] = plugins;
135
229
  }
230
+ const list = plugins;
231
+ if (list.includes(url) && !force)
232
+ return "kept";
233
+ if (!list.includes(url))
234
+ list.push(url);
235
+ return "added";
236
+ }
237
+ /**
238
+ * D46: wire the native plugin at the opencode user level — the one-time
239
+ * global setup. Merges into the existing config when it parses as JSON;
240
+ * a file with comments (JSONC) is left untouched with a manual hint instead
241
+ * of being clobbered.
242
+ */
243
+ function writeOpencodeGlobalPlugin(force) {
244
+ const dir = path.dirname(opencodeGlobalConfigPath());
245
+ const file = ["opencode.jsonc", "opencode.json"]
246
+ .map((f) => path.join(dir, f))
247
+ .find((f) => fs.existsSync(f)) ?? path.join(dir, "opencode.json");
248
+ const url = opencodePluginUrl();
249
+ let doc = {};
250
+ if (fs.existsSync(file)) {
251
+ try {
252
+ doc = JSON.parse(fs.readFileSync(file, "utf8"));
253
+ }
254
+ catch {
255
+ console.log(` ! ${file} has comments or invalid JSON — left untouched, fix it manually.`);
256
+ console.log(` To enable open-memex everywhere, add "plugin": ["${url}"] to it.`);
257
+ return null;
258
+ }
259
+ }
260
+ if (mergePluginEntry(doc, url, force) === "kept") {
261
+ console.log(` = ${file} already loads the open-memex plugin — left as is (use --force to overwrite)`);
262
+ return file;
263
+ }
264
+ fs.mkdirSync(path.dirname(file), { recursive: true });
265
+ fs.writeFileSync(file, JSON.stringify(doc, null, 2) + "\n");
266
+ console.log(` + ${file} (native plugin — works in every project, no per-project init needed)`);
136
267
  return file;
137
268
  }
138
269
  /** opencode MCP config: project-level opencode.jsonc, `type: "local"` + command array (v1 format). */
@@ -293,17 +424,133 @@ async function promptInstructionsScope() {
293
424
  rl.close();
294
425
  }
295
426
  }
427
+ /** D45: ask whether the MCP server config should be project-level or user-level. */
428
+ async function promptConfigLevel() {
429
+ console.log("Where should the MCP server config live?");
430
+ console.log(" 1) this project only — .vscode/mcp.json (or .cursor/mcp.json)");
431
+ console.log(" 2) user-level — all projects, init once (VS Code / Cursor)");
432
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
433
+ try {
434
+ const ans = (await rl.question("Choice [1]: ")).trim();
435
+ return ans === "2";
436
+ }
437
+ finally {
438
+ rl.close();
439
+ }
440
+ }
441
+ function exists(p) {
442
+ try {
443
+ return fs.existsSync(p);
444
+ }
445
+ catch {
446
+ return false;
447
+ }
448
+ }
449
+ /** True when `name` resolves on PATH (honors .cmd/.exe on Windows). */
450
+ function binOnPath(name, pathEnv, platform) {
451
+ const names = platform === "win32" ? [`${name}.cmd`, `${name}.exe`, name] : [name];
452
+ return pathEnv
453
+ .split(path.delimiter)
454
+ .filter((d) => d && !d.includes("_npx"))
455
+ .some((d) => names.some((n) => exists(path.join(d, n))));
456
+ }
457
+ function opencodeConfigDir(home, xdg) {
458
+ return path.join(xdg || path.join(home, ".config"), "opencode");
459
+ }
460
+ /**
461
+ * D46: detect which supported editors are installed. Used when `init` runs
462
+ * without --client — one init wires every detected editor (user-level where
463
+ * the editor supports it). Visual Studio is included only when the project
464
+ * has a solution file, since VS config is solution-scoped by design.
465
+ */
466
+ export function detectInstalledClients(env = {}) {
467
+ const platform = env.platform ?? process.platform;
468
+ const pathEnv = env.pathEnv ?? process.env.PATH ?? "";
469
+ const home = env.home ?? os.homedir();
470
+ const found = [];
471
+ // VS Code: bin on PATH, well-known install location, or an existing
472
+ // user-level MCP config (a previous init counts as installed).
473
+ const vscodeInstallPaths = platform === "win32"
474
+ ? [path.join(home, "AppData", "Local", "Programs", "Microsoft VS Code", "Code.exe")]
475
+ : platform === "darwin"
476
+ ? ["/Applications/Visual Studio Code.app"]
477
+ : ["/usr/bin/code", "/usr/share/code/bin/code", "/snap/bin/code"];
478
+ if (binOnPath("code", pathEnv, platform) ||
479
+ vscodeInstallPaths.some(exists) ||
480
+ exists(userMcpConfigPath("vscode", platform, home))) {
481
+ found.push("vscode");
482
+ }
483
+ // Cursor: bin on PATH or its user config dir exists.
484
+ if (binOnPath("cursor", pathEnv, platform) || exists(path.join(home, ".cursor"))) {
485
+ found.push("cursor");
486
+ }
487
+ // opencode: bin on PATH or its global config dir exists.
488
+ const xdg = env.xdgConfigHome ?? process.env.XDG_CONFIG_HOME;
489
+ if (binOnPath("opencode", pathEnv, platform) || exists(opencodeConfigDir(home, xdg))) {
490
+ found.push("opencode");
491
+ }
492
+ // Visual Studio: solution-scoped — only when the project has a .sln.
493
+ let hasSln = false;
494
+ try {
495
+ hasSln = fs.readdirSync(env.root ?? projectRoot()).some((f) => f.toLowerCase().endsWith(".sln"));
496
+ }
497
+ catch {
498
+ hasSln = false;
499
+ }
500
+ if (hasSln)
501
+ found.push("visualstudio");
502
+ return found;
503
+ }
296
504
  export async function initProject(opts) {
297
505
  const interactive = !opts.yes && !!process.stdin.isTTY && !!process.stdout.isTTY;
298
- let client = normalizeClient(opts.client ?? "");
299
- if (client && !INIT_CLIENTS.includes(client)) {
506
+ const explicit = normalizeClient(opts.client ?? "");
507
+ if (explicit && !INIT_CLIENTS.includes(explicit)) {
300
508
  console.error(`unknown client "${opts.client}" (${INIT_CLIENTS.join("|")})`);
301
509
  process.exit(1);
302
510
  }
303
- if (!client && interactive)
304
- client = (await promptClient()) ?? "";
305
- if (!client && !interactive)
306
- client = "vscode"; // historical default for scripts / one-shot npx
511
+ // D46: no --client → auto-detect installed editors and wire them all
512
+ // (user-level where the editor supports it — init once). An explicit
513
+ // --client keeps the old single-editor behavior.
514
+ let clients;
515
+ let autoGlobal = false;
516
+ if (explicit) {
517
+ clients = [explicit];
518
+ }
519
+ else if (interactive) {
520
+ const detected = detectInstalledClients();
521
+ if (detected.length === 0) {
522
+ console.log(" - no supported editors detected — editor setup skipped");
523
+ clients = [];
524
+ }
525
+ else {
526
+ console.log(`Detected editors: ${detected.join(", ")}`);
527
+ const all = await askBool("Wire up open-memex in all of them (one-time, user-level where supported)?", true);
528
+ if (all) {
529
+ clients = detected;
530
+ autoGlobal = true;
531
+ }
532
+ else {
533
+ const one = await promptClient();
534
+ clients = one ? [one] : [];
535
+ }
536
+ }
537
+ }
538
+ else {
539
+ clients = detectInstalledClients();
540
+ autoGlobal = true;
541
+ if (clients.length === 0) {
542
+ console.log(" - no supported editors detected — editor setup skipped");
543
+ }
544
+ else {
545
+ console.log(`Detected editors: ${clients.join(", ")} — wiring all (use --client to pick one)`);
546
+ }
547
+ }
548
+ let global = !!opts.global || autoGlobal;
549
+ // The project-vs-user-level choice only applies to an explicit single
550
+ // client; auto mode is user-level by design (init once).
551
+ if (explicit && !global && interactive && (clients[0] === "vscode" || clients[0] === "cursor")) {
552
+ global = await promptConfigLevel();
553
+ }
307
554
  let scope = "personal";
308
555
  if (opts.instructions) {
309
556
  if (opts.instructions !== "personal" && opts.instructions !== "project") {
@@ -332,17 +579,39 @@ export async function initProject(opts) {
332
579
  }
333
580
  const root = projectRoot();
334
581
  console.log(`open-memex init — project root: ${root}`);
335
- if (client) {
336
- writeMcpJson(root, client, opts.force);
582
+ for (const client of clients) {
583
+ if (client === "vscode" || client === "cursor") {
584
+ if (global)
585
+ writeGlobalMcpJson(client, opts.force);
586
+ else
587
+ writeMcpJson(root, client, opts.force);
588
+ }
589
+ else if (client === "opencode") {
590
+ // D46: the native plugin is the one-time global setup; without --global
591
+ // (explicit single-client mode) keep the per-project plain-MCP config.
592
+ if (global)
593
+ writeOpencodeGlobalPlugin(opts.force);
594
+ else
595
+ writeMcpJson(root, client, opts.force);
596
+ }
597
+ else if (client === "visualstudio") {
598
+ if (explicit && global) {
599
+ console.log(` - visualstudio: --global not supported — VS uses solution-level .mcp.json by design.`);
600
+ }
601
+ else {
602
+ writeMcpJson(root, client, opts.force);
603
+ }
604
+ }
605
+ }
606
+ if (clients.length === 0) {
607
+ console.log(" - editor setup skipped");
608
+ }
609
+ else if (clients.some((c) => c !== "opencode")) {
337
610
  // copilot-instructions.md is VS Code/Cursor-shaped; opencode as a plain MCP
338
611
  // consumer already gets the guidance from the tool descriptions (D16).
339
612
  // D22: personal scope (default) writes to the Copilot user-level location
340
613
  // so the repo stays clean for teammates without open-memex.
341
- if (client !== "opencode")
342
- writeInstructions(root, scope, client);
343
- }
344
- else {
345
- console.log(" - editor setup skipped");
614
+ writeInstructions(root, scope, clients.find((c) => c !== "opencode"));
346
615
  }
347
616
  console.log(`\nDone. Reload your editor window to start the open-memex MCP server.`);
348
617
  }
@@ -83,23 +83,45 @@ export function planConversion(filePath, memoriesRoot) {
83
83
  return { fm, body, fromPath: filePath, toPath, changes };
84
84
  }
85
85
  /**
86
- * Merge a legacy `my-o-memory` data root into the current `open-memex` root.
87
- * The old dir is renamed to a dated backup — never deleted. Returns the
88
- * backup path, or null when no legacy dir exists.
86
+ * Locate a legacy `my-o-memory` data root next to the current root.
87
+ * Pure lookup — no disk writes.
89
88
  */
90
- function migrateLegacyDataRoot(dryRun) {
89
+ export function findLegacyDataRoot() {
91
90
  const { root } = paths();
92
91
  const legacy = path.join(path.dirname(root), "my-o-memory");
93
92
  if (!fs.existsSync(legacy) || !fs.statSync(legacy).isDirectory())
94
93
  return null;
94
+ return legacy;
95
+ }
96
+ /** Dated backup path for a legacy data root. Pure — no disk writes. */
97
+ export function legacyBackupPath(legacy) {
95
98
  const stamp = new Date().toISOString().slice(0, 10);
96
- const backup = `${legacy}.backup-${stamp}`;
97
- if (!dryRun) {
98
- const legacyMem = path.join(legacy, "memories");
99
- if (fs.existsSync(legacyMem)) {
100
- for (const entry of fs.readdirSync(legacyMem)) {
101
- const src = path.join(legacyMem, entry);
102
- const dst = path.join(root, "memories", entry);
99
+ return `${legacy}.backup-${stamp}`;
100
+ }
101
+ /**
102
+ * Merge a legacy `my-o-memory` data root into the current `open-memex` root.
103
+ * The old dir is renamed to a dated backup — never deleted.
104
+ * Failures throw an actionable Error (no raw syscall dump): on Windows the
105
+ * backup rename typically fails with EPERM when another program holds the
106
+ * folder open.
107
+ */
108
+ function mergeLegacyDataRoot(legacy, backup) {
109
+ const { memories } = paths();
110
+ const fail = (where, err) => {
111
+ const code = err?.code;
112
+ throw new Error(`could not ${where} (${legacy})` +
113
+ (code ? ` [${code}]` : "") +
114
+ `. Another program may be holding the folder open (e.g. a running MCP server, editor, or antivirus). ` +
115
+ `Your memories are safe — nothing was deleted. Close the program and re-run ` +
116
+ `\`open-memex migrate --to-v2\`, or rename the folder to ${backup} yourself and re-run.`);
117
+ };
118
+ const legacyMem = path.join(legacy, "memories");
119
+ if (fs.existsSync(legacyMem)) {
120
+ fs.mkdirSync(memories, { recursive: true });
121
+ for (const entry of fs.readdirSync(legacyMem)) {
122
+ const src = path.join(legacyMem, entry);
123
+ const dst = path.join(memories, entry);
124
+ try {
103
125
  if (!fs.existsSync(dst)) {
104
126
  fs.renameSync(src, dst);
105
127
  }
@@ -113,46 +135,73 @@ function migrateLegacyDataRoot(dryRun) {
113
135
  }
114
136
  }
115
137
  }
138
+ catch (err) {
139
+ fail(`move memories from the legacy data dir`, err);
140
+ }
116
141
  }
142
+ }
143
+ try {
117
144
  fs.renameSync(legacy, backup);
118
145
  }
119
- return backup;
146
+ catch (err) {
147
+ fail(`back up the legacy data dir`, err);
148
+ }
120
149
  }
121
- export function migrateV2(opts) {
122
- const { memories: memoriesRoot } = paths();
123
- const result = {
124
- scanned: 0,
125
- converted: 0,
126
- skippedV2: 0,
127
- plans: [],
128
- legacyBackup: null,
129
- };
130
- result.legacyBackup = migrateLegacyDataRoot(opts.dryRun);
131
- if (!fs.existsSync(memoriesRoot))
132
- return result;
133
- for (const entry of fs.readdirSync(memoriesRoot, { withFileTypes: true })) {
150
+ /** Plan v1 → v2 conversion for every `.md` file under `dir`. Read-only. */
151
+ function planDir(dir, memoriesRoot, result) {
152
+ if (!fs.existsSync(dir))
153
+ return;
154
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
134
155
  if (!entry.isDirectory())
135
156
  continue;
136
- const dir = path.join(memoriesRoot, entry.name);
137
- for (const name of fs.readdirSync(dir)) {
157
+ const sub = path.join(dir, entry.name);
158
+ for (const name of fs.readdirSync(sub)) {
138
159
  if (!name.endsWith(".md"))
139
160
  continue;
140
- const filePath = path.join(dir, name);
161
+ const filePath = path.join(sub, name);
141
162
  result.scanned++;
142
163
  const plan = planConversion(filePath, memoriesRoot);
143
164
  if (!plan) {
144
165
  result.skippedV2++;
145
166
  continue;
146
167
  }
147
- if (!opts.dryRun) {
148
- fs.mkdirSync(path.dirname(plan.toPath), { recursive: true });
149
- fs.writeFileSync(plan.toPath, serialize(plan.fm, plan.body), "utf8");
150
- if (plan.toPath !== plan.fromPath)
151
- fs.unlinkSync(plan.fromPath);
152
- }
153
168
  result.converted++;
154
169
  result.plans.push(plan);
155
170
  }
156
171
  }
172
+ }
173
+ /** Apply conversion plans to disk (real run only — never in dry-run). */
174
+ function applyPlans(result) {
175
+ for (const plan of result.plans) {
176
+ fs.mkdirSync(path.dirname(plan.toPath), { recursive: true });
177
+ fs.writeFileSync(plan.toPath, serialize(plan.fm, plan.body), "utf8");
178
+ if (plan.toPath !== plan.fromPath)
179
+ fs.unlinkSync(plan.fromPath);
180
+ }
181
+ }
182
+ export function migrateV2(opts) {
183
+ const { memories: memoriesRoot } = paths();
184
+ const result = {
185
+ scanned: 0,
186
+ converted: 0,
187
+ skippedV2: 0,
188
+ plans: [],
189
+ legacyBackup: null,
190
+ };
191
+ const legacy = findLegacyDataRoot();
192
+ if (legacy) {
193
+ result.legacyBackup = legacyBackupPath(legacy);
194
+ if (opts.dryRun) {
195
+ // D44: dry-run previews the legacy files in place — nothing is moved,
196
+ // so the preview actually shows what would convert (issue #7).
197
+ planDir(path.join(legacy, "memories"), memoriesRoot, result);
198
+ }
199
+ else {
200
+ mergeLegacyDataRoot(legacy, result.legacyBackup);
201
+ }
202
+ }
203
+ planDir(memoriesRoot, memoriesRoot, result);
204
+ if (!opts.dryRun)
205
+ applyPlans(result);
157
206
  return result;
158
207
  }
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 真实场景)
package/docs/V2-DESIGN.md CHANGED
@@ -867,6 +867,39 @@ requirement: personal data never touches third-party services). Benchmarks to tr
867
867
  MCP handshake instructions or the `init`-written instruction files — but
868
868
  opencode reads AGENTS.md natively, so the distilled snippet teaches the
869
869
  checkpoint habit wherever it lands. Approved 2026-09-29.*
870
+ - **D44** — `migrate --to-v2` hotfix (issue #7, 0.4.1): (1) `--dry-run` now
871
+ previews the legacy `my-o-memory` files in place — per-file conversion plans
872
+ without moving anything (previously it scanned only the new, still-empty
873
+ root and always reported "0 files", making the preview useless); (2) dry-run
874
+ no longer prints the false "legacy data dir merged" line; (3) the Windows
875
+ EPERM on the legacy-dir backup rename is caught and rethrown as an actionable
876
+ message (close the program holding the folder — e.g. a running MCP server —
877
+ and re-run; nothing was deleted), and the CLI prints it as `Error: …` with
878
+ exit 1 instead of a raw syscall stack; (4) `parseFlags` accepts `--key=value`
879
+ in addition to `--key value` (the `=` form was silently misparsed before,
880
+ dropping the flag — which can turn a `--dry-run` into a real run).
881
+ *Rationale: a preview that shows nothing is worse than no preview — the user
882
+ cannot confirm what the real run will do; and a destructive-path failure must
883
+ speak in user terms. Shipped as 0.4.1 hotfix on the stable line. Approved
884
+ 2026-09-29.*
885
+ - **D45** — `init --global` (0.5.0-alpha.1): one-time user-level MCP wiring for
886
+ VS Code / Cursor. Writes the open-memex server entry to the editor's
887
+ user-level `mcp.json` (`%APPDATA%\Code\User\mcp.json` on Windows,
888
+ `~/Library/Application Support/Code/User/mcp.json` on macOS,
889
+ `~/.config/Code/User/mcp.json` on Linux; `~/.cursor/mcp.json` for Cursor)
890
+ instead of the project's `.vscode/mcp.json` — init once, the server starts
891
+ in every project. The entry keeps `cwd: "${workspaceFolder}"` so VS Code
892
+ substitutes it per window and project-scope resolution keeps working;
893
+ a per-project config still wins when present. Merge semantics are shared
894
+ with project-level init (merge, never clobber; `--force` overwrites).
895
+ Interactive `init` now asks per-project vs user-level for vscode/cursor
896
+ (default: per-project, preserving old behavior); `--global` skips the
897
+ question. For opencode, `--global` is a no-op that prints the native-plugin
898
+ one-liner (already global, and strictly more capable than plain-MCP mode);
899
+ Visual Studio stays solution-level by design. *Rationale: per-project init
900
+ is a paper cut that compounds — the data layer already needs zero per-project
901
+ setup (scope is derived from cwd), so the editor wiring should be able to
902
+ match. Approved 2026-09-29.*
870
903
  - **D41** — The type taxonomy is reconciled to 11 types with one-line definitions
871
904
  (§3.1): `fact` `preference` `decision` `constraint` `todo` `knowledge` `howto`
872
905
  `gotcha` `lesson` `observation` `reference`. Merged away: `warning`→`gotcha`,
@@ -878,6 +911,25 @@ requirement: personal data never touches third-party services). Benchmarks to tr
878
911
  is deliberately broader than `incident`: a postmortem's shape (timeline, root
879
912
  cause, actions) is a template concern, not a type. Code
880
913
  `MEMORY_TYPE_TAXONOMY` updated to match. Approved 2026-09-28.*
914
+ - **D46** — `init` with no `--client` auto-detects installed editors and wires
915
+ them all (0.5.0-alpha.2). Detection: VS Code via `code` on `PATH`, well-known
916
+ install locations, or an existing user-level `mcp.json`; Cursor via `cursor`
917
+ on `PATH` or `~/.cursor`; opencode via `opencode` on `PATH` or its global
918
+ config dir; Visual Studio only when the project has a `.sln` (solution-scoped
919
+ by design). Auto mode always wires user-level where the editor supports it —
920
+ VS Code / Cursor MCP entry (D45), and for opencode the native plugin entry is
921
+ now *actually merged* into `~/.config/opencode/opencode.json` (replacing D45's
922
+ hint-only `--global`), so one init covers every editor and every project.
923
+ A config file with comments (JSONC) is never rewritten — init prints the
924
+ manual one-liner instead. Interactive `init` shows the detected editors and
925
+ confirms wiring all of them (declining falls back to the single-editor
926
+ prompt); non-interactive (`--yes`) wires all detected with no prompts.
927
+ An explicit `--client` keeps the old single-editor behavior, including the
928
+ per-project default for vscode/cursor. *Rationale: Stone's two hats — he
929
+ writes code in several editors himself, and new users / pilot colleagues
930
+ should not have to learn `--client` to get started. The help text already
931
+ promised "default: auto-detect"; D46 makes the code keep that promise.
932
+ Approved 2026-09-29.*
881
933
 
882
934
  ## Open Questions
883
935
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-memex",
3
- "version": "0.4.0",
3
+ "version": "0.5.0-alpha.2",
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",