@tuskydp/cli 0.1.0 → 0.1.1
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/bin/tuskydp.ts +2 -0
- package/dist/src/commands/account.d.ts.map +1 -1
- package/dist/src/commands/account.js +5 -2
- package/dist/src/commands/account.js.map +1 -1
- package/dist/src/commands/auth.d.ts.map +1 -1
- package/dist/src/commands/auth.js +2 -1
- package/dist/src/commands/auth.js.map +1 -1
- package/dist/src/commands/files.d.ts.map +1 -1
- package/dist/src/commands/files.js +9 -4
- package/dist/src/commands/files.js.map +1 -1
- package/dist/src/commands/mcp.js +1 -1
- package/dist/src/commands/mcp.js.map +1 -1
- package/dist/src/commands/rehydrate.d.ts.map +1 -1
- package/dist/src/commands/rehydrate.js +5 -2
- package/dist/src/commands/rehydrate.js.map +1 -1
- package/dist/src/commands/upload.d.ts.map +1 -1
- package/dist/src/commands/upload.js +5 -0
- package/dist/src/commands/upload.js.map +1 -1
- package/dist/src/config.d.ts +0 -2
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +5 -4
- package/dist/src/config.js.map +1 -1
- package/dist/src/index.js +16 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/lib/keyring.d.ts.map +1 -1
- package/dist/src/lib/keyring.js +3 -5
- package/dist/src/lib/keyring.js.map +1 -1
- package/dist/src/lib/output.js +1 -1
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/resolve.js +1 -1
- package/dist/src/lib/resolve.js.map +1 -1
- package/dist/src/mcp/tools/files.d.ts.map +1 -1
- package/dist/src/mcp/tools/files.js +20 -0
- package/dist/src/mcp/tools/files.js.map +1 -1
- package/dist/src/mcp/tools/folders.d.ts.map +1 -1
- package/dist/src/mcp/tools/folders.js +15 -0
- package/dist/src/mcp/tools/folders.js.map +1 -1
- package/dist/src/mcp/tools/trash.d.ts.map +1 -1
- package/dist/src/mcp/tools/trash.js +14 -0
- package/dist/src/mcp/tools/trash.js.map +1 -1
- package/dist/src/sdk.d.ts +1 -1
- package/dist/src/sdk.d.ts.map +1 -1
- package/dist/src/sdk.js +3 -3
- package/dist/src/sdk.js.map +1 -1
- package/dist/src/tui/auth-screen.d.ts.map +1 -1
- package/dist/src/tui/auth-screen.js +7 -1
- package/dist/src/tui/auth-screen.js.map +1 -1
- package/package.json +12 -18
- package/src/__tests__/crypto.test.ts +315 -0
- package/src/commands/account.ts +82 -0
- package/src/commands/auth.ts +190 -0
- package/src/commands/decrypt.ts +276 -0
- package/src/commands/download.ts +82 -0
- package/src/commands/encryption.ts +305 -0
- package/src/commands/export.ts +251 -0
- package/src/commands/files.ts +192 -0
- package/src/commands/mcp.ts +220 -0
- package/src/commands/rehydrate.ts +37 -0
- package/src/commands/tui.ts +11 -0
- package/src/commands/upload.ts +143 -0
- package/src/commands/vault.ts +132 -0
- package/src/config.ts +38 -0
- package/src/crypto.ts +130 -0
- package/src/index.ts +79 -0
- package/src/lib/keyring.ts +50 -0
- package/src/lib/output.ts +36 -0
- package/src/lib/progress.ts +5 -0
- package/src/lib/resolve.ts +26 -0
- package/src/mcp/context.ts +22 -0
- package/src/mcp/server.ts +140 -0
- package/src/mcp/tools/account.ts +40 -0
- package/src/mcp/tools/files.ts +428 -0
- package/src/mcp/tools/folders.ts +109 -0
- package/src/mcp/tools/helpers.ts +28 -0
- package/src/mcp/tools/trash.ts +82 -0
- package/src/mcp/tools/vaults.ts +114 -0
- package/src/sdk.ts +115 -0
- package/src/tui/auth-screen.ts +176 -0
- package/src/tui/dialogs.ts +339 -0
- package/src/tui/files-panel.ts +165 -0
- package/src/tui/helpers.ts +206 -0
- package/src/tui/index.ts +420 -0
- package/src/tui/overview.ts +155 -0
- package/src/tui/status-bar.ts +61 -0
- package/src/tui/vaults-panel.ts +143 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tools — Account
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import type { McpContext } from '../context.js';
|
|
7
|
+
import { wrapToolError } from './helpers.js';
|
|
8
|
+
|
|
9
|
+
export function registerAccountTools(server: McpServer, ctx: McpContext) {
|
|
10
|
+
server.tool(
|
|
11
|
+
'tusky_account_info',
|
|
12
|
+
'Get account information including email, plan, storage usage, and encryption status',
|
|
13
|
+
{},
|
|
14
|
+
async () => {
|
|
15
|
+
try {
|
|
16
|
+
const account = await ctx.sdk.account.get();
|
|
17
|
+
|
|
18
|
+
const info = {
|
|
19
|
+
id: account.id,
|
|
20
|
+
email: account.email,
|
|
21
|
+
plan: account.planName,
|
|
22
|
+
planTier: account.planTier,
|
|
23
|
+
storageUsed: account.storageUsedFormatted,
|
|
24
|
+
storageLimit: account.storageLimitFormatted,
|
|
25
|
+
storageUsedBytes: account.storageUsedBytes,
|
|
26
|
+
storageLimitBytes: account.storageLimitBytes,
|
|
27
|
+
encryptionSetup: account.encryptionSetupComplete,
|
|
28
|
+
encryptionUnlocked: ctx.isEncryptionReady(),
|
|
29
|
+
createdAt: account.createdAt,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
content: [{ type: 'text' as const, text: JSON.stringify(info, null, 2) }],
|
|
34
|
+
};
|
|
35
|
+
} catch (err) {
|
|
36
|
+
return wrapToolError(err);
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tools — File operations
|
|
3
|
+
*
|
|
4
|
+
* Handles upload with client-side encryption for private vaults,
|
|
5
|
+
* download with decryption and rehydration polling for cold files.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { readFileSync, writeFileSync, statSync } from 'fs';
|
|
10
|
+
import { basename, resolve, join } from 'path';
|
|
11
|
+
import { lookup } from 'mime-types';
|
|
12
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
13
|
+
import type { McpContext } from '../context.js';
|
|
14
|
+
import { encryptBuffer, decryptBuffer } from '../../crypto.js';
|
|
15
|
+
import { wrapToolError } from './helpers.js';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Shared helpers
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/** MIME types that should be returned as text rather than base64. */
|
|
22
|
+
function isTextMimeType(mimeType: string): boolean {
|
|
23
|
+
if (mimeType.startsWith('text/')) return true;
|
|
24
|
+
const textTypes = [
|
|
25
|
+
'application/json',
|
|
26
|
+
'application/xml',
|
|
27
|
+
'application/javascript',
|
|
28
|
+
'application/typescript',
|
|
29
|
+
'application/x-yaml',
|
|
30
|
+
'application/yaml',
|
|
31
|
+
'application/toml',
|
|
32
|
+
'application/x-sh',
|
|
33
|
+
'application/sql',
|
|
34
|
+
'application/graphql',
|
|
35
|
+
'application/xhtml+xml',
|
|
36
|
+
'application/svg+xml',
|
|
37
|
+
'application/x-httpd-php',
|
|
38
|
+
];
|
|
39
|
+
return textTypes.includes(mimeType) || mimeType.endsWith('+json') || mimeType.endsWith('+xml');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Fetch a file from Tusky, handle rehydration polling and decryption.
|
|
44
|
+
* Returns the plaintext buffer and encryption info.
|
|
45
|
+
*/
|
|
46
|
+
async function fetchAndDecryptFile(fileId: string, ctx: McpContext) {
|
|
47
|
+
let dlResponse = await ctx.sdk.files.getDownloadUrl(fileId);
|
|
48
|
+
let { downloadUrl, status, encryption } = dlResponse;
|
|
49
|
+
|
|
50
|
+
// Handle rehydration from Walrus (cold files)
|
|
51
|
+
if (status === 'rehydrating') {
|
|
52
|
+
for (let i = 0; i < 30; i++) {
|
|
53
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
54
|
+
const check = await ctx.sdk.files.getDownloadUrl(fileId);
|
|
55
|
+
if (check.status === 'ready') {
|
|
56
|
+
downloadUrl = check.downloadUrl;
|
|
57
|
+
encryption = check.encryption;
|
|
58
|
+
status = 'ready';
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (status !== 'ready') {
|
|
63
|
+
throw new Error('File rehydration timed out (60s). The file is being retrieved from Walrus. Try again later.');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!downloadUrl) {
|
|
68
|
+
throw new Error('No download URL available for this file.');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Download
|
|
72
|
+
const response = await fetch(downloadUrl);
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
throw new Error(`Download failed: HTTP ${response.status}`);
|
|
75
|
+
}
|
|
76
|
+
const arrayBuf = await response.arrayBuffer();
|
|
77
|
+
let fileBuffer: Buffer = Buffer.from(arrayBuf as ArrayBuffer);
|
|
78
|
+
|
|
79
|
+
// Decrypt if needed
|
|
80
|
+
if (encryption?.encrypted) {
|
|
81
|
+
const masterKey = ctx.getMasterKey();
|
|
82
|
+
if (!masterKey) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
'Encryption passphrase required to decrypt this file. ' +
|
|
85
|
+
'Set the TUSKYDP_PASSWORD environment variable in your MCP server config.',
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
fileBuffer = decryptBuffer(
|
|
89
|
+
fileBuffer,
|
|
90
|
+
encryption.wrappedKey!,
|
|
91
|
+
encryption.iv!,
|
|
92
|
+
masterKey,
|
|
93
|
+
encryption.plaintextChecksumSha256 ?? undefined,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { fileBuffer, encryption };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Encrypt (if private vault) and upload a buffer to Tusky.
|
|
102
|
+
* Shared by tusky_file_upload (from disk) and tusky_file_create (from content).
|
|
103
|
+
*/
|
|
104
|
+
async function encryptAndUpload(
|
|
105
|
+
fileBuffer: Buffer,
|
|
106
|
+
fileName: string,
|
|
107
|
+
mimeType: string,
|
|
108
|
+
vaultId: string,
|
|
109
|
+
folderId: string | undefined,
|
|
110
|
+
ctx: McpContext,
|
|
111
|
+
) {
|
|
112
|
+
const vault = await ctx.sdk.vaults.get(vaultId);
|
|
113
|
+
const isPrivate = vault.visibility === 'private';
|
|
114
|
+
|
|
115
|
+
let uploadBody: Buffer;
|
|
116
|
+
let encryptionMeta: {
|
|
117
|
+
wrappedKey?: string;
|
|
118
|
+
encryptionIv?: string;
|
|
119
|
+
plaintextSizeBytes?: number;
|
|
120
|
+
plaintextChecksumSha256?: string;
|
|
121
|
+
} = {};
|
|
122
|
+
|
|
123
|
+
if (isPrivate) {
|
|
124
|
+
const masterKey = ctx.getMasterKey();
|
|
125
|
+
if (!masterKey) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
'Encryption passphrase required for private vault uploads. ' +
|
|
128
|
+
'Set the TUSKYDP_PASSWORD environment variable in your MCP server config.',
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { ciphertext, wrappedKey, iv, plaintextChecksum } = encryptBuffer(fileBuffer, masterKey);
|
|
133
|
+
uploadBody = ciphertext;
|
|
134
|
+
encryptionMeta = {
|
|
135
|
+
wrappedKey,
|
|
136
|
+
encryptionIv: iv,
|
|
137
|
+
plaintextSizeBytes: fileBuffer.length,
|
|
138
|
+
plaintextChecksumSha256: plaintextChecksum,
|
|
139
|
+
};
|
|
140
|
+
} else {
|
|
141
|
+
uploadBody = fileBuffer;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Request presigned upload URL
|
|
145
|
+
const { fileId, uploadUrl } = await ctx.sdk.files.requestUpload({
|
|
146
|
+
name: fileName,
|
|
147
|
+
mimeType,
|
|
148
|
+
sizeBytes: uploadBody.length,
|
|
149
|
+
vaultId,
|
|
150
|
+
folderId,
|
|
151
|
+
...encryptionMeta,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// PUT to presigned URL
|
|
155
|
+
const putRes = await fetch(uploadUrl, {
|
|
156
|
+
method: 'PUT',
|
|
157
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
158
|
+
body: new Uint8Array(uploadBody),
|
|
159
|
+
});
|
|
160
|
+
if (!putRes.ok) {
|
|
161
|
+
const errText = await putRes.text().catch(() => putRes.statusText);
|
|
162
|
+
throw new Error(`Upload PUT failed (${putRes.status}): ${errText}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Confirm upload
|
|
166
|
+
const file = await ctx.sdk.files.confirmUpload(fileId);
|
|
167
|
+
|
|
168
|
+
return { file, isPrivate, uploadedSizeBytes: uploadBody.length };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Tool registration
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
export function registerFileTools(server: McpServer, ctx: McpContext) {
|
|
176
|
+
// ── Upload file from disk ──────────────────────────────────────────────
|
|
177
|
+
server.tool(
|
|
178
|
+
'tusky_file_upload',
|
|
179
|
+
'Upload a file from a local filesystem path to a vault. Handles encryption transparently for private vaults. Use tusky_file_create instead if you want to upload content directly without a file on disk.',
|
|
180
|
+
{
|
|
181
|
+
filePath: z.string().describe('Absolute or relative path to the local file to upload'),
|
|
182
|
+
vaultId: z.string().describe('Target vault ID'),
|
|
183
|
+
folderId: z.string().optional().describe('Target folder ID within the vault (omit for vault root)'),
|
|
184
|
+
},
|
|
185
|
+
async ({ filePath, vaultId, folderId }) => {
|
|
186
|
+
try {
|
|
187
|
+
const resolvedPath = resolve(filePath);
|
|
188
|
+
|
|
189
|
+
// Validate file exists
|
|
190
|
+
let stat;
|
|
191
|
+
try {
|
|
192
|
+
stat = statSync(resolvedPath);
|
|
193
|
+
} catch {
|
|
194
|
+
return wrapToolError(new Error(`File not found: ${resolvedPath}`));
|
|
195
|
+
}
|
|
196
|
+
if (!stat.isFile()) {
|
|
197
|
+
return wrapToolError(new Error(`Not a file: ${resolvedPath}`));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const fileBuffer = readFileSync(resolvedPath);
|
|
201
|
+
const fileName = basename(resolvedPath);
|
|
202
|
+
const mimeType = lookup(resolvedPath) || 'application/octet-stream';
|
|
203
|
+
|
|
204
|
+
const { file, isPrivate, uploadedSizeBytes } = await encryptAndUpload(
|
|
205
|
+
fileBuffer, fileName, mimeType, vaultId, folderId, ctx,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
content: [{ type: 'text' as const, text: JSON.stringify({
|
|
210
|
+
...file,
|
|
211
|
+
localPath: resolvedPath,
|
|
212
|
+
encrypted: isPrivate,
|
|
213
|
+
uploadedSizeBytes,
|
|
214
|
+
}, null, 2) }],
|
|
215
|
+
};
|
|
216
|
+
} catch (err) {
|
|
217
|
+
return wrapToolError(err);
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
// ── Create file from content ─────────────────────────────────────────
|
|
223
|
+
server.tool(
|
|
224
|
+
'tusky_file_create',
|
|
225
|
+
'Create a file in a vault from raw content (text or base64-encoded binary). Use this when you want to upload content directly without needing a file on disk. Handles encryption transparently for private vaults.',
|
|
226
|
+
{
|
|
227
|
+
name: z.string().describe('File name including extension (e.g. "report.md", "data.json", "image.png")'),
|
|
228
|
+
content: z.string().describe('File content: plain text for text files, or base64-encoded string for binary files'),
|
|
229
|
+
encoding: z.enum(['utf-8', 'base64']).optional().describe(
|
|
230
|
+
'Content encoding. "utf-8" (default) for text content, "base64" for binary content.',
|
|
231
|
+
),
|
|
232
|
+
vaultId: z.string().describe('Target vault ID'),
|
|
233
|
+
folderId: z.string().optional().describe('Target folder ID within the vault (omit for vault root)'),
|
|
234
|
+
},
|
|
235
|
+
async ({ name, content, encoding, vaultId, folderId }) => {
|
|
236
|
+
try {
|
|
237
|
+
const enc = encoding ?? 'utf-8';
|
|
238
|
+
const fileBuffer = enc === 'base64'
|
|
239
|
+
? Buffer.from(content, 'base64')
|
|
240
|
+
: Buffer.from(content, 'utf-8');
|
|
241
|
+
|
|
242
|
+
const mimeType = lookup(name) || 'application/octet-stream';
|
|
243
|
+
|
|
244
|
+
const { file, isPrivate, uploadedSizeBytes } = await encryptAndUpload(
|
|
245
|
+
fileBuffer, name, mimeType, vaultId, folderId, ctx,
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
content: [{ type: 'text' as const, text: JSON.stringify({
|
|
250
|
+
...file,
|
|
251
|
+
encrypted: isPrivate,
|
|
252
|
+
uploadedSizeBytes,
|
|
253
|
+
}, null, 2) }],
|
|
254
|
+
};
|
|
255
|
+
} catch (err) {
|
|
256
|
+
return wrapToolError(err);
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
// ── Download file (to disk) ────────────────────────────────────────────
|
|
262
|
+
server.tool(
|
|
263
|
+
'tusky_file_download',
|
|
264
|
+
'Download a file by ID and save it to a local path on disk. Handles decryption for private vaults and rehydration for cold files. Use tusky_file_read instead if you just want the file content returned directly.',
|
|
265
|
+
{
|
|
266
|
+
fileId: z.string().describe('File ID to download'),
|
|
267
|
+
outputPath: z.string().describe('Local path to save the file (must be writable)'),
|
|
268
|
+
},
|
|
269
|
+
async ({ fileId, outputPath }) => {
|
|
270
|
+
try {
|
|
271
|
+
const { fileBuffer, encryption } = await fetchAndDecryptFile(fileId, ctx);
|
|
272
|
+
|
|
273
|
+
const fileInfo = await ctx.sdk.files.getStatus(fileId);
|
|
274
|
+
const finalPath = resolve(outputPath);
|
|
275
|
+
|
|
276
|
+
writeFileSync(finalPath, fileBuffer);
|
|
277
|
+
|
|
278
|
+
const result = {
|
|
279
|
+
fileId,
|
|
280
|
+
name: fileInfo.name,
|
|
281
|
+
savedTo: finalPath,
|
|
282
|
+
sizeBytes: fileBuffer.length,
|
|
283
|
+
mimeType: fileInfo.mimeType,
|
|
284
|
+
wasEncrypted: encryption?.encrypted ?? false,
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
|
289
|
+
};
|
|
290
|
+
} catch (err) {
|
|
291
|
+
return wrapToolError(err);
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
// ── Read file content (inline) ───────────────────────────────────────
|
|
297
|
+
server.tool(
|
|
298
|
+
'tusky_file_read',
|
|
299
|
+
'Read a file\'s content and return it directly. For text files the content is returned as text. For binary files it is returned as base64. Handles decryption for private vaults and rehydration for cold files transparently. Prefer this over tusky_file_download when you need to inspect file content.',
|
|
300
|
+
{
|
|
301
|
+
fileId: z.string().describe('File ID to read'),
|
|
302
|
+
},
|
|
303
|
+
async ({ fileId }) => {
|
|
304
|
+
try {
|
|
305
|
+
const { fileBuffer, encryption } = await fetchAndDecryptFile(fileId, ctx);
|
|
306
|
+
const fileInfo = await ctx.sdk.files.getStatus(fileId);
|
|
307
|
+
|
|
308
|
+
const isText = isTextMimeType(fileInfo.mimeType);
|
|
309
|
+
|
|
310
|
+
if (isText) {
|
|
311
|
+
return {
|
|
312
|
+
content: [
|
|
313
|
+
{
|
|
314
|
+
type: 'text' as const,
|
|
315
|
+
text: `--- ${fileInfo.name} (${fileInfo.mimeType}, ${fileBuffer.length} bytes${encryption?.encrypted ? ', decrypted' : ''}) ---\n\n${fileBuffer.toString('utf-8')}`,
|
|
316
|
+
},
|
|
317
|
+
],
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Binary file — return as base64
|
|
322
|
+
return {
|
|
323
|
+
content: [
|
|
324
|
+
{
|
|
325
|
+
type: 'text' as const,
|
|
326
|
+
text: JSON.stringify({
|
|
327
|
+
fileId,
|
|
328
|
+
name: fileInfo.name,
|
|
329
|
+
mimeType: fileInfo.mimeType,
|
|
330
|
+
sizeBytes: fileBuffer.length,
|
|
331
|
+
wasEncrypted: encryption?.encrypted ?? false,
|
|
332
|
+
encoding: 'base64',
|
|
333
|
+
data: fileBuffer.toString('base64'),
|
|
334
|
+
}, null, 2),
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
};
|
|
338
|
+
} catch (err) {
|
|
339
|
+
return wrapToolError(err);
|
|
340
|
+
}
|
|
341
|
+
},
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
// ── List files ───────────────────────────────────────────────────────
|
|
345
|
+
server.tool(
|
|
346
|
+
'tusky_file_list',
|
|
347
|
+
'List files, optionally filtered by vault or folder. Returns file metadata including status.',
|
|
348
|
+
{
|
|
349
|
+
vaultId: z.string().optional().describe('Filter by vault ID'),
|
|
350
|
+
folderId: z.string().optional().describe('Filter by folder ID'),
|
|
351
|
+
limit: z.number().optional().describe('Max number of files to return (default 50)'),
|
|
352
|
+
cursor: z.string().optional().describe('Pagination cursor from a previous response'),
|
|
353
|
+
},
|
|
354
|
+
async ({ vaultId, folderId, limit, cursor }) => {
|
|
355
|
+
try {
|
|
356
|
+
const result = await ctx.sdk.files.list({ vaultId, folderId, limit, cursor });
|
|
357
|
+
return {
|
|
358
|
+
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
|
359
|
+
};
|
|
360
|
+
} catch (err) {
|
|
361
|
+
return wrapToolError(err);
|
|
362
|
+
}
|
|
363
|
+
},
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
// ── Get file info ────────────────────────────────────────────────────
|
|
367
|
+
server.tool(
|
|
368
|
+
'tusky_file_get',
|
|
369
|
+
'Get detailed metadata for a specific file by ID',
|
|
370
|
+
{
|
|
371
|
+
fileId: z.string().describe('File ID'),
|
|
372
|
+
},
|
|
373
|
+
async ({ fileId }) => {
|
|
374
|
+
try {
|
|
375
|
+
const file = await ctx.sdk.files.get(fileId);
|
|
376
|
+
return {
|
|
377
|
+
content: [{ type: 'text' as const, text: JSON.stringify(file, null, 2) }],
|
|
378
|
+
};
|
|
379
|
+
} catch (err) {
|
|
380
|
+
return wrapToolError(err);
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
// ── Delete file ──────────────────────────────────────────────────────
|
|
386
|
+
server.tool(
|
|
387
|
+
'tusky_file_delete',
|
|
388
|
+
'Soft-delete a file (moves to trash). Can be restored within 7 days.',
|
|
389
|
+
{
|
|
390
|
+
fileId: z.string().describe('File ID to delete'),
|
|
391
|
+
},
|
|
392
|
+
async ({ fileId }) => {
|
|
393
|
+
try {
|
|
394
|
+
await ctx.sdk.files.delete(fileId);
|
|
395
|
+
return {
|
|
396
|
+
content: [{ type: 'text' as const, text: `File ${fileId} moved to trash.` }],
|
|
397
|
+
};
|
|
398
|
+
} catch (err) {
|
|
399
|
+
return wrapToolError(err);
|
|
400
|
+
}
|
|
401
|
+
},
|
|
402
|
+
);
|
|
403
|
+
|
|
404
|
+
// ── Retry failed file sync ──────────────────────────────────────────
|
|
405
|
+
server.tool(
|
|
406
|
+
'tusky_file_retry',
|
|
407
|
+
'Retry Walrus sync for a failed file, or retry all failed files if no fileId given.',
|
|
408
|
+
{
|
|
409
|
+
fileId: z.string().optional().describe('File ID to retry (omit to retry all failed files)'),
|
|
410
|
+
},
|
|
411
|
+
async ({ fileId }) => {
|
|
412
|
+
try {
|
|
413
|
+
if (fileId) {
|
|
414
|
+
const result = await ctx.sdk.files.retry(fileId);
|
|
415
|
+
return {
|
|
416
|
+
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
const result = await ctx.sdk.files.retryAll();
|
|
420
|
+
return {
|
|
421
|
+
content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
|
|
422
|
+
};
|
|
423
|
+
} catch (err) {
|
|
424
|
+
return wrapToolError(err);
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
);
|
|
428
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tools — Folder management
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
7
|
+
import type { McpContext } from '../context.js';
|
|
8
|
+
import { wrapToolError } from './helpers.js';
|
|
9
|
+
|
|
10
|
+
export function registerFolderTools(server: McpServer, ctx: McpContext) {
|
|
11
|
+
// ── Create folder ────────────────────────────────────────────────────
|
|
12
|
+
server.tool(
|
|
13
|
+
'tusky_folder_create',
|
|
14
|
+
'Create a folder inside a vault',
|
|
15
|
+
{
|
|
16
|
+
vaultId: z.string().describe('Vault ID'),
|
|
17
|
+
name: z.string().describe('Folder name'),
|
|
18
|
+
parentId: z.string().optional().describe('Parent folder ID (omit for root)'),
|
|
19
|
+
},
|
|
20
|
+
async ({ vaultId, name, parentId }) => {
|
|
21
|
+
try {
|
|
22
|
+
const folder = await ctx.sdk.folders.create({ vaultId, name, parentId });
|
|
23
|
+
return {
|
|
24
|
+
content: [{ type: 'text' as const, text: JSON.stringify(folder, null, 2) }],
|
|
25
|
+
};
|
|
26
|
+
} catch (err) {
|
|
27
|
+
return wrapToolError(err);
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
// ── List folders ─────────────────────────────────────────────────────
|
|
33
|
+
server.tool(
|
|
34
|
+
'tusky_folder_list',
|
|
35
|
+
'List folders in a vault (optionally within a parent folder)',
|
|
36
|
+
{
|
|
37
|
+
vaultId: z.string().describe('Vault ID'),
|
|
38
|
+
parentId: z.string().optional().describe('Parent folder ID (omit for root-level folders)'),
|
|
39
|
+
},
|
|
40
|
+
async ({ vaultId, parentId }) => {
|
|
41
|
+
try {
|
|
42
|
+
const folders = await ctx.sdk.folders.list({ vaultId, parentId });
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: 'text' as const, text: JSON.stringify(folders, null, 2) }],
|
|
45
|
+
};
|
|
46
|
+
} catch (err) {
|
|
47
|
+
return wrapToolError(err);
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// ── Get folder contents ──────────────────────────────────────────────
|
|
53
|
+
server.tool(
|
|
54
|
+
'tusky_folder_get_contents',
|
|
55
|
+
'Get the contents (files and subfolders) of a folder',
|
|
56
|
+
{
|
|
57
|
+
folderId: z.string().describe('Folder ID'),
|
|
58
|
+
},
|
|
59
|
+
async ({ folderId }) => {
|
|
60
|
+
try {
|
|
61
|
+
const contents = await ctx.sdk.folders.getContents(folderId);
|
|
62
|
+
return {
|
|
63
|
+
content: [{ type: 'text' as const, text: JSON.stringify(contents, null, 2) }],
|
|
64
|
+
};
|
|
65
|
+
} catch (err) {
|
|
66
|
+
return wrapToolError(err);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
// ── Update/rename folder ──────────────────────────────────────────────
|
|
72
|
+
server.tool(
|
|
73
|
+
'tusky_folder_update',
|
|
74
|
+
'Rename a folder',
|
|
75
|
+
{
|
|
76
|
+
folderId: z.string().describe('Folder ID'),
|
|
77
|
+
name: z.string().describe('New folder name'),
|
|
78
|
+
},
|
|
79
|
+
async ({ folderId, name }) => {
|
|
80
|
+
try {
|
|
81
|
+
const folder = await ctx.sdk.folders.update(folderId, { name });
|
|
82
|
+
return {
|
|
83
|
+
content: [{ type: 'text' as const, text: JSON.stringify(folder, null, 2) }],
|
|
84
|
+
};
|
|
85
|
+
} catch (err) {
|
|
86
|
+
return wrapToolError(err);
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// ── Delete folder ────────────────────────────────────────────────────
|
|
92
|
+
server.tool(
|
|
93
|
+
'tusky_folder_delete',
|
|
94
|
+
'Delete a folder',
|
|
95
|
+
{
|
|
96
|
+
folderId: z.string().describe('Folder ID'),
|
|
97
|
+
},
|
|
98
|
+
async ({ folderId }) => {
|
|
99
|
+
try {
|
|
100
|
+
await ctx.sdk.folders.delete(folderId);
|
|
101
|
+
return {
|
|
102
|
+
content: [{ type: 'text' as const, text: `Folder ${folderId} deleted successfully.` }],
|
|
103
|
+
};
|
|
104
|
+
} catch (err) {
|
|
105
|
+
return wrapToolError(err);
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for MCP tool handlers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { TuskyError } from '@tuskydp/sdk';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Wraps an error into an MCP tool error result.
|
|
9
|
+
* Extracts meaningful messages from TuskyError or generic Error instances.
|
|
10
|
+
*/
|
|
11
|
+
export function wrapToolError(err: unknown): {
|
|
12
|
+
content: { type: 'text'; text: string }[];
|
|
13
|
+
isError: true;
|
|
14
|
+
} {
|
|
15
|
+
let message: string;
|
|
16
|
+
if (err instanceof TuskyError) {
|
|
17
|
+
message = `Tusky API error (${err.statusCode}): ${err.message}`;
|
|
18
|
+
} else if (err instanceof Error) {
|
|
19
|
+
message = err.message;
|
|
20
|
+
} else {
|
|
21
|
+
message = String(err);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
content: [{ type: 'text' as const, text: message }],
|
|
26
|
+
isError: true,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP tools — Trash management
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
7
|
+
import type { McpContext } from '../context.js';
|
|
8
|
+
import { wrapToolError } from './helpers.js';
|
|
9
|
+
|
|
10
|
+
export function registerTrashTools(server: McpServer, ctx: McpContext) {
|
|
11
|
+
// ── List trash ───────────────────────────────────────────────────────
|
|
12
|
+
server.tool(
|
|
13
|
+
'tusky_trash_list',
|
|
14
|
+
'List all soft-deleted files and vaults in the trash',
|
|
15
|
+
{},
|
|
16
|
+
async () => {
|
|
17
|
+
try {
|
|
18
|
+
const trash = await ctx.sdk.trash.list();
|
|
19
|
+
return {
|
|
20
|
+
content: [{ type: 'text' as const, text: JSON.stringify(trash, null, 2) }],
|
|
21
|
+
};
|
|
22
|
+
} catch (err) {
|
|
23
|
+
return wrapToolError(err);
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
// ── Restore from trash ───────────────────────────────────────────────
|
|
29
|
+
server.tool(
|
|
30
|
+
'tusky_trash_restore',
|
|
31
|
+
'Restore a file or vault from the trash',
|
|
32
|
+
{
|
|
33
|
+
id: z.string().describe('ID of the trashed file or vault to restore'),
|
|
34
|
+
},
|
|
35
|
+
async ({ id }) => {
|
|
36
|
+
try {
|
|
37
|
+
const result = await ctx.sdk.trash.restore(id);
|
|
38
|
+
return {
|
|
39
|
+
content: [{ type: 'text' as const, text: `Restored ${result.type ?? 'item'} ${id} successfully.` }],
|
|
40
|
+
};
|
|
41
|
+
} catch (err) {
|
|
42
|
+
return wrapToolError(err);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// ── Delete single item from trash permanently ────────────────────────
|
|
48
|
+
server.tool(
|
|
49
|
+
'tusky_trash_delete',
|
|
50
|
+
'Permanently delete a single item from the trash. This action cannot be undone.',
|
|
51
|
+
{
|
|
52
|
+
id: z.string().describe('ID of the trashed file or vault to permanently delete'),
|
|
53
|
+
},
|
|
54
|
+
async ({ id }) => {
|
|
55
|
+
try {
|
|
56
|
+
const result = await ctx.sdk.trash.delete(id);
|
|
57
|
+
return {
|
|
58
|
+
content: [{ type: 'text' as const, text: `Permanently deleted ${result.type ?? 'item'} ${id}.` }],
|
|
59
|
+
};
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return wrapToolError(err);
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// ── Empty trash ──────────────────────────────────────────────────────
|
|
67
|
+
server.tool(
|
|
68
|
+
'tusky_trash_empty',
|
|
69
|
+
'Permanently delete ALL items in the trash. This action cannot be undone.',
|
|
70
|
+
{},
|
|
71
|
+
async () => {
|
|
72
|
+
try {
|
|
73
|
+
await ctx.sdk.trash.empty();
|
|
74
|
+
return {
|
|
75
|
+
content: [{ type: 'text' as const, text: 'Trash emptied successfully. All items permanently deleted.' }],
|
|
76
|
+
};
|
|
77
|
+
} catch (err) {
|
|
78
|
+
return wrapToolError(err);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
}
|