kinetic-mcp 1.3.1 → 1.3.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.
Files changed (4) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +138 -138
  3. package/package.json +53 -53
  4. package/scripts/setup.mjs +779 -760
package/scripts/setup.mjs CHANGED
@@ -1,760 +1,779 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * Interactive setup wizard for Kinetic MCP Server.
5
- *
6
- * Run: npx kinetic-mcp setup
7
- *
8
- * This is a standalone .mjs file — no compilation required.
9
- * Uses @clack/prompts for beautiful terminal UI.
10
- */
11
-
12
- import {
13
- readFileSync,
14
- writeFileSync,
15
- existsSync,
16
- mkdirSync,
17
- chmodSync,
18
- copyFileSync,
19
- } from "node:fs";
20
- import { homedir } from "node:os";
21
- import { join, resolve, dirname } from "node:path";
22
- import { execSync } from "node:child_process";
23
- import { platform, execPath } from "node:process";
24
- import { createRequire } from "node:module";
25
-
26
- import { Keypair } from "@solana/web3.js";
27
- import bs58 from "bs58";
28
- import * as p from "@clack/prompts";
29
- import pc from "picocolors";
30
-
31
- // ---------------------------------------------------------------------------
32
- // Version
33
- // ---------------------------------------------------------------------------
34
-
35
- const require = createRequire(import.meta.url);
36
- const { version } = require("../package.json");
37
-
38
- // ---------------------------------------------------------------------------
39
- // Non-TTY check
40
- // ---------------------------------------------------------------------------
41
-
42
- if (!process.stdin.isTTY) {
43
- console.error("Error: Setup wizard requires an interactive terminal.");
44
- console.error("Run: npx kinetic-mcp setup");
45
- process.exit(1);
46
- }
47
-
48
- // ---------------------------------------------------------------------------
49
- // ASCII Banner
50
- // ---------------------------------------------------------------------------
51
-
52
- const BANNER = `
53
- ██╗ ██╗██╗███╗ ██╗███████╗████████╗██╗ ██████╗
54
- ██║ ██╔╝██║████╗ ██║██╔════╝╚══██╔══╝██║██╔════╝
55
- █████╔╝ ██║██╔██╗ ██║█████╗ ██║ ██║██║
56
- ██╔═██╗ ██║██║╚██╗██║██╔══╝ ██║ ██║██║
57
- ██║ ██╗██║██║ ╚████║███████╗ ██║ ██║╚██████╗
58
- ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝`;
59
-
60
- async function renderBanner() {
61
- let rendered;
62
- try {
63
- const gradient = (await import("gradient-string")).default;
64
- const kineticGradient = gradient(["#FEDA75", "#FA7E1E", "#D62976", "#962FBF", "#4F5BD5"]);
65
- rendered = kineticGradient.multiline(BANNER);
66
- } catch {
67
- rendered = pc.bold(pc.magenta(BANNER));
68
- }
69
-
70
- console.log(rendered);
71
- console.log(pc.dim(` v${version} Solana Trading via Claude Desktop`));
72
- console.log();
73
- }
74
-
75
- // ---------------------------------------------------------------------------
76
- // Helpers
77
- // ---------------------------------------------------------------------------
78
-
79
- function handleCancel(value) {
80
- if (p.isCancel(value)) {
81
- p.cancel("Setup cancelled.");
82
- process.exit(0);
83
- }
84
- return value;
85
- }
86
-
87
- function expandHome(filePath) {
88
- if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
89
- return resolve(homedir(), filePath.slice(2));
90
- }
91
- return filePath;
92
- }
93
-
94
- function isWSL() {
95
- try {
96
- const ver = readFileSync("/proc/version", "utf-8");
97
- return /microsoft|wsl/i.test(ver);
98
- } catch {
99
- return false;
100
- }
101
- }
102
-
103
- function getClaudeConfigPath() {
104
- const home = homedir();
105
- switch (platform) {
106
- case "darwin":
107
- return join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
108
- case "win32":
109
- return join(
110
- process.env.APPDATA || join(home, "AppData", "Roaming"),
111
- "Claude",
112
- "claude_desktop_config.json",
113
- );
114
- case "linux":
115
- if (isWSL()) return null;
116
- return join(home, ".config", "Claude", "claude_desktop_config.json");
117
- default:
118
- return null;
119
- }
120
- }
121
-
122
- function getDefaultKeypairPath() {
123
- return join(homedir(), ".config", "solana", "trading-keypair.json");
124
- }
125
-
126
- function validateKeypairFile(filePath) {
127
- const expanded = expandHome(filePath);
128
- if (!existsSync(expanded)) {
129
- throw new Error(`File not found: ${expanded}`);
130
- }
131
- const raw = readFileSync(expanded, "utf-8");
132
- let parsed;
133
- try {
134
- parsed = JSON.parse(raw);
135
- } catch {
136
- throw new Error(
137
- "File is not valid JSON. Expected a JSON array of 64 integers (Solana CLI format).",
138
- );
139
- }
140
- if (
141
- !Array.isArray(parsed) ||
142
- parsed.length !== 64 ||
143
- !parsed.every((n) => typeof n === "number" && Number.isInteger(n))
144
- ) {
145
- throw new Error("Invalid Solana keypair. Expected a JSON array of exactly 64 integers.");
146
- }
147
- return Keypair.fromSecretKey(Uint8Array.from(parsed));
148
- }
149
-
150
- // ---------------------------------------------------------------------------
151
- // Step 2: Build Check
152
- // ---------------------------------------------------------------------------
153
-
154
- async function checkBuild() {
155
- const buildExists = existsSync(resolve("build", "index.js"));
156
- if (buildExists) return;
157
-
158
- p.log.warn("Project hasn't been built yet — build/index.js not found.");
159
-
160
- const runBuild = handleCancel(
161
- await p.confirm({ message: "Run npm run build now?", initialValue: true }),
162
- );
163
-
164
- if (runBuild) {
165
- const s = p.spinner();
166
- s.start("Building project...");
167
- try {
168
- execSync("npm run build", { stdio: "pipe" });
169
- s.stop("Build complete");
170
- } catch {
171
- s.stop("Build failed");
172
- p.log.warn("You can try again later with: npm run build");
173
- }
174
- } else {
175
- p.log.info("Skipped. Remember to run npm run build before using the server.");
176
- }
177
- }
178
-
179
- // ---------------------------------------------------------------------------
180
- // Step 3: Keypair Setup
181
- // ---------------------------------------------------------------------------
182
-
183
- async function importFromPhantom() {
184
- p.note(
185
- [
186
- `${pc.bold("How to export from Phantom:")}`,
187
- "",
188
- "1. Open Phantom → Settings → Manage Accounts → [Your Account] → Show Private Key",
189
- "2. Enter your password and copy the base58 string",
190
- "",
191
- `${pc.dim("Your key will be masked. It is NOT saved to shell history.")}`,
192
- ].join("\n"),
193
- "Import Private Key",
194
- );
195
-
196
- const key = handleCancel(
197
- await p.password({
198
- message: "Paste your private key:",
199
- mask: "*",
200
- validate(value) {
201
- if (!value || value.trim().length === 0) return "Private key is required.";
202
-
203
- let decoded;
204
- try {
205
- decoded = bs58.decode(value.trim());
206
- } catch {
207
- return "Invalid format. Expected a base58-encoded string from Phantom.";
208
- }
209
-
210
- if (decoded.length !== 64) {
211
- if (decoded.length === 32) {
212
- return "This looks like a 32-byte seed. Use the Solana CLI to generate the full keypair.";
213
- }
214
- return `Expected 64-byte secret key, got ${decoded.length} bytes.`;
215
- }
216
- },
217
- }),
218
- );
219
-
220
- const decoded = bs58.decode(key.trim());
221
- const keypair = Keypair.fromSecretKey(decoded);
222
-
223
- const publicKey = keypair.publicKey.toBase58();
224
- p.log.info(`Wallet address: ${pc.cyan(publicKey)}`);
225
- p.log.message(pc.dim("Verify this matches your Phantom wallet address."));
226
-
227
- // Save keypair to default path
228
- const defaultPath = getDefaultKeypairPath();
229
-
230
- // Check if file exists
231
- if (existsSync(defaultPath)) {
232
- const overwrite = handleCancel(
233
- await p.confirm({
234
- message: `Keypair file already exists at ${pc.dim(defaultPath)}. Overwrite?`,
235
- initialValue: false,
236
- }),
237
- );
238
-
239
- if (!overwrite) {
240
- p.log.info("Keeping existing keypair file.");
241
- return { publicKey, path: defaultPath };
242
- }
243
- }
244
-
245
- mkdirSync(dirname(defaultPath), { recursive: true, mode: 0o700 });
246
- writeFileSync(defaultPath, JSON.stringify(Array.from(keypair.secretKey)) + "\n", { mode: 0o600 });
247
-
248
- if (platform !== "win32") {
249
- try {
250
- chmodSync(defaultPath, 0o600);
251
- } catch (err) {
252
- p.log.warn(`Could not set file permissions: ${err.message}. Run: chmod 600 ${defaultPath}`);
253
- }
254
- }
255
-
256
- p.log.success(`Keypair saved to ${pc.dim(defaultPath)}`);
257
- return { publicKey, path: defaultPath };
258
- }
259
-
260
- async function useExistingKeypair() {
261
- const filePath = handleCancel(
262
- await p.text({
263
- message: "Path to keypair file:",
264
- placeholder: "~/.config/solana/id.json",
265
- validate(value) {
266
- if (!value || value.trim().length === 0) return "Path is required.";
267
- try {
268
- const expanded = expandHome(value.trim());
269
- validateKeypairFile(expanded);
270
- } catch (err) {
271
- return err.message;
272
- }
273
- },
274
- }),
275
- );
276
-
277
- const expanded = expandHome(filePath.trim());
278
- const keypair = validateKeypairFile(expanded);
279
- const publicKey = keypair.publicKey.toBase58();
280
-
281
- p.log.info(`Wallet address: ${pc.cyan(publicKey)}`);
282
-
283
- return { publicKey, path: expanded };
284
- }
285
-
286
- async function setupKeypair() {
287
- const method = handleCancel(
288
- await p.select({
289
- message: "How would you like to set up your Solana keypair?",
290
- options: [
291
- {
292
- value: "phantom",
293
- label: "Import from Phantom",
294
- hint: "paste your base58 private key",
295
- },
296
- {
297
- value: "existing",
298
- label: "Use existing keypair file",
299
- hint: "Solana CLI JSON format",
300
- },
301
- ],
302
- }),
303
- );
304
-
305
- if (method === "phantom") {
306
- return importFromPhantom();
307
- } else {
308
- return useExistingKeypair();
309
- }
310
- }
311
-
312
- // ---------------------------------------------------------------------------
313
- // Step 4: API Key
314
- // ---------------------------------------------------------------------------
315
-
316
- async function setupApiKey() {
317
- const apiKey = handleCancel(
318
- await p.password({
319
- message: "Kinetic API key (optional) (press Enter to skip):",
320
- mask: "*",
321
- }),
322
- );
323
-
324
- return apiKey?.trim() || null;
325
- }
326
-
327
- // ---------------------------------------------------------------------------
328
- // Step 5: Advanced Settings
329
- // ---------------------------------------------------------------------------
330
-
331
- const DEFAULTS = {
332
- rpcUrl: "https://api.mainnet-beta.solana.com",
333
- maxTradeSol: "10",
334
- priorityFeeLamports: "400000",
335
- jitoEnabled: false,
336
- dynamicSlippageMaxBps: "300",
337
- };
338
-
339
- async function advancedSettings() {
340
- const configure = handleCancel(
341
- await p.confirm({
342
- message: "Configure advanced settings?",
343
- initialValue: false,
344
- }),
345
- );
346
-
347
- if (!configure) return {};
348
-
349
- p.log.info(pc.dim("Press Enter to keep defaults shown."));
350
-
351
- const rpcUrl = handleCancel(
352
- await p.text({
353
- message: "Solana RPC URL:",
354
- placeholder: DEFAULTS.rpcUrl,
355
- defaultValue: DEFAULTS.rpcUrl,
356
- validate(value) {
357
- if (!value) return;
358
- try {
359
- const url = new URL(value);
360
- if (url.protocol !== "https:") return "URL must use https://";
361
- } catch {
362
- return "Invalid URL format.";
363
- }
364
- },
365
- }),
366
- );
367
-
368
- const maxTradeSol = handleCancel(
369
- await p.text({
370
- message: "Max trade size (SOL):",
371
- placeholder: DEFAULTS.maxTradeSol,
372
- defaultValue: DEFAULTS.maxTradeSol,
373
- validate(value) {
374
- if (!value) return;
375
- const num = Number(value);
376
- if (isNaN(num) || num <= 0) return "Must be a positive number.";
377
- },
378
- }),
379
- );
380
-
381
- const priorityFeeLamports = handleCancel(
382
- await p.text({
383
- message: `Priority fee (lamports): ${pc.dim("400000 = ~0.0004 SOL")}`,
384
- placeholder: DEFAULTS.priorityFeeLamports,
385
- defaultValue: DEFAULTS.priorityFeeLamports,
386
- validate(value) {
387
- if (!value) return;
388
- const num = Number(value);
389
- if (isNaN(num) || !Number.isInteger(num) || num < 0)
390
- return "Must be a non-negative integer.";
391
- },
392
- }),
393
- );
394
-
395
- const jitoEnabled = handleCancel(
396
- await p.confirm({
397
- message: "Enable Jito MEV protection?",
398
- initialValue: DEFAULTS.jitoEnabled,
399
- }),
400
- );
401
-
402
- const dynamicSlippageMaxBps = handleCancel(
403
- await p.text({
404
- message: "Max dynamic slippage (BPS):",
405
- placeholder: DEFAULTS.dynamicSlippageMaxBps,
406
- defaultValue: DEFAULTS.dynamicSlippageMaxBps,
407
- validate(value) {
408
- if (!value) return;
409
- const num = Number(value);
410
- if (isNaN(num) || !Number.isInteger(num) || num <= 0) return "Must be a positive integer.";
411
- if (num > 1000) return "Cannot exceed 1000 BPS (10%).";
412
- },
413
- }),
414
- );
415
-
416
- // Return only non-default values
417
- const settings = {};
418
- if (rpcUrl !== DEFAULTS.rpcUrl) settings.SOLANA_RPC_URL = rpcUrl;
419
- if (maxTradeSol !== DEFAULTS.maxTradeSol) settings.MAX_TRADE_SOL = maxTradeSol;
420
- if (priorityFeeLamports !== DEFAULTS.priorityFeeLamports)
421
- settings.PRIORITY_FEE_LAMPORTS = priorityFeeLamports;
422
- if (jitoEnabled !== DEFAULTS.jitoEnabled) settings.JITO_ENABLED = jitoEnabled ? "true" : "false";
423
- if (dynamicSlippageMaxBps !== DEFAULTS.dynamicSlippageMaxBps)
424
- settings.DYNAMIC_SLIPPAGE_MAX_BPS = dynamicSlippageMaxBps;
425
-
426
- return settings;
427
- }
428
-
429
- // ---------------------------------------------------------------------------
430
- // Step 6: Claude Desktop Config
431
- // ---------------------------------------------------------------------------
432
-
433
- function generateConfigEntry(keypairPath, apiKey, advancedEnv) {
434
- const projectRoot = resolve(".");
435
- const entry = {
436
- command: execPath,
437
- args: [join(projectRoot, "build", "index.js").replace(/\\/g, "/")],
438
- env: {
439
- SOLANA_KEYPAIR_PATH: keypairPath.replace(/\\/g, "/"),
440
- },
441
- };
442
- if (apiKey) {
443
- entry.env.API_KEY = apiKey;
444
- }
445
- // Add non-default advanced settings
446
- if (advancedEnv) {
447
- Object.assign(entry.env, advancedEnv);
448
- }
449
- return entry;
450
- }
451
-
452
- function formatConfigSnippet(entry) {
453
- const snippet = {
454
- mcpServers: {
455
- "solana-trading": entry,
456
- },
457
- };
458
- return JSON.stringify(snippet, null, 2);
459
- }
460
-
461
- async function configureClaudeDesktop(keypairPath, apiKey, advancedEnv) {
462
- const entry = generateConfigEntry(keypairPath, apiKey, advancedEnv);
463
-
464
- if (isWSL()) {
465
- p.note(
466
- [
467
- "WSL detected. Claude Desktop runs on the Windows host.",
468
- "",
469
- `Config file: ${pc.dim("%APPDATA%\\Claude\\claude_desktop_config.json")}`,
470
- "",
471
- "Add this to your config:",
472
- "",
473
- formatConfigSnippet(entry),
474
- "",
475
- pc.dim('Merge "solana-trading" into your existing "mcpServers" object.'),
476
- ].join("\n"),
477
- "Claude Desktop Config",
478
- );
479
- return;
480
- }
481
-
482
- const configPath = getClaudeConfigPath();
483
- if (!configPath) {
484
- p.note(
485
- [
486
- "Could not detect Claude Desktop config path.",
487
- "",
488
- "Add this to your claude_desktop_config.json:",
489
- "",
490
- formatConfigSnippet(entry),
491
- ].join("\n"),
492
- "Claude Desktop Config",
493
- );
494
- return;
495
- }
496
-
497
- p.log.info(`Config file: ${pc.dim(configPath)}`);
498
-
499
- const autoModify = handleCancel(
500
- await p.confirm({
501
- message: "Automatically add this MCP server to Claude Desktop config?",
502
- initialValue: true,
503
- }),
504
- );
505
-
506
- if (!autoModify) {
507
- p.note(
508
- [
509
- "Add this to your claude_desktop_config.json:",
510
- "",
511
- formatConfigSnippet(entry),
512
- "",
513
- pc.dim('Merge "solana-trading" into your existing "mcpServers" object.'),
514
- ].join("\n"),
515
- "Manual Config",
516
- );
517
- return;
518
- }
519
-
520
- const s = p.spinner();
521
- s.start("Writing Claude Desktop config...");
522
-
523
- // Read existing config
524
- let config = {};
525
- if (existsSync(configPath)) {
526
- try {
527
- const raw = readFileSync(configPath, "utf-8");
528
- config = JSON.parse(raw);
529
- } catch {
530
- s.stop("Existing config has invalid JSON");
531
- p.note(
532
- [
533
- "Could not parse existing config file.",
534
- "",
535
- "Add this manually to your claude_desktop_config.json:",
536
- "",
537
- formatConfigSnippet(entry),
538
- ].join("\n"),
539
- "Manual Config",
540
- );
541
- return;
542
- }
543
-
544
- // Backup
545
- const backupPath = configPath + ".bak";
546
- copyFileSync(configPath, backupPath);
547
- p.log.info(`Backup created: ${pc.dim(backupPath)}`);
548
- }
549
-
550
- // Check for existing entry
551
- if (config.mcpServers?.["solana-trading"]) {
552
- s.stop("Existing entry found");
553
- const overwrite = handleCancel(
554
- await p.confirm({
555
- message: "A 'solana-trading' entry already exists. Overwrite?",
556
- initialValue: false,
557
- }),
558
- );
559
-
560
- if (!overwrite) {
561
- p.log.info("Existing config preserved.");
562
- return;
563
- }
564
-
565
- s.start("Writing Claude Desktop config...");
566
- }
567
-
568
- // Merge
569
- if (!config.mcpServers) {
570
- config.mcpServers = {};
571
- }
572
- config.mcpServers["solana-trading"] = entry;
573
-
574
- // Write
575
- mkdirSync(dirname(configPath), { recursive: true });
576
- writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
577
-
578
- s.stop("Claude Desktop config updated");
579
- }
580
-
581
- // ---------------------------------------------------------------------------
582
- // Step 7: Verification
583
- // ---------------------------------------------------------------------------
584
-
585
- const MAX_VERIFICATION_RETRIES = 3;
586
-
587
- async function runVerification(keypairPath, rpcUrl, apiKey) {
588
- for (let attempt = 0; attempt <= MAX_VERIFICATION_RETRIES; attempt++) {
589
- const s = p.spinner();
590
- let hasFailure = false;
591
-
592
- // 1. Load keypair
593
- s.start("Loading keypair...");
594
- let keypair;
595
- try {
596
- keypair = validateKeypairFile(keypairPath);
597
- s.stop(`Keypair loaded ${pc.cyan(keypair.publicKey.toBase58())}`);
598
- } catch (err) {
599
- s.stop(`Keypair load failed: ${err.message}`);
600
- hasFailure = true;
601
- }
602
-
603
- // 2. Check SOL balance (only if keypair loaded)
604
- if (keypair) {
605
- const effectiveRpc = rpcUrl || DEFAULTS.rpcUrl;
606
- s.start("Checking SOL balance...");
607
- try {
608
- const response = await fetch(effectiveRpc, {
609
- method: "POST",
610
- headers: { "Content-Type": "application/json" },
611
- body: JSON.stringify({
612
- jsonrpc: "2.0",
613
- id: 1,
614
- method: "getBalance",
615
- params: [keypair.publicKey.toBase58()],
616
- }),
617
- signal: AbortSignal.timeout(10_000),
618
- });
619
-
620
- const data = await response.json();
621
- const lamports = data.result?.value ?? 0;
622
- const sol = (lamports / 1e9).toFixed(4);
623
-
624
- if (lamports === 0) {
625
- s.stop(`Balance: ${pc.yellow("0 SOL")}`);
626
- p.log.warn("Fund this wallet with SOL before trading.");
627
- } else {
628
- s.stop(`Balance: ${pc.green(sol + " SOL")}`);
629
- }
630
- } catch (err) {
631
- const msg =
632
- err.name === "TimeoutError" || err.name === "AbortError"
633
- ? "Request timed out"
634
- : err.message;
635
- s.stop(`Balance check failed: ${pc.yellow(msg)}`);
636
- hasFailure = true;
637
- }
638
- }
639
-
640
- // 3. Test Kinetic API (only if API key provided)
641
- if (apiKey) {
642
- s.start("Testing Kinetic API...");
643
- try {
644
- const response = await fetch("https://auth.kinetic.xyz/v1/token", {
645
- method: "POST",
646
- headers: { "Content-Type": "application/json" },
647
- body: JSON.stringify({ apiKey }),
648
- signal: AbortSignal.timeout(10_000),
649
- });
650
-
651
- if (response.ok) {
652
- s.stop(`Kinetic API: ${pc.green("connected")}`);
653
- } else {
654
- s.stop(`Kinetic API: ${pc.yellow(`${response.status} ${response.statusText}`)}`);
655
- hasFailure = true;
656
- }
657
- } catch (err) {
658
- const msg =
659
- err.name === "TimeoutError" || err.name === "AbortError"
660
- ? "Request timed out"
661
- : err.message;
662
- s.stop(`Kinetic API: ${pc.yellow(msg)}`);
663
- hasFailure = true;
664
- }
665
- } else {
666
- p.log.info(pc.dim("Skipping Kinetic API test (no API key)."));
667
- }
668
-
669
- if (!hasFailure) return;
670
-
671
- // Retry on failure (up to MAX_VERIFICATION_RETRIES)
672
- if (attempt < MAX_VERIFICATION_RETRIES) {
673
- const retry = handleCancel(
674
- await p.confirm({
675
- message: "Some checks failed. Retry verification?",
676
- initialValue: true,
677
- }),
678
- );
679
-
680
- if (!retry) {
681
- p.log.warn("Continuing with warnings. You can verify connectivity later.");
682
- return;
683
- }
684
- } else {
685
- p.log.warn("Maximum retries reached. Continuing with warnings.");
686
- return;
687
- }
688
- }
689
- }
690
-
691
- // ---------------------------------------------------------------------------
692
- // Main
693
- // ---------------------------------------------------------------------------
694
-
695
- async function main() {
696
- await renderBanner();
697
- p.intro(pc.bold("Setup Wizard"));
698
-
699
- // Step 2: Build check
700
- await checkBuild();
701
-
702
- // Step 3: Keypair setup
703
- const keypairResult = await setupKeypair();
704
-
705
- // Step 4: API key
706
- const apiKey = await setupApiKey();
707
-
708
- // Step 5: Advanced settings
709
- const advancedEnv = await advancedSettings();
710
-
711
- // Step 6: Claude Desktop config
712
- await configureClaudeDesktop(keypairResult.path, apiKey, advancedEnv);
713
-
714
- // Step 7: Verification
715
- const rpcUrl = advancedEnv.SOLANA_RPC_URL || null;
716
- await runVerification(keypairResult.path, rpcUrl, apiKey);
717
-
718
- // Step 8: Outro
719
- const summaryLines = [
720
- `Wallet: ${pc.cyan(keypairResult.publicKey)}`,
721
- `Keypair: ${pc.dim(keypairResult.path)}`,
722
- ];
723
- if (apiKey) summaryLines.push(`API Key: ${pc.green("configured")}`);
724
-
725
- const advancedKeys = Object.keys(advancedEnv);
726
- if (advancedKeys.length > 0) {
727
- summaryLines.push("");
728
- summaryLines.push(pc.bold("Custom settings:"));
729
- for (const [key, val] of Object.entries(advancedEnv)) {
730
- summaryLines.push(` ${pc.dim(key)}: ${val}`);
731
- }
732
- }
733
-
734
- p.note(summaryLines.join("\n"), "Setup Complete");
735
-
736
- p.note(
737
- [
738
- `1. ${pc.bold("Restart Claude Desktop")} (fully quit and reopen)`,
739
- `2. Look for the ${pc.bold("hammer icon")} in the chat input`,
740
- `3. Try: ${pc.cyan('"What\'s my SOL balance?"')}`,
741
- "",
742
- pc.dim("Safety guardrails:"),
743
- pc.dim(` • Swaps always show a preview first`),
744
- pc.dim(` Max trade size: ${advancedEnv.MAX_TRADE_SOL || "10"} SOL`),
745
- pc.dim(` Slippage capped at ${advancedEnv.DYNAMIC_SLIPPAGE_MAX_BPS || "300"} BPS`),
746
- ].join("\n"),
747
- "Next Steps",
748
- );
749
-
750
- p.outro(pc.green("You're all set! Happy trading."));
751
- }
752
-
753
- main().catch((err) => {
754
- if (err.message === "Cancelled by user") {
755
- p.cancel("Setup cancelled.");
756
- } else {
757
- p.log.error(`Setup failed: ${err.message}`);
758
- }
759
- process.exit(1);
760
- });
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Interactive setup wizard for Kinetic MCP Server.
5
+ *
6
+ * Run: npx kinetic-mcp setup
7
+ *
8
+ * This is a standalone .mjs file — no compilation required.
9
+ * Uses @clack/prompts for beautiful terminal UI.
10
+ */
11
+
12
+ import {
13
+ readFileSync,
14
+ writeFileSync,
15
+ existsSync,
16
+ mkdirSync,
17
+ chmodSync,
18
+ copyFileSync,
19
+ } from "node:fs";
20
+ import { homedir } from "node:os";
21
+ import { join, resolve, dirname } from "node:path";
22
+ import { fileURLToPath } from "node:url";
23
+ import { execSync } from "node:child_process";
24
+ import { platform, execPath } from "node:process";
25
+ import { createRequire } from "node:module";
26
+
27
+ import { Keypair } from "@solana/web3.js";
28
+ import bs58 from "bs58";
29
+ import * as p from "@clack/prompts";
30
+ import pc from "picocolors";
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Version
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const require = createRequire(import.meta.url);
37
+ const { version } = require("../package.json");
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Package root detection
41
+ // ---------------------------------------------------------------------------
42
+
43
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
44
+ const isNpmInstall = /node_modules|[/\\]_npx[/\\]/.test(packageRoot);
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Non-TTY check
48
+ // ---------------------------------------------------------------------------
49
+
50
+ if (!process.stdin.isTTY) {
51
+ console.error("Error: Setup wizard requires an interactive terminal.");
52
+ console.error("Run: npx kinetic-mcp setup");
53
+ process.exit(1);
54
+ }
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // ASCII Banner
58
+ // ---------------------------------------------------------------------------
59
+
60
+ const BANNER = `
61
+ ██╗ ██╗██╗███╗ ██╗███████╗████████╗██╗ ██████╗
62
+ ██║ ██╔╝██║████╗ ██║██╔════╝╚══██╔══╝██║██╔════╝
63
+ █████╔╝ ██║██╔██╗ ██║█████╗ ██║ ██║██║
64
+ ██╔═██╗ ██║██║╚██╗██║██╔══╝ ██║ ██║██║
65
+ ██║ ██╗██║██║ ╚████║███████╗ ██║ ██║╚██████╗
66
+ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚══════╝ ╚═╝ ╚═╝ ╚═════╝`;
67
+
68
+ async function renderBanner() {
69
+ let rendered;
70
+ try {
71
+ const gradient = (await import("gradient-string")).default;
72
+ const kineticGradient = gradient(["#FEDA75", "#FA7E1E", "#D62976", "#962FBF", "#4F5BD5"]);
73
+ rendered = kineticGradient.multiline(BANNER);
74
+ } catch {
75
+ rendered = pc.bold(pc.magenta(BANNER));
76
+ }
77
+
78
+ console.log(rendered);
79
+ console.log(pc.dim(` v${version} — Solana Trading via Claude Desktop`));
80
+ console.log();
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // Helpers
85
+ // ---------------------------------------------------------------------------
86
+
87
+ function handleCancel(value) {
88
+ if (p.isCancel(value)) {
89
+ p.cancel("Setup cancelled.");
90
+ process.exit(0);
91
+ }
92
+ return value;
93
+ }
94
+
95
+ function expandHome(filePath) {
96
+ if (filePath.startsWith("~/") || filePath.startsWith("~\\")) {
97
+ return resolve(homedir(), filePath.slice(2));
98
+ }
99
+ return filePath;
100
+ }
101
+
102
+ function isWSL() {
103
+ try {
104
+ const ver = readFileSync("/proc/version", "utf-8");
105
+ return /microsoft|wsl/i.test(ver);
106
+ } catch {
107
+ return false;
108
+ }
109
+ }
110
+
111
+ function getClaudeConfigPath() {
112
+ const home = homedir();
113
+ switch (platform) {
114
+ case "darwin":
115
+ return join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
116
+ case "win32":
117
+ return join(
118
+ process.env.APPDATA || join(home, "AppData", "Roaming"),
119
+ "Claude",
120
+ "claude_desktop_config.json",
121
+ );
122
+ case "linux":
123
+ if (isWSL()) return null;
124
+ return join(home, ".config", "Claude", "claude_desktop_config.json");
125
+ default:
126
+ return null;
127
+ }
128
+ }
129
+
130
+ function getDefaultKeypairPath() {
131
+ return join(homedir(), ".config", "solana", "trading-keypair.json");
132
+ }
133
+
134
+ function validateKeypairFile(filePath) {
135
+ const expanded = expandHome(filePath);
136
+ if (!existsSync(expanded)) {
137
+ throw new Error(`File not found: ${expanded}`);
138
+ }
139
+ const raw = readFileSync(expanded, "utf-8");
140
+ let parsed;
141
+ try {
142
+ parsed = JSON.parse(raw);
143
+ } catch {
144
+ throw new Error(
145
+ "File is not valid JSON. Expected a JSON array of 64 integers (Solana CLI format).",
146
+ );
147
+ }
148
+ if (
149
+ !Array.isArray(parsed) ||
150
+ parsed.length !== 64 ||
151
+ !parsed.every((n) => typeof n === "number" && Number.isInteger(n))
152
+ ) {
153
+ throw new Error("Invalid Solana keypair. Expected a JSON array of exactly 64 integers.");
154
+ }
155
+ return Keypair.fromSecretKey(Uint8Array.from(parsed));
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // Step 2: Build Check
160
+ // ---------------------------------------------------------------------------
161
+
162
+ async function checkBuild() {
163
+ // npm/npx installs ship with build/ already included
164
+ if (isNpmInstall) return;
165
+
166
+ const buildExists = existsSync(join(packageRoot, "build", "index.js"));
167
+ if (buildExists) return;
168
+
169
+ p.log.warn("Project hasn't been built yet — build/index.js not found.");
170
+
171
+ const runBuild = handleCancel(
172
+ await p.confirm({ message: "Run npm run build now?", initialValue: true }),
173
+ );
174
+
175
+ if (runBuild) {
176
+ const s = p.spinner();
177
+ s.start("Building project...");
178
+ try {
179
+ execSync("npm run build", { stdio: "pipe", cwd: packageRoot });
180
+ s.stop("Build complete");
181
+ } catch {
182
+ s.stop("Build failed");
183
+ p.log.warn("You can try again later with: npm run build");
184
+ }
185
+ } else {
186
+ p.log.info("Skipped. Remember to run npm run build before using the server.");
187
+ }
188
+ }
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // Step 3: Keypair Setup
192
+ // ---------------------------------------------------------------------------
193
+
194
+ async function importFromPhantom() {
195
+ p.note(
196
+ [
197
+ `${pc.bold("How to export from Phantom:")}`,
198
+ "",
199
+ "1. Open Phantom → Settings → Manage Accounts → [Your Account] → Show Private Key",
200
+ "2. Enter your password and copy the base58 string",
201
+ "",
202
+ `${pc.dim("Your key will be masked. It is NOT saved to shell history.")}`,
203
+ ].join("\n"),
204
+ "Import Private Key",
205
+ );
206
+
207
+ const key = handleCancel(
208
+ await p.password({
209
+ message: "Paste your private key:",
210
+ mask: "*",
211
+ validate(value) {
212
+ if (!value || value.trim().length === 0) return "Private key is required.";
213
+
214
+ let decoded;
215
+ try {
216
+ decoded = bs58.decode(value.trim());
217
+ } catch {
218
+ return "Invalid format. Expected a base58-encoded string from Phantom.";
219
+ }
220
+
221
+ if (decoded.length !== 64) {
222
+ if (decoded.length === 32) {
223
+ return "This looks like a 32-byte seed. Use the Solana CLI to generate the full keypair.";
224
+ }
225
+ return `Expected 64-byte secret key, got ${decoded.length} bytes.`;
226
+ }
227
+ },
228
+ }),
229
+ );
230
+
231
+ const decoded = bs58.decode(key.trim());
232
+ const keypair = Keypair.fromSecretKey(decoded);
233
+
234
+ const publicKey = keypair.publicKey.toBase58();
235
+ p.log.info(`Wallet address: ${pc.cyan(publicKey)}`);
236
+ p.log.message(pc.dim("Verify this matches your Phantom wallet address."));
237
+
238
+ // Save keypair to default path
239
+ const defaultPath = getDefaultKeypairPath();
240
+
241
+ // Check if file exists
242
+ if (existsSync(defaultPath)) {
243
+ const overwrite = handleCancel(
244
+ await p.confirm({
245
+ message: `Keypair file already exists at ${pc.dim(defaultPath)}. Overwrite?`,
246
+ initialValue: false,
247
+ }),
248
+ );
249
+
250
+ if (!overwrite) {
251
+ p.log.info("Keeping existing keypair file.");
252
+ return { publicKey, path: defaultPath };
253
+ }
254
+ }
255
+
256
+ mkdirSync(dirname(defaultPath), { recursive: true, mode: 0o700 });
257
+ writeFileSync(defaultPath, JSON.stringify(Array.from(keypair.secretKey)) + "\n", { mode: 0o600 });
258
+
259
+ if (platform !== "win32") {
260
+ try {
261
+ chmodSync(defaultPath, 0o600);
262
+ } catch (err) {
263
+ p.log.warn(`Could not set file permissions: ${err.message}. Run: chmod 600 ${defaultPath}`);
264
+ }
265
+ }
266
+
267
+ p.log.success(`Keypair saved to ${pc.dim(defaultPath)}`);
268
+ return { publicKey, path: defaultPath };
269
+ }
270
+
271
+ async function useExistingKeypair() {
272
+ const filePath = handleCancel(
273
+ await p.text({
274
+ message: "Path to keypair file:",
275
+ placeholder: "~/.config/solana/id.json",
276
+ validate(value) {
277
+ if (!value || value.trim().length === 0) return "Path is required.";
278
+ try {
279
+ const expanded = expandHome(value.trim());
280
+ validateKeypairFile(expanded);
281
+ } catch (err) {
282
+ return err.message;
283
+ }
284
+ },
285
+ }),
286
+ );
287
+
288
+ const expanded = expandHome(filePath.trim());
289
+ const keypair = validateKeypairFile(expanded);
290
+ const publicKey = keypair.publicKey.toBase58();
291
+
292
+ p.log.info(`Wallet address: ${pc.cyan(publicKey)}`);
293
+
294
+ return { publicKey, path: expanded };
295
+ }
296
+
297
+ async function setupKeypair() {
298
+ const method = handleCancel(
299
+ await p.select({
300
+ message: "How would you like to set up your Solana keypair?",
301
+ options: [
302
+ {
303
+ value: "phantom",
304
+ label: "Import from Phantom",
305
+ hint: "paste your base58 private key",
306
+ },
307
+ {
308
+ value: "existing",
309
+ label: "Use existing keypair file",
310
+ hint: "Solana CLI JSON format",
311
+ },
312
+ ],
313
+ }),
314
+ );
315
+
316
+ if (method === "phantom") {
317
+ return importFromPhantom();
318
+ } else {
319
+ return useExistingKeypair();
320
+ }
321
+ }
322
+
323
+ // ---------------------------------------------------------------------------
324
+ // Step 4: API Key
325
+ // ---------------------------------------------------------------------------
326
+
327
+ async function setupApiKey() {
328
+ const apiKey = handleCancel(
329
+ await p.password({
330
+ message: "Kinetic API key (optional) (press Enter to skip):",
331
+ mask: "*",
332
+ }),
333
+ );
334
+
335
+ return apiKey?.trim() || null;
336
+ }
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // Step 5: Advanced Settings
340
+ // ---------------------------------------------------------------------------
341
+
342
+ const DEFAULTS = {
343
+ rpcUrl: "https://api.mainnet-beta.solana.com",
344
+ maxTradeSol: "10",
345
+ priorityFeeLamports: "400000",
346
+ jitoEnabled: false,
347
+ dynamicSlippageMaxBps: "300",
348
+ };
349
+
350
+ async function advancedSettings() {
351
+ const configure = handleCancel(
352
+ await p.confirm({
353
+ message: "Configure advanced settings?",
354
+ initialValue: false,
355
+ }),
356
+ );
357
+
358
+ if (!configure) return {};
359
+
360
+ p.log.info(pc.dim("Press Enter to keep defaults shown."));
361
+
362
+ const rpcUrl = handleCancel(
363
+ await p.text({
364
+ message: "Solana RPC URL:",
365
+ placeholder: DEFAULTS.rpcUrl,
366
+ defaultValue: DEFAULTS.rpcUrl,
367
+ validate(value) {
368
+ if (!value) return;
369
+ try {
370
+ const url = new URL(value);
371
+ if (url.protocol !== "https:") return "URL must use https://";
372
+ } catch {
373
+ return "Invalid URL format.";
374
+ }
375
+ },
376
+ }),
377
+ );
378
+
379
+ const maxTradeSol = handleCancel(
380
+ await p.text({
381
+ message: "Max trade size (SOL):",
382
+ placeholder: DEFAULTS.maxTradeSol,
383
+ defaultValue: DEFAULTS.maxTradeSol,
384
+ validate(value) {
385
+ if (!value) return;
386
+ const num = Number(value);
387
+ if (isNaN(num) || num <= 0) return "Must be a positive number.";
388
+ },
389
+ }),
390
+ );
391
+
392
+ const priorityFeeLamports = handleCancel(
393
+ await p.text({
394
+ message: `Priority fee (lamports): ${pc.dim("400000 = ~0.0004 SOL")}`,
395
+ placeholder: DEFAULTS.priorityFeeLamports,
396
+ defaultValue: DEFAULTS.priorityFeeLamports,
397
+ validate(value) {
398
+ if (!value) return;
399
+ const num = Number(value);
400
+ if (isNaN(num) || !Number.isInteger(num) || num < 0)
401
+ return "Must be a non-negative integer.";
402
+ },
403
+ }),
404
+ );
405
+
406
+ const jitoEnabled = handleCancel(
407
+ await p.confirm({
408
+ message: "Enable Jito MEV protection?",
409
+ initialValue: DEFAULTS.jitoEnabled,
410
+ }),
411
+ );
412
+
413
+ const dynamicSlippageMaxBps = handleCancel(
414
+ await p.text({
415
+ message: "Max dynamic slippage (BPS):",
416
+ placeholder: DEFAULTS.dynamicSlippageMaxBps,
417
+ defaultValue: DEFAULTS.dynamicSlippageMaxBps,
418
+ validate(value) {
419
+ if (!value) return;
420
+ const num = Number(value);
421
+ if (isNaN(num) || !Number.isInteger(num) || num <= 0) return "Must be a positive integer.";
422
+ if (num > 1000) return "Cannot exceed 1000 BPS (10%).";
423
+ },
424
+ }),
425
+ );
426
+
427
+ // Return only non-default values
428
+ const settings = {};
429
+ if (rpcUrl !== DEFAULTS.rpcUrl) settings.SOLANA_RPC_URL = rpcUrl;
430
+ if (maxTradeSol !== DEFAULTS.maxTradeSol) settings.MAX_TRADE_SOL = maxTradeSol;
431
+ if (priorityFeeLamports !== DEFAULTS.priorityFeeLamports)
432
+ settings.PRIORITY_FEE_LAMPORTS = priorityFeeLamports;
433
+ if (jitoEnabled !== DEFAULTS.jitoEnabled) settings.JITO_ENABLED = jitoEnabled ? "true" : "false";
434
+ if (dynamicSlippageMaxBps !== DEFAULTS.dynamicSlippageMaxBps)
435
+ settings.DYNAMIC_SLIPPAGE_MAX_BPS = dynamicSlippageMaxBps;
436
+
437
+ return settings;
438
+ }
439
+
440
+ // ---------------------------------------------------------------------------
441
+ // Step 6: Claude Desktop Config
442
+ // ---------------------------------------------------------------------------
443
+
444
+ function generateConfigEntry(keypairPath, apiKey, advancedEnv) {
445
+ const entry = {
446
+ env: {
447
+ SOLANA_KEYPAIR_PATH: keypairPath.replace(/\\/g, "/"),
448
+ },
449
+ };
450
+
451
+ if (isNpmInstall) {
452
+ // npm/npx install — use portable npx command
453
+ entry.command = "npx";
454
+ entry.args = ["-y", "kinetic-mcp"];
455
+ } else {
456
+ // Local source checkout — use direct node path
457
+ entry.command = execPath;
458
+ entry.args = [join(packageRoot, "build", "index.js").replace(/\\/g, "/")];
459
+ }
460
+
461
+ if (apiKey) {
462
+ entry.env.API_KEY = apiKey;
463
+ }
464
+ // Add non-default advanced settings
465
+ if (advancedEnv) {
466
+ Object.assign(entry.env, advancedEnv);
467
+ }
468
+ return entry;
469
+ }
470
+
471
+ function formatConfigSnippet(entry) {
472
+ const snippet = {
473
+ mcpServers: {
474
+ "solana-trading": entry,
475
+ },
476
+ };
477
+ return JSON.stringify(snippet, null, 2);
478
+ }
479
+
480
+ async function configureClaudeDesktop(keypairPath, apiKey, advancedEnv) {
481
+ const entry = generateConfigEntry(keypairPath, apiKey, advancedEnv);
482
+
483
+ if (isWSL()) {
484
+ p.note(
485
+ [
486
+ "WSL detected. Claude Desktop runs on the Windows host.",
487
+ "",
488
+ `Config file: ${pc.dim("%APPDATA%\\Claude\\claude_desktop_config.json")}`,
489
+ "",
490
+ "Add this to your config:",
491
+ "",
492
+ formatConfigSnippet(entry),
493
+ "",
494
+ pc.dim('Merge "solana-trading" into your existing "mcpServers" object.'),
495
+ ].join("\n"),
496
+ "Claude Desktop Config",
497
+ );
498
+ return;
499
+ }
500
+
501
+ const configPath = getClaudeConfigPath();
502
+ if (!configPath) {
503
+ p.note(
504
+ [
505
+ "Could not detect Claude Desktop config path.",
506
+ "",
507
+ "Add this to your claude_desktop_config.json:",
508
+ "",
509
+ formatConfigSnippet(entry),
510
+ ].join("\n"),
511
+ "Claude Desktop Config",
512
+ );
513
+ return;
514
+ }
515
+
516
+ p.log.info(`Config file: ${pc.dim(configPath)}`);
517
+
518
+ const autoModify = handleCancel(
519
+ await p.confirm({
520
+ message: "Automatically add this MCP server to Claude Desktop config?",
521
+ initialValue: true,
522
+ }),
523
+ );
524
+
525
+ if (!autoModify) {
526
+ p.note(
527
+ [
528
+ "Add this to your claude_desktop_config.json:",
529
+ "",
530
+ formatConfigSnippet(entry),
531
+ "",
532
+ pc.dim('Merge "solana-trading" into your existing "mcpServers" object.'),
533
+ ].join("\n"),
534
+ "Manual Config",
535
+ );
536
+ return;
537
+ }
538
+
539
+ const s = p.spinner();
540
+ s.start("Writing Claude Desktop config...");
541
+
542
+ // Read existing config
543
+ let config = {};
544
+ if (existsSync(configPath)) {
545
+ try {
546
+ const raw = readFileSync(configPath, "utf-8");
547
+ config = JSON.parse(raw);
548
+ } catch {
549
+ s.stop("Existing config has invalid JSON");
550
+ p.note(
551
+ [
552
+ "Could not parse existing config file.",
553
+ "",
554
+ "Add this manually to your claude_desktop_config.json:",
555
+ "",
556
+ formatConfigSnippet(entry),
557
+ ].join("\n"),
558
+ "Manual Config",
559
+ );
560
+ return;
561
+ }
562
+
563
+ // Backup
564
+ const backupPath = configPath + ".bak";
565
+ copyFileSync(configPath, backupPath);
566
+ p.log.info(`Backup created: ${pc.dim(backupPath)}`);
567
+ }
568
+
569
+ // Check for existing entry
570
+ if (config.mcpServers?.["solana-trading"]) {
571
+ s.stop("Existing entry found");
572
+ const overwrite = handleCancel(
573
+ await p.confirm({
574
+ message: "A 'solana-trading' entry already exists. Overwrite?",
575
+ initialValue: false,
576
+ }),
577
+ );
578
+
579
+ if (!overwrite) {
580
+ p.log.info("Existing config preserved.");
581
+ return;
582
+ }
583
+
584
+ s.start("Writing Claude Desktop config...");
585
+ }
586
+
587
+ // Merge
588
+ if (!config.mcpServers) {
589
+ config.mcpServers = {};
590
+ }
591
+ config.mcpServers["solana-trading"] = entry;
592
+
593
+ // Write
594
+ mkdirSync(dirname(configPath), { recursive: true });
595
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
596
+
597
+ s.stop("Claude Desktop config updated");
598
+ }
599
+
600
+ // ---------------------------------------------------------------------------
601
+ // Step 7: Verification
602
+ // ---------------------------------------------------------------------------
603
+
604
+ const MAX_VERIFICATION_RETRIES = 3;
605
+
606
+ async function runVerification(keypairPath, rpcUrl, apiKey) {
607
+ for (let attempt = 0; attempt <= MAX_VERIFICATION_RETRIES; attempt++) {
608
+ const s = p.spinner();
609
+ let hasFailure = false;
610
+
611
+ // 1. Load keypair
612
+ s.start("Loading keypair...");
613
+ let keypair;
614
+ try {
615
+ keypair = validateKeypairFile(keypairPath);
616
+ s.stop(`Keypair loaded — ${pc.cyan(keypair.publicKey.toBase58())}`);
617
+ } catch (err) {
618
+ s.stop(`Keypair load failed: ${err.message}`);
619
+ hasFailure = true;
620
+ }
621
+
622
+ // 2. Check SOL balance (only if keypair loaded)
623
+ if (keypair) {
624
+ const effectiveRpc = rpcUrl || DEFAULTS.rpcUrl;
625
+ s.start("Checking SOL balance...");
626
+ try {
627
+ const response = await fetch(effectiveRpc, {
628
+ method: "POST",
629
+ headers: { "Content-Type": "application/json" },
630
+ body: JSON.stringify({
631
+ jsonrpc: "2.0",
632
+ id: 1,
633
+ method: "getBalance",
634
+ params: [keypair.publicKey.toBase58()],
635
+ }),
636
+ signal: AbortSignal.timeout(10_000),
637
+ });
638
+
639
+ const data = await response.json();
640
+ const lamports = data.result?.value ?? 0;
641
+ const sol = (lamports / 1e9).toFixed(4);
642
+
643
+ if (lamports === 0) {
644
+ s.stop(`Balance: ${pc.yellow("0 SOL")}`);
645
+ p.log.warn("Fund this wallet with SOL before trading.");
646
+ } else {
647
+ s.stop(`Balance: ${pc.green(sol + " SOL")}`);
648
+ }
649
+ } catch (err) {
650
+ const msg =
651
+ err.name === "TimeoutError" || err.name === "AbortError"
652
+ ? "Request timed out"
653
+ : err.message;
654
+ s.stop(`Balance check failed: ${pc.yellow(msg)}`);
655
+ hasFailure = true;
656
+ }
657
+ }
658
+
659
+ // 3. Test Kinetic API (only if API key provided)
660
+ if (apiKey) {
661
+ s.start("Testing Kinetic API...");
662
+ try {
663
+ const response = await fetch("https://auth.kinetic.xyz/v1/token", {
664
+ method: "POST",
665
+ headers: { "Content-Type": "application/json" },
666
+ body: JSON.stringify({ apiKey }),
667
+ signal: AbortSignal.timeout(10_000),
668
+ });
669
+
670
+ if (response.ok) {
671
+ s.stop(`Kinetic API: ${pc.green("connected")}`);
672
+ } else {
673
+ s.stop(`Kinetic API: ${pc.yellow(`${response.status} ${response.statusText}`)}`);
674
+ hasFailure = true;
675
+ }
676
+ } catch (err) {
677
+ const msg =
678
+ err.name === "TimeoutError" || err.name === "AbortError"
679
+ ? "Request timed out"
680
+ : err.message;
681
+ s.stop(`Kinetic API: ${pc.yellow(msg)}`);
682
+ hasFailure = true;
683
+ }
684
+ } else {
685
+ p.log.info(pc.dim("Skipping Kinetic API test (no API key)."));
686
+ }
687
+
688
+ if (!hasFailure) return;
689
+
690
+ // Retry on failure (up to MAX_VERIFICATION_RETRIES)
691
+ if (attempt < MAX_VERIFICATION_RETRIES) {
692
+ const retry = handleCancel(
693
+ await p.confirm({
694
+ message: "Some checks failed. Retry verification?",
695
+ initialValue: true,
696
+ }),
697
+ );
698
+
699
+ if (!retry) {
700
+ p.log.warn("Continuing with warnings. You can verify connectivity later.");
701
+ return;
702
+ }
703
+ } else {
704
+ p.log.warn("Maximum retries reached. Continuing with warnings.");
705
+ return;
706
+ }
707
+ }
708
+ }
709
+
710
+ // ---------------------------------------------------------------------------
711
+ // Main
712
+ // ---------------------------------------------------------------------------
713
+
714
+ async function main() {
715
+ await renderBanner();
716
+ p.intro(pc.bold("Setup Wizard"));
717
+
718
+ // Step 2: Build check
719
+ await checkBuild();
720
+
721
+ // Step 3: Keypair setup
722
+ const keypairResult = await setupKeypair();
723
+
724
+ // Step 4: API key
725
+ const apiKey = await setupApiKey();
726
+
727
+ // Step 5: Advanced settings
728
+ const advancedEnv = await advancedSettings();
729
+
730
+ // Step 6: Claude Desktop config
731
+ await configureClaudeDesktop(keypairResult.path, apiKey, advancedEnv);
732
+
733
+ // Step 7: Verification
734
+ const rpcUrl = advancedEnv.SOLANA_RPC_URL || null;
735
+ await runVerification(keypairResult.path, rpcUrl, apiKey);
736
+
737
+ // Step 8: Outro
738
+ const summaryLines = [
739
+ `Wallet: ${pc.cyan(keypairResult.publicKey)}`,
740
+ `Keypair: ${pc.dim(keypairResult.path)}`,
741
+ ];
742
+ if (apiKey) summaryLines.push(`API Key: ${pc.green("configured")}`);
743
+
744
+ const advancedKeys = Object.keys(advancedEnv);
745
+ if (advancedKeys.length > 0) {
746
+ summaryLines.push("");
747
+ summaryLines.push(pc.bold("Custom settings:"));
748
+ for (const [key, val] of Object.entries(advancedEnv)) {
749
+ summaryLines.push(` ${pc.dim(key)}: ${val}`);
750
+ }
751
+ }
752
+
753
+ p.note(summaryLines.join("\n"), "Setup Complete");
754
+
755
+ p.note(
756
+ [
757
+ `1. ${pc.bold("Restart Claude Desktop")} (fully quit and reopen)`,
758
+ `2. Look for the ${pc.bold("hammer icon")} in the chat input`,
759
+ `3. Try: ${pc.cyan('"What\'s my SOL balance?"')}`,
760
+ "",
761
+ pc.dim("Safety guardrails:"),
762
+ pc.dim(` • Swaps always show a preview first`),
763
+ pc.dim(` • Max trade size: ${advancedEnv.MAX_TRADE_SOL || "10"} SOL`),
764
+ pc.dim(` • Slippage capped at ${advancedEnv.DYNAMIC_SLIPPAGE_MAX_BPS || "300"} BPS`),
765
+ ].join("\n"),
766
+ "Next Steps",
767
+ );
768
+
769
+ p.outro(pc.green("You're all set! Happy trading."));
770
+ }
771
+
772
+ main().catch((err) => {
773
+ if (err.message === "Cancelled by user") {
774
+ p.cancel("Setup cancelled.");
775
+ } else {
776
+ p.log.error(`Setup failed: ${err.message}`);
777
+ }
778
+ process.exit(1);
779
+ });