nansen-cli 1.43.0 → 1.44.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 CHANGED
@@ -12,8 +12,12 @@ import { buildLimitOrderCommands } from './limit-order.js';
12
12
  import { formatAlertsTable, buildAlertsCommands } from './commands/alerts.js';
13
13
  import { buildAgentCommands } from './commands/agent.js';
14
14
  import { buildMcpCommands } from './commands/mcp.js';
15
- import { buildResearchCommands, RESEARCH_HISTORICAL_SUBCOMMANDS } from './commands/research.js';
15
+ import { buildCompletionCommands } from './commands/completion.js';
16
+ import { buildResearchCommands, RESEARCH_HISTORICAL_SUBCOMMANDS, RESEARCH_SUBCOMMANDS } from './commands/research.js';
17
+ import { buildPagination, parseSort } from './query-options.js';
18
+ export { buildPagination, parseSort };
16
19
  import { resolveAddress, isEnsName } from './ens.js';
20
+ import { compareSemver } from './semver.js';
17
21
  import fs from 'fs';
18
22
  import { getUpdateNotification, getUpgradeNotice, scheduleUpdateCheck } from './update-check.js';
19
23
  import { getAuthStatus, runDoctorChecks, runConnectivityChecks, formatDoctorReport } from './doctor.js';
@@ -54,14 +58,6 @@ export function resolveBooleanOption(options, flags, key) {
54
58
  return undefined;
55
59
  }
56
60
 
57
- export function buildPagination(options) {
58
- if (!options.limit && !options.page) return undefined;
59
- return {
60
- page: Math.max(1, parseInt(options.page, 10) || 1),
61
- per_page: options.limit,
62
- };
63
- }
64
-
65
61
  // ============= Field Filtering =============
66
62
 
67
63
  /**
@@ -168,18 +164,15 @@ export function compactSchema(schema) {
168
164
  };
169
165
  }
170
166
 
171
- /**
172
- * Compare two semver strings. Returns 1 if a > b, -1 if a < b, 0 if equal.
173
- */
174
- function compareSemver(a, b) {
175
- const parse = v => v.replace(/^v/, '').split('.').map(Number);
176
- const [aM, am, ap] = parse(a);
177
- const [bM, bm, bp] = parse(b);
178
- if (aM !== bM) return aM > bM ? 1 : -1;
179
- if (am !== bm) return am > bm ? 1 : -1;
180
- if (ap !== bp) return ap > bp ? 1 : -1;
181
- return 0;
182
- }
167
+ // Long options that never consume the next argument. The shell-completion
168
+ // generator needs the same list to tell an option's value apart from a
169
+ // subcommand, so it lives here rather than inline in parseArgs.
170
+ export const VALUELESS_FLAGS = new Set([
171
+ 'pretty', 'help', 'version', 'table', 'no-retry', 'cache', 'no-cache', 'stream',
172
+ 'enrich', 'full', 'human', 'enabled', 'disabled', 'expert', 'json', 'offline',
173
+ 'no-simulate', 'no-verify-outcome', 'no-revoke-excessive-allowance', 'dry-run',
174
+ 'send-api-key',
175
+ ]);
183
176
 
184
177
  export function parseArgs(args) {
185
178
  const result = { _: [], flags: {}, options: {} };
@@ -191,7 +184,7 @@ export function parseArgs(args) {
191
184
  const key = arg.slice(2);
192
185
  const next = args[i + 1];
193
186
 
194
- 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' || key === 'expert' || key === 'json' || key === 'offline' || key === 'no-simulate' || key === 'no-verify-outcome' || key === 'no-revoke-excessive-allowance' || key === 'dry-run') {
187
+ if (VALUELESS_FLAGS.has(key)) {
195
188
  result.flags[key] = true;
196
189
  } else if (next && (!next.startsWith('-') || /^-\d/.test(next))) {
197
190
  // Try to parse as JSON first (for objects/arrays/booleans),
@@ -232,7 +225,11 @@ export function formatValue(val) {
232
225
  if (val === null || val === undefined) return '';
233
226
  if (typeof val === 'number') {
234
227
  if (Math.abs(val) >= 1000000) return (val / 1000000).toFixed(2) + 'M';
235
- if (Math.abs(val) >= 1000) return (val / 1000).toFixed(2) + 'K';
228
+ if (Math.abs(val) >= 1000) {
229
+ const formatted = (val / 1000).toFixed(2);
230
+ if (Math.abs(parseFloat(formatted)) >= 1000) return (val / 1000000).toFixed(2) + 'M';
231
+ return formatted + 'K';
232
+ }
236
233
  if (Number.isInteger(val)) return val.toString();
237
234
  return val.toFixed(2);
238
235
  }
@@ -456,22 +453,6 @@ export function parseDateOption(dateOption, days = 30) {
456
453
  return { from, to };
457
454
  }
458
455
 
459
- // Parse simple sort syntax: "field:direction" or "field" (defaults to DESC)
460
- export function parseSort(sortOption, orderByOption) {
461
- // If --order-by is provided, use it (full JSON control)
462
- if (orderByOption) return orderByOption;
463
-
464
- // If no --sort, return undefined
465
- if (!sortOption) return undefined;
466
-
467
- // Parse --sort field:direction or --sort field
468
- const parts = sortOption.split(':');
469
- const field = parts[0];
470
- const direction = (parts[1] || 'desc').toUpperCase();
471
-
472
- return [{ field, direction }];
473
- }
474
-
475
456
  // Enrich transfers with Nansen labels for from/to addresses
476
457
  async function enrichTransfers(result, apiInstance, chain) {
477
458
  const transfers = result?.data?.results || result?.transfers || result?.data || [];
@@ -742,6 +723,7 @@ COMMANDS:
742
723
  logout Remove saved API key
743
724
  doctor Diagnostics: auth, wallets, caches, connectivity (--offline --json)
744
725
  schema JSON schema for all commands (use "nansen schema <cmd>" for one)
726
+ completion Shell completions: bash, zsh, fish
745
727
  cache clear
746
728
  changelog --since <version> to filter
747
729
 
@@ -781,7 +763,7 @@ Labels: Fund, Smart Trader, 30D/90D/180D Smart Trader, Smart HL Perps Trader
781
763
  Docs: https://docs.nansen.ai
782
764
  Skills: npx skills add nansen-ai/nansen-cli (agent-optimised docs per command group)
783
765
 
784
- Telemetry: anonymous usage stats (commands, timing, errors). Perp order/close additionally send the order side and Hyperliquid order id. Disable: DO_NOT_TRACK=1
766
+ Telemetry: anonymous usage stats (commands, timing, errors). Perp order/close additionally send each leg's side, outcome, order id, shared submission id, and a SHA-256 wallet identifier. Raw wallet, price, size, and exchange error text are not sent. Disable: DO_NOT_TRACK=1
785
767
  `;
786
768
 
787
769
  // Usage text for the `trade` command group. Shared by the trade handler and the
@@ -1128,6 +1110,15 @@ export function buildCommands(deps = {}) {
1128
1110
  }
1129
1111
  const since = _options.since;
1130
1112
  if (since) {
1113
+ // compareSemver treats a missing trailing component as 0, so accept
1114
+ // "1", "1.43", and "1.43.0" alike here — but anything that isn't
1115
+ // digits-and-dots (e.g. "abc") needs a clear error instead of
1116
+ // silently comparing as if it were version 0.0.0, which would show
1117
+ // every entry rather than flag the typo.
1118
+ if (!/^v?\d+(\.\d+){0,2}$/.test(String(since))) {
1119
+ log(`Invalid --since value "${since}": expected a version like 1.43 or 1.43.0.`);
1120
+ return;
1121
+ }
1131
1122
  // Show only entries from the given version onwards
1132
1123
  const lines = content.split('\n');
1133
1124
  const filtered = [];
@@ -1526,7 +1517,7 @@ export function buildCommands(deps = {}) {
1526
1517
  };
1527
1518
 
1528
1519
  if (!handlers[subcommand]) {
1529
- return { error: `Unknown subcommand: ${subcommand}`, available: Object.keys(handlers) };
1520
+ throw new NansenError(`Unknown perp analytics subcommand: ${subcommand}. Available: screener, leaderboard`, ErrorCode.UNKNOWN);
1530
1521
  }
1531
1522
 
1532
1523
  return handlers[subcommand]();
@@ -1583,7 +1574,7 @@ export function buildCommands(deps = {}) {
1583
1574
  const maxUniqueTraders24h = options['max-unique-traders-24h'] != null ? Number(options['max-unique-traders-24h']) : undefined;
1584
1575
  const minVolume24hr = options['min-volume-24hr'] != null ? Number(options['min-volume-24hr']) : undefined;
1585
1576
  const maxVolume24hr = options['max-volume-24hr'] != null ? Number(options['max-volume-24hr']) : undefined;
1586
- const negRisk = options['neg-risk'] != null ? options['neg-risk'] === 'true' : undefined;
1577
+ const negRisk = resolveBooleanOption(options, flags, 'neg-risk');
1587
1578
  const minOpenInterest = options['min-open-interest'] != null ? Number(options['min-open-interest']) : undefined;
1588
1579
  const maxOpenInterest = options['max-open-interest'] != null ? Number(options['max-open-interest']) : undefined;
1589
1580
  const endDateBefore = options['end-date-before'];
@@ -1627,32 +1618,33 @@ export function buildCommands(deps = {}) {
1627
1618
  // it, so it has to be taken exactly once, here.
1628
1619
  const perpAnalytics = cmds['perp'];
1629
1620
 
1630
- const researchHistorical = buildResearchCommands(deps).research;
1621
+ const researchSub = buildResearchCommands(deps).research;
1631
1622
 
1632
1623
  cmds['research'] = async (args, apiInstance, flags, options) => {
1633
1624
  const rawCategory = args[0];
1634
1625
  if (!rawCategory || rawCategory === 'help') {
1635
1626
  return {
1636
1627
  categories: [...RESEARCH_CATEGORIES],
1628
+ subcommands: [...RESEARCH_SUBCOMMANDS],
1637
1629
  historical: [...RESEARCH_HISTORICAL_SUBCOMMANDS],
1638
1630
  aliases: RESEARCH_CATEGORY_ALIASES,
1639
1631
  description: 'Research and analytics commands',
1640
1632
  example: 'nansen research smart-money netflow --chain solana'
1641
1633
  };
1642
1634
  }
1643
- if (RESEARCH_HISTORICAL_SUBCOMMANDS.has(rawCategory)) {
1644
- return researchHistorical(args, apiInstance, flags, options);
1635
+ if (RESEARCH_SUBCOMMANDS.has(rawCategory)) {
1636
+ return researchSub(args, apiInstance, flags, options);
1645
1637
  }
1646
1638
  const category = RESEARCH_CATEGORY_ALIASES[rawCategory] || rawCategory;
1647
1639
  if (!RESEARCH_CATEGORIES.has(category)) {
1648
- throw new NansenError(`Unknown research category: ${rawCategory}. Available: ${[...RESEARCH_CATEGORIES, ...RESEARCH_HISTORICAL_SUBCOMMANDS].join(', ')}`, ErrorCode.UNKNOWN);
1640
+ throw new NansenError(`Unknown research category: ${rawCategory}. Available: ${[...RESEARCH_CATEGORIES, ...RESEARCH_SUBCOMMANDS].join(', ')}`, ErrorCode.UNKNOWN);
1649
1641
  }
1650
1642
  // `research perp` reaches only the analytics half (screener/leaderboard) —
1651
- // the trading subcommands live at the top level. Routing its help through
1652
- // cmds['perp'] printed the trading help, advertising order/close/leverage
1653
- // from a command that can't run them.
1654
- if (category === 'perp' && (!args[1] || args[1] === 'help')) {
1655
- return perpAnalytics(['help'], apiInstance, flags, options);
1643
+ // the trading subcommands live at the top level. Use the captured handler
1644
+ // for every subcommand because cmds['perp'] is replaced below by the
1645
+ // combined top-level trading dispatcher.
1646
+ if (category === 'perp') {
1647
+ return perpAnalytics(args.slice(1), apiInstance, flags, options);
1656
1648
  }
1657
1649
  return cmds[category](args.slice(1), apiInstance, flags, options);
1658
1650
  };
@@ -1873,7 +1865,9 @@ export async function runCLI(rawArgs, deps = {}) {
1873
1865
  // contract covers the background update-check fetch and telemetry too,
1874
1866
  // not just the command's own requests.
1875
1867
  const isMcpUsage = command === 'mcp' && (subcommand !== 'verify' || flags.help || flags.h);
1876
- const isOfflineCommand = command === 'auth' || (command === 'doctor' && flags.offline) || isMcpUsage;
1868
+ // `completion` renders from the checked-in schema no network, and its
1869
+ // stdout is piped straight into a shell, so keep the update check out of it.
1870
+ const isOfflineCommand = command === 'auth' || (command === 'doctor' && flags.offline) || isMcpUsage || command === 'completion';
1877
1871
  const trackSucceeded = isOfflineCommand ? async () => {} : trackCommandSucceeded;
1878
1872
  const trackFailed = isOfflineCommand ? async () => {} : trackCommandFailed;
1879
1873
 
@@ -1895,7 +1889,7 @@ export async function runCLI(rawArgs, deps = {}) {
1895
1889
 
1896
1890
  // mcp prints its own output via `log`; runCLI callers inject their stdout
1897
1891
  // sink as `output`, so map it across (an explicit `log` dep still wins).
1898
- const commands = { ...buildCommands(deps), ...buildWalletCommands(deps), ...buildTradingCommands(deps), ...buildAlertsCommands(deps), ...buildAgentCommands(deps), ...buildMcpCommands({ ...deps, log: deps.log ?? output }), ...commandOverrides };
1892
+ const commands = { ...buildCommands(deps), ...buildWalletCommands(deps), ...buildTradingCommands(deps), ...buildAlertsCommands(deps), ...buildAgentCommands(deps), ...buildMcpCommands({ ...deps, log: deps.log ?? output }), ...buildCompletionCommands({ ...deps, log: deps.log ?? output }), ...commandOverrides };
1899
1893
 
1900
1894
  if (flags.version || flags.v) {
1901
1895
  output(VERSION);