nansen-cli 1.16.1 → 1.18.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/package.json +2 -1
  3. package/skills/nansen-alerts/SKILL.md +137 -0
  4. package/skills/nansen-alpha-discovery/SKILL.md +43 -0
  5. package/skills/nansen-batch-wallet/SKILL.md +26 -0
  6. package/skills/nansen-cross-chain-flow/SKILL.md +27 -0
  7. package/skills/nansen-dca-watch/SKILL.md +38 -0
  8. package/skills/nansen-defi-exposure/SKILL.md +37 -0
  9. package/skills/nansen-exit-signal/SKILL.md +39 -0
  10. package/skills/nansen-fund-watch/SKILL.md +35 -0
  11. package/skills/nansen-holder-quality/SKILL.md +38 -0
  12. package/skills/nansen-perp-scan/SKILL.md +32 -0
  13. package/skills/nansen-perp-trader/SKILL.md +39 -0
  14. package/skills/nansen-pm-deep-dive/SKILL.md +50 -0
  15. package/skills/nansen-pm-insider-scan/SKILL.md +62 -0
  16. package/skills/nansen-polymarket-trader/SKILL.md +43 -0
  17. package/skills/nansen-portfolio-history/SKILL.md +36 -0
  18. package/skills/nansen-prediction-market/SKILL.md +47 -0
  19. package/skills/nansen-profiler/SKILL.md +98 -0
  20. package/skills/nansen-search/SKILL.md +34 -0
  21. package/skills/nansen-sm-trend/SKILL.md +30 -0
  22. package/skills/nansen-smart-money/SKILL.md +71 -0
  23. package/skills/nansen-token/SKILL.md +90 -0
  24. package/skills/nansen-token-discovery/SKILL.md +54 -0
  25. package/skills/nansen-token-forensics/SKILL.md +40 -0
  26. package/skills/nansen-trade/SKILL.md +100 -0
  27. package/skills/nansen-wallet/SKILL.md +140 -0
  28. package/skills/nansen-wallet-analysis/SKILL.md +45 -0
  29. package/skills/nansen-wallet-attribution/REFERENCE.md +43 -0
  30. package/skills/nansen-wallet-attribution/SKILL.md +46 -0
  31. package/skills/nansen-wallet-migration/SKILL.md +183 -0
  32. package/skills/nansen-web-fetch/SKILL.md +50 -0
  33. package/skills/nansen-web-search/SKILL.md +39 -0
  34. package/src/api.js +144 -73
  35. package/src/cli.js +181 -14
  36. package/src/rpc-urls.js +29 -0
  37. package/src/schema.json +401 -1448
  38. package/src/telemetry.js +237 -0
  39. package/src/trading.js +26 -12
  40. package/src/transfer.js +1 -11
  41. package/src/update-check.js +2 -2
  42. package/src/wallet.js +2 -1
  43. package/src/x402.js +3 -2
package/src/cli.js CHANGED
@@ -6,9 +6,11 @@
6
6
  import { NansenAPI, NansenError, ErrorCode, saveConfig, deleteConfig, getConfigFile, clearCache, getCacheDir, validateAddress, sleep } from './api.js';
7
7
  import { buildWalletCommands } from './wallet.js';
8
8
  import { buildTradingCommands } from './trading.js';
9
+ import { formatAlertsTable, buildAlertsCommands } from './commands/alerts.js';
9
10
  import { resolveAddress, isEnsName } from './ens.js';
10
11
  import fs from 'fs';
11
12
  import { getUpdateNotification, getUpgradeNotice, scheduleUpdateCheck } from './update-check.js';
13
+ import { trackCommandSucceeded, trackCommandFailed } from './telemetry.js';
12
14
  import { createRequire } from 'module';
13
15
  import * as readline from 'readline';
14
16
 
@@ -163,19 +165,29 @@ export function parseArgs(args) {
163
165
  const key = arg.slice(2);
164
166
  const next = args[i + 1];
165
167
 
166
- if (key === 'pretty' || key === 'help' || key === 'version' || key === 'table' || key === 'no-retry' || key === 'cache' || key === 'no-cache' || key === 'stream' || key === 'enrich' || key === 'full' || key === 'human') {
168
+ if (key === 'pretty' || key === 'help' || key === 'version' || key === 'table' || key === 'no-retry' || key === 'cache' || key === 'no-cache' || key === 'stream' || key === 'enrich' || key === 'full' || key === 'human' || key === 'enabled' || key === 'disabled') {
167
169
  result.flags[key] = true;
168
- } else if (next && !next.startsWith('-')) {
170
+ } else if (next && (!next.startsWith('-') || /^-\d/.test(next))) {
169
171
  // Try to parse as JSON first (for objects/arrays/booleans),
170
172
  // but keep numeric strings as strings to avoid precision loss
171
173
  // and scientific notation for large integers (e.g. 1e+21).
174
+ let parsedValue;
172
175
  try {
173
176
  const parsed = JSON.parse(next);
174
- result.options[key] = typeof parsed === 'number' ? next : parsed;
177
+ parsedValue = typeof parsed === 'number' ? next : parsed;
175
178
  } catch {
176
- result.options[key] = next;
179
+ parsedValue = next;
177
180
  }
178
181
  i++;
182
+ // Accumulate repeated options into arrays (supports repeatable flags like --token, --subject)
183
+ if (key in result.options) {
184
+ if (!Array.isArray(result.options[key])) {
185
+ result.options[key] = [result.options[key]];
186
+ }
187
+ result.options[key].push(parsedValue);
188
+ } else {
189
+ result.options[key] = parsedValue;
190
+ }
179
191
  } else {
180
192
  result.flags[key] = true;
181
193
  }
@@ -226,7 +238,7 @@ export function formatTable(data) {
226
238
  // Get columns from first record, prioritize common useful fields
227
239
  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'];
228
240
  const allKeys = [...new Set(records.flatMap(r => Object.keys(r)))];
229
-
241
+
230
242
  // Sort: priority fields first, then alphabetically
231
243
  const columns = allKeys.sort((a, b) => {
232
244
  const aIdx = priorityFields.indexOf(a);
@@ -250,12 +262,12 @@ export function formatTable(data) {
250
262
  // Build table
251
263
  const separator = '─';
252
264
  const lines = [];
253
-
265
+
254
266
  // Header
255
267
  const header = columns.map((col, i) => col.padEnd(widths[i])).join(' │ ');
256
268
  lines.push(header);
257
269
  lines.push(widths.map(w => separator.repeat(w)).join('─┼─'));
258
-
270
+
259
271
  // Rows
260
272
  for (const record of records.slice(0, 50)) { // Limit to 50 rows
261
273
  const row = columns.map((col, i) => {
@@ -670,6 +682,9 @@ COMMANDS:
670
682
  research smart-money, profiler, token, search, perp, portfolio, points
671
683
  trade quote, execute
672
684
  wallet create, list, show, export, default, delete, forget-password
685
+ alerts list, create, update, toggle, delete
686
+ web search, fetch
687
+ account Show API key status, plan, and remaining credits
673
688
  login Save API key (--api-key <key> or NANSEN_API_KEY env var)
674
689
  logout Remove saved API key
675
690
  schema JSON schema for all commands (use "nansen schema <cmd>" for one)
@@ -692,6 +707,8 @@ Labels: Fund, Smart Trader, 30D/90D/180D Smart Trader, Smart HL Perps Trader
692
707
 
693
708
  Docs: https://docs.nansen.ai
694
709
  Skills: npx skills add nansen-ai/nansen-cli (agent-optimised docs per command group)
710
+
711
+ Telemetry: anonymous usage stats collected. Disable: DO_NOT_TRACK=1
695
712
  `;
696
713
 
697
714
  // Helper to prompt for input (exported for mocking)
@@ -757,6 +774,80 @@ export function buildCommands(deps = {}) {
757
774
  } = deps;
758
775
 
759
776
  const cmds = {
777
+ 'account': async (_args, apiInstance, _flags, _options) => {
778
+ return apiInstance.getAccount();
779
+ },
780
+
781
+ 'web': async (args, apiInstance, flags, options) => {
782
+ const subcommand = args[0] || 'help';
783
+ const subArgs = args.slice(1);
784
+
785
+ const handlers = {
786
+ 'search': async () => {
787
+ // Accept queries as positional args or --query (repeated)
788
+ let queries = subArgs.length > 0 ? subArgs : [];
789
+ if (options.query) {
790
+ const fromOption = Array.isArray(options.query) ? options.query : [options.query];
791
+ queries = queries.concat(fromOption);
792
+ }
793
+ queries = queries.filter(q => q.trim());
794
+ if (queries.length === 0) {
795
+ throw new NansenError('At least one query is required. Usage: nansen web search "bitcoin price" --num-results 5', ErrorCode.MISSING_PARAM);
796
+ }
797
+ let numResults;
798
+ if (options['num-results'] !== undefined) {
799
+ const numResultsRaw = parseInt(options['num-results'], 10);
800
+ if (Number.isNaN(numResultsRaw)) {
801
+ // Non-numeric — fall back to API default
802
+ numResults = undefined;
803
+ } else if (numResultsRaw >= 1 && numResultsRaw <= 20) {
804
+ numResults = numResultsRaw;
805
+ } else {
806
+ throw new NansenError('--num-results must be between 1 and 20', ErrorCode.INVALID_PARAMS);
807
+ }
808
+ }
809
+ return apiInstance.webSearch({ queries, numResults });
810
+ },
811
+
812
+ 'fetch': async () => {
813
+ // Accept URLs as positional args or --url (repeated)
814
+ let urls = subArgs.length > 0 ? subArgs : [];
815
+ if (options.url) {
816
+ const fromOption = Array.isArray(options.url) ? options.url : [options.url];
817
+ urls = urls.concat(fromOption);
818
+ }
819
+ if (urls.length === 0) {
820
+ throw new NansenError('At least one URL is required. Usage: nansen web fetch https://example.com --question "What is this about?"', ErrorCode.MISSING_PARAM);
821
+ }
822
+ for (const u of urls) {
823
+ try { new URL(u); } catch {
824
+ throw new NansenError(`Invalid URL: "${u}". URLs must include a scheme, e.g. https://example.com`, ErrorCode.INVALID_PARAMS);
825
+ }
826
+ }
827
+ if (!options.question || !options.question.trim()) {
828
+ throw new NansenError('--question is required and cannot be blank. Usage: nansen web fetch https://example.com --question "What is this about?"', ErrorCode.MISSING_PARAM);
829
+ }
830
+ return apiInstance.webFetch({ urls, question: options.question });
831
+ },
832
+
833
+ 'help': async () => ({
834
+ subcommands: ['search', 'fetch'],
835
+ description: 'Web search and fetch commands',
836
+ examples: [
837
+ 'nansen web search "bitcoin price"',
838
+ 'nansen web search "solana news" --num-results 5',
839
+ 'nansen web fetch https://nansen.ai --question "What does Nansen do?"',
840
+ ],
841
+ }),
842
+ };
843
+
844
+ if (!handlers[subcommand]) {
845
+ throw new NansenError(`Unknown web subcommand: ${subcommand}. Available: search, fetch`, ErrorCode.UNKNOWN);
846
+ }
847
+
848
+ return handlers[subcommand]();
849
+ },
850
+
760
851
  'login': async (args, apiInstance, flags, options) => {
761
852
  if (flags.help || flags.h) {
762
853
  log('nansen login - Save your Nansen API key\n');
@@ -806,13 +897,48 @@ export function buildCommands(deps = {}) {
806
897
  return;
807
898
  }
808
899
 
900
+ // Verify API key before saving
901
+ const NansenAPIClass = _NansenAPIClass;
902
+ const testApi = new NansenAPIClass(apiKey.trim(), undefined, {
903
+ retry: { maxRetries: 2 },
904
+ cache: { enabled: false }
905
+ });
906
+
907
+ let accountInfo;
908
+ try {
909
+ accountInfo = await testApi.getAccount();
910
+ } catch (error) {
911
+ if (error.code === ErrorCode.UNAUTHORIZED) {
912
+ log(JSON.stringify({
913
+ error: 'INVALID_API_KEY',
914
+ message: 'The API key is not valid.',
915
+ resolution: ['Check your key at https://app.nansen.ai/api']
916
+ }));
917
+ } else {
918
+ log(JSON.stringify({
919
+ error: 'VERIFICATION_FAILED',
920
+ message: `Could not verify API key: ${error.message}`,
921
+ resolution: ['Check your internet connection', 'Try again']
922
+ }));
923
+ }
924
+ exit(1);
925
+ return;
926
+ }
927
+
928
+ // Key is valid - now save
809
929
  saveConfigFn({
810
930
  apiKey: apiKey.trim(),
811
931
  baseUrl: 'https://api.nansen.ai'
812
932
  });
813
933
 
814
934
  log(`✓ Saved to ${getConfigFileFn()}\n`);
815
- log('You can now use the Nansen CLI. Try:');
935
+ if (accountInfo?.plan) {
936
+ log(`Plan: ${accountInfo.plan}`);
937
+ }
938
+ if (accountInfo?.credits_remaining !== undefined) {
939
+ log(`Credits remaining: ${accountInfo.credits_remaining}`);
940
+ }
941
+ log('\nYou can now use the Nansen CLI. Try:');
816
942
  log(' nansen research token screener --chain solana --pretty');
817
943
  },
818
944
 
@@ -1443,7 +1569,7 @@ export async function runCLI(rawArgs, deps = {}) {
1443
1569
  if (updateNotification) errorOutput(updateNotification);
1444
1570
  };
1445
1571
 
1446
- const commands = { ...buildCommands(deps), ...buildWalletCommands(deps), ...buildTradingCommands(deps), ...commandOverrides };
1572
+ const commands = { ...buildCommands(deps), ...buildWalletCommands(deps), ...buildTradingCommands(deps), ...buildAlertsCommands(deps), ...commandOverrides };
1447
1573
 
1448
1574
  if (flags.version || flags.v) {
1449
1575
  output(VERSION);
@@ -1480,8 +1606,8 @@ export async function runCLI(rawArgs, deps = {}) {
1480
1606
  }
1481
1607
  }
1482
1608
  // First try subcommand help
1483
- // Skip for 'trade' — its handlers show their own rich usage when required args are missing
1484
- if (command && subcommand && command !== 'trade') {
1609
+ // Skip for 'trade'/'alerts'their handlers show their own rich usage
1610
+ if (command && subcommand && command !== 'trade' && command !== 'alerts') {
1485
1611
  const subHelp = generateSubcommandHelp(command, subcommand);
1486
1612
  if (subHelp) {
1487
1613
  output(subHelp);
@@ -1490,8 +1616,8 @@ export async function runCLI(rawArgs, deps = {}) {
1490
1616
  }
1491
1617
  }
1492
1618
  // Then try command-level help (list subcommands)
1493
- // Skip for 'trade' — let the handler show its own usage
1494
- const cmdSchemaLookup = command !== 'trade' && (SCHEMA.commands[command] || SCHEMA.commands.research.subcommands[command]);
1619
+ // Skip for 'trade'/'alerts' — let the handler show its own usage
1620
+ const cmdSchemaLookup = command !== 'trade' && command !== 'alerts' && (SCHEMA.commands[command] || SCHEMA.commands.research.subcommands[command]);
1495
1621
  if (command && cmdSchemaLookup) {
1496
1622
  const cmdSchema = cmdSchemaLookup;
1497
1623
  const lines = [`${command} — ${cmdSchema.description}`];
@@ -1499,6 +1625,19 @@ export async function runCLI(rawArgs, deps = {}) {
1499
1625
  lines.push('Subcommands: ' + Object.keys(cmdSchema.subcommands).join(', '));
1500
1626
  lines.push(`Use: nansen ${command} <subcommand> --help`);
1501
1627
  }
1628
+ if (cmdSchema.options) {
1629
+ const params = Object.entries(cmdSchema.options).map(([name, opt]) => {
1630
+ const parts = [`--${name}`];
1631
+ if (opt.required) parts[0] += '*';
1632
+ if (opt.default !== undefined) parts.push(`(default: ${opt.default})`);
1633
+ if (opt.description) parts.push(`— ${opt.description}`);
1634
+ return parts.join(' ');
1635
+ });
1636
+ lines.push(`\nOptions (* required):\n ${params.join('\n ')}`);
1637
+ }
1638
+ if (cmdSchema.examples?.length) {
1639
+ lines.push(`\nExamples:\n ${cmdSchema.examples.join('\n ')}`);
1640
+ }
1502
1641
  output(lines.join('\n'));
1503
1642
  notify();
1504
1643
  return { type: 'command-help', command };
@@ -1526,13 +1665,22 @@ export async function runCLI(rawArgs, deps = {}) {
1526
1665
  }
1527
1666
  }
1528
1667
 
1668
+ // ── Telemetry setup ──
1669
+ const startTime = Date.now();
1670
+ const fullCommand = subcommand ? `${command} ${subcommand}` : command;
1671
+ const flagNames = Object.keys(flags).filter(k => flags[k]).map(k => `--${k}`);
1672
+ const optionNames = Object.keys(options).map(k => `--${k}`);
1673
+ const usedFlags = [...flagNames, ...optionNames];
1674
+ const chain = options.chain || null;
1675
+
1529
1676
  if (!commands[command]) {
1530
- const errorData = {
1677
+ const errorData = {
1531
1678
  error: `Unknown command: ${command}`,
1532
1679
  available: Object.keys(commands)
1533
1680
  };
1534
1681
  const formatted = formatOutput(errorData, { pretty, table });
1535
1682
  output(formatted.text);
1683
+ trackCommandFailed({ command: fullCommand, duration_ms: Date.now() - startTime, error_code: 'UNKNOWN_COMMAND', flags: usedFlags, chain });
1536
1684
  notify();
1537
1685
  exit(1);
1538
1686
  return { type: 'error', data: errorData };
@@ -1560,6 +1708,7 @@ export async function runCLI(rawArgs, deps = {}) {
1560
1708
 
1561
1709
  // Commands that handle their own output return undefined
1562
1710
  if (result === undefined) {
1711
+ trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
1563
1712
  notify();
1564
1713
  return { type: 'no-output', command };
1565
1714
  }
@@ -1568,6 +1717,7 @@ export async function runCLI(rawArgs, deps = {}) {
1568
1717
  if (command === 'schema') {
1569
1718
  const formatted = formatOutput(result, { pretty, table: false });
1570
1719
  output(formatted.text);
1720
+ trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
1571
1721
  notify();
1572
1722
  return { type: 'schema', data: result };
1573
1723
  }
@@ -1578,6 +1728,13 @@ export async function runCLI(rawArgs, deps = {}) {
1578
1728
  result = filterFields(result, fields);
1579
1729
  }
1580
1730
 
1731
+ // Alerts list with --table uses custom table format
1732
+ if (command === 'alerts' && subcommand === 'list' && table) {
1733
+ output(formatAlertsTable(result));
1734
+ notify();
1735
+ return { type: 'success', data: result };
1736
+ }
1737
+
1581
1738
  // Output in requested format
1582
1739
  if (stream) {
1583
1740
  // Stream mode: output each record as a JSON line (NDJSON)
@@ -1585,6 +1742,7 @@ export async function runCLI(rawArgs, deps = {}) {
1585
1742
  if (streamOutput) {
1586
1743
  output(streamOutput);
1587
1744
  }
1745
+ trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
1588
1746
  notify();
1589
1747
  return { type: 'stream', data: result };
1590
1748
  }
@@ -1592,12 +1750,21 @@ export async function runCLI(rawArgs, deps = {}) {
1592
1750
  const successData = { success: true, data: result };
1593
1751
  const formatted = formatOutput(successData, { pretty, table, csv });
1594
1752
  output(formatted.text);
1753
+ trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
1595
1754
  notify();
1596
1755
  return { type: csv ? 'csv' : 'success', data: result };
1597
1756
  } catch (error) {
1598
1757
  const errorData = formatError(error);
1599
1758
  const formatted = formatOutput(errorData, { pretty, table, csv });
1600
1759
  output(formatted.text);
1760
+ trackCommandFailed({
1761
+ command: fullCommand,
1762
+ duration_ms: Date.now() - startTime,
1763
+ error_code: error.code || 'UNKNOWN',
1764
+ status: error.status || null,
1765
+ flags: usedFlags,
1766
+ chain,
1767
+ });
1601
1768
  notify();
1602
1769
  exit(1);
1603
1770
  return { type: 'error', data: errorData };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Single source of truth for chain RPC endpoints.
3
+ *
4
+ * Both trading.js and transfer.js read from here so that:
5
+ * (a) adding a new chain only requires one edit, and
6
+ * (b) env-var overrides work consistently across all commands.
7
+ *
8
+ * Override env vars:
9
+ * NANSEN_EVM_RPC Custom Ethereum RPC (also used as generic EVM fallback)
10
+ * NANSEN_BASE_RPC Custom Base RPC
11
+ * NANSEN_SOLANA_RPC Custom Solana RPC
12
+ *
13
+ * Backward-compat aliases (deprecated — prefer the forms above):
14
+ * NANSEN_RPC_BASE Old name for NANSEN_BASE_RPC; trading.js previously read this
15
+ * but transfer.js never did, so the two commands were inconsistent.
16
+ * Both forms are now accepted here so existing .env files keep
17
+ * working while new code uses the standardised NANSEN_BASE_RPC name.
18
+ */
19
+
20
+ const DEFAULT_EVM_RPC = 'https://eth.public-rpc.com';
21
+ const DEFAULT_BASE_RPC = 'https://mainnet.base.org';
22
+ const DEFAULT_SOLANA_RPC = 'https://api.mainnet-beta.solana.com';
23
+
24
+ export const CHAIN_RPCS = {
25
+ ethereum: process.env.NANSEN_EVM_RPC || DEFAULT_EVM_RPC,
26
+ evm: process.env.NANSEN_EVM_RPC || DEFAULT_EVM_RPC, // generic EVM fallback
27
+ base: process.env.NANSEN_BASE_RPC || process.env.NANSEN_RPC_BASE || DEFAULT_BASE_RPC,
28
+ solana: process.env.NANSEN_SOLANA_RPC || DEFAULT_SOLANA_RPC,
29
+ };