@noesis-brain/mcp-server 2.0.0 → 2.0.1

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.
@@ -1,469 +1,469 @@
1
- #!/usr/bin/env node
2
- /**
3
- * noesis-sync.mjs — Standalone sync script for Noesis cloud API.
4
- * Zero dependencies (Node.js 18+ built-ins only).
5
- *
6
- * Usage:
7
- * node noesis-sync.mjs # auto-detect from CWD
8
- * node noesis-sync.mjs --files a.md b.md # sync specific files
9
- * node noesis-sync.mjs --root my-notes # sync a named root
10
- * node noesis-sync.mjs --dry-run # preview only
11
- *
12
- * Credentials: reads from ~/.claude/.mcp.json → mcpServers.noesis.env
13
- * or env vars NOESIS_API_TOKEN / NOESIS_API_URL.
14
- */
15
-
16
- import { createHash } from 'node:crypto';
17
- import { readFileSync, statSync, existsSync, readdirSync } from 'node:fs';
18
- import { resolve, dirname, basename, relative, join, sep } from 'node:path';
19
- import { homedir } from 'node:os';
20
-
21
- // ── Path helpers ────────────────────────────────────────────────────
22
-
23
- /**
24
- * Tilde-expand `~/Noesis/...` against this machine's home directory.
25
- * Cloud-flow roots store `~` in the DB because the cloud doesn't know each
26
- * machine's $HOME — we expand here at sync time. No-op for already-
27
- * absolute paths.
28
- */
29
- function expandHome(p) {
30
- if (!p) return '';
31
- if (p === '~') return homedir();
32
- if (p.startsWith('~/') || p.startsWith('~\\')) {
33
- return join(homedir(), p.slice(2));
34
- }
35
- return p;
36
- }
37
-
38
- // ── Config ──────────────────────────────────────────────────────────
39
-
40
- function loadCredentials() {
41
- // Env vars take priority
42
- let token = process.env.NOESIS_API_TOKEN;
43
- let url = process.env.NOESIS_API_URL;
44
-
45
- if (token && url) return { token, url };
46
-
47
- // Fall back to ~/.claude/.mcp.json
48
- const mcpPath = join(homedir(), '.claude', '.mcp.json');
49
- if (!existsSync(mcpPath)) {
50
- console.error(`ERROR: No credentials. Set NOESIS_API_TOKEN + NOESIS_API_URL or configure ${mcpPath}`);
51
- process.exit(1);
52
- }
53
- const mcp = JSON.parse(readFileSync(mcpPath, 'utf-8'));
54
- const env = mcp?.mcpServers?.noesis?.env;
55
- if (!env?.NOESIS_API_TOKEN || !env?.NOESIS_API_URL) {
56
- console.error('ERROR: mcpServers.noesis.env missing NOESIS_API_TOKEN or NOESIS_API_URL in', mcpPath);
57
- process.exit(1);
58
- }
59
- return { token: env.NOESIS_API_TOKEN, url: env.NOESIS_API_URL };
60
- }
61
-
62
- // ── HTTP helpers ────────────────────────────────────────────────────
63
-
64
- async function api(method, path, body, { token, url }) {
65
- const res = await fetch(`${url.replace(/\/$/, '')}${path}`, {
66
- method,
67
- headers: {
68
- 'Authorization': `Bearer ${token}`,
69
- 'Content-Type': 'application/json',
70
- },
71
- body: body ? JSON.stringify(body) : undefined,
72
- });
73
- if (!res.ok) {
74
- const err = await res.json().catch(() => ({}));
75
- throw new Error(`API ${method} ${path} → ${res.status}: ${err.error || res.statusText}`);
76
- }
77
- return res.json();
78
- }
79
-
80
- // ── Hashing (matches NoesisClient.computeHash) ─────────────────────
81
-
82
- function computeHash(content) {
83
- const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
84
- return createHash('sha256').update(normalized, 'utf8').digest('hex');
85
- }
86
-
87
- // ── Git/root detection (mirrors tools/index.ts) ─────────────────────
88
-
89
- function findGitRoot(startPath) {
90
- let current = resolve(startPath);
91
- while (current !== dirname(current)) {
92
- if (existsSync(join(current, '.git'))) return current;
93
- current = dirname(current);
94
- }
95
- return null;
96
- }
97
-
98
- function detectRootFromCwd() {
99
- const gitRoot = findGitRoot(process.cwd());
100
- if (!gitRoot) return null;
101
- return { rootPath: dirname(gitRoot), projectName: basename(gitRoot) };
102
- }
103
-
104
- function detectProjectForFile(filePath, rootPath) {
105
- const rel = relative(rootPath, filePath);
106
- const parts = rel.split(sep).filter(Boolean);
107
- // Walk up from file to find .git
108
- let current = resolve(filePath);
109
- while (current !== resolve(rootPath) && current !== dirname(current)) {
110
- current = dirname(current);
111
- if (existsSync(join(current, '.git'))) return basename(current);
112
- }
113
- return parts[0] || basename(dirname(filePath));
114
- }
115
-
116
- // ── Path normalization ───────────────────────────────────────────────
117
-
118
- /**
119
- * Normalize a file path: forward slashes + lowercase drive letter on Windows.
120
- */
121
- function normalizePath(p) {
122
- let normalized = p.replace(/\\/g, '/');
123
- if (normalized.length >= 2 && normalized[1] === ':') {
124
- normalized = normalized[0].toLowerCase() + normalized.slice(1);
125
- }
126
- return normalized;
127
- }
128
-
129
- // ── File scanning ───────────────────────────────────────────────────
130
-
131
- function scanMarkdownFiles(rootPath) {
132
- const files = [];
133
- function walk(dir) {
134
- let entries;
135
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
136
- for (const e of entries) {
137
- const full = join(dir, e.name);
138
- if (e.isDirectory()) {
139
- if (e.name.startsWith('.') && e.name !== '.noesis' && e.name !== '.claude-notes') continue;
140
- if (e.name === 'node_modules' || e.name === 'dist' || e.name === '.git') continue;
141
- walk(full);
142
- } else if (e.name.endsWith('.md')) {
143
- files.push(full);
144
- }
145
- }
146
- }
147
- walk(rootPath);
148
- return files;
149
- }
150
-
151
- // ── Parse YAML frontmatter ──────────────────────────────────────────
152
-
153
- function parseFrontmatter(content) {
154
- const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
155
- if (!match) return {};
156
- const meta = {};
157
- const lines = match[1].split('\n');
158
- let i = 0;
159
- while (i < lines.length) {
160
- const keyMatch = lines[i].match(/^(\w[\w-]*):\s*(.*)/);
161
- if (!keyMatch) { i++; continue; }
162
- const key = keyMatch[1];
163
- const inline = keyMatch[2].trim();
164
-
165
- // Block scalar: >-, >, |-, |
166
- if (/^[>|]-?$/.test(inline)) {
167
- const parts = [];
168
- i++;
169
- while (i < lines.length && /^\s+/.test(lines[i])) {
170
- parts.push(lines[i].trim());
171
- i++;
172
- }
173
- meta[key] = parts.join(inline.startsWith('>') ? ' ' : '\n');
174
- continue;
175
- }
176
-
177
- // Array: key with no inline value, followed by " - item" lines
178
- if (inline === '') {
179
- const items = [];
180
- let j = i + 1;
181
- while (j < lines.length && /^\s+-\s+/.test(lines[j])) {
182
- items.push(lines[j].replace(/^\s+-\s+/, '').trim());
183
- j++;
184
- }
185
- if (items.length > 0) {
186
- meta[key] = items;
187
- i = j;
188
- continue;
189
- }
190
- }
191
-
192
- // Simple key: value
193
- if (inline) {
194
- meta[key] = inline.replace(/^["']|["']$/g, '');
195
- }
196
- i++;
197
- }
198
- return meta;
199
- }
200
-
201
- // ── Main sync logic ─────────────────────────────────────────────────
202
-
203
- async function syncFiles(filePaths, { creds, dryRun, force, rootFilter }) {
204
- // Get roots
205
- const { roots } = await api('GET', '/api/mcp/roots?forSync=true', null, creds);
206
- let targetRoots = roots;
207
-
208
- // Auto-detect or create root if none match
209
- const detected = detectRootFromCwd();
210
-
211
- if (rootFilter) {
212
- targetRoots = roots.filter(r => r.name.toLowerCase().includes(rootFilter.toLowerCase()));
213
- if (targetRoots.length === 0) {
214
- console.error(`No root matching "${rootFilter}". Available: ${roots.map(r => r.name).join(', ')}`);
215
- process.exit(1);
216
- }
217
- }
218
-
219
- // If syncing specific files, resolve their roots
220
- if (filePaths.length > 0) {
221
- return await syncSpecificFiles(filePaths, targetRoots, detected, creds, dryRun, force);
222
- }
223
-
224
- // Full root sync — auto-detect from CWD
225
- if (!rootFilter && detected) {
226
- const match = targetRoots.find(r =>
227
- resolve(r.path).toLowerCase() === resolve(detected.rootPath).toLowerCase()
228
- );
229
- if (match) {
230
- targetRoots = [match];
231
- } else if (!dryRun) {
232
- // Auto-create root
233
- console.log(`Creating new root: ${basename(detected.rootPath)} → ${detected.rootPath}`);
234
- const { root } = await api('POST', '/api/mcp/roots', {
235
- name: basename(detected.rootPath),
236
- path: detected.rootPath,
237
- }, creds);
238
- targetRoots = [{ ...root, last_scanned_at: null }];
239
- } else {
240
- console.log(`[dry-run] Would create root: ${detected.rootPath}`);
241
- return;
242
- }
243
- }
244
-
245
- if (targetRoots.length === 0) {
246
- console.error('No roots to sync. Run from a git repo or specify --root.');
247
- process.exit(1);
248
- }
249
-
250
- let totalAdded = 0, totalUpdated = 0, totalSkipped = 0;
251
-
252
- for (const root of targetRoots) {
253
- // Tilde-expand cloud-flow paths (~/Noesis/...) once at the top of the
254
- // loop so all downstream uses see the absolute on-disk path.
255
- const rootPath = expandHome(root.path);
256
- if (!rootPath) {
257
- console.log(`\nSkipping root '${root.name}': no path configured for this OS`);
258
- continue;
259
- }
260
- console.log(`\nSyncing root: ${root.name} (${rootPath})`);
261
-
262
- const mdFiles = scanMarkdownFiles(rootPath);
263
- console.log(` Found ${mdFiles.length} markdown files`);
264
-
265
- // Get existing hashes from cloud
266
- const { hashes } = await api('GET', `/api/mcp/roots/${root.id}/hashes`, null, creds);
267
-
268
- let added = 0, updated = 0, skipped = 0;
269
-
270
- for (const filePath of mdFiles) {
271
- const content = readFileSync(filePath, 'utf-8');
272
- const hash = computeHash(content);
273
- const stats = statSync(filePath);
274
- const relativePath = normalizePath(relative(rootPath, filePath));
275
- const existingHash = hashes[relativePath];
276
-
277
- if (existingHash === hash && !force) { skipped++; continue; }
278
- const action = existingHash ? 'update' : 'create';
279
-
280
- if (dryRun) {
281
- console.log(` [dry-run] Would ${action}: ${relativePath}`);
282
- action === 'create' ? added++ : updated++;
283
- continue;
284
- }
285
-
286
- const project = detectProjectForFile(filePath, rootPath);
287
- const metadata = parseFrontmatter(content);
288
-
289
- await api('POST', '/api/mcp/notes/upsert', {
290
- file: {
291
- path: normalizePath(filePath),
292
- relativePath,
293
- content,
294
- hash,
295
- mtime: stats.mtime,
296
- size: stats.size,
297
- rootId: root.id,
298
- rootName: root.name,
299
- project,
300
- },
301
- metadata,
302
- force,
303
- }, creds);
304
-
305
- console.log(` ${action === 'create' ? '+' : '~'} ${relativePath}`);
306
- action === 'create' ? added++ : updated++;
307
- }
308
-
309
- console.log(` Done: +${added} ~${updated} =${skipped}`);
310
- totalAdded += added; totalUpdated += updated; totalSkipped += skipped;
311
-
312
- if (!dryRun && (added > 0 || updated > 0)) {
313
- await api('POST', '/api/mcp/sync/log', {
314
- rootId: root.id,
315
- filesScanned: mdFiles.length,
316
- filesAdded: added,
317
- filesUpdated: updated,
318
- filesDeleted: 0,
319
- source: 'noesis-sync-script',
320
- }, creds);
321
- }
322
- }
323
-
324
- console.log(`\nTotal: +${totalAdded} ~${totalUpdated} =${totalSkipped}`);
325
- }
326
-
327
- async function syncSpecificFiles(filePaths, roots, detected, creds, dryRun, force) {
328
- let added = 0, updated = 0, errors = 0;
329
- const affectedRoots = new Map();
330
-
331
- for (const filePath of filePaths) {
332
- const absPath = normalizePath(resolve(filePath));
333
-
334
- if (!absPath.endsWith('.md')) {
335
- console.error(` SKIP (not .md): ${filePath}`);
336
- errors++; continue;
337
- }
338
- if (!existsSync(absPath)) {
339
- console.error(` SKIP (not found): ${filePath}`);
340
- errors++; continue;
341
- }
342
-
343
- // Find matching root (case-insensitive path comparison for Windows)
344
- let matchingRoot = roots.find(r => absPath.startsWith(normalizePath(resolve(r.path))));
345
-
346
- if (!matchingRoot && detected) {
347
- // Try auto-creating root from CWD detection
348
- const existingByPath = roots.find(r =>
349
- normalizePath(resolve(r.path)) === normalizePath(resolve(detected.rootPath))
350
- );
351
- if (existingByPath) {
352
- matchingRoot = existingByPath;
353
- } else if (!dryRun) {
354
- console.log(`Creating root: ${basename(detected.rootPath)} → ${detected.rootPath}`);
355
- const { root } = await api('POST', '/api/mcp/roots', {
356
- name: basename(detected.rootPath),
357
- path: normalizePath(detected.rootPath),
358
- }, creds);
359
- matchingRoot = root;
360
- roots.push(root);
361
- }
362
- }
363
-
364
- if (!matchingRoot) {
365
- console.error(` SKIP (no root): ${filePath}`);
366
- errors++; continue;
367
- }
368
-
369
- const content = readFileSync(absPath, 'utf-8');
370
- const hash = computeHash(content);
371
- const stats = statSync(absPath);
372
- const relativePath = normalizePath(relative(matchingRoot.path, absPath));
373
- const project = detectProjectForFile(absPath, matchingRoot.path);
374
- const metadata = parseFrontmatter(content);
375
-
376
- if (dryRun) {
377
- console.log(` [dry-run] Would sync: ${relativePath} → root "${matchingRoot.name}"`);
378
- added++; continue;
379
- }
380
-
381
- const result = await api('POST', '/api/mcp/notes/upsert', {
382
- file: {
383
- path: absPath,
384
- relativePath,
385
- content,
386
- hash,
387
- mtime: stats.mtime,
388
- size: stats.size,
389
- rootId: matchingRoot.id,
390
- rootName: matchingRoot.name,
391
- project,
392
- },
393
- metadata,
394
- force,
395
- }, creds);
396
-
397
- const action = result.action || 'synced';
398
- console.log(` ${action}: ${relativePath}`);
399
- action === 'created' ? added++ : updated++;
400
-
401
- // Track for sync log
402
- if (!affectedRoots.has(matchingRoot.id)) {
403
- affectedRoots.set(matchingRoot.id, { root: matchingRoot, added: 0, updated: 0, scanned: 0 });
404
- }
405
- const rInfo = affectedRoots.get(matchingRoot.id);
406
- rInfo.scanned++;
407
- action === 'created' ? rInfo.added++ : rInfo.updated++;
408
- }
409
-
410
- // Log sync for each affected root
411
- if (!dryRun) {
412
- for (const [rootId, info] of affectedRoots) {
413
- if (info.added > 0 || info.updated > 0) {
414
- await api('POST', '/api/mcp/sync/log', {
415
- rootId,
416
- filesScanned: info.scanned,
417
- filesAdded: info.added,
418
- filesUpdated: info.updated,
419
- filesDeleted: 0,
420
- source: 'noesis-sync-script',
421
- }, creds);
422
- }
423
- }
424
- }
425
-
426
- console.log(`\nDone: +${added} ~${updated}${errors ? ` errors:${errors}` : ''}`);
427
- }
428
-
429
- // ── CLI ─────────────────────────────────────────────────────────────
430
-
431
- const args = process.argv.slice(2);
432
- const dryRun = args.includes('--dry-run');
433
- const force = args.includes('--force');
434
- const filesIdx = args.indexOf('--files');
435
- const rootIdx = args.indexOf('--root');
436
-
437
- let files = [];
438
- let rootFilter = null;
439
-
440
- if (filesIdx !== -1) {
441
- // Collect all args after --files until next flag or end
442
- for (let i = filesIdx + 1; i < args.length; i++) {
443
- if (args[i].startsWith('--')) break;
444
- files.push(args[i]);
445
- }
446
- }
447
-
448
- if (rootIdx !== -1 && args[rootIdx + 1]) {
449
- rootFilter = args[rootIdx + 1];
450
- }
451
-
452
- // Positional arg that isn't a flag — treat as root name or file
453
- if (files.length === 0 && !rootFilter) {
454
- for (const a of args) {
455
- if (a.startsWith('--')) continue;
456
- if (a.endsWith('.md')) files.push(a);
457
- else rootFilter = a;
458
- }
459
- }
460
-
461
- const creds = loadCredentials();
462
- console.log(`Noesis sync → ${creds.url}`);
463
- if (dryRun) console.log('[DRY RUN MODE]');
464
- if (force) console.log('[FORCE MODE]');
465
-
466
- syncFiles(files, { creds, dryRun, force, rootFilter }).catch(err => {
467
- console.error('Sync failed:', err.message);
468
- process.exit(1);
469
- });
1
+ #!/usr/bin/env node
2
+ /**
3
+ * noesis-sync.mjs — Standalone sync script for Noesis cloud API.
4
+ * Zero dependencies (Node.js 18+ built-ins only).
5
+ *
6
+ * Usage:
7
+ * node noesis-sync.mjs # auto-detect from CWD
8
+ * node noesis-sync.mjs --files a.md b.md # sync specific files
9
+ * node noesis-sync.mjs --root my-notes # sync a named root
10
+ * node noesis-sync.mjs --dry-run # preview only
11
+ *
12
+ * Credentials: reads from ~/.claude/.mcp.json → mcpServers.noesis.env
13
+ * or env vars NOESIS_API_TOKEN / NOESIS_API_URL.
14
+ */
15
+
16
+ import { createHash } from 'node:crypto';
17
+ import { readFileSync, statSync, existsSync, readdirSync } from 'node:fs';
18
+ import { resolve, dirname, basename, relative, join, sep } from 'node:path';
19
+ import { homedir } from 'node:os';
20
+
21
+ // ── Path helpers ────────────────────────────────────────────────────
22
+
23
+ /**
24
+ * Tilde-expand `~/Noesis/...` against this machine's home directory.
25
+ * Cloud-flow roots store `~` in the DB because the cloud doesn't know each
26
+ * machine's $HOME — we expand here at sync time. No-op for already-
27
+ * absolute paths.
28
+ */
29
+ function expandHome(p) {
30
+ if (!p) return '';
31
+ if (p === '~') return homedir();
32
+ if (p.startsWith('~/') || p.startsWith('~\\')) {
33
+ return join(homedir(), p.slice(2));
34
+ }
35
+ return p;
36
+ }
37
+
38
+ // ── Config ──────────────────────────────────────────────────────────
39
+
40
+ function loadCredentials() {
41
+ // Env vars take priority
42
+ let token = process.env.NOESIS_API_TOKEN;
43
+ let url = process.env.NOESIS_API_URL;
44
+
45
+ if (token && url) return { token, url };
46
+
47
+ // Fall back to ~/.claude/.mcp.json
48
+ const mcpPath = join(homedir(), '.claude', '.mcp.json');
49
+ if (!existsSync(mcpPath)) {
50
+ console.error(`ERROR: No credentials. Set NOESIS_API_TOKEN + NOESIS_API_URL or configure ${mcpPath}`);
51
+ process.exit(1);
52
+ }
53
+ const mcp = JSON.parse(readFileSync(mcpPath, 'utf-8'));
54
+ const env = mcp?.mcpServers?.noesis?.env;
55
+ if (!env?.NOESIS_API_TOKEN || !env?.NOESIS_API_URL) {
56
+ console.error('ERROR: mcpServers.noesis.env missing NOESIS_API_TOKEN or NOESIS_API_URL in', mcpPath);
57
+ process.exit(1);
58
+ }
59
+ return { token: env.NOESIS_API_TOKEN, url: env.NOESIS_API_URL };
60
+ }
61
+
62
+ // ── HTTP helpers ────────────────────────────────────────────────────
63
+
64
+ async function api(method, path, body, { token, url }) {
65
+ const res = await fetch(`${url.replace(/\/$/, '')}${path}`, {
66
+ method,
67
+ headers: {
68
+ 'Authorization': `Bearer ${token}`,
69
+ 'Content-Type': 'application/json',
70
+ },
71
+ body: body ? JSON.stringify(body) : undefined,
72
+ });
73
+ if (!res.ok) {
74
+ const err = await res.json().catch(() => ({}));
75
+ throw new Error(`API ${method} ${path} → ${res.status}: ${err.error || res.statusText}`);
76
+ }
77
+ return res.json();
78
+ }
79
+
80
+ // ── Hashing (matches NoesisClient.computeHash) ─────────────────────
81
+
82
+ function computeHash(content) {
83
+ const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
84
+ return createHash('sha256').update(normalized, 'utf8').digest('hex');
85
+ }
86
+
87
+ // ── Git/root detection (mirrors tools/index.ts) ─────────────────────
88
+
89
+ function findGitRoot(startPath) {
90
+ let current = resolve(startPath);
91
+ while (current !== dirname(current)) {
92
+ if (existsSync(join(current, '.git'))) return current;
93
+ current = dirname(current);
94
+ }
95
+ return null;
96
+ }
97
+
98
+ function detectRootFromCwd() {
99
+ const gitRoot = findGitRoot(process.cwd());
100
+ if (!gitRoot) return null;
101
+ return { rootPath: dirname(gitRoot), projectName: basename(gitRoot) };
102
+ }
103
+
104
+ function detectProjectForFile(filePath, rootPath) {
105
+ const rel = relative(rootPath, filePath);
106
+ const parts = rel.split(sep).filter(Boolean);
107
+ // Walk up from file to find .git
108
+ let current = resolve(filePath);
109
+ while (current !== resolve(rootPath) && current !== dirname(current)) {
110
+ current = dirname(current);
111
+ if (existsSync(join(current, '.git'))) return basename(current);
112
+ }
113
+ return parts[0] || basename(dirname(filePath));
114
+ }
115
+
116
+ // ── Path normalization ───────────────────────────────────────────────
117
+
118
+ /**
119
+ * Normalize a file path: forward slashes + lowercase drive letter on Windows.
120
+ */
121
+ function normalizePath(p) {
122
+ let normalized = p.replace(/\\/g, '/');
123
+ if (normalized.length >= 2 && normalized[1] === ':') {
124
+ normalized = normalized[0].toLowerCase() + normalized.slice(1);
125
+ }
126
+ return normalized;
127
+ }
128
+
129
+ // ── File scanning ───────────────────────────────────────────────────
130
+
131
+ function scanMarkdownFiles(rootPath) {
132
+ const files = [];
133
+ function walk(dir) {
134
+ let entries;
135
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
136
+ for (const e of entries) {
137
+ const full = join(dir, e.name);
138
+ if (e.isDirectory()) {
139
+ if (e.name.startsWith('.') && e.name !== '.noesis' && e.name !== '.claude-notes') continue;
140
+ if (e.name === 'node_modules' || e.name === 'dist' || e.name === '.git') continue;
141
+ walk(full);
142
+ } else if (e.name.endsWith('.md')) {
143
+ files.push(full);
144
+ }
145
+ }
146
+ }
147
+ walk(rootPath);
148
+ return files;
149
+ }
150
+
151
+ // ── Parse YAML frontmatter ──────────────────────────────────────────
152
+
153
+ function parseFrontmatter(content) {
154
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
155
+ if (!match) return {};
156
+ const meta = {};
157
+ const lines = match[1].split('\n');
158
+ let i = 0;
159
+ while (i < lines.length) {
160
+ const keyMatch = lines[i].match(/^(\w[\w-]*):\s*(.*)/);
161
+ if (!keyMatch) { i++; continue; }
162
+ const key = keyMatch[1];
163
+ const inline = keyMatch[2].trim();
164
+
165
+ // Block scalar: >-, >, |-, |
166
+ if (/^[>|]-?$/.test(inline)) {
167
+ const parts = [];
168
+ i++;
169
+ while (i < lines.length && /^\s+/.test(lines[i])) {
170
+ parts.push(lines[i].trim());
171
+ i++;
172
+ }
173
+ meta[key] = parts.join(inline.startsWith('>') ? ' ' : '\n');
174
+ continue;
175
+ }
176
+
177
+ // Array: key with no inline value, followed by " - item" lines
178
+ if (inline === '') {
179
+ const items = [];
180
+ let j = i + 1;
181
+ while (j < lines.length && /^\s+-\s+/.test(lines[j])) {
182
+ items.push(lines[j].replace(/^\s+-\s+/, '').trim());
183
+ j++;
184
+ }
185
+ if (items.length > 0) {
186
+ meta[key] = items;
187
+ i = j;
188
+ continue;
189
+ }
190
+ }
191
+
192
+ // Simple key: value
193
+ if (inline) {
194
+ meta[key] = inline.replace(/^["']|["']$/g, '');
195
+ }
196
+ i++;
197
+ }
198
+ return meta;
199
+ }
200
+
201
+ // ── Main sync logic ─────────────────────────────────────────────────
202
+
203
+ async function syncFiles(filePaths, { creds, dryRun, force, rootFilter }) {
204
+ // Get roots
205
+ const { roots } = await api('GET', '/api/mcp/roots?forSync=true', null, creds);
206
+ let targetRoots = roots;
207
+
208
+ // Auto-detect or create root if none match
209
+ const detected = detectRootFromCwd();
210
+
211
+ if (rootFilter) {
212
+ targetRoots = roots.filter(r => r.name.toLowerCase().includes(rootFilter.toLowerCase()));
213
+ if (targetRoots.length === 0) {
214
+ console.error(`No root matching "${rootFilter}". Available: ${roots.map(r => r.name).join(', ')}`);
215
+ process.exit(1);
216
+ }
217
+ }
218
+
219
+ // If syncing specific files, resolve their roots
220
+ if (filePaths.length > 0) {
221
+ return await syncSpecificFiles(filePaths, targetRoots, detected, creds, dryRun, force);
222
+ }
223
+
224
+ // Full root sync — auto-detect from CWD
225
+ if (!rootFilter && detected) {
226
+ const match = targetRoots.find(r =>
227
+ resolve(r.path).toLowerCase() === resolve(detected.rootPath).toLowerCase()
228
+ );
229
+ if (match) {
230
+ targetRoots = [match];
231
+ } else if (!dryRun) {
232
+ // Auto-create root
233
+ console.log(`Creating new root: ${basename(detected.rootPath)} → ${detected.rootPath}`);
234
+ const { root } = await api('POST', '/api/mcp/roots', {
235
+ name: basename(detected.rootPath),
236
+ path: detected.rootPath,
237
+ }, creds);
238
+ targetRoots = [{ ...root, last_scanned_at: null }];
239
+ } else {
240
+ console.log(`[dry-run] Would create root: ${detected.rootPath}`);
241
+ return;
242
+ }
243
+ }
244
+
245
+ if (targetRoots.length === 0) {
246
+ console.error('No roots to sync. Run from a git repo or specify --root.');
247
+ process.exit(1);
248
+ }
249
+
250
+ let totalAdded = 0, totalUpdated = 0, totalSkipped = 0;
251
+
252
+ for (const root of targetRoots) {
253
+ // Tilde-expand cloud-flow paths (~/Noesis/...) once at the top of the
254
+ // loop so all downstream uses see the absolute on-disk path.
255
+ const rootPath = expandHome(root.path);
256
+ if (!rootPath) {
257
+ console.log(`\nSkipping root '${root.name}': no path configured for this OS`);
258
+ continue;
259
+ }
260
+ console.log(`\nSyncing root: ${root.name} (${rootPath})`);
261
+
262
+ const mdFiles = scanMarkdownFiles(rootPath);
263
+ console.log(` Found ${mdFiles.length} markdown files`);
264
+
265
+ // Get existing hashes from cloud
266
+ const { hashes } = await api('GET', `/api/mcp/roots/${root.id}/hashes`, null, creds);
267
+
268
+ let added = 0, updated = 0, skipped = 0;
269
+
270
+ for (const filePath of mdFiles) {
271
+ const content = readFileSync(filePath, 'utf-8');
272
+ const hash = computeHash(content);
273
+ const stats = statSync(filePath);
274
+ const relativePath = normalizePath(relative(rootPath, filePath));
275
+ const existingHash = hashes[relativePath];
276
+
277
+ if (existingHash === hash && !force) { skipped++; continue; }
278
+ const action = existingHash ? 'update' : 'create';
279
+
280
+ if (dryRun) {
281
+ console.log(` [dry-run] Would ${action}: ${relativePath}`);
282
+ action === 'create' ? added++ : updated++;
283
+ continue;
284
+ }
285
+
286
+ const project = detectProjectForFile(filePath, rootPath);
287
+ const metadata = parseFrontmatter(content);
288
+
289
+ await api('POST', '/api/mcp/notes/upsert', {
290
+ file: {
291
+ path: normalizePath(filePath),
292
+ relativePath,
293
+ content,
294
+ hash,
295
+ mtime: stats.mtime,
296
+ size: stats.size,
297
+ rootId: root.id,
298
+ rootName: root.name,
299
+ project,
300
+ },
301
+ metadata,
302
+ force,
303
+ }, creds);
304
+
305
+ console.log(` ${action === 'create' ? '+' : '~'} ${relativePath}`);
306
+ action === 'create' ? added++ : updated++;
307
+ }
308
+
309
+ console.log(` Done: +${added} ~${updated} =${skipped}`);
310
+ totalAdded += added; totalUpdated += updated; totalSkipped += skipped;
311
+
312
+ if (!dryRun && (added > 0 || updated > 0)) {
313
+ await api('POST', '/api/mcp/sync/log', {
314
+ rootId: root.id,
315
+ filesScanned: mdFiles.length,
316
+ filesAdded: added,
317
+ filesUpdated: updated,
318
+ filesDeleted: 0,
319
+ source: 'noesis-sync-script',
320
+ }, creds);
321
+ }
322
+ }
323
+
324
+ console.log(`\nTotal: +${totalAdded} ~${totalUpdated} =${totalSkipped}`);
325
+ }
326
+
327
+ async function syncSpecificFiles(filePaths, roots, detected, creds, dryRun, force) {
328
+ let added = 0, updated = 0, errors = 0;
329
+ const affectedRoots = new Map();
330
+
331
+ for (const filePath of filePaths) {
332
+ const absPath = normalizePath(resolve(filePath));
333
+
334
+ if (!absPath.endsWith('.md')) {
335
+ console.error(` SKIP (not .md): ${filePath}`);
336
+ errors++; continue;
337
+ }
338
+ if (!existsSync(absPath)) {
339
+ console.error(` SKIP (not found): ${filePath}`);
340
+ errors++; continue;
341
+ }
342
+
343
+ // Find matching root (case-insensitive path comparison for Windows)
344
+ let matchingRoot = roots.find(r => absPath.startsWith(normalizePath(resolve(r.path))));
345
+
346
+ if (!matchingRoot && detected) {
347
+ // Try auto-creating root from CWD detection
348
+ const existingByPath = roots.find(r =>
349
+ normalizePath(resolve(r.path)) === normalizePath(resolve(detected.rootPath))
350
+ );
351
+ if (existingByPath) {
352
+ matchingRoot = existingByPath;
353
+ } else if (!dryRun) {
354
+ console.log(`Creating root: ${basename(detected.rootPath)} → ${detected.rootPath}`);
355
+ const { root } = await api('POST', '/api/mcp/roots', {
356
+ name: basename(detected.rootPath),
357
+ path: normalizePath(detected.rootPath),
358
+ }, creds);
359
+ matchingRoot = root;
360
+ roots.push(root);
361
+ }
362
+ }
363
+
364
+ if (!matchingRoot) {
365
+ console.error(` SKIP (no root): ${filePath}`);
366
+ errors++; continue;
367
+ }
368
+
369
+ const content = readFileSync(absPath, 'utf-8');
370
+ const hash = computeHash(content);
371
+ const stats = statSync(absPath);
372
+ const relativePath = normalizePath(relative(matchingRoot.path, absPath));
373
+ const project = detectProjectForFile(absPath, matchingRoot.path);
374
+ const metadata = parseFrontmatter(content);
375
+
376
+ if (dryRun) {
377
+ console.log(` [dry-run] Would sync: ${relativePath} → root "${matchingRoot.name}"`);
378
+ added++; continue;
379
+ }
380
+
381
+ const result = await api('POST', '/api/mcp/notes/upsert', {
382
+ file: {
383
+ path: absPath,
384
+ relativePath,
385
+ content,
386
+ hash,
387
+ mtime: stats.mtime,
388
+ size: stats.size,
389
+ rootId: matchingRoot.id,
390
+ rootName: matchingRoot.name,
391
+ project,
392
+ },
393
+ metadata,
394
+ force,
395
+ }, creds);
396
+
397
+ const action = result.action || 'synced';
398
+ console.log(` ${action}: ${relativePath}`);
399
+ action === 'created' ? added++ : updated++;
400
+
401
+ // Track for sync log
402
+ if (!affectedRoots.has(matchingRoot.id)) {
403
+ affectedRoots.set(matchingRoot.id, { root: matchingRoot, added: 0, updated: 0, scanned: 0 });
404
+ }
405
+ const rInfo = affectedRoots.get(matchingRoot.id);
406
+ rInfo.scanned++;
407
+ action === 'created' ? rInfo.added++ : rInfo.updated++;
408
+ }
409
+
410
+ // Log sync for each affected root
411
+ if (!dryRun) {
412
+ for (const [rootId, info] of affectedRoots) {
413
+ if (info.added > 0 || info.updated > 0) {
414
+ await api('POST', '/api/mcp/sync/log', {
415
+ rootId,
416
+ filesScanned: info.scanned,
417
+ filesAdded: info.added,
418
+ filesUpdated: info.updated,
419
+ filesDeleted: 0,
420
+ source: 'noesis-sync-script',
421
+ }, creds);
422
+ }
423
+ }
424
+ }
425
+
426
+ console.log(`\nDone: +${added} ~${updated}${errors ? ` errors:${errors}` : ''}`);
427
+ }
428
+
429
+ // ── CLI ─────────────────────────────────────────────────────────────
430
+
431
+ const args = process.argv.slice(2);
432
+ const dryRun = args.includes('--dry-run');
433
+ const force = args.includes('--force');
434
+ const filesIdx = args.indexOf('--files');
435
+ const rootIdx = args.indexOf('--root');
436
+
437
+ let files = [];
438
+ let rootFilter = null;
439
+
440
+ if (filesIdx !== -1) {
441
+ // Collect all args after --files until next flag or end
442
+ for (let i = filesIdx + 1; i < args.length; i++) {
443
+ if (args[i].startsWith('--')) break;
444
+ files.push(args[i]);
445
+ }
446
+ }
447
+
448
+ if (rootIdx !== -1 && args[rootIdx + 1]) {
449
+ rootFilter = args[rootIdx + 1];
450
+ }
451
+
452
+ // Positional arg that isn't a flag — treat as root name or file
453
+ if (files.length === 0 && !rootFilter) {
454
+ for (const a of args) {
455
+ if (a.startsWith('--')) continue;
456
+ if (a.endsWith('.md')) files.push(a);
457
+ else rootFilter = a;
458
+ }
459
+ }
460
+
461
+ const creds = loadCredentials();
462
+ console.log(`Noesis sync → ${creds.url}`);
463
+ if (dryRun) console.log('[DRY RUN MODE]');
464
+ if (force) console.log('[FORCE MODE]');
465
+
466
+ syncFiles(files, { creds, dryRun, force, rootFilter }).catch(err => {
467
+ console.error('Sync failed:', err.message);
468
+ process.exit(1);
469
+ });