open-memex 0.4.1 → 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
  }
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
@@ -882,6 +882,24 @@ requirement: personal data never touches third-party services). Benchmarks to tr
882
882
  cannot confirm what the real run will do; and a destructive-path failure must
883
883
  speak in user terms. Shipped as 0.4.1 hotfix on the stable line. Approved
884
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.*
885
903
  - **D41** — The type taxonomy is reconciled to 11 types with one-line definitions
886
904
  (§3.1): `fact` `preference` `decision` `constraint` `todo` `knowledge` `howto`
887
905
  `gotcha` `lesson` `observation` `reference`. Merged away: `warning`→`gotcha`,
@@ -893,6 +911,25 @@ requirement: personal data never touches third-party services). Benchmarks to tr
893
911
  is deliberately broader than `incident`: a postmortem's shape (timeline, root
894
912
  cause, actions) is a template concern, not a type. Code
895
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.*
896
933
 
897
934
  ## Open Questions
898
935
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-memex",
3
- "version": "0.4.1",
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",
@@ -8,6 +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
12
 
12
13
  let fails = 0;
13
14
  function ok(name: string, cond: boolean, info?: unknown) {
@@ -376,5 +377,74 @@ const near = similarity(
376
377
  ok(`similarity near-dup ${near.toFixed(2)} >= ${NEAR_DUP_THRESHOLD}`, near >= NEAR_DUP_THRESHOLD);
377
378
  ok("similarity empty → 0", similarity("", "anything") === 0);
378
379
 
380
+ console.log("== init --global: userMcpConfigPath / mergeServerEntry ==");
381
+ // D45: user-level MCP config locations, per platform (platform param is injectable).
382
+ ok("vscode win32", userMcpConfigPath("vscode", "win32").endsWith(path.join("Code", "User", "mcp.json")));
383
+ ok("vscode darwin", userMcpConfigPath("vscode", "darwin").includes(path.join("Library", "Application Support", "Code", "User", "mcp.json")));
384
+ ok("vscode linux", userMcpConfigPath("vscode", "linux").endsWith(path.join(".config", "Code", "User", "mcp.json")));
385
+ ok("cursor is ~/.cursor/mcp.json on every platform",
386
+ (["win32", "darwin", "linux"] as const).every((p) =>
387
+ userMcpConfigPath("cursor", p).endsWith(path.join(".cursor", "mcp.json"))));
388
+ // mergeServerEntry: pure merge semantics.
389
+ const doc1: Record<string, unknown> = {};
390
+ ok("merge into empty doc adds", mergeServerEntry(doc1, "servers", { command: "x" }, false) === "added");
391
+ ok("entry landed under section", (doc1["servers"] as Record<string, unknown>)["open-memex"] !== undefined);
392
+ ok("existing entry kept without force", mergeServerEntry(doc1, "servers", { command: "y" }, false) === "kept");
393
+ ok("kept entry untouched", ((doc1["servers"] as Record<string, unknown>)["open-memex"] as Record<string, unknown>)["command"] === "x");
394
+ ok("force overwrites", mergeServerEntry(doc1, "servers", { command: "y" }, true) === "added");
395
+ ok("forced entry applied", ((doc1["servers"] as Record<string, unknown>)["open-memex"] as Record<string, unknown>)["command"] === "y");
396
+ const doc2: Record<string, unknown> = { servers: { other: { command: "z" } } };
397
+ ok("merge preserves sibling entries", mergeServerEntry(doc2, "servers", { command: "x" }, false) === "added"
398
+ && (doc2["servers"] as Record<string, unknown>)["other"] !== undefined);
399
+
400
+ console.log("== init D46: auto-detect + opencode global plugin ==");
401
+ // opencodeGlobalConfigPath: user-level location, XDG-aware.
402
+ const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "memex-smoke-"));
403
+ ok("opencode global config under ~/.config/opencode",
404
+ opencodeGlobalConfigPath(fakeHome).endsWith(path.join(".config", "opencode", "opencode.json")));
405
+ ok("opencode global config honors XDG_CONFIG_HOME", (() => {
406
+ const prev = process.env.XDG_CONFIG_HOME;
407
+ process.env.XDG_CONFIG_HOME = path.join(fakeHome, "xdg");
408
+ try {
409
+ return opencodeGlobalConfigPath(fakeHome).startsWith(path.join(fakeHome, "xdg"));
410
+ } finally {
411
+ if (prev === undefined) delete process.env.XDG_CONFIG_HOME;
412
+ else process.env.XDG_CONFIG_HOME = prev;
413
+ }
414
+ })());
415
+ // mergePluginEntry: pure merge semantics.
416
+ const pdoc: Record<string, unknown> = {};
417
+ ok("plugin merge creates array", mergePluginEntry(pdoc, "file:///x", false) === "added"
418
+ && JSON.stringify(pdoc["plugin"]) === JSON.stringify(["file:///x"]));
419
+ ok("plugin merge dup kept", mergePluginEntry(pdoc, "file:///x", false) === "kept");
420
+ ok("plugin merge force on dup adds nothing twice", mergePluginEntry(pdoc, "file:///x", true) === "added"
421
+ && (pdoc["plugin"] as unknown[]).length === 1);
422
+ const pdoc2: Record<string, unknown> = { plugin: ["file:///other"], theme: "dark" };
423
+ ok("plugin merge preserves siblings", mergePluginEntry(pdoc2, "file:///x", false) === "added"
424
+ && (pdoc2["plugin"] as unknown[]).length === 2 && pdoc2["theme"] === "dark");
425
+ // detectInstalledClients with a fully fake env.
426
+ const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "memex-bin-"));
427
+ fs.writeFileSync(path.join(binDir, "code"), "#!/bin/sh\n");
428
+ const detHome = fs.mkdtempSync(path.join(os.tmpdir(), "memex-home-"));
429
+ fs.mkdirSync(path.join(detHome, ".cursor"), { recursive: true });
430
+ fs.mkdirSync(path.join(detHome, ".config", "opencode"), { recursive: true });
431
+ const detRoot = fs.mkdtempSync(path.join(os.tmpdir(), "memex-root-"));
432
+ const detEnv = { pathEnv: binDir, home: detHome, platform: "linux" as const, root: detRoot, xdgConfigHome: path.join(detHome, ".config") };
433
+ const det = detectInstalledClients(detEnv);
434
+ ok("detects vscode via PATH", det.includes("vscode"));
435
+ ok("detects cursor via ~/.cursor", det.includes("cursor"));
436
+ ok("detects opencode via config dir", det.includes("opencode"));
437
+ ok("no visualstudio without .sln", !det.includes("visualstudio"));
438
+ fs.writeFileSync(path.join(detRoot, "app.sln"), "");
439
+ ok("visualstudio detected with .sln", detectInstalledClients(detEnv).includes("visualstudio"));
440
+ const emptyHome = fs.mkdtempSync(path.join(os.tmpdir(), "memex-empty-"));
441
+ const detEmpty = detectInstalledClients({ pathEnv: "", home: emptyHome, platform: "linux", root: emptyHome, xdgConfigHome: path.join(emptyHome, ".config") });
442
+ ok("empty env detects nothing", detEmpty.length === 0);
443
+ const winBin = fs.mkdtempSync(path.join(os.tmpdir(), "memex-winbin-"));
444
+ fs.writeFileSync(path.join(winBin, "code.cmd"), "@echo off\n");
445
+ ok("win32 detects vscode via code.cmd",
446
+ detectInstalledClients({ pathEnv: winBin, home: emptyHome, platform: "win32", root: emptyHome, xdgConfigHome: path.join(emptyHome, ".config") }).includes("vscode"));
447
+ for (const d of [fakeHome, binDir, detHome, detRoot, emptyHome, winBin]) fs.rmSync(d, { recursive: true, force: true });
448
+
379
449
  console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAILURES`);
380
450
  process.exit(fails === 0 ? 0 : 1);
package/src/cli.ts CHANGED
@@ -279,16 +279,21 @@ Examples:
279
279
  agent memory instructions. Existing files are merged, never clobbered.
280
280
 
281
281
  Usage: open-memex init [--client vscode|cursor|opencode|visualstudio]
282
- [--instructions personal|project] [--force] [--yes]
282
+ [--instructions personal|project] [--global] [--force] [--yes]
283
283
 
284
284
  Flags:
285
- --client editor to configure (default: auto-detect)
285
+ --client editor to configure (default: auto-detect all installed editors)
286
286
  --instructions personal (default, ~/.copilot/copilot-instructions.md) or project
287
+ --global write the MCP server entry to the editor's user-level config
288
+ (VS Code / Cursor) — init once, works in every project.
289
+ For opencode, --global wires the native plugin at user level
290
+ (~/.config/opencode/opencode.json) — no per-project init needed.
287
291
  --force overwrite existing config
288
292
  --yes accept all defaults, never prompt
289
293
 
290
294
  Examples:
291
295
  open-memex init
296
+ open-memex init --client vscode --global --yes
292
297
  open-memex init --client cursor --yes`,
293
298
 
294
299
  config: `Show config, or set a key.
@@ -353,16 +358,19 @@ Usage:
353
358
  Every command has its own help with description and examples:
354
359
  open-memex <command> --help (or -h)
355
360
 
356
- One-command project setup: \`open-memex init\` (or \`npx open-memex@alpha init\`) writes
357
- the MCP config for your editor (\`.vscode/mcp.json\`, \`.cursor/mcp.json\`,
358
- \`opencode.jsonc\`, or Visual Studio's solution-level \`.mcp.json\`) — no copy-paste
359
- needed. The Copilot memory instructions default to your user-level
361
+ One-command project setup: \`open-memex init\` (or \`npx open-memex@alpha init\`) detects
362
+ your installed editors and wires them all — user-level where the editor supports it
363
+ (VS Code / Cursor MCP config, opencode native plugin), so one init covers every project;
364
+ Visual Studio is included when the project has a solution file (its \`.mcp.json\`
365
+ stays solution-level by design). \`--client\` picks a single editor instead, and a
366
+ single-editor opencode init writes the per-project plain-MCP \`opencode.jsonc\`.
367
+ The Copilot memory instructions default to your user-level
360
368
  \`~/.copilot/copilot-instructions.md\` (all projects, never checked into a repo);
361
369
  \`--instructions project\` writes \`.github/copilot-instructions.md\` instead for
362
370
  teams where everyone uses open-memex.
363
371
  Existing files are merged, never clobbered; re-running is safe. On a terminal it
364
- asks which editor to set up and a couple of settings (keyword capture, first-turn
365
- injection); \`--yes\` accepts all defaults, and non-terminal runs never prompt.
372
+ confirms the detected editors and asks a couple of settings (keyword capture,
373
+ first-turn injection); \`--yes\` accepts all defaults, and non-terminal runs never prompt.
366
374
  \`open-memex config set <key> <value>\` changes those settings after install.
367
375
 
368
376
  Once installed globally (\`npm i -g open-memex@alpha\`) the \`open-memex\` command is
@@ -595,7 +603,7 @@ async function main() {
595
603
  }
596
604
 
597
605
  // `init` is a pure file operation (§17 adoption path) — no DB needed.
598
- // Interactive when on a TTY (asks editor + settings); --yes skips prompts.
606
+ // Interactive when on a TTY (confirms detected editors + settings); --yes skips prompts.
599
607
  if (cmd === "init") {
600
608
  const flags = parseFlags(rest);
601
609
  const { initProject } = await import("./init.ts");
@@ -604,6 +612,7 @@ async function main() {
604
612
  force: flags["force"] === "true",
605
613
  yes: flags["yes"] === "true",
606
614
  instructions: flags["instructions"],
615
+ global: flags["global"] === "true",
607
616
  });
608
617
  return;
609
618
  }