@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.
- 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 +482 -5
- 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 +6 -4
|
@@ -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
|
|
13
|
+
description: `List your synced dev documents stored in AI Note cloud.
|
|
7
14
|
|
|
8
|
-
|
|
9
|
-
|
|
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: `
|
|
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
|
-
|
|
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
|
+
}
|