@tuskydp/cli 0.1.0 → 0.1.2
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,192 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import inquirer from 'inquirer';
|
|
4
|
+
import { cliConfig } from '../config.js';
|
|
5
|
+
import { getSDKClient } from '../sdk.js';
|
|
6
|
+
import { createTable, formatBytes, formatDate } from '../lib/output.js';
|
|
7
|
+
import { resolveVault } from '../lib/resolve.js';
|
|
8
|
+
|
|
9
|
+
export function registerFileCommands(program: Command) {
|
|
10
|
+
// ls command (top-level shortcut)
|
|
11
|
+
program.command('ls [vault]')
|
|
12
|
+
.description('List files')
|
|
13
|
+
.option('--sort <field>', 'Sort by: name, size, date', 'date')
|
|
14
|
+
.option('--limit <n>', 'Max results', '50')
|
|
15
|
+
.option('-f, --follow', 'Watch mode: refresh every 3 seconds')
|
|
16
|
+
.action(async (vaultRef: string | undefined, options) => {
|
|
17
|
+
const sdk = getSDKClient(program);
|
|
18
|
+
const format = program.opts().format || cliConfig.get('outputFormat');
|
|
19
|
+
|
|
20
|
+
let vaultId: string | undefined;
|
|
21
|
+
if (vaultRef) {
|
|
22
|
+
vaultId = await resolveVault(sdk, vaultRef);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const sortMap: Record<string, string> = { name: 'name', size: 'sizeBytes', date: 'createdAt' };
|
|
26
|
+
|
|
27
|
+
const fetchAndRender = async (): Promise<void> => {
|
|
28
|
+
const { files } = await sdk.files.list({
|
|
29
|
+
vaultId,
|
|
30
|
+
limit: parseInt(options.limit),
|
|
31
|
+
sortBy: sortMap[options.sort] as 'createdAt' | 'name' | 'sizeBytes',
|
|
32
|
+
order: 'desc',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (format === 'json') {
|
|
36
|
+
console.log(JSON.stringify({ files }, null, 2));
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (files.length === 0) {
|
|
41
|
+
console.log(chalk.dim('No files found.'));
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const table = createTable(['Name', 'Size', 'Status', 'Uploaded', 'ID']);
|
|
46
|
+
for (const f of files) {
|
|
47
|
+
const statusColors: Record<string, typeof chalk.green> = {
|
|
48
|
+
hot: chalk.green,
|
|
49
|
+
synced: chalk.blue,
|
|
50
|
+
cold: chalk.yellow,
|
|
51
|
+
error: chalk.red,
|
|
52
|
+
uploading: chalk.dim,
|
|
53
|
+
};
|
|
54
|
+
const statusColor = statusColors[f.status] || chalk.white;
|
|
55
|
+
|
|
56
|
+
table.push([
|
|
57
|
+
f.name,
|
|
58
|
+
formatBytes(f.plaintextSizeBytes || f.sizeBytes),
|
|
59
|
+
statusColor(f.status),
|
|
60
|
+
formatDate(f.createdAt),
|
|
61
|
+
chalk.dim(f.id),
|
|
62
|
+
]);
|
|
63
|
+
}
|
|
64
|
+
console.log(table.toString());
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
if (options.follow) {
|
|
68
|
+
const clear = () => process.stdout.write('\x1B[2J\x1B[H');
|
|
69
|
+
const render = async () => {
|
|
70
|
+
clear();
|
|
71
|
+
console.log(chalk.dim(`Watching files — refreshing every 3s (Ctrl+C to stop)\n`));
|
|
72
|
+
try {
|
|
73
|
+
await fetchAndRender();
|
|
74
|
+
} catch (err: any) {
|
|
75
|
+
console.error(chalk.red(`Error: ${err.message}`));
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
await render();
|
|
79
|
+
const interval = setInterval(render, 3000);
|
|
80
|
+
process.on('SIGINT', () => {
|
|
81
|
+
clearInterval(interval);
|
|
82
|
+
process.exit(0);
|
|
83
|
+
});
|
|
84
|
+
// Keep process alive
|
|
85
|
+
await new Promise(() => {});
|
|
86
|
+
} else {
|
|
87
|
+
await fetchAndRender();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// info command
|
|
92
|
+
program.command('info <file-id>')
|
|
93
|
+
.description('Show file details')
|
|
94
|
+
.action(async (fileId: string) => {
|
|
95
|
+
const sdk = getSDKClient(program);
|
|
96
|
+
const format = program.opts().format || cliConfig.get('outputFormat');
|
|
97
|
+
const file = await sdk.files.get(fileId);
|
|
98
|
+
|
|
99
|
+
if (format === 'json') {
|
|
100
|
+
console.log(JSON.stringify(file, null, 2));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log(`Name: ${file.name}`);
|
|
105
|
+
console.log(`Size: ${formatBytes(file.plaintextSizeBytes || file.sizeBytes)}`);
|
|
106
|
+
console.log(`MIME: ${file.mimeType}`);
|
|
107
|
+
console.log(`Status: ${file.status}`);
|
|
108
|
+
console.log(`Encrypted: ${file.encrypted ? 'Yes' : 'No'}`);
|
|
109
|
+
console.log(`Vault: ${file.vaultId}`);
|
|
110
|
+
if (file.folderId) console.log(`Folder: ${file.folderId}`);
|
|
111
|
+
if (file.walrusBlobId) console.log(`Walrus Blob: ${file.walrusBlobId}`);
|
|
112
|
+
if (file.lastSyncError) console.log(`Sync Error: ${chalk.red(file.lastSyncError)}`);
|
|
113
|
+
console.log(`Uploaded: ${new Date(file.createdAt).toISOString()}`);
|
|
114
|
+
console.log(`ID: ${file.id}`);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// status command
|
|
118
|
+
program.command('status <file-id>')
|
|
119
|
+
.description('Show sync status')
|
|
120
|
+
.action(async (fileId: string) => {
|
|
121
|
+
const sdk = getSDKClient(program);
|
|
122
|
+
const file = await sdk.files.getStatus(fileId);
|
|
123
|
+
|
|
124
|
+
console.log(`File: ${file.name}`);
|
|
125
|
+
console.log(`Status: ${file.status}`);
|
|
126
|
+
if (file.walrusBlobId) {
|
|
127
|
+
console.log(`Walrus Blob: ${file.walrusBlobId}`);
|
|
128
|
+
} else {
|
|
129
|
+
console.log(`Walrus: ${chalk.dim('Not synced yet')}`);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// retry command
|
|
134
|
+
program.command('retry [file-ids...]')
|
|
135
|
+
.description('Retry failed Walrus sync (all errored files if no IDs given)')
|
|
136
|
+
.action(async (fileIds: string[]) => {
|
|
137
|
+
const sdk = getSDKClient(program);
|
|
138
|
+
const format = program.opts().format || cliConfig.get('outputFormat');
|
|
139
|
+
|
|
140
|
+
if (fileIds.length === 0) {
|
|
141
|
+
const result = await sdk.files.retryAll();
|
|
142
|
+
if (format === 'json') {
|
|
143
|
+
console.log(JSON.stringify(result, null, 2));
|
|
144
|
+
} else if (result.retriedCount === 0) {
|
|
145
|
+
console.log(chalk.dim('No files in error status.'));
|
|
146
|
+
} else {
|
|
147
|
+
console.log(chalk.green(`Re-queued ${result.retriedCount} file(s) for Walrus sync.`));
|
|
148
|
+
}
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const fileId of fileIds) {
|
|
153
|
+
try {
|
|
154
|
+
const result = await sdk.files.retry(fileId);
|
|
155
|
+
if (format === 'json') {
|
|
156
|
+
console.log(JSON.stringify(result, null, 2));
|
|
157
|
+
} else {
|
|
158
|
+
console.log(chalk.green(`Retried: ${fileId}`));
|
|
159
|
+
}
|
|
160
|
+
} catch (err: any) {
|
|
161
|
+
console.error(chalk.red(`Failed to retry ${fileId}: ${err.message}`));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// rm command
|
|
167
|
+
program.command('rm <file-ids...>')
|
|
168
|
+
.description('Delete one or more files')
|
|
169
|
+
.option('--force', 'Skip confirmation prompt')
|
|
170
|
+
.action(async (fileIds: string[], options) => {
|
|
171
|
+
const sdk = getSDKClient(program);
|
|
172
|
+
|
|
173
|
+
if (!options.force) {
|
|
174
|
+
const answers = await inquirer.prompt([{
|
|
175
|
+
type: 'confirm',
|
|
176
|
+
name: 'confirm',
|
|
177
|
+
message: `Delete ${fileIds.length} file(s)? This cannot be undone.`,
|
|
178
|
+
default: false,
|
|
179
|
+
}]);
|
|
180
|
+
if (!answers.confirm) return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
for (const fileId of fileIds) {
|
|
184
|
+
try {
|
|
185
|
+
await sdk.files.delete(fileId);
|
|
186
|
+
console.log(chalk.green(`Deleted: ${fileId}`));
|
|
187
|
+
} catch (err: any) {
|
|
188
|
+
console.error(chalk.red(`Failed to delete ${fileId}: ${err.message}`));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commander registration for `tusky mcp` subcommands:
|
|
3
|
+
* - tusky mcp serve — Start the MCP server (stdio transport)
|
|
4
|
+
* - tusky mcp install-config — Write MCP config for Claude Code / Cursor
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Command } from 'commander';
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
9
|
+
import { join, dirname, resolve } from 'path';
|
|
10
|
+
import { homedir } from 'os';
|
|
11
|
+
import chalk from 'chalk';
|
|
12
|
+
import { getApiUrl, getApiKey, cliConfig } from '../config.js';
|
|
13
|
+
import { startMcpServer } from '../mcp/server.js';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Config file locations for each supported client
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
interface McpClientTarget {
|
|
20
|
+
name: string;
|
|
21
|
+
configPath: string;
|
|
22
|
+
description: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getTargets(): McpClientTarget[] {
|
|
26
|
+
const home = homedir();
|
|
27
|
+
|
|
28
|
+
return [
|
|
29
|
+
{
|
|
30
|
+
name: 'claude-code',
|
|
31
|
+
configPath: join(process.cwd(), '.mcp.json'),
|
|
32
|
+
description: 'Claude Code (project-level .mcp.json in current directory)',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'claude-desktop',
|
|
36
|
+
configPath:
|
|
37
|
+
process.platform === 'win32'
|
|
38
|
+
? join(home, 'AppData', 'Roaming', 'Claude', 'claude_desktop_config.json')
|
|
39
|
+
: join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json'),
|
|
40
|
+
description: 'Claude Desktop',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'cursor',
|
|
44
|
+
configPath: join(home, '.cursor', 'mcp.json'),
|
|
45
|
+
description: 'Cursor editor',
|
|
46
|
+
},
|
|
47
|
+
];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Helpers
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
function getTuskyBinaryCommand(): { command: string; args: string[] } {
|
|
55
|
+
// Strategy: use absolute paths to avoid PATH resolution failures.
|
|
56
|
+
// Agent hosts (Claude Code, Cursor, Desktop) spawn processes with a
|
|
57
|
+
// minimal PATH that won't include nvm shims, pnpm globals, etc.
|
|
58
|
+
//
|
|
59
|
+
// Priority:
|
|
60
|
+
// 1. If `tusky` exists in a well-known PATH dir → use it directly
|
|
61
|
+
// (standard npm -g install lands in /usr/local/bin or /opt/homebrew/bin)
|
|
62
|
+
// 2. Resolve absolute path to node + our entry script
|
|
63
|
+
// (works for pnpm link, local dev, non-standard installs)
|
|
64
|
+
// 3. Fallback to npx
|
|
65
|
+
|
|
66
|
+
// Check if `tusky` is available in the standard agent-visible PATH dirs
|
|
67
|
+
const agentPathDirs = ['/usr/local/bin', '/opt/homebrew/bin', '/usr/bin'];
|
|
68
|
+
for (const dir of agentPathDirs) {
|
|
69
|
+
const tuskyBin = join(dir, 'tusky');
|
|
70
|
+
if (existsSync(tuskyBin)) {
|
|
71
|
+
return { command: tuskyBin, args: ['mcp', 'serve'] };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Not in standard PATH — resolve absolute node + entry script paths
|
|
76
|
+
// (handles pnpm link --global, local dev, monorepo installs)
|
|
77
|
+
const nodeExe = process.execPath;
|
|
78
|
+
const thisDir = dirname(new URL(import.meta.url).pathname);
|
|
79
|
+
|
|
80
|
+
// Try: <thisDir>/../../bin/tuskydp.js (running from dist/commands/)
|
|
81
|
+
const entryPoint = resolve(join(thisDir, '..', '..', 'bin', 'tuskydp.js'));
|
|
82
|
+
if (existsSync(entryPoint)) {
|
|
83
|
+
return { command: nodeExe, args: [entryPoint, 'mcp', 'serve'] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Try: <thisDir>/../../../dist/bin/tuskydp.js (running from src/commands/ in dev)
|
|
87
|
+
const distEntry = resolve(join(thisDir, '..', '..', '..', 'dist', 'bin', 'tuskydp.js'));
|
|
88
|
+
if (existsSync(distEntry)) {
|
|
89
|
+
return { command: nodeExe, args: [distEntry, 'mcp', 'serve'] };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Last resort: npx (works if npm/npx is in the agent's PATH)
|
|
93
|
+
return { command: 'npx', args: ['-y', '@tuskydp/cli', 'mcp', 'serve'] };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildMcpServerEntry(apiKey?: string, apiUrl?: string) {
|
|
97
|
+
const { command, args } = getTuskyBinaryCommand();
|
|
98
|
+
|
|
99
|
+
const env: Record<string, string> = {};
|
|
100
|
+
if (apiKey) env.TUSKYDP_API_KEY = apiKey;
|
|
101
|
+
if (apiUrl) env.TUSKYDP_API_URL = apiUrl;
|
|
102
|
+
|
|
103
|
+
// Placeholder for password — user fills in
|
|
104
|
+
env.TUSKYDP_PASSWORD = '';
|
|
105
|
+
|
|
106
|
+
const entry: Record<string, unknown> = {
|
|
107
|
+
command,
|
|
108
|
+
args,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
if (Object.keys(env).length > 0) {
|
|
112
|
+
entry.env = env;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return entry;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readJsonFile(filePath: string): Record<string, unknown> {
|
|
119
|
+
if (!existsSync(filePath)) return {};
|
|
120
|
+
try {
|
|
121
|
+
return JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
122
|
+
} catch {
|
|
123
|
+
return {};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function writeJsonFile(filePath: string, data: Record<string, unknown>): void {
|
|
128
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
129
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Command registration
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
export function registerMcpCommands(program: Command) {
|
|
137
|
+
const mcp = program
|
|
138
|
+
.command('mcp')
|
|
139
|
+
.description('MCP server for AI agents (Claude Code, Cursor, etc.)');
|
|
140
|
+
|
|
141
|
+
// ── tusky mcp serve ──────────────────────────────────────────────────
|
|
142
|
+
mcp
|
|
143
|
+
.command('serve')
|
|
144
|
+
.description('Start the MCP server (stdio transport for AI agent integration)')
|
|
145
|
+
.action(async () => {
|
|
146
|
+
// Resolve API credentials from the same priority chain as all CLI commands
|
|
147
|
+
const root = mcp.parent || mcp;
|
|
148
|
+
const apiUrl = getApiUrl(root.opts().apiUrl);
|
|
149
|
+
const apiKey = getApiKey(root.opts().apiKey);
|
|
150
|
+
|
|
151
|
+
await startMcpServer({ apiKey, apiUrl });
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ── tusky mcp install-config ─────────────────────────────────────────
|
|
155
|
+
mcp
|
|
156
|
+
.command('install-config')
|
|
157
|
+
.description('Add Tusky MCP server config to Claude Code, Claude Desktop, or Cursor')
|
|
158
|
+
.option('--target <target>', 'Target client: claude-code, claude-desktop, cursor', 'claude-code')
|
|
159
|
+
.option('--api-key <key>', 'API key to embed (defaults to stored key)')
|
|
160
|
+
.option('--api-url <url>', 'API URL override')
|
|
161
|
+
.action(async (options: { target: string; apiKey?: string; apiUrl?: string }) => {
|
|
162
|
+
const targets = getTargets();
|
|
163
|
+
const target = targets.find((t) => t.name === options.target);
|
|
164
|
+
|
|
165
|
+
if (!target) {
|
|
166
|
+
console.error(
|
|
167
|
+
chalk.red(`Unknown target "${options.target}". Valid targets: ${targets.map((t) => t.name).join(', ')}`),
|
|
168
|
+
);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Resolve API key (flag > stored > error)
|
|
173
|
+
const root = mcp.parent || mcp;
|
|
174
|
+
const apiKey = options.apiKey ?? root.opts().apiKey ?? process.env.TUSKYDP_API_KEY ?? cliConfig.get('apiKey');
|
|
175
|
+
const apiUrl = options.apiUrl ?? root.opts().apiUrl ?? process.env.TUSKYDP_API_URL;
|
|
176
|
+
|
|
177
|
+
// Only include non-default values
|
|
178
|
+
const resolvedApiUrl = apiUrl && apiUrl !== 'https://api.tusky.ai' ? apiUrl : undefined;
|
|
179
|
+
|
|
180
|
+
// Build the server entry
|
|
181
|
+
const serverEntry = buildMcpServerEntry(apiKey, resolvedApiUrl);
|
|
182
|
+
|
|
183
|
+
// Read existing config
|
|
184
|
+
const config = readJsonFile(target.configPath);
|
|
185
|
+
|
|
186
|
+
// Merge into mcpServers
|
|
187
|
+
if (!config.mcpServers || typeof config.mcpServers !== 'object') {
|
|
188
|
+
config.mcpServers = {};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const mcpServers = config.mcpServers as Record<string, unknown>;
|
|
192
|
+
const existed = 'tusky' in mcpServers;
|
|
193
|
+
mcpServers.tusky = serverEntry;
|
|
194
|
+
|
|
195
|
+
// Write back
|
|
196
|
+
writeJsonFile(target.configPath, config);
|
|
197
|
+
|
|
198
|
+
console.log(chalk.green(`${existed ? 'Updated' : 'Added'} Tusky MCP server config for ${target.description}`));
|
|
199
|
+
console.log(chalk.dim(` Config file: ${target.configPath}`));
|
|
200
|
+
console.log('');
|
|
201
|
+
|
|
202
|
+
if (!apiKey) {
|
|
203
|
+
console.log(chalk.yellow(' Note: No API key found. Add your API key to the config:'));
|
|
204
|
+
console.log(chalk.dim(` Set TUSKYDP_API_KEY in the env block of ${target.configPath}`));
|
|
205
|
+
console.log('');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
console.log(chalk.yellow(' Remember to set TUSKYDP_PASSWORD in the config for private vault encryption:'));
|
|
209
|
+
console.log(chalk.dim(` Edit the env block in ${target.configPath}`));
|
|
210
|
+
console.log('');
|
|
211
|
+
|
|
212
|
+
if (options.target === 'claude-desktop') {
|
|
213
|
+
console.log(chalk.dim(' Restart Claude Desktop to pick up the changes.'));
|
|
214
|
+
} else if (options.target === 'claude-code') {
|
|
215
|
+
console.log(chalk.dim(' Claude Code will automatically detect the .mcp.json file.'));
|
|
216
|
+
} else if (options.target === 'cursor') {
|
|
217
|
+
console.log(chalk.dim(' Restart Cursor to pick up the changes.'));
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { writeFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { resolveWalrusConfig } from '@tuskydp/shared/walrus-networks.js';
|
|
5
|
+
import { formatBytes } from '../lib/output.js';
|
|
6
|
+
import { createSpinner } from '../lib/progress.js';
|
|
7
|
+
|
|
8
|
+
export async function rehydrateCommand(blobId: string, options: {
|
|
9
|
+
output?: string;
|
|
10
|
+
}, _program: Command) {
|
|
11
|
+
const spinner = createSpinner('Fetching blob from Walrus...');
|
|
12
|
+
spinner.start();
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const tuskyEnv = process.env.TUSKY_ENV || 'production';
|
|
16
|
+
const walrus = resolveWalrusConfig(tuskyEnv);
|
|
17
|
+
|
|
18
|
+
const url = `${walrus.aggregatorUrl}/v1/blobs/${blobId}`;
|
|
19
|
+
const response = await fetch(url);
|
|
20
|
+
if (!response.ok) {
|
|
21
|
+
throw new Error(`Walrus fetch failed: ${response.status} ${response.statusText}`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const arrayBuf = await response.arrayBuffer();
|
|
25
|
+
const fileBuffer = Buffer.from(new Uint8Array(arrayBuf));
|
|
26
|
+
|
|
27
|
+
// Sanitize blob ID for use as filename (replace non-filesystem-safe characters)
|
|
28
|
+
const safeBlobId = blobId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
29
|
+
const outputPath = options.output || join(process.cwd(), `blob-${safeBlobId}`);
|
|
30
|
+
writeFileSync(outputPath, fileBuffer);
|
|
31
|
+
|
|
32
|
+
spinner.succeed(`Downloaded -> ${outputPath} (${formatBytes(fileBuffer.length)})`);
|
|
33
|
+
} catch (err: any) {
|
|
34
|
+
spinner.fail(`Rehydrate failed: ${err.message}`);
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { launchTui } from '../tui/index.js';
|
|
3
|
+
|
|
4
|
+
export function registerTuiCommand(program: Command) {
|
|
5
|
+
program
|
|
6
|
+
.command('tui')
|
|
7
|
+
.description('Interactive terminal UI')
|
|
8
|
+
.action(async () => {
|
|
9
|
+
await launchTui();
|
|
10
|
+
});
|
|
11
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { Command } from 'commander';
|
|
2
|
+
import { readFileSync, statSync, readdirSync } from 'fs';
|
|
3
|
+
import { basename, join, resolve } from 'path';
|
|
4
|
+
import { lookup } from 'mime-types';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { getApiUrl, getApiKey } from '../config.js';
|
|
7
|
+
import { createSDKClient } from '../sdk.js';
|
|
8
|
+
import { resolveVault } from '../lib/resolve.js';
|
|
9
|
+
import { formatBytes } from '../lib/output.js';
|
|
10
|
+
import { createSpinner } from '../lib/progress.js';
|
|
11
|
+
import { encryptBuffer } from '../crypto.js';
|
|
12
|
+
import { loadMasterKey } from '../lib/keyring.js';
|
|
13
|
+
|
|
14
|
+
async function expandPaths(paths: string[], recursive?: boolean): Promise<string[]> {
|
|
15
|
+
const result: string[] = [];
|
|
16
|
+
for (const p of paths) {
|
|
17
|
+
const resolved = resolve(p);
|
|
18
|
+
const stat = statSync(resolved, { throwIfNoEntry: false });
|
|
19
|
+
if (!stat) {
|
|
20
|
+
console.warn(chalk.yellow(`Warning: ${p} not found, skipping`));
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
if (stat.isFile()) {
|
|
24
|
+
result.push(resolved);
|
|
25
|
+
} else if (stat.isDirectory() && recursive) {
|
|
26
|
+
const entries = readdirSync(resolved, { withFileTypes: true });
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
if (entry.isFile()) {
|
|
29
|
+
result.push(join(resolved, entry.name));
|
|
30
|
+
} else if (entry.isDirectory()) {
|
|
31
|
+
const subFiles = await expandPaths([join(resolved, entry.name)], true);
|
|
32
|
+
result.push(...subFiles);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} else if (stat.isDirectory()) {
|
|
36
|
+
console.warn(chalk.yellow(`Warning: ${p} is a directory. Use --recursive to upload contents.`));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function uploadCommand(paths: string[], options: {
|
|
43
|
+
vault?: string;
|
|
44
|
+
recursive?: boolean;
|
|
45
|
+
}, program: Command) {
|
|
46
|
+
const apiUrl = getApiUrl(program.opts().apiUrl);
|
|
47
|
+
const apiKey = getApiKey(program.opts().apiKey);
|
|
48
|
+
const sdk = createSDKClient(apiUrl, apiKey);
|
|
49
|
+
|
|
50
|
+
const vaultId = await resolveVault(sdk, options.vault);
|
|
51
|
+
const vault = await sdk.vaults.get(vaultId);
|
|
52
|
+
const isPrivate = vault.visibility === 'private';
|
|
53
|
+
|
|
54
|
+
let masterKey: Buffer | null = null;
|
|
55
|
+
if (isPrivate) {
|
|
56
|
+
masterKey = loadMasterKey();
|
|
57
|
+
if (!masterKey) {
|
|
58
|
+
console.error(chalk.red('Encryption session not unlocked. Run: tusky encryption unlock'));
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const filePaths = await expandPaths(paths, options.recursive);
|
|
64
|
+
if (filePaths.length === 0) {
|
|
65
|
+
console.log(chalk.yellow('No files to upload.'));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let totalSize = 0;
|
|
70
|
+
let successCount = 0;
|
|
71
|
+
|
|
72
|
+
for (const filePath of filePaths) {
|
|
73
|
+
const spinner = createSpinner(`Uploading ${basename(filePath)}`);
|
|
74
|
+
spinner.start();
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const stat = statSync(filePath);
|
|
78
|
+
const fileBuffer = readFileSync(filePath);
|
|
79
|
+
const mimeType = lookup(filePath) || 'application/octet-stream';
|
|
80
|
+
|
|
81
|
+
let uploadBody: Buffer;
|
|
82
|
+
let encryptionMeta: {
|
|
83
|
+
wrappedKey?: string;
|
|
84
|
+
encryptionIv?: string;
|
|
85
|
+
plaintextSizeBytes?: number;
|
|
86
|
+
plaintextChecksumSha256?: string;
|
|
87
|
+
} = {};
|
|
88
|
+
|
|
89
|
+
if (isPrivate && masterKey) {
|
|
90
|
+
spinner.text = `Encrypting ${basename(filePath)}...`;
|
|
91
|
+
const { ciphertext, wrappedKey, iv, plaintextChecksum } = encryptBuffer(fileBuffer, masterKey);
|
|
92
|
+
uploadBody = ciphertext;
|
|
93
|
+
encryptionMeta = {
|
|
94
|
+
wrappedKey,
|
|
95
|
+
encryptionIv: iv,
|
|
96
|
+
plaintextSizeBytes: stat.size,
|
|
97
|
+
plaintextChecksumSha256: plaintextChecksum,
|
|
98
|
+
};
|
|
99
|
+
} else {
|
|
100
|
+
uploadBody = fileBuffer;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Request presigned URL
|
|
104
|
+
spinner.text = `Requesting upload URL for ${basename(filePath)}...`;
|
|
105
|
+
const { fileId, uploadUrl } = await sdk.files.requestUpload({
|
|
106
|
+
name: basename(filePath),
|
|
107
|
+
mimeType,
|
|
108
|
+
sizeBytes: uploadBody.length,
|
|
109
|
+
vaultId,
|
|
110
|
+
...encryptionMeta,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// Upload to Spaces via presigned URL
|
|
114
|
+
spinner.text = `Uploading ${basename(filePath)} (${formatBytes(uploadBody.length)})...`;
|
|
115
|
+
const uploadResponse = await fetch(uploadUrl, {
|
|
116
|
+
method: 'PUT',
|
|
117
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
118
|
+
body: new Uint8Array(uploadBody),
|
|
119
|
+
});
|
|
120
|
+
if (!uploadResponse.ok) throw new Error(`Upload failed: ${uploadResponse.status}`);
|
|
121
|
+
|
|
122
|
+
// Confirm
|
|
123
|
+
spinner.text = `Confirming ${basename(filePath)}...`;
|
|
124
|
+
await sdk.files.confirmUpload(fileId);
|
|
125
|
+
|
|
126
|
+
spinner.succeed(`${basename(filePath)} -> ${fileId} (${formatBytes(uploadBody.length)})`);
|
|
127
|
+
totalSize += uploadBody.length;
|
|
128
|
+
successCount++;
|
|
129
|
+
} catch (err: any) {
|
|
130
|
+
spinner.fail(`Failed: ${basename(filePath)} — ${err.message}`);
|
|
131
|
+
// Surface PPU payment details if present (402 errors)
|
|
132
|
+
if (err.statusCode === 402) {
|
|
133
|
+
console.error(chalk.yellow(' This may require topping up your wallet balance.'));
|
|
134
|
+
console.error(chalk.dim(' Check your wallet: tusky account usage'));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (filePaths.length > 1) {
|
|
140
|
+
const icon = isPrivate ? 'private' : 'public';
|
|
141
|
+
console.log(`\nUploaded ${successCount} file(s) (${formatBytes(totalSize)}) to vault "${vault.name}" [${icon}]`);
|
|
142
|
+
}
|
|
143
|
+
}
|