nansen-cli 1.0.2 → 1.1.0

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/src/cli.js ADDED
@@ -0,0 +1,843 @@
1
+ /**
2
+ * Nansen CLI - Core logic (testable)
3
+ * Extracted from index.js for coverage
4
+ */
5
+
6
+ import { NansenAPI, saveConfig, deleteConfig, getConfigFile } from './api.js';
7
+ import * as readline from 'readline';
8
+
9
+ // ============= Schema Definition =============
10
+
11
+ export const SCHEMA = {
12
+ version: '1.0.0',
13
+ commands: {
14
+ 'smart-money': {
15
+ description: 'Smart Money analytics - track sophisticated market participants',
16
+ subcommands: {
17
+ 'netflow': {
18
+ description: 'Net capital flows (inflows vs outflows)',
19
+ options: {
20
+ chain: { type: 'string', default: 'solana', description: 'Blockchain to query' },
21
+ chains: { type: 'array', description: 'Multiple chains as JSON array' },
22
+ limit: { type: 'number', description: 'Number of results' },
23
+ labels: { type: 'string|array', description: 'Smart Money label filter' },
24
+ sort: { type: 'string', description: 'Sort field:direction (e.g., value_usd:desc)' },
25
+ filters: { type: 'object', description: 'Additional filters as JSON' }
26
+ },
27
+ returns: ['token_address', 'token_symbol', 'token_name', 'chain', 'inflow_usd', 'outflow_usd', 'net_flow_usd']
28
+ },
29
+ 'dex-trades': {
30
+ description: 'Real-time DEX trading activity',
31
+ options: {
32
+ chain: { type: 'string', default: 'solana' },
33
+ chains: { type: 'array' },
34
+ limit: { type: 'number' },
35
+ labels: { type: 'string|array' },
36
+ sort: { type: 'string' },
37
+ filters: { type: 'object' }
38
+ },
39
+ returns: ['tx_hash', 'wallet_address', 'token_address', 'token_symbol', 'side', 'amount', 'value_usd', 'timestamp']
40
+ },
41
+ 'perp-trades': {
42
+ description: 'Perpetual trading on Hyperliquid',
43
+ options: { limit: { type: 'number' }, sort: { type: 'string' }, filters: { type: 'object' } },
44
+ returns: ['wallet_address', 'symbol', 'side', 'size', 'price', 'value_usd', 'pnl_usd', 'timestamp']
45
+ },
46
+ 'holdings': {
47
+ description: 'Aggregated token balances',
48
+ options: { chain: { type: 'string', default: 'solana' }, chains: { type: 'array' }, limit: { type: 'number' }, labels: { type: 'string|array' } },
49
+ returns: ['token_address', 'token_symbol', 'chain', 'balance', 'balance_usd', 'holder_count']
50
+ },
51
+ 'dcas': {
52
+ description: 'DCA strategies on Jupiter',
53
+ options: { limit: { type: 'number' }, filters: { type: 'object' } },
54
+ returns: ['wallet_address', 'input_token', 'output_token', 'total_input', 'total_output', 'avg_price']
55
+ },
56
+ 'historical-holdings': {
57
+ description: 'Historical holdings over time',
58
+ options: { chain: { type: 'string', default: 'solana' }, chains: { type: 'array' }, days: { type: 'number', default: 30 }, limit: { type: 'number' } },
59
+ returns: ['date', 'token_address', 'token_symbol', 'balance', 'balance_usd']
60
+ }
61
+ }
62
+ },
63
+ 'profiler': {
64
+ description: 'Wallet profiling - detailed information about any blockchain address',
65
+ subcommands: {
66
+ 'balance': {
67
+ description: 'Current token holdings',
68
+ options: {
69
+ address: { type: 'string', required: true, description: 'Wallet address to query' },
70
+ chain: { type: 'string', default: 'ethereum' },
71
+ entity: { type: 'string', description: 'Entity name instead of address' }
72
+ },
73
+ returns: ['token_address', 'token_symbol', 'token_name', 'balance', 'balance_usd', 'price_usd']
74
+ },
75
+ 'labels': {
76
+ description: 'Behavioral and entity labels',
77
+ options: { address: { type: 'string', required: true }, chain: { type: 'string', default: 'ethereum' } },
78
+ returns: ['label', 'label_type', 'label_subtype']
79
+ },
80
+ 'transactions': {
81
+ description: 'Transaction history',
82
+ options: { address: { type: 'string', required: true }, chain: { type: 'string', default: 'ethereum' }, limit: { type: 'number' }, days: { type: 'number', default: 30 } },
83
+ returns: ['tx_hash', 'block_number', 'timestamp', 'from', 'to', 'value', 'value_usd', 'method']
84
+ },
85
+ 'pnl': {
86
+ description: 'PnL and trade performance',
87
+ options: { address: { type: 'string', required: true }, chain: { type: 'string', default: 'ethereum' } },
88
+ returns: ['token_address', 'token_symbol', 'realized_pnl_usd', 'unrealized_pnl_usd', 'total_pnl_usd']
89
+ },
90
+ 'search': {
91
+ description: 'Search for entities by name',
92
+ options: { query: { type: 'string', required: true, description: 'Search query' }, limit: { type: 'number' } },
93
+ returns: ['entity_name', 'address', 'chain', 'labels']
94
+ },
95
+ 'historical-balances': {
96
+ description: 'Historical balances over time',
97
+ options: { address: { type: 'string', required: true }, chain: { type: 'string', default: 'ethereum' }, days: { type: 'number', default: 30 } },
98
+ returns: ['date', 'token_address', 'token_symbol', 'balance', 'balance_usd']
99
+ },
100
+ 'related-wallets': {
101
+ description: 'Find wallets related to an address',
102
+ options: { address: { type: 'string', required: true }, chain: { type: 'string', default: 'ethereum' }, limit: { type: 'number' } },
103
+ returns: ['address', 'relationship', 'transaction_count', 'volume_usd']
104
+ },
105
+ 'counterparties': {
106
+ description: 'Top counterparties by volume',
107
+ options: { address: { type: 'string', required: true }, chain: { type: 'string', default: 'ethereum' }, days: { type: 'number', default: 30 } },
108
+ returns: ['counterparty_address', 'counterparty_label', 'transaction_count', 'volume_usd']
109
+ },
110
+ 'pnl-summary': {
111
+ description: 'Summarized PnL metrics',
112
+ options: { address: { type: 'string', required: true }, chain: { type: 'string', default: 'ethereum' }, days: { type: 'number', default: 30 } },
113
+ returns: ['total_realized_pnl', 'total_unrealized_pnl', 'win_rate', 'total_trades']
114
+ },
115
+ 'perp-positions': {
116
+ description: 'Current perpetual positions',
117
+ options: { address: { type: 'string', required: true }, limit: { type: 'number' } },
118
+ returns: ['symbol', 'side', 'size', 'entry_price', 'mark_price', 'unrealized_pnl', 'leverage']
119
+ },
120
+ 'perp-trades': {
121
+ description: 'Perpetual trading history',
122
+ options: { address: { type: 'string', required: true }, days: { type: 'number', default: 30 }, limit: { type: 'number' } },
123
+ returns: ['symbol', 'side', 'size', 'price', 'value_usd', 'pnl_usd', 'timestamp']
124
+ }
125
+ }
126
+ },
127
+ 'token': {
128
+ description: 'Token God Mode - deep analytics for any token',
129
+ subcommands: {
130
+ 'screener': {
131
+ description: 'Discover and filter tokens',
132
+ options: {
133
+ chain: { type: 'string', default: 'solana' },
134
+ chains: { type: 'array' },
135
+ timeframe: { type: 'string', default: '24h', enum: ['5m', '10m', '1h', '6h', '24h', '7d', '30d'] },
136
+ 'smart-money': { type: 'boolean', description: 'Filter for Smart Money only' },
137
+ limit: { type: 'number' },
138
+ sort: { type: 'string' }
139
+ },
140
+ returns: ['token_address', 'token_symbol', 'token_name', 'chain', 'price_usd', 'volume_usd', 'market_cap', 'holder_count', 'smart_money_holders']
141
+ },
142
+ 'holders': {
143
+ description: 'Token holder analysis',
144
+ options: { token: { type: 'string', required: true }, chain: { type: 'string', default: 'solana' }, 'smart-money': { type: 'boolean' }, limit: { type: 'number' } },
145
+ returns: ['wallet_address', 'balance', 'balance_usd', 'pct_supply', 'labels']
146
+ },
147
+ 'flows': {
148
+ description: 'Token flow metrics',
149
+ options: { token: { type: 'string', required: true }, chain: { type: 'string', default: 'solana' }, limit: { type: 'number' } },
150
+ returns: ['label', 'inflow', 'outflow', 'net_flow', 'wallet_count']
151
+ },
152
+ 'dex-trades': {
153
+ description: 'DEX trading activity',
154
+ options: { token: { type: 'string', required: true }, chain: { type: 'string', default: 'solana' }, 'smart-money': { type: 'boolean' }, days: { type: 'number', default: 30 }, limit: { type: 'number' } },
155
+ returns: ['tx_hash', 'wallet_address', 'side', 'amount', 'price_usd', 'value_usd', 'timestamp']
156
+ },
157
+ 'pnl': {
158
+ description: 'PnL leaderboard',
159
+ options: { token: { type: 'string', required: true }, chain: { type: 'string', default: 'solana' }, days: { type: 'number', default: 30 }, limit: { type: 'number' }, sort: { type: 'string' } },
160
+ returns: ['wallet_address', 'realized_pnl_usd', 'unrealized_pnl_usd', 'total_pnl_usd', 'labels']
161
+ },
162
+ 'who-bought-sold': {
163
+ description: 'Recent buyers and sellers',
164
+ options: { token: { type: 'string', required: true }, chain: { type: 'string', default: 'solana' }, limit: { type: 'number' } },
165
+ returns: ['wallet_address', 'side', 'amount', 'value_usd', 'timestamp', 'labels']
166
+ },
167
+ 'flow-intelligence': {
168
+ description: 'Detailed flow intelligence by label',
169
+ options: { token: { type: 'string', required: true }, chain: { type: 'string', default: 'solana' }, limit: { type: 'number' } },
170
+ returns: ['label', 'inflow_usd', 'outflow_usd', 'net_flow_usd', 'unique_wallets']
171
+ },
172
+ 'transfers': {
173
+ description: 'Token transfer history',
174
+ options: { token: { type: 'string', required: true }, chain: { type: 'string', default: 'solana' }, days: { type: 'number', default: 30 }, limit: { type: 'number' } },
175
+ returns: ['tx_hash', 'from', 'to', 'amount', 'value_usd', 'timestamp']
176
+ },
177
+ 'jup-dca': {
178
+ description: 'Jupiter DCA orders for token',
179
+ options: { token: { type: 'string', required: true }, limit: { type: 'number' } },
180
+ returns: ['wallet_address', 'input_token', 'output_token', 'total_input', 'executed', 'remaining']
181
+ },
182
+ 'perp-trades': {
183
+ description: 'Perp trades by token symbol',
184
+ options: { symbol: { type: 'string', required: true, description: 'Token symbol (e.g., BTC, ETH)' }, days: { type: 'number', default: 30 }, limit: { type: 'number' } },
185
+ returns: ['wallet_address', 'side', 'size', 'price', 'value_usd', 'pnl_usd', 'timestamp']
186
+ },
187
+ 'perp-positions': {
188
+ description: 'Open perp positions by token symbol',
189
+ options: { symbol: { type: 'string', required: true }, limit: { type: 'number' } },
190
+ returns: ['wallet_address', 'side', 'size', 'entry_price', 'mark_price', 'unrealized_pnl', 'leverage']
191
+ },
192
+ 'perp-pnl-leaderboard': {
193
+ description: 'Perp PnL leaderboard by token',
194
+ options: { symbol: { type: 'string', required: true }, days: { type: 'number', default: 30 }, limit: { type: 'number' } },
195
+ returns: ['wallet_address', 'realized_pnl', 'unrealized_pnl', 'total_pnl', 'trade_count']
196
+ }
197
+ }
198
+ },
199
+ 'portfolio': {
200
+ description: 'Portfolio analytics',
201
+ subcommands: {
202
+ 'defi': {
203
+ description: 'DeFi holdings across protocols',
204
+ options: { wallet: { type: 'string', required: true, description: 'Wallet address' } },
205
+ returns: ['protocol', 'chain', 'position_type', 'token_symbol', 'balance', 'balance_usd']
206
+ }
207
+ }
208
+ }
209
+ },
210
+ globalOptions: {
211
+ pretty: { type: 'boolean', description: 'Format JSON output for readability' },
212
+ table: { type: 'boolean', description: 'Format output as human-readable table' },
213
+ fields: { type: 'string', description: 'Comma-separated list of fields to include in output' },
214
+ 'no-retry': { type: 'boolean', description: 'Disable automatic retry on rate limits/errors' },
215
+ retries: { type: 'number', default: 3, description: 'Max retry attempts' }
216
+ },
217
+ chains: ['ethereum', 'solana', 'base', 'bnb', 'arbitrum', 'polygon', 'optimism', 'avalanche', 'linea', 'scroll', 'zksync', 'mantle', 'ronin', 'sei', 'plasma', 'sonic', 'unichain', 'monad', 'hyperevm', 'iotaevm'],
218
+ smartMoneyLabels: ['Fund', 'Smart Trader', '30D Smart Trader', '90D Smart Trader', '180D Smart Trader', 'Smart HL Perps Trader']
219
+ };
220
+
221
+ // ============= Field Filtering =============
222
+
223
+ /**
224
+ * Filter object to include only specified fields
225
+ * Supports nested paths with dot notation (e.g., "data.results")
226
+ */
227
+ export function filterFields(data, fields) {
228
+ if (!fields || fields.length === 0) return data;
229
+
230
+ const fieldSet = new Set(fields);
231
+
232
+ function filterObject(obj) {
233
+ if (obj === null || obj === undefined) return obj;
234
+ if (Array.isArray(obj)) {
235
+ return obj.map(item => filterObject(item));
236
+ }
237
+ if (typeof obj !== 'object') return obj;
238
+
239
+ const filtered = {};
240
+ for (const key of Object.keys(obj)) {
241
+ if (fieldSet.has(key)) {
242
+ filtered[key] = obj[key];
243
+ } else if (typeof obj[key] === 'object' && obj[key] !== null) {
244
+ // Recurse into nested objects/arrays
245
+ const nested = filterObject(obj[key]);
246
+ // Only include if it has content
247
+ if (nested !== null && nested !== undefined) {
248
+ if (Array.isArray(nested) && nested.length > 0) {
249
+ filtered[key] = nested;
250
+ } else if (!Array.isArray(nested) && Object.keys(nested).length > 0) {
251
+ filtered[key] = nested;
252
+ }
253
+ }
254
+ }
255
+ }
256
+ return filtered;
257
+ }
258
+
259
+ return filterObject(data);
260
+ }
261
+
262
+ /**
263
+ * Parse comma-separated fields string
264
+ */
265
+ export function parseFields(fieldsOption) {
266
+ if (!fieldsOption) return null;
267
+ return fieldsOption.split(',').map(f => f.trim()).filter(f => f.length > 0);
268
+ }
269
+
270
+ // Parse command line arguments
271
+ export function parseArgs(args) {
272
+ const result = { _: [], flags: {}, options: {} };
273
+
274
+ for (let i = 0; i < args.length; i++) {
275
+ const arg = args[i];
276
+
277
+ if (arg.startsWith('--')) {
278
+ const key = arg.slice(2);
279
+ const next = args[i + 1];
280
+
281
+ if (key === 'pretty' || key === 'help' || key === 'table' || key === 'no-retry') {
282
+ result.flags[key] = true;
283
+ } else if (next && !next.startsWith('-')) {
284
+ // Try to parse as JSON first
285
+ try {
286
+ result.options[key] = JSON.parse(next);
287
+ } catch {
288
+ result.options[key] = next;
289
+ }
290
+ i++;
291
+ } else {
292
+ result.flags[key] = true;
293
+ }
294
+ } else if (arg.startsWith('-')) {
295
+ result.flags[arg.slice(1)] = true;
296
+ } else {
297
+ result._.push(arg);
298
+ }
299
+ }
300
+
301
+ return result;
302
+ }
303
+
304
+ // Format a single value for table display
305
+ export function formatValue(val) {
306
+ if (val === null || val === undefined) return '';
307
+ if (typeof val === 'number') {
308
+ if (Math.abs(val) >= 1000000) return (val / 1000000).toFixed(2) + 'M';
309
+ if (Math.abs(val) >= 1000) return (val / 1000).toFixed(2) + 'K';
310
+ if (Number.isInteger(val)) return val.toString();
311
+ return val.toFixed(2);
312
+ }
313
+ if (typeof val === 'object') return JSON.stringify(val);
314
+ return String(val);
315
+ }
316
+
317
+ // Table formatter for human-readable output
318
+ export function formatTable(data) {
319
+ // Extract array of records from various response shapes
320
+ let records = [];
321
+ if (Array.isArray(data)) {
322
+ records = data;
323
+ } else if (data?.data && Array.isArray(data.data)) {
324
+ records = data.data;
325
+ } else if (data?.results && Array.isArray(data.results)) {
326
+ records = data.results;
327
+ } else if (data?.data?.results && Array.isArray(data.data.results)) {
328
+ records = data.data.results;
329
+ } else if (typeof data === 'object' && data !== null) {
330
+ // Single object - convert to array
331
+ records = [data];
332
+ }
333
+
334
+ if (records.length === 0) {
335
+ return 'No data';
336
+ }
337
+
338
+ // Get columns from first record, prioritize common useful fields
339
+ const priorityFields = ['token_symbol', 'token_name', 'symbol', 'name', 'address', 'label', 'chain', 'value_usd', 'amount', 'pnl_usd', 'price_usd', 'volume_usd', 'net_flow_usd', 'timestamp', 'block_timestamp'];
340
+ const allKeys = [...new Set(records.flatMap(r => Object.keys(r)))];
341
+
342
+ // Sort: priority fields first, then alphabetically
343
+ const columns = allKeys.sort((a, b) => {
344
+ const aIdx = priorityFields.indexOf(a);
345
+ const bIdx = priorityFields.indexOf(b);
346
+ if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx;
347
+ if (aIdx !== -1) return -1;
348
+ if (bIdx !== -1) return 1;
349
+ return a.localeCompare(b);
350
+ }).slice(0, 8); // Limit to 8 columns for readability
351
+
352
+ // Calculate column widths
353
+ const widths = columns.map(col => {
354
+ const headerLen = col.length;
355
+ const maxDataLen = Math.max(...records.map(r => {
356
+ const val = formatValue(r[col]);
357
+ return val.length;
358
+ }));
359
+ return Math.min(Math.max(headerLen, maxDataLen), 30); // Cap at 30 chars
360
+ });
361
+
362
+ // Build table
363
+ const separator = '─';
364
+ const lines = [];
365
+
366
+ // Header
367
+ const header = columns.map((col, i) => col.padEnd(widths[i])).join(' │ ');
368
+ lines.push(header);
369
+ lines.push(widths.map(w => separator.repeat(w)).join('─┼─'));
370
+
371
+ // Rows
372
+ for (const record of records.slice(0, 50)) { // Limit to 50 rows
373
+ const row = columns.map((col, i) => {
374
+ const val = formatValue(record[col]);
375
+ return val.slice(0, widths[i]).padEnd(widths[i]);
376
+ }).join(' │ ');
377
+ lines.push(row);
378
+ }
379
+
380
+ if (records.length > 50) {
381
+ lines.push(`... and ${records.length - 50} more rows`);
382
+ }
383
+
384
+ return lines.join('\n');
385
+ }
386
+
387
+ // Format output data (returns string, does not print)
388
+ export function formatOutput(data, { pretty = false, table = false } = {}) {
389
+ if (table) {
390
+ if (data.success === false) {
391
+ return { type: 'error', text: `Error: ${data.error}` };
392
+ } else {
393
+ const tableData = data.data || data;
394
+ return { type: 'table', text: formatTable(tableData) };
395
+ }
396
+ } else if (pretty) {
397
+ return { type: 'json', text: JSON.stringify(data, null, 2) };
398
+ } else {
399
+ return { type: 'json', text: JSON.stringify(data) };
400
+ }
401
+ }
402
+
403
+ // Format error data (returns object, does not exit)
404
+ export function formatError(error) {
405
+ return {
406
+ success: false,
407
+ error: error.message,
408
+ code: error.code || 'UNKNOWN',
409
+ status: error.status || null,
410
+ details: error.data || null
411
+ };
412
+ }
413
+
414
+ // Parse simple sort syntax: "field:direction" or "field" (defaults to DESC)
415
+ export function parseSort(sortOption, orderByOption) {
416
+ // If --order-by is provided, use it (full JSON control)
417
+ if (orderByOption) return orderByOption;
418
+
419
+ // If no --sort, return undefined
420
+ if (!sortOption) return undefined;
421
+
422
+ // Parse --sort field:direction or --sort field
423
+ const parts = sortOption.split(':');
424
+ const field = parts[0];
425
+ const direction = (parts[1] || 'desc').toUpperCase();
426
+
427
+ return [{ field, direction }];
428
+ }
429
+
430
+ // Help text
431
+ export const HELP = `
432
+ Nansen CLI - Command-line interface for Nansen API
433
+ Designed for AI agents with structured JSON output.
434
+
435
+ USAGE:
436
+ nansen <command> [subcommand] [options]
437
+
438
+ COMMANDS:
439
+ login Save your API key (interactive)
440
+ logout Remove saved API key
441
+ schema Output JSON schema for all commands (for agent introspection)
442
+ smart-money Smart Money analytics (netflow, dex-trades, holdings, dcas, historical-holdings)
443
+ profiler Wallet profiling (balance, labels, transactions, pnl, perp-positions, perp-trades)
444
+ token Token God Mode (screener, holders, flows, trades, pnl, perp-trades, perp-positions)
445
+ portfolio Portfolio analytics (defi-holdings)
446
+ help Show this help message
447
+
448
+ GLOBAL OPTIONS:
449
+ --pretty Format JSON output for readability
450
+ --table Format output as human-readable table
451
+ --fields Comma-separated list of fields to include (e.g., --fields address,value_usd)
452
+ --chain Blockchain to query (ethereum, solana, base, etc.)
453
+ --chains Multiple chains as JSON array
454
+ --limit Number of results (shorthand for pagination)
455
+ --filters JSON object with filters
456
+ --sort Sort by field (e.g., --sort value_usd:desc)
457
+ --order-by JSON array with sort order (advanced)
458
+ --days Date range in days (default: 30 for most endpoints)
459
+ --symbol Token symbol (for perp endpoints)
460
+ --no-retry Disable automatic retry on rate limits/errors
461
+ --retries <n> Max retry attempts (default: 3)
462
+
463
+ EXAMPLES:
464
+ # Get Smart Money netflow on Solana
465
+ nansen smart-money netflow --chain solana
466
+
467
+ # Get top tokens by Smart Money activity
468
+ nansen token screener --chain solana --timeframe 24h --pretty
469
+
470
+ # Get wallet balance
471
+ nansen profiler balance --address 0x123... --chain ethereum
472
+
473
+ # Get wallet labels
474
+ nansen profiler labels --address 0x123... --chain ethereum
475
+
476
+ # Search for entity
477
+ nansen profiler search --query "Vitalik"
478
+
479
+ # Get token holders with filters
480
+ nansen token holders --token 0x123... --filters '{"only_smart_money":true}'
481
+
482
+ SMART MONEY LABELS:
483
+ Fund, Smart Trader, 30D Smart Trader, 90D Smart Trader,
484
+ 180D Smart Trader, Smart HL Perps Trader
485
+
486
+ SUPPORTED CHAINS:
487
+ ethereum, solana, base, bnb, arbitrum, polygon, optimism,
488
+ avalanche, linea, scroll, zksync, mantle, ronin, sei,
489
+ plasma, sonic, unichain, monad, hyperevm, iotaevm
490
+
491
+ For more info: https://docs.nansen.ai
492
+ `;
493
+
494
+ // Helper to prompt for input (exported for mocking)
495
+ export async function prompt(question, hidden = false) {
496
+ const rl = readline.createInterface({
497
+ input: process.stdin,
498
+ output: process.stdout
499
+ });
500
+
501
+ return new Promise((resolve) => {
502
+ if (hidden && process.stdout.isTTY) {
503
+ process.stdout.write(question);
504
+ let input = '';
505
+ process.stdin.setRawMode(true);
506
+ process.stdin.resume();
507
+ process.stdin.setEncoding('utf8');
508
+
509
+ const onData = (char) => {
510
+ if (char === '\n' || char === '\r') {
511
+ process.stdin.setRawMode(false);
512
+ process.stdin.pause();
513
+ process.stdin.removeListener('data', onData);
514
+ process.stdout.write('\n');
515
+ rl.close();
516
+ resolve(input);
517
+ } else if (char === '\u0003') {
518
+ // Ctrl+C
519
+ process.exit();
520
+ } else if (char === '\u007F' || char === '\b') {
521
+ // Backspace
522
+ if (input.length > 0) {
523
+ input = input.slice(0, -1);
524
+ process.stdout.write('\b \b');
525
+ }
526
+ } else {
527
+ input += char;
528
+ process.stdout.write('*');
529
+ }
530
+ };
531
+
532
+ process.stdin.on('data', onData);
533
+ } else {
534
+ rl.question(question, (answer) => {
535
+ rl.close();
536
+ resolve(answer);
537
+ });
538
+ }
539
+ });
540
+ }
541
+
542
+ // Build command handlers (returns object with handler functions)
543
+ export function buildCommands(deps = {}) {
544
+ // Allow dependency injection for testing
545
+ const {
546
+ api = null,
547
+ promptFn = prompt,
548
+ log = console.log,
549
+ NansenAPIClass = NansenAPI,
550
+ saveConfigFn = saveConfig,
551
+ deleteConfigFn = deleteConfig,
552
+ getConfigFileFn = getConfigFile,
553
+ exit = process.exit
554
+ } = deps;
555
+
556
+ return {
557
+ 'login': async (args, apiInstance, flags, options) => {
558
+ log('Nansen CLI Login\n');
559
+ log('Get your API key at: https://app.nansen.ai/api\n');
560
+
561
+ const apiKey = await promptFn('Enter your API key: ', true);
562
+
563
+ if (!apiKey || apiKey.trim().length === 0) {
564
+ log('\n❌ No API key provided');
565
+ exit(1);
566
+ return;
567
+ }
568
+
569
+ // Validate the key with a test request
570
+ log('\nValidating API key...');
571
+ try {
572
+ const testApi = new NansenAPIClass(apiKey.trim());
573
+ await testApi.tokenScreener({ chains: ['solana'], pagination: { page: 1, per_page: 1 } });
574
+
575
+ // Save the config
576
+ saveConfigFn({
577
+ apiKey: apiKey.trim(),
578
+ baseUrl: 'https://api.nansen.ai'
579
+ });
580
+
581
+ log('✓ API key validated');
582
+ log(`✓ Saved to ${getConfigFileFn()}\n`);
583
+ log('You can now use the Nansen CLI. Try:');
584
+ log(' nansen token screener --chain solana --pretty');
585
+ } catch (error) {
586
+ log(`\n❌ Invalid API key: ${error.message}`);
587
+ exit(1);
588
+ }
589
+ },
590
+
591
+ 'logout': async (args, apiInstance, flags, options) => {
592
+ const deleted = deleteConfigFn();
593
+ if (deleted) {
594
+ log(`✓ Removed ${getConfigFileFn()}`);
595
+ } else {
596
+ log('No saved credentials found');
597
+ }
598
+ },
599
+
600
+ 'help': async (args, apiInstance, flags, options) => {
601
+ log(HELP);
602
+ },
603
+
604
+ 'schema': async (args, apiInstance, flags, options) => {
605
+ // Return schema for agent introspection
606
+ const subcommand = args[0];
607
+
608
+ if (subcommand && SCHEMA.commands[subcommand]) {
609
+ // Return schema for specific command
610
+ return {
611
+ command: subcommand,
612
+ ...SCHEMA.commands[subcommand],
613
+ globalOptions: SCHEMA.globalOptions,
614
+ chains: SCHEMA.chains,
615
+ smartMoneyLabels: SCHEMA.smartMoneyLabels
616
+ };
617
+ }
618
+
619
+ // Return full schema
620
+ return SCHEMA;
621
+ },
622
+
623
+ 'smart-money': async (args, apiInstance, flags, options) => {
624
+ const subcommand = args[0] || 'help';
625
+ const chain = options.chain || 'solana';
626
+ const chains = options.chains || [chain];
627
+ const filters = options.filters || {};
628
+ const orderBy = parseSort(options.sort, options['order-by']);
629
+ const pagination = options.limit ? { page: 1, per_page: options.limit } : undefined;
630
+
631
+ // Add smart money label filter if specified
632
+ if (options.labels) {
633
+ filters.include_smart_money_labels = Array.isArray(options.labels)
634
+ ? options.labels
635
+ : [options.labels];
636
+ }
637
+
638
+ const days = options.days ? parseInt(options.days) : 30;
639
+
640
+ const handlers = {
641
+ 'netflow': () => apiInstance.smartMoneyNetflow({ chains, filters, orderBy, pagination }),
642
+ 'dex-trades': () => apiInstance.smartMoneyDexTrades({ chains, filters, orderBy, pagination }),
643
+ 'perp-trades': () => apiInstance.smartMoneyPerpTrades({ filters, orderBy, pagination }),
644
+ 'holdings': () => apiInstance.smartMoneyHoldings({ chains, filters, orderBy, pagination }),
645
+ 'dcas': () => apiInstance.smartMoneyDcas({ filters, orderBy, pagination }),
646
+ 'historical-holdings': () => apiInstance.smartMoneyHistoricalHoldings({ chains, filters, orderBy, pagination, days }),
647
+ 'help': () => ({
648
+ commands: ['netflow', 'dex-trades', 'perp-trades', 'holdings', 'dcas', 'historical-holdings'],
649
+ description: 'Smart Money analytics endpoints',
650
+ example: 'nansen smart-money netflow --chain solana --labels Fund'
651
+ })
652
+ };
653
+
654
+ if (!handlers[subcommand]) {
655
+ return { error: `Unknown subcommand: ${subcommand}`, available: Object.keys(handlers) };
656
+ }
657
+
658
+ return handlers[subcommand]();
659
+ },
660
+
661
+ 'profiler': async (args, apiInstance, flags, options) => {
662
+ const subcommand = args[0] || 'help';
663
+ const address = options.address;
664
+ const entityName = options.entity || options['entity-name'];
665
+ const chain = options.chain || 'ethereum';
666
+ const filters = options.filters || {};
667
+ const orderBy = parseSort(options.sort, options['order-by']);
668
+ const pagination = options.limit ? { page: 1, recordsPerPage: options.limit } : undefined;
669
+ const days = options.days ? parseInt(options.days) : 30;
670
+
671
+ const handlers = {
672
+ 'balance': () => apiInstance.addressBalance({ address, entityName, chain, filters, orderBy }),
673
+ 'labels': () => apiInstance.addressLabels({ address, chain, pagination }),
674
+ 'transactions': () => apiInstance.addressTransactions({ address, chain, filters, orderBy, pagination }),
675
+ 'pnl': () => apiInstance.addressPnl({ address, chain }),
676
+ 'search': () => apiInstance.entitySearch({ query: options.query, pagination }),
677
+ 'historical-balances': () => apiInstance.addressHistoricalBalances({ address, chain, filters, orderBy, pagination, days }),
678
+ 'related-wallets': () => apiInstance.addressRelatedWallets({ address, chain, filters, orderBy, pagination }),
679
+ 'counterparties': () => apiInstance.addressCounterparties({ address, chain, filters, orderBy, pagination, days }),
680
+ 'pnl-summary': () => apiInstance.addressPnlSummary({ address, chain, filters, orderBy, pagination, days }),
681
+ 'perp-positions': () => apiInstance.addressPerpPositions({ address, filters, orderBy, pagination }),
682
+ 'perp-trades': () => apiInstance.addressPerpTrades({ address, filters, orderBy, pagination, days }),
683
+ 'help': () => ({
684
+ commands: ['balance', 'labels', 'transactions', 'pnl', 'search', 'historical-balances', 'related-wallets', 'counterparties', 'pnl-summary', 'perp-positions', 'perp-trades'],
685
+ description: 'Wallet profiling endpoints',
686
+ example: 'nansen profiler balance --address 0x123... --chain ethereum'
687
+ })
688
+ };
689
+
690
+ if (!handlers[subcommand]) {
691
+ return { error: `Unknown subcommand: ${subcommand}`, available: Object.keys(handlers) };
692
+ }
693
+
694
+ return handlers[subcommand]();
695
+ },
696
+
697
+ 'token': async (args, apiInstance, flags, options) => {
698
+ const subcommand = args[0] || 'help';
699
+ const tokenAddress = options.token || options['token-address'];
700
+ const tokenSymbol = options.symbol || options['token-symbol'];
701
+ const chain = options.chain || 'solana';
702
+ const chains = options.chains || [chain];
703
+ const timeframe = options.timeframe || '24h';
704
+ const filters = options.filters || {};
705
+ const orderBy = parseSort(options.sort, options['order-by']);
706
+ const pagination = options.limit ? { page: 1, per_page: options.limit } : undefined;
707
+ const days = options.days ? parseInt(options.days) : 30;
708
+
709
+ // Convenience filter for smart money only
710
+ const onlySmartMoney = options['smart-money'] || flags['smart-money'] || false;
711
+ if (onlySmartMoney) {
712
+ filters.only_smart_money = true;
713
+ }
714
+
715
+ const handlers = {
716
+ 'screener': () => apiInstance.tokenScreener({ chains, timeframe, filters, orderBy, pagination }),
717
+ 'holders': () => apiInstance.tokenHolders({ tokenAddress, chain, filters, orderBy, pagination }),
718
+ 'flows': () => apiInstance.tokenFlows({ tokenAddress, chain, filters, orderBy, pagination }),
719
+ 'dex-trades': () => apiInstance.tokenDexTrades({ tokenAddress, chain, onlySmartMoney, filters, orderBy, pagination, days }),
720
+ 'pnl': () => apiInstance.tokenPnlLeaderboard({ tokenAddress, chain, filters, orderBy, pagination, days }),
721
+ 'who-bought-sold': () => apiInstance.tokenWhoBoughtSold({ tokenAddress, chain, filters, orderBy, pagination }),
722
+ 'flow-intelligence': () => apiInstance.tokenFlowIntelligence({ tokenAddress, chain, filters, orderBy, pagination }),
723
+ 'transfers': () => apiInstance.tokenTransfers({ tokenAddress, chain, filters, orderBy, pagination, days }),
724
+ 'jup-dca': () => apiInstance.tokenJupDca({ tokenAddress, filters, orderBy, pagination }),
725
+ 'perp-trades': () => apiInstance.tokenPerpTrades({ tokenSymbol, filters, orderBy, pagination, days }),
726
+ 'perp-positions': () => apiInstance.tokenPerpPositions({ tokenSymbol, filters, orderBy, pagination }),
727
+ 'perp-pnl-leaderboard': () => apiInstance.tokenPerpPnlLeaderboard({ tokenSymbol, filters, orderBy, pagination, days }),
728
+ 'help': () => ({
729
+ commands: ['screener', 'holders', 'flows', 'dex-trades', 'pnl', 'who-bought-sold', 'flow-intelligence', 'transfers', 'jup-dca', 'perp-trades', 'perp-positions', 'perp-pnl-leaderboard'],
730
+ description: 'Token God Mode endpoints',
731
+ example: 'nansen token screener --chain solana --timeframe 24h --smart-money'
732
+ })
733
+ };
734
+
735
+ if (!handlers[subcommand]) {
736
+ return { error: `Unknown subcommand: ${subcommand}`, available: Object.keys(handlers) };
737
+ }
738
+
739
+ return handlers[subcommand]();
740
+ },
741
+
742
+ 'portfolio': async (args, apiInstance, flags, options) => {
743
+ const subcommand = args[0] || 'help';
744
+ const walletAddress = options.wallet || options.address;
745
+
746
+ const handlers = {
747
+ 'defi': () => apiInstance.portfolioDefiHoldings({ walletAddress }),
748
+ 'defi-holdings': () => apiInstance.portfolioDefiHoldings({ walletAddress }),
749
+ 'help': () => ({
750
+ commands: ['defi', 'defi-holdings'],
751
+ description: 'Portfolio analytics endpoints',
752
+ example: 'nansen portfolio defi --wallet 0x123...'
753
+ })
754
+ };
755
+
756
+ if (!handlers[subcommand]) {
757
+ return { error: `Unknown subcommand: ${subcommand}`, available: Object.keys(handlers) };
758
+ }
759
+
760
+ return handlers[subcommand]();
761
+ }
762
+ };
763
+ }
764
+
765
+ // Commands that don't require API authentication
766
+ export const NO_AUTH_COMMANDS = ['login', 'logout', 'help', 'schema'];
767
+
768
+ // Run CLI with given args (returns result, allows custom output/exit handlers)
769
+ export async function runCLI(rawArgs, deps = {}) {
770
+ const {
771
+ output = console.log,
772
+ errorOutput = console.error,
773
+ exit = process.exit,
774
+ NansenAPIClass = NansenAPI,
775
+ commandOverrides = {}
776
+ } = deps;
777
+
778
+ const { _: positional, flags, options } = parseArgs(rawArgs);
779
+
780
+ const command = positional[0] || 'help';
781
+ const subArgs = positional.slice(1);
782
+ const pretty = flags.pretty || flags.p;
783
+ const table = flags.table || flags.t;
784
+
785
+ const commands = { ...buildCommands(deps), ...commandOverrides };
786
+
787
+ if (command === 'help' || flags.help || flags.h) {
788
+ output(HELP);
789
+ return { type: 'help' };
790
+ }
791
+
792
+ if (!commands[command]) {
793
+ const errorData = {
794
+ error: `Unknown command: ${command}`,
795
+ available: Object.keys(commands)
796
+ };
797
+ const formatted = formatOutput(errorData, { pretty, table });
798
+ output(formatted.text);
799
+ exit(1);
800
+ return { type: 'error', data: errorData };
801
+ }
802
+
803
+ // Commands that don't require API authentication
804
+ if (NO_AUTH_COMMANDS.includes(command)) {
805
+ const result = await commands[command](subArgs, null, flags, options);
806
+
807
+ // Schema command returns data that should be output
808
+ if (command === 'schema' && result) {
809
+ const formatted = formatOutput(result, { pretty, table: false });
810
+ output(formatted.text);
811
+ return { type: 'schema', data: result };
812
+ }
813
+
814
+ return { type: 'no-auth', command };
815
+ }
816
+
817
+ try {
818
+ // Configure retry options
819
+ const retryOptions = flags['no-retry']
820
+ ? { maxRetries: 0 }
821
+ : { maxRetries: options.retries !== undefined ? options.retries : 3 };
822
+
823
+ const api = new NansenAPIClass(undefined, undefined, { retry: retryOptions });
824
+ let result = await commands[command](subArgs, api, flags, options);
825
+
826
+ // Apply field filtering if --fields is specified
827
+ const fields = parseFields(options.fields);
828
+ if (fields) {
829
+ result = filterFields(result, fields);
830
+ }
831
+
832
+ const successData = { success: true, data: result };
833
+ const formatted = formatOutput(successData, { pretty, table });
834
+ output(formatted.text);
835
+ return { type: 'success', data: result };
836
+ } catch (error) {
837
+ const errorData = formatError(error);
838
+ const formatted = formatOutput(errorData, { pretty, table });
839
+ errorOutput(formatted.text);
840
+ exit(1);
841
+ return { type: 'error', data: errorData };
842
+ }
843
+ }