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.
@@ -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
+ }
@@ -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') {