@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 +68 -0
- package/package.json +42 -0
- package/src/index.js +706 -0
- package/src/mcp-merge.mjs +171 -0
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
|
+
}
|