nansen-cli 1.20.0 → 1.21.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/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.21.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#315](https://github.com/nansen-ai/nansen-cli/pull/315) [`908fa0c`](https://github.com/nansen-ai/nansen-cli/commit/908fa0ccdf18cb79b7efb58b9b31f66e0afedff6) Thanks [@TimNooren](https://github.com/TimNooren)! - Add `nansen agent` command for the Nansen AI research agent with fast/expert modes, SSE streaming, conversation continuation, and JSON output.
8
+
9
+ ### Patch Changes
10
+
11
+ - [#326](https://github.com/nansen-ai/nansen-cli/pull/326) [`1532ba4`](https://github.com/nansen-ai/nansen-cli/commit/1532ba420174ed7635a42641f0c1a2802077fdc0) Thanks [@TimNooren](https://github.com/TimNooren)! - Show API credit cost in research subcommand help text (fetched from OpenAPI spec, cached 24h).
12
+
13
+ - [#330](https://github.com/nansen-ai/nansen-cli/pull/330) [`a6b9b8f`](https://github.com/nansen-ai/nansen-cli/commit/a6b9b8fc7d291ee7375941bb275c224939338161) Thanks [@0xlaveen](https://github.com/0xlaveen)! - Suppress misleading PASSWORD_REQUIRED error when `--provider privy` is specified. Privy wallets don't need a password — only the Privy-specific credentials error is now shown when PRIVY_APP_ID/PRIVY_APP_SECRET are missing.
14
+
15
+ - [#329](https://github.com/nansen-ai/nansen-cli/pull/329) [`f047833`](https://github.com/nansen-ai/nansen-cli/commit/f047833c7e6fb55cb713e0b69f82282fe87d4566) Thanks [@TimNooren](https://github.com/TimNooren)! - Limit deprecation warnings and update notices to help output only, keeping stdout/stderr clean for programmatic usage.
16
+
17
+ - [#332](https://github.com/nansen-ai/nansen-cli/pull/332) [`e9b6de1`](https://github.com/nansen-ai/nansen-cli/commit/e9b6de17fdba3f46733dff026c2495c095bfbf35) Thanks [@0xlaveen](https://github.com/0xlaveen)! - docs: add trading examples and Privy wallet setup to README
18
+
3
19
  ## 1.20.0
4
20
 
5
21
  ### Minor Changes
package/README.md CHANGED
@@ -36,10 +36,35 @@ nansen schema [command] [--pretty] # full command reference (no API key neede
36
36
 
37
37
  **Trade:** `quote`, `execute` — DEX swaps on Solana and Base.
38
38
 
39
- **Wallet:** `create`, `list`, `show`, `export`, `default`, `delete`, `send` — local keypairs (EVM + Solana).
39
+ **Wallet:** `create`, `list`, `show`, `export`, `default`, `delete`, `send` — local or Privy server-side wallets (EVM + Solana).
40
40
 
41
41
  Run `nansen schema --pretty` for the full subcommand and field reference.
42
42
 
43
+ ## Trading
44
+
45
+ DEX swaps on `solana` and `base`. Two-step: quote then execute.
46
+
47
+ ```bash
48
+ nansen trade quote --chain solana --from SOL --to USDC --amount 1000000000
49
+ nansen trade execute --quote <quoteId>
50
+ ```
51
+
52
+ Amounts are in base units (lamports, wei). Common symbols (`SOL`, `ETH`, `USDC`, `USDT`) resolve automatically. A wallet is required — set one with `nansen wallet default <name>`.
53
+
54
+ ## Wallet
55
+
56
+ ```bash
57
+ nansen wallet create --name my-wallet # local keypair (EVM + Solana)
58
+ nansen wallet create --name my-wallet --provider privy # server-side via Privy
59
+ nansen wallet list
60
+ nansen wallet default <name>
61
+ nansen wallet send --wallet <name> --to <addr> --amount <n> --chain <chain>
62
+ ```
63
+
64
+ **Local wallets** are password-encrypted. Set `NANSEN_WALLET_PASSWORD` to skip the prompt.
65
+
66
+ **Privy wallets** are server-side — no password, no local key storage. Requires `PRIVY_APP_ID` and `PRIVY_APP_SECRET` env vars. Get credentials at [dashboard.privy.io](https://dashboard.privy.io).
67
+
43
68
  ## Key Options
44
69
 
45
70
  | Option | Description |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
4
4
  "description": "Command-line interface for Nansen API - designed for AI agents",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -58,7 +58,9 @@ nansen trade execute --quote "$quote_id"
58
58
  | ETH | Base | `0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee` |
59
59
  | USDC | Base | `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913` |
60
60
 
61
- ## Amounts are in base units
61
+ ## Amounts are in base units — NEVER USD
62
+
63
+ `--amount` accepts **integer base units only** (lamports, wei, etc). It is never a USD value.
62
64
 
63
65
  | Token | Decimals | 1 token = |
64
66
  |-------|----------|-----------|
@@ -66,6 +68,8 @@ nansen trade execute --quote "$quote_id"
66
68
  | ETH | 18 | `1000000000000000000` |
67
69
  | USDC | 6 | `1000000` |
68
70
 
71
+ If the user says "$20 worth of X", you must convert USD → token amount → base units. For example, to buy $20 of SOL at $150/SOL: $20 ÷ $150 = 0.1333 SOL = 133,300,000 lamports → `--amount 133300000`. Use a price lookup (e.g. `nansen research token info`) to get the current price first.
72
+
69
73
  ## Flags
70
74
 
71
75
  | Flag | Purpose |
@@ -109,6 +109,18 @@ If the `.credentials` file fallback is used, the CLI prints a warning on every o
109
109
 
110
110
  ## Create
111
111
 
112
+ ### Privy (server-side, no password)
113
+
114
+ ```bash
115
+ nansen wallet create --provider privy
116
+ # Or with a custom name:
117
+ nansen wallet create --name trading --provider privy
118
+ ```
119
+
120
+ Requires `PRIVY_APP_ID` + `PRIVY_APP_SECRET` env vars. No password needed.
121
+
122
+ ### Local (encrypted on disk, password required)
123
+
112
124
  ```bash
113
125
  # Ask the user for a password first, then:
114
126
  NANSEN_WALLET_PASSWORD="<password_from_user>" nansen wallet create
package/src/api.js CHANGED
@@ -11,12 +11,12 @@ import { getAnonymousId, TELEMETRY_DISABLED } from './telemetry.js';
11
11
 
12
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
13
 
14
- function telemetryHeaders() {
14
+ export function telemetryHeaders() {
15
15
  if (TELEMETRY_DISABLED) return {};
16
16
  return { 'X-Anonymous-Id': getAnonymousId() };
17
17
  }
18
18
 
19
- const { version: packageVersion } = JSON.parse(
19
+ export const { version: packageVersion } = JSON.parse(
20
20
  fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')
21
21
  );
22
22
 
@@ -85,7 +85,7 @@ export class NansenError extends Error {
85
85
  /**
86
86
  * Map HTTP status codes to error codes
87
87
  */
88
- function statusToErrorCode(status, data = {}) {
88
+ export function statusToErrorCode(status, data = {}) {
89
89
  const message = data?.message || data?.error || '';
90
90
  const messageLower = message.toLowerCase();
91
91
 
@@ -486,7 +486,7 @@ export class NansenAPI {
486
486
  return cached;
487
487
  }
488
488
  }
489
-
489
+
490
490
  let lastError;
491
491
 
492
492
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
package/src/cli.js CHANGED
@@ -7,9 +7,11 @@ import { NansenAPI, NansenError, ErrorCode, saveConfig, deleteConfig, getConfigF
7
7
  import { buildWalletCommands } from './wallet.js';
8
8
  import { buildTradingCommands } from './trading.js';
9
9
  import { formatAlertsTable, buildAlertsCommands } from './commands/alerts.js';
10
+ import { buildAgentCommands } from './commands/agent.js';
10
11
  import { resolveAddress, isEnsName } from './ens.js';
11
12
  import fs from 'fs';
12
13
  import { getUpdateNotification, getUpgradeNotice, scheduleUpdateCheck } from './update-check.js';
14
+ import { refreshCostMapIfStale, getCostForEndpoint } from './cost-cache.js';
13
15
  import { trackCommandSucceeded, trackCommandFailed } from './telemetry.js';
14
16
  import { createRequire } from 'module';
15
17
  import * as readline from 'readline';
@@ -165,7 +167,7 @@ export function parseArgs(args) {
165
167
  const key = arg.slice(2);
166
168
  const next = args[i + 1];
167
169
 
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') {
170
+ 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') {
169
171
  result.flags[key] = true;
170
172
  } else if (next && (!next.startsWith('-') || /^-\d/.test(next))) {
171
173
  // Try to parse as JSON first (for objects/arrays/booleans),
@@ -682,6 +684,7 @@ COMMANDS:
682
684
  research smart-money, profiler, token, search, perp, portfolio, points
683
685
  trade quote, execute
684
686
  wallet create, list, show, export, default, delete, forget-password
687
+ agent Ask the Nansen AI research agent (fast/expert modes)
685
688
  alerts list, create, update, toggle, delete
686
689
  web search, fetch
687
690
  account Show API key status, plan, and remaining credits
@@ -701,6 +704,10 @@ EXAMPLES:
701
704
  nansen research profiler balance --address 0x... --chain ethereum
702
705
  nansen trade quote --chain base --from ETH --to USDC --amount 1000000000000000000
703
706
 
707
+ DEPRECATED ALIASES (still work, will be removed in a future version):
708
+ smart-money, profiler, token, search, perp, portfolio, points → use "nansen research <command>"
709
+ quote, execute → use "nansen trade <command>"
710
+
704
711
  Research chains: ethereum, solana, base, bnb, arbitrum, polygon, optimism, avalanche, linea, scroll, mantle, ronin, sei, plasma, sonic, monad, hyperevm, iotaevm
705
712
  Trade chains: solana, base
706
713
  Labels: Fund, Smart Trader, 30D/90D/180D Smart Trader, Smart HL Perps Trader
@@ -1509,6 +1516,11 @@ export function generateSubcommandHelp(command, subcommand, prefix = null) {
1509
1516
  lines.push(`Params (* required): ${params.join(', ')}`);
1510
1517
  }
1511
1518
 
1519
+ if (subSchema.endpoint) {
1520
+ const cost = getCostForEndpoint(subSchema.endpoint);
1521
+ if (cost) lines.push(`Cost: ${cost.free} credit${cost.free === 1 ? '' : 's'} (Free tier) / ${cost.pro} credit${cost.pro === 1 ? '' : 's'} (Pro tier)`);
1522
+ }
1523
+
1512
1524
  if (subSchema.returns?.length) {
1513
1525
  lines.push(`Returns: ${subSchema.returns.join(', ')}`);
1514
1526
  }
@@ -1548,12 +1560,7 @@ export async function runCLI(rawArgs, deps = {}) {
1548
1560
  const subArgs = positional.slice(1);
1549
1561
  const subcommand = subArgs[0];
1550
1562
 
1551
- // Deprecation warnings for commands that moved under 'research' or 'trade'
1552
- if (DEPRECATED_TO_RESEARCH.has(command)) {
1553
- errorOutput(`Warning: "nansen ${command}" is deprecated. Use "nansen research ${command}" instead.`);
1554
- } else if (DEPRECATED_TO_TRADE.has(command)) {
1555
- errorOutput(`Warning: "nansen ${command}" is deprecated. Use "nansen trade ${command}" instead.`);
1556
- }
1563
+
1557
1564
 
1558
1565
  const pretty = flags.pretty || flags.p;
1559
1566
  const table = flags.table || flags.t;
@@ -1569,7 +1576,14 @@ export async function runCLI(rawArgs, deps = {}) {
1569
1576
  if (updateNotification) errorOutput(updateNotification);
1570
1577
  };
1571
1578
 
1572
- const commands = { ...buildCommands(deps), ...buildWalletCommands(deps), ...buildTradingCommands(deps), ...buildAlertsCommands(deps), ...commandOverrides };
1579
+ // Deprecation note for help output
1580
+ const deprecationNote = (cmd) => {
1581
+ if (DEPRECATED_TO_RESEARCH.has(cmd)) return `Note: "nansen ${cmd}" is deprecated. Use "nansen research ${cmd}" instead.\n\n`;
1582
+ if (DEPRECATED_TO_TRADE.has(cmd)) return `Note: "nansen ${cmd}" is deprecated. Use "nansen trade ${cmd}" instead.\n\n`;
1583
+ return '';
1584
+ };
1585
+
1586
+ const commands = { ...buildCommands(deps), ...buildWalletCommands(deps), ...buildTradingCommands(deps), ...buildAlertsCommands(deps), ...buildAgentCommands(deps), ...commandOverrides };
1573
1587
 
1574
1588
  if (flags.version || flags.v) {
1575
1589
  output(VERSION);
@@ -1577,6 +1591,7 @@ export async function runCLI(rawArgs, deps = {}) {
1577
1591
  }
1578
1592
 
1579
1593
  if (command === 'help' || flags.help || flags.h) {
1594
+ await refreshCostMapIfStale();
1580
1595
  // Check for subcommand-specific help: nansen <command> <subcommand> --help
1581
1596
  if (flags.help || flags.h) {
1582
1597
  // Handle 'research <category> <sub> --help' (3-level)
@@ -1607,17 +1622,17 @@ export async function runCLI(rawArgs, deps = {}) {
1607
1622
  }
1608
1623
  // First try subcommand help
1609
1624
  // Skip for 'trade'/'alerts' — their handlers show their own rich usage
1610
- if (command && subcommand && command !== 'trade' && command !== 'alerts') {
1625
+ if (command && subcommand && command !== 'trade' && command !== 'alerts' && command !== 'agent') {
1611
1626
  const subHelp = generateSubcommandHelp(command, subcommand);
1612
1627
  if (subHelp) {
1613
- output(subHelp);
1628
+ output(deprecationNote(command) + subHelp);
1614
1629
  notify();
1615
1630
  return { type: 'subcommand-help', command, subcommand };
1616
1631
  }
1617
1632
  }
1618
1633
  // Then try command-level help (list subcommands)
1619
1634
  // 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]);
1635
+ const cmdSchemaLookup = command !== 'trade' && command !== 'alerts' && command !== 'agent' && (SCHEMA.commands[command] || SCHEMA.commands.research.subcommands[command]);
1621
1636
  if (command && cmdSchemaLookup) {
1622
1637
  const cmdSchema = cmdSchemaLookup;
1623
1638
  const lines = [`${command} — ${cmdSchema.description}`];
@@ -1638,7 +1653,7 @@ export async function runCLI(rawArgs, deps = {}) {
1638
1653
  if (cmdSchema.examples?.length) {
1639
1654
  lines.push(`\nExamples:\n ${cmdSchema.examples.join('\n ')}`);
1640
1655
  }
1641
- output(lines.join('\n'));
1656
+ output(deprecationNote(command) + lines.join('\n'));
1642
1657
  notify();
1643
1658
  return { type: 'command-help', command };
1644
1659
  }
@@ -1681,7 +1696,6 @@ export async function runCLI(rawArgs, deps = {}) {
1681
1696
  const formatted = formatOutput(errorData, { pretty, table });
1682
1697
  output(formatted.text);
1683
1698
  trackCommandFailed({ command: fullCommand, duration_ms: Date.now() - startTime, error_code: 'UNKNOWN_COMMAND', flags: usedFlags, chain });
1684
- notify();
1685
1699
  exit(1);
1686
1700
  return { type: 'error', data: errorData };
1687
1701
  }
@@ -1709,7 +1723,6 @@ export async function runCLI(rawArgs, deps = {}) {
1709
1723
  // Commands that handle their own output return undefined
1710
1724
  if (result === undefined) {
1711
1725
  trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
1712
- notify();
1713
1726
  return { type: 'no-output', command };
1714
1727
  }
1715
1728
 
@@ -1718,7 +1731,6 @@ export async function runCLI(rawArgs, deps = {}) {
1718
1731
  const formatted = formatOutput(result, { pretty, table: false });
1719
1732
  output(formatted.text);
1720
1733
  trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
1721
- notify();
1722
1734
  return { type: 'schema', data: result };
1723
1735
  }
1724
1736
 
@@ -1731,7 +1743,6 @@ export async function runCLI(rawArgs, deps = {}) {
1731
1743
  // Alerts list with --table uses custom table format
1732
1744
  if (command === 'alerts' && subcommand === 'list' && table) {
1733
1745
  output(formatAlertsTable(result));
1734
- notify();
1735
1746
  return { type: 'success', data: result };
1736
1747
  }
1737
1748
 
@@ -1743,7 +1754,6 @@ export async function runCLI(rawArgs, deps = {}) {
1743
1754
  output(streamOutput);
1744
1755
  }
1745
1756
  trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
1746
- notify();
1747
1757
  return { type: 'stream', data: result };
1748
1758
  }
1749
1759
 
@@ -1751,7 +1761,6 @@ export async function runCLI(rawArgs, deps = {}) {
1751
1761
  const formatted = formatOutput(successData, { pretty, table, csv });
1752
1762
  output(formatted.text);
1753
1763
  trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, from_cache: !!result?.fromCache, flags: usedFlags, chain });
1754
- notify();
1755
1764
  return { type: csv ? 'csv' : 'success', data: result };
1756
1765
  } catch (error) {
1757
1766
  const errorData = formatError(error);
@@ -1765,7 +1774,6 @@ export async function runCLI(rawArgs, deps = {}) {
1765
1774
  flags: usedFlags,
1766
1775
  chain,
1767
1776
  });
1768
- notify();
1769
1777
  exit(1);
1770
1778
  return { type: 'error', data: errorData };
1771
1779
  }
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Nansen CLI - Agent command
3
+ * Interactive research agent with fast/expert modes via SSE streaming.
4
+ */
5
+
6
+ import crypto from 'crypto';
7
+ import { NansenError, ErrorCode, statusToErrorCode, telemetryHeaders, packageVersion } from '../api.js';
8
+ import { getCostForEndpoint } from '../cost-cache.js';
9
+
10
+ /**
11
+ * Build standard request headers, matching apiInstance.request() conventions.
12
+ */
13
+ function buildHeaders(apiInstance) {
14
+ return {
15
+ 'Content-Type': 'application/json',
16
+ 'X-Client-Type': 'nansen-cli',
17
+ 'X-Client-Version': packageVersion,
18
+ ...telemetryHeaders(),
19
+ ...(apiInstance.apiKey ? { 'apikey': apiInstance.apiKey } : {}),
20
+ ...(apiInstance.defaultHeaders || {}),
21
+ };
22
+ }
23
+
24
+ /**
25
+ * Throw a NansenError with the same structure as apiInstance.request() errors.
26
+ * Includes `details` field for consistency with other commands.
27
+ */
28
+ function throwApiError(message, status, serverDetail) {
29
+ // Match the friendly wrapper messages from apiInstance.request()
30
+ let friendlyMessage = message;
31
+ if (status === 401) {
32
+ friendlyMessage = 'Not logged in. Run: nansen login';
33
+ } else if (status === 429) {
34
+ friendlyMessage = 'Rate limited. Try again in a few seconds.';
35
+ }
36
+
37
+ throw new NansenError(
38
+ friendlyMessage,
39
+ statusToErrorCode(status),
40
+ status,
41
+ { detail: serverDetail || message, attempt: 1, retryAfterMs: null },
42
+ );
43
+ }
44
+
45
+ /**
46
+ * Process an SSE response from the agent endpoint.
47
+ *
48
+ * In buffered mode (no callbacks), collects everything and returns it.
49
+ * In streaming mode (callbacks provided), invokes them as events arrive.
50
+ *
51
+ * @param {Response} response – fetch Response with SSE body
52
+ * @param {object} [callbacks]
53
+ * @param {Function} [callbacks.onDelta] – called with each text chunk
54
+ * @param {Function} [callbacks.onToolCall] – called with each tool name
55
+ * @returns {{ text: string, toolCalls: string[], conversationId: string|null }}
56
+ */
57
+ export async function consumeSSEStream(response, callbacks = {}) {
58
+ const { onDelta, onToolCall } = callbacks;
59
+ const chunks = [];
60
+ const toolCalls = [];
61
+ let conversationId = null;
62
+ let errorPayload = null;
63
+
64
+ const reader = response.body;
65
+ const decoder = new TextDecoder();
66
+ let buffer = '';
67
+
68
+ for await (const raw of reader) {
69
+ buffer += decoder.decode(raw, { stream: true });
70
+
71
+ // Normalize \r\n and \r to \n (SSE spec allows all three line terminators)
72
+ buffer = buffer.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
73
+
74
+ // SSE: split on double-newline boundaries
75
+ let boundary;
76
+ let done = false;
77
+ while ((boundary = buffer.indexOf('\n\n')) !== -1) {
78
+ const frame = buffer.slice(0, boundary);
79
+ buffer = buffer.slice(boundary + 2);
80
+
81
+ for (const line of frame.split('\n')) {
82
+ if (!line.startsWith('data: ')) continue;
83
+ const payload = line.slice(6);
84
+ if (payload === '[DONE]') { done = true; break; }
85
+
86
+ let event;
87
+ try {
88
+ event = JSON.parse(payload);
89
+ } catch {
90
+ continue;
91
+ }
92
+
93
+ switch (event.type) {
94
+ case 'delta':
95
+ if (event.text) {
96
+ chunks.push(event.text);
97
+ if (onDelta) onDelta(event.text);
98
+ }
99
+ break;
100
+ case 'tool_call':
101
+ if (event.name) {
102
+ toolCalls.push(event.name);
103
+ if (onToolCall) onToolCall(event.name);
104
+ }
105
+ break;
106
+ case 'finish':
107
+ conversationId = event.conversation_id ?? null;
108
+ break;
109
+ case 'error':
110
+ errorPayload = event;
111
+ break;
112
+ }
113
+ }
114
+ if (done) break;
115
+ }
116
+ if (done) break;
117
+ }
118
+
119
+ if (errorPayload) {
120
+ const status = errorPayload.status_code || 502;
121
+ throwApiError(
122
+ errorPayload.error || 'Agent request failed',
123
+ status,
124
+ errorPayload.error,
125
+ );
126
+ }
127
+
128
+ return { text: chunks.join(''), toolCalls, conversationId };
129
+ }
130
+
131
+ /**
132
+ * Build the `agent` command handler.
133
+ *
134
+ * @param {object} [deps]
135
+ * @param {Function} [deps.log] – stdout line output (default: console.log)
136
+ * @param {Function} [deps.errorLog] – stderr line output (default: console.error)
137
+ * @param {Function} [deps.write] – raw stdout writer, no trailing newline (default: process.stdout.write)
138
+ * @returns {object} command map
139
+ */
140
+ export function buildAgentCommands(deps = {}) {
141
+ const {
142
+ log = console.log,
143
+ errorLog = console.error,
144
+ write = (s) => process.stdout.write(s),
145
+ } = deps;
146
+
147
+ return {
148
+ 'agent': async (args, apiInstance, flags, options) => {
149
+ // ── Help ──
150
+ if (flags.help || flags.h || args[0] === 'help' || args.length === 0) {
151
+ const fmtCost = (c) => `${c.free} credit${c.free === 1 ? '' : 's'} (Free tier) / ${c.pro} credit${c.pro === 1 ? '' : 's'} (Pro tier)`;
152
+ const fastCost = getCostForEndpoint('/api/v1/agent/fast');
153
+ const expertCost = getCostForEndpoint('/api/v1/agent/expert');
154
+ const costSection = (fastCost || expertCost)
155
+ ? `\nCOST:\n${fastCost ? ` fast: ${fmtCost(fastCost)}\n` : ''}${expertCost ? ` expert: ${fmtCost(expertCost)}\n` : ''}`
156
+ : '';
157
+ log(`nansen agent — Nansen Research Agent
158
+
159
+ Ask the Nansen AI agent research questions about crypto wallets, tokens,
160
+ smart money flows, and on-chain activity. The agent uses Nansen's full
161
+ data platform to answer your questions.
162
+
163
+ MODES:
164
+ fast Faster responses, best for simple lookups (default)
165
+ expert Deeper analysis, uses a more capable model
166
+
167
+ USAGE:
168
+ nansen agent "<question>"
169
+ nansen agent "<question>" --expert
170
+ nansen agent "<question>" --conversation-id <id>
171
+
172
+ OPTIONS:
173
+ --expert Use expert mode (default: fast)
174
+ --conversation-id <uuid> Continue a previous conversation (UUID v4)
175
+ --json Output raw JSON instead of formatted text
176
+ ${costSection}
177
+ CONVERSATION FLOW:
178
+ Each request generates a UUID v4 conversation ID. To continue a
179
+ multi-turn conversation, pass it back with --conversation-id. The ID
180
+ and a ready-to-copy follow-up command are printed to stderr after each
181
+ response.
182
+
183
+ EXAMPLES:
184
+ nansen agent "What are the top smart money inflows on Ethereum today?"
185
+ nansen agent "Show me the largest whale wallets on Solana"
186
+ nansen agent "Analyze wallet 0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" --expert
187
+ nansen agent "Tell me more about their DeFi positions" --conversation-id 550e8400-e29b-41d4-a716-446655440000`);
188
+ return;
189
+ }
190
+
191
+ // ── Parse question ──
192
+ const question = args.join(' ').trim();
193
+ if (!question) {
194
+ throw new NansenError(
195
+ 'Query cannot be empty. Usage: nansen agent "<question>"',
196
+ ErrorCode.INVALID_PARAMS,
197
+ null,
198
+ { detail: 'Empty query string' },
199
+ );
200
+ }
201
+
202
+ // ── Mode ──
203
+ const expert = !!flags.expert;
204
+ const endpoint = expert ? '/api/v1/agent/expert' : '/api/v1/agent/fast';
205
+ const modeName = expert ? 'expert' : 'fast';
206
+
207
+ // ── Conversation ID (must be UUID v4) ──
208
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
209
+ const rawConvId = options['conversation-id'];
210
+ let conversationId;
211
+ if (typeof rawConvId === 'string' && rawConvId) {
212
+ if (!UUID_RE.test(rawConvId)) {
213
+ throw new NansenError(
214
+ `Invalid --conversation-id: expected a UUID (e.g. 550e8400-e29b-41d4-a716-446655440000), got "${rawConvId.slice(0, 60)}${rawConvId.length > 60 ? '...' : ''}"`,
215
+ ErrorCode.INVALID_PARAMS,
216
+ null,
217
+ { detail: 'conversation-id must be a UUID v4' },
218
+ );
219
+ }
220
+ conversationId = rawConvId;
221
+ } else {
222
+ conversationId = crypto.randomUUID();
223
+ }
224
+
225
+ // ── Auth guard ──
226
+ if (!apiInstance.apiKey) {
227
+ throw new NansenError(
228
+ 'Not logged in. Run: nansen login',
229
+ ErrorCode.UNAUTHORIZED,
230
+ 401,
231
+ { detail: 'No API key configured' },
232
+ );
233
+ }
234
+
235
+ // ── Request (no retry — SSE streams are not idempotent) ──
236
+ const url = `${apiInstance.baseUrl}${endpoint}`;
237
+ const body = {
238
+ text: question,
239
+ conversation_id: conversationId,
240
+ };
241
+
242
+ // ── Timeout ──
243
+ const timeoutMs = expert ? 300_000 : 120_000; // 5min expert, 2min fast
244
+ const controller = new AbortController();
245
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
246
+
247
+ let response;
248
+ try {
249
+ response = await fetch(url, {
250
+ method: 'POST',
251
+ headers: buildHeaders(apiInstance),
252
+ body: JSON.stringify(body),
253
+ signal: controller.signal,
254
+ });
255
+ } catch (err) {
256
+ clearTimeout(timer);
257
+ if (err.name === 'AbortError') {
258
+ throw new NansenError(
259
+ `Request timed out after ${timeoutMs / 1000}s`,
260
+ ErrorCode.TIMEOUT,
261
+ 504,
262
+ { detail: `${modeName} mode timeout (${timeoutMs / 1000}s)` },
263
+ );
264
+ }
265
+ throw new NansenError(
266
+ `Network error: ${err.message}`,
267
+ ErrorCode.NETWORK_ERROR,
268
+ null,
269
+ { originalError: err.message },
270
+ );
271
+ }
272
+
273
+ if (!response.ok) {
274
+ clearTimeout(timer);
275
+ let serverDetail;
276
+ if (response.headers.get('content-type')?.includes('application/json')) {
277
+ try {
278
+ const errData = await response.json();
279
+ serverDetail = errData.detail || errData.message;
280
+ } catch { /* ignore parse failure */ }
281
+ }
282
+ throwApiError(
283
+ serverDetail || `Agent returned ${response.status}`,
284
+ response.status,
285
+ serverDetail,
286
+ );
287
+ }
288
+
289
+ // ── JSON mode: buffer everything, return structured data ──
290
+ if (flags.json) {
291
+ let result;
292
+ try {
293
+ result = await consumeSSEStream(response);
294
+ } finally {
295
+ clearTimeout(timer);
296
+ }
297
+ return {
298
+ conversation_id: result.conversationId || conversationId,
299
+ mode: modeName,
300
+ text: result.text,
301
+ tool_calls: result.toolCalls,
302
+ };
303
+ }
304
+
305
+ // ── Streaming output mode ──
306
+ let hasOutput = false;
307
+ let midLine = false; // true when write() was called without a trailing newline
308
+ let result;
309
+ try {
310
+ result = await consumeSSEStream(response, {
311
+ onDelta(text) {
312
+ if (!midLine && text.trim() === '') return;
313
+ write(text);
314
+ hasOutput = true;
315
+ midLine = text.length > 0 && !text.endsWith('\n');
316
+ },
317
+ onToolCall(name) {
318
+ if (midLine) { write('\n'); midLine = false; }
319
+ errorLog(`⚙ ${name}`);
320
+ },
321
+ });
322
+ } finally {
323
+ clearTimeout(timer);
324
+ }
325
+
326
+ // Ensure a trailing newline after streamed text
327
+ if (midLine) {
328
+ write('\n');
329
+ }
330
+
331
+ if (!hasOutput) {
332
+ log('(no response from agent)');
333
+ }
334
+
335
+ // Print conversation continuation hint
336
+ const effectiveConvId = result.conversationId || conversationId;
337
+ const expertFlag = expert ? ' --expert' : '';
338
+ errorLog(`\nTo continue this conversation:`);
339
+ errorLog(` nansen agent "<follow-up>" --conversation-id "${effectiveConvId}"${expertFlag}`);
340
+
341
+ return;
342
+ },
343
+ };
344
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Credit cost cache — reads per-endpoint costs from a local cache populated
3
+ * by an inline fetch of the Nansen OpenAPI spec (at most once per 24h).
4
+ *
5
+ * getCostForEndpoint(endpoint) — sync, reads cache, returns { free, pro } or null
6
+ * refreshCostMapIfStale() — async, fetches inline if cache is missing or stale
7
+ */
8
+
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+
12
+ const CONFIG_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.nansen');
13
+ const CACHE_FILE = path.join(CONFIG_DIR, 'cost-map.json');
14
+ const STALE_MS = 24 * 60 * 60 * 1000; // 24 hours
15
+ const OPENAPI_URL = 'https://api.nansen.ai/openapi.json';
16
+
17
+ /**
18
+ * Returns { free, pro } credit cost for the given API path, or null if unavailable.
19
+ */
20
+ export function getCostForEndpoint(endpoint) {
21
+ try {
22
+ if (!fs.existsSync(CACHE_FILE)) return null;
23
+ const { costs } = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
24
+ return costs?.[endpoint] ?? null;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Fetches the OpenAPI spec and writes the cost map to disk if the cache is
32
+ * missing or older than 24h. Awaited inline — only blocks on cold/stale cache.
33
+ * Silent on any error.
34
+ */
35
+ export async function refreshCostMapIfStale() {
36
+ try {
37
+ if (fs.existsSync(CACHE_FILE)) {
38
+ const { fetchedAt } = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
39
+ if (fetchedAt && Date.now() - fetchedAt < STALE_MS) return;
40
+ }
41
+
42
+ const controller = new AbortController();
43
+ const timer = setTimeout(() => controller.abort(), 10_000);
44
+ let spec;
45
+ try {
46
+ const res = await fetch(OPENAPI_URL, { signal: controller.signal });
47
+ spec = await res.json();
48
+ } finally {
49
+ clearTimeout(timer);
50
+ }
51
+
52
+ const costs = {};
53
+ for (const [p, methods] of Object.entries(spec.paths || {})) {
54
+ for (const op of Object.values(methods)) {
55
+ if (op['x-credit-cost']) {
56
+ costs[p] = op['x-credit-cost'];
57
+ break;
58
+ }
59
+ }
60
+ }
61
+
62
+ if (!fs.existsSync(CONFIG_DIR)) fs.mkdirSync(CONFIG_DIR, { mode: 0o700, recursive: true });
63
+ fs.writeFileSync(CACHE_FILE, JSON.stringify({ costs, fetchedAt: Date.now() }));
64
+ } catch {
65
+ // silent — network failure, parse error, write error
66
+ }
67
+ }
package/src/schema.json CHANGED
@@ -6,6 +6,7 @@
6
6
  "portfolio": {
7
7
  "subcommands": {
8
8
  "defi": {
9
+ "endpoint": "/api/v1/portfolio/defi-holdings",
9
10
  "description": "DeFi holdings across protocols",
10
11
  "options": {
11
12
  "wallet": {
@@ -19,6 +20,7 @@
19
20
  "smart-money": {
20
21
  "subcommands": {
21
22
  "netflow": {
23
+ "endpoint": "/api/v1/smart-money/netflow",
22
24
  "description": "Net capital flows (inflows vs outflows)",
23
25
  "options": {
24
26
  "chain": {
@@ -27,6 +29,7 @@
27
29
  }
28
30
  },
29
31
  "dex-trades": {
32
+ "endpoint": "/api/v1/smart-money/dex-trades",
30
33
  "description": "Real-time DEX trading activity",
31
34
  "options": {
32
35
  "chain": {
@@ -35,12 +38,15 @@
35
38
  }
36
39
  },
37
40
  "perp-trades": {
41
+ "endpoint": "/api/v1/smart-money/perp-trades",
38
42
  "description": "Perpetual trading on Hyperliquid"
39
43
  },
40
44
  "dcas": {
45
+ "endpoint": "/api/v1/smart-money/dcas",
41
46
  "description": "DCA strategies on Jupiter"
42
47
  },
43
48
  "holdings": {
49
+ "endpoint": "/api/v1/smart-money/holdings",
44
50
  "description": "Aggregated token balances",
45
51
  "options": {
46
52
  "chain": {
@@ -49,6 +55,7 @@
49
55
  }
50
56
  },
51
57
  "historical-holdings": {
58
+ "endpoint": "/api/v1/smart-money/historical-holdings",
52
59
  "description": "Historical holdings over time",
53
60
  "options": {
54
61
  "chain": {
@@ -65,6 +72,7 @@
65
72
  "profiler": {
66
73
  "subcommands": {
67
74
  "transactions": {
75
+ "endpoint": "/api/v1/profiler/address/transactions",
68
76
  "description": "Transaction history",
69
77
  "options": {
70
78
  "address": {
@@ -79,6 +87,7 @@
79
87
  }
80
88
  },
81
89
  "pnl-summary": {
90
+ "endpoint": "/api/v1/profiler/address/pnl-summary",
82
91
  "description": "Summarized PnL metrics",
83
92
  "options": {
84
93
  "address": {
@@ -93,6 +102,7 @@
93
102
  }
94
103
  },
95
104
  "balance": {
105
+ "endpoint": "/api/v1/profiler/address/current-balance",
96
106
  "description": "Current token holdings",
97
107
  "options": {
98
108
  "address": {
@@ -104,6 +114,7 @@
104
114
  }
105
115
  },
106
116
  "counterparties": {
117
+ "endpoint": "/api/v1/profiler/address/counterparties",
107
118
  "description": "Top counterparties by volume",
108
119
  "options": {
109
120
  "address": {
@@ -118,6 +129,7 @@
118
129
  }
119
130
  },
120
131
  "historical-balances": {
132
+ "endpoint": "/api/v1/profiler/address/historical-balances",
121
133
  "description": "Historical balances over time",
122
134
  "options": {
123
135
  "address": {
@@ -132,6 +144,7 @@
132
144
  }
133
145
  },
134
146
  "related-wallets": {
147
+ "endpoint": "/api/v1/profiler/address/related-wallets",
135
148
  "description": "Find wallets related to an address",
136
149
  "options": {
137
150
  "address": {
@@ -143,6 +156,7 @@
143
156
  }
144
157
  },
145
158
  "pnl": {
159
+ "endpoint": "/api/v1/profiler/address/pnl",
146
160
  "description": "PnL and trade performance",
147
161
  "options": {
148
162
  "address": {
@@ -157,6 +171,7 @@
157
171
  }
158
172
  },
159
173
  "labels": {
174
+ "endpoint": "/api/v1/profiler/address/labels",
160
175
  "description": "Behavioral and entity labels",
161
176
  "options": {
162
177
  "address": {
@@ -168,6 +183,7 @@
168
183
  }
169
184
  },
170
185
  "perp-positions": {
186
+ "endpoint": "/api/v1/profiler/perp-positions",
171
187
  "description": "Current perpetual positions",
172
188
  "options": {
173
189
  "address": {
@@ -176,6 +192,7 @@
176
192
  }
177
193
  },
178
194
  "perp-trades": {
195
+ "endpoint": "/api/v1/profiler/perp-trades",
179
196
  "description": "Perpetual trading history",
180
197
  "options": {
181
198
  "address": {
@@ -187,6 +204,7 @@
187
204
  }
188
205
  },
189
206
  "search": {
207
+ "endpoint": "/api/v1/search/entity-name",
190
208
  "description": "Search for entities by name",
191
209
  "options": {
192
210
  "query": {
@@ -251,6 +269,7 @@
251
269
  "token": {
252
270
  "subcommands": {
253
271
  "flows": {
272
+ "endpoint": "/api/v1/tgm/flows",
254
273
  "description": "Token flow metrics",
255
274
  "options": {
256
275
  "chain": {
@@ -265,6 +284,7 @@
265
284
  }
266
285
  },
267
286
  "who-bought-sold": {
287
+ "endpoint": "/api/v1/tgm/who-bought-sold",
268
288
  "description": "Recent buyers and sellers",
269
289
  "options": {
270
290
  "chain": {
@@ -279,6 +299,7 @@
279
299
  }
280
300
  },
281
301
  "dex-trades": {
302
+ "endpoint": "/api/v1/tgm/dex-trades",
282
303
  "description": "DEX trading activity",
283
304
  "options": {
284
305
  "chain": {
@@ -293,6 +314,7 @@
293
314
  }
294
315
  },
295
316
  "transfers": {
317
+ "endpoint": "/api/v1/tgm/transfers",
296
318
  "description": "Token transfer history",
297
319
  "options": {
298
320
  "chain": {
@@ -307,6 +329,7 @@
307
329
  }
308
330
  },
309
331
  "holders": {
332
+ "endpoint": "/api/v1/tgm/holders",
310
333
  "description": "Token holder analysis",
311
334
  "options": {
312
335
  "chain": {
@@ -318,6 +341,7 @@
318
341
  }
319
342
  },
320
343
  "pnl": {
344
+ "endpoint": "/api/v1/tgm/pnl-leaderboard",
321
345
  "description": "PnL leaderboard",
322
346
  "options": {
323
347
  "chain": {
@@ -332,6 +356,7 @@
332
356
  }
333
357
  },
334
358
  "perp-pnl-leaderboard": {
359
+ "endpoint": "/api/v1/tgm/perp-pnl-leaderboard",
335
360
  "description": "Perp PnL leaderboard by token",
336
361
  "options": {
337
362
  "symbol": {
@@ -343,6 +368,7 @@
343
368
  }
344
369
  },
345
370
  "perp-positions": {
371
+ "endpoint": "/api/v1/tgm/perp-positions",
346
372
  "description": "Open perp positions by token symbol",
347
373
  "options": {
348
374
  "symbol": {
@@ -351,6 +377,7 @@
351
377
  }
352
378
  },
353
379
  "perp-trades": {
380
+ "endpoint": "/api/v1/tgm/perp-trades",
354
381
  "description": "Perp trades by token symbol",
355
382
  "options": {
356
383
  "symbol": {
@@ -362,6 +389,7 @@
362
389
  }
363
390
  },
364
391
  "flow-intelligence": {
392
+ "endpoint": "/api/v1/tgm/flow-intelligence",
365
393
  "description": "Detailed flow intelligence by label",
366
394
  "options": {
367
395
  "chain": {
@@ -376,6 +404,7 @@
376
404
  }
377
405
  },
378
406
  "info": {
407
+ "endpoint": "/api/v1/tgm/token-information",
379
408
  "description": "Get detailed information for a specific token",
380
409
  "options": {
381
410
  "chain": {
@@ -390,6 +419,7 @@
390
419
  }
391
420
  },
392
421
  "indicators": {
422
+ "endpoint": "/api/v1/tgm/indicators",
393
423
  "description": "Risk and reward indicators for a token (Nansen Score)",
394
424
  "options": {
395
425
  "chain": {
@@ -401,6 +431,7 @@
401
431
  }
402
432
  },
403
433
  "ohlcv": {
434
+ "endpoint": "/api/v1/tgm/token-ohlcv",
404
435
  "description": "OHLCV candle data for a token",
405
436
  "options": {
406
437
  "chain": {
@@ -415,6 +446,7 @@
415
446
  }
416
447
  },
417
448
  "jup-dca": {
449
+ "endpoint": "/api/v1/tgm/jup-dca",
418
450
  "description": "Jupiter DCA orders for token",
419
451
  "options": {
420
452
  "token": {
@@ -423,6 +455,7 @@
423
455
  }
424
456
  },
425
457
  "screener": {
458
+ "endpoint": "/api/v1/token-screener",
426
459
  "description": "Discover and filter tokens",
427
460
  "options": {
428
461
  "timeframe": {
@@ -437,6 +470,7 @@
437
470
  "description": "Token God Mode - deep analytics for any token"
438
471
  },
439
472
  "search": {
473
+ "endpoint": "/api/v1/search/general",
440
474
  "description": "Search for tokens and entities across Nansen",
441
475
  "options": {
442
476
  "query": {
@@ -453,6 +487,7 @@
453
487
  "perp": {
454
488
  "subcommands": {
455
489
  "screener": {
490
+ "endpoint": "/api/v1/perp-screener",
456
491
  "description": "Screen perpetual futures contracts",
457
492
  "options": {
458
493
  "days": {
@@ -461,6 +496,7 @@
461
496
  }
462
497
  },
463
498
  "leaderboard": {
499
+ "endpoint": "/api/v1/perp-leaderboard",
464
500
  "description": "Perpetual futures PnL leaderboard",
465
501
  "options": {
466
502
  "days": {
@@ -474,6 +510,7 @@
474
510
  "prediction-market": {
475
511
  "subcommands": {
476
512
  "ohlcv": {
513
+ "endpoint": "/api/v1/prediction-market/ohlcv",
477
514
  "description": "Get Prediction Market OHLCV Candles",
478
515
  "options": {
479
516
  "market-id": {
@@ -482,6 +519,7 @@
482
519
  }
483
520
  },
484
521
  "orderbook": {
522
+ "endpoint": "/api/v1/prediction-market/orderbook",
485
523
  "description": "Get Prediction Market Orderbook",
486
524
  "options": {
487
525
  "market-id": {
@@ -490,6 +528,7 @@
490
528
  }
491
529
  },
492
530
  "top-holders": {
531
+ "endpoint": "/api/v1/prediction-market/top-holders",
493
532
  "description": "Get Prediction Market Top Holders",
494
533
  "options": {
495
534
  "market-id": {
@@ -498,6 +537,7 @@
498
537
  }
499
538
  },
500
539
  "trades-by-market": {
540
+ "endpoint": "/api/v1/prediction-market/trades-by-market",
501
541
  "description": "Get Prediction Market Trades by Market",
502
542
  "options": {
503
543
  "market-id": {
@@ -506,6 +546,7 @@
506
546
  }
507
547
  },
508
548
  "market-screener": {
549
+ "endpoint": "/api/v1/prediction-market/market-screener",
509
550
  "description": "Get Prediction Market Screener",
510
551
  "options": {
511
552
  "query": {
@@ -514,6 +555,7 @@
514
555
  }
515
556
  },
516
557
  "event-screener": {
558
+ "endpoint": "/api/v1/prediction-market/event-screener",
517
559
  "description": "Get Prediction Market Event Screener",
518
560
  "options": {
519
561
  "query": {
@@ -522,6 +564,7 @@
522
564
  }
523
565
  },
524
566
  "pnl-by-market": {
567
+ "endpoint": "/api/v1/prediction-market/pnl-by-market",
525
568
  "description": "Get Prediction Market PnL by Market",
526
569
  "options": {
527
570
  "market-id": {
@@ -530,6 +573,7 @@
530
573
  }
531
574
  },
532
575
  "pnl-by-address": {
576
+ "endpoint": "/api/v1/prediction-market/pnl-by-address",
533
577
  "description": "Get Prediction Market PnL by Address",
534
578
  "options": {
535
579
  "address": {
@@ -538,6 +582,7 @@
538
582
  }
539
583
  },
540
584
  "position-detail": {
585
+ "endpoint": "/api/v1/prediction-market/position-detail",
541
586
  "description": "Get Prediction Market Position Detail",
542
587
  "options": {
543
588
  "market-id": {
@@ -546,6 +591,7 @@
546
591
  }
547
592
  },
548
593
  "trades-by-address": {
594
+ "endpoint": "/api/v1/prediction-market/trades-by-address",
549
595
  "description": "Get Prediction Market Trades by Address",
550
596
  "options": {
551
597
  "address": {
@@ -554,6 +600,7 @@
554
600
  }
555
601
  },
556
602
  "categories": {
603
+ "endpoint": "/api/v1/prediction-market/categories",
557
604
  "description": "Get Prediction Market Categories"
558
605
  }
559
606
  },
@@ -826,6 +873,35 @@
826
873
  ]
827
874
  }
828
875
  }
876
+ },
877
+ "agent": {
878
+ "description": "Nansen AI research agent \u2014 ask questions about wallets, tokens, and on-chain activity",
879
+ "options": {
880
+ "expert": {
881
+ "type": "boolean",
882
+ "description": "Use expert mode (deeper analysis, slower). Default: fast mode."
883
+ },
884
+ "conversation-id": {
885
+ "type": "string",
886
+ "format": "uuid",
887
+ "description": "Continue a previous conversation by passing its UUID v4 ID"
888
+ },
889
+ "json": {
890
+ "type": "boolean",
891
+ "description": "Output raw JSON (conversation_id, mode, text, tool_calls) instead of formatted text"
892
+ }
893
+ },
894
+ "examples": [
895
+ "nansen agent \"What are the top smart money inflows on Ethereum today?\"",
896
+ "nansen agent \"Analyze wallet 0xd8dA...6045\" --expert",
897
+ "nansen agent \"Tell me more\" --conversation-id <id>"
898
+ ],
899
+ "returns": [
900
+ "conversation_id",
901
+ "mode",
902
+ "text",
903
+ "tool_calls"
904
+ ]
829
905
  }
830
906
  },
831
907
  "globalOptions": {
package/src/wallet.js CHANGED
@@ -596,8 +596,15 @@ export function buildWalletCommands(deps = {}) {
596
596
  // All other subcommands fall through to unified handlers below
597
597
  }
598
598
 
599
+ const isPrivy = options.provider === 'privy' || process.env.NANSEN_WALLET_PROVIDER === 'privy';
600
+
599
601
  const handlers = {
600
602
  'create': async () => {
603
+ // Privy wallets are handled above — if we reach here with provider=privy,
604
+ // the privy path already failed and called exit(). Guard against environments
605
+ // where exit() does not terminate (agent frameworks, test harnesses).
606
+ if (isPrivy) return;
607
+
601
608
  const name = options.name || args[1] || 'default';
602
609
 
603
610
  let password;