@ainote/mcp 1.3.2 → 1.3.3
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/LICENSE +20 -20
- package/README-ko.md +374 -374
- package/README.md +800 -800
- package/index.js +61 -61
- package/lib/auth/device-flow.js +285 -274
- package/lib/auth/mcp-key.js +40 -40
- package/lib/auth/oauth.js +18 -18
- package/lib/auth/refresh.js +78 -78
- package/lib/auth/strategy-factory.js +31 -31
- package/lib/auth/token-store.js +302 -286
- package/lib/cli/call.js +211 -211
- package/lib/cli/login.js +238 -238
- package/lib/cli/logout.js +39 -39
- package/lib/cli/signup.js +118 -118
- package/lib/cli/whoami.js +77 -77
- package/lib/core/api-client.js +1 -1
- package/lib/core/config-manager.js +106 -106
- package/lib/core/config.js +1 -1
- package/lib/core/mcp-server.js +2 -2
- package/lib/core/server-factory.js +82 -82
- package/lib/core/tool-registry.js +31 -31
- package/lib/tools/chatgpt-tools.js +93 -93
- package/lib/tools/dev-doc-tools.js +643 -643
- package/lib/tools/index.js +5 -5
- package/lib/tools/internal/claude-scanner.js +150 -150
- package/lib/tools/internal/encryption.js +88 -88
- package/lib/tools/internal/formatters.js +80 -80
- package/lib/tools/internal/keychain.js +98 -98
- package/lib/tools/internal/path-mapper.js +221 -221
- package/lib/tools/onboarding-tools.js +124 -124
- package/lib/tools/shared-tools.js +280 -280
- package/lib/utils/api-client.js +152 -152
- package/package.json +101 -101
- package/server.js +99 -99
|
@@ -1,643 +1,643 @@
|
|
|
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';
|
|
9
|
-
|
|
10
|
-
function listDevDocsDefinition() {
|
|
11
|
-
return {
|
|
12
|
-
name: 'list_dev_docs',
|
|
13
|
-
description: `List your synced dev documents stored in AI Note cloud.
|
|
14
|
-
|
|
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.`,
|
|
19
|
-
inputSchema: {
|
|
20
|
-
type: 'object',
|
|
21
|
-
properties: {
|
|
22
|
-
category: {
|
|
23
|
-
type: 'string',
|
|
24
|
-
description: 'Subcategory filter (claude, cursor, windsurf, copilot, docs, etc.)'
|
|
25
|
-
},
|
|
26
|
-
search: {
|
|
27
|
-
type: 'string',
|
|
28
|
-
description: 'Search keyword in document title'
|
|
29
|
-
},
|
|
30
|
-
content_type: {
|
|
31
|
-
type: 'string',
|
|
32
|
-
enum: ['markdown', 'json', 'yaml', 'text'],
|
|
33
|
-
description: 'Filter by content type'
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function getDevDocDefinition() {
|
|
41
|
-
return {
|
|
42
|
-
name: 'get_dev_doc',
|
|
43
|
-
description: 'Get a single dev document by title or id. Returns full content.',
|
|
44
|
-
inputSchema: {
|
|
45
|
-
type: 'object',
|
|
46
|
-
properties: {
|
|
47
|
-
title: { type: 'string', description: 'Document title (e.g., project-a-memory.md)' },
|
|
48
|
-
id: { type: 'string', description: 'Document UUID' },
|
|
49
|
-
category: { type: 'string', description: 'Subcategory (claude, cursor, etc.) to disambiguate' },
|
|
50
|
-
include_versions: { type: 'boolean', description: 'Include version history (default: false)' }
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function createDevDocDefinition() {
|
|
57
|
-
return {
|
|
58
|
-
name: 'create_dev_doc',
|
|
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
|
|
65
|
-
|
|
66
|
-
Set local_path to enable pull_dev_docs auto-sync on other devices.
|
|
67
|
-
Categories: memory | claude | cursor | env | docs`,
|
|
68
|
-
inputSchema: {
|
|
69
|
-
type: 'object',
|
|
70
|
-
properties: {
|
|
71
|
-
title: { type: 'string', description: 'Document title (e.g., project-a-claude.md)' },
|
|
72
|
-
content: { type: 'string', description: 'Document content (markdown, json, yaml)' },
|
|
73
|
-
category: {
|
|
74
|
-
type: 'string',
|
|
75
|
-
description: 'Subcategory (claude, cursor, windsurf, copilot, docs). Default: docs'
|
|
76
|
-
},
|
|
77
|
-
content_type: {
|
|
78
|
-
type: 'string',
|
|
79
|
-
enum: ['markdown', 'json', 'yaml', 'text'],
|
|
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)'
|
|
85
|
-
}
|
|
86
|
-
},
|
|
87
|
-
required: ['title', 'content']
|
|
88
|
-
}
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
function updateDevDocDefinition() {
|
|
93
|
-
return {
|
|
94
|
-
name: 'update_dev_doc',
|
|
95
|
-
description: 'Update a dev document. Supports replace, append, or prepend modes.',
|
|
96
|
-
inputSchema: {
|
|
97
|
-
type: 'object',
|
|
98
|
-
properties: {
|
|
99
|
-
title: { type: 'string', description: 'Document title' },
|
|
100
|
-
id: { type: 'string', description: 'Document UUID' },
|
|
101
|
-
category: { type: 'string', description: 'Subcategory to disambiguate' },
|
|
102
|
-
content: { type: 'string', description: 'New content' },
|
|
103
|
-
mode: {
|
|
104
|
-
type: 'string',
|
|
105
|
-
enum: ['replace', 'append', 'prepend'],
|
|
106
|
-
description: 'Update mode (default: replace)'
|
|
107
|
-
},
|
|
108
|
-
local_path: {
|
|
109
|
-
type: 'string',
|
|
110
|
-
description: 'Update the local file path for multi-device sync'
|
|
111
|
-
}
|
|
112
|
-
},
|
|
113
|
-
required: ['content']
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function deleteDevDocDefinition() {
|
|
119
|
-
return {
|
|
120
|
-
name: 'delete_dev_doc',
|
|
121
|
-
description: 'Delete a dev document (soft delete).',
|
|
122
|
-
inputSchema: {
|
|
123
|
-
type: 'object',
|
|
124
|
-
properties: {
|
|
125
|
-
title: { type: 'string', description: 'Document title' },
|
|
126
|
-
id: { type: 'string', description: 'Document UUID' },
|
|
127
|
-
category: { type: 'string', description: 'Subcategory to disambiguate' }
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
};
|
|
131
|
-
}
|
|
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
|
-
|
|
299
|
-
function listDevCategoriesDefinition() {
|
|
300
|
-
return {
|
|
301
|
-
name: 'list_dev_categories',
|
|
302
|
-
description: 'List all subcategories under dev/. Shows document count per category.',
|
|
303
|
-
inputSchema: {
|
|
304
|
-
type: 'object',
|
|
305
|
-
properties: {}
|
|
306
|
-
}
|
|
307
|
-
};
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
export function getDevDocTools() {
|
|
311
|
-
return [
|
|
312
|
-
{
|
|
313
|
-
definition: listDevDocsDefinition(),
|
|
314
|
-
handler: async (args, { apiClient }) => {
|
|
315
|
-
const result = await apiClient.callTool('list_dev_docs', args);
|
|
316
|
-
return result;
|
|
317
|
-
}
|
|
318
|
-
},
|
|
319
|
-
{
|
|
320
|
-
definition: getDevDocDefinition(),
|
|
321
|
-
handler: async (args, { apiClient }) => {
|
|
322
|
-
const result = await apiClient.callTool('get_dev_doc', args);
|
|
323
|
-
return result;
|
|
324
|
-
}
|
|
325
|
-
},
|
|
326
|
-
{
|
|
327
|
-
definition: createDevDocDefinition(),
|
|
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
|
-
}
|
|
333
|
-
const result = await apiClient.callTool('create_dev_doc', args);
|
|
334
|
-
return result;
|
|
335
|
-
}
|
|
336
|
-
},
|
|
337
|
-
{
|
|
338
|
-
definition: updateDevDocDefinition(),
|
|
339
|
-
handler: async (args, { apiClient }) => {
|
|
340
|
-
if (args.local_path) {
|
|
341
|
-
args.local_path = canonicalizePath(args.local_path);
|
|
342
|
-
}
|
|
343
|
-
const result = await apiClient.callTool('update_dev_doc', args);
|
|
344
|
-
return result;
|
|
345
|
-
}
|
|
346
|
-
},
|
|
347
|
-
{
|
|
348
|
-
definition: deleteDevDocDefinition(),
|
|
349
|
-
handler: async (args, { apiClient }) => {
|
|
350
|
-
const result = await apiClient.callTool('delete_dev_doc', args);
|
|
351
|
-
return result;
|
|
352
|
-
}
|
|
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
|
-
},
|
|
403
|
-
{
|
|
404
|
-
definition: listDevCategoriesDefinition(),
|
|
405
|
-
handler: async (_args, { apiClient }) => {
|
|
406
|
-
const result = await apiClient.callTool('list_dev_categories', {});
|
|
407
|
-
return result;
|
|
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
|
-
}
|
|
641
|
-
}
|
|
642
|
-
];
|
|
643
|
-
}
|
|
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';
|
|
9
|
+
|
|
10
|
+
function listDevDocsDefinition() {
|
|
11
|
+
return {
|
|
12
|
+
name: 'list_dev_docs',
|
|
13
|
+
description: `List your synced dev documents stored in AI Note cloud.
|
|
14
|
+
|
|
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.`,
|
|
19
|
+
inputSchema: {
|
|
20
|
+
type: 'object',
|
|
21
|
+
properties: {
|
|
22
|
+
category: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: 'Subcategory filter (claude, cursor, windsurf, copilot, docs, etc.)'
|
|
25
|
+
},
|
|
26
|
+
search: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description: 'Search keyword in document title'
|
|
29
|
+
},
|
|
30
|
+
content_type: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
enum: ['markdown', 'json', 'yaml', 'text'],
|
|
33
|
+
description: 'Filter by content type'
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getDevDocDefinition() {
|
|
41
|
+
return {
|
|
42
|
+
name: 'get_dev_doc',
|
|
43
|
+
description: 'Get a single dev document by title or id. Returns full content.',
|
|
44
|
+
inputSchema: {
|
|
45
|
+
type: 'object',
|
|
46
|
+
properties: {
|
|
47
|
+
title: { type: 'string', description: 'Document title (e.g., project-a-memory.md)' },
|
|
48
|
+
id: { type: 'string', description: 'Document UUID' },
|
|
49
|
+
category: { type: 'string', description: 'Subcategory (claude, cursor, etc.) to disambiguate' },
|
|
50
|
+
include_versions: { type: 'boolean', description: 'Include version history (default: false)' }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function createDevDocDefinition() {
|
|
57
|
+
return {
|
|
58
|
+
name: 'create_dev_doc',
|
|
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
|
|
65
|
+
|
|
66
|
+
Set local_path to enable pull_dev_docs auto-sync on other devices.
|
|
67
|
+
Categories: memory | claude | cursor | env | docs`,
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
title: { type: 'string', description: 'Document title (e.g., project-a-claude.md)' },
|
|
72
|
+
content: { type: 'string', description: 'Document content (markdown, json, yaml)' },
|
|
73
|
+
category: {
|
|
74
|
+
type: 'string',
|
|
75
|
+
description: 'Subcategory (claude, cursor, windsurf, copilot, docs). Default: docs'
|
|
76
|
+
},
|
|
77
|
+
content_type: {
|
|
78
|
+
type: 'string',
|
|
79
|
+
enum: ['markdown', 'json', 'yaml', 'text'],
|
|
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)'
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
required: ['title', 'content']
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function updateDevDocDefinition() {
|
|
93
|
+
return {
|
|
94
|
+
name: 'update_dev_doc',
|
|
95
|
+
description: 'Update a dev document. Supports replace, append, or prepend modes.',
|
|
96
|
+
inputSchema: {
|
|
97
|
+
type: 'object',
|
|
98
|
+
properties: {
|
|
99
|
+
title: { type: 'string', description: 'Document title' },
|
|
100
|
+
id: { type: 'string', description: 'Document UUID' },
|
|
101
|
+
category: { type: 'string', description: 'Subcategory to disambiguate' },
|
|
102
|
+
content: { type: 'string', description: 'New content' },
|
|
103
|
+
mode: {
|
|
104
|
+
type: 'string',
|
|
105
|
+
enum: ['replace', 'append', 'prepend'],
|
|
106
|
+
description: 'Update mode (default: replace)'
|
|
107
|
+
},
|
|
108
|
+
local_path: {
|
|
109
|
+
type: 'string',
|
|
110
|
+
description: 'Update the local file path for multi-device sync'
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
required: ['content']
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function deleteDevDocDefinition() {
|
|
119
|
+
return {
|
|
120
|
+
name: 'delete_dev_doc',
|
|
121
|
+
description: 'Delete a dev document (soft delete).',
|
|
122
|
+
inputSchema: {
|
|
123
|
+
type: 'object',
|
|
124
|
+
properties: {
|
|
125
|
+
title: { type: 'string', description: 'Document title' },
|
|
126
|
+
id: { type: 'string', description: 'Document UUID' },
|
|
127
|
+
category: { type: 'string', description: 'Subcategory to disambiguate' }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
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
|
+
|
|
299
|
+
function listDevCategoriesDefinition() {
|
|
300
|
+
return {
|
|
301
|
+
name: 'list_dev_categories',
|
|
302
|
+
description: 'List all subcategories under dev/. Shows document count per category.',
|
|
303
|
+
inputSchema: {
|
|
304
|
+
type: 'object',
|
|
305
|
+
properties: {}
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function getDevDocTools() {
|
|
311
|
+
return [
|
|
312
|
+
{
|
|
313
|
+
definition: listDevDocsDefinition(),
|
|
314
|
+
handler: async (args, { apiClient }) => {
|
|
315
|
+
const result = await apiClient.callTool('list_dev_docs', args);
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
definition: getDevDocDefinition(),
|
|
321
|
+
handler: async (args, { apiClient }) => {
|
|
322
|
+
const result = await apiClient.callTool('get_dev_doc', args);
|
|
323
|
+
return result;
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
{
|
|
327
|
+
definition: createDevDocDefinition(),
|
|
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
|
+
}
|
|
333
|
+
const result = await apiClient.callTool('create_dev_doc', args);
|
|
334
|
+
return result;
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
definition: updateDevDocDefinition(),
|
|
339
|
+
handler: async (args, { apiClient }) => {
|
|
340
|
+
if (args.local_path) {
|
|
341
|
+
args.local_path = canonicalizePath(args.local_path);
|
|
342
|
+
}
|
|
343
|
+
const result = await apiClient.callTool('update_dev_doc', args);
|
|
344
|
+
return result;
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
{
|
|
348
|
+
definition: deleteDevDocDefinition(),
|
|
349
|
+
handler: async (args, { apiClient }) => {
|
|
350
|
+
const result = await apiClient.callTool('delete_dev_doc', args);
|
|
351
|
+
return result;
|
|
352
|
+
}
|
|
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
|
+
},
|
|
403
|
+
{
|
|
404
|
+
definition: listDevCategoriesDefinition(),
|
|
405
|
+
handler: async (_args, { apiClient }) => {
|
|
406
|
+
const result = await apiClient.callTool('list_dev_categories', {});
|
|
407
|
+
return result;
|
|
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
|
+
}
|
|
641
|
+
}
|
|
642
|
+
];
|
|
643
|
+
}
|