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 +14 -8
- package/TODO.md +3 -25
- package/package.json +1 -1
- package/src/api.js +108 -0
- package/src/cli.js +83 -4
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/nansen-cli)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[]()
|
|
6
6
|
[]()
|
|
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
|
-
##
|
|
5
|
+
## P2 - Nice to Have
|
|
6
6
|
|
|
7
|
-
###
|
|
8
|
-
- [ ]
|
|
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
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
|
-
|
|
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);
|