@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,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,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
|
+
}
|