@ainote/mcp 1.1.0 → 1.2.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,12 +1,21 @@
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';
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import os from 'os';
2
9
 
3
10
  function listDevDocsDefinition() {
4
11
  return {
5
12
  name: 'list_dev_docs',
6
- description: `List dev documents under dev/ category hierarchy. Manage AI coding tool configs (CLAUDE.md, .cursorrules, etc.) and dev docs centrally.
13
+ description: `List your synced dev documents stored in AI Note cloud.
7
14
 
8
- Categories: claude, cursor, windsurf, copilot, docs, or custom subcategories.
9
- Supports markdown, json, yaml content types.`,
15
+ Shows all documents under dev/ category including memory files, AI configs, and project docs.
16
+ Use this to see what's synced and which files have local_path set for multi-device sync.
17
+
18
+ Categories: memory, claude, cursor, windsurf, env, docs, or custom subcategories.`,
10
19
  inputSchema: {
11
20
  type: 'object',
12
21
  properties: {
@@ -47,9 +56,15 @@ function getDevDocDefinition() {
47
56
  function createDevDocDefinition() {
48
57
  return {
49
58
  name: 'create_dev_doc',
50
- description: `Create a new dev document under dev/ category. Auto-creates subcategory if needed.
59
+ description: `Save a file to AI Note cloud for multi-device sync.
60
+
61
+ PRIMARY USE CASES:
62
+ - Memory files: ~/.claude/projects/.../memory/MEMORY.md
63
+ - AI configs: CLAUDE.md, .cursorrules, .windsurfrules (not in git)
64
+ - Project docs: architecture notes, planning docs
51
65
 
52
- Use this to store CLAUDE.md content, cursor rules, windsurf rules, API specs, architecture docs, etc.`,
66
+ Set local_path to enable pull_dev_docs auto-sync on other devices.
67
+ Categories: memory | claude | cursor | env | docs`,
53
68
  inputSchema: {
54
69
  type: 'object',
55
70
  properties: {
@@ -63,6 +78,10 @@ Use this to store CLAUDE.md content, cursor rules, windsurf rules, API specs, ar
63
78
  type: 'string',
64
79
  enum: ['markdown', 'json', 'yaml', 'text'],
65
80
  description: 'Content type. Auto-detected from title extension if omitted.'
81
+ },
82
+ local_path: {
83
+ type: 'string',
84
+ description: 'Absolute local file path for multi-device sync (e.g., ~/.claude/projects/.../MEMORY.md)'
66
85
  }
67
86
  },
68
87
  required: ['title', 'content']
@@ -85,6 +104,10 @@ function updateDevDocDefinition() {
85
104
  type: 'string',
86
105
  enum: ['replace', 'append', 'prepend'],
87
106
  description: 'Update mode (default: replace)'
107
+ },
108
+ local_path: {
109
+ type: 'string',
110
+ description: 'Update the local file path for multi-device sync'
88
111
  }
89
112
  },
90
113
  required: ['content']
@@ -107,6 +130,172 @@ function deleteDevDocDefinition() {
107
130
  };
108
131
  }
109
132
 
133
+ function pullDevDocsDefinition() {
134
+ return {
135
+ name: 'pull_dev_docs',
136
+ description: `Restore all synced files to THIS device. Writes files to their local paths on disk.
137
+
138
+ USE THIS WHEN:
139
+ - Setting up a new machine (desktop, laptop, WSL)
140
+ - Switching between macOS / Windows / Linux
141
+ - Want to sync latest versions of memory/config files from cloud
142
+
143
+ WHAT HAPPENS:
144
+ 1. Fetches all dev docs with local_path set
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)
152
+
153
+ Run once after installing ainote MCP on a new device to restore everything.`,
154
+ inputSchema: {
155
+ type: 'object',
156
+ properties: {
157
+ category: {
158
+ type: 'string',
159
+ description: 'Subcategory filter. Omit to pull all docs with local_path.'
160
+ }
161
+ }
162
+ }
163
+ };
164
+ }
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
+
110
299
  function listDevCategoriesDefinition() {
111
300
  return {
112
301
  name: 'list_dev_categories',
@@ -137,6 +326,10 @@ export function getDevDocTools() {
137
326
  {
138
327
  definition: createDevDocDefinition(),
139
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
+ }
140
333
  const result = await apiClient.callTool('create_dev_doc', args);
141
334
  return result;
142
335
  }
@@ -144,6 +337,9 @@ export function getDevDocTools() {
144
337
  {
145
338
  definition: updateDevDocDefinition(),
146
339
  handler: async (args, { apiClient }) => {
340
+ if (args.local_path) {
341
+ args.local_path = canonicalizePath(args.local_path);
342
+ }
147
343
  const result = await apiClient.callTool('update_dev_doc', args);
148
344
  return result;
149
345
  }
@@ -155,12 +351,293 @@ export function getDevDocTools() {
155
351
  return result;
156
352
  }
157
353
  },
354
+ {
355
+ definition: pullDevDocsDefinition(),
356
+ handler: async (args, { apiClient }) => {
357
+ const result = await apiClient.callTool('pull_dev_docs', args);
358
+
359
+ // Extract docs from resource data and write to local paths
360
+ let written = [];
361
+ let skipped = [];
362
+ let errors = [];
363
+
364
+ const platformInfo = getPlatformInfo();
365
+
366
+ try {
367
+ const resourceText = result?.content?.find(c => c.type === 'resource')?.resource?.text;
368
+ if (resourceText) {
369
+ const data = JSON.parse(resourceText);
370
+ for (const doc of data.docs || []) {
371
+ if (!doc.local_path || !doc.content) {
372
+ skipped.push(doc.title);
373
+ continue;
374
+ }
375
+ // Map stored path to current platform (macOS↔WSL↔Linux)
376
+ const mappedPath = mapPathForCurrentPlatform(doc.local_path);
377
+ try {
378
+ const dir = path.dirname(mappedPath);
379
+ fs.mkdirSync(dir, { recursive: true });
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}`);
384
+ } catch (e) {
385
+ errors.push(`❌ ${doc.title}: ${e.message}`);
386
+ }
387
+ }
388
+ }
389
+ } catch (e) {
390
+ errors.push(`Parse error: ${e.message}`);
391
+ }
392
+
393
+ const summary = [
394
+ `🖥️ Platform: ${platformInfo.platform} | Home: ${platformInfo.home} | Project key: ${platformInfo.homeProjectKey}`,
395
+ written.length ? `\nWritten (${written.length}):\n${written.join('\n')}` : null,
396
+ skipped.length ? `Skipped (no local_path): ${skipped.join(', ')}` : null,
397
+ errors.length ? `Errors:\n${errors.join('\n')}` : null,
398
+ ].filter(Boolean).join('\n\n');
399
+
400
+ return [{ type: 'text', text: summary || 'No docs with local_path found.' }];
401
+ }
402
+ },
158
403
  {
159
404
  definition: listDevCategoriesDefinition(),
160
405
  handler: async (_args, { apiClient }) => {
161
406
  const result = await apiClient.callTool('list_dev_categories', {});
162
407
  return result;
163
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
+ }
164
641
  }
165
642
  ];
166
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
+ }