@vike-io/cli 0.14.0 → 0.15.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.
@@ -1,24 +1,35 @@
1
1
  import { callTool } from '../mcp-client.js';
2
2
  import { emit, emitError } from '../format.js';
3
3
  import { parseInt10 } from '../util.js';
4
+ import { addTool } from './tool-command.js';
4
5
 
5
6
  export function registerPolymarket(program) {
6
7
  const cmd = program
7
8
  .command('polymarket')
8
9
  .alias('pm')
9
- .description('Polymarket prediction-market analytics (markets, traders, smart money)');
10
+ .description('Polymarket prediction-market analytics (markets, traders, smart money, tape, order book, settlements, archive)');
10
11
 
11
12
  cmd
12
13
  .command('markets')
13
- .description('Trending Polymarket markets ranked by 24h volume')
14
- .option('--category <c>', 'Crypto | Sports | Politics | Pop-Culture | Tech | all', 'all')
15
- .option('--limit <n>', 'Max markets (max 30)', '10')
14
+ .description('Trending Polymarket markets ranked by 24h volume (or --view search / market)')
15
+ .option('--view <v>', 'trending (default) | search (by slug) | market (one by --condition-id)')
16
+ .option('--category <c>', 'trending: Crypto | Sports | Politics | Pop-Culture | Tech | all (default all)')
17
+ .option('--query <q>', 'search: part of the market slug')
18
+ .option('--active <bool>', 'search: true = only active, false = only closed')
19
+ .option('--offset <n>', 'search: rows to skip')
20
+ .option('--condition-id <id>', 'market: the condition id')
21
+ .option('--limit <n>', 'Max markets (trending default 10, max 30; search default 50, max 100)')
16
22
  .option('--json', 'Emit JSON')
17
23
  .action(async (opts) => {
18
24
  try {
19
25
  const r = await callTool('polymarket_markets', {
26
+ view: opts.view,
20
27
  category: opts.category,
21
- limit: parseInt10(opts.limit, 10),
28
+ query: opts.query,
29
+ active: opts.active,
30
+ offset: opts.offset,
31
+ condition_id: opts.conditionId,
32
+ limit: opts.limit,
22
33
  });
23
34
  emit(r, opts);
24
35
  } catch (err) { emitError(err, opts); }
@@ -71,4 +82,93 @@ export function registerPolymarket(program) {
71
82
  emit(r, opts);
72
83
  } catch (err) { emitError(err, opts); }
73
84
  });
85
+
86
+ const FROM = ['--from-ms <ms>', 'Epoch milliseconds, inclusive'];
87
+ const TO = ['--to-ms <ms>', 'Epoch milliseconds, EXCLUSIVE'];
88
+ const CURSOR = ['--cursor <c>', 'next_cursor from the previous page'];
89
+
90
+ addTool(cmd, 'market-trades <condition_id>', {
91
+ tool: 'polymarket_market_trades',
92
+ args: ['condition_id'],
93
+ desc: 'One market\'s trades with the wallet behind each, newest first (one row = one order\'s fill)',
94
+ options: [FROM, TO, CURSOR, ['--limit <n>', 'Rows per page (default 100)']],
95
+ });
96
+
97
+ addTool(cmd, 'tape', {
98
+ tool: 'polymarket_tape',
99
+ desc: 'Raw L2 event tape of outcome tokens over one window of at most 24 hours, oldest first',
100
+ options: [
101
+ ['--token-id <id>', 'One outcome token id (decimal), not a condition_id'],
102
+ ['--token-ids <list>', 'Comma-separated outcome token ids, at most 100'],
103
+ ['--from-ms <ms>', 'Required. Window start, epoch ms, inclusive'],
104
+ ['--to-ms <ms>', 'Required. Window end, epoch ms, exclusive, at most 24h after --from-ms'],
105
+ ['--event-type <t>', 'book | price_change | trade | tick_size_change | status (strongly recommended)'],
106
+ CURSOR,
107
+ ['--limit <n>', 'Events per page (default 100)'],
108
+ ],
109
+ });
110
+
111
+ addTool(cmd, 'orderbook <token_id>', {
112
+ tool: 'polymarket_orderbook',
113
+ args: ['token_id'],
114
+ desc: 'Order-book snapshot of one outcome token at or before --ts (latest when omitted)',
115
+ options: [
116
+ ['--ts <ms>', 'As-of epoch milliseconds'],
117
+ ['--depth <n>', 'Levels per side, 1-25 (default 25)'],
118
+ ],
119
+ });
120
+
121
+ addTool(cmd, 'market-stats <token_id>', {
122
+ tool: 'polymarket_market_stats',
123
+ args: ['token_id'],
124
+ desc: 'Tape summary of one outcome token: events, trades, volume in shares, first and last recorded time',
125
+ });
126
+
127
+ addTool(cmd, 'settlement <condition_id>', {
128
+ tool: 'polymarket_settlement',
129
+ args: ['condition_id'],
130
+ desc: 'On-chain resolution of one market ([] = the chain has not resolved it)',
131
+ });
132
+
133
+ addTool(cmd, 'settlements', {
134
+ tool: 'polymarket_settlements_bulk',
135
+ desc: 'On-chain resolutions of many markets, oldest resolution first',
136
+ options: [
137
+ FROM, TO,
138
+ ['--slug-prefix <p>', 'Only markets whose slug starts with this, e.g. btc-updown-5m-'],
139
+ CURSOR,
140
+ ['--limit <n>', 'Rows per page (default 100)'],
141
+ ],
142
+ });
143
+
144
+ addTool(cmd, 'archive', {
145
+ tool: 'polymarket_archive',
146
+ desc: 'Index of the Parquet L2 archive, or a 7-day download link for one file (--view url)',
147
+ options: [
148
+ ['--view <v>', 'datasets | url (default datasets)'],
149
+ ['--stream <s>', 'book_events | trades | l1_quotes (required for url)'],
150
+ ['--date <d>', 'url: the UTC day, YYYY-MM-DD'],
151
+ ['--date-from <d>', 'datasets: first UTC day, inclusive'],
152
+ ['--date-to <d>', 'datasets: last UTC day, inclusive'],
153
+ ['--asset <a>', 'Family files only, e.g. btc (with --tenor)'],
154
+ ['--tenor <t>', 'Family files only, e.g. 5m (with --asset)'],
155
+ ['--limit <n>', 'datasets: files per page (default 50)'],
156
+ ['--offset <n>', 'datasets: skip this many files'],
157
+ ],
158
+ });
159
+
160
+ addTool(cmd, 'leaderboard', {
161
+ tool: 'polymarket_leaderboard',
162
+ desc: 'Wallets ranked by realized P&L, hit rate, volume or trade count',
163
+ options: [
164
+ ['--category <c>', 'all | Crypto | Sports | Politics | Pop-Culture | Tech (default all)'],
165
+ ['--window <w>', '24h | 7d | 30d | all (default all)'],
166
+ ['--sort <s>', 'realized_pnl | realized_pnl_window | hit_rate | resolved | volume_all | trades_all | last_seen | markets | ...'],
167
+ ['--min-resolved <n>', 'Minimum resolved markets (default 20)'],
168
+ ['--min-volume-usd <usd>', 'Minimum lifetime volume in USD'],
169
+ ['--min-avg-usd <usd>', 'Minimum average trade in USD (20 drops bots)'],
170
+ ['--limit <n>', 'Rows per page (default 20, max 50)'],
171
+ ['--offset <n>', 'Rows to skip, 0-1000'],
172
+ ],
173
+ });
74
174
  }
@@ -0,0 +1,10 @@
1
+ import { addTool } from './tool-command.js';
2
+
3
+ export function registerSearch(program) {
4
+ addTool(program, 'search <q>', {
5
+ tool: 'search',
6
+ args: ['q'],
7
+ desc: 'Search everything by name, symbol, address, ENS name or polymarket.com link: tokens, wallets and entities, HL traders, vaults, builders, Polymarket wallets and markets',
8
+ options: [['--limit <n>', 'Rows per section, 1-20 (default 5)']],
9
+ });
10
+ }
@@ -0,0 +1,308 @@
1
+ import WebSocket from 'ws';
2
+ import { requireApiKey } from '../auth.js';
3
+ import { USER_AGENT, callTool, redact } from '../mcp-client.js';
4
+ import { emit, emitError } from '../format.js';
5
+ import { addTool } from './tool-command.js';
6
+
7
+ // The four event streams on stream.vike.io; a channel belongs to one of them. The protocol (frames,
8
+ // close codes, resume) is https://vike.io/docs/data/reference/stream-hl.
9
+ export const STREAM_PATHS = ['hl', 'hl-cohorts', 'onchain', 'polymarket'];
10
+ const MAX_SUBSCRIPTIONS = 100; // per connection, the server's cap
11
+ const IDLE_MS = 60_000; // the server pings every 15 s: a minute of silence is a dead connection
12
+ const NUMBER_KEYS = new Set(['min_usd', 'min_amount', 'min_move']);
13
+ const ONE_VALUE_KEYS = new Set(['side', 'direction', 'event', 'axis', 'move', 'role']); // every other key is a list
14
+ // No channel list here: the server decides which channels a path serves (`stream info` lists them) and refuses
15
+ // an unknown one with `unknown_channel`.
16
+ const FATAL_ERRORS = new Set(['bad_subscription', 'unknown_channel', 'rate_limited']);
17
+ const FRESH_ERRORS = new Set(['cursor_expired', 'cursor_ahead']); // resubscribe from now
18
+
19
+ // `--filter k=v` pairs -> the subscribe frame's filter object. A list key takes comma-separated values and
20
+ // repeats add to it; the server validates the keys and values and names a bad one.
21
+ export function parseFilters(pairs = []) {
22
+ const filter = {};
23
+ for (const pair of pairs) {
24
+ const i = pair.indexOf('=');
25
+ if (i < 1) throw new Error(`--filter takes key=value, got ${JSON.stringify(pair)}`);
26
+ const key = pair.slice(0, i).trim();
27
+ const value = pair.slice(i + 1).trim();
28
+ if (NUMBER_KEYS.has(key)) {
29
+ const n = Number(value);
30
+ if (value === '' || !Number.isFinite(n)) throw new Error(`--filter ${key} must be a number, got ${JSON.stringify(value)}`);
31
+ filter[key] = n;
32
+ } else if (value === 'true' || value === 'false') {
33
+ filter[key] = value === 'true'; // a yes/no key, e.g. contract=false on chain.transfers
34
+ } else if (ONE_VALUE_KEYS.has(key)) {
35
+ filter[key] = value;
36
+ } else {
37
+ filter[key] = [...(filter[key] ?? []), ...value.split(',').map((v) => v.trim()).filter(Boolean)];
38
+ }
39
+ }
40
+ return filter;
41
+ }
42
+
43
+ // The later of two cursors; they are fixed-width, so text order is time order.
44
+ const later = (a, b) => (b === undefined || (a !== undefined && a >= b) ? a : b);
45
+
46
+ const backoff = (n) => Math.min(15_000, 1000 * 2 ** (n - 1)); // 1, 2, 4, 8, 15 s
47
+ const MAX_CATCHUP_RETRIES = 5; // then the subscription continues from now and says what it skipped
48
+
49
+ // Run the subscriptions until a close the CLI must not retry; resolves to the exit code. Events and gap frames go
50
+ // to `out` as received (one JSON object per line, so 64-bit integers stay exact); `out` returning false (stdout
51
+ // full) pauses the socket until `drain` calls back. Status lines go to `log`. Every other end reconnects with the
52
+ // last cursor of each subscription.
53
+ export async function runStream({
54
+ url, apiKey, subs, out, log,
55
+ drain = (cb) => cb(),
56
+ WebSocketImpl = WebSocket,
57
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
58
+ }) {
59
+ const cursors = new Map(subs.filter((s) => s.cursor).map((s) => [s.id, s.cursor]));
60
+ const ctx = { url, headers: { 'X-API-KEY': apiKey, 'User-Agent': USER_AGENT }, subs, cursors,
61
+ catchups: new Map(), out, drain, log, sleep, WS: WebSocketImpl };
62
+ let failures = 0;
63
+ for (;;) {
64
+ const r = await session(ctx);
65
+ if (r.exit !== undefined) return r.exit;
66
+ failures = r.opened ? 0 : failures + 1;
67
+ // A connection that had opened waits 1 s, so a server that keeps closing at once is not hammered. A handshake
68
+ // that keeps failing (stream restarting) retries at once, then after 1, 2, 4 ... 15 s.
69
+ const wait = r.opened ? 1000 : failures > 1 ? backoff(failures - 1) : 0;
70
+ if (wait) await sleep(wait);
71
+ }
72
+ }
73
+
74
+ function session({ url, headers, subs, cursors, catchups, out, drain, log, sleep, WS }) {
75
+ return new Promise((resolve) => {
76
+ let opened = false;
77
+ let done = false;
78
+ let paused = false;
79
+ let idle;
80
+ const ws = new WS(url, { headers, handshakeTimeout: 30_000 });
81
+ const finish = (r) => {
82
+ if (done) return;
83
+ done = true;
84
+ clearTimeout(idle);
85
+ resolve({ opened, ...r });
86
+ };
87
+ const stop = (exit) => { finish({ exit }); ws.terminate(); };
88
+ const alive = () => {
89
+ clearTimeout(idle);
90
+ if (paused) return; // no frames while we hold the socket: that silence is ours
91
+ idle = setTimeout(() => { log(`no frame for ${IDLE_MS / 1000} s; connecting again`); ws.terminate(); }, IDLE_MS);
92
+ };
93
+ // stdout is full (a slow reader): stop reading the socket instead of buffering without bound. Frames the socket
94
+ // had already read still arrive, and pings among them are answered first. The server closes a reader stalled
95
+ // for 30 s with 4408, and the reconnect resumes from the cursors.
96
+ const print = (text) => {
97
+ if (out(text) !== false || paused) return;
98
+ paused = true;
99
+ clearTimeout(idle);
100
+ ws.pause();
101
+ drain(() => { paused = false; if (!done) { ws.resume(); alive(); } });
102
+ };
103
+ const subscribe = (s) => {
104
+ const msg = { action: 'subscribe', id: s.id, channel: s.channel };
105
+ if (s.filter) msg.filter = s.filter;
106
+ if (cursors.has(s.id)) msg.cursor = cursors.get(s.id);
107
+ ws.send(JSON.stringify(msg));
108
+ };
109
+
110
+ ws.on('unexpected-response', (_req, res) => {
111
+ let body = '';
112
+ res.on('data', (chunk) => { body += chunk; });
113
+ res.on('end', () => {
114
+ const status = res.statusCode;
115
+ let why = body.slice(0, 200);
116
+ try { const j = JSON.parse(body); why = j.message ?? j.error?.message ?? why; } catch { /* HTML or empty */ }
117
+ // 401: the key; 403 / 410: the wrong address. Anything else (503, Cloudflare 521/522) is worth another try.
118
+ if ([401, 403, 410].includes(status)) {
119
+ log(`refused: HTTP ${status} ${why}`);
120
+ stop(1);
121
+ } else {
122
+ log(`refused: HTTP ${status} ${why}; connecting again`);
123
+ stop(undefined);
124
+ }
125
+ });
126
+ });
127
+ ws.on('error', (err) => { if (!done) log(`connection error: ${redact(err.message, headers['X-API-KEY'])}`); });
128
+ ws.on('open', () => {
129
+ opened = true;
130
+ alive();
131
+ for (const s of subs) subscribe(s);
132
+ });
133
+ ws.on('message', (data) => {
134
+ if (done) return;
135
+ alive();
136
+ const text = String(data);
137
+ let f;
138
+ try { f = JSON.parse(text); } catch { return; }
139
+ if (f.type === 'ping') {
140
+ ws.send('{"action":"pong"}');
141
+ } else if (f.type === 'event') {
142
+ for (const id of f.ids ?? []) {
143
+ cursors.set(id, later(cursors.get(id), f.cursor));
144
+ catchups.delete(id); // the subscription is delivering: its catch-up retries start over
145
+ }
146
+ print(text);
147
+ } else if (f.type === 'gap') {
148
+ // About the channel, not one subscription: every subscription on it moves past the window.
149
+ for (const s of subs) if (s.channel === f.channel) cursors.set(s.id, later(cursors.get(s.id), f.cursor));
150
+ print(text);
151
+ } else if (f.type === 'subscribed') {
152
+ if (f.cursor) cursors.set(f.id, later(cursors.get(f.id), f.cursor));
153
+ log(`subscribed ${f.id} to ${f.channel} at ${f.cursor} (replay: ${f.replay?.from ?? 'none'})`);
154
+ } else if (f.type === 'error') {
155
+ log(`error: ${text}`);
156
+ const sub = subs.find((s) => s.id === f.id);
157
+ if (FATAL_ERRORS.has(f.code)) {
158
+ stop(1);
159
+ } else if (sub && FRESH_ERRORS.has(f.code)) {
160
+ cursors.delete(sub.id);
161
+ subscribe(sub);
162
+ } else if (sub && f.code === 'replay_too_large') {
163
+ if (!f.cursor || f.cursor === cursors.get(sub.id)) {
164
+ log('the replay is too large even from the last cursor: narrow the filter');
165
+ stop(1);
166
+ return;
167
+ }
168
+ cursors.set(sub.id, f.cursor);
169
+ subscribe(sub);
170
+ } else if (sub && f.code === 'catchup_unavailable') {
171
+ // The server could not read the replay (its store down or the read timing out): retry after 1, 2, 4,
172
+ // 8, 15 s, then continue from now and say which window was skipped.
173
+ if (f.cursor) cursors.set(sub.id, f.cursor);
174
+ const n = (catchups.get(sub.id) ?? 0) + 1;
175
+ if (n > MAX_CATCHUP_RETRIES) {
176
+ const from = cursors.get(sub.id);
177
+ catchups.delete(sub.id);
178
+ cursors.delete(sub.id);
179
+ log(`catch-up of ${sub.channel} failed ${MAX_CATCHUP_RETRIES} times: continuing from now. The events `
180
+ + `after cursor ${from} up to now were SKIPPED; read them with `
181
+ + `\`vike-data stream events ${sub.channel} --cursor ${from}\` (same --filter).`);
182
+ subscribe(sub);
183
+ } else {
184
+ catchups.set(sub.id, n);
185
+ sleep(backoff(n)).then(() => { if (!done) subscribe(sub); });
186
+ }
187
+ }
188
+ // slow_consumer, server_catching_up, replaced, memory_backstop, ping_timeout: the close follows.
189
+ }
190
+ });
191
+ ws.on('close', (code, reason) => {
192
+ if (done) return;
193
+ const why = String(reason ?? '');
194
+ if (code === 4410) {
195
+ log('replaced (4410): a newer connection of this key took over this address (one connection per key '
196
+ + 'per address, 3 per account). Not reconnecting: give each program its own key.');
197
+ finish({ exit: 1 });
198
+ } else if (code === 4401) {
199
+ log(`unauthorized (4401)${why ? `: ${why}` : ''}`);
200
+ finish({ exit: 1 });
201
+ } else {
202
+ log(`closed (${code}${why ? ` ${why}` : ''}); connecting again from the last cursors`);
203
+ finish({});
204
+ }
205
+ });
206
+ });
207
+ }
208
+
209
+ const FILTER_OPTION = ['--filter <key=value...>', 'Filter, repeatable: coin=BTC,ETH min_usd=50000 wallet=0x... side=buy'];
210
+
211
+ export function registerStream(program) {
212
+ // `stream check --filter ...`: the subcommand's flags must stay the subcommand's, not go to `stream <path>`,
213
+ // which has the same names. Commander needs positional options on the parent chain for that; the root has
214
+ // only --version and --help, which keep working before a command name.
215
+ program.enablePositionalOptions();
216
+ const cmd = program
217
+ .command('stream <path>')
218
+ .enablePositionalOptions()
219
+ .description(`Print live events as NDJSON from wss://stream.vike.io/v1/stream/<path> (${STREAM_PATHS.join(', ')}); reconnects and resumes by itself`)
220
+ // Checked in the action, not requiredOption: commander checks a parent's required options for its subcommands too.
221
+ .option('--channel <channel...>', 'Required. Channel(s), one subscription each, e.g. hl.fills hl.liquidations (hl), chain.transfers (onchain), pm.trades (polymarket)')
222
+ .option(FILTER_OPTION[0], `${FILTER_OPTION[1]} (applies to every --channel)`)
223
+ .option('--cursor <cursor>', 'Resume from this cursor (from a frame, at most 3 days old) instead of starting now')
224
+ .action(async (path, opts) => {
225
+ try {
226
+ if (!STREAM_PATHS.includes(path)) {
227
+ throw new Error(`unknown stream ${JSON.stringify(path)}: use one of ${STREAM_PATHS.join(', ')}`);
228
+ }
229
+ if (!opts.channel) throw new Error('--channel is required, e.g. --channel hl.fills (`vike-data stream info` lists the channels)');
230
+ const channels = [...new Set(opts.channel)];
231
+ if (channels.length > MAX_SUBSCRIPTIONS) {
232
+ throw new Error(`at most ${MAX_SUBSCRIPTIONS} subscriptions per connection, got ${channels.length}`);
233
+ }
234
+ const filter = parseFilters(opts.filter);
235
+ const subs = channels.map((channel) => ({
236
+ id: channel.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 32),
237
+ channel,
238
+ filter: Object.keys(filter).length ? filter : undefined,
239
+ cursor: opts.cursor,
240
+ }));
241
+ const log = (line) => process.stderr.write(`vike-data stream: ${line}\n`);
242
+ // The reader went away (`| head -5`): that is the end the user asked for, not an error.
243
+ process.stdout.on('error', (err) => { if (err.code === 'EPIPE') process.exit(0); else throw err; });
244
+ log(`one connection per key on each address: another program streaming ${path} with this key replaces this one`);
245
+ process.exitCode = await runStream({
246
+ url: `wss://stream.vike.io/v1/stream/${path}`,
247
+ apiKey: requireApiKey(),
248
+ subs,
249
+ out: (line) => process.stdout.write(line + '\n'),
250
+ drain: (cb) => process.stdout.once('drain', cb),
251
+ log,
252
+ });
253
+ } catch (err) { emitError(err, opts); }
254
+ });
255
+
256
+ // The stream's MCP tools: no socket, so no connection of the key is replaced.
257
+ addTool(cmd, 'info', {
258
+ tool: 'stream_info',
259
+ desc: 'Every path, channel and filter key, the limits and close codes, from the server\'s own registry',
260
+ });
261
+
262
+ cmd
263
+ .command('check <channel>')
264
+ .description('Check a channel and filter with the stream server\'s own validator, without connecting (exit 1 if refused)')
265
+ .option(...FILTER_OPTION)
266
+ .option('--path <path>', `The endpoint the connection would use: ${STREAM_PATHS.join(' | ')} (default the channel's own)`)
267
+ .option('--json', 'Emit JSON')
268
+ .action(async (channel, opts) => {
269
+ try {
270
+ const filter = parseFilters(opts.filter);
271
+ const r = await callTool('stream_check_filter', {
272
+ channel, filter: Object.keys(filter).length ? filter : undefined, path: opts.path,
273
+ });
274
+ emit(r, opts);
275
+ if (r?.valid === false) process.exitCode = 1;
276
+ } catch (err) { emitError(err, opts); }
277
+ });
278
+
279
+ cmd
280
+ .command('events <channel>')
281
+ .description('Stored events of one channel after a cursor, oldest first (the REST catch-up, kept 3 days)')
282
+ .option(...FILTER_OPTION)
283
+ .option('--cursor <cursor>', 'next_cursor of the previous page, or a frame\'s cursor (omit for the oldest kept)')
284
+ .option('--since-ms <ms>', 'Start after this arrival time, epoch ms (instead of --cursor)')
285
+ .option('--limit <n>', 'Events per page, 1-1000 (default 200)')
286
+ .option('--json', 'Emit JSON')
287
+ .action(async (channel, opts) => {
288
+ try {
289
+ const filter = parseFilters(opts.filter);
290
+ emit(await callTool('stream_events', {
291
+ channel,
292
+ filter: Object.keys(filter).length ? filter : undefined,
293
+ cursor: opts.cursor,
294
+ since_ms: opts.sinceMs,
295
+ limit: opts.limit,
296
+ }), opts);
297
+ } catch (err) { emitError(err, opts); }
298
+ });
299
+
300
+ addTool(cmd, 'connections', {
301
+ tool: 'stream_connections',
302
+ desc: 'Your account\'s recent stream connections: path, key, state, close code (4410 = replaced), channels',
303
+ options: [
304
+ ['--hours <n>', 'How far back, 1-720 (default 168)'],
305
+ ['--limit <n>', 'Connections, newest first, 1-1000 (default 50)'],
306
+ ],
307
+ });
308
+ }
@@ -30,6 +30,7 @@ export function registerToken(program) {
30
30
  .option('--period <p>', '1h | 24h | 7d | 30d', '24h')
31
31
  .option('--tab <t>', 'bought | sold', 'sold')
32
32
  .option('--chain <chain>', 'ethereum | bsc | base | all', 'ethereum')
33
+ .option('--hide-contracts', 'Leave out pools, routers and other contracts (the website\'s "Hide pools & routers")')
33
34
  .option('--json', 'Emit JSON')
34
35
  .action(async (tokenAddress, opts) => {
35
36
  try {
@@ -38,6 +39,7 @@ export function registerToken(program) {
38
39
  period: opts.period,
39
40
  tab: opts.tab,
40
41
  chain: opts.chain,
42
+ hide_contracts: opts.hideContracts,
41
43
  });
42
44
  emit(r, opts);
43
45
  } catch (err) { emitError(err, opts); }
@@ -0,0 +1,27 @@
1
+ import { callTool } from '../mcp-client.js';
2
+ import { emit, emitError } from '../format.js';
3
+
4
+ // Register `<group> <spec>` as one call to one MCP tool. `args` names the tool parameters the
5
+ // positional arguments of `spec` fill, in order. Each flag is forwarded under its snake_case
6
+ // parameter name (--from-ms -> from_ms) and an unset flag is not sent: a view-based tool refuses
7
+ // a parameter its view does not take, and the server's defaults stay the defaults. Values go as
8
+ // typed; the server casts them and names the fix when one is wrong.
9
+ export function addTool(group, spec, { tool, desc, args = [], options = [] }) {
10
+ const cmd = group.command(spec).description(desc);
11
+ for (const [flags, text] of options) cmd.option(flags, text);
12
+ cmd.option('--json', 'Emit JSON');
13
+ cmd.action(async (...received) => {
14
+ received.pop(); // the Command
15
+ const opts = received.pop();
16
+ const payload = {};
17
+ args.forEach((name, i) => { payload[name] = received[i]; });
18
+ for (const [key, value] of Object.entries(opts)) {
19
+ if (value === undefined || key === 'json') continue;
20
+ payload[key.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`)] = value;
21
+ }
22
+ try {
23
+ emit(await callTool(tool, payload), opts);
24
+ } catch (err) { emitError(err, opts); }
25
+ });
26
+ return cmd;
27
+ }
@@ -1,6 +1,7 @@
1
1
  import { callTool } from '../mcp-client.js';
2
2
  import { emit, emitError } from '../format.js';
3
3
  import { parseInt10 } from '../util.js';
4
+ import { addTool } from './tool-command.js';
4
5
 
5
6
  export function registerWallet(program) {
6
7
  const cmd = program
@@ -184,4 +185,24 @@ export function registerWallet(program) {
184
185
  emit(r, opts);
185
186
  } catch (err) { emitError(err, opts); }
186
187
  });
188
+
189
+ const CHAIN = ['--chain <c>', 'ethereum | bsc | base | hyperevm (default ethereum)'];
190
+
191
+ addTool(cmd, 'first-funder <address>', {
192
+ tool: 'wallet_first_funder',
193
+ args: ['address'],
194
+ desc: 'The earliest inbound native coin and token transfers of a wallet, with the senders\' labels',
195
+ options: [
196
+ CHAIN,
197
+ ['--min-native <n>', 'Dust floor in WHOLE coins (default 0.0001; 0 = none)'],
198
+ ['--min-usd <n>', 'Floor for the token side in USD (default 1; 0 = unpriced included)'],
199
+ ],
200
+ });
201
+
202
+ addTool(cmd, 'exchange-usage <address>', {
203
+ tool: 'wallet_exchange_usage',
204
+ args: ['address'],
205
+ desc: 'Which centralised exchanges a wallet sent to and received from, with USD and shares',
206
+ options: [CHAIN, ['--window <w>', '30d | 90d | 180d | 365d | all (default 365d)']],
207
+ });
187
208
  }
package/src/index.js CHANGED
@@ -13,7 +13,11 @@ import { registerInit } from './commands/init.js';
13
13
  import { registerInstall } from './commands/install.js';
14
14
  import { registerDoctor } from './commands/doctor.js';
15
15
  import { registerSchema } from './commands/schema.js';
16
- import { registerLogin, registerLogout } from './commands/auth.js';
16
+ import { registerLogin, registerLogout, registerWhoami } from './commands/auth.js';
17
+ import { registerCall } from './commands/call.js';
18
+ import { registerHl } from './commands/hl.js';
19
+ import { registerStream } from './commands/stream.js';
20
+ import { registerSearch } from './commands/search.js';
17
21
  import { readPackageVersion } from './util.js';
18
22
  import {
19
23
  getUpdateNotification,
@@ -53,6 +57,7 @@ program.hook('postAction', (_thisCmd, actionCmd) => {
53
57
 
54
58
  registerLogin(program);
55
59
  registerLogout(program);
60
+ registerWhoami(program);
56
61
  registerInit(program);
57
62
  registerInstall(program);
58
63
  registerDoctor(program);
@@ -64,6 +69,10 @@ registerAlerts(program);
64
69
  registerPolymarket(program);
65
70
  registerLabels(program);
66
71
  registerCandles(program);
72
+ registerHl(program);
73
+ registerStream(program);
74
+ registerSearch(program);
75
+ registerCall(program);
67
76
 
68
77
  const _parseT0 = Date.now();
69
78
  program.parseAsync(process.argv)
package/src/mcp-client.js CHANGED
@@ -1,33 +1,49 @@
1
1
  import { requireApiKey, getEndpoint } from './auth.js';
2
+ import { readPackageVersion } from './util.js';
3
+
4
+ // The installed package's own version: npm_package_version is set only under `npm run`.
5
+ export const USER_AGENT = `vike-cli/${readPackageVersion()}`;
2
6
 
3
7
  let _id = 0;
4
8
  function nextId() { return ++_id; }
5
9
 
10
+ // A fetch error can quote a header value (an invalid one, e.g. a key pasted with a newline): never echo the key.
11
+ export function redact(message, apiKey) {
12
+ return apiKey ? String(message).split(apiKey).join('vk_***') : String(message);
13
+ }
14
+
15
+ // fetch, with any error message cleared of the key.
16
+ async function post(endpoint, apiKey, init) {
17
+ try {
18
+ return await fetch(endpoint, init);
19
+ } catch (err) {
20
+ throw Object.assign(new Error(redact(err.message, apiKey)), { name: err.name });
21
+ }
22
+ }
23
+
6
24
  export async function callTool(toolName, args = {}, opts = {}) {
7
25
  const apiKey = opts.apiKey ?? requireApiKey();
8
26
  const endpoint = opts.endpoint ?? getEndpoint();
9
27
  const timeoutMs = opts.timeoutMs ?? 60_000;
10
28
 
11
- const body = {
12
- jsonrpc: '2.0',
13
- id: nextId(),
14
- method: 'tools/call',
15
- params: { name: toolName, arguments: args },
16
- };
29
+ // argsText: the caller's own JSON object text, sent byte for byte (a 77-digit number is not rounded).
30
+ const envelope = JSON.stringify({ jsonrpc: '2.0', id: nextId(), method: 'tools/call' });
31
+ const body = `${envelope.slice(0, -1)},"params":{"name":${JSON.stringify(toolName)},`
32
+ + `"arguments":${opts.argsText ?? JSON.stringify(args)}}}`;
17
33
 
18
34
  const ctrl = new AbortController();
19
35
  const t = setTimeout(() => ctrl.abort(), timeoutMs);
20
36
 
21
37
  let resp;
22
38
  try {
23
- resp = await fetch(endpoint, {
39
+ resp = await post(endpoint, apiKey, {
24
40
  method: 'POST',
25
41
  headers: {
26
42
  'Content-Type': 'application/json',
27
43
  'X-API-KEY': apiKey,
28
- 'User-Agent': `vike-cli/${process.env.npm_package_version ?? 'dev'}`,
44
+ 'User-Agent': USER_AGENT,
29
45
  },
30
- body: JSON.stringify(body),
46
+ body,
31
47
  signal: ctrl.signal,
32
48
  });
33
49
  } catch (err) {
@@ -52,10 +68,15 @@ export async function callTool(toolName, args = {}, opts = {}) {
52
68
 
53
69
  // tools/call returns { content: [{type:'text', text: JSON}], isError }
54
70
  const content = json.result?.content?.[0]?.text;
55
- if (!content) return json.result;
56
- try { return JSON.parse(content); } catch {
57
- return content;
71
+ let parsed = content;
72
+ try { parsed = JSON.parse(content); } catch { /* plain text */ }
73
+ if (json.result?.isError) {
74
+ // An in-band failure ({"error": ...}) is an error in every output mode, so the exit code is 1.
75
+ throw new Error(String(parsed?.error ?? parsed?.[0]?.error ?? content ?? 'tool error'));
58
76
  }
77
+ if (!content) return json.result;
78
+ // raw: the tool's text as sent, so integers above 2^53 survive.
79
+ return opts.raw ? content : parsed;
59
80
  }
60
81
 
61
82
  export async function listTools(opts = {}) {
@@ -67,9 +88,9 @@ export async function listTools(opts = {}) {
67
88
  method: 'tools/list',
68
89
  params: {},
69
90
  };
70
- const resp = await fetch(endpoint, {
91
+ const resp = await post(endpoint, apiKey, {
71
92
  method: 'POST',
72
- headers: { 'Content-Type': 'application/json', 'X-API-KEY': apiKey },
93
+ headers: { 'Content-Type': 'application/json', 'X-API-KEY': apiKey, 'User-Agent': USER_AGENT },
73
94
  body: JSON.stringify(body),
74
95
  });
75
96
  const json = await resp.json();
@@ -82,9 +103,9 @@ export async function ping(opts = {}) {
82
103
  // JSON-RPC "Invalid or missing API key" error (soon HTTP 401), so fail early.
83
104
  const endpoint = opts.endpoint ?? getEndpoint();
84
105
  const apiKey = opts.apiKey ?? requireApiKey();
85
- const resp = await fetch(endpoint, {
106
+ const resp = await post(endpoint, apiKey, {
86
107
  method: 'POST',
87
- headers: { 'Content-Type': 'application/json', 'X-API-KEY': apiKey },
108
+ headers: { 'Content-Type': 'application/json', 'X-API-KEY': apiKey, 'User-Agent': USER_AGENT },
88
109
  body: JSON.stringify({
89
110
  jsonrpc: '2.0', id: nextId(), method: 'initialize', params: {},
90
111
  }),