perp-cli 0.12.3 → 0.12.5

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.
@@ -266,8 +266,15 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
266
266
  .option("--ip-whitelist <list>", "Comma-separated IP whitelist (pass empty string to include empty)")
267
267
  .option("--api-key-index <n>", "Lighter slot index (4-254). Default: next free slot.")
268
268
  .option("--json", "Machine-readable output")
269
- .action(async (exchange, opts) => {
269
+ .action(async (exchange, opts, command) => {
270
270
  const useJson = opts.json ?? isJson();
271
+ // optsWithGlobals() merges parent + subcommand options so --passphrase
272
+ // works whether placed on `perp --passphrase X wallet agent approve` or
273
+ // on the subcommand. Commander v13 silently shadows the subcommand flag
274
+ // when the parent defines an identically-named flag — the visible bug
275
+ // was approve throwing PASSPHRASE_REQUIRED even with --passphrase set.
276
+ const mergedOpts = command.optsWithGlobals();
277
+ const passphraseFlag = mergedOpts.passphrase ?? opts.passphrase;
271
278
  // Normalize aliases
272
279
  const exchangeNorm = exchange === "lt" ? "lighter"
273
280
  : exchange === "hl" ? "hyperliquid"
@@ -314,7 +321,7 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
314
321
  }
315
322
  chosenSlot = n;
316
323
  }
317
- const passphrase = await resolvePassphrase({ flag: opts.passphrase });
324
+ const passphrase = await resolvePassphrase({ flag: passphraseFlag });
318
325
  if (passphrase === null) {
319
326
  reportErrorAndExit(new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
320
327
  remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
@@ -377,7 +384,7 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
377
384
  remediation: "Run: perp setup or use --master <name>",
378
385
  }));
379
386
  }
380
- const passphrase = await resolvePassphrase({ flag: opts.passphrase });
387
+ const passphrase = await resolvePassphrase({ flag: passphraseFlag });
381
388
  if (passphrase === null) {
382
389
  reportErrorAndExit(new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
383
390
  remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
@@ -438,7 +445,7 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
438
445
  remediation: "Run: perp setup or use --master <name>",
439
446
  }));
440
447
  }
441
- const passphrase = await resolvePassphrase({ flag: opts.passphrase });
448
+ const passphrase = await resolvePassphrase({ flag: passphraseFlag });
442
449
  if (passphrase === null) {
443
450
  reportErrorAndExit(new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
444
451
  remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
@@ -526,7 +533,7 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
526
533
  canSpot = opts.canSpot ?? false;
527
534
  canWithdraw = opts.canWithdraw ?? false;
528
535
  // 3-path passphrase resolver (non-TTY path)
529
- passphrase = await resolvePassphrase({ flag: opts.passphrase });
536
+ passphrase = await resolvePassphrase({ flag: passphraseFlag });
530
537
  if (passphrase === null) {
531
538
  // TTY but non-wizard (some flags were supplied) — throw PASSPHRASE_REQUIRED
532
539
  reportErrorAndExit(new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY or wizard skipped", {
@@ -666,8 +673,11 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
666
673
  .option("--force", "Skip Aster POST, only clear local state")
667
674
  .option("--passphrase <pp>", "Master OWS passphrase")
668
675
  .option("--json", "Machine-readable output")
669
- .action(async (exchange, agentName, opts) => {
676
+ .action(async (exchange, agentName, opts, command) => {
670
677
  const useJson = opts.json ?? isJson();
678
+ // optsWithGlobals(): see wallet agent approve for full rationale.
679
+ const mergedOpts = command.optsWithGlobals();
680
+ const passphraseFlag = mergedOpts.passphrase ?? opts.passphrase;
671
681
  try {
672
682
  // Step 1: Check local meta (idempotent — absent = already revoked)
673
683
  const meta = getAgent(exchange, agentName);
@@ -683,7 +693,7 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
683
693
  }
684
694
  if (!opts.force) {
685
695
  // Step 2: Resolve passphrase
686
- const passphrase = await resolvePassphrase({ flag: opts.passphrase });
696
+ const passphrase = await resolvePassphrase({ flag: passphraseFlag });
687
697
  if (passphrase === null) {
688
698
  throw new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
689
699
  remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
@@ -791,8 +801,11 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
791
801
  .option("--can-withdraw", "Allow withdrawals (default: inherit or off)")
792
802
  .option("--no-can-withdraw", "Disallow withdrawals")
793
803
  .option("--json", "Machine-readable output")
794
- .action(async (exchange, oldAgentNameArg, opts) => {
804
+ .action(async (exchange, oldAgentNameArg, opts, command) => {
795
805
  const useJson = opts.json ?? isJson();
806
+ // optsWithGlobals(): see wallet agent approve for full rationale.
807
+ const mergedOpts = command.optsWithGlobals();
808
+ const passphraseFlag = mergedOpts.passphrase ?? opts.passphrase;
796
809
  try {
797
810
  // Determine which agent to rotate
798
811
  const agentNameToRotate = oldAgentNameArg ?? opts.agentName ?? getAgent(exchange)?.agentName;
@@ -802,7 +815,7 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
802
815
  });
803
816
  }
804
817
  // Resolve passphrase early (needed for both revoke and approve phases)
805
- const passphrase = await resolvePassphrase({ flag: opts.passphrase });
818
+ const passphrase = await resolvePassphrase({ flag: passphraseFlag });
806
819
  if (passphrase === null) {
807
820
  throw new PerpError("PASSPHRASE_REQUIRED", "No passphrase provided and stdin is non-TTY", {
808
821
  remediation: "Provide passphrase via --passphrase flag, OWS_PASSPHRASE env var, or stdin pipe",
@@ -1006,11 +1019,14 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
1006
1019
  .option("--account-index <n>", "Account index (Lighter only)")
1007
1020
  .option("--passphrase <pp>", "Master OWS passphrase (Aster/PAC). Falls back to OWS_PASSPHRASE env.")
1008
1021
  .option("--json", "Machine-readable output")
1009
- .action(async (exchange, agentName, opts) => {
1022
+ .action(async (exchange, agentName, opts, command) => {
1010
1023
  const useJson = opts.json ?? isJson();
1011
1024
  const ts = new Date().toISOString();
1025
+ // optsWithGlobals(): see wallet agent approve for full rationale.
1026
+ const mergedOpts = command.optsWithGlobals();
1027
+ const passphraseFlag = mergedOpts.passphrase ?? opts.passphrase;
1012
1028
  // Resolve passphrase via 3-path: flag > env > (no stdin prompt in verify)
1013
- const passphrase = opts.passphrase ?? process.env["OWS_PASSPHRASE"] ?? "";
1029
+ const passphrase = passphraseFlag ?? process.env["OWS_PASSPHRASE"] ?? "";
1014
1030
  const verifyOpts = {
1015
1031
  agentName,
1016
1032
  master: opts.master,
@@ -70,11 +70,15 @@ function askChoice(rl, question, choices) {
70
70
  });
71
71
  }
72
72
  // ── Exchange → env var mapping ────────────────────────────────
73
+ // Aster removed in v0.12.4: legacy HMAC API-key signing path was retired in
74
+ // Plan v3.0. New users must onboard via `perp wallet agent approve aster`
75
+ // (off-chain agent wallet, same flow as HL/PAC/LT). The setup wizard
76
+ // surfaces a redirect when Aster is selected; `wallet set aster <key>`
77
+ // throws INVALID_PARAMS pointing at the agent flow.
73
78
  export const EXCHANGE_ENV_MAP = {
74
79
  pacifica: { envKey: "PACIFICA_PRIVATE_KEY", chain: "solana", label: "Pacifica (Solana)" },
75
80
  hyperliquid: { envKey: "HL_PRIVATE_KEY", chain: "evm", label: "Hyperliquid (EVM)" },
76
81
  lighter: { envKey: "LIGHTER_PRIVATE_KEY", chain: "evm", label: "Lighter (EVM)" },
77
- aster: { envKey: "ASTER_API_KEY", envKeySecret: "ASTER_API_SECRET", chain: "apikey", label: "Aster (BNB Chain)" },
78
82
  };
79
83
  // ── Validate keys ─────────────────────────────────────────────
80
84
  export async function validateKey(chain, key) {
@@ -122,13 +126,24 @@ export function registerInitCommand(program) {
122
126
  .option("--wallet-name <name>", "OWS wallet alias to create (default: 'main')")
123
127
  .option("--passphrase <pp>", "OWS vault passphrase (fallback: OWS_PASSPHRASE env / stdin)")
124
128
  .option("--default-exchange <name>", "Default exchange (pacifica/hyperliquid/lighter/aster)")
125
- .action(async (opts) => {
129
+ .action(async (opts, command) => {
130
+ // optsWithGlobals() merges parent + subcommand options so --passphrase
131
+ // works whether placed on `perp --passphrase X setup` (parent) or
132
+ // `perp setup --passphrase X` (subcommand). Commander v13 silently
133
+ // shadows the subcommand flag when the parent defines an identically
134
+ // named flag — the visible bug was non-interactive setup throwing
135
+ // PASSPHRASE_REQUIRED with --passphrase set on the subcommand.
136
+ const mergedOpts = command.optsWithGlobals();
137
+ const resolvedOpts = {
138
+ ...opts,
139
+ passphrase: mergedOpts.passphrase ?? opts.passphrase,
140
+ };
126
141
  // Non-interactive mode: skip all readline prompts, drive everything from
127
142
  // flags + OWS_PASSPHRASE env. This is the agent-friendly onboarding path
128
143
  // (CI / Docker / scripted init) — see plan v3.0 "agent-friendly five
129
144
  // guarantees" P1 (no-prompt) and P3 (passphrase 3-path).
130
- if (opts.nonInteractive) {
131
- return runNonInteractiveSetup(opts);
145
+ if (resolvedOpts.nonInteractive) {
146
+ return runNonInteractiveSetup(resolvedOpts);
132
147
  }
133
148
  const rl = createInterface({ input: process.stdin, output: process.stdout });
134
149
  try {
@@ -161,12 +176,28 @@ export function registerInitCommand(program) {
161
176
  console.log(` 1) ${chalk.cyan("Pacifica")} ${chalk.gray("— Solana perps (USDC on Solana)")}`);
162
177
  console.log(` 2) ${chalk.cyan("Hyperliquid")} ${chalk.gray("— EVM perps (USDC on Arbitrum)")}`);
163
178
  console.log(` 3) ${chalk.cyan("Lighter")} ${chalk.gray("— EVM perps (USDC on Ethereum/Arb)")}`);
164
- console.log(` 4) ${chalk.cyan("Aster")} ${chalk.gray("— BNB Chain perps (API key + secret)")}`);
179
+ console.log(` 4) ${chalk.cyan("Aster")} ${chalk.gray("— BNB Chain perps (use 'wallet agent approve aster')")}`);
165
180
  console.log();
166
181
  const exchangeChoice = await ask(rl, " Which exchange(s)? (1,2,3,4 or 'all'): ");
167
- const selected = parseExchangeChoice(exchangeChoice);
182
+ const selectedRaw = parseExchangeChoice(exchangeChoice);
183
+ // v0.12.4: legacy Aster HMAC API-key path is removed. The wizard can
184
+ // no longer collect ASTER_API_KEY/ASTER_API_SECRET — Aster onboarding
185
+ // is now exclusively via `perp wallet agent approve aster --master`.
186
+ // Print a redirect and drop Aster from the env-write set.
187
+ const asterRequested = selectedRaw.includes("aster");
188
+ const selected = selectedRaw.filter((e) => e !== "aster");
189
+ if (asterRequested) {
190
+ console.log(chalk.yellow.bold("\n Aster onboarding is via agent wallet (no API keys):"));
191
+ console.log(` ${chalk.green("perp wallet agent approve aster --master <name>")}`);
192
+ console.log(chalk.gray(" Run that after this wizard completes.\n"));
193
+ }
168
194
  if (selected.length === 0) {
169
- console.log(chalk.red("\n No exchanges selected.\n"));
195
+ if (asterRequested) {
196
+ console.log(chalk.gray(" No additional exchanges to configure here. See the Aster instructions above.\n"));
197
+ }
198
+ else {
199
+ console.log(chalk.red("\n No exchanges selected.\n"));
200
+ }
170
201
  rl.close();
171
202
  return;
172
203
  }
@@ -174,7 +205,6 @@ export function registerInitCommand(program) {
174
205
  // EVM exchanges can share the same key
175
206
  const evmExchanges = selected.filter((e) => EXCHANGE_ENV_MAP[e].chain === "evm");
176
207
  const solExchanges = selected.filter((e) => EXCHANGE_ENV_MAP[e].chain === "solana");
177
- const apikeyExchanges = selected.filter((e) => EXCHANGE_ENV_MAP[e].chain === "apikey");
178
208
  const newEnv = { ...env };
179
209
  if (evmExchanges.length > 0) {
180
210
  console.log(chalk.cyan.bold("\n EVM Private Key") + chalk.gray(` (for ${evmExchanges.join(", ")})`));
@@ -230,34 +260,9 @@ export function registerInitCommand(program) {
230
260
  }
231
261
  }
232
262
  }
233
- if (apikeyExchanges.length > 0) {
234
- for (const ex of apikeyExchanges) {
235
- const info = EXCHANGE_ENV_MAP[ex];
236
- console.log(chalk.cyan.bold(`\n ${info.label} API Credentials`));
237
- const apiKey = await ask(rl, ` ${info.label} API key: `);
238
- if (!apiKey) {
239
- console.log(chalk.gray(" Skipped."));
240
- continue;
241
- }
242
- const { valid, address } = await validateKey("apikey", apiKey);
243
- if (!valid) {
244
- console.log(chalk.red(" Invalid API key (must be at least 16 chars), skipped."));
245
- continue;
246
- }
247
- newEnv[info.envKey] = apiKey;
248
- console.log(` ${chalk.green("OK")} ${chalk.gray(address)}`);
249
- if (info.envKeySecret) {
250
- const secret = await ask(rl, ` ${info.label} API secret: `);
251
- if (secret) {
252
- newEnv[info.envKeySecret] = secret;
253
- console.log(` ${chalk.green("OK")} secret saved`);
254
- }
255
- else {
256
- console.log(chalk.yellow(" No secret provided — some operations may fail."));
257
- }
258
- }
259
- }
260
- }
263
+ // v0.12.4: API-key (Aster legacy HMAC) onboarding removed from the
264
+ // wizard. Aster users follow the redirect above to
265
+ // `perp wallet agent approve aster`.
261
266
  // Default exchange
262
267
  const settings = loadSettings();
263
268
  if (selected.length === 1) {
@@ -326,7 +326,7 @@ export function registerWalletCommands(program, isJson, getAdapter, getPacificaA
326
326
  .option("--show-mnemonic", "Display the mnemonic phrase (CAUTION)")
327
327
  .option("--passphrase <pp>", "Master OWS passphrase (fallback: OWS_PASSPHRASE env / stdin)")
328
328
  .option("--legacy <chain>", "Legacy mode: generate solana or evm key (unencrypted)")
329
- .action(async (name, opts) => {
329
+ .action(async (name, opts, command) => {
330
330
  // Legacy mode for backward compat
331
331
  if (opts.legacy) {
332
332
  return _legacyGenerate(opts.legacy, name, isJson);
@@ -337,8 +337,15 @@ export function registerWalletCommands(program, isJson, getAdapter, getPacificaA
337
337
  // > OWS_PASSPHRASE env > stdin pipe). When stdin is a TTY and nothing was
338
338
  // provided, the resolver returns null; fall back to "" to preserve the
339
339
  // historical "no passphrase" default for interactive use.
340
+ //
341
+ // optsWithGlobals() merges parent + subcommand options, so --passphrase
342
+ // works whether placed on `perp --passphrase X subcmd` (parent) or
343
+ // `perp subcmd --passphrase X` (subcommand). Without this, Commander v13
344
+ // shadows the subcommand flag with the parent's same-named flag and
345
+ // opts.passphrase is always undefined.
346
+ const merged = command.optsWithGlobals();
340
347
  const { resolvePassphrase } = await import("../agent-wallet/passphrase.js");
341
- const pp = (await resolvePassphrase({ flag: opts.passphrase })) ?? "";
348
+ const pp = (await resolvePassphrase({ flag: merged.passphrase ?? opts.passphrase })) ?? "";
342
349
  const ows = loadOws();
343
350
  const w = ows.createWallet(walletName, pp, parseInt(opts.words));
344
351
  // Set as active wallet
@@ -402,7 +409,7 @@ export function registerWalletCommands(program, isJson, getAdapter, getPacificaA
402
409
  .option("--mnemonic", "Import as mnemonic phrase instead of private key")
403
410
  .option("--passphrase <pp>", "Master OWS passphrase (fallback: OWS_PASSPHRASE env / stdin)")
404
411
  .option("--legacy <chain>", "Legacy mode: import to wallets.json (solana or evm)")
405
- .action(async (privateKey, opts) => {
412
+ .action(async (privateKey, opts, command) => {
406
413
  // Legacy mode (positional only)
407
414
  if (opts.legacy) {
408
415
  if (!privateKey) {
@@ -486,8 +493,13 @@ export function registerWalletCommands(program, isJson, getAdapter, getPacificaA
486
493
  // > OWS_PASSPHRASE env > stdin pipe). When stdin is a TTY and nothing was
487
494
  // provided, the resolver returns null; fall back to "" to preserve the
488
495
  // historical "no passphrase" default for interactive use.
496
+ //
497
+ // optsWithGlobals() merges parent + subcommand options — see the
498
+ // wallet generate action for context. Without it, the parent program's
499
+ // --passphrase shadows the subcommand's same-named flag.
500
+ const merged = command.optsWithGlobals();
489
501
  const { resolvePassphrase } = await import("../agent-wallet/passphrase.js");
490
- const pp = (await resolvePassphrase({ flag: opts.passphrase })) ?? "";
502
+ const pp = (await resolvePassphrase({ flag: merged.passphrase ?? opts.passphrase })) ?? "";
491
503
  let w;
492
504
  if (opts.mnemonic) {
493
505
  w = ows.importWalletMnemonic(opts.name, privateKey, pp);
@@ -598,17 +610,32 @@ export function registerWalletCommands(program, isJson, getAdapter, getPacificaA
598
610
  .description("Set private key for an exchange")
599
611
  .option("--default", "Also set as default exchange")
600
612
  .action(async (exchange, key, opts) => {
601
- // Resolve alias (hl → hyperliquid, pac → pacifica, lt → lighter)
602
- const aliases = { hl: "hyperliquid", pac: "pacifica", lt: "lighter" };
613
+ // Resolve alias (hl → hyperliquid, pac → pacifica, lt → lighter, ast → aster)
614
+ const aliases = { hl: "hyperliquid", pac: "pacifica", lt: "lighter", ast: "aster" };
603
615
  const resolved = aliases[exchange.toLowerCase()] || exchange.toLowerCase();
616
+ // v0.12.4: Aster legacy HMAC API-key path is removed entirely. Plan v3.0
617
+ // de-wired the runtime/signing surface but left the wallet set entry
618
+ // accepting keys that no longer drive any signing path. Fail loudly
619
+ // (SSOT Rule #2) and point at the agent flow.
620
+ if (resolved === "aster") {
621
+ const msg = "'wallet set aster' is removed in v0.13. Use 'wallet agent approve aster' instead.";
622
+ const remediation = "perp wallet agent approve aster --master <wallet>";
623
+ if (isJson()) {
624
+ const { jsonError } = await import("../utils.js");
625
+ return printJson(jsonError("INVALID_PARAMS", msg, { details: { remediation } }));
626
+ }
627
+ console.error(chalk.red(`\n ${msg}`));
628
+ console.error(chalk.gray(` Run: ${remediation}\n`));
629
+ process.exit(1);
630
+ }
604
631
  const info = EXCHANGE_ENV_MAP[resolved];
605
632
  if (!info) {
606
633
  if (isJson()) {
607
634
  const { jsonError } = await import("../utils.js");
608
- return printJson(jsonError("INVALID_PARAMS", `Unknown exchange: ${exchange}. Use: pacifica, hyperliquid, lighter, aster (or hl, pac, lt, ast)`));
635
+ return printJson(jsonError("INVALID_PARAMS", `Unknown exchange: ${exchange}. Use: pacifica, hyperliquid, lighter (or hl, pac, lt). For Aster: 'perp wallet agent approve aster'.`));
609
636
  }
610
637
  console.error(chalk.red(`\n Unknown exchange: ${exchange}`));
611
- console.error(chalk.gray(` Use: pacifica, hyperliquid, lighter, aster (or hl, pac, lt, ast)\n`));
638
+ console.error(chalk.gray(` Use: pacifica, hyperliquid, lighter (or hl, pac, lt). For Aster: 'perp wallet agent approve aster'.\n`));
612
639
  process.exit(1);
613
640
  }
614
641
  const { valid, address } = await validateKey(info.chain, key);
package/dist/config.js CHANGED
@@ -74,11 +74,15 @@ export async function loadPrivateKey(exchange, pkOverride, walletName) {
74
74
  // settings module not available, skip
75
75
  }
76
76
  // 3. Exchange-specific env vars
77
+ // v0.12.4: Aster's ASTER_API_KEY entry is removed — the legacy HMAC
78
+ // signing path was retired in Plan v3.0. Aster onboarding is now via
79
+ // `perp wallet agent approve aster` (off-chain agent wallet); the runtime
80
+ // adapter pulls credentials from the agent store, not the env.
77
81
  const envMap = {
78
82
  pacifica: ["PACIFICA_PRIVATE_KEY", "pk"],
79
83
  hyperliquid: ["HYPERLIQUID_PRIVATE_KEY", "HL_PRIVATE_KEY"],
80
84
  lighter: ["LIGHTER_PRIVATE_KEY"],
81
- aster: ["ASTER_API_KEY"],
85
+ aster: [],
82
86
  };
83
87
  for (const envVar of envMap[exchange]) {
84
88
  if (process.env[envVar])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.12.3",
3
+ "version": "0.12.5",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter, Aster",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",
@@ -18,7 +18,8 @@
18
18
  "test": "vitest run --exclude '**/integration/**'",
19
19
  "test:integration": "vitest run -c vitest.integration.config.ts",
20
20
  "test:all": "vitest run",
21
- "prepublishOnly": "pnpm run build"
21
+ "sync-skill-version": "node scripts/sync-skill-version.mjs",
22
+ "prepublishOnly": "node scripts/sync-skill-version.mjs && pnpm run build"
22
23
  },
23
24
  "keywords": [
24
25
  "pacifica",
@@ -5,7 +5,7 @@ allowed-tools: "Bash(perp:*), Bash(npx perp-cli:*), Bash(npx -y perp-cli:*)"
5
5
  license: MIT
6
6
  metadata:
7
7
  author: hypurrquant
8
- version: "0.7.7"
8
+ version: "0.12.5"
9
9
  ---
10
10
 
11
11
  # perp-cli Agent Guide
@@ -41,7 +41,7 @@ Multi-DEX perpetual futures CLI + MCP server — Pacifica (Solana), Hyperliquid
41
41
  ## Install
42
42
 
43
43
  ```bash
44
- perp --version 2>/dev/null # check if exists (must be >= 0.7.7)
44
+ perp --version 2>/dev/null # check if exists (must be >= 0.12.5)
45
45
  npm install -g perp-cli@latest 2>/dev/null || npx -y perp-cli@latest --json --version
46
46
  ```
47
47