nansen-cli 1.17.0 → 1.19.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 (39) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/package.json +5 -3
  3. package/skills/nansen-dca-tracker/SKILL.md +38 -0
  4. package/skills/nansen-defi-positions/SKILL.md +37 -0
  5. package/skills/nansen-exit-signals/SKILL.md +39 -0
  6. package/skills/nansen-fund-tracker/SKILL.md +35 -0
  7. package/skills/nansen-general-search/SKILL.md +34 -0
  8. package/skills/nansen-holder-analysis/SKILL.md +38 -0
  9. package/skills/nansen-perp-screener/SKILL.md +32 -0
  10. package/skills/nansen-perp-trader-profile/SKILL.md +39 -0
  11. package/skills/nansen-polymarket-deep-dive/SKILL.md +50 -0
  12. package/skills/nansen-polymarket-insider-scan/SKILL.md +62 -0
  13. package/skills/nansen-polymarket-trader-profile/SKILL.md +43 -0
  14. package/skills/nansen-portfolio-tracker/SKILL.md +36 -0
  15. package/skills/nansen-prediction-markets/SKILL.md +47 -0
  16. package/skills/nansen-sm-cross-chain-flows/SKILL.md +27 -0
  17. package/skills/nansen-smart-alerts/SKILL.md +137 -0
  18. package/skills/nansen-smart-money-alpha/SKILL.md +43 -0
  19. package/skills/nansen-smart-money-tracker/SKILL.md +71 -0
  20. package/skills/nansen-smart-money-trend/SKILL.md +30 -0
  21. package/skills/nansen-token-research/SKILL.md +90 -0
  22. package/skills/nansen-token-screener/SKILL.md +54 -0
  23. package/skills/nansen-token-transfer-analysis/SKILL.md +40 -0
  24. package/skills/nansen-trading/SKILL.md +100 -0
  25. package/skills/nansen-wallet-batch/SKILL.md +26 -0
  26. package/skills/nansen-wallet-clustering/REFERENCE.md +43 -0
  27. package/skills/nansen-wallet-clustering/SKILL.md +46 -0
  28. package/skills/nansen-wallet-deep-dive/SKILL.md +45 -0
  29. package/skills/nansen-wallet-keychain-migration/SKILL.md +183 -0
  30. package/skills/nansen-wallet-manager/SKILL.md +140 -0
  31. package/skills/nansen-wallet-profiler/SKILL.md +98 -0
  32. package/skills/nansen-web-fetcher/SKILL.md +50 -0
  33. package/skills/nansen-web-searcher/SKILL.md +39 -0
  34. package/src/api.js +76 -3
  35. package/src/cli.js +176 -14
  36. package/src/commands/alerts.js +717 -0
  37. package/src/schema.json +164 -1
  38. package/src/telemetry.js +237 -0
  39. package/src/update-check.js +2 -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,8 @@ 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
673
687
  account Show API key status, plan, and remaining credits
674
688
  login Save API key (--api-key <key> or NANSEN_API_KEY env var)
675
689
  logout Remove saved API key
@@ -693,6 +707,8 @@ Labels: Fund, Smart Trader, 30D/90D/180D Smart Trader, Smart HL Perps Trader
693
707
 
694
708
  Docs: https://docs.nansen.ai
695
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
696
712
  `;
697
713
 
698
714
  // Helper to prompt for input (exported for mocking)
@@ -762,6 +778,76 @@ export function buildCommands(deps = {}) {
762
778
  return apiInstance.getAccount();
763
779
  },
764
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
+
765
851
  'login': async (args, apiInstance, flags, options) => {
766
852
  if (flags.help || flags.h) {
767
853
  log('nansen login - Save your Nansen API key\n');
@@ -811,13 +897,48 @@ export function buildCommands(deps = {}) {
811
897
  return;
812
898
  }
813
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
814
929
  saveConfigFn({
815
930
  apiKey: apiKey.trim(),
816
931
  baseUrl: 'https://api.nansen.ai'
817
932
  });
818
933
 
819
934
  log(`✓ Saved to ${getConfigFileFn()}\n`);
820
- 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:');
821
942
  log(' nansen research token screener --chain solana --pretty');
822
943
  },
823
944
 
@@ -1448,7 +1569,7 @@ export async function runCLI(rawArgs, deps = {}) {
1448
1569
  if (updateNotification) errorOutput(updateNotification);
1449
1570
  };
1450
1571
 
1451
- const commands = { ...buildCommands(deps), ...buildWalletCommands(deps), ...buildTradingCommands(deps), ...commandOverrides };
1572
+ const commands = { ...buildCommands(deps), ...buildWalletCommands(deps), ...buildTradingCommands(deps), ...buildAlertsCommands(deps), ...commandOverrides };
1452
1573
 
1453
1574
  if (flags.version || flags.v) {
1454
1575
  output(VERSION);
@@ -1485,8 +1606,8 @@ export async function runCLI(rawArgs, deps = {}) {
1485
1606
  }
1486
1607
  }
1487
1608
  // First try subcommand help
1488
- // Skip for 'trade' — its handlers show their own rich usage when required args are missing
1489
- 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') {
1490
1611
  const subHelp = generateSubcommandHelp(command, subcommand);
1491
1612
  if (subHelp) {
1492
1613
  output(subHelp);
@@ -1495,8 +1616,8 @@ export async function runCLI(rawArgs, deps = {}) {
1495
1616
  }
1496
1617
  }
1497
1618
  // Then try command-level help (list subcommands)
1498
- // Skip for 'trade' — let the handler show its own usage
1499
- 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]);
1500
1621
  if (command && cmdSchemaLookup) {
1501
1622
  const cmdSchema = cmdSchemaLookup;
1502
1623
  const lines = [`${command} — ${cmdSchema.description}`];
@@ -1504,6 +1625,19 @@ export async function runCLI(rawArgs, deps = {}) {
1504
1625
  lines.push('Subcommands: ' + Object.keys(cmdSchema.subcommands).join(', '));
1505
1626
  lines.push(`Use: nansen ${command} <subcommand> --help`);
1506
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
+ }
1507
1641
  output(lines.join('\n'));
1508
1642
  notify();
1509
1643
  return { type: 'command-help', command };
@@ -1531,13 +1665,22 @@ export async function runCLI(rawArgs, deps = {}) {
1531
1665
  }
1532
1666
  }
1533
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
+
1534
1676
  if (!commands[command]) {
1535
- const errorData = {
1677
+ const errorData = {
1536
1678
  error: `Unknown command: ${command}`,
1537
1679
  available: Object.keys(commands)
1538
1680
  };
1539
1681
  const formatted = formatOutput(errorData, { pretty, table });
1540
1682
  output(formatted.text);
1683
+ trackCommandFailed({ command: fullCommand, duration_ms: Date.now() - startTime, error_code: 'UNKNOWN_COMMAND', flags: usedFlags, chain });
1541
1684
  notify();
1542
1685
  exit(1);
1543
1686
  return { type: 'error', data: errorData };
@@ -1565,6 +1708,7 @@ export async function runCLI(rawArgs, deps = {}) {
1565
1708
 
1566
1709
  // Commands that handle their own output return undefined
1567
1710
  if (result === undefined) {
1711
+ trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
1568
1712
  notify();
1569
1713
  return { type: 'no-output', command };
1570
1714
  }
@@ -1573,6 +1717,7 @@ export async function runCLI(rawArgs, deps = {}) {
1573
1717
  if (command === 'schema') {
1574
1718
  const formatted = formatOutput(result, { pretty, table: false });
1575
1719
  output(formatted.text);
1720
+ trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
1576
1721
  notify();
1577
1722
  return { type: 'schema', data: result };
1578
1723
  }
@@ -1583,6 +1728,13 @@ export async function runCLI(rawArgs, deps = {}) {
1583
1728
  result = filterFields(result, fields);
1584
1729
  }
1585
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
+
1586
1738
  // Output in requested format
1587
1739
  if (stream) {
1588
1740
  // Stream mode: output each record as a JSON line (NDJSON)
@@ -1590,6 +1742,7 @@ export async function runCLI(rawArgs, deps = {}) {
1590
1742
  if (streamOutput) {
1591
1743
  output(streamOutput);
1592
1744
  }
1745
+ trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
1593
1746
  notify();
1594
1747
  return { type: 'stream', data: result };
1595
1748
  }
@@ -1597,12 +1750,21 @@ export async function runCLI(rawArgs, deps = {}) {
1597
1750
  const successData = { success: true, data: result };
1598
1751
  const formatted = formatOutput(successData, { pretty, table, csv });
1599
1752
  output(formatted.text);
1753
+ trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
1600
1754
  notify();
1601
1755
  return { type: csv ? 'csv' : 'success', data: result };
1602
1756
  } catch (error) {
1603
1757
  const errorData = formatError(error);
1604
1758
  const formatted = formatOutput(errorData, { pretty, table, csv });
1605
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
+ });
1606
1768
  notify();
1607
1769
  exit(1);
1608
1770
  return { type: 'error', data: errorData };