@vike-io/cli 0.14.0 → 0.16.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 +30 -0
- package/README.md +32 -14
- package/package.json +4 -4
- package/skills/vike/SKILL.md +75 -37
- package/skills/vike-alerts-setup/SKILL.md +42 -10
- package/skills/vike-alerts-webhook/SKILL.md +18 -10
- package/skills/vike-candles/SKILL.md +10 -2
- package/skills/vike-core/SKILL.md +6 -3
- package/skills/vike-daily-brief/SKILL.md +68 -0
- package/skills/vike-follow-the-money/SKILL.md +60 -0
- package/skills/vike-hl-asset-briefing/SKILL.md +48 -0
- package/skills/vike-hl-builder-intel/SKILL.md +39 -0
- package/skills/vike-hl-cohort-backtest/SKILL.md +61 -0
- package/skills/vike-hl-cohort-positioning/SKILL.md +58 -0
- package/skills/vike-hl-liquidation-radar/SKILL.md +49 -0
- package/skills/vike-hl-position-match/SKILL.md +4 -3
- package/skills/vike-hl-top-traders/SKILL.md +22 -9
- package/skills/vike-hl-trader-profile/SKILL.md +65 -0
- package/skills/vike-perp-crowding/SKILL.md +49 -0
- package/skills/vike-perp-funding/SKILL.md +4 -1
- package/skills/vike-perp-overview/SKILL.md +23 -3
- package/skills/vike-pm-l2-archive/SKILL.md +50 -0
- package/skills/vike-pm-market-diligence/SKILL.md +60 -0
- package/skills/vike-polymarket-market-detail/SKILL.md +20 -15
- package/skills/vike-polymarket-markets/SKILL.md +2 -1
- package/skills/vike-polymarket-smart-money/SKILL.md +1 -0
- package/skills/vike-polymarket-trader/SKILL.md +2 -1
- package/skills/vike-smart-money-discovery/SKILL.md +4 -3
- package/skills/vike-stream/SKILL.md +99 -0
- package/skills/vike-token-holder-diligence/SKILL.md +58 -0
- package/skills/vike-token-holders/SKILL.md +6 -4
- package/skills/vike-token-research/SKILL.md +6 -6
- package/skills/vike-token-screener/SKILL.md +3 -3
- package/skills/vike-token-search/SKILL.md +2 -1
- package/skills/vike-token-smart-money-trend/SKILL.md +57 -0
- package/skills/vike-token-transfers/SKILL.md +8 -6
- package/skills/vike-wallet-balances/SKILL.md +9 -7
- package/skills/vike-wallet-batch/SKILL.md +3 -3
- package/skills/vike-wallet-compare/SKILL.md +2 -2
- package/skills/vike-wallet-counterparties/SKILL.md +2 -2
- package/skills/vike-wallet-discover/SKILL.md +3 -1
- package/skills/vike-wallet-ens/SKILL.md +2 -2
- package/skills/vike-wallet-label-provenance/SKILL.md +5 -4
- package/skills/vike-wallet-pnl-history/SKILL.md +2 -2
- package/skills/vike-wallet-portfolio/SKILL.md +10 -8
- package/skills/vike-wallet-summary/SKILL.md +6 -6
- package/skills/vike-wallet-trace/SKILL.md +4 -3
- package/skills/vike-wallet-transactions/SKILL.md +2 -2
- package/skills/vike-wallet-who-is/SKILL.md +50 -0
- package/skills/vike-wallets-related/SKILL.md +52 -0
- package/skills.sh.json +34 -12
- package/src/commands/alerts.js +85 -15
- package/src/commands/call.js +32 -0
- package/src/commands/candles.js +11 -15
- package/src/commands/doctor.js +4 -4
- package/src/commands/hl.js +274 -0
- package/src/commands/perp.js +5 -5
- package/src/commands/polymarket.js +105 -5
- package/src/commands/search.js +10 -0
- package/src/commands/stream.js +308 -0
- package/src/commands/token.js +2 -0
- package/src/commands/tool-command.js +27 -0
- package/src/commands/wallet.js +21 -0
- package/src/index.js +10 -1
- package/src/mcp-client.js +37 -16
|
@@ -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
|
+
}
|
package/src/commands/token.js
CHANGED
|
@@ -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
|
+
}
|
package/src/commands/wallet.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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':
|
|
44
|
+
'User-Agent': USER_AGENT,
|
|
29
45
|
},
|
|
30
|
-
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
|
-
|
|
56
|
-
try {
|
|
57
|
-
|
|
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
|
|
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
|
|
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
|
}),
|