nansen-cli 1.1.1 → 1.3.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/nansen-cli.svg)](https://www.npmjs.com/package/nansen-cli)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Tests](https://img.shields.io/badge/tests-342%20passing-brightgreen.svg)]()
5
+ [![Tests](https://img.shields.io/badge/tests-323%20passing-brightgreen.svg)]()
6
6
  [![Coverage](https://img.shields.io/badge/coverage-80%25-brightgreen.svg)]()
7
7
 
8
8
  > **Built by agents, for agents.** We prioritize the best possible AI agent experience.
@@ -137,6 +137,15 @@ nansen schema smart-money --pretty
137
137
 
138
138
  Returns command definitions, option types/defaults, supported chains, and smart money labels.
139
139
 
140
+ ### `cache` - Cache Management
141
+
142
+ Manage the local response cache.
143
+
144
+ ```bash
145
+ # Clear all cached responses
146
+ nansen cache clear
147
+ ```
148
+
140
149
  ## Options
141
150
 
142
151
  | Option | Description |
@@ -144,6 +153,10 @@ Returns command definitions, option types/defaults, supported chains, and smart
144
153
  | `--pretty` | Format JSON output for readability |
145
154
  | `--table` | Format output as human-readable table |
146
155
  | `--fields <list>` | Comma-separated fields to include (reduces response size) |
156
+ | `--cache` | Enable response caching |
157
+ | `--no-cache` | Bypass cache for this request |
158
+ | `--cache-ttl <s>` | Cache TTL in seconds (default: 300) |
159
+ | `--stream` | Output as JSON lines (NDJSON) for incremental processing |
147
160
  | `--chain <chain>` | Blockchain to query |
148
161
  | `--chains <json>` | Multiple chains as JSON array |
149
162
  | `--limit <n>` | Number of results |
@@ -191,13 +204,6 @@ This CLI is built specifically for AI agents. Every design decision prioritizes
191
204
  }
192
205
  ```
193
206
 
194
- **Roadmap** (see [TODO.md](TODO.md)):
195
- - ~~Rate limit handling with auto-retry~~ ✅
196
- - ~~Structured error codes for programmatic handling~~ ✅
197
- - ~~Schema discovery endpoint (`nansen schema`)~~ ✅
198
- - ~~Field filtering to reduce response size~~ ✅
199
- - Response caching and batch queries
200
-
201
207
  ## Examples
202
208
 
203
209
  ```bash
package/TODO.md CHANGED
@@ -2,36 +2,14 @@
2
2
 
3
3
  > **Built by agents, for agents.** We prioritize improvements that create the best possible AI agent experience.
4
4
 
5
- ## Medium Priority
5
+ ## P2 - Nice to Have
6
6
 
7
- ### Response Caching
8
- - [ ] Add optional local cache (SQLite or file-based)
9
- - [ ] Configurable TTL (default 60-300s)
10
- - [ ] `--no-cache` flag to bypass
11
- - [ ] `--cache-ttl` flag to override
12
-
13
- ### Batch Queries
14
- - [ ] Support multiple addresses: `--addresses '[...]'`
15
- - [ ] Support multiple tokens: `--tokens '[...]'`
16
- - [ ] Reduce N calls to 1 call
17
-
18
- ## Test Quality
19
-
20
- ### Remaining Items
21
- - [ ] Test config priority chain (ENV > ~/.nansen > local config.json) — needs `loadConfig()` exported
7
+ ### Test Coverage Gaps
8
+ - [ ] Test config priority chain (ENV > ~/.nansen > local) — needs `loadConfig()` exported
22
9
  - [ ] Add snapshot tests for `--help` output
23
10
  - [ ] Document magic test addresses (e.g. Binance hot wallet)
24
11
  - [ ] Test Bitcoin address validation
25
12
  - [ ] Test stdin pipe mode for API key input
26
- - [ ] Remove duplicated `parseArgs` in unit.test.js (now exported from cli.js)
27
- - [ ] Reduce cli.test.js subprocess tests to ~10 smoke tests
28
-
29
- ## Nice to Have
30
-
31
- ### Streaming Output
32
- - [ ] `--stream` flag for large result sets
33
- - [ ] Output as JSON lines (newline-delimited JSON)
34
- - [ ] Enable incremental processing by agents
35
13
 
36
14
  ### Shell Completions
37
15
  - [ ] Bash completions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nansen-cli",
3
- "version": "1.1.1",
3
+ "version": "1.3.0",
4
4
  "description": "Command-line interface for Nansen API - designed for AI agents",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/api.js CHANGED
@@ -145,6 +145,93 @@ export function deleteConfig() {
145
145
  return false;
146
146
  }
147
147
 
148
+ // ============= Response Cache =============
149
+
150
+ const CACHE_DIR = path.join(CONFIG_DIR, 'cache');
151
+ const DEFAULT_CACHE_TTL = 300; // 5 minutes
152
+
153
+ import crypto from 'crypto';
154
+
155
+ /**
156
+ * Generate cache key from endpoint and request body
157
+ */
158
+ function getCacheKey(endpoint, body) {
159
+ const data = JSON.stringify({ endpoint, body });
160
+ return crypto.createHash('md5').update(data).digest('hex');
161
+ }
162
+
163
+ /**
164
+ * Get cached response if valid
165
+ */
166
+ export function getCachedResponse(endpoint, body, ttlSeconds = DEFAULT_CACHE_TTL) {
167
+ const cacheKey = getCacheKey(endpoint, body);
168
+ const cacheFile = path.join(CACHE_DIR, `${cacheKey}.json`);
169
+
170
+ if (!fs.existsSync(cacheFile)) {
171
+ return null;
172
+ }
173
+
174
+ try {
175
+ const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
176
+ const age = (Date.now() - cached.timestamp) / 1000;
177
+
178
+ if (ttlSeconds <= 0 || age > ttlSeconds) {
179
+ // Cache expired or TTL is 0, delete it
180
+ fs.unlinkSync(cacheFile);
181
+ return null;
182
+ }
183
+
184
+ return { ...cached.data, _meta: { ...cached.data._meta, fromCache: true, cacheAge: Math.round(age) } };
185
+ } catch (e) {
186
+ // Invalid cache file, delete it
187
+ try { fs.unlinkSync(cacheFile); } catch {}
188
+ return null;
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Save response to cache
194
+ */
195
+ export function setCachedResponse(endpoint, body, data) {
196
+ if (!fs.existsSync(CACHE_DIR)) {
197
+ fs.mkdirSync(CACHE_DIR, { mode: 0o700, recursive: true });
198
+ }
199
+
200
+ const cacheKey = getCacheKey(endpoint, body);
201
+ const cacheFile = path.join(CACHE_DIR, `${cacheKey}.json`);
202
+
203
+ const cached = {
204
+ timestamp: Date.now(),
205
+ endpoint,
206
+ data
207
+ };
208
+
209
+ fs.writeFileSync(cacheFile, JSON.stringify(cached), { mode: 0o600 });
210
+ }
211
+
212
+ /**
213
+ * Clear all cached responses
214
+ */
215
+ export function clearCache() {
216
+ if (fs.existsSync(CACHE_DIR)) {
217
+ const files = fs.readdirSync(CACHE_DIR);
218
+ for (const file of files) {
219
+ if (file.endsWith('.json')) {
220
+ fs.unlinkSync(path.join(CACHE_DIR, file));
221
+ }
222
+ }
223
+ return files.length;
224
+ }
225
+ return 0;
226
+ }
227
+
228
+ /**
229
+ * Get cache directory path
230
+ */
231
+ export function getCacheDir() {
232
+ return CACHE_DIR;
233
+ }
234
+
148
235
  // ============= Address Validation =============
149
236
 
150
237
  const ADDRESS_PATTERNS = {
@@ -299,6 +386,10 @@ export class NansenAPI {
299
386
  this.apiKey = apiKey;
300
387
  this.baseUrl = baseUrl;
301
388
  this.retryOptions = { ...DEFAULT_RETRY_OPTIONS, ...options.retry };
389
+ this.cacheOptions = {
390
+ enabled: options.cache?.enabled ?? false,
391
+ ttl: options.cache?.ttl ?? DEFAULT_CACHE_TTL
392
+ };
302
393
  }
303
394
 
304
395
  async request(endpoint, body = {}, options = {}) {
@@ -306,6 +397,17 @@ export class NansenAPI {
306
397
  const { maxRetries, baseDelayMs, maxDelayMs, retryOnStatus } = this.retryOptions;
307
398
  const shouldRetry = options.retry !== false; // Allow disabling retry per-request
308
399
 
400
+ // Check cache first (if enabled and not bypassed)
401
+ const useCache = options.cache !== false && this.cacheOptions.enabled;
402
+ const cacheTtl = options.cacheTtl ?? this.cacheOptions.ttl;
403
+
404
+ if (useCache) {
405
+ const cached = getCachedResponse(endpoint, body, cacheTtl);
406
+ if (cached) {
407
+ return cached;
408
+ }
409
+ }
410
+
309
411
  let lastError;
310
412
 
311
413
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
@@ -383,6 +485,12 @@ export class NansenAPI {
383
485
  if (attempt > 0) {
384
486
  data._meta = { ...(data._meta || {}), retriedAttempts: attempt };
385
487
  }
488
+
489
+ // Cache successful response
490
+ if (useCache) {
491
+ setCachedResponse(endpoint, body, data);
492
+ }
493
+
386
494
  return data;
387
495
  }
388
496
 
package/src/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  * Extracted from index.js for coverage
4
4
  */
5
5
 
6
- import { NansenAPI, saveConfig, deleteConfig, getConfigFile } from './api.js';
6
+ import { NansenAPI, saveConfig, deleteConfig, getConfigFile, clearCache, getCacheDir } from './api.js';
7
7
  import * as readline from 'readline';
8
8
 
9
9
  // ============= Schema Definition =============
@@ -278,7 +278,7 @@ export function parseArgs(args) {
278
278
  const key = arg.slice(2);
279
279
  const next = args[i + 1];
280
280
 
281
- if (key === 'pretty' || key === 'help' || key === 'table' || key === 'no-retry') {
281
+ if (key === 'pretty' || key === 'help' || key === 'table' || key === 'no-retry' || key === 'cache' || key === 'no-cache' || key === 'stream') {
282
282
  result.flags[key] = true;
283
283
  } else if (next && !next.startsWith('-')) {
284
284
  // Try to parse as JSON first
@@ -411,6 +411,34 @@ export function formatError(error) {
411
411
  };
412
412
  }
413
413
 
414
+ /**
415
+ * Format data as JSON lines (NDJSON) for streaming output
416
+ * Each record is output as a separate JSON line
417
+ */
418
+ export function formatStream(data) {
419
+ // Extract array of records from various response shapes
420
+ let records = [];
421
+ if (Array.isArray(data)) {
422
+ records = data;
423
+ } else if (data?.data && Array.isArray(data.data)) {
424
+ records = data.data;
425
+ } else if (data?.results && Array.isArray(data.results)) {
426
+ records = data.results;
427
+ } else if (data?.data?.results && Array.isArray(data.data.results)) {
428
+ records = data.data.results;
429
+ } else if (typeof data === 'object' && data !== null) {
430
+ // Single object - output as single line
431
+ records = [data];
432
+ }
433
+
434
+ if (records.length === 0) {
435
+ return '';
436
+ }
437
+
438
+ // Output each record as a separate JSON line
439
+ return records.map(record => JSON.stringify(record)).join('\n');
440
+ }
441
+
414
442
  // Parse simple sort syntax: "field:direction" or "field" (defaults to DESC)
415
443
  export function parseSort(sortOption, orderByOption) {
416
444
  // If --order-by is provided, use it (full JSON control)
@@ -439,6 +467,7 @@ COMMANDS:
439
467
  login Save your API key (interactive)
440
468
  logout Remove saved API key
441
469
  schema Output JSON schema for all commands (for agent introspection)
470
+ cache Cache management (clear)
442
471
  smart-money Smart Money analytics (netflow, dex-trades, holdings, dcas, historical-holdings)
443
472
  profiler Wallet profiling (balance, labels, transactions, pnl, perp-positions, perp-trades)
444
473
  token Token God Mode (screener, holders, flows, trades, pnl, perp-trades, perp-positions)
@@ -459,6 +488,10 @@ GLOBAL OPTIONS:
459
488
  --symbol Token symbol (for perp endpoints)
460
489
  --no-retry Disable automatic retry on rate limits/errors
461
490
  --retries <n> Max retry attempts (default: 3)
491
+ --cache Enable response caching (default: off)
492
+ --no-cache Disable cache for this request
493
+ --cache-ttl <s> Cache TTL in seconds (default: 300)
494
+ --stream Output as JSON lines (NDJSON) for incremental processing
462
495
 
463
496
  EXAMPLES:
464
497
  # Get Smart Money netflow on Solana
@@ -620,6 +653,35 @@ export function buildCommands(deps = {}) {
620
653
  return SCHEMA;
621
654
  },
622
655
 
656
+ 'cache': async (args, apiInstance, flags, options) => {
657
+ const subcommand = args[0] || 'help';
658
+
659
+ const handlers = {
660
+ 'clear': () => {
661
+ const count = clearCache();
662
+ log(`✓ Cleared ${count} cached responses`);
663
+ log(` Cache dir: ${getCacheDir()}`);
664
+ },
665
+ 'help': () => {
666
+ log('Cache Management\n');
667
+ log('USAGE:');
668
+ log(' nansen cache clear Clear all cached responses\n');
669
+ log('CACHE OPTIONS (for any command):');
670
+ log(' --cache Enable caching for this session');
671
+ log(' --no-cache Bypass cache for this request');
672
+ log(' --cache-ttl <seconds> Set cache TTL (default: 300)');
673
+ }
674
+ };
675
+
676
+ if (!handlers[subcommand]) {
677
+ log(`Unknown cache subcommand: ${subcommand}`);
678
+ handlers['help']();
679
+ return;
680
+ }
681
+
682
+ return handlers[subcommand]();
683
+ },
684
+
623
685
  'smart-money': async (args, apiInstance, flags, options) => {
624
686
  const subcommand = args[0] || 'help';
625
687
  const chain = options.chain || 'solana';
@@ -763,7 +825,7 @@ export function buildCommands(deps = {}) {
763
825
  }
764
826
 
765
827
  // Commands that don't require API authentication
766
- export const NO_AUTH_COMMANDS = ['login', 'logout', 'help', 'schema'];
828
+ export const NO_AUTH_COMMANDS = ['login', 'logout', 'help', 'schema', 'cache'];
767
829
 
768
830
  // Run CLI with given args (returns result, allows custom output/exit handlers)
769
831
  export async function runCLI(rawArgs, deps = {}) {
@@ -781,6 +843,7 @@ export async function runCLI(rawArgs, deps = {}) {
781
843
  const subArgs = positional.slice(1);
782
844
  const pretty = flags.pretty || flags.p;
783
845
  const table = flags.table || flags.t;
846
+ const stream = flags.stream || flags.s;
784
847
 
785
848
  const commands = { ...buildCommands(deps), ...commandOverrides };
786
849
 
@@ -820,7 +883,13 @@ export async function runCLI(rawArgs, deps = {}) {
820
883
  ? { maxRetries: 0 }
821
884
  : { maxRetries: options.retries !== undefined ? options.retries : 3 };
822
885
 
823
- const api = new NansenAPIClass(undefined, undefined, { retry: retryOptions });
886
+ // Configure cache options
887
+ const cacheOptions = {
888
+ enabled: flags['cache'] && !flags['no-cache'],
889
+ ttl: options['cache-ttl'] !== undefined ? options['cache-ttl'] : 300
890
+ };
891
+
892
+ const api = new NansenAPIClass(undefined, undefined, { retry: retryOptions, cache: cacheOptions });
824
893
  let result = await commands[command](subArgs, api, flags, options);
825
894
 
826
895
  // Apply field filtering if --fields is specified
@@ -829,6 +898,16 @@ export async function runCLI(rawArgs, deps = {}) {
829
898
  result = filterFields(result, fields);
830
899
  }
831
900
 
901
+ // Output in requested format
902
+ if (stream) {
903
+ // Stream mode: output each record as a JSON line (NDJSON)
904
+ const streamOutput = formatStream(result);
905
+ if (streamOutput) {
906
+ output(streamOutput);
907
+ }
908
+ return { type: 'stream', data: result };
909
+ }
910
+
832
911
  const successData = { success: true, data: result };
833
912
  const formatted = formatOutput(successData, { pretty, table });
834
913
  output(formatted.text);