@ainote/mcp 1.1.1 → 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.
- package/README.md +71 -4
- package/index.js +29 -4
- package/lib/auth/device-flow.js +274 -0
- package/lib/auth/mcp-key.js +27 -1
- package/lib/auth/refresh.js +78 -0
- package/lib/auth/token-store.js +286 -0
- package/lib/cli/login.js +238 -0
- package/lib/cli/logout.js +39 -0
- package/lib/cli/whoami.js +77 -0
- package/lib/core/config-manager.js +40 -1
- package/lib/tools/dev-doc-tools.js +397 -9
- package/lib/tools/internal/claude-scanner.js +150 -0
- package/lib/tools/internal/encryption.js +88 -0
- package/lib/tools/internal/keychain.js +98 -0
- package/lib/tools/internal/path-mapper.js +221 -0
- package/lib/tools/shared-tools.js +67 -33
- package/lib/utils/api-client.js +74 -1
- package/package.json +5 -3
|
@@ -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
|
-
-
|
|
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.
|
|
141
|
-
3.
|
|
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
|
-
|
|
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(
|
|
378
|
+
const dir = path.dirname(mappedPath);
|
|
226
379
|
fs.mkdirSync(dir, { recursive: true });
|
|
227
|
-
fs.writeFileSync(
|
|
228
|
-
|
|
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
|
-
|
|
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
|
+
}
|