@vkmikc/create-obsidian-memory 3.0.0

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/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # @vkmikc/create-obsidian-memory
2
+
3
+ Interactive initializer for **Obsidian-style, file-based agent memory** — a Markdown vault your
4
+ AI coding agent reads and writes across sessions, wired to your IDE over **MCP**.
5
+
6
+ It configures the [`basic-memory`](https://github.com/basicmachines-co/basic-memory) MCP server
7
+ (and, optionally, the `obsidian-memory-hybrid` BM25 + semantic retrieval sidecar) for **Cursor**
8
+ and/or **Claude Code**, points it at your vault, and can build the local search index — in one
9
+ command.
10
+
11
+ Part of the [cursor-obsidian-memory-guide](https://github.com/Vahlame/cursor-obsidian-memory-guide)
12
+ kit. Full docs (English + Spanish), architecture and ADRs live there.
13
+
14
+ ## Quick start
15
+
16
+ ```bash
17
+ # Interactive (recommended the first time)
18
+ npm create @vkmikc/obsidian-memory@latest
19
+ # or
20
+ npx @vkmikc/create-obsidian-memory@latest
21
+ ```
22
+
23
+ The wizard asks for your vault path and which IDE(s) to wire, then writes the MCP config and an
24
+ example vault scaffold (`START_HERE.md`, `MEMORY.md`, `PROJECTS/`, `SESSION_LOG.md`).
25
+
26
+ ## One-command, non-interactive (CI / fresh PC)
27
+
28
+ ```bash
29
+ # Cursor + Claude Code, hybrid search with multilingual embeddings, index built — from a kit clone
30
+ node packages/create-obsidian-memory/src/index.js --non-interactive \
31
+ --vault "$HOME/my-vault" --ide cursor,claude \
32
+ --with-hybrid --semantic --build-index --repo-root .
33
+ ```
34
+
35
+ `--with-hybrid` needs a local clone of the kit (it wires the Node bridge + Python backend), so run
36
+ it from the clone or pass `--repo-root <clone>`. The plain `basic-memory` path needs no clone.
37
+
38
+ ## Options
39
+
40
+ | Flag | Purpose |
41
+ | ---------------------------- | ----------------------------------------------------------------------------------------------- |
42
+ | `--lang en` | English prompts (default is Spanish-first). |
43
+ | `--dry-run` | Print the merged Cursor `mcp.json` only — no writes. |
44
+ | `--non-interactive`, `--yes` | Headless mode (no prompts); requires `--vault`. |
45
+ | `--vault <path>` | Vault root (absolute or cwd-relative). **Required** in non-interactive mode. |
46
+ | `--ide <list>` | IDEs to wire, comma-separated: `cursor`, `claude` (default: `cursor`). |
47
+ | `--no-cursor-mcp` | Skip writing `~/.cursor/mcp.json`. |
48
+ | `--no-git-init` | Skip `git init` when the vault has no `.git`. |
49
+ | `--with-hybrid` | Also wire `obsidian-memory-hybrid` (needs a kit clone; use `--repo-root` or cwd walk). |
50
+ | `--repo-root <path>` | Root of the `cursor-obsidian-memory-guide` clone (hybrid bridge + Python source). |
51
+ | `--semantic` | With `--with-hybrid`: neural embeddings (fastembed multilingual; needs the `[semantic]` extra). |
52
+ | `--build-index` | After wiring, build the local FTS (+ semantic) index (needs the Python backend). |
53
+ | `--with-gitleaks` | Install a gitleaks pre-commit hook in `<vault>/.git/hooks/`. |
54
+ | `--help` | Show usage. |
55
+
56
+ - `cursor` writes `~/.cursor/mcp.json`.
57
+ - `claude` registers servers via the Claude Code CLI (`claude mcp add -s user`).
58
+
59
+ ## Requirements
60
+
61
+ - **Node.js ≥ 20**
62
+ - **[`uv`](https://docs.astral.sh/uv/)** (for `uvx basic-memory mcp`)
63
+ - For `--with-hybrid` / `--build-index`: **Python ≥ 3.11** and the kit's `obsidian-memory-rag` package.
64
+ - For `--ide claude`: the **Claude Code** CLI on `PATH`.
65
+
66
+ ## License
67
+
68
+ MIT © Vahlame. See the [repository](https://github.com/Vahlame/cursor-obsidian-memory-guide) for details.
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@vkmikc/create-obsidian-memory",
3
+ "version": "3.0.0",
4
+ "description": "Interactive initializer for Obsidian-style agent memory (v3 kit)",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/Vahlame/cursor-obsidian-memory-guide#readme",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/Vahlame/cursor-obsidian-memory-guide.git",
10
+ "directory": "packages/create-obsidian-memory"
11
+ },
12
+ "keywords": [
13
+ "mcp",
14
+ "obsidian",
15
+ "cursor",
16
+ "claude-code",
17
+ "agent-memory",
18
+ "basic-memory"
19
+ ],
20
+ "files": [
21
+ "src"
22
+ ],
23
+ "type": "module",
24
+ "bin": {
25
+ "create-obsidian-memory": "src/index.js"
26
+ },
27
+ "scripts": {
28
+ "test": "node --test"
29
+ },
30
+ "dependencies": {
31
+ "execa": "^9.5.2",
32
+ "fs-extra": "^11.2.0",
33
+ "picocolors": "^1.1.1",
34
+ "prompts": "^2.4.2"
35
+ },
36
+ "engines": {
37
+ "node": ">=20"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ }
42
+ }
package/src/index.js ADDED
@@ -0,0 +1,706 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @vkmikc/create-obsidian-memory — interactive initializer (v2 / v3).
4
+ * Spanish-first CLI; pass --lang en for English labels.
5
+ *
6
+ * Source-of-truth lives in this `src/` directory. There is no `dist/` build
7
+ * step — `src/` is what npm publishes and what `bin` in package.json points
8
+ * to. (Pre-2026 the directory was named `dist/`, which falsely implied a
9
+ * compile step. Renamed for clarity; see CHANGELOG.)
10
+ */
11
+ import path from "node:path";
12
+ import pc from "picocolors";
13
+ import prompts from "prompts";
14
+ import { execa } from "execa";
15
+ import fse from "fs-extra";
16
+ import {
17
+ mergeBasicMemoryServer,
18
+ mergeObsidianHybridServer,
19
+ resolveKitRepoRoot,
20
+ hybridMcpPathsFromKitRoot,
21
+ flagValue,
22
+ basicMemoryServer,
23
+ hybridServer,
24
+ claudeAddArgv,
25
+ claudeRemoveArgv,
26
+ SEMANTIC_EMBEDDER
27
+ } from "./mcp-merge.mjs";
28
+
29
+ /** Cursor/VS Code workspace defaults: fewer `git` + `conhost` spikes on Windows (SCM polling). */
30
+ const VAULT_VSCODE_GIT_SETTINGS = {
31
+ "git.autoRepositoryDetection": false,
32
+ "git.autorefresh": false,
33
+ "git.autofetch": false,
34
+ "git.terminalAuthentication": false,
35
+ "git.decorations.enabled": false,
36
+ "git.timeline.enabled": false,
37
+ "git.blame.editorDecoration.enabled": false,
38
+ "git.blame.statusBarItem.enabled": false,
39
+ "git.showProgress": false,
40
+ "npm.autoDetect": "off",
41
+ "files.watcherExclude": {
42
+ "**/node_modules/**": true,
43
+ "**/bin/**": true,
44
+ "**/dist/**": true,
45
+ "**/.cursor/**": true,
46
+ "**/packages/**/node_modules/**": true,
47
+ "**/.pytest_cache/**": true,
48
+ "**/coverage/**": true,
49
+ "**/.venv/**": true,
50
+ "**/__pycache__/**": true,
51
+ "**/.obsidian/**": true,
52
+ "**/go.work.sum": true,
53
+ "**/.git/objects/**": true,
54
+ "**/.git/lfs/**": true
55
+ }
56
+ };
57
+
58
+ const messages = {
59
+ es: {
60
+ title: "create-obsidian-memory",
61
+ vaultQ: "Ruta del vault (debe contener .obsidian o crearemos uno)",
62
+ createVault: "Crear ./obsidian-vault de ejemplo",
63
+ ides: "IDEs a configurar (espacio para MCP)",
64
+ gitleaks: "Activar hook pre-commit gitleaks",
65
+ age: "Activar cifrado age para datos sensibles (mas friccion)",
66
+ daemon: "Instalar obsidian-memoryd como servicio de usuario",
67
+ summary: "Listo. Pasos siguientes",
68
+ otherIdes: "Copia este bloque MCP en la config del IDE:",
69
+ ftsHint:
70
+ "Opcional (vaults grandes): MCP obsidian-memory-hybrid (tras pip install -e …/obsidian-memory-rag) o obsidian-memory-rag index manual; ver docs/es/instalacion.md (Verificación).",
71
+ hybridQ:
72
+ "¿Añadir MCP obsidian-memory-hybrid (FTS5 / BM25) además de basic-memory? (requiere clon del kit y pip install -e packages/obsidian-memory-rag)",
73
+ semanticQ:
74
+ "¿Usar embeddings neuronales (fastembed) para recall por significado? (requiere el extra [semantic])"
75
+ },
76
+ en: {
77
+ title: "create-obsidian-memory",
78
+ vaultQ: "Vault path (must contain .obsidian or we create a sample)",
79
+ createVault: "Create ./obsidian-vault sample",
80
+ ides: "IDEs to wire for MCP",
81
+ gitleaks: "Enable gitleaks pre-commit hook",
82
+ age: "Enable age encryption (more friction)",
83
+ daemon: "Install obsidian-memoryd user service",
84
+ summary: "Done. Next steps",
85
+ otherIdes: "Paste this MCP block into each IDE's config:",
86
+ ftsHint:
87
+ "Optional (large vaults): obsidian-memory-hybrid MCP (after pip install -e …/obsidian-memory-rag) or manual obsidian-memory-rag index; see docs/en/install.md (Verification).",
88
+ hybridQ:
89
+ "Add obsidian-memory-hybrid MCP (FTS5 / BM25) in addition to basic-memory? (needs this repo clone + pip install -e packages/obsidian-memory-rag)",
90
+ semanticQ:
91
+ "Use neural embeddings (fastembed) for meaning-based recall? (needs the [semantic] extra)"
92
+ }
93
+ };
94
+
95
+ function langFromArgs() {
96
+ const i = process.argv.indexOf("--lang");
97
+ if (i >= 0 && process.argv[i + 1] === "en") return "en";
98
+ return "es";
99
+ }
100
+
101
+ function dryRunFromArgs() {
102
+ return process.argv.includes("--dry-run");
103
+ }
104
+
105
+ function nonInteractiveFromArgs() {
106
+ return process.argv.includes("--non-interactive") || process.argv.includes("--yes");
107
+ }
108
+
109
+ async function findVault(cwd, home) {
110
+ let cur = cwd;
111
+ for (let i = 0; i < 6; i++) {
112
+ if (await fse.pathExists(path.join(cur, ".obsidian"))) return cur;
113
+ const parent = path.dirname(cur);
114
+ if (parent === cur) break;
115
+ cur = parent;
116
+ }
117
+ const h = path.join(home, "Documents");
118
+ if (await fse.pathExists(path.join(h, ".obsidian"))) return h;
119
+ return null;
120
+ }
121
+
122
+ /**
123
+ * Merges kit Git/SCM tuning into `<vault>/.vscode/settings.json` (creates or updates).
124
+ * Kit keys win for known tuning; `files.watcherExclude` is merged with existing entries.
125
+ * @param {string} vault
126
+ * @param {boolean} dryRun
127
+ */
128
+ async function writeVaultGitWorkspaceSettings(vault, dryRun) {
129
+ const fp = path.join(vault, ".vscode", "settings.json");
130
+ if (dryRun) {
131
+ console.log(pc.cyan("[dry-run] would merge"), fp);
132
+ return;
133
+ }
134
+ await fse.ensureDir(path.dirname(fp));
135
+ const existedBefore = await fse.pathExists(fp);
136
+ let existing = {};
137
+ if (existedBefore) {
138
+ try {
139
+ const raw = (await fse.readFile(fp, "utf8")).trim().replace(/^\uFEFF/, "");
140
+ if (raw) existing = JSON.parse(raw);
141
+ } catch {
142
+ const bak = `${fp}.bak.${Date.now()}`;
143
+ await fse.copy(fp, bak);
144
+ console.warn(pc.yellow("Invalid JSON in vault .vscode/settings.json; backed up to"), bak);
145
+ existing = {};
146
+ }
147
+ }
148
+ const merged = { ...existing };
149
+ for (const [key, value] of Object.entries(VAULT_VSCODE_GIT_SETTINGS)) {
150
+ if (key === "files.watcherExclude" && value && typeof value === "object") {
151
+ const prev = existing[key] && typeof existing[key] === "object" ? existing[key] : {};
152
+ merged[key] = { ...prev, ...value };
153
+ } else {
154
+ merged[key] = value;
155
+ }
156
+ }
157
+ if (process.platform === "win32") {
158
+ const candidates = [
159
+ "C:\\Program Files\\Git\\cmd\\git.exe",
160
+ path.join(process.env.ProgramFiles || "C:\\Program Files", "Git", "cmd", "git.exe")
161
+ ];
162
+ const pf86 = process.env["ProgramFiles(x86)"];
163
+ if (pf86) candidates.push(path.join(pf86, "Git", "cmd", "git.exe"));
164
+ for (const g of candidates) {
165
+ if (g && (await fse.pathExists(g))) {
166
+ merged["git.path"] = g;
167
+ break;
168
+ }
169
+ }
170
+ }
171
+ await fse.writeFile(fp, `${JSON.stringify(merged, null, 2)}\n`, "utf8");
172
+ console.log(pc.green(existedBefore ? "Merged" : "Wrote"), fp);
173
+ }
174
+
175
+ async function scaffoldNewVault(vault, lang, dryRun) {
176
+ await fse.ensureDir(path.join(vault, ".obsidian"));
177
+ const start =
178
+ lang === "en"
179
+ ? `---
180
+ type: index
181
+ tags: [start]
182
+ ---
183
+
184
+ # START_HERE
185
+
186
+ 1. Read \`MEMORY.md\`.
187
+ 2. Then \`PROJECTS/<your-repo>.md\` (match your workspace folder name).
188
+ 3. Log decisions in \`SESSION_LOG.md\`.
189
+ `
190
+ : `---
191
+ type: index
192
+ tags: [start]
193
+ ---
194
+
195
+ # START_HERE
196
+
197
+ 1. Lee \`MEMORY.md\`.
198
+ 2. Luego \`PROJECTS/<tu-repo>.md\` (ajusta el nombre a tu carpeta de proyecto).
199
+ 3. Cierra tareas en \`SESSION_LOG.md\`.
200
+ `;
201
+ await fse.writeFile(path.join(vault, "START_HERE.md"), start, "utf8");
202
+ await fse.writeFile(
203
+ path.join(vault, "MEMORY.md"),
204
+ lang === "en"
205
+ ? "# Global memory\n\nSeparate **facts** vs **hypotheses** explicitly.\n"
206
+ : "# Memoria global\n\nSepara **hechos** e **hipótesis** explícitamente.\n",
207
+ "utf8"
208
+ );
209
+ await fse.writeFile(path.join(vault, "SESSION_LOG.md"), "# SESSION_LOG\n\n", "utf8");
210
+ await fse.ensureDir(path.join(vault, "PROJECTS"));
211
+ await fse.writeFile(path.join(vault, "PROJECTS", ".gitkeep"), "", "utf8");
212
+ await fse.writeFile(path.join(vault, ".gitignore"), ".obsidian-memory-rag/\n", "utf8");
213
+ await writeVaultGitWorkspaceSettings(vault, dryRun);
214
+ }
215
+
216
+ /** Strip UTF-8 BOM so JSON.parse succeeds (common when mcp.json was saved from PowerShell). */
217
+ function stripLeadingUtf8Bom(text) {
218
+ return typeof text === "string" && text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
219
+ }
220
+
221
+ /**
222
+ * Write JSON to `fp` atomically: stage at `<fp>.tmp.<pid>.<ts>`, fsync, rename.
223
+ * Crash mid-write leaves the original `fp` intact rather than truncating it.
224
+ * On Linux/macOS the final file is chmod 0o600 — `mcp.json` may carry env
225
+ * blocks for other MCP servers (API tokens, etc.) that shouldn't be world-readable.
226
+ * @param {string} fp - target path
227
+ * @param {unknown} data - JSON-serializable payload
228
+ */
229
+ async function atomicWriteJson(fp, data) {
230
+ await fse.ensureDir(path.dirname(fp));
231
+ const tmp = `${fp}.tmp.${process.pid}.${Date.now()}`;
232
+ await fse.writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, "utf8");
233
+ if (process.platform !== "win32") {
234
+ try {
235
+ await fse.chmod(tmp, 0o600);
236
+ } catch {
237
+ /* best-effort: not all filesystems support chmod */
238
+ }
239
+ }
240
+ await fse.rename(tmp, fp);
241
+ }
242
+
243
+ /**
244
+ * @param {string} home
245
+ * @param {string} vaultAbs
246
+ * @param {boolean} dryRun
247
+ * @param {{ withHybrid?: boolean, repoRoot?: string | null }} [hybridOpts]
248
+ */
249
+ async function writeCursorMcp(home, vaultAbs, dryRun, hybridOpts = {}) {
250
+ const dir = path.join(home, ".cursor");
251
+ const fp = path.join(dir, "mcp.json");
252
+ let parsed = {};
253
+ /** @type {Buffer | null} */
254
+ let priorBytes = null;
255
+ if (await fse.pathExists(fp)) {
256
+ priorBytes = await fse.readFile(fp);
257
+ const text = stripLeadingUtf8Bom(priorBytes.toString("utf8"));
258
+ try {
259
+ parsed = JSON.parse(text);
260
+ } catch {
261
+ console.warn(pc.yellow("Invalid JSON in mcp.json; will back up the original before overwriting"));
262
+ }
263
+ }
264
+ let merged = mergeBasicMemoryServer(parsed, vaultAbs);
265
+ const { withHybrid = false, repoRoot = null, semantic = false } = hybridOpts;
266
+ if (withHybrid && repoRoot) {
267
+ merged = mergeObsidianHybridServer(merged, vaultAbs, path.resolve(repoRoot), { semantic });
268
+ }
269
+ if (dryRun) {
270
+ console.log(pc.cyan("[dry-run] would write"), fp);
271
+ console.log(JSON.stringify(merged, null, 2));
272
+ return;
273
+ }
274
+ await fse.ensureDir(dir);
275
+ // Always preserve the previous mcp.json before overwriting — agent-driven
276
+ // installs that go wrong should be 1 `mv` away from recovery, not a re-run
277
+ // of the IDE wizard. Backups are kept indefinitely; clean up manually.
278
+ if (priorBytes) {
279
+ const bak = `${fp}.bak.${Date.now()}`;
280
+ await fse.writeFile(bak, priorBytes);
281
+ if (process.platform !== "win32") {
282
+ try {
283
+ await fse.chmod(bak, 0o600);
284
+ } catch {
285
+ /* ignore */
286
+ }
287
+ }
288
+ console.log(pc.dim("Backed up previous mcp.json to"), bak);
289
+ }
290
+ await atomicWriteJson(fp, merged);
291
+ console.log(pc.green("Wrote"), fp);
292
+ }
293
+
294
+ /**
295
+ * Install a gitleaks `pre-commit` hook in the vault repo so secrets caught at
296
+ * commit time block both the user's interactive commits AND the obsidian-memoryd
297
+ * daemon's auto-commits. Falls through with a warning if gitleaks is not on PATH
298
+ * at commit time — does not hard-fail commits when the tool isn't installed.
299
+ * @param {string} vault
300
+ * @param {boolean} enable
301
+ * @param {boolean} dryRun
302
+ */
303
+ async function maybeInstallGitleaksHook(vault, enable, dryRun) {
304
+ if (!enable) return;
305
+ const gitDir = path.join(vault, ".git");
306
+ if (!(await fse.pathExists(gitDir))) {
307
+ console.warn(pc.yellow("gitleaks hook: vault has no .git; skipping (re-run after git init)"));
308
+ return;
309
+ }
310
+ const hookPath = path.join(gitDir, "hooks", "pre-commit");
311
+ const script = `#!/usr/bin/env sh
312
+ # obsidian-memory vault: gitleaks pre-commit guard
313
+ # Refuses commits that introduce secrets. Install gitleaks per OS:
314
+ # macOS: brew install gitleaks
315
+ # Windows: winget install gitleaks
316
+ # Linux: see https://github.com/gitleaks/gitleaks#installing
317
+ if ! command -v gitleaks >/dev/null 2>&1; then
318
+ echo "gitleaks not installed; pre-commit guard skipped." >&2
319
+ echo " install: https://github.com/gitleaks/gitleaks" >&2
320
+ exit 0
321
+ fi
322
+ exec gitleaks protect --staged --no-banner --redact
323
+ `;
324
+ if (dryRun) {
325
+ console.log(pc.cyan("[dry-run] would install"), hookPath);
326
+ return;
327
+ }
328
+ await fse.ensureDir(path.dirname(hookPath));
329
+ await fse.writeFile(hookPath, script, "utf8");
330
+ if (process.platform !== "win32") {
331
+ try {
332
+ await fse.chmod(hookPath, 0o755);
333
+ } catch {
334
+ /* ignore */
335
+ }
336
+ }
337
+ console.log(pc.green("Installed gitleaks pre-commit hook at"), hookPath);
338
+ }
339
+
340
+ /** Parse `--ide cursor,claude` → lowercased array; default ["cursor"] (back-compat). */
341
+ function idesFromArgs(argv) {
342
+ const raw = flagValue(argv, "--ide");
343
+ if (!raw) return ["cursor"];
344
+ return raw
345
+ .split(",")
346
+ .map((s) => s.trim().toLowerCase())
347
+ .filter(Boolean);
348
+ }
349
+
350
+ /**
351
+ * Register the MCP servers in Claude Code via its CLI (`claude mcp add -s user`) —
352
+ * Claude Code does not read an mcp.json file. Idempotent: removes any same-scope
353
+ * entry first. Falls back to printing the command if `claude` isn't on PATH.
354
+ * @param {string} vaultAbs
355
+ * @param {boolean} dryRun
356
+ * @param {{ withHybrid?: boolean, repoRoot?: string|null, semantic?: boolean }} [hybridOpts]
357
+ */
358
+ async function registerClaudeCodeMcp(vaultAbs, dryRun, hybridOpts = {}) {
359
+ const { withHybrid = false, repoRoot = null, semantic = false } = hybridOpts;
360
+ /** @type {Array<[string, object]>} */
361
+ const servers = [["basic-memory", basicMemoryServer(vaultAbs)]];
362
+ if (withHybrid && repoRoot) {
363
+ servers.push([
364
+ "obsidian-memory-hybrid",
365
+ hybridServer(vaultAbs, path.resolve(repoRoot), { semantic })
366
+ ]);
367
+ }
368
+ for (const [name, server] of servers) {
369
+ const addArgv = claudeAddArgv(name, server);
370
+ if (dryRun) {
371
+ console.log(pc.cyan("[dry-run] claude"), addArgv.join(" "));
372
+ continue;
373
+ }
374
+ try {
375
+ await execa("claude", claudeRemoveArgv(name), { reject: false }); // ignore "not found"
376
+ const r = await execa("claude", addArgv, { reject: false });
377
+ if (r.exitCode === 0) console.log(pc.green("Claude Code MCP added:"), name);
378
+ else {
379
+ console.warn(pc.yellow(`claude mcp add ${name} exited ${r.exitCode}; run manually:`));
380
+ console.log(" claude " + addArgv.join(" "));
381
+ }
382
+ } catch {
383
+ console.warn(pc.yellow("`claude` CLI not found; run manually:"));
384
+ console.log(" claude " + addArgv.join(" "));
385
+ }
386
+ }
387
+ }
388
+
389
+ /**
390
+ * Optionally build the local FTS (+semantic) index so search works on first use.
391
+ * Best-effort: prints the pip command if the Python backend isn't importable.
392
+ * @param {string} vaultAbs
393
+ * @param {boolean} dryRun
394
+ * @param {{ repoRoot?: string|null, semantic?: boolean }} [opts]
395
+ */
396
+ async function maybeBuildIndex(vaultAbs, dryRun, { repoRoot = null, semantic = false } = {}) {
397
+ if (!repoRoot) return;
398
+ const pySrc = path.join(repoRoot, "packages", "obsidian-memory-rag", "src");
399
+ const args = ["-m", "obsidian_memory_rag", "index", "--vault", vaultAbs];
400
+ if (semantic) args.push("--semantic", "--embedder", SEMANTIC_EMBEDDER);
401
+ const py = process.platform === "win32" ? "python" : "python3";
402
+ if (dryRun) {
403
+ console.log(pc.cyan("[dry-run] would build index:"), py, args.join(" "));
404
+ return;
405
+ }
406
+ try {
407
+ const r = await execa(py, args, {
408
+ env: { ...process.env, PYTHONPATH: pySrc, PYTHONUTF8: "1" },
409
+ reject: false
410
+ });
411
+ if (r.exitCode === 0) console.log(pc.green("Index built"), semantic ? "(semantic)" : "(FTS)");
412
+ else {
413
+ const ragPkg = path.join(repoRoot, "packages", "obsidian-memory-rag");
414
+ console.warn(pc.yellow("Index build skipped/failed — install the backend first:"));
415
+ console.log(' pip install -e "' + ragPkg + (semantic ? '[semantic]"' : '"'));
416
+ }
417
+ } catch {
418
+ console.warn(pc.yellow("python not found; build the index later (see docs install-fresh-pc)."));
419
+ }
420
+ }
421
+
422
+ /**
423
+ * Headless / CI path: no prompts. Requires --vault.
424
+ * @param {string[]} argv
425
+ */
426
+ async function runNonInteractive(argv) {
427
+ const cwd = process.cwd();
428
+ const home = process.env.HOME || process.env.USERPROFILE || cwd;
429
+ const lang = langFromArgs();
430
+ const dryRun = dryRunFromArgs();
431
+ const t = messages[lang];
432
+ const vaultRaw = flagValue(argv, "--vault");
433
+ if (!vaultRaw) {
434
+ console.error(pc.red("--vault <path> is required with --non-interactive"));
435
+ process.exit(2);
436
+ }
437
+ const vault = path.resolve(cwd, vaultRaw);
438
+ if (!(await fse.pathExists(vault))) {
439
+ console.error(pc.red("Vault path does not exist:"), vault);
440
+ process.exit(2);
441
+ }
442
+ const noCursorMcp = argv.includes("--no-cursor-mcp");
443
+ const noGitInit = argv.includes("--no-git-init");
444
+ const wantHybrid = argv.includes("--with-hybrid");
445
+ const wantSemantic = argv.includes("--semantic");
446
+ const wantGitleaks = argv.includes("--with-gitleaks");
447
+ const wantBuildIndex = argv.includes("--build-index");
448
+ const ides = idesFromArgs(argv);
449
+ let kitRoot = null;
450
+
451
+ if (wantHybrid) {
452
+ kitRoot = await resolveKitRepoRoot({ cwd, argv, pathExists: (p) => fse.pathExists(p) });
453
+ if (!kitRoot) {
454
+ console.error(
455
+ pc.red(
456
+ "--with-hybrid: pass --repo-root <path-to-cursor-obsidian-memory-guide-clone> or run from that clone (cwd walk)."
457
+ )
458
+ );
459
+ process.exit(2);
460
+ }
461
+ const { hybridJs, pythonSrc } = hybridMcpPathsFromKitRoot(kitRoot);
462
+ if (!(await fse.pathExists(hybridJs))) {
463
+ console.error(pc.red("--with-hybrid: missing"), hybridJs);
464
+ process.exit(2);
465
+ }
466
+ if (!(await fse.pathExists(pythonSrc))) {
467
+ console.error(pc.red("--with-hybrid: missing"), pythonSrc);
468
+ process.exit(2);
469
+ }
470
+ }
471
+
472
+ console.log(pc.cyan(t.title), pc.dim("non-interactive"));
473
+
474
+ const mcpSnippet = {
475
+ command: "uvx",
476
+ args: ["basic-memory", "mcp"],
477
+ env: { BASIC_MEMORY_HOME: vault }
478
+ };
479
+
480
+ const hybridOpts = { withHybrid: wantHybrid, repoRoot: kitRoot, semantic: wantSemantic };
481
+ if (ides.includes("cursor") && !noCursorMcp) {
482
+ await writeCursorMcp(home, vault, dryRun, hybridOpts);
483
+ } else if (ides.includes("cursor") && noCursorMcp) {
484
+ console.log(pc.dim("Skipped Cursor mcp.json (--no-cursor-mcp)"));
485
+ }
486
+ if (ides.includes("claude")) {
487
+ await registerClaudeCodeMcp(vault, dryRun, hybridOpts);
488
+ }
489
+ if (wantBuildIndex) {
490
+ await maybeBuildIndex(vault, dryRun, { repoRoot: kitRoot, semantic: wantSemantic });
491
+ }
492
+
493
+ if (!noGitInit && !(await fse.pathExists(path.join(vault, ".git")))) {
494
+ await execa("git", ["init"], { cwd: vault, stdio: "inherit" });
495
+ }
496
+
497
+ await writeVaultGitWorkspaceSettings(vault, dryRun);
498
+ await maybeInstallGitleaksHook(vault, wantGitleaks, dryRun);
499
+
500
+ console.log(pc.green("\n" + t.summary));
501
+ console.log("- Vault:", vault);
502
+ console.log("- MCP:", JSON.stringify(mcpSnippet));
503
+ if (wantHybrid && kitRoot) {
504
+ console.log("- obsidian-memory-hybrid: merged (kit root", kitRoot + ")");
505
+ const ragPkg = path.join(kitRoot, "packages", "obsidian-memory-rag");
506
+ console.log(pc.dim(' pip install -e "' + ragPkg + (wantSemantic ? '[semantic]"' : '"')));
507
+ if (wantSemantic) {
508
+ console.log(
509
+ pc.dim(" embedder: fastembed (OBSIDIAN_MEMORY_EMBEDDER); build once with vault_fts_index semantic:true")
510
+ );
511
+ }
512
+ }
513
+ if (ides.includes("claude")) {
514
+ console.log("- Claude Code: MCP registered via `claude mcp add -s user` (verify: `claude mcp list`)");
515
+ }
516
+ if (wantGitleaks) {
517
+ console.log("- gitleaks pre-commit hook: installed (vault/.git/hooks/pre-commit)");
518
+ }
519
+ console.log("-", t.ftsHint);
520
+ }
521
+
522
+ async function main() {
523
+ const argv = process.argv;
524
+ if (argv.includes("--help")) {
525
+ console.log(`Usage: create-obsidian-memory [options]
526
+
527
+ Interactive (default):
528
+ --lang en English prompts
529
+ --dry-run Show merged Cursor mcp.json only (no write)
530
+
531
+ Non-interactive (CI / scripts):
532
+ --non-interactive | --yes
533
+ --vault <path> Absolute or cwd-relative vault root (required)
534
+ --ide <list> IDEs to wire, comma-separated: cursor, claude (default: cursor).
535
+ cursor writes ~/.cursor/mcp.json; claude uses the Claude Code CLI (claude mcp add -s user)
536
+ --no-cursor-mcp Skip writing ~/.cursor/mcp.json
537
+ --no-git-init Skip git init when .git is missing
538
+ (Merges kit Git/SCM keys into <vault>/.vscode/settings.json — creates or updates.)
539
+ --with-hybrid Also wire obsidian-memory-hybrid (needs kit clone; use --repo-root or cwd walk)
540
+ --repo-root <path> Root of cursor-obsidian-memory-guide clone (hybrid bridge + Python src)
541
+ --semantic With --with-hybrid: neural embeddings (fastembed multilingual; needs the [semantic] extra)
542
+ --build-index After wiring, build the local FTS (+semantic) index (needs the Python backend)
543
+ --with-gitleaks Install gitleaks pre-commit hook in <vault>/.git/hooks/
544
+
545
+ --help This message`);
546
+ return;
547
+ }
548
+
549
+ if (nonInteractiveFromArgs()) {
550
+ await runNonInteractive(argv);
551
+ return;
552
+ }
553
+
554
+ const lang = langFromArgs();
555
+ const dryRun = dryRunFromArgs();
556
+ const t = messages[lang];
557
+ console.log(pc.cyan(t.title), pc.dim("v2 / v3"));
558
+ if (dryRun) console.log(pc.dim("dry-run: Cursor mcp.json will not be written"));
559
+
560
+ const cwd = process.cwd();
561
+ const home = process.env.HOME || process.env.USERPROFILE || cwd;
562
+ let vault = await findVault(cwd, home);
563
+
564
+ if (!vault) {
565
+ const { ok } = await prompts({
566
+ type: "confirm",
567
+ name: "ok",
568
+ message: t.createVault,
569
+ initial: true
570
+ });
571
+ if (ok) {
572
+ vault = path.join(cwd, "obsidian-vault");
573
+ await scaffoldNewVault(vault, lang, dryRun);
574
+ } else {
575
+ const { p } = await prompts({
576
+ type: "text",
577
+ name: "p",
578
+ message: t.vaultQ,
579
+ initial: cwd
580
+ });
581
+ vault = p;
582
+ }
583
+ }
584
+
585
+ if (!vault) {
586
+ console.error(pc.red("No vault path; aborted."));
587
+ process.exit(1);
588
+ }
589
+ vault = path.resolve(cwd, vault);
590
+
591
+ const { ides } = await prompts({
592
+ type: "multiselect",
593
+ name: "ides",
594
+ message: t.ides,
595
+ choices: [
596
+ { title: "Cursor", value: "cursor", selected: true },
597
+ { title: "Claude Code", value: "claude", selected: false },
598
+ { title: "VS Code / Cline", value: "cline", selected: false },
599
+ { title: "Windsurf", value: "windsurf", selected: false },
600
+ { title: "Zed", value: "zed", selected: false }
601
+ ]
602
+ });
603
+
604
+ const { gitleaks } = await prompts({
605
+ type: "confirm",
606
+ name: "gitleaks",
607
+ message: t.gitleaks,
608
+ initial: true
609
+ });
610
+
611
+ const { age } = await prompts({
612
+ type: "confirm",
613
+ name: "age",
614
+ message: t.age,
615
+ initial: false
616
+ });
617
+
618
+ const { daemon } = await prompts({
619
+ type: "confirm",
620
+ name: "daemon",
621
+ message: t.daemon,
622
+ initial: process.platform !== "win32"
623
+ });
624
+
625
+ let hybridOpts = { withHybrid: false, repoRoot: null };
626
+ if (ides?.includes("cursor") || ides?.includes("claude")) {
627
+ const kitRoot = await resolveKitRepoRoot({
628
+ cwd,
629
+ argv: process.argv,
630
+ pathExists: (p) => fse.pathExists(p)
631
+ });
632
+ if (kitRoot) {
633
+ const { hybrid } = await prompts({
634
+ type: "confirm",
635
+ name: "hybrid",
636
+ message: t.hybridQ,
637
+ initial: false
638
+ });
639
+ if (hybrid) {
640
+ const { hybridJs, pythonSrc } = hybridMcpPathsFromKitRoot(kitRoot);
641
+ if ((await fse.pathExists(hybridJs)) && (await fse.pathExists(pythonSrc))) {
642
+ const { semantic } = await prompts({
643
+ type: "confirm",
644
+ name: "semantic",
645
+ message: t.semanticQ,
646
+ initial: false
647
+ });
648
+ hybridOpts = { withHybrid: true, repoRoot: kitRoot, semantic: Boolean(semantic) };
649
+ } else {
650
+ console.warn(pc.yellow("Hybrid paths not found; skipping obsidian-memory-hybrid."));
651
+ }
652
+ }
653
+ }
654
+ }
655
+
656
+ const mcpSnippet = {
657
+ command: "uvx",
658
+ args: ["basic-memory", "mcp"],
659
+ env: { BASIC_MEMORY_HOME: vault }
660
+ };
661
+
662
+ if (ides?.includes("cursor")) {
663
+ await writeCursorMcp(home, vault, dryRun, hybridOpts);
664
+ }
665
+ if (ides?.includes("claude")) {
666
+ await registerClaudeCodeMcp(vault, dryRun, hybridOpts);
667
+ }
668
+
669
+ const others = (ides || []).filter((x) => x !== "cursor" && x !== "claude");
670
+ if (others.length) {
671
+ console.log(pc.yellow(t.otherIdes), others.join(", "));
672
+ console.log(JSON.stringify({ mcpServers: { "basic-memory": mcpSnippet } }, null, 2));
673
+ }
674
+
675
+ if (!(await fse.pathExists(path.join(vault, ".git")))) {
676
+ await execa("git", ["init"], { cwd: vault, stdio: "inherit" });
677
+ }
678
+
679
+ await writeVaultGitWorkspaceSettings(vault, dryRun);
680
+ await maybeInstallGitleaksHook(vault, Boolean(gitleaks), dryRun);
681
+
682
+ console.log(pc.green("\n" + t.summary));
683
+ console.log("- Vault:", vault);
684
+ console.log("- MCP:", JSON.stringify(mcpSnippet));
685
+ if (hybridOpts.withHybrid && hybridOpts.repoRoot) {
686
+ console.log("- obsidian-memory-hybrid: enabled (kit", hybridOpts.repoRoot + ")");
687
+ const ragPkg = path.join(hybridOpts.repoRoot, "packages", "obsidian-memory-rag");
688
+ console.log(pc.dim(' pip install -e "' + ragPkg + (hybridOpts.semantic ? '[semantic]"' : '"')));
689
+ if (hybridOpts.semantic) {
690
+ console.log(pc.dim(" embedder: fastembed; build once with vault_fts_index semantic:true"));
691
+ }
692
+ }
693
+ console.log("-", t.ftsHint);
694
+ if (gitleaks) console.log("- gitleaks pre-commit hook: installed (vault/.git/hooks/pre-commit); install gitleaks CLI to activate");
695
+ if (age) console.log("- age: document keys outside repo");
696
+ if (daemon)
697
+ console.log(
698
+ "- obsidian-memoryd:",
699
+ "`obsidian-memoryd service install --user && obsidian-memoryd service start`"
700
+ );
701
+ }
702
+
703
+ main().catch((e) => {
704
+ console.error(e);
705
+ process.exit(1);
706
+ });
@@ -0,0 +1,171 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ /**
5
+ * Read the value following a `--flag` in an argv array, or null if absent.
6
+ * Shared by the initializer entrypoint (index.js) and resolveKitRepoRoot below.
7
+ * @param {string[]} argv
8
+ * @param {string} name
9
+ * @returns {string | null}
10
+ */
11
+ export function flagValue(argv, name) {
12
+ const i = argv.indexOf(name);
13
+ if (i >= 0 && i + 1 < argv.length) return argv[i + 1];
14
+ return null;
15
+ }
16
+
17
+ // Pinned basic-memory version. Bumping requires:
18
+ // 1. update this constant
19
+ // 2. update config/mcp/basic-memory.json
20
+ // 3. update scripts/mcp-smoke.mjs
21
+ // 4. update docs/es/instalacion.md + docs/en/install.md (User Rules) + docs/{es,en}/install-with-agent.md
22
+ // 5. mention the bump in CHANGELOG.md (with rationale: CVE? new tool? compat?)
23
+ // Rationale for pinning: `uvx <pkg> mcp` without a version pin pulls latest from
24
+ // PyPI on every Cursor restart — a supply-chain RCE if the package is taken over.
25
+ export const BASIC_MEMORY_VERSION = "0.21.4";
26
+
27
+ // Default neural embedder for opt-in semantic recall. Multilingual MiniLM so
28
+ // non-English vaults (e.g. Spanish) match by meaning, not just English. Needs the
29
+ // Python `[semantic]` extra. Used by BOTH the Cursor mcp.json merge and the
30
+ // Claude Code `claude mcp add` path so the two configs stay identical.
31
+ export const SEMANTIC_EMBEDDER =
32
+ "fastembed:sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2";
33
+
34
+ function basicMemoryArgs() {
35
+ return ["--from", `basic-memory==${BASIC_MEMORY_VERSION}`, "basic-memory", "mcp"];
36
+ }
37
+
38
+ /**
39
+ * Canonical `basic-memory` stdio server object ({command, args, env}).
40
+ * @param {string} vaultAbs
41
+ */
42
+ export function basicMemoryServer(vaultAbs) {
43
+ return { command: "uvx", args: basicMemoryArgs(), env: { BASIC_MEMORY_HOME: vaultAbs } };
44
+ }
45
+
46
+ /**
47
+ * Canonical `obsidian-memory-hybrid` stdio server object. The UTF-8 env vars keep
48
+ * the Python bridge correct on legacy Windows consoles; `semantic` wires the
49
+ * neural embedder. Shared by the Cursor merge and the Claude Code CLI path.
50
+ * @param {string} vaultAbs
51
+ * @param {string} kitRepoAbs
52
+ * @param {{ semantic?: boolean }} [opts]
53
+ */
54
+ export function hybridServer(vaultAbs, kitRepoAbs, opts = {}) {
55
+ const { hybridJs, pythonSrc } = hybridMcpPathsFromKitRoot(kitRepoAbs);
56
+ /** @type {Record<string, string>} */
57
+ const env = {
58
+ BASIC_MEMORY_HOME: vaultAbs,
59
+ PYTHONPATH: pythonSrc,
60
+ PYTHONUTF8: "1",
61
+ PYTHONIOENCODING: "utf-8"
62
+ };
63
+ if (opts && opts.semantic) env.OBSIDIAN_MEMORY_EMBEDDER = SEMANTIC_EMBEDDER;
64
+ return { command: "node", args: [hybridJs], env };
65
+ }
66
+
67
+ /**
68
+ * Build argv for `claude mcp add <name> -s <scope> -e K=V ... -- <command> <args...>`.
69
+ * Claude Code registers MCP through its CLI (not an mcp.json file), so the
70
+ * initializer shells out with this — reusing the same server objects as Cursor.
71
+ * @param {string} name
72
+ * @param {{ command: string, args?: string[], env?: Record<string,string> }} server
73
+ * @param {string} [scope] local | user | project (default `user` = every chat)
74
+ * @returns {string[]}
75
+ */
76
+ export function claudeAddArgv(name, server, scope = "user") {
77
+ const argv = ["mcp", "add", name, "-s", scope];
78
+ for (const [k, v] of Object.entries(server.env || {})) argv.push("-e", `${k}=${v}`);
79
+ argv.push("--", server.command, ...(server.args || []));
80
+ return argv;
81
+ }
82
+
83
+ /** Build argv for `claude mcp remove <name> -s <scope>` (makes `add` idempotent). */
84
+ export function claudeRemoveArgv(name, scope = "user") {
85
+ return ["mcp", "remove", name, "-s", scope];
86
+ }
87
+
88
+ /**
89
+ * Merge basic-memory MCP server entry into an existing mcp.json object.
90
+ * @param {unknown} raw - parsed JSON root object
91
+ * @param {string} vaultAbs - absolute vault path for BASIC_MEMORY_HOME
92
+ * @returns {Record<string, unknown>}
93
+ */
94
+ export function mergeBasicMemoryServer(raw, vaultAbs) {
95
+ const base =
96
+ typeof raw === "object" && raw !== null && !Array.isArray(raw)
97
+ ? /** @type {Record<string, unknown>} */ (JSON.parse(JSON.stringify(raw)))
98
+ : {};
99
+ const servers = base.mcpServers;
100
+ if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
101
+ base.mcpServers = {};
102
+ }
103
+ const mcpServers = /** @type {Record<string, unknown>} */ (base.mcpServers);
104
+ mcpServers["basic-memory"] = basicMemoryServer(vaultAbs);
105
+ return base;
106
+ }
107
+
108
+ /**
109
+ * Add `obsidian-memory-hybrid` MCP (Node bridge + Python FTS5) after `basic-memory` is set.
110
+ * @param {Record<string, unknown>} merged - output of mergeBasicMemoryServer (or compatible)
111
+ * @param {string} vaultAbs - absolute vault root
112
+ * @param {string} kitRepoAbs - absolute path to cursor-obsidian-memory-guide clone (contains packages/)
113
+ * @param {{ semantic?: boolean }} [opts] - semantic:true wires OBSIDIAN_MEMORY_EMBEDDER=fastembed
114
+ * so vault_hybrid_search ranks by meaning (needs the Python `[semantic]` extra installed).
115
+ */
116
+ export function mergeObsidianHybridServer(merged, vaultAbs, kitRepoAbs, opts = {}) {
117
+ const base = /** @type {Record<string, unknown>} */ (JSON.parse(JSON.stringify(merged)));
118
+ const servers = base.mcpServers;
119
+ if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
120
+ base.mcpServers = {};
121
+ }
122
+ const mcpServers = /** @type {Record<string, unknown>} */ (base.mcpServers);
123
+ mcpServers["obsidian-memory-hybrid"] = hybridServer(vaultAbs, kitRepoAbs, opts);
124
+ return base;
125
+ }
126
+
127
+ /** @param {string} dir */
128
+ export function hybridMcpPathsFromKitRoot(dir) {
129
+ const root = path.resolve(dir);
130
+ return {
131
+ root,
132
+ hybridJs: path.join(root, "packages", "obsidian-memory-mcp", "src", "hybrid-mcp.mjs"),
133
+ pythonSrc: path.join(root, "packages", "obsidian-memory-rag", "src")
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Resolve kit repo root: explicit --repo-root, layout next to this package in a monorepo clone, or walk cwd upward.
139
+ * @param {{ cwd: string, argv: string[], pathExists: (p: string) => Promise<boolean> }} opts
140
+ */
141
+ export async function resolveKitRepoRoot({ cwd, argv, pathExists }) {
142
+ const explicit = flagValue(argv, "--repo-root");
143
+ if (explicit) {
144
+ return path.resolve(cwd, explicit);
145
+ }
146
+ const here = path.dirname(fileURLToPath(import.meta.url));
147
+ const fromPackage = path.resolve(here, "..", "..", "..");
148
+ const hybridFromPackage = path.join(
149
+ fromPackage,
150
+ "packages",
151
+ "obsidian-memory-mcp",
152
+ "src",
153
+ "hybrid-mcp.mjs"
154
+ );
155
+ if (await pathExists(hybridFromPackage)) {
156
+ return fromPackage;
157
+ }
158
+ let cur = path.resolve(cwd);
159
+ for (let i = 0; i < 28; i++) {
160
+ const hybridJs = path.join(cur, "packages", "obsidian-memory-mcp", "src", "hybrid-mcp.mjs");
161
+ if (await pathExists(hybridJs)) {
162
+ return cur;
163
+ }
164
+ const parent = path.dirname(cur);
165
+ if (parent === cur) {
166
+ break;
167
+ }
168
+ cur = parent;
169
+ }
170
+ return null;
171
+ }