@ainote/mcp 1.1.1 → 1.3.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.
@@ -1,6 +1,11 @@
1
1
  import { toSuccessContent } from './internal/formatters.js';
2
+ import { mapPathForCurrentPlatform, canonicalizePath, getPlatformInfo } from './internal/path-mapper.js';
3
+ import { scanClaudeSubdir, readMcpServers, writeMcpServersSidecar, discoverSyncTargets } from './internal/claude-scanner.js';
4
+ import { encryptText, decryptText, generateKeypair } from './internal/encryption.js';
5
+ import { getAgeIdentity, setAgeIdentity, getAgeRecipients, addRecipient, storageBackend } from './internal/keychain.js';
2
6
  import fs from 'fs';
3
7
  import path from 'path';
8
+ import os from 'os';
4
9
 
5
10
  function listDevDocsDefinition() {
6
11
  return {
@@ -131,14 +136,19 @@ function pullDevDocsDefinition() {
131
136
  description: `Restore all synced files to THIS device. Writes files to their local paths on disk.
132
137
 
133
138
  USE THIS WHEN:
134
- - Setting up a new machine (desktop, laptop)
135
- - Reinstalled macOS / fresh environment
139
+ - Setting up a new machine (desktop, laptop, WSL)
140
+ - Switching between macOS / Windows / Linux
136
141
  - Want to sync latest versions of memory/config files from cloud
137
142
 
138
143
  WHAT HAPPENS:
139
144
  1. Fetches all dev docs with local_path set
140
- 2. Creates missing parent directories
141
- 3. Writes content to each local_path on this machine
145
+ 2. Auto-detects platform (macOS/WSL/Linux/Windows) and maps paths accordingly
146
+ 3. Creates missing parent directories
147
+ 4. Writes content to each mapped local_path on this machine
148
+
149
+ CROSS-PLATFORM PATH MAPPING (automatic):
150
+ - macOS ~/... ↔ WSL ~/... ↔ Linux ~/...
151
+ - Claude project keys mapped per platform (e.g., -Users-seunghan ↔ -mnt-c-Users-Owner)
142
152
 
143
153
  Run once after installing ainote MCP on a new device to restore everything.`,
144
154
  inputSchema: {
@@ -153,6 +163,139 @@ Run once after installing ainote MCP on a new device to restore everything.`,
153
163
  };
154
164
  }
155
165
 
166
+ // ─────────────────────────────────────────────────────────────
167
+ // Claude environment sync tools (Phase A: server-untouched wrappers)
168
+ // ─────────────────────────────────────────────────────────────
169
+
170
+ function discoverClaudeSyncTargetsDefinition() {
171
+ return {
172
+ name: 'discover_claude_sync_targets',
173
+ description: `Preview what would be synced from ~/.claude/ to ainote cloud.
174
+
175
+ Read-only. Shows file counts, sizes, and sample names for:
176
+ - skills/ (directory bundles like ui-ux-pro-max/)
177
+ - agents/ (single .md files)
178
+ - commands/ (slash command definitions)
179
+ - hooks/ (event handler scripts)
180
+ - mcp_servers (mcpServers section of ~/.claude.json)
181
+
182
+ Run this BEFORE push_claude_* tools to see scope.`,
183
+ inputSchema: { type: 'object', properties: {} }
184
+ };
185
+ }
186
+
187
+ function pushClaudeFilesDefinition(name, description) {
188
+ return {
189
+ name,
190
+ description,
191
+ inputSchema: {
192
+ type: 'object',
193
+ properties: {
194
+ dry_run: {
195
+ type: 'boolean',
196
+ description: 'Preview without uploading. Default: false'
197
+ },
198
+ max_files: {
199
+ type: 'number',
200
+ description: `Max files to push (safety cap). Default: 500`
201
+ }
202
+ }
203
+ }
204
+ };
205
+ }
206
+
207
+ function pushClaudeMcpServersDefinition() {
208
+ return {
209
+ name: 'push_claude_mcp_servers',
210
+ description: `Snapshot ~/.claude.json mcpServers section to ainote cloud (mcp category).
211
+
212
+ Stores as a single JSON dev_doc titled "mcp-servers-snapshot.json".
213
+ Includes API keys/env vars currently — Phase B will add encryption.
214
+
215
+ ⚠️ Until encryption lands, only run when comfortable storing keys server-side
216
+ (server is private to your account, but keys are not zero-knowledge yet).`,
217
+ inputSchema: {
218
+ type: 'object',
219
+ properties: {
220
+ dry_run: { type: 'boolean', description: 'Preview only. Default: false' }
221
+ }
222
+ }
223
+ };
224
+ }
225
+
226
+ function pullClaudeMcpServersDefinition() {
227
+ return {
228
+ name: 'pull_claude_mcp_servers',
229
+ description: `Restore mcpServers snapshot from ainote cloud to a SIDECAR file.
230
+
231
+ Writes to ~/.claude/mcp-servers.d/from-ainote.json (NOT ~/.claude.json directly).
232
+ This avoids corrupting the live-written ~/.claude.json while Claude is running.
233
+
234
+ After pull, to actually activate the servers you must either:
235
+ (a) merge manually: jq -s '.[0] * .[1]' ~/.claude.json ~/.claude/mcp-servers.d/from-ainote.json > new.json
236
+ (b) close Claude, then run a future merge tool (Phase B)
237
+
238
+ Sidecar approach = safe always, manual merge step required.`,
239
+ inputSchema: {
240
+ type: 'object',
241
+ properties: {
242
+ sidecar_name: {
243
+ type: 'string',
244
+ description: 'Sidecar filename stem. Default: from-ainote'
245
+ }
246
+ }
247
+ }
248
+ };
249
+ }
250
+
251
+ async function pushClaudeFilesHandler(subdir, args, { apiClient }) {
252
+ const dryRun = args.dry_run === true;
253
+ const maxFiles = typeof args.max_files === 'number' ? args.max_files : 500;
254
+ const files = scanClaudeSubdir(subdir, { maxFiles });
255
+
256
+ if (files.length === 0) {
257
+ return [{ type: 'text', text: `No text files found under ~/.claude/${subdir}/` }];
258
+ }
259
+
260
+ if (dryRun) {
261
+ const list = files.slice(0, 20).map(f => ` ${f.title} (${f.size}b)`).join('\n');
262
+ const more = files.length > 20 ? `\n ... and ${files.length - 20} more` : '';
263
+ return [{ type: 'text', text: `Would push ${files.length} files from ~/.claude/${subdir}/:\n${list}${more}` }];
264
+ }
265
+
266
+ const pushed = [];
267
+ const errors = [];
268
+ for (const f of files) {
269
+ try {
270
+ const created = await apiClient.callTool('create_dev_doc', {
271
+ title: f.title,
272
+ content: f.content,
273
+ category: subdir,
274
+ content_type: f.content_type,
275
+ local_path: f.local_path
276
+ });
277
+ // Server may return error inside content — surface it
278
+ const isErr = Array.isArray(created?.content) && created.content.some(c => c.type === 'text' && /error/i.test(c.text || ''));
279
+ if (isErr) {
280
+ errors.push(`⚠️ ${f.title}: ${created.content.map(c => c.text).join(' ').slice(0, 200)}`);
281
+ } else {
282
+ pushed.push(`✅ ${f.title}`);
283
+ }
284
+ } catch (e) {
285
+ errors.push(`❌ ${f.title}: ${e.message}`);
286
+ }
287
+ }
288
+
289
+ const summary = [
290
+ `Pushed ${pushed.length}/${files.length} files from ~/.claude/${subdir}/ → ainote (category: ${subdir})`,
291
+ pushed.length ? `\n${pushed.slice(0, 30).join('\n')}` : null,
292
+ pushed.length > 30 ? `... and ${pushed.length - 30} more` : null,
293
+ errors.length ? `\nErrors:\n${errors.join('\n')}` : null
294
+ ].filter(Boolean).join('\n');
295
+
296
+ return [{ type: 'text', text: summary }];
297
+ }
298
+
156
299
  function listDevCategoriesDefinition() {
157
300
  return {
158
301
  name: 'list_dev_categories',
@@ -183,6 +326,10 @@ export function getDevDocTools() {
183
326
  {
184
327
  definition: createDevDocDefinition(),
185
328
  handler: async (args, { apiClient }) => {
329
+ // Canonicalize local_path (replace $HOME with ~) for portability
330
+ if (args.local_path) {
331
+ args.local_path = canonicalizePath(args.local_path);
332
+ }
186
333
  const result = await apiClient.callTool('create_dev_doc', args);
187
334
  return result;
188
335
  }
@@ -190,6 +337,9 @@ export function getDevDocTools() {
190
337
  {
191
338
  definition: updateDevDocDefinition(),
192
339
  handler: async (args, { apiClient }) => {
340
+ if (args.local_path) {
341
+ args.local_path = canonicalizePath(args.local_path);
342
+ }
193
343
  const result = await apiClient.callTool('update_dev_doc', args);
194
344
  return result;
195
345
  }
@@ -211,6 +361,8 @@ export function getDevDocTools() {
211
361
  let skipped = [];
212
362
  let errors = [];
213
363
 
364
+ const platformInfo = getPlatformInfo();
365
+
214
366
  try {
215
367
  const resourceText = result?.content?.find(c => c.type === 'resource')?.resource?.text;
216
368
  if (resourceText) {
@@ -220,12 +372,15 @@ export function getDevDocTools() {
220
372
  skipped.push(doc.title);
221
373
  continue;
222
374
  }
223
- const expandedPath = doc.local_path.replace(/^~/, process.env.HOME || '');
375
+ // Map stored path to current platform (macOS↔WSL↔Linux)
376
+ const mappedPath = mapPathForCurrentPlatform(doc.local_path);
224
377
  try {
225
- const dir = path.dirname(expandedPath);
378
+ const dir = path.dirname(mappedPath);
226
379
  fs.mkdirSync(dir, { recursive: true });
227
- fs.writeFileSync(expandedPath, doc.content, 'utf8');
228
- written.push(`✅ ${doc.title} → ${expandedPath}`);
380
+ fs.writeFileSync(mappedPath, doc.content, 'utf8');
381
+ const pathChanged = mappedPath !== doc.local_path.replace(/^~/, process.env.HOME || '');
382
+ const arrow = pathChanged ? ` (mapped from ${doc.local_path})` : '';
383
+ written.push(`✅ ${doc.title} → ${mappedPath}${arrow}`);
229
384
  } catch (e) {
230
385
  errors.push(`❌ ${doc.title}: ${e.message}`);
231
386
  }
@@ -236,7 +391,8 @@ export function getDevDocTools() {
236
391
  }
237
392
 
238
393
  const summary = [
239
- written.length ? `Written (${written.length}):\n${written.join('\n')}` : null,
394
+ `🖥️ Platform: ${platformInfo.platform} | Home: ${platformInfo.home} | Project key: ${platformInfo.homeProjectKey}`,
395
+ written.length ? `\nWritten (${written.length}):\n${written.join('\n')}` : null,
240
396
  skipped.length ? `Skipped (no local_path): ${skipped.join(', ')}` : null,
241
397
  errors.length ? `Errors:\n${errors.join('\n')}` : null,
242
398
  ].filter(Boolean).join('\n\n');
@@ -250,6 +406,238 @@ export function getDevDocTools() {
250
406
  const result = await apiClient.callTool('list_dev_categories', {});
251
407
  return result;
252
408
  }
409
+ },
410
+ {
411
+ definition: discoverClaudeSyncTargetsDefinition(),
412
+ handler: async () => {
413
+ const info = discoverSyncTargets();
414
+ const lines = [`🔍 Claude dir: ${info.claude_dir}\n`];
415
+ for (const [name, t] of Object.entries(info.targets)) {
416
+ if (t.error) {
417
+ lines.push(` ${name.padEnd(14)} ⚠️ ${t.error}`);
418
+ continue;
419
+ }
420
+ const samples = t.samples?.length ? ` — ${t.samples.join(', ')}${t.samples.length < t.file_count ? ', ...' : ''}` : '';
421
+ lines.push(` ${name.padEnd(14)} ${String(t.file_count).padStart(4)} items, ${t.total_bytes}b${samples}`);
422
+ }
423
+ return [{ type: 'text', text: lines.join('\n') }];
424
+ }
425
+ },
426
+ {
427
+ definition: pushClaudeFilesDefinition(
428
+ 'push_claude_skills',
429
+ `Push ~/.claude/skills/ tree (each file as a dev_doc, category=skills).
430
+
431
+ Walks subdirectories. Each SKILL.md, reference, asset is uploaded separately
432
+ with local_path set so pull_dev_docs can restore on another machine.`
433
+ ),
434
+ handler: (args, ctx) => pushClaudeFilesHandler('skills', args, ctx)
435
+ },
436
+ {
437
+ definition: pushClaudeFilesDefinition(
438
+ 'push_claude_agents',
439
+ `Push ~/.claude/agents/ files (category=agents). Subagent definitions.`
440
+ ),
441
+ handler: (args, ctx) => pushClaudeFilesHandler('agents', args, ctx)
442
+ },
443
+ {
444
+ definition: pushClaudeFilesDefinition(
445
+ 'push_claude_commands',
446
+ `Push ~/.claude/commands/ tree (category=commands). Slash command definitions.`
447
+ ),
448
+ handler: (args, ctx) => pushClaudeFilesHandler('commands', args, ctx)
449
+ },
450
+ {
451
+ definition: pushClaudeFilesDefinition(
452
+ 'push_claude_hooks',
453
+ `Push ~/.claude/hooks/ tree (category=hooks). Event handler scripts.`
454
+ ),
455
+ handler: (args, ctx) => pushClaudeFilesHandler('hooks', args, ctx)
456
+ },
457
+ {
458
+ definition: pushClaudeMcpServersDefinition(),
459
+ handler: async (args, { apiClient }) => {
460
+ const dryRun = args.dry_run === true;
461
+ const { servers, source, exists } = readMcpServers();
462
+ if (!exists) {
463
+ return [{ type: 'text', text: `No ~/.claude.json found.` }];
464
+ }
465
+ const names = Object.keys(servers);
466
+ if (dryRun) {
467
+ return [{ type: 'text', text: `Would push mcpServers snapshot from ${source}\nServers (${names.length}): ${names.join(', ')}\n(would be encrypted: mcp category auto-encrypts)` }];
468
+ }
469
+ const plaintext = JSON.stringify({ mcpServers: servers }, null, 2);
470
+
471
+ // mcp 카테고리 자동 암호화
472
+ const recipients = await getAgeRecipients();
473
+ if (recipients.length === 0) {
474
+ return [{ type: 'text', text: `❌ No age recipients configured. Run sync_init_encryption (first machine) or sync_add_recipient (additional machines) before pushing secrets.` }];
475
+ }
476
+ const ciphertext = await encryptText(plaintext, { recipients });
477
+
478
+ const result = await apiClient.callTool('create_dev_doc', {
479
+ title: 'mcp-servers-snapshot.json',
480
+ content: ciphertext,
481
+ category: 'mcp',
482
+ content_type: 'text',
483
+ encrypted: true,
484
+ encryption_meta: { scheme: 'age-armored', recipient_count: recipients.length },
485
+ local_path: canonicalizePath(path.join(os.homedir(), '.claude/mcp-servers.d/from-ainote.json'))
486
+ });
487
+ return [{ type: 'text', text: `📤🔒 Pushed encrypted mcpServers snapshot (${names.length} servers, ${recipients.length} recipients): ${names.join(', ')}` }, ...(result?.content || [])];
488
+ }
489
+ },
490
+ {
491
+ definition: pullClaudeMcpServersDefinition(),
492
+ handler: async (args, { apiClient }) => {
493
+ const sidecarName = args.sidecar_name || 'from-ainote';
494
+ const result = await apiClient.callTool('get_dev_doc', {
495
+ title: 'mcp-servers-snapshot.json',
496
+ category: 'mcp'
497
+ });
498
+ const resourceText = result?.content?.find(c => c.type === 'resource')?.resource?.text;
499
+ let content;
500
+ let isEncrypted = false;
501
+ if (resourceText) {
502
+ try {
503
+ const parsedRes = JSON.parse(resourceText);
504
+ content = parsedRes.content || parsedRes.doc?.content;
505
+ isEncrypted = parsedRes.encrypted === true || parsedRes.doc?.encrypted === true;
506
+ } catch {}
507
+ }
508
+ if (!content) {
509
+ const textBlock = result?.content?.find(c => c.type === 'text')?.text;
510
+ content = textBlock;
511
+ }
512
+ if (!content) {
513
+ return [{ type: 'text', text: `❌ No mcp-servers-snapshot.json found in cloud (category: mcp). Run push_claude_mcp_servers first.` }];
514
+ }
515
+ // Auto-detect encryption if server didn't flag it (e.g. older docs)
516
+ if (!isEncrypted && /-----BEGIN AGE ENCRYPTED FILE-----/.test(content)) {
517
+ isEncrypted = true;
518
+ }
519
+ if (isEncrypted) {
520
+ try {
521
+ content = await decryptText(content);
522
+ } catch (e) {
523
+ return [{ type: 'text', text: `❌ Decryption failed: ${e.message}\nHint: ensure this machine's age identity is approved (sync_add_recipient on the original machine after re-push).` }];
524
+ }
525
+ }
526
+ let parsed;
527
+ try {
528
+ parsed = JSON.parse(content);
529
+ } catch (e) {
530
+ return [{ type: 'text', text: `❌ Snapshot is not valid JSON after decrypt: ${e.message}` }];
531
+ }
532
+ const servers = parsed.mcpServers || parsed;
533
+ const target = writeMcpServersSidecar(servers, { name: sidecarName });
534
+ const names = Object.keys(servers);
535
+ return [{
536
+ type: 'text',
537
+ text: [
538
+ `📥 Wrote sidecar: ${target}`,
539
+ `Servers (${names.length}): ${names.join(', ')}`,
540
+ '',
541
+ '⚠️ This does NOT activate the servers automatically.',
542
+ 'To merge into ~/.claude.json (after closing Claude):',
543
+ ` jq -s '.[0] * .[1]' ~/.claude.json ${target} > ~/.claude.json.new && mv ~/.claude.json.new ~/.claude.json`
544
+ ].join('\n')
545
+ }];
546
+ }
547
+ },
548
+ {
549
+ definition: {
550
+ name: 'sync_init_encryption',
551
+ description: `Initialize age encryption on THIS machine (first-time setup).
552
+
553
+ Generates an age keypair, stores the secret key in OS keychain (or ~/.config/ainote/ fallback),
554
+ and sets this machine as the sole recipient. Run once per machine.
555
+
556
+ After running on the FIRST machine, share its public key (recipient) with subsequent machines
557
+ via sync_add_recipient so they can decrypt shared secrets.`,
558
+ inputSchema: {
559
+ type: 'object',
560
+ properties: {
561
+ force: { type: 'boolean', description: 'Regenerate even if identity already exists. WARNING: invalidates existing ciphertexts unless old recipients retained.' }
562
+ }
563
+ }
564
+ },
565
+ handler: async (args) => {
566
+ const existing = await getAgeIdentity();
567
+ if (existing && !args.force) {
568
+ const recps = await getAgeRecipients();
569
+ return [{ type: 'text', text: `✅ Already initialized.\nBackend: ${storageBackend()}\nRecipients: ${recps.length}\nPass force=true to regenerate.` }];
570
+ }
571
+ const { identity, recipient } = generateKeypair();
572
+ await setAgeIdentity(identity);
573
+ await addRecipient(recipient);
574
+ return [{
575
+ type: 'text',
576
+ text: [
577
+ `🔐 Encryption initialized`,
578
+ `Backend: ${storageBackend()}`,
579
+ `Your public key (share with other machines via sync_add_recipient):`,
580
+ ` ${recipient}`,
581
+ ``,
582
+ `Next: on each other machine, run sync_init_encryption then back here run`,
583
+ ` sync_add_recipient <their_public_key>`,
584
+ `then re-push secrets so they're encrypted to all approved machines.`
585
+ ].join('\n')
586
+ }];
587
+ }
588
+ },
589
+ {
590
+ definition: {
591
+ name: 'sync_add_recipient',
592
+ description: `Add an age recipient (public key) to the encryption set for this machine.
593
+
594
+ Future encryptions on this machine will include this recipient.
595
+ Already-encrypted ciphertexts on the server are NOT re-encrypted retroactively —
596
+ call push_claude_mcp_servers again to re-encrypt with the updated recipient list.`,
597
+ inputSchema: {
598
+ type: 'object',
599
+ properties: {
600
+ recipient: { type: 'string', description: 'age public key (starts with age1...)' }
601
+ },
602
+ required: ['recipient']
603
+ }
604
+ },
605
+ handler: async (args) => {
606
+ const recipient = (args.recipient || '').trim();
607
+ if (!/^age1[a-z0-9]+$/i.test(recipient)) {
608
+ return [{ type: 'text', text: `❌ Invalid age recipient format. Expected: age1...` }];
609
+ }
610
+ const updated = await addRecipient(recipient);
611
+ return [{ type: 'text', text: `✅ Recipient added.\nTotal recipients: ${updated.length}\n${updated.map(r => ' ' + r).join('\n')}` }];
612
+ }
613
+ },
614
+ {
615
+ definition: {
616
+ name: 'sync_encryption_status',
617
+ description: 'Show encryption setup status (backend, identity present, recipients, age binary).',
618
+ inputSchema: { type: 'object', properties: {} }
619
+ },
620
+ handler: async () => {
621
+ const identity = await getAgeIdentity();
622
+ const recipients = await getAgeRecipients();
623
+ let ageVer = 'not found';
624
+ try {
625
+ const { spawnSync } = await import('child_process');
626
+ const r = spawnSync('age', ['--version'], { encoding: 'utf8' });
627
+ if (r.status === 0) ageVer = (r.stdout || '').trim();
628
+ } catch {}
629
+ return [{
630
+ type: 'text',
631
+ text: [
632
+ `🔐 Encryption status`,
633
+ ` age binary: ${ageVer}`,
634
+ ` backend: ${storageBackend()}`,
635
+ ` identity: ${identity ? 'present' : 'MISSING — run sync_init_encryption'}`,
636
+ ` recipients: ${recipients.length}`,
637
+ ...recipients.map(r => ` - ${r}`)
638
+ ].join('\n')
639
+ }];
640
+ }
253
641
  }
254
642
  ];
255
643
  }
@@ -0,0 +1,150 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { canonicalizePath } from './path-mapper.js';
5
+
6
+ const HOME = os.homedir();
7
+ const CLAUDE_DIR = path.join(HOME, '.claude');
8
+
9
+ const TEXT_EXTENSIONS = new Set([
10
+ '.md', '.txt', '.json', '.yaml', '.yml', '.toml',
11
+ '.js', '.ts', '.sh', '.py', '.rb', '.css', '.html'
12
+ ]);
13
+
14
+ function isTextFile(p) {
15
+ return TEXT_EXTENSIONS.has(path.extname(p).toLowerCase());
16
+ }
17
+
18
+ function walkDir(root, maxFiles = 500) {
19
+ const out = [];
20
+ if (!fs.existsSync(root)) return out;
21
+
22
+ function visit(dir) {
23
+ if (out.length >= maxFiles) return;
24
+ let entries;
25
+ try {
26
+ entries = fs.readdirSync(dir, { withFileTypes: true });
27
+ } catch {
28
+ return;
29
+ }
30
+ for (const e of entries) {
31
+ if (e.name.startsWith('.')) continue;
32
+ const full = path.join(dir, e.name);
33
+ if (e.isDirectory()) {
34
+ visit(full);
35
+ } else if (e.isFile() && isTextFile(e.name)) {
36
+ out.push(full);
37
+ }
38
+ if (out.length >= maxFiles) return;
39
+ }
40
+ }
41
+ visit(root);
42
+ return out;
43
+ }
44
+
45
+ function detectContentType(p) {
46
+ const ext = path.extname(p).toLowerCase();
47
+ if (ext === '.json') return 'json';
48
+ if (ext === '.yaml' || ext === '.yml') return 'yaml';
49
+ if (ext === '.md') return 'markdown';
50
+ return 'text';
51
+ }
52
+
53
+ /**
54
+ * Scan a Claude subdirectory (skills/agents/commands/hooks) and return file
55
+ * descriptors ready for create_dev_doc calls.
56
+ *
57
+ * Returns: [{ title, content, content_type, local_path, relative_path }]
58
+ * - title: unique per-file (e.g., "skills/ui-ux-pro-max/SKILL.md")
59
+ * - local_path: canonicalized (~/...) so pull works cross-platform
60
+ * - relative_path: path relative to ~/.claude/<subdir>/
61
+ */
62
+ export function scanClaudeSubdir(subdir, { maxFiles = 500 } = {}) {
63
+ const root = path.join(CLAUDE_DIR, subdir);
64
+ const files = walkDir(root, maxFiles);
65
+ const results = [];
66
+ for (const abs of files) {
67
+ let content;
68
+ try {
69
+ content = fs.readFileSync(abs, 'utf8');
70
+ } catch {
71
+ continue;
72
+ }
73
+ const relativeFromClaude = path.relative(CLAUDE_DIR, abs);
74
+ const relativeFromSubdir = path.relative(root, abs);
75
+ results.push({
76
+ title: relativeFromClaude.replace(/\\/g, '/'),
77
+ content,
78
+ content_type: detectContentType(abs),
79
+ local_path: canonicalizePath(abs),
80
+ relative_path: relativeFromSubdir.replace(/\\/g, '/'),
81
+ size: content.length
82
+ });
83
+ }
84
+ return results;
85
+ }
86
+
87
+ /**
88
+ * Read mcpServers section from ~/.claude.json.
89
+ * Returns the object (possibly empty) and the canonicalized source path.
90
+ */
91
+ export function readMcpServers() {
92
+ const claudeJson = path.join(HOME, '.claude.json');
93
+ if (!fs.existsSync(claudeJson)) {
94
+ return { servers: {}, source: canonicalizePath(claudeJson), exists: false };
95
+ }
96
+ try {
97
+ const raw = fs.readFileSync(claudeJson, 'utf8');
98
+ const json = JSON.parse(raw);
99
+ return {
100
+ servers: json.mcpServers || {},
101
+ source: canonicalizePath(claudeJson),
102
+ exists: true
103
+ };
104
+ } catch (e) {
105
+ throw new Error(`Failed to parse ~/.claude.json: ${e.message}`);
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Sidecar pattern: write mcpServers snapshot to ~/.claude/mcp-servers.d/<name>.json
111
+ * Never touches ~/.claude.json directly (avoids live-write corruption).
112
+ */
113
+ export function writeMcpServersSidecar(servers, { name = 'from-ainote' } = {}) {
114
+ const sidecarDir = path.join(CLAUDE_DIR, 'mcp-servers.d');
115
+ fs.mkdirSync(sidecarDir, { recursive: true });
116
+ const target = path.join(sidecarDir, `${name}.json`);
117
+ const tmp = `${target}.tmp.${process.pid}`;
118
+ fs.writeFileSync(tmp, JSON.stringify({ mcpServers: servers }, null, 2));
119
+ fs.renameSync(tmp, target);
120
+ return canonicalizePath(target);
121
+ }
122
+
123
+ /**
124
+ * Preview what would be synced. Read-only, fast.
125
+ */
126
+ export function discoverSyncTargets() {
127
+ const result = {
128
+ claude_dir: canonicalizePath(CLAUDE_DIR),
129
+ targets: {}
130
+ };
131
+ for (const sub of ['skills', 'agents', 'commands', 'hooks']) {
132
+ const files = scanClaudeSubdir(sub);
133
+ result.targets[sub] = {
134
+ file_count: files.length,
135
+ total_bytes: files.reduce((a, b) => a + b.size, 0),
136
+ samples: files.slice(0, 3).map(f => f.title)
137
+ };
138
+ }
139
+ try {
140
+ const mcp = readMcpServers();
141
+ result.targets.mcp_servers = {
142
+ file_count: Object.keys(mcp.servers).length,
143
+ total_bytes: JSON.stringify(mcp.servers).length,
144
+ samples: Object.keys(mcp.servers).slice(0, 5)
145
+ };
146
+ } catch (e) {
147
+ result.targets.mcp_servers = { error: e.message };
148
+ }
149
+ return result;
150
+ }