nansen-cli 1.34.0 → 1.35.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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.35.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#469](https://github.com/nansen-ai/nansen-cli/pull/469) [`85b1934`](https://github.com/nansen-ai/nansen-cli/commit/85b1934ae25ed02d1726de8ebe92ea41a98f4454) Thanks [@gulshngill](https://github.com/gulshngill)! - Surface the API's credit and rate-limit response headers.
8
+
9
+ Failed calls now report quota state in their error details: an out-of-credits error carries your actual remaining balance, and a rate-limited error carries the limit, what is left, and how long the window needs to drain. Previously the only credit figure the CLI could show was the static per-endpoint estimate published in the API reference — a quote, not what you were charged.
10
+
11
+ A warning goes to stderr when your balance will not cover another call of the size just made, so it never interferes with the JSON on stdout.
12
+
13
+ Successful responses carry the same numbers under an exported `RESPONSE_META` symbol, and the client exposes `lastResponseMeta`. Both are additive: the JSON each command prints is unchanged.
14
+
15
+ - [#470](https://github.com/nansen-ai/nansen-cli/pull/470) [`8159300`](https://github.com/nansen-ai/nansen-cli/commit/81593008f829cc83f1d4a6ee9e5c1a10237a9553) Thanks [@gulshngill](https://github.com/gulshngill)! - Surface the API's request id.
16
+
17
+ Failed calls now carry `details.requestId` — the value that identifies the call end to end. Quote it when reporting a problem; previously nothing identifying a failed request ever reached the user, which made server errors effectively unreportable. Successful responses expose it alongside the credit and rate-limit figures under the `RESPONSE_META` symbol.
18
+
19
+ Absent on deployments that do not send the header yet, in which case the field is simply omitted.
20
+
3
21
  ## 1.34.0
4
22
 
5
23
  ### Minor Changes
package/README.md CHANGED
@@ -177,17 +177,28 @@ nansen research smart-money netflow --chain solana --fields token_symbol,net_flo
177
177
 
178
178
  ```json
179
179
  { "success": true, "data": <api_response> }
180
- { "success": false, "error": "message", "code": "ERROR_CODE", "status": 401 }
180
+ { "success": false, "error": "message", "code": "ERROR_CODE", "status": 401, "details": { ... } }
181
181
  ```
182
182
 
183
183
  **Critical error codes:**
184
184
 
185
185
  | Code | Action |
186
186
  |------|--------|
187
- | `CREDITS_EXHAUSTED` | Stop all API calls immediately. Check [app.nansen.ai](https://app.nansen.ai). |
187
+ | `CREDITS_EXHAUSTED` | Stop all API calls immediately. `details.credits.remaining` is your actual balance. Top up at [app.nansen.ai/api](https://app.nansen.ai/api). |
188
188
  | `UNAUTHORIZED` | Wrong or missing key. Re-auth. |
189
- | `RATE_LIMITED` | Auto-retried by CLI. |
189
+ | `RATE_LIMITED` | Auto-retried by CLI. `details.rateLimit.resetSeconds` is how long the window needs to drain. |
190
190
  | `UNSUPPORTED_FILTER` | Remove the filter and retry. |
191
+ | `SERVER_ERROR` | Not your fault. Quote `details.requestId` when reporting it. |
192
+
193
+ **Error metadata.** When the API reports them, `details` carries:
194
+
195
+ | Field | Meaning |
196
+ |-------|---------|
197
+ | `requestId` | Identifies this call end to end. Quote it in any support report. Opaque — do not parse it. |
198
+ | `credits` | `used`, `remaining` |
199
+ | `rateLimit` | `limit`, `remaining`, `resetSeconds` |
200
+
201
+ Any field may be absent or `null`, meaning unknown — never assume zero. A low-balance warning goes to **stderr**, so stdout stays pure JSON.
191
202
 
192
203
  ## Troubleshooting
193
204
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.34.0",
3
+ "version": "1.35.0",
4
4
  "description": "AI-agent CLI for Nansen API analytics, DEX swaps, and cross-chain trading",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -41,9 +41,19 @@ function hasTTY() {
41
41
  return process.stdin.isTTY && process.stderr.isTTY;
42
42
  }
43
43
 
44
+ // npx is a .cmd shim on Windows, and Node refuses to spawn .cmd/.bat without a
45
+ // shell (CVE-2024-27980). Go through cmd.exe explicitly rather than enabling
46
+ // `shell: true`, which would hand the whole command line to the shell parser.
47
+ const IS_WIN = process.platform === "win32";
48
+
49
+ function npxInvocation(args) {
50
+ return IS_WIN ? ["cmd.exe", ["/c", "npx", ...args]] : ["npx", args];
51
+ }
52
+
44
53
  function hasNpx() {
45
54
  try {
46
- execFileSync("npx", ["--version"], { stdio: "ignore", shell: process.platform === "win32" });
55
+ const [cmd, cmdArgs] = npxInvocation(["--version"]);
56
+ execFileSync(cmd, cmdArgs, { stdio: "ignore", shell: false });
47
57
  return true;
48
58
  } catch {
49
59
  return false;
@@ -86,7 +96,7 @@ function prompt(question) {
86
96
 
87
97
  function runCommand(cmd, args) {
88
98
  return new Promise((resolve) => {
89
- const child = spawn(cmd, args, { stdio: "inherit", shell: process.platform === "win32" });
99
+ const child = spawn(cmd, args, { stdio: "inherit", shell: false });
90
100
  child.on("close", (code) => resolve(code === 0));
91
101
  child.on("error", () => resolve(false));
92
102
  });
@@ -113,7 +123,7 @@ async function installSkill() {
113
123
  }
114
124
 
115
125
  log(`Installing Nansen skill...`);
116
- const ok = await runCommand("npx", ["-y", "skills", "add", SKILL_REPO]);
126
+ const ok = await runCommand(...npxInvocation(["-y", "skills", "add", SKILL_REPO]));
117
127
  if (!ok) {
118
128
  log(`${YELLOW}Skill installation failed. You can retry with: npx skills add ${SKILL_REPO}${RESET}`);
119
129
  }
package/src/api.js CHANGED
@@ -8,6 +8,16 @@ import path from 'path';
8
8
  import { fileURLToPath } from 'url';
9
9
  import { EVM_CHAINS } from './chain-ids.js';
10
10
  import { getAnonymousId, TELEMETRY_DISABLED } from './telemetry.js';
11
+ import { readResponseMeta } from './response-meta.js';
12
+
13
+ /**
14
+ * Key for the credit/rate-limit metadata attached to a successful response.
15
+ *
16
+ * A symbol on purpose: JSON.stringify and Object.keys both skip it, so the JSON
17
+ * every command prints is byte-for-byte unchanged while callers that want the
18
+ * numbers can still read them off the returned object.
19
+ */
20
+ export const RESPONSE_META = Symbol('nansenResponseMeta');
11
21
 
12
22
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
23
 
@@ -465,6 +475,15 @@ export class NansenAPI {
465
475
  ttl: options.cache?.ttl ?? DEFAULT_CACHE_TTL
466
476
  };
467
477
  this.defaultHeaders = options.defaultHeaders || {};
478
+ /**
479
+ * Credit/rate-limit metadata from the most recent response, or null.
480
+ *
481
+ * Survives any reshaping a command handler does to the response body, which
482
+ * the RESPONSE_META symbol on the returned object does not. Last write wins
483
+ * when a handler makes several calls — the freshest balance, which is what a
484
+ * low-credit warning wants.
485
+ */
486
+ this.lastResponseMeta = null;
468
487
  }
469
488
 
470
489
  static cleanBody(body) {
@@ -520,7 +539,11 @@ export class NansenAPI {
520
539
  }
521
540
  } catch { /* balance check is best-effort */ }
522
541
  }
523
- return await paidResponse.json();
542
+ const data = await paidResponse.json();
543
+ const meta = readResponseMeta(paidResponse);
544
+ this.lastResponseMeta = meta;
545
+ if (meta && data !== null && typeof data === 'object') data[RESPONSE_META] = meta;
546
+ return data;
524
547
  }
525
548
 
526
549
  async request(endpoint, body = {}, options = {}) {
@@ -581,11 +604,17 @@ export class NansenAPI {
581
604
  data = await response.json();
582
605
  } catch (_err) {
583
606
  // Non-JSON response (rare, usually server errors)
607
+ const meta = readResponseMeta(response);
608
+ this.lastResponseMeta = meta;
584
609
  const error = new NansenError(
585
610
  `Invalid response from API (status ${response.status})`,
586
611
  response.status >= 500 ? ErrorCode.SERVER_ERROR : ErrorCode.UNKNOWN,
587
612
  response.status,
588
- { body: await response.text().catch(() => null), attempt: attempt + 1 }
613
+ {
614
+ body: await response.text().catch(() => null),
615
+ attempt: attempt + 1,
616
+ ...(meta?.requestId && { requestId: meta.requestId })
617
+ }
589
618
  );
590
619
 
591
620
  if (shouldRetry && attempt < maxRetries && response.status >= 500) {
@@ -702,10 +731,23 @@ export class NansenAPI {
702
731
  }
703
732
  }
704
733
 
734
+ // Quota state and the request id belong on the error above all: an
735
+ // out-of-credits or rate-limited failure is exactly when the caller
736
+ // needs the numbers, and a 5xx is worthless to support without the id.
737
+ // formatError() surfaces details, so this needs no plumbing.
738
+ //
739
+ // On a retried call this is the LAST attempt's id — each attempt gets
740
+ // its own server-side id, and the last one is the failure worth
741
+ // reporting.
742
+ const meta = readResponseMeta(response);
743
+ this.lastResponseMeta = meta;
705
744
  lastError = new NansenError(message, code, response.status, {
706
745
  ...data,
707
746
  attempt: attempt + 1,
708
- retryAfterMs
747
+ retryAfterMs,
748
+ ...(meta?.requestId && { requestId: meta.requestId }),
749
+ ...(meta?.credits && { credits: meta.credits }),
750
+ ...(meta?.rateLimit && { rateLimit: meta.rateLimit })
709
751
  });
710
752
 
711
753
  // Retry on specific status codes
@@ -722,12 +764,21 @@ export class NansenAPI {
722
764
  if (attempt > 0) {
723
765
  data._meta = { ...(data._meta || {}), retriedAttempts: attempt };
724
766
  }
725
-
767
+
726
768
  // Cache successful response
727
769
  if (useCache) {
728
770
  setCachedResponse(endpoint, body, data);
729
771
  }
730
-
772
+
773
+ // Attach after caching so the cache stores the payload alone — quota
774
+ // numbers are per-response and would be stale on a cache hit.
775
+ // Guarded: a response body can be a primitive, which cannot take a property.
776
+ const meta = readResponseMeta(response);
777
+ this.lastResponseMeta = meta;
778
+ if (meta) {
779
+ if (data !== null && typeof data === 'object') data[RESPONSE_META] = meta;
780
+ }
781
+
731
782
  return data;
732
783
  }
733
784
 
package/src/cli.js CHANGED
@@ -14,6 +14,7 @@ import { resolveAddress, isEnsName } from './ens.js';
14
14
  import fs from 'fs';
15
15
  import { getUpdateNotification, getUpgradeNotice, scheduleUpdateCheck } from './update-check.js';
16
16
  import { refreshCostMapIfStale, getCostForEndpoint } from './cost-cache.js';
17
+ import { creditWarning, noticeWarnings } from './response-meta.js';
17
18
  import { trackCommandSucceeded, trackCommandFailed } from './telemetry.js';
18
19
  import { createRequire } from 'module';
19
20
  import * as readline from 'readline';
@@ -1854,6 +1855,14 @@ export async function runCLI(rawArgs, deps = {}) {
1854
1855
  const api = new NansenAPIClass(undefined, undefined, { retry: retryOptions, cache: cacheOptions, defaultHeaders });
1855
1856
  let result = await commands[command](subArgs, api, flags, options);
1856
1857
 
1858
+ // Credit balance warning, from the headers on the call just made. Goes to
1859
+ // stderr so it never contaminates the JSON on stdout that agents parse.
1860
+ // Placed before every return path below so it fires for operational
1861
+ // commands too, which print their own output and return undefined.
1862
+ const lowCredits = creditWarning(api.lastResponseMeta);
1863
+ if (lowCredits) errorOutput(lowCredits);
1864
+ for (const notice of noticeWarnings(api.lastResponseMeta)) errorOutput(notice);
1865
+
1857
1866
  // Commands that handle their own output return undefined
1858
1867
  if (result === undefined) {
1859
1868
  await trackCommandSucceeded({ command: fullCommand, duration_ms: Date.now() - startTime, flags: usedFlags, chain });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Request-id, credit, rate-limit, and notice metadata, read from Nansen API
3
+ * response headers.
4
+ *
5
+ * The API reports what a call actually cost, what quota is left, and an id that
6
+ * identifies the call end to end. Until now the CLI dropped those headers on the
7
+ * floor and showed only the static per-endpoint estimate published in the
8
+ * OpenAPI spec (see cost-cache.js), which is a quote rather than a charge.
9
+ *
10
+ * readResponseMeta(response) — parse the headers, or null if none are present
11
+ * creditWarning(meta) — stderr warning string when the balance is short, else null
12
+ *
13
+ * Every header is optional. Some auth rails charge no credits, some responses
14
+ * are served before quota is resolved, and older deployments may send neither
15
+ * the rate-limit triplet nor the request id — so a missing header means
16
+ * "unknown", never zero.
17
+ */
18
+
19
+ /** Header names, as documented in the API reference. */
20
+ const CREDITS_USED = 'x-nansen-credits-used';
21
+ const CREDITS_REMAINING = 'x-nansen-credits-remaining';
22
+ const RATE_LIMIT = 'x-ratelimit-limit';
23
+ const RATE_REMAINING = 'x-ratelimit-remaining';
24
+ const RATE_RESET = 'x-ratelimit-reset';
25
+ const UPGRADE_HINT = 'x-nansen-upgrade-hint';
26
+ const PLAN_NOTICE = 'x-nansen-plan-notice';
27
+ const API_KEY_NOTICE = 'x-nansen-api-key-notice';
28
+ const REQUEST_ID = 'x-request-id';
29
+
30
+ /**
31
+ * Read a header as a non-negative integer, or null when absent/unparseable.
32
+ * Tolerates any header bag with a .get() — a real Headers, or a Map in tests.
33
+ */
34
+ function intHeader(response, name) {
35
+ const raw = stringHeader(response, name);
36
+ if (raw === null) return null;
37
+ const value = Number.parseInt(raw, 10);
38
+ return Number.isInteger(value) && value >= 0 ? value : null;
39
+ }
40
+
41
+ /**
42
+ * Read a header as a trimmed non-empty string, or null when absent.
43
+ * Tolerates any header bag with a .get() — a real Headers, or a Map in tests.
44
+ */
45
+ function stringHeader(response, name) {
46
+ const raw = response?.headers?.get?.(name);
47
+ if (raw == null) return null;
48
+ const value = String(raw).trim();
49
+ return value === '' ? null : value;
50
+ }
51
+
52
+ /**
53
+ * Extract request-id, credit, rate-limit, and notice metadata from a fetch Response.
54
+ * Returns null when the response carries none of it, so callers can skip
55
+ * attaching an object full of nulls.
56
+ */
57
+ export function readResponseMeta(response) {
58
+ const used = intHeader(response, CREDITS_USED);
59
+ const remaining = intHeader(response, CREDITS_REMAINING);
60
+ const limit = intHeader(response, RATE_LIMIT);
61
+ const rateRemaining = intHeader(response, RATE_REMAINING);
62
+ const resetSeconds = intHeader(response, RATE_RESET);
63
+ const upgradeHint = stringHeader(response, UPGRADE_HINT);
64
+ const planNotice = stringHeader(response, PLAN_NOTICE);
65
+ const apiKeyNotice = stringHeader(response, API_KEY_NOTICE);
66
+ const requestId = stringHeader(response, REQUEST_ID);
67
+
68
+ const meta = {};
69
+ if (requestId !== null) {
70
+ // The single value that identifies this call to Nansen support. Opaque —
71
+ // never parse it or assume a format.
72
+ meta.requestId = requestId;
73
+ }
74
+ if (used !== null || remaining !== null) {
75
+ meta.credits = { used, remaining };
76
+ }
77
+ if (limit !== null || rateRemaining !== null || resetSeconds !== null) {
78
+ // resetSeconds is a delta in seconds — how long the tripped window needs to
79
+ // drain — not a wall-clock timestamp.
80
+ meta.rateLimit = { limit, remaining: rateRemaining, resetSeconds };
81
+ }
82
+ if (upgradeHint !== null || planNotice !== null || apiKeyNotice !== null) {
83
+ meta.notices = {
84
+ ...(upgradeHint !== null && { upgradeHint }),
85
+ ...(planNotice !== null && { planNotice }),
86
+ ...(apiKeyNotice !== null && { apiKeyNotice }),
87
+ };
88
+ }
89
+ return Object.keys(meta).length > 0 ? meta : null;
90
+ }
91
+
92
+ /**
93
+ * Yield notice strings for any server-set advisory headers.
94
+ * Each yields a `⚠️ <message>` line for stderr.
95
+ * Order: apiKeyNotice (most urgent) → upgradeHint → planNotice.
96
+ */
97
+ export function noticeWarnings(meta) {
98
+ const notices = meta?.notices;
99
+ if (!notices) return [];
100
+ const out = [];
101
+ if (notices.apiKeyNotice) out.push(`⚠️ ${notices.apiKeyNotice}`);
102
+ if (notices.upgradeHint) out.push(`ℹ️ ${notices.upgradeHint}`);
103
+ if (notices.planNotice) out.push(`ℹ️ ${notices.planNotice}`);
104
+ return out;
105
+ }
106
+
107
+ /**
108
+ * Warn only when the remaining balance will not cover another call of the size
109
+ * just made.
110
+ */
111
+ export function creditWarning(meta) {
112
+ const credits = meta?.credits;
113
+ if (!credits) return null;
114
+ const { used, remaining } = credits;
115
+ if (remaining === null) return null;
116
+ if (remaining === 0) {
117
+ return '⚠️ Out of API credits. Top up at https://app.nansen.ai/api';
118
+ }
119
+ if (used !== null && used > 0 && remaining < used) {
120
+ return `⚠️ ${remaining} API credit${remaining === 1 ? '' : 's'} left — less than this call cost (${used}). Top up at https://app.nansen.ai/api`;
121
+ }
122
+ return null;
123
+ }