nansen-cli 1.43.1 → 1.44.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 +66 -0
- package/README.md +34 -0
- package/package.json +1 -1
- package/src/api.js +184 -23
- package/src/bridge.js +103 -5
- package/src/cli.js +42 -52
- package/src/commands/completion.js +652 -0
- package/src/commands/mcp.js +19 -3
- package/src/commands/research.js +199 -25
- package/src/hl-client.js +20 -8
- package/src/limit-order.js +31 -20
- package/src/mcp-verify.js +66 -1
- package/src/perp.js +72 -24
- package/src/query-options.js +32 -0
- package/src/schema.json +631 -131
- package/src/semver.js +26 -0
- package/src/telemetry.js +118 -19
- package/src/trading.js +196 -22
- package/src/update-check.js +13 -6
- package/src/x402.js +8 -1
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nansen CLI - shell completion generator
|
|
3
|
+
*
|
|
4
|
+
* `nansen completion <bash|zsh|fish>` prints a completion script built from
|
|
5
|
+
* src/schema.json, the same source of truth `nansen schema` and `--help` read.
|
|
6
|
+
* Generating instead of checking in three hand-written scripts is what keeps
|
|
7
|
+
* completions from drifting the moment a command or flag is added.
|
|
8
|
+
*
|
|
9
|
+
* Purely local: no disk writes, no network calls.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { CommandError } from '../api.js';
|
|
13
|
+
import { createRequire } from 'module';
|
|
14
|
+
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
const schemaDefinition = require('../schema.json');
|
|
17
|
+
const { version: VERSION } = require('../../package.json');
|
|
18
|
+
|
|
19
|
+
export const COMPLETION_SHELLS = ['bash', 'zsh', 'fish'];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Commands the CLI dispatches that schema.json does not describe. Adding them
|
|
23
|
+
* to schema.json instead would change `nansen logout --help` (the schema help
|
|
24
|
+
* path runs before the hand-written text), so they are declared here.
|
|
25
|
+
* completion.test.js fails if a new top-level command appears in neither place.
|
|
26
|
+
*/
|
|
27
|
+
export const UNSCHEMA_COMMANDS = {
|
|
28
|
+
login: {
|
|
29
|
+
description: 'Save your Nansen API key',
|
|
30
|
+
options: {
|
|
31
|
+
'api-key': { type: 'string', description: 'API key (recorded in shell history — prefer --human)' },
|
|
32
|
+
human: { type: 'boolean', description: 'Prompt for the key interactively' },
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
logout: { description: 'Remove the saved API key' },
|
|
36
|
+
schema: {
|
|
37
|
+
description: 'Print the JSON schema for every command',
|
|
38
|
+
options: { full: { type: 'boolean', description: 'Verbose schema instead of the compact listing' } },
|
|
39
|
+
},
|
|
40
|
+
cache: {
|
|
41
|
+
description: 'API response cache maintenance',
|
|
42
|
+
subcommands: { clear: { description: 'Clear all cached responses' } },
|
|
43
|
+
},
|
|
44
|
+
changelog: {
|
|
45
|
+
description: 'Show release history',
|
|
46
|
+
options: { since: { type: 'string', description: 'Only show versions at or above this one' } },
|
|
47
|
+
},
|
|
48
|
+
help: { description: 'Show the top-level help' },
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Deprecated top-level aliases still routed by runCLI. Left out on purpose:
|
|
53
|
+
* completion is a discoverability surface, and suggesting `nansen token ...`
|
|
54
|
+
* teaches the spelling we are trying to retire.
|
|
55
|
+
*/
|
|
56
|
+
export const EXCLUDED_COMMANDS = new Set([
|
|
57
|
+
'smart-money', 'profiler', 'token', 'search', 'portfolio', 'points', 'prediction-market',
|
|
58
|
+
'quote', 'execute',
|
|
59
|
+
// Undocumented top-level alias of `trade bridge-status`: reachable because
|
|
60
|
+
// runCLI spreads buildTradingCommands over the root, absent from HELP.
|
|
61
|
+
'bridge-status',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Long flags parseArgs treats as valueless but schema.json does not type as
|
|
66
|
+
* boolean (output flags that live only in the parser). Single-dash tokens
|
|
67
|
+
* (-p, -h, -5) never take a value in parseArgs, so the walkers treat every one
|
|
68
|
+
* as valueless without a list. The walker needs a *superset* of the real
|
|
69
|
+
* valueless flags: an extra name here is harmless (the following word is
|
|
70
|
+
* checked against the subcommand table anyway), a missing one makes the walker
|
|
71
|
+
* swallow a real subcommand.
|
|
72
|
+
*/
|
|
73
|
+
const EXTRA_VALUELESS = ['help', 'version', 'cache', 'no-cache', 'stream', 'enrich', 'full', 'human'];
|
|
74
|
+
|
|
75
|
+
// Command, subcommand, option and enum tokens are interpolated straight into
|
|
76
|
+
// shell source. Anything that is not a bare word is dropped rather than escaped
|
|
77
|
+
// — a schema entry with a space or a quote in its *name* is a bug, not input we
|
|
78
|
+
// should try to render.
|
|
79
|
+
const SAFE_TOKEN = /^[A-Za-z0-9_.:@+-]+$/;
|
|
80
|
+
|
|
81
|
+
const MAX_DESC = 72;
|
|
82
|
+
|
|
83
|
+
/** One-line, length-capped description safe to sit inside a quoted shell string. */
|
|
84
|
+
export function shortDesc(text) {
|
|
85
|
+
if (!text) return '';
|
|
86
|
+
const flat = String(text).replace(/\s+/g, ' ').trim();
|
|
87
|
+
return flat.length > MAX_DESC ? `${flat.slice(0, MAX_DESC - 1).trimEnd()}…` : flat;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function safeList(values) {
|
|
91
|
+
return values.map(String).filter(v => SAFE_TOKEN.test(v));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A boolean option never consumes the next word — unless it also declares an
|
|
96
|
+
* enum, which means it accepts an explicit value (`--neg-risk true`, resolved
|
|
97
|
+
* by resolveBooleanOption). Those stay value-taking so the enum is offered
|
|
98
|
+
* after them and the walker skips the value.
|
|
99
|
+
*/
|
|
100
|
+
function isValueless(opt) {
|
|
101
|
+
return opt.type === 'boolean' && !Array.isArray(opt.enum);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Values to offer after an option. Explicit `enum` first; `--chain` under the
|
|
106
|
+
* research tree falls back to schema.chains, which is exactly the list the
|
|
107
|
+
* research endpoints accept. Trade/bridge chains are narrower and are left to
|
|
108
|
+
* their own enums rather than guessed at.
|
|
109
|
+
*/
|
|
110
|
+
function optionValues(path, name, opt, schema) {
|
|
111
|
+
if (Array.isArray(opt.enum)) return safeList(opt.enum);
|
|
112
|
+
if (name === 'chain' && path.split(' ')[0] === 'research') return safeList(schema.chains || []);
|
|
113
|
+
return [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Flatten the schema into one node per command path:
|
|
118
|
+
* { path: 'research token', subcommands: [...], options: [...], args: [...] }
|
|
119
|
+
* The root node has path '' and every top-level command as its subcommands.
|
|
120
|
+
* `args` holds the enum values of a command's positional arguments (schema
|
|
121
|
+
* `args: [{ name, enum }]`); they are offered like subcommands but never
|
|
122
|
+
* extend the command path.
|
|
123
|
+
*/
|
|
124
|
+
export function buildCompletionSpec({ schema = schemaDefinition, version = VERSION } = {}) {
|
|
125
|
+
const nodes = [];
|
|
126
|
+
const valueless = new Set(EXTRA_VALUELESS);
|
|
127
|
+
|
|
128
|
+
const visit = (path, node) => {
|
|
129
|
+
const subEntries = Object.entries(node.subcommands || {})
|
|
130
|
+
.filter(([name]) => SAFE_TOKEN.test(name) && !(path === '' && EXCLUDED_COMMANDS.has(name)));
|
|
131
|
+
const options = [];
|
|
132
|
+
for (const [name, opt] of Object.entries(node.options || {})) {
|
|
133
|
+
if (!SAFE_TOKEN.test(name)) continue;
|
|
134
|
+
if (isValueless(opt)) valueless.add(name);
|
|
135
|
+
options.push({
|
|
136
|
+
name: `--${name}`,
|
|
137
|
+
description: shortDesc(opt.description),
|
|
138
|
+
values: optionValues(path, name, opt, schema),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
nodes.push({
|
|
142
|
+
path,
|
|
143
|
+
subcommands: subEntries.map(([name, sub]) => ({ name, description: shortDesc(sub.description) })),
|
|
144
|
+
options,
|
|
145
|
+
args: safeList((node.args || []).flatMap(a => (Array.isArray(a.enum) ? a.enum : []))),
|
|
146
|
+
});
|
|
147
|
+
for (const [name, sub] of subEntries) visit(path ? `${path} ${name}` : name, sub);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
visit('', { subcommands: { ...schema.commands, ...UNSCHEMA_COMMANDS } });
|
|
151
|
+
|
|
152
|
+
const globalOptions = Object.entries(schema.globalOptions || {})
|
|
153
|
+
.filter(([name]) => SAFE_TOKEN.test(name))
|
|
154
|
+
.map(([name, opt]) => {
|
|
155
|
+
if (isValueless(opt)) valueless.add(name);
|
|
156
|
+
return {
|
|
157
|
+
name: `--${name}`,
|
|
158
|
+
description: shortDesc(opt.description),
|
|
159
|
+
values: Array.isArray(opt.enum) ? safeList(opt.enum) : [],
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
globalOptions.push({ name: '--help', description: 'Show help for this command', values: [] });
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
version,
|
|
166
|
+
nodes,
|
|
167
|
+
globalOptions,
|
|
168
|
+
valuelessFlags: [...valueless].sort().map(f => `--${f}`),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ============= Shell quoting =============
|
|
173
|
+
|
|
174
|
+
// Every interpolated token is SAFE_TOKEN or a shortDesc string, so the only
|
|
175
|
+
// metacharacter that can reach these is a quote inside a description.
|
|
176
|
+
const dq = s => `"${s}"`; // bash: tokens only
|
|
177
|
+
const sq = s => `'${String(s).replace(/'/g, "'\\''")}'`; // bash/zsh
|
|
178
|
+
const fq = s => `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; // fish
|
|
179
|
+
|
|
180
|
+
function header(shell, version, install) {
|
|
181
|
+
return [
|
|
182
|
+
`# nansen ${shell} completion — generated by \`nansen completion ${shell}\` (nansen-cli v${version}).`,
|
|
183
|
+
'# Regenerate after upgrading the CLI; do not edit by hand.',
|
|
184
|
+
...install.map(line => `# ${line}`),
|
|
185
|
+
].join('\n');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Group (path, option) pairs that share an identical value list into one case arm. */
|
|
189
|
+
function valueGroups(nodes) {
|
|
190
|
+
const groups = new Map();
|
|
191
|
+
for (const node of nodes) {
|
|
192
|
+
for (const opt of node.options) {
|
|
193
|
+
if (!opt.values.length) continue;
|
|
194
|
+
const key = opt.values.join(' ');
|
|
195
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
196
|
+
groups.get(key).push(`${node.path}|${opt.name}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return groups;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ============= bash =============
|
|
203
|
+
|
|
204
|
+
export function generateBash(spec) {
|
|
205
|
+
const { nodes, globalOptions, valuelessFlags, version } = spec;
|
|
206
|
+
|
|
207
|
+
const subArms = nodes
|
|
208
|
+
.filter(n => n.subcommands.length)
|
|
209
|
+
.map(n => ` ${dq(n.path)}) echo ${dq(n.subcommands.map(s => s.name).join(' '))} ;;`);
|
|
210
|
+
|
|
211
|
+
const optArms = nodes
|
|
212
|
+
.filter(n => n.options.length)
|
|
213
|
+
.map(n => ` ${dq(n.path)}) echo ${dq(n.options.map(o => o.name).join(' '))} ;;`);
|
|
214
|
+
|
|
215
|
+
const valArms = [...valueGroups(nodes)].map(([values, keys]) =>
|
|
216
|
+
` ${keys.map(dq).join('|')}) echo ${dq(values)} ;;`);
|
|
217
|
+
|
|
218
|
+
const argArms = nodes
|
|
219
|
+
.filter(n => n.args.length)
|
|
220
|
+
.map(n => ` ${dq(n.path)}) echo ${dq(n.args.join(' '))} ;;`);
|
|
221
|
+
|
|
222
|
+
return `${header('bash', version, [
|
|
223
|
+
'Install: eval "$(nansen completion bash)" # add to ~/.bashrc',
|
|
224
|
+
' or: nansen completion bash > /etc/bash_completion.d/nansen',
|
|
225
|
+
])}
|
|
226
|
+
|
|
227
|
+
# Options that never consume the following word. Used to tell an option's value
|
|
228
|
+
# apart from a subcommand while walking the command line. A single-dash token
|
|
229
|
+
# never takes a value; only --long options need the lookup.
|
|
230
|
+
_nansen_is_flag() {
|
|
231
|
+
case "$1" in
|
|
232
|
+
--*) ;;
|
|
233
|
+
*) return 0 ;;
|
|
234
|
+
esac
|
|
235
|
+
case " ${valuelessFlags.join(' ')} " in
|
|
236
|
+
*" $1 "*) return 0 ;;
|
|
237
|
+
esac
|
|
238
|
+
return 1
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
_nansen_subs() {
|
|
242
|
+
case "$1" in
|
|
243
|
+
${subArms.join('\n')}
|
|
244
|
+
esac
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
_nansen_opts() {
|
|
248
|
+
case "$1" in
|
|
249
|
+
${optArms.join('\n')}
|
|
250
|
+
esac
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
_nansen_values() {
|
|
254
|
+
case "$1|$2" in
|
|
255
|
+
${valArms.join('\n')}
|
|
256
|
+
esac
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
# Positional argument values; offered alongside subcommands, never part of the path.
|
|
260
|
+
_nansen_args() {
|
|
261
|
+
case "$1" in
|
|
262
|
+
${argArms.join('\n')}
|
|
263
|
+
esac
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
_nansen_global_opts() {
|
|
267
|
+
echo ${dq(globalOptions.map(o => o.name).join(' '))}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
_nansen_has_sub() {
|
|
271
|
+
case " $(_nansen_subs "$1") " in
|
|
272
|
+
*" $2 "*) return 0 ;;
|
|
273
|
+
esac
|
|
274
|
+
return 1
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
_nansen_complete() {
|
|
278
|
+
local cur prev path word i
|
|
279
|
+
COMPREPLY=()
|
|
280
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
281
|
+
prev=""
|
|
282
|
+
[ "$COMP_CWORD" -gt 0 ] && prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
283
|
+
|
|
284
|
+
# Rebuild the command path: a bare word only extends it when it is a known
|
|
285
|
+
# subcommand there, so option values and positionals are ignored.
|
|
286
|
+
path=""
|
|
287
|
+
i=1
|
|
288
|
+
while [ "$i" -lt "$COMP_CWORD" ]; do
|
|
289
|
+
word="\${COMP_WORDS[i]}"
|
|
290
|
+
case "$word" in
|
|
291
|
+
-*)
|
|
292
|
+
_nansen_is_flag "$word" || i=$((i + 1))
|
|
293
|
+
;;
|
|
294
|
+
*)
|
|
295
|
+
if _nansen_has_sub "$path" "$word"; then
|
|
296
|
+
if [ -z "$path" ]; then path="$word"; else path="$path $word"; fi
|
|
297
|
+
fi
|
|
298
|
+
;;
|
|
299
|
+
esac
|
|
300
|
+
i=$((i + 1))
|
|
301
|
+
done
|
|
302
|
+
|
|
303
|
+
case "$cur" in
|
|
304
|
+
-*)
|
|
305
|
+
COMPREPLY=( $(compgen -W "$(_nansen_opts "$path") $(_nansen_global_opts)" -- "$cur") )
|
|
306
|
+
return 0
|
|
307
|
+
;;
|
|
308
|
+
esac
|
|
309
|
+
|
|
310
|
+
case "$prev" in
|
|
311
|
+
-*)
|
|
312
|
+
if ! _nansen_is_flag "$prev"; then
|
|
313
|
+
COMPREPLY=( $(compgen -W "$(_nansen_values "$path" "$prev")" -- "$cur") )
|
|
314
|
+
return 0
|
|
315
|
+
fi
|
|
316
|
+
;;
|
|
317
|
+
esac
|
|
318
|
+
|
|
319
|
+
COMPREPLY=( $(compgen -W "$(_nansen_subs "$path") $(_nansen_args "$path")" -- "$cur") )
|
|
320
|
+
return 0
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
complete -F _nansen_complete nansen
|
|
324
|
+
`;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ============= zsh =============
|
|
328
|
+
|
|
329
|
+
function zshItems(items) {
|
|
330
|
+
// _describe reads "value:description" and splits on the first colon.
|
|
331
|
+
return items.map(({ name, description }) =>
|
|
332
|
+
sq(description ? `${name.replace(/:/g, '\\:')}:${description}` : name.replace(/:/g, '\\:'))
|
|
333
|
+
).join(' ');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
export function generateZsh(spec) {
|
|
337
|
+
const { nodes, globalOptions, valuelessFlags, version } = spec;
|
|
338
|
+
|
|
339
|
+
const subArms = nodes
|
|
340
|
+
.filter(n => n.subcommands.length)
|
|
341
|
+
.map(n => ` ${sq(n.path)}) _nansen_reply=( ${zshItems(n.subcommands)} ) ;;`);
|
|
342
|
+
|
|
343
|
+
const optArms = nodes
|
|
344
|
+
.filter(n => n.options.length)
|
|
345
|
+
.map(n => ` ${sq(n.path)}) _nansen_reply=( ${zshItems(n.options)} ) ;;`);
|
|
346
|
+
|
|
347
|
+
const valArms = [...valueGroups(nodes)].map(([values, keys]) =>
|
|
348
|
+
` ${keys.map(sq).join('|')}) _nansen_reply=( ${values.split(' ').map(sq).join(' ')} ) ;;`);
|
|
349
|
+
|
|
350
|
+
const argArms = nodes
|
|
351
|
+
.filter(n => n.args.length)
|
|
352
|
+
.map(n => ` ${sq(n.path)}) _nansen_reply=( ${n.args.map(sq).join(' ')} ) ;;`);
|
|
353
|
+
|
|
354
|
+
return `#compdef nansen
|
|
355
|
+
${header('zsh', version, [
|
|
356
|
+
'Install: nansen completion zsh > "${fpath[1]}/_nansen" && compinit',
|
|
357
|
+
' or: eval "$(nansen completion zsh)" # in ~/.zshrc, after compinit',
|
|
358
|
+
])}
|
|
359
|
+
|
|
360
|
+
# A single-dash token never takes a value; only --long options need the lookup.
|
|
361
|
+
_nansen_is_flag() {
|
|
362
|
+
case "$1" in
|
|
363
|
+
--*) ;;
|
|
364
|
+
*) return 0 ;;
|
|
365
|
+
esac
|
|
366
|
+
case " ${valuelessFlags.join(' ')} " in
|
|
367
|
+
*" $1 "*) return 0 ;;
|
|
368
|
+
esac
|
|
369
|
+
return 1
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
# Each table fills the shared _nansen_reply array with "value:description" items.
|
|
373
|
+
_nansen_subs() {
|
|
374
|
+
_nansen_reply=()
|
|
375
|
+
case "$1" in
|
|
376
|
+
${subArms.join('\n')}
|
|
377
|
+
esac
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
_nansen_opts() {
|
|
381
|
+
_nansen_reply=()
|
|
382
|
+
case "$1" in
|
|
383
|
+
${optArms.join('\n')}
|
|
384
|
+
esac
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
_nansen_values() {
|
|
388
|
+
_nansen_reply=()
|
|
389
|
+
case "$1|$2" in
|
|
390
|
+
${valArms.join('\n')}
|
|
391
|
+
esac
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
# Positional argument values; offered alongside subcommands, never part of the path.
|
|
395
|
+
_nansen_args() {
|
|
396
|
+
_nansen_reply=()
|
|
397
|
+
case "$1" in
|
|
398
|
+
${argArms.join('\n')}
|
|
399
|
+
esac
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
_nansen_global_opts() {
|
|
403
|
+
_nansen_reply=( ${zshItems(globalOptions)} )
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
_nansen_has_sub() {
|
|
407
|
+
local item
|
|
408
|
+
_nansen_subs "$1"
|
|
409
|
+
for item in "\${_nansen_reply[@]}"; do
|
|
410
|
+
[[ "\${item%%:*}" == "$2" ]] && return 0
|
|
411
|
+
done
|
|
412
|
+
return 1
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
_nansen() {
|
|
416
|
+
# Not "path": zsh ties that array to PATH, and a local by that name would
|
|
417
|
+
# empty PATH for the duration of every completion.
|
|
418
|
+
local -a _nansen_reply all
|
|
419
|
+
local cmdpath="" cur prev word
|
|
420
|
+
local -i i
|
|
421
|
+
|
|
422
|
+
cur="\${words[CURRENT]}"
|
|
423
|
+
prev=""
|
|
424
|
+
(( CURRENT > 1 )) && prev="\${words[CURRENT-1]}"
|
|
425
|
+
|
|
426
|
+
# See the bash script: a bare word extends the path only where it is a real
|
|
427
|
+
# subcommand, which keeps option values and positionals out of it.
|
|
428
|
+
for (( i = 2; i < CURRENT; i++ )); do
|
|
429
|
+
word="\${words[i]}"
|
|
430
|
+
if [[ "$word" == -* ]]; then
|
|
431
|
+
_nansen_is_flag "$word" || (( i++ ))
|
|
432
|
+
continue
|
|
433
|
+
fi
|
|
434
|
+
if _nansen_has_sub "$cmdpath" "$word"; then
|
|
435
|
+
if [[ -z "$cmdpath" ]]; then cmdpath="$word"; else cmdpath="$cmdpath $word"; fi
|
|
436
|
+
fi
|
|
437
|
+
done
|
|
438
|
+
|
|
439
|
+
if [[ "$cur" == -* ]]; then
|
|
440
|
+
_nansen_opts "$cmdpath"
|
|
441
|
+
all=( "\${_nansen_reply[@]}" )
|
|
442
|
+
_nansen_global_opts
|
|
443
|
+
all+=( "\${_nansen_reply[@]}" )
|
|
444
|
+
_nansen_reply=( "\${all[@]}" )
|
|
445
|
+
_describe -t options 'option' _nansen_reply
|
|
446
|
+
return
|
|
447
|
+
fi
|
|
448
|
+
|
|
449
|
+
if [[ "$prev" == -* ]] && ! _nansen_is_flag "$prev"; then
|
|
450
|
+
_nansen_values "$cmdpath" "$prev"
|
|
451
|
+
(( \${#_nansen_reply[@]} )) && _describe -t values 'value' _nansen_reply
|
|
452
|
+
return
|
|
453
|
+
fi
|
|
454
|
+
|
|
455
|
+
local ret=1
|
|
456
|
+
_nansen_subs "$cmdpath"
|
|
457
|
+
(( \${#_nansen_reply[@]} )) && _describe -t commands 'command' _nansen_reply && ret=0
|
|
458
|
+
_nansen_args "$cmdpath"
|
|
459
|
+
(( \${#_nansen_reply[@]} )) && _describe -t arguments 'argument' _nansen_reply && ret=0
|
|
460
|
+
return ret
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if [[ "$funcstack[1]" == "_nansen" ]]; then
|
|
464
|
+
_nansen "$@"
|
|
465
|
+
else
|
|
466
|
+
compdef _nansen nansen
|
|
467
|
+
fi
|
|
468
|
+
`;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// ============= fish =============
|
|
472
|
+
|
|
473
|
+
function fishItems(items) {
|
|
474
|
+
// printf reuses the format string for every remaining pair, so one call emits
|
|
475
|
+
// the whole table as fish's "value<TAB>description" completion format.
|
|
476
|
+
return items.map(({ name, description }) => `${fq(name)} ${fq(description)}`).join(' ');
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function generateFish(spec) {
|
|
480
|
+
const { nodes, globalOptions, valuelessFlags, version } = spec;
|
|
481
|
+
|
|
482
|
+
const subArms = nodes
|
|
483
|
+
.filter(n => n.subcommands.length)
|
|
484
|
+
.map(n => ` case ${fq(n.path)}\n printf '%s\\t%s\\n' ${fishItems(n.subcommands)}`);
|
|
485
|
+
|
|
486
|
+
const optArms = nodes
|
|
487
|
+
.filter(n => n.options.length)
|
|
488
|
+
.map(n => ` case ${fq(n.path)}\n printf '%s\\t%s\\n' ${fishItems(n.options)}`);
|
|
489
|
+
|
|
490
|
+
const valArms = [...valueGroups(nodes)].map(([values, keys]) =>
|
|
491
|
+
` case ${keys.map(fq).join(' ')}\n printf '%s\\n' ${values.split(' ').map(fq).join(' ')}`);
|
|
492
|
+
|
|
493
|
+
const argArms = nodes
|
|
494
|
+
.filter(n => n.args.length)
|
|
495
|
+
.map(n => ` case ${fq(n.path)}\n printf '%s\\n' ${n.args.map(fq).join(' ')}`);
|
|
496
|
+
|
|
497
|
+
return `${header('fish', version, [
|
|
498
|
+
'Install: nansen completion fish > ~/.config/fish/completions/nansen.fish',
|
|
499
|
+
])}
|
|
500
|
+
|
|
501
|
+
function __nansen_flags
|
|
502
|
+
echo ${fq(valuelessFlags.join(' '))}
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# A single-dash token never takes a value; only --long options need the lookup.
|
|
506
|
+
# The "--" matters: the flag list itself starts with "--", and without it
|
|
507
|
+
# string split reads the list as its own options.
|
|
508
|
+
function __nansen_is_flag
|
|
509
|
+
string match -q -- '--*' $argv[1]; or return 0
|
|
510
|
+
contains -- $argv[1] (string split -- ' ' (__nansen_flags))
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
function __nansen_subs
|
|
514
|
+
switch "$argv[1]"
|
|
515
|
+
${subArms.join('\n')}
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
function __nansen_opts
|
|
520
|
+
switch "$argv[1]"
|
|
521
|
+
${optArms.join('\n')}
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
function __nansen_values
|
|
526
|
+
switch "$argv[1]|$argv[2]"
|
|
527
|
+
${valArms.join('\n')}
|
|
528
|
+
end
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Positional argument values; offered alongside subcommands, never part of the path.
|
|
532
|
+
function __nansen_args
|
|
533
|
+
switch "$argv[1]"
|
|
534
|
+
${argArms.join('\n')}
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
function __nansen_global_opts
|
|
539
|
+
printf '%s\\t%s\\n' ${fishItems(globalOptions)}
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# See the bash script: a bare word extends the path only where it is a real
|
|
543
|
+
# subcommand, which keeps option values and positionals out of it.
|
|
544
|
+
function __nansen_path
|
|
545
|
+
# -opc is deprecated in fish 4 in favour of -xpc, but is the only spelling
|
|
546
|
+
# that also works on fish 3. Revisit once fish 3 support is dropped.
|
|
547
|
+
set -l tokens (commandline -opc)
|
|
548
|
+
set -l path ''
|
|
549
|
+
set -l skip 0
|
|
550
|
+
set -l count (count $tokens)
|
|
551
|
+
if test $count -ge 2
|
|
552
|
+
for i in (seq 2 $count)
|
|
553
|
+
set -l word $tokens[$i]
|
|
554
|
+
if test $skip -eq 1
|
|
555
|
+
set skip 0
|
|
556
|
+
continue
|
|
557
|
+
end
|
|
558
|
+
if string match -q -- '-*' $word
|
|
559
|
+
if not __nansen_is_flag $word
|
|
560
|
+
set skip 1
|
|
561
|
+
end
|
|
562
|
+
continue
|
|
563
|
+
end
|
|
564
|
+
if contains -- $word (__nansen_subs "$path" | string replace -r '\\t.*$' '')
|
|
565
|
+
if test -z "$path"
|
|
566
|
+
set path $word
|
|
567
|
+
else
|
|
568
|
+
set path "$path $word"
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
end
|
|
572
|
+
end
|
|
573
|
+
echo $path
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
function __nansen_complete
|
|
577
|
+
set -l path (__nansen_path)
|
|
578
|
+
set -l cur (commandline -ct)
|
|
579
|
+
set -l tokens (commandline -opc)
|
|
580
|
+
|
|
581
|
+
if string match -q -- '-*' $cur
|
|
582
|
+
__nansen_opts "$path"
|
|
583
|
+
__nansen_global_opts
|
|
584
|
+
return
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
if test (count $tokens) -ge 2
|
|
588
|
+
set -l prev $tokens[-1]
|
|
589
|
+
if string match -q -- '-*' $prev
|
|
590
|
+
if not __nansen_is_flag $prev
|
|
591
|
+
__nansen_values "$path" "$prev"
|
|
592
|
+
return
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
__nansen_subs "$path"
|
|
598
|
+
__nansen_args "$path"
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
complete -c nansen -f -a '(__nansen_complete)'
|
|
602
|
+
`;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const GENERATORS = { bash: generateBash, zsh: generateZsh, fish: generateFish };
|
|
606
|
+
|
|
607
|
+
/** Render the completion script for one shell. Throws on an unknown shell. */
|
|
608
|
+
export function generateCompletion(shell, spec = buildCompletionSpec()) {
|
|
609
|
+
const generate = GENERATORS[shell];
|
|
610
|
+
if (!generate) {
|
|
611
|
+
throw new CommandError(
|
|
612
|
+
`Unsupported shell: ${shell}. Run: nansen completion <${COMPLETION_SHELLS.join('|')}>`,
|
|
613
|
+
'INVALID_PARAMS'
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
return generate(spec);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export const COMPLETION_USAGE = `nansen completion — Generate a shell completion script
|
|
620
|
+
|
|
621
|
+
USAGE:
|
|
622
|
+
nansen completion bash Print the bash completion script
|
|
623
|
+
nansen completion zsh Print the zsh completion script
|
|
624
|
+
nansen completion fish Print the fish completion script
|
|
625
|
+
|
|
626
|
+
INSTALL:
|
|
627
|
+
bash echo 'eval "$(nansen completion bash)"' >> ~/.bashrc
|
|
628
|
+
# or: nansen completion bash > /etc/bash_completion.d/nansen
|
|
629
|
+
zsh nansen completion zsh > "\${fpath[1]}/_nansen" && compinit
|
|
630
|
+
# or, in ~/.zshrc after the compinit line: eval "$(nansen completion zsh)"
|
|
631
|
+
fish nansen completion fish > ~/.config/fish/completions/nansen.fish
|
|
632
|
+
|
|
633
|
+
Completions cover nested subcommands, per-command flags, global flags, and the
|
|
634
|
+
enum values a flag accepts. They are generated from the same schema as
|
|
635
|
+
\`nansen schema\`, so re-run this after upgrading the CLI.`;
|
|
636
|
+
|
|
637
|
+
export function buildCompletionCommands(deps = {}) {
|
|
638
|
+
const { log = console.log } = deps;
|
|
639
|
+
|
|
640
|
+
return {
|
|
641
|
+
// --help never reaches here: runCLI answers it from schema.json first.
|
|
642
|
+
'completion': async (args) => {
|
|
643
|
+
const shell = args[0];
|
|
644
|
+
if (!shell) {
|
|
645
|
+
log(COMPLETION_USAGE);
|
|
646
|
+
return undefined;
|
|
647
|
+
}
|
|
648
|
+
log(generateCompletion(shell));
|
|
649
|
+
return undefined;
|
|
650
|
+
},
|
|
651
|
+
};
|
|
652
|
+
}
|
package/src/commands/mcp.js
CHANGED
|
@@ -36,7 +36,7 @@ const MCP_USAGE = `nansen mcp — Install the Nansen MCP server into a local MCP
|
|
|
36
36
|
USAGE:
|
|
37
37
|
nansen mcp install <client> Add the Nansen MCP server to the client's config
|
|
38
38
|
nansen mcp uninstall <client> Remove the Nansen MCP server from the client's config
|
|
39
|
-
nansen mcp verify [--api-key <key>] [--url <url>] [--json]
|
|
39
|
+
nansen mcp verify [--api-key <key>] [--url <url>] [--send-api-key] [--json]
|
|
40
40
|
Verify the hosted MCP server and API key
|
|
41
41
|
|
|
42
42
|
CLIENTS:
|
|
@@ -46,6 +46,8 @@ CLIENTS:
|
|
|
46
46
|
|
|
47
47
|
OPTIONS:
|
|
48
48
|
--dry-run Preview the change (key redacted) without writing
|
|
49
|
+
--send-api-key Authorize sending your saved API key to a custom --url
|
|
50
|
+
(an https:// or loopback host). Not needed for --api-key.
|
|
49
51
|
|
|
50
52
|
The API key is taken from \`nansen login\` / NANSEN_API_KEY. Re-run install after
|
|
51
53
|
rotating your key to update the entry. Other clients: https://docs.nansen.ai/mcp/connecting`;
|
|
@@ -212,7 +214,20 @@ export function buildMcpCommands(deps = {}) {
|
|
|
212
214
|
}
|
|
213
215
|
};
|
|
214
216
|
|
|
215
|
-
const verify = async (flags, options) => {
|
|
217
|
+
const verify = async (flags, options, extraArgs = []) => {
|
|
218
|
+
// --send-api-key is a valueless flag: `--send-api-key false` parses the
|
|
219
|
+
// `false` as a positional arg while the flag still reads as present, which
|
|
220
|
+
// would authorize the very disclosure the caller meant to decline. Reject
|
|
221
|
+
// any positional args so that footgun fails loudly instead of leaking.
|
|
222
|
+
// Never echo the argument: a plausible misuse is `--send-api-key "$KEY"`,
|
|
223
|
+
// which lands the real key in extraArgs[0]; interpolating it would leak the
|
|
224
|
+
// credential to stdout (and to logs under --json).
|
|
225
|
+
if (extraArgs.length > 0) {
|
|
226
|
+
throw new CommandError(
|
|
227
|
+
'`nansen mcp verify` takes no positional arguments — --send-api-key is a valueless flag, do not pass it a value. Usage: nansen mcp verify [--api-key <key>] [--url <url>] [--send-api-key] [--json]',
|
|
228
|
+
'INVALID_PARAMS',
|
|
229
|
+
);
|
|
230
|
+
}
|
|
216
231
|
// A valueless --api-key parses as a flag and would silently fall back to
|
|
217
232
|
// the saved key - the exact false positive this command exists to catch.
|
|
218
233
|
if (flags['api-key']) {
|
|
@@ -239,6 +254,7 @@ export function buildMcpCommands(deps = {}) {
|
|
|
239
254
|
env,
|
|
240
255
|
fetchFn,
|
|
241
256
|
devConfigPath,
|
|
257
|
+
sendApiKey: Boolean(flags['send-api-key']),
|
|
242
258
|
});
|
|
243
259
|
const verified = checks.some(checkItem => checkItem.id === 'mcp-auth' && checkItem.status === 'ok');
|
|
244
260
|
const result = {
|
|
@@ -279,7 +295,7 @@ export function buildMcpCommands(deps = {}) {
|
|
|
279
295
|
}
|
|
280
296
|
|
|
281
297
|
if (sub === 'verify') {
|
|
282
|
-
return verify(flags, options);
|
|
298
|
+
return verify(flags, options, args.slice(1));
|
|
283
299
|
}
|
|
284
300
|
|
|
285
301
|
if (sub !== 'install' && sub !== 'uninstall') {
|