perp-cli 0.12.11 → 0.12.13

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.
@@ -271,7 +271,7 @@ export function registerAlertCommands(program, isJson) {
271
271
  // Pass through env vars for keys
272
272
  // L2 agent key + indices now live in ~/.perp keystore + settings.agents (SSOT Rule #3),
273
273
  // not env. Only Tier 3 master PKs need passthrough.
274
- const envKeys = ["LIGHTER_PRIVATE_KEY", "HL_PRIVATE_KEY", "PACIFICA_PRIVATE_KEY", "PRIVATE_KEY"];
274
+ const envKeys = ["LIGHTER_PRIVATE_KEY", "HL_PRIVATE_KEY", "PACIFICA_PRIVATE_KEY", "ASTER_PRIVATE_KEY", "PRIVATE_KEY"];
275
275
  const envStr = envKeys.filter(k => process.env[k]).map(k => `${k}='${process.env[k]}'`).join(" ");
276
276
  const cmd = `${envStr} ${nodeCmd} ${cliPath} ${args}`;
277
277
  execSync(`tmux new-session -d -s ${session} '${cmd.replace(/'/g, "'\\''")}'`);
@@ -183,7 +183,14 @@ async function fetchExchangeEntry(exName, getAdapter) {
183
183
  valueUsd: base === "USDC" ? Number(b.total) : (priceMap.get(base) ?? 0) * Number(b.total),
184
184
  };
185
185
  });
186
- isUnified = !isDexCapable(adapter) || !adapter.dex;
186
+ // Read the actual abstraction mode (populated during init()) instead
187
+ // of assuming all main HL accounts are unified. Standard/default mode
188
+ // accounts must include spot USDC in totalAccountValueUsd because
189
+ // perp equity does NOT contain it. HIP-3 dex accounts always run
190
+ // standard semantics. Codex v0.12.12 final QA #1.
191
+ const hlAdapter = adapter;
192
+ const dexScoped = isDexCapable(adapter) && !!adapter.dex;
193
+ isUnified = !dexScoped && hlAdapter.isUnifiedAccount === true;
187
194
  }
188
195
  else if (exName === "lighter") {
189
196
  const { LighterAdapter } = await import("../exchanges/lighter.js");
package/dist/errors.d.ts CHANGED
@@ -163,7 +163,11 @@ export interface StructuredError {
163
163
  export declare function extractErrorMessage(err: unknown): string;
164
164
  /**
165
165
  * Classify an error from any exchange into a structured error code.
166
- * Pattern-matches on error messages to detect known error types.
166
+ *
167
+ * If the input is already a PerpError, return its structured payload verbatim
168
+ * — the typed code and remediation are the source of truth and must not be
169
+ * re-derived from message text. Pattern-matching only kicks in for plain
170
+ * Error instances or unknown thrown values. Codex v0.12.12 final QA #2.
167
171
  */
168
172
  export declare function classifyError(err: unknown, exchange?: string): StructuredError;
169
173
  /** Custom error class that carries a structured error code */
package/dist/errors.js CHANGED
@@ -59,9 +59,19 @@ export function extractErrorMessage(err) {
59
59
  }
60
60
  /**
61
61
  * Classify an error from any exchange into a structured error code.
62
- * Pattern-matches on error messages to detect known error types.
62
+ *
63
+ * If the input is already a PerpError, return its structured payload verbatim
64
+ * — the typed code and remediation are the source of truth and must not be
65
+ * re-derived from message text. Pattern-matching only kicks in for plain
66
+ * Error instances or unknown thrown values. Codex v0.12.12 final QA #2.
63
67
  */
64
68
  export function classifyError(err, exchange) {
69
+ if (err instanceof PerpError) {
70
+ // Preserve the PerpError's typed shape; only fill in `exchange` if the
71
+ // caller provided one and the error didn't already attach it.
72
+ const ex = (err.structured.exchange ?? exchange);
73
+ return ex !== undefined ? { ...err.structured, exchange: ex } : { ...err.structured };
74
+ }
65
75
  const message = extractErrorMessage(err);
66
76
  const lower = message.toLowerCase();
67
77
  // Margin-specific checks first (before generic "insufficient" catch)
@@ -1,10 +1,17 @@
1
1
  /**
2
- * Aster DEX adapter — EIP-712 three-tier signer routing (v3.2).
2
+ * Aster DEX adapter — EIP-712 agent-only signer routing (v3.3).
3
3
  *
4
- * Signer priority:
5
- * Tier 1 — Agent OWS wallet (registered + not expired + no --no-agent)
6
- * Tier 2 — OWS master wallet (--ows / owsActiveWallet)
7
- * Tier 3 — PK direct (--private-key / PRIVATE_KEY / ASTER_PRIVATE_KEY)
4
+ * Signer policy:
5
+ * Tier 1 — Agent OWS wallet (registered + not expired + no --no-agent) — REQUIRED
6
+ * Tier 2 — OWS master — NOT_SUPPORTED (venue rejects master self-signing)
7
+ * Tier 3 — PK direct — NOT_SUPPORTED (venue rejects master self-signing)
8
+ *
9
+ * Aster V3 spec requires `signer` to be a registered API_WALLET (agent). The
10
+ * master wallet is never a valid signer — venue returns
11
+ * `code:-1000 msg:"Signature check failed"` when user==signer==master, even
12
+ * though the EIP-712 envelope is well-formed. Master/PK signers are still
13
+ * accepted via setMasterSigner/setPkSigner (used during approveAgent flow),
14
+ * but signed read/trade requests must use Tier 1.
8
15
  *
9
16
  * HMAC paths fully removed (see Step 3 plan — all Binance-compat HMAC code deleted).
10
17
  *
@@ -116,9 +123,9 @@ export declare class AsterAdapter implements ExchangeAdapter {
116
123
  * signed dict and the URL query string must be byte-identical except for
117
124
  * the appended signature.
118
125
  *
119
- * Both `user` and `signer` are ALWAYS emitted as distinct query fields,
120
- * even when the values are identical (Tier 2/3 self-signing case where
121
- * master signs for itself). Aster's authority server checks both fields.
126
+ * Both `user` and `signer` are ALWAYS emitted as distinct query fields.
127
+ * In production (Tier 1 only), `user` is the master and `signer` is the
128
+ * registered agent address. Aster's authority server checks both fields.
122
129
  *
123
130
  * `signatureChainId` is NOT a v3 parameter — it's an artifact of an older
124
131
  * scheme; including it breaks signature verification on every call.
@@ -137,12 +144,24 @@ export declare class AsterAdapter implements ExchangeAdapter {
137
144
  * Unlike public GETs, signed GETs route through _handleAsterResponse so
138
145
  * venue JSON error envelopes ({code, msg}) are not silently treated as
139
146
  * success (SSOT Rule #2).
147
+ *
148
+ * Retries up to 3 attempts with exponential backoff (2s/4s/8s) on HTTP 429
149
+ * rate-limit. Each retry rebuilds a fresh signed query string (new nonce
150
+ * + signature) — Aster reuses recent nonces strictly, so reusing a stale
151
+ * one would itself be rejected. Non-429 venue errors throw immediately.
140
152
  */
141
153
  private _signedGetEip712;
142
154
  /**
143
155
  * Sign and DELETE from a path using EIP-712 Domain B.
156
+ * Same retry behavior as _signedGetEip712.
144
157
  */
145
158
  private _signedDeleteEip712;
159
+ /** Shared GET/DELETE retry loop with fresh-nonce per attempt.
160
+ *
161
+ * Total 4 attempts: initial + 3 retries with backoffs 2s, 4s, 8s.
162
+ * Codex v0.12.12 final QA #3: previously MAX_ATTEMPTS = 3 truncated the
163
+ * documented curve so the 8s backoff was never reachable in practice. */
164
+ private _signedRequestWithRetry;
146
165
  /**
147
166
  * Unified response validator for ALL signed Aster requests.
148
167
  *
@@ -1,10 +1,17 @@
1
1
  /**
2
- * Aster DEX adapter — EIP-712 three-tier signer routing (v3.2).
2
+ * Aster DEX adapter — EIP-712 agent-only signer routing (v3.3).
3
3
  *
4
- * Signer priority:
5
- * Tier 1 — Agent OWS wallet (registered + not expired + no --no-agent)
6
- * Tier 2 — OWS master wallet (--ows / owsActiveWallet)
7
- * Tier 3 — PK direct (--private-key / PRIVATE_KEY / ASTER_PRIVATE_KEY)
4
+ * Signer policy:
5
+ * Tier 1 — Agent OWS wallet (registered + not expired + no --no-agent) — REQUIRED
6
+ * Tier 2 — OWS master — NOT_SUPPORTED (venue rejects master self-signing)
7
+ * Tier 3 — PK direct — NOT_SUPPORTED (venue rejects master self-signing)
8
+ *
9
+ * Aster V3 spec requires `signer` to be a registered API_WALLET (agent). The
10
+ * master wallet is never a valid signer — venue returns
11
+ * `code:-1000 msg:"Signature check failed"` when user==signer==master, even
12
+ * though the EIP-712 envelope is well-formed. Master/PK signers are still
13
+ * accepted via setMasterSigner/setPkSigner (used during approveAgent flow),
14
+ * but signed read/trade requests must use Tier 1.
8
15
  *
9
16
  * HMAC paths fully removed (see Step 3 plan — all Binance-compat HMAC code deleted).
10
17
  *
@@ -528,18 +535,19 @@ export class AsterAdapter {
528
535
  };
529
536
  }
530
537
  }
531
- // Tier 2: OWS master — master self-signs, so user==signer per V3 spec.
532
- // The query string MUST still emit `user=` AND `signer=` as distinct
533
- // fields even when the values are identical (Aster authority verifies
534
- // both keys); see _buildSignedQueryString.
535
- if (this._masterSigner) {
536
- const addr = this._masterSigner.getAddress();
537
- return { tier: "master", signer: this._masterSigner, signerAddress: addr, userAddress: addr };
538
- }
539
- // Tier 3: PK direct — same self-signing model as Tier 2.
540
- if (this._pkSigner) {
541
- const addr = this._pkSigner.getAddress();
542
- return { tier: "pk", signer: this._pkSigner, signerAddress: addr, userAddress: addr };
538
+ // Tier 2/3: master self-signing is not supported by the venue. Per Aster
539
+ // V3 spec the `signer` field MUST be a registered API_WALLET (agent);
540
+ // sending `user==signer==master` is rejected with
541
+ // `code:-1000 msg:"Signature check failed"` even though the EIP-712
542
+ // envelope is well-formed. SSOT Rule #2: throw with remediation rather
543
+ // than silently dispatching a request the venue will reject.
544
+ //
545
+ // Reference: HypurrQuant_FE AsterPerpAdapter.ts throws if agent isn't
546
+ // configured; never attempts master self-signing.
547
+ if (this._masterSigner || this._pkSigner) {
548
+ throw new PerpError("NOT_IMPLEMENTED", "Aster requires a registered agent — master self-signing is not supported by venue", {
549
+ remediation: "perp wallet agent approve aster --master <wallet>",
550
+ });
543
551
  }
544
552
  // No signer at any tier
545
553
  throw new PerpError("NO_SIGNER_AVAILABLE", "No signing path configured for Aster", {
@@ -564,9 +572,9 @@ export class AsterAdapter {
564
572
  * signed dict and the URL query string must be byte-identical except for
565
573
  * the appended signature.
566
574
  *
567
- * Both `user` and `signer` are ALWAYS emitted as distinct query fields,
568
- * even when the values are identical (Tier 2/3 self-signing case where
569
- * master signs for itself). Aster's authority server checks both fields.
575
+ * Both `user` and `signer` are ALWAYS emitted as distinct query fields.
576
+ * In production (Tier 1 only), `user` is the master and `signer` is the
577
+ * registered agent address. Aster's authority server checks both fields.
570
578
  *
571
579
  * `signatureChainId` is NOT a v3 parameter — it's an artifact of an older
572
580
  * scheme; including it breaks signature verification on every call.
@@ -610,21 +618,57 @@ export class AsterAdapter {
610
618
  * Unlike public GETs, signed GETs route through _handleAsterResponse so
611
619
  * venue JSON error envelopes ({code, msg}) are not silently treated as
612
620
  * success (SSOT Rule #2).
621
+ *
622
+ * Retries up to 3 attempts with exponential backoff (2s/4s/8s) on HTTP 429
623
+ * rate-limit. Each retry rebuilds a fresh signed query string (new nonce
624
+ * + signature) — Aster reuses recent nonces strictly, so reusing a stale
625
+ * one would itself be rejected. Non-429 venue errors throw immediately.
613
626
  */
614
627
  async _signedGetEip712(path, params, resolved) {
615
- const qs = await this._buildSignedQueryString(params, resolved);
616
- const url = `${this._baseUrl}${path}?${qs}`;
617
- const res = await fetch(url);
618
- return this._handleAsterResponse(res, "GET", path);
628
+ return this._signedRequestWithRetry("GET", path, params, resolved);
619
629
  }
620
630
  /**
621
631
  * Sign and DELETE from a path using EIP-712 Domain B.
632
+ * Same retry behavior as _signedGetEip712.
622
633
  */
623
634
  async _signedDeleteEip712(path, params, resolved) {
624
- const qs = await this._buildSignedQueryString(params, resolved);
625
- const url = `${this._baseUrl}${path}?${qs}`;
626
- const res = await fetch(url, { method: "DELETE" });
627
- return this._handleAsterResponse(res, "DELETE", path);
635
+ return this._signedRequestWithRetry("DELETE", path, params, resolved);
636
+ }
637
+ /** Shared GET/DELETE retry loop with fresh-nonce per attempt.
638
+ *
639
+ * Total 4 attempts: initial + 3 retries with backoffs 2s, 4s, 8s.
640
+ * Codex v0.12.12 final QA #3: previously MAX_ATTEMPTS = 3 truncated the
641
+ * documented curve so the 8s backoff was never reachable in practice. */
642
+ async _signedRequestWithRetry(method, path, params, resolved) {
643
+ const MAX_ATTEMPTS = 4;
644
+ let lastErr;
645
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
646
+ // Rebuild signed query string with fresh nonce on each attempt.
647
+ const qs = await this._buildSignedQueryString(params, resolved);
648
+ const url = `${this._baseUrl}${path}?${qs}`;
649
+ const res = await fetch(url, method === "DELETE" ? { method: "DELETE" } : undefined);
650
+ // 429 → backoff + retry. All other paths (including non-429 venue
651
+ // errors wrapped in HTTP 200 with code≠0) throw immediately via
652
+ // _handleAsterResponse — no silent fallback (Rule #2).
653
+ if (res.status === 429 && attempt < MAX_ATTEMPTS - 1) {
654
+ const backoffMs = 2000 * Math.pow(2, attempt); // 2s, 4s, 8s
655
+ await new Promise((r) => setTimeout(r, backoffMs));
656
+ continue;
657
+ }
658
+ try {
659
+ return await this._handleAsterResponse(res, method, path);
660
+ }
661
+ catch (e) {
662
+ lastErr = e;
663
+ if (res.status === 429 && attempt < MAX_ATTEMPTS - 1) {
664
+ const backoffMs = 2000 * Math.pow(2, attempt);
665
+ await new Promise((r) => setTimeout(r, backoffMs));
666
+ continue;
667
+ }
668
+ throw e;
669
+ }
670
+ }
671
+ throw lastErr;
628
672
  }
629
673
  // ── Private: HTTP response handling ──────────────────────────────────────
630
674
  /**
@@ -6,7 +6,14 @@ export declare class HyperliquidAdapter implements ExchangeAdapter {
6
6
  readonly name = "hyperliquid";
7
7
  readonly chain = "evm";
8
8
  readonly aliases: readonly ["hl"];
9
- readonly isUnifiedAccount = true;
9
+ /**
10
+ * Reflects whether perp + spot share the same USDC pool (true for unified /
11
+ * portfolio mode) or are separately margined (false for standard mode and
12
+ * HIP-3 dex accounts). Populated during init() from _getAbstractionMode();
13
+ * defaults to false until init resolves so callers never assume unified
14
+ * semantics for accounts that have never opted in. Codex v0.12.12 final QA #1.
15
+ */
16
+ isUnifiedAccount: boolean;
10
17
  private sdk;
11
18
  private _address;
12
19
  private _privateKey;
@@ -5,7 +5,14 @@ export class HyperliquidAdapter {
5
5
  name = "hyperliquid";
6
6
  chain = "evm";
7
7
  aliases = ["hl"];
8
- isUnifiedAccount = true;
8
+ /**
9
+ * Reflects whether perp + spot share the same USDC pool (true for unified /
10
+ * portfolio mode) or are separately margined (false for standard mode and
11
+ * HIP-3 dex accounts). Populated during init() from _getAbstractionMode();
12
+ * defaults to false until init resolves so callers never assume unified
13
+ * semantics for accounts that have never opted in. Codex v0.12.12 final QA #1.
14
+ */
15
+ isUnifiedAccount = false;
9
16
  sdk;
10
17
  _address;
11
18
  _privateKey;
@@ -147,6 +154,22 @@ export class HyperliquidAdapter {
147
154
  }
148
155
  // Build asset index map
149
156
  await this._loadAssetMap();
157
+ // Resolve abstraction mode → isUnifiedAccount flag for callers
158
+ // (portfolio accounting + spot-perp arb sizing). Standard/HIP-3 dex
159
+ // accounts are NOT unified (perp + spot are separately margined).
160
+ // Read-only paths (no address yet) can't query userAbstraction; leave the
161
+ // default false until a downstream caller invokes setAddress + the cache
162
+ // populates during getBalance(). Codex v0.12.12 final QA #1.
163
+ if (this._address) {
164
+ try {
165
+ const mode = await this._getAbstractionMode();
166
+ this.isUnifiedAccount = mode === "unified" || mode === "portfolio";
167
+ }
168
+ catch {
169
+ // If userAbstraction lookup fails, retain conservative default
170
+ // (false). getBalance() will surface the underlying error when called.
171
+ }
172
+ }
150
173
  }
151
174
  /** Load asset index map — supports native and HIP-3 dex. */
152
175
  async _loadAssetMap() {
@@ -15,6 +15,7 @@ import { tryLoadPrivateKey, parseSolanaKeypair } from "./config.js";
15
15
  import { PacificaAdapter } from "./exchanges/pacifica.js";
16
16
  import { HyperliquidAdapter } from "./exchanges/hyperliquid.js";
17
17
  import { LighterAdapter } from "./exchanges/lighter.js";
18
+ import { AsterAdapter } from "./exchanges/aster.js";
18
19
  import { fetchPacificaPrices, fetchHyperliquidMeta, fetchLighterOrderBookDetails, pingPacifica, pingHyperliquid, pingLighter, } from "./shared-api.js";
19
20
  import { fetchAllFundingRates } from "./funding-rates.js";
20
21
  import { loadArbState } from "./arb/state.js";
@@ -47,8 +48,14 @@ async function getOrCreateAdapter(exchange) {
47
48
  adapter = lt;
48
49
  break;
49
50
  }
51
+ case "aster": {
52
+ const ast = new AsterAdapter(pk);
53
+ await ast.init();
54
+ adapter = ast;
55
+ break;
56
+ }
50
57
  default:
51
- throw new Error(`Unknown exchange: ${exchange}. Supported: pacifica, hyperliquid, lighter`);
58
+ throw new Error(`Unknown exchange: ${exchange}. Supported: pacifica, hyperliquid, lighter, aster`);
52
59
  }
53
60
  adapters.set(key, adapter);
54
61
  return adapter;
@@ -80,7 +87,14 @@ async function fetchMarketsPublic(exchange) {
80
87
  const data = await fetchLighterOrderBookDetails();
81
88
  return data.map(d => ({ symbol: d.symbol, markPrice: String(d.lastTradePrice), indexPrice: String(d.lastTradePrice), fundingRate: "0", volume24h: "0", openInterest: "0", maxLeverage: 20 }));
82
89
  }
83
- throw new Error(`Unknown exchange: ${exchange}. Supported: pacifica, hyperliquid, lighter`);
90
+ else if (ex === "aster" || ex === "ast") {
91
+ // Aster has no shared-api helper; AsterAdapter.getMarkets() uses
92
+ // public-only endpoints (exchangeInfo + ticker/24hr + premiumIndex).
93
+ const ast = new AsterAdapter();
94
+ await ast.init();
95
+ return ast.getMarkets();
96
+ }
97
+ throw new Error(`Unknown exchange: ${exchange}. Supported: pacifica, hyperliquid, lighter, aster`);
84
98
  }
85
99
  /** Fetch orderbook using public API (no key needed) */
86
100
  async function fetchOrderbookPublic(exchange, symbol) {
@@ -116,6 +130,12 @@ async function fetchOrderbookPublic(exchange, symbol) {
116
130
  asks: (data.l?.[1] ?? []).map(e => [e.p, e.a]),
117
131
  };
118
132
  }
133
+ else if (ex === "aster" || ex === "ast") {
134
+ // AsterAdapter.getOrderbook() uses /fapi/v1/depth (public).
135
+ const ast = new AsterAdapter();
136
+ await ast.init();
137
+ return ast.getOrderbook(symbol);
138
+ }
119
139
  throw new Error(`Unknown exchange: ${exchange}`);
120
140
  }
121
141
  // ── JSON envelope helpers ──
@@ -132,7 +152,7 @@ const server = new McpServer({ name: "perp-cli", version: _pkg.version }, { capa
132
152
  // ============================================================
133
153
  // Market Data tools (read-only, no private key needed)
134
154
  // ============================================================
135
- server.tool("get_markets", "Get all available perpetual futures markets on an exchange, including price, funding rate, volume, and max leverage. Works without API keys.", { exchange: z.string().describe("Exchange name: pacifica, hyperliquid, or lighter") }, async ({ exchange }) => {
155
+ server.tool("get_markets", "Get all available perpetual futures markets on an exchange, including price, funding rate, volume, and max leverage. Works without API keys.", { exchange: z.string().describe("Exchange name: pacifica, hyperliquid, lighter, or aster") }, async ({ exchange }) => {
136
156
  try {
137
157
  // Try adapter first (richer data), fall back to public API (no key needed)
138
158
  const adapter = await tryGetAdapter(exchange);
@@ -148,7 +168,7 @@ server.tool("get_markets", "Get all available perpetual futures markets on an ex
148
168
  }
149
169
  });
150
170
  server.tool("get_orderbook", "Get the order book (bids and asks) for a symbol on an exchange. Works without API keys.", {
151
- exchange: z.string().describe("Exchange name: pacifica, hyperliquid, or lighter"),
171
+ exchange: z.string().describe("Exchange name: pacifica, hyperliquid, lighter, or aster"),
152
172
  symbol: z.string().describe("Trading pair symbol, e.g. BTC, ETH, SOL"),
153
173
  }, async ({ exchange, symbol }) => {
154
174
  try {
@@ -165,7 +185,7 @@ server.tool("get_orderbook", "Get the order book (bids and asks) for a symbol on
165
185
  return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e), { exchange, symbol }) }], isError: true };
166
186
  }
167
187
  });
168
- server.tool("get_funding_rates", "Compare funding rates across all 3 exchanges (Pacifica, Hyperliquid, Lighter). Returns rates per symbol with spread analysis", {
188
+ server.tool("get_funding_rates", "Compare funding rates across all 4 exchanges (Pacifica, Hyperliquid, Lighter, Aster). Returns rates per symbol with spread analysis", {
169
189
  symbols: z
170
190
  .array(z.string())
171
191
  .optional()
@@ -185,17 +205,24 @@ server.tool("get_funding_rates", "Compare funding rates across all 3 exchanges (
185
205
  return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e)) }], isError: true };
186
206
  }
187
207
  });
188
- server.tool("get_prices", "Get cross-exchange prices for symbols. Fetches mark prices from all 3 exchanges for comparison", {
208
+ server.tool("get_prices", "Get cross-exchange prices for symbols. Fetches mark prices from all 4 exchanges for comparison", {
189
209
  symbols: z
190
210
  .array(z.string())
191
211
  .optional()
192
212
  .describe("Symbols to fetch prices for (e.g. ['BTC','ETH']). Omit for top assets"),
193
213
  }, async ({ symbols }) => {
194
214
  try {
195
- const [pacifica, hl, lighter] = await Promise.all([
215
+ // Aster has no shared-api helper; use AsterAdapter.getMarkets() (public).
216
+ const asterMarkets = (async () => {
217
+ const ast = new AsterAdapter();
218
+ await ast.init();
219
+ return ast.getMarkets();
220
+ })();
221
+ const [pacifica, hl, lighter, aster] = await Promise.all([
196
222
  fetchPacificaPrices(),
197
223
  fetchHyperliquidMeta(),
198
224
  fetchLighterOrderBookDetails(),
225
+ asterMarkets,
199
226
  ]);
200
227
  const filter = symbols ? new Set(symbols.map(s => s.toUpperCase())) : null;
201
228
  // Build symbol → exchange prices map
@@ -224,6 +251,14 @@ server.tool("get_prices", "Get cross-exchange prices for symbols. Fetches mark p
224
251
  priceMap.set(sym, {});
225
252
  priceMap.get(sym).lighter = m.lastTradePrice;
226
253
  }
254
+ for (const a of aster) {
255
+ const sym = a.symbol.toUpperCase();
256
+ if (filter && !filter.has(sym))
257
+ continue;
258
+ if (!priceMap.has(sym))
259
+ priceMap.set(sym, {});
260
+ priceMap.get(sym).aster = Number(a.markPrice);
261
+ }
227
262
  const data = Array.from(priceMap.entries()).map(([symbol, prices]) => ({ symbol, prices }));
228
263
  return { content: [{ type: "text", text: ok(data, { count: data.length }) }] };
229
264
  }
@@ -234,7 +269,7 @@ server.tool("get_prices", "Get cross-exchange prices for symbols. Fetches mark p
234
269
  // ============================================================
235
270
  // Account tools (need private key)
236
271
  // ============================================================
237
- server.tool("get_balance", "Get account balance (equity, available margin, margin used, unrealized PnL) on an exchange", { exchange: z.string().describe("Exchange name: pacifica, hyperliquid, or lighter") }, async ({ exchange }) => {
272
+ server.tool("get_balance", "Get account balance (equity, available margin, margin used, unrealized PnL) on an exchange", { exchange: z.string().describe("Exchange name: pacifica, hyperliquid, lighter, or aster") }, async ({ exchange }) => {
238
273
  try {
239
274
  const adapter = await getOrCreateAdapter(exchange);
240
275
  const balance = await adapter.getBalance();
@@ -244,7 +279,7 @@ server.tool("get_balance", "Get account balance (equity, available margin, margi
244
279
  return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e), { exchange }) }], isError: true };
245
280
  }
246
281
  });
247
- server.tool("get_positions", "Get all open positions on an exchange, including size, entry price, PnL, leverage", { exchange: z.string().describe("Exchange name: pacifica, hyperliquid, or lighter") }, async ({ exchange }) => {
282
+ server.tool("get_positions", "Get all open positions on an exchange, including size, entry price, PnL, leverage", { exchange: z.string().describe("Exchange name: pacifica, hyperliquid, lighter, or aster") }, async ({ exchange }) => {
248
283
  try {
249
284
  const adapter = await getOrCreateAdapter(exchange);
250
285
  const positions = await adapter.getPositions();
@@ -254,7 +289,7 @@ server.tool("get_positions", "Get all open positions on an exchange, including s
254
289
  return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e), { exchange }) }], isError: true };
255
290
  }
256
291
  });
257
- server.tool("get_open_orders", "Get all open/pending orders on an exchange", { exchange: z.string().describe("Exchange name: pacifica, hyperliquid, or lighter") }, async ({ exchange }) => {
292
+ server.tool("get_open_orders", "Get all open/pending orders on an exchange", { exchange: z.string().describe("Exchange name: pacifica, hyperliquid, lighter, or aster") }, async ({ exchange }) => {
258
293
  try {
259
294
  const adapter = await getOrCreateAdapter(exchange);
260
295
  const orders = await adapter.getOpenOrders();
@@ -264,8 +299,8 @@ server.tool("get_open_orders", "Get all open/pending orders on an exchange", { e
264
299
  return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e), { exchange }) }], isError: true };
265
300
  }
266
301
  });
267
- server.tool("portfolio", "Cross-exchange portfolio summary: balances, positions, and risk metrics across all exchanges", {}, async () => {
268
- const EXCHANGES = ["pacifica", "hyperliquid", "lighter"];
302
+ server.tool("portfolio", "Cross-exchange portfolio summary: balances, positions, and risk metrics across all 4 exchanges", {}, async () => {
303
+ const EXCHANGES = ["pacifica", "hyperliquid", "lighter", "aster"];
269
304
  const snapshots = await Promise.all(EXCHANGES.map(async (name) => {
270
305
  try {
271
306
  const adapter = await getOrCreateAdapter(name);
@@ -340,7 +375,7 @@ server.tool("portfolio", "Cross-exchange portfolio summary: balances, positions,
340
375
  // ============================================================
341
376
  server.tool("suggest_command", "Given a natural language trading goal, suggest the exact perp CLI commands to run. Does NOT execute anything — only returns commands for the user to review and run manually", {
342
377
  goal: z.string().describe("Natural language goal, e.g. 'buy 0.1 BTC on pacifica', 'close all positions', 'check funding arb opportunities'"),
343
- exchange: z.string().optional().describe("Preferred exchange (default: pacifica). Options: pacifica, hyperliquid, lighter"),
378
+ exchange: z.string().optional().describe("Preferred exchange (default: pacifica). Options: pacifica, hyperliquid, lighter, aster"),
344
379
  }, async ({ goal, exchange }) => {
345
380
  try {
346
381
  const ex = exchange ?? "pacifica";
@@ -349,18 +384,18 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
349
384
  if (g.includes("buy") || g.includes("long")) {
350
385
  const symbol = extractSymbol(g) || "BTC";
351
386
  const size = extractNumber(g) || "<size>";
352
- steps.push({ step: 1, command: `perp -e ${ex} --json market book ${symbol}`, description: `Check ${symbol} orderbook and liquidity` }, { step: 2, command: `perp -e ${ex} --json account balance`, description: "Check available balance and margin" }, { step: 3, command: `perp -e ${ex} --json trade check ${symbol} buy ${size}`, description: "Pre-flight validation (dry run)" }, { step: 4, command: `perp -e ${ex} --json trade market ${symbol} buy ${size}`, description: `Buy ${size} ${symbol} at market`, dangerous: true }, { step: 5, command: `perp -e ${ex} --json account positions`, description: "Verify position opened" });
387
+ steps.push({ step: 1, command: `perp -e ${ex} --json market book ${symbol}`, description: `Check ${symbol} orderbook and liquidity` }, { step: 2, command: `perp -e ${ex} --json portfolio`, description: "Check available balance and margin" }, { step: 3, command: `perp -e ${ex} --json trade check ${symbol} buy ${size}`, description: "Pre-flight validation (dry run)" }, { step: 4, command: `perp -e ${ex} --json trade market ${symbol} buy ${size}`, description: `Buy ${size} ${symbol} at market`, dangerous: true }, { step: 5, command: `perp -e ${ex} --json account positions`, description: "Verify position opened" });
353
388
  }
354
389
  else if (g.includes("sell") || g.includes("short")) {
355
390
  const symbol = extractSymbol(g) || "BTC";
356
391
  const size = extractNumber(g) || "<size>";
357
- steps.push({ step: 1, command: `perp -e ${ex} --json market book ${symbol}`, description: `Check ${symbol} orderbook and liquidity` }, { step: 2, command: `perp -e ${ex} --json account balance`, description: "Check available balance and margin" }, { step: 3, command: `perp -e ${ex} --json trade check ${symbol} sell ${size}`, description: "Pre-flight validation (dry run)" }, { step: 4, command: `perp -e ${ex} --json trade market ${symbol} sell ${size}`, description: `Sell ${size} ${symbol} at market`, dangerous: true }, { step: 5, command: `perp -e ${ex} --json account positions`, description: "Verify position opened" });
392
+ steps.push({ step: 1, command: `perp -e ${ex} --json market book ${symbol}`, description: `Check ${symbol} orderbook and liquidity` }, { step: 2, command: `perp -e ${ex} --json portfolio`, description: "Check available balance and margin" }, { step: 3, command: `perp -e ${ex} --json trade check ${symbol} sell ${size}`, description: "Pre-flight validation (dry run)" }, { step: 4, command: `perp -e ${ex} --json trade market ${symbol} sell ${size}`, description: `Sell ${size} ${symbol} at market`, dangerous: true }, { step: 5, command: `perp -e ${ex} --json account positions`, description: "Verify position opened" });
358
393
  }
359
394
  else if (g.includes("limit")) {
360
395
  const symbol = extractSymbol(g) || "BTC";
361
396
  const size = extractNumber(g) || "<size>";
362
397
  const side = g.includes("sell") || g.includes("short") ? "sell" : "buy";
363
- steps.push({ step: 1, command: `perp -e ${ex} --json market book ${symbol}`, description: `Check ${symbol} orderbook for price levels` }, { step: 2, command: `perp -e ${ex} --json account balance`, description: "Check available balance" }, { step: 3, command: `perp -e ${ex} --json trade limit ${symbol} ${side} <price> ${size}`, description: `Place limit ${side} order`, dangerous: true }, { step: 4, command: `perp -e ${ex} --json account orders`, description: "Verify order placed" });
398
+ steps.push({ step: 1, command: `perp -e ${ex} --json market book ${symbol}`, description: `Check ${symbol} orderbook for price levels` }, { step: 2, command: `perp -e ${ex} --json portfolio`, description: "Check available balance" }, { step: 3, command: `perp -e ${ex} --json trade limit ${symbol} ${side} <price> ${size}`, description: `Place limit ${side} order`, dangerous: true }, { step: 4, command: `perp -e ${ex} --json account orders`, description: "Verify order placed" });
364
399
  }
365
400
  else if (g.includes("close") || g.includes("exit") || g.includes("flatten")) {
366
401
  const symbol = extractSymbol(g);
@@ -382,7 +417,7 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
382
417
  else if (g.includes("scale-in") || g.includes("scale in") || g.includes("dca in")) {
383
418
  const symbol = extractSymbol(g) || "<symbol>";
384
419
  const side = g.includes("sell") || g.includes("short") ? "sell" : "buy";
385
- steps.push({ step: 1, command: `perp -e ${ex} --json market book ${symbol}`, description: "Check current prices" }, { step: 2, command: `perp -e ${ex} --json account balance`, description: "Check available balance" }, { step: 3, command: `perp -e ${ex} --json trade scale-in ${symbol} ${side} --levels '<price1>:<size>,<price2>:<size>'`, description: "Place scaled entry orders at multiple levels", dangerous: true });
420
+ steps.push({ step: 1, command: `perp -e ${ex} --json market book ${symbol}`, description: "Check current prices" }, { step: 2, command: `perp -e ${ex} --json portfolio`, description: "Check available balance" }, { step: 3, command: `perp -e ${ex} --json trade scale-in ${symbol} ${side} --levels '<price1>:<size>,<price2>:<size>'`, description: "Place scaled entry orders at multiple levels", dangerous: true });
386
421
  }
387
422
  else if (g.includes("tpsl") || g.includes("tp/sl") || g.includes("tp sl") || (g.includes("take profit") && g.includes("stop loss"))) {
388
423
  const symbol = extractSymbol(g) || "<symbol>";
@@ -401,13 +436,13 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
401
436
  const symbol = extractSymbol(g) || "<symbol>";
402
437
  const amount = extractNumber(g) || "<usd>";
403
438
  const side = g.includes("sell") || g.includes("short") ? "sell" : "buy";
404
- steps.push({ step: 1, command: `perp -e ${ex} --json market book ${symbol}`, description: `Check ${symbol} orderbook depth` }, { step: 2, command: `perp -e ${ex} --json account balance`, description: "Check available balance" }, { step: 3, command: `perp -e ${ex} --json trade split ${symbol} ${side} ${amount}`, description: `Split ${side} $${amount} ${symbol} into depth-based slices`, dangerous: true }, { step: 4, command: `perp -e ${ex} --json account positions`, description: "Verify position opened" });
439
+ steps.push({ step: 1, command: `perp -e ${ex} --json market book ${symbol}`, description: `Check ${symbol} orderbook depth` }, { step: 2, command: `perp -e ${ex} --json portfolio`, description: "Check available balance" }, { step: 3, command: `perp -e ${ex} --json trade split ${symbol} ${side} ${amount}`, description: `Split ${side} $${amount} ${symbol} into depth-based slices`, dangerous: true }, { step: 4, command: `perp -e ${ex} --json account positions`, description: "Verify position opened" });
405
440
  }
406
441
  else if (g.includes("twap")) {
407
442
  const symbol = extractSymbol(g) || "<symbol>";
408
443
  const size = extractNumber(g) || "<size>";
409
444
  const side = g.includes("sell") || g.includes("short") ? "sell" : "buy";
410
- steps.push({ step: 1, command: `perp -e ${ex} --json account balance`, description: "Check available balance" }, { step: 2, command: `perp -e ${ex} --json trade twap ${symbol} ${side} ${size} <duration>`, description: `TWAP ${side} ${size} ${symbol} over duration`, dangerous: true });
445
+ steps.push({ step: 1, command: `perp -e ${ex} --json portfolio`, description: "Check available balance" }, { step: 2, command: `perp -e ${ex} --json trade twap ${symbol} ${side} ${size} <duration>`, description: `TWAP ${side} ${size} ${symbol} over duration`, dangerous: true });
411
446
  }
412
447
  else if (g.includes("reduce")) {
413
448
  const symbol = extractSymbol(g) || "<symbol>";
@@ -441,7 +476,7 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
441
476
  else if (g.includes("dca")) {
442
477
  const amount = extractNumber(g) || "<amount>";
443
478
  const side = g.includes("sell") || g.includes("short") ? "sell" : "buy";
444
- steps.push({ step: 1, command: `perp -e ${ex} --json account balance`, description: "Check available balance" }, { step: 2, command: `perp -e ${ex} --json strategy quick-dca ${symbol} ${side} ${amount} <interval>`, description: "Start DCA bot", dangerous: true }, { step: 3, command: "perp --json background list", description: "Verify bot is running" });
479
+ steps.push({ step: 1, command: `perp -e ${ex} --json portfolio`, description: "Check available balance" }, { step: 2, command: `perp -e ${ex} --json strategy quick-dca ${symbol} ${side} ${amount} <interval>`, description: "Start DCA bot", dangerous: true }, { step: 3, command: "perp --json background list", description: "Verify bot is running" });
445
480
  }
446
481
  else {
447
482
  steps.push({ step: 1, command: "perp --json strategy preset-list", description: "List available bot presets" }, { step: 2, command: `perp -e ${ex} --json strategy quick-arb`, description: "Start arb bot", dangerous: true }, { step: 3, command: "perp --json background list", description: "Verify bot is running" });
@@ -503,7 +538,7 @@ server.tool("suggest_command", "Given a natural language trading goal, suggest t
503
538
  }
504
539
  else if (g.includes("withdraw")) {
505
540
  const amount = extractNumber(g) || "<amount>";
506
- steps.push({ step: 1, command: `perp -e ${ex} --json account balance`, description: "Check available balance" }, { step: 2, command: `perp --json funds withdraw ${ex} ${amount}`, description: `Withdraw $${amount} from ${ex}`, dangerous: true }, { step: 3, command: "perp --json wallet balance", description: "Verify withdrawal received" });
541
+ steps.push({ step: 1, command: `perp -e ${ex} --json portfolio`, description: "Check available balance" }, { step: 2, command: `perp --json funds withdraw ${ex} ${amount}`, description: `Withdraw $${amount} from ${ex}`, dangerous: true }, { step: 3, command: "perp --json wallet balance", description: "Verify withdrawal received" });
507
542
  }
508
543
  else if (g.includes("leverage")) {
509
544
  const symbol = extractSymbol(g) || "<symbol>";
@@ -981,18 +1016,31 @@ server.tool("arb_scan", "Scan for funding rate arbitrage opportunities across ex
981
1016
  return { content: [{ type: "text", text: err(e instanceof Error ? e.message : String(e)) }], isError: true };
982
1017
  }
983
1018
  });
984
- server.tool("health_check", "Ping all exchanges and return connectivity status and latency", {}, async () => {
1019
+ server.tool("health_check", "Ping all 4 exchanges and return connectivity status and latency", {}, async () => {
985
1020
  try {
986
- const [pacifica, hyperliquid, lighter] = await Promise.all([
1021
+ // Aster has no shared-api ping helper; ping /fapi/v1/time directly.
1022
+ const pingAster = async () => {
1023
+ const start = Date.now();
1024
+ try {
1025
+ const res = await fetch("https://fapi.asterdex.com/fapi/v1/time");
1026
+ return { ok: res.ok, latencyMs: Date.now() - start, status: res.status };
1027
+ }
1028
+ catch {
1029
+ return { ok: false, latencyMs: Date.now() - start, status: 0 };
1030
+ }
1031
+ };
1032
+ const [pacifica, hyperliquid, lighter, aster] = await Promise.all([
987
1033
  pingPacifica(),
988
1034
  pingHyperliquid(),
989
1035
  pingLighter(),
1036
+ pingAster(),
990
1037
  ]);
991
1038
  const result = {
992
1039
  pacifica: { ...pacifica, statusText: pacifica.ok ? "healthy" : "unreachable" },
993
1040
  hyperliquid: { ...hyperliquid, statusText: hyperliquid.ok ? "healthy" : "unreachable" },
994
1041
  lighter: { ...lighter, statusText: lighter.ok ? "healthy" : "unreachable" },
995
- allHealthy: pacifica.ok && hyperliquid.ok && lighter.ok,
1042
+ aster: { ...aster, statusText: aster.ok ? "healthy" : "unreachable" },
1043
+ allHealthy: pacifica.ok && hyperliquid.ok && lighter.ok && aster.ok,
996
1044
  };
997
1045
  return { content: [{ type: "text", text: ok(result) }] };
998
1046
  }
@@ -1216,7 +1264,7 @@ server.resource("cli_schema", "perp://schema", { mimeType: "application/json", d
1216
1264
  // Trade execution tools (dry-run + confirm safety model)
1217
1265
  // ============================================================
1218
1266
  server.tool("trade_preview", "Preview a trade WITHOUT executing. Returns estimated fill price, fees, margin impact, and risk checks. ALWAYS call this before trade_execute and show the result to the user for confirmation.", {
1219
- exchange: z.string().describe("Exchange: pacifica, hyperliquid, or lighter"),
1267
+ exchange: z.string().describe("Exchange: pacifica, hyperliquid, lighter, or aster"),
1220
1268
  symbol: z.string().describe("Trading symbol (e.g., BTC, ETH, SOL)"),
1221
1269
  side: z.enum(["buy", "sell"]).describe("Order side"),
1222
1270
  size: z.string().describe("Order size (base currency units, e.g., '0.1' for 0.1 BTC)"),
@@ -1293,7 +1341,7 @@ server.tool("trade_preview", "Preview a trade WITHOUT executing. Returns estimat
1293
1341
  }
1294
1342
  });
1295
1343
  server.tool("trade_execute", "Execute a trade. IMPORTANT: Always call trade_preview first and get explicit user confirmation before calling this tool. Supports market and limit orders.", {
1296
- exchange: z.string().describe("Exchange: pacifica, hyperliquid, or lighter"),
1344
+ exchange: z.string().describe("Exchange: pacifica, hyperliquid, lighter, or aster"),
1297
1345
  symbol: z.string().describe("Trading symbol (e.g., BTC, ETH, SOL)"),
1298
1346
  side: z.enum(["buy", "sell"]).describe("Order side"),
1299
1347
  size: z.string().describe("Order size (base currency units)"),
@@ -1332,7 +1380,7 @@ server.tool("trade_execute", "Execute a trade. IMPORTANT: Always call trade_prev
1332
1380
  }
1333
1381
  });
1334
1382
  server.tool("trade_close", "Close an existing position. IMPORTANT: Call trade_preview first with the opposite side to show impact, then get user confirmation.", {
1335
- exchange: z.string().describe("Exchange: pacifica, hyperliquid, or lighter"),
1383
+ exchange: z.string().describe("Exchange: pacifica, hyperliquid, lighter, or aster"),
1336
1384
  symbol: z.string().describe("Symbol to close (e.g., BTC, ETH)"),
1337
1385
  }, async ({ exchange, symbol }) => {
1338
1386
  try {
package/dist/utils.d.ts CHANGED
@@ -17,6 +17,8 @@ export interface ApiResponse<T = unknown> {
17
17
  status?: number;
18
18
  retryable?: boolean;
19
19
  retryAfterMs?: number;
20
+ /** Actionable hint for automated callers (Codex v0.12.12 final QA #2). */
21
+ remediation?: string;
20
22
  details?: Record<string, unknown>;
21
23
  };
22
24
  meta?: {
@@ -33,9 +35,15 @@ export declare function jsonError(code: string, message: string, meta?: Partial<
33
35
  retryable?: boolean;
34
36
  retryAfterMs?: number;
35
37
  details?: Record<string, unknown>;
38
+ remediation?: string;
36
39
  }): ApiResponse<never>;
37
40
  /** Execute a command action with structured error handling.
38
41
  * In JSON mode, errors are returned as JSON instead of crashing.
42
+ *
43
+ * PerpError-typed throws are preserved end-to-end: their `code`,
44
+ * `remediation`, and other StructuredError fields surface in the JSON
45
+ * envelope's `error` block so machine consumers (agents / MCP / automation)
46
+ * receive actionable hints. Codex v0.12.12 final QA #2.
39
47
  */
40
48
  export declare function withJsonErrors<T>(isJson: boolean, fn: () => Promise<T>, exchange?: string): Promise<T | undefined>;
41
49
  /**
package/dist/utils.js CHANGED
@@ -194,6 +194,7 @@ export function jsonError(code, message, meta) {
194
194
  ...(meta?.status !== undefined ? { status: meta.status } : {}),
195
195
  ...(meta?.retryable !== undefined ? { retryable: meta.retryable } : {}),
196
196
  ...(meta?.retryAfterMs !== undefined ? { retryAfterMs: meta.retryAfterMs } : {}),
197
+ ...(meta?.remediation !== undefined ? { remediation: meta.remediation } : {}),
197
198
  ...(meta?.details ? { details: meta.details } : {}),
198
199
  },
199
200
  meta: { timestamp: new Date().toISOString(), ...meta },
@@ -201,6 +202,11 @@ export function jsonError(code, message, meta) {
201
202
  }
202
203
  /** Execute a command action with structured error handling.
203
204
  * In JSON mode, errors are returned as JSON instead of crashing.
205
+ *
206
+ * PerpError-typed throws are preserved end-to-end: their `code`,
207
+ * `remediation`, and other StructuredError fields surface in the JSON
208
+ * envelope's `error` block so machine consumers (agents / MCP / automation)
209
+ * receive actionable hints. Codex v0.12.12 final QA #2.
204
210
  */
205
211
  export async function withJsonErrors(isJson, fn, exchange) {
206
212
  try {
@@ -213,10 +219,15 @@ export async function withJsonErrors(isJson, fn, exchange) {
213
219
  status: classified.status,
214
220
  retryable: classified.retryable,
215
221
  retryAfterMs: classified.retryAfterMs,
222
+ remediation: classified.remediation,
223
+ details: classified.details,
216
224
  })));
217
225
  }
218
226
  else {
219
227
  console.error(chalk.red(`Error: ${classified.message}`));
228
+ if (classified.remediation) {
229
+ console.error(chalk.yellow(`Remediation: ${classified.remediation}`));
230
+ }
220
231
  }
221
232
  return undefined;
222
233
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "perp-cli",
3
- "version": "0.12.11",
3
+ "version": "0.12.13",
4
4
  "description": "Multi-DEX Perpetual Futures CLI - Pacifica, Hyperliquid, Lighter, Aster",
5
5
  "bin": {
6
6
  "perp": "dist/index.js",
@@ -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.12.11"
8
+ version: "0.12.13"
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.12.11)
44
+ perp --version 2>/dev/null # check if exists (must be >= 0.12.13)
45
45
  npm install -g perp-cli@latest 2>/dev/null || npx -y perp-cli@latest --json --version
46
46
  ```
47
47