@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.
Files changed (87) hide show
  1. package/bin/tuskydp.ts +2 -0
  2. package/dist/src/commands/account.d.ts.map +1 -1
  3. package/dist/src/commands/account.js +5 -2
  4. package/dist/src/commands/account.js.map +1 -1
  5. package/dist/src/commands/auth.d.ts.map +1 -1
  6. package/dist/src/commands/auth.js +2 -1
  7. package/dist/src/commands/auth.js.map +1 -1
  8. package/dist/src/commands/files.d.ts.map +1 -1
  9. package/dist/src/commands/files.js +9 -4
  10. package/dist/src/commands/files.js.map +1 -1
  11. package/dist/src/commands/mcp.js +1 -1
  12. package/dist/src/commands/mcp.js.map +1 -1
  13. package/dist/src/commands/rehydrate.d.ts.map +1 -1
  14. package/dist/src/commands/rehydrate.js +5 -2
  15. package/dist/src/commands/rehydrate.js.map +1 -1
  16. package/dist/src/commands/upload.d.ts.map +1 -1
  17. package/dist/src/commands/upload.js +5 -0
  18. package/dist/src/commands/upload.js.map +1 -1
  19. package/dist/src/config.d.ts +0 -2
  20. package/dist/src/config.d.ts.map +1 -1
  21. package/dist/src/config.js +5 -4
  22. package/dist/src/config.js.map +1 -1
  23. package/dist/src/index.js +16 -2
  24. package/dist/src/index.js.map +1 -1
  25. package/dist/src/lib/keyring.d.ts.map +1 -1
  26. package/dist/src/lib/keyring.js +3 -5
  27. package/dist/src/lib/keyring.js.map +1 -1
  28. package/dist/src/lib/output.js +1 -1
  29. package/dist/src/lib/output.js.map +1 -1
  30. package/dist/src/lib/resolve.js +1 -1
  31. package/dist/src/lib/resolve.js.map +1 -1
  32. package/dist/src/mcp/tools/files.d.ts.map +1 -1
  33. package/dist/src/mcp/tools/files.js +20 -0
  34. package/dist/src/mcp/tools/files.js.map +1 -1
  35. package/dist/src/mcp/tools/folders.d.ts.map +1 -1
  36. package/dist/src/mcp/tools/folders.js +15 -0
  37. package/dist/src/mcp/tools/folders.js.map +1 -1
  38. package/dist/src/mcp/tools/trash.d.ts.map +1 -1
  39. package/dist/src/mcp/tools/trash.js +14 -0
  40. package/dist/src/mcp/tools/trash.js.map +1 -1
  41. package/dist/src/sdk.d.ts +1 -1
  42. package/dist/src/sdk.d.ts.map +1 -1
  43. package/dist/src/sdk.js +3 -3
  44. package/dist/src/sdk.js.map +1 -1
  45. package/dist/src/tui/auth-screen.d.ts.map +1 -1
  46. package/dist/src/tui/auth-screen.js +7 -1
  47. package/dist/src/tui/auth-screen.js.map +1 -1
  48. package/package.json +12 -18
  49. package/src/__tests__/crypto.test.ts +315 -0
  50. package/src/commands/account.ts +82 -0
  51. package/src/commands/auth.ts +190 -0
  52. package/src/commands/decrypt.ts +276 -0
  53. package/src/commands/download.ts +82 -0
  54. package/src/commands/encryption.ts +305 -0
  55. package/src/commands/export.ts +251 -0
  56. package/src/commands/files.ts +192 -0
  57. package/src/commands/mcp.ts +220 -0
  58. package/src/commands/rehydrate.ts +37 -0
  59. package/src/commands/tui.ts +11 -0
  60. package/src/commands/upload.ts +143 -0
  61. package/src/commands/vault.ts +132 -0
  62. package/src/config.ts +38 -0
  63. package/src/crypto.ts +130 -0
  64. package/src/index.ts +79 -0
  65. package/src/lib/keyring.ts +50 -0
  66. package/src/lib/output.ts +36 -0
  67. package/src/lib/progress.ts +5 -0
  68. package/src/lib/resolve.ts +26 -0
  69. package/src/mcp/context.ts +22 -0
  70. package/src/mcp/server.ts +140 -0
  71. package/src/mcp/tools/account.ts +40 -0
  72. package/src/mcp/tools/files.ts +428 -0
  73. package/src/mcp/tools/folders.ts +109 -0
  74. package/src/mcp/tools/helpers.ts +28 -0
  75. package/src/mcp/tools/trash.ts +82 -0
  76. package/src/mcp/tools/vaults.ts +114 -0
  77. package/src/sdk.ts +115 -0
  78. package/src/tui/auth-screen.ts +176 -0
  79. package/src/tui/dialogs.ts +339 -0
  80. package/src/tui/files-panel.ts +165 -0
  81. package/src/tui/helpers.ts +206 -0
  82. package/src/tui/index.ts +420 -0
  83. package/src/tui/overview.ts +155 -0
  84. package/src/tui/status-bar.ts +61 -0
  85. package/src/tui/vaults-panel.ts +143 -0
  86. package/tsconfig.json +9 -0
  87. package/vitest.config.ts +7 -0
@@ -0,0 +1,132 @@
1
+ import type { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import inquirer from 'inquirer';
4
+ import { cliConfig } from '../config.js';
5
+ import { getSDKClientFromParent } from '../sdk.js';
6
+ import { createTable, formatBytes, shortId } from '../lib/output.js';
7
+ import { resolveVault } from '../lib/resolve.js';
8
+
9
+ export function registerVaultCommands(program: Command) {
10
+ const vault = program.command('vault').description('Manage vaults');
11
+
12
+ vault.command('create <name>')
13
+ .description('Create a new vault (private/encrypted by default)')
14
+ .option('--public', 'Create as public vault (unencrypted, shareable via URL)')
15
+ .option('--description <text>', 'Vault description')
16
+ .action(async (name: string, options) => {
17
+ const sdk = getSDKClientFromParent(vault);
18
+ const visibility = options.public ? 'public' as const : 'private' as const;
19
+
20
+ if (visibility === 'private') {
21
+ try {
22
+ const { setupComplete } = await sdk.account.getEncryptionParams();
23
+ if (!setupComplete) {
24
+ console.log(chalk.red('Encryption must be set up before creating private vaults.'));
25
+ console.log(chalk.dim(' Run: tusky encryption setup'));
26
+ return;
27
+ }
28
+ } catch {
29
+ // If endpoint fails, proceed anyway
30
+ }
31
+ }
32
+
33
+ const created = await sdk.vaults.create({ name, description: options.description, visibility });
34
+ const icon = visibility === 'private' ? 'private' : 'public';
35
+ console.log(chalk.green(`Created ${visibility} vault [${icon}] "${created.name}" (${created.id})`));
36
+ });
37
+
38
+ vault.command('list')
39
+ .description('List all vaults')
40
+ .action(async () => {
41
+ const sdk = getSDKClientFromParent(vault);
42
+ const format = program.opts().format || cliConfig.get('outputFormat');
43
+ const vaults = await sdk.vaults.list();
44
+
45
+ if (format === 'json') {
46
+ console.log(JSON.stringify(vaults, null, 2));
47
+ return;
48
+ }
49
+
50
+ if (vaults.length === 0) {
51
+ console.log(chalk.dim('No vaults found.'));
52
+ return;
53
+ }
54
+
55
+ const table = createTable(['Name', 'Slug', 'Visibility', 'Files', 'Size', 'ID']);
56
+ for (const v of vaults) {
57
+ const visIcon = v.visibility === 'private' ? 'private' : 'public';
58
+ table.push([
59
+ v.isDefault ? `${v.name} ${chalk.dim('(default)')}` : v.name,
60
+ v.slug,
61
+ visIcon,
62
+ String(v.fileCount),
63
+ formatBytes(v.totalSizeBytes),
64
+ chalk.dim(shortId(v.id)),
65
+ ]);
66
+ }
67
+ console.log(table.toString());
68
+ });
69
+
70
+ vault.command('info <vault>')
71
+ .description('Show vault details')
72
+ .action(async (vaultRef: string) => {
73
+ const sdk = getSDKClientFromParent(vault);
74
+ const format = program.opts().format || cliConfig.get('outputFormat');
75
+ const vaultId = await resolveVault(sdk, vaultRef);
76
+ const v = await sdk.vaults.get(vaultId);
77
+
78
+ if (format === 'json') {
79
+ console.log(JSON.stringify(v, null, 2));
80
+ return;
81
+ }
82
+
83
+ console.log(`Name: ${v.name}`);
84
+ console.log(`Slug: ${v.slug}`);
85
+ console.log(`Visibility: ${v.visibility === 'private' ? 'private' : 'public'}`);
86
+ console.log(`Description: ${v.description || chalk.dim('(none)')}`);
87
+ console.log(`Files: ${v.fileCount}`);
88
+ console.log(`Total Size: ${formatBytes(v.totalSizeBytes)}`);
89
+ console.log(`Default: ${v.isDefault ? 'Yes' : 'No'}`);
90
+ console.log(`Created: ${new Date(v.createdAt).toLocaleString()}`);
91
+ console.log(`ID: ${v.id}`);
92
+ });
93
+
94
+ vault.command('rename <vault> <new-name>')
95
+ .description('Rename a vault')
96
+ .action(async (vaultRef: string, newName: string) => {
97
+ const sdk = getSDKClientFromParent(vault);
98
+ const vaultId = await resolveVault(sdk, vaultRef);
99
+ await sdk.vaults.update(vaultId, { name: newName });
100
+ console.log(chalk.green(`Vault renamed to "${newName}"`));
101
+ });
102
+
103
+ vault.command('delete <vault>')
104
+ .description('Delete a vault')
105
+ .option('--force', 'Delete vault and all its files')
106
+ .action(async (vaultRef: string, options) => {
107
+ const sdk = getSDKClientFromParent(vault);
108
+ const vaultId = await resolveVault(sdk, vaultRef);
109
+
110
+ if (!options.force) {
111
+ const answers = await inquirer.prompt([{
112
+ type: 'confirm',
113
+ name: 'confirm',
114
+ message: 'Delete vault? This cannot be undone.',
115
+ default: false,
116
+ }]);
117
+ if (!answers.confirm) return;
118
+ }
119
+
120
+ await sdk.vaults.delete(vaultId, { force: options.force });
121
+ console.log(chalk.green('Vault deleted.'));
122
+ });
123
+
124
+ vault.command('set-default <vault>')
125
+ .description('Set the default vault for uploads')
126
+ .action(async (vaultRef: string) => {
127
+ const sdk = getSDKClientFromParent(vault);
128
+ const vaultId = await resolveVault(sdk, vaultRef);
129
+ cliConfig.set('defaultVault', vaultId);
130
+ console.log(chalk.green(`Default vault set to ${vaultRef}`));
131
+ });
132
+ }
package/src/config.ts ADDED
@@ -0,0 +1,38 @@
1
+ import Conf from 'conf';
2
+
3
+ export interface TuskyDPConfig {
4
+ apiUrl: string;
5
+ apiKey?: string;
6
+ defaultVault?: string;
7
+ outputFormat: 'table' | 'json' | 'plain';
8
+ encryptionEnabled: boolean;
9
+ }
10
+
11
+ export const cliConfig = new Conf<TuskyDPConfig>({
12
+ projectName: 'tuskydp',
13
+ defaults: {
14
+ apiUrl: 'https://api.tusky.ai',
15
+ outputFormat: 'table',
16
+ encryptionEnabled: true,
17
+ },
18
+ });
19
+
20
+ export function getApiUrl(override?: string): string {
21
+ return override || process.env.TUSKYDP_API_URL || cliConfig.get('apiUrl');
22
+ }
23
+
24
+ export function getApiKey(override?: string): string {
25
+ const key = override || process.env.TUSKYDP_API_KEY || cliConfig.get('apiKey');
26
+ if (!key) {
27
+ console.error('Not authenticated. Run: tusky login');
28
+ process.exit(1);
29
+ }
30
+ return key;
31
+ }
32
+
33
+ const VALID_FORMATS = new Set(['table', 'json', 'plain']);
34
+
35
+ export function getOutputFormat(override?: string): 'table' | 'json' | 'plain' {
36
+ if (override && VALID_FORMATS.has(override)) return override as 'table' | 'json' | 'plain';
37
+ return cliConfig.get('outputFormat');
38
+ }
package/src/crypto.ts ADDED
@@ -0,0 +1,130 @@
1
+ import {
2
+ createHash,
3
+ createHmac,
4
+ randomBytes,
5
+ createCipheriv,
6
+ createDecipheriv,
7
+ pbkdf2Sync,
8
+ } from 'crypto';
9
+
10
+ const PBKDF2_ITERATIONS = 600_000;
11
+ const AES_ALGO = 'aes-256-gcm' as const;
12
+ const VERIFIER_CONTEXT = 'tuskydp-key-verifier-v1';
13
+
14
+ export function deriveMasterKey(passphrase: string, salt: Buffer): Buffer {
15
+ return pbkdf2Sync(passphrase, salt, PBKDF2_ITERATIONS, 32, 'sha256');
16
+ }
17
+
18
+ export function computeVerifier(masterKey: Buffer): Buffer {
19
+ return createHmac('sha256', masterKey)
20
+ .update(VERIFIER_CONTEXT)
21
+ .digest();
22
+ }
23
+
24
+ export function verifyPassphrase(masterKey: Buffer, expectedVerifier: Buffer): boolean {
25
+ const computed = computeVerifier(masterKey);
26
+ if (computed.length !== expectedVerifier.length) return false;
27
+ let diff = 0;
28
+ for (let i = 0; i < computed.length; i++) diff |= computed[i] ^ expectedVerifier[i];
29
+ return diff === 0;
30
+ }
31
+
32
+ export function generateSalt(): Buffer {
33
+ return randomBytes(16);
34
+ }
35
+
36
+ export function generateRecoveryKey(): Buffer {
37
+ return randomBytes(32);
38
+ }
39
+
40
+ export function generateMasterKey(): Buffer {
41
+ return randomBytes(32);
42
+ }
43
+
44
+ export function wrapMasterKey(masterKey: Buffer, recoveryKey: Buffer): Buffer {
45
+ const wrapIv = randomBytes(12);
46
+ const cipher = createCipheriv(AES_ALGO, recoveryKey, wrapIv);
47
+ const wrapped = Buffer.concat([cipher.update(masterKey), cipher.final()]);
48
+ const tag = cipher.getAuthTag();
49
+ return Buffer.concat([wrapIv, wrapped, tag]);
50
+ }
51
+
52
+ export function unwrapMasterKey(wrappedData: Buffer, recoveryKey: Buffer): Buffer {
53
+ const wrapIv = wrappedData.subarray(0, 12);
54
+ const wrapped = wrappedData.subarray(12, wrappedData.length - 16);
55
+ const tag = wrappedData.subarray(wrappedData.length - 16);
56
+ const decipher = createDecipheriv(AES_ALGO, recoveryKey, wrapIv);
57
+ decipher.setAuthTag(tag);
58
+ return Buffer.concat([decipher.update(wrapped), decipher.final()]);
59
+ }
60
+
61
+ export function encryptBuffer(plaintext: Buffer, masterKey: Buffer): {
62
+ ciphertext: Buffer;
63
+ wrappedKey: string;
64
+ iv: string;
65
+ plaintextChecksum: string;
66
+ } {
67
+ // Hash plaintext
68
+ const plaintextChecksum = createHash('sha256').update(plaintext).digest('hex');
69
+
70
+ // Generate random per-file key and IV
71
+ const fileKey = randomBytes(32);
72
+ const fileIv = randomBytes(12);
73
+
74
+ // Encrypt file
75
+ const cipher = createCipheriv(AES_ALGO, fileKey, fileIv);
76
+ const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]);
77
+ const authTag = cipher.getAuthTag();
78
+ const ciphertext = Buffer.concat([encrypted, authTag]);
79
+
80
+ // Wrap file key with master key
81
+ const wrapIv = randomBytes(12);
82
+ const wrapCipher = createCipheriv(AES_ALGO, masterKey, wrapIv);
83
+ const wrappedKeyData = Buffer.concat([wrapCipher.update(fileKey), wrapCipher.final()]);
84
+ const wrapTag = wrapCipher.getAuthTag();
85
+ const wrappedKeyFull = Buffer.concat([wrapIv, wrappedKeyData, wrapTag]);
86
+
87
+ return {
88
+ ciphertext,
89
+ wrappedKey: wrappedKeyFull.toString('base64'),
90
+ iv: fileIv.toString('base64'),
91
+ plaintextChecksum,
92
+ };
93
+ }
94
+
95
+ export function decryptBuffer(
96
+ ciphertext: Buffer,
97
+ wrappedKeyBase64: string,
98
+ ivBase64: string,
99
+ masterKey: Buffer,
100
+ expectedChecksum?: string,
101
+ ): Buffer {
102
+ // Unwrap file key
103
+ const wrappedKeyFull = Buffer.from(wrappedKeyBase64, 'base64');
104
+ const wrapIv = wrappedKeyFull.subarray(0, 12);
105
+ const wrappedKeyData = wrappedKeyFull.subarray(12, wrappedKeyFull.length - 16);
106
+ const wrapTag = wrappedKeyFull.subarray(wrappedKeyFull.length - 16);
107
+
108
+ const unwrapDecipher = createDecipheriv(AES_ALGO, masterKey, wrapIv);
109
+ unwrapDecipher.setAuthTag(wrapTag);
110
+ const fileKey = Buffer.concat([unwrapDecipher.update(wrappedKeyData), unwrapDecipher.final()]);
111
+
112
+ // Decrypt file
113
+ const fileIv = Buffer.from(ivBase64, 'base64');
114
+ const authTag = ciphertext.subarray(ciphertext.length - 16);
115
+ const encryptedData = ciphertext.subarray(0, ciphertext.length - 16);
116
+
117
+ const decipher = createDecipheriv(AES_ALGO, fileKey, fileIv);
118
+ decipher.setAuthTag(authTag);
119
+ const plaintext = Buffer.concat([decipher.update(encryptedData), decipher.final()]);
120
+
121
+ // Verify integrity
122
+ if (expectedChecksum) {
123
+ const actualChecksum = createHash('sha256').update(plaintext).digest('hex');
124
+ if (actualChecksum !== expectedChecksum) {
125
+ throw new Error('Integrity check failed — file may be corrupted');
126
+ }
127
+ }
128
+
129
+ return plaintext;
130
+ }
package/src/index.ts ADDED
@@ -0,0 +1,79 @@
1
+ import { Command } from 'commander';
2
+ import chalk from 'chalk';
3
+ import { registerAuthCommands } from './commands/auth.js';
4
+ import { registerEncryptionCommands } from './commands/encryption.js';
5
+ import { registerVaultCommands } from './commands/vault.js';
6
+ import { registerFileCommands } from './commands/files.js';
7
+ import { registerAccountCommands } from './commands/account.js';
8
+ import { uploadCommand } from './commands/upload.js';
9
+ import { downloadCommand } from './commands/download.js';
10
+ import { rehydrateCommand } from './commands/rehydrate.js';
11
+ import { registerTuiCommand } from './commands/tui.js';
12
+ import { registerMcpCommands } from './commands/mcp.js';
13
+ import { registerExportCommand } from './commands/export.js';
14
+ import { registerDecryptCommand } from './commands/decrypt.js';
15
+
16
+ const VERSION = '0.1.0';
17
+
18
+ const program = new Command();
19
+
20
+ program
21
+ .name('tusky')
22
+ .description('Tusky — Encrypted decentralized storage for developers, apps, and agents')
23
+ .version(VERSION)
24
+ .option('--api-key <key>', 'Override API key')
25
+ .option('--api-url <url>', 'Override API URL')
26
+ .option('--format <fmt>', 'Output format: table, json, plain', 'table')
27
+ .option('--quiet', 'Suppress non-essential output')
28
+ .option('--verbose', 'Show debug output')
29
+ .option('--no-color', 'Disable colored output')
30
+ .hook('preAction', () => {
31
+ const opts = program.opts();
32
+ // Disable chalk colors when --no-color is used
33
+ if (opts.color === false) {
34
+ chalk.level = 0;
35
+ }
36
+ });
37
+
38
+ registerAuthCommands(program);
39
+ registerEncryptionCommands(program);
40
+ registerVaultCommands(program);
41
+ registerFileCommands(program);
42
+ registerAccountCommands(program);
43
+ registerTuiCommand(program);
44
+ registerMcpCommands(program);
45
+ registerExportCommand(program);
46
+ registerDecryptCommand(program);
47
+
48
+ // Direct shortcuts for common operations
49
+ program.command('upload <paths...>')
50
+ .description('Upload files')
51
+ .option('--vault <vault>', 'Target vault')
52
+ .option('--recursive', 'Upload directory contents')
53
+ .action(async (paths: string[], options) => {
54
+ await uploadCommand(paths, options, program);
55
+ });
56
+
57
+ program.command('download <file-id>')
58
+ .description('Download a file')
59
+ .option('--output <path>', 'Output path')
60
+ .action(async (fileId: string, options) => {
61
+ await downloadCommand(fileId, options, program);
62
+ });
63
+
64
+ program.command('rehydrate <blob-id>')
65
+ .description('Download a file directly from Walrus by blob ID')
66
+ .option('--output <path>', 'Output file path')
67
+ .action(async (blobId: string, options) => {
68
+ await rehydrateCommand(blobId, options, program);
69
+ });
70
+
71
+ program.parseAsync().catch((err) => {
72
+ const opts = program.opts();
73
+ if (opts.verbose) {
74
+ console.error(err.stack || err.message);
75
+ } else {
76
+ console.error(err.message);
77
+ }
78
+ process.exit(1);
79
+ });
@@ -0,0 +1,50 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+ import { createCipheriv, createDecipheriv, randomBytes, createHash } from 'crypto';
5
+
6
+ const SESSION_DIR = join(homedir(), '.tusky');
7
+ const SESSION_FILE = join(SESSION_DIR, 'session.enc');
8
+
9
+ // Derive a machine-specific key from hostname + user
10
+ function getMachineKey(): Buffer {
11
+ const machineId = `${homedir()}-tusky-session-key`;
12
+ return createHash('sha256').update(machineId).digest();
13
+ }
14
+
15
+ export function storeMasterKey(masterKey: Buffer): void {
16
+ mkdirSync(SESSION_DIR, { recursive: true, mode: 0o700 });
17
+ const key = getMachineKey();
18
+ const iv = randomBytes(12);
19
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
20
+ const encrypted = Buffer.concat([cipher.update(masterKey), cipher.final()]);
21
+ const tag = cipher.getAuthTag();
22
+ const data = Buffer.concat([iv, tag, encrypted]);
23
+ writeFileSync(SESSION_FILE, data, { mode: 0o600 });
24
+ }
25
+
26
+ export function loadMasterKey(): Buffer | null {
27
+ if (!existsSync(SESSION_FILE)) return null;
28
+ try {
29
+ const data = readFileSync(SESSION_FILE);
30
+ const key = getMachineKey();
31
+ const iv = data.subarray(0, 12);
32
+ const tag = data.subarray(12, 28);
33
+ const encrypted = data.subarray(28);
34
+ const decipher = createDecipheriv('aes-256-gcm', key, iv);
35
+ decipher.setAuthTag(tag);
36
+ return Buffer.concat([decipher.update(encrypted), decipher.final()]);
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ export function clearSession(): void {
43
+ try {
44
+ if (existsSync(SESSION_FILE)) {
45
+ unlinkSync(SESSION_FILE);
46
+ }
47
+ } catch {
48
+ // Ignore
49
+ }
50
+ }
@@ -0,0 +1,36 @@
1
+ import Table from 'cli-table3';
2
+ import chalk from 'chalk';
3
+
4
+ export function formatBytes(bytes: number): string {
5
+ if (bytes <= 0) return '0 B';
6
+ const k = 1024;
7
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
8
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
9
+ return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
10
+ }
11
+
12
+ export function createTable(head: string[]): Table.Table {
13
+ return new Table({
14
+ head: head.map(h => chalk.cyan(h)),
15
+ style: { head: [] },
16
+ });
17
+ }
18
+
19
+ export function formatDate(date: string | Date): string {
20
+ const d = new Date(date);
21
+ const now = new Date();
22
+ const diffMs = now.getTime() - d.getTime();
23
+ const diffMins = Math.floor(diffMs / 60000);
24
+ const diffHours = Math.floor(diffMs / 3600000);
25
+ const diffDays = Math.floor(diffMs / 86400000);
26
+
27
+ if (diffMins < 1) return 'just now';
28
+ if (diffMins < 60) return `${diffMins} min ago`;
29
+ if (diffHours < 24) return `${diffHours}h ago`;
30
+ if (diffDays < 7) return `${diffDays}d ago`;
31
+ return d.toLocaleDateString();
32
+ }
33
+
34
+ export function shortId(id: string): string {
35
+ return id.slice(0, 8) + '...';
36
+ }
@@ -0,0 +1,5 @@
1
+ import ora from 'ora';
2
+
3
+ export function createSpinner(text: string) {
4
+ return ora({ text, spinner: 'dots' });
5
+ }
@@ -0,0 +1,26 @@
1
+ import type { TuskyClient } from '@tuskydp/sdk';
2
+ import { cliConfig } from '../config.js';
3
+
4
+ export async function resolveVault(sdk: TuskyClient, vaultRef?: string): Promise<string> {
5
+ // If no vault specified, use the configured default
6
+ if (!vaultRef) {
7
+ const defaultVault = cliConfig.get('defaultVault');
8
+ if (defaultVault) return defaultVault;
9
+
10
+ // Fall back to the user's default vault
11
+ const vaults = await sdk.vaults.list();
12
+ const defaultV = vaults.find((v) => v.isDefault);
13
+ if (defaultV) return defaultV.id;
14
+
15
+ throw new Error('No default vault found. Create one with: tusky vault create <name>');
16
+ }
17
+
18
+ // If it looks like a UUID, use it directly
19
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(vaultRef)) return vaultRef;
20
+
21
+ // Otherwise, treat it as a slug and look it up
22
+ const vaults = await sdk.vaults.list();
23
+ const match = vaults.find((v) => v.slug === vaultRef || v.name === vaultRef);
24
+ if (!match) throw new Error(`Vault "${vaultRef}" not found`);
25
+ return match.id;
26
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Shared context for MCP tool handlers.
3
+ *
4
+ * Holds the authenticated SDK client and (optionally) the unlocked
5
+ * master key for client-side encryption/decryption of private vault files.
6
+ */
7
+
8
+ import type { TuskyClient } from '@tuskydp/sdk';
9
+
10
+ export interface McpContext {
11
+ /** Authenticated Tusky SDK client. */
12
+ sdk: TuskyClient;
13
+
14
+ /**
15
+ * Returns the in-memory master key, or null if encryption was not
16
+ * unlocked (i.e. TUSKYDP_PASSWORD was not provided or setup is incomplete).
17
+ */
18
+ getMasterKey(): Buffer | null;
19
+
20
+ /** True when the master key is available for encrypt/decrypt operations. */
21
+ isEncryptionReady(): boolean;
22
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * MCP Server for Tusky — exposes Tusky storage as tools over stdio.
3
+ *
4
+ * Agents (Claude Code, Cursor, etc.) spawn this process and communicate
5
+ * via JSON-RPC over stdin/stdout. All human-readable logging goes to
6
+ * stderr because stdout is reserved for the MCP protocol.
7
+ */
8
+
9
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
10
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
11
+ import { TuskyClient, TuskyError } from '@tuskydp/sdk';
12
+ import {
13
+ deriveMasterKey,
14
+ verifyPassphrase,
15
+ unwrapMasterKey,
16
+ } from '../crypto.js';
17
+ import { loadMasterKey } from '../lib/keyring.js';
18
+ import type { McpContext } from './context.js';
19
+
20
+ // Tool registrars
21
+ import { registerAccountTools } from './tools/account.js';
22
+ import { registerVaultTools } from './tools/vaults.js';
23
+ import { registerFolderTools } from './tools/folders.js';
24
+ import { registerFileTools } from './tools/files.js';
25
+ import { registerTrashTools } from './tools/trash.js';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Encryption bootstrap
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Attempt to unlock the master key using (in order):
33
+ * 1. TUSKYDP_PASSWORD env var → derive wrapping key → unwrap master key
34
+ * 2. Existing session file (~/.tusky/session.enc)
35
+ *
36
+ * Returns the master key Buffer, or null if neither method works.
37
+ */
38
+ async function unlockMasterKey(sdk: TuskyClient): Promise<Buffer | null> {
39
+ const password = process.env.TUSKYDP_PASSWORD;
40
+
41
+ if (password) {
42
+ try {
43
+ const params = await sdk.account.getEncryptionParams();
44
+ if (!params.setupComplete) {
45
+ console.error('[tusky-mcp] Encryption not set up on this account. Skipping encryption unlock.');
46
+ return null;
47
+ }
48
+
49
+ const salt = Buffer.from(params.salt!, 'base64');
50
+ const verifier = Buffer.from(params.verifier!, 'base64');
51
+ const wrappingKey = deriveMasterKey(password, salt);
52
+
53
+ if (!verifyPassphrase(wrappingKey, verifier)) {
54
+ console.error('[tusky-mcp] TUSKYDP_PASSWORD is incorrect. Encryption will be unavailable.');
55
+ return null;
56
+ }
57
+
58
+ if (params.encryptedMasterKey) {
59
+ return unwrapMasterKey(Buffer.from(params.encryptedMasterKey, 'base64'), wrappingKey);
60
+ }
61
+ // Legacy accounts where wrapping key IS the master key
62
+ return wrappingKey;
63
+ } catch (err: any) {
64
+ console.error(`[tusky-mcp] Failed to unlock encryption: ${err.message}`);
65
+ return null;
66
+ }
67
+ }
68
+
69
+ // Fallback: try existing session file
70
+ const sessionKey = loadMasterKey();
71
+ if (sessionKey) {
72
+ console.error('[tusky-mcp] Using encryption key from existing session.');
73
+ return sessionKey;
74
+ }
75
+
76
+ return null;
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // Server lifecycle
81
+ // ---------------------------------------------------------------------------
82
+
83
+ export interface McpServerOptions {
84
+ apiKey: string;
85
+ apiUrl: string;
86
+ }
87
+
88
+ export async function startMcpServer(options: McpServerOptions): Promise<void> {
89
+ const { apiKey, apiUrl } = options;
90
+
91
+ // Create SDK client
92
+ const sdk = new TuskyClient({ apiKey, baseUrl: apiUrl });
93
+
94
+ // Validate connection by fetching account info
95
+ try {
96
+ const account = await sdk.account.get();
97
+ console.error(`[tusky-mcp] Authenticated as ${account.email} (${account.planName})`);
98
+ } catch (err: any) {
99
+ if (err instanceof TuskyError) {
100
+ console.error(`[tusky-mcp] Authentication failed: ${err.message}`);
101
+ } else {
102
+ console.error(`[tusky-mcp] Cannot reach Tusky API at ${apiUrl}: ${err.message}`);
103
+ }
104
+ process.exit(1);
105
+ }
106
+
107
+ // Unlock encryption (best effort)
108
+ const masterKey = await unlockMasterKey(sdk);
109
+ if (masterKey) {
110
+ console.error('[tusky-mcp] Encryption unlocked — private vault operations are available.');
111
+ } else {
112
+ console.error('[tusky-mcp] Encryption not unlocked — private vault encrypt/decrypt unavailable.');
113
+ console.error('[tusky-mcp] Set TUSKYDP_PASSWORD env var or run `tusky encryption unlock` first.');
114
+ }
115
+
116
+ // Build context
117
+ const ctx: McpContext = {
118
+ sdk,
119
+ getMasterKey: () => masterKey,
120
+ isEncryptionReady: () => masterKey !== null,
121
+ };
122
+
123
+ // Create MCP server
124
+ const server = new McpServer({
125
+ name: 'tusky',
126
+ version: '0.1.0',
127
+ });
128
+
129
+ // Register all tools
130
+ registerAccountTools(server, ctx);
131
+ registerVaultTools(server, ctx);
132
+ registerFolderTools(server, ctx);
133
+ registerFileTools(server, ctx);
134
+ registerTrashTools(server, ctx);
135
+
136
+ // Connect via stdio transport
137
+ const transport = new StdioServerTransport();
138
+ await server.connect(transport);
139
+ console.error('[tusky-mcp] MCP server running on stdio');
140
+ }