perp-cli 0.12.2 → 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,
@@ -1157,6 +1173,56 @@ export function registerWalletAgentCommands(walletCmd, isJson) {
1157
1173
  }
1158
1174
  });
1159
1175
  }
1176
+ // ── Pre-venue rollback helpers ───────────────────────────────────────────────
1177
+ //
1178
+ // When `wallet agent approve <dex>` fails BEFORE the venue accepted the agent
1179
+ // (HTTP error, body-code rejection, parse error, network drop), the locally
1180
+ // generated `agent-<dex>-<master>` OWS wallet is orphaned in `~/.ows/wallets/`.
1181
+ // Retry collides with "wallet name already exists". These helpers auto-delete
1182
+ // the orphan and produce an actionable remediation string.
1183
+ //
1184
+ // Post-venue (persist-only) failures must NOT use these — those leave a
1185
+ // status:"partial" record so the user can still revoke venue-side state.
1186
+ /**
1187
+ * Best-effort delete of an orphan local OWS wallet. Returns true on success,
1188
+ * false on failure (also logs the failure to stderr per SSOT Rule #2: rollback
1189
+ * failures must not silently mask the original error). The caller threads the
1190
+ * boolean into the user-visible remediation.
1191
+ */
1192
+ function tryDeleteOrphanWallet(ows, walletName) {
1193
+ if (!walletName)
1194
+ return false;
1195
+ try {
1196
+ ows.deleteWallet(walletName);
1197
+ return true;
1198
+ }
1199
+ catch (cleanupErr) {
1200
+ // Don't swallow — surface the rollback failure on stderr so the user can
1201
+ // see exactly what's stuck. Original error still propagates from caller.
1202
+ process.stderr.write(`[warn] failed to clean up orphan wallet "${walletName}": ${cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr)}\n`);
1203
+ return false;
1204
+ }
1205
+ }
1206
+ /**
1207
+ * Build an actionable remediation message for pre-venue approve failures.
1208
+ * On clean rollback: tells the user to deposit + retry the canonical
1209
+ * `wallet agent approve <dex> --master <master>` command (no manual steps).
1210
+ * On rollback failure: tells the user to manually delete the orphan wallet
1211
+ * before retrying. The agent address is appended for diagnostic purposes
1212
+ * (some users may need it for the venue UI).
1213
+ */
1214
+ function buildPreVenueRemediation(opts) {
1215
+ const { cleanupOk, dex, masterName, agentAddress, hint } = opts;
1216
+ const retry = `perp wallet agent approve ${dex} --master ${masterName}`;
1217
+ const head = cleanupOk
1218
+ ? `Local agent wallet cleaned up. Deposit funds and retry: ${retry}`
1219
+ : `Manual cleanup required — local agent wallet may still exist. Run: perp wallet remove agent-${dex === "hyperliquid" ? "hl" : dex === "pacifica" ? "pac" : "aster"}-${masterName}, then retry: ${retry}`;
1220
+ const parts = [head];
1221
+ if (hint)
1222
+ parts.push(hint);
1223
+ parts.push(`Agent address (diagnostic): ${agentAddress}`);
1224
+ return parts.join(" | ");
1225
+ }
1160
1226
  /**
1161
1227
  * Core approve flow: Steps 2-12 (OWS wallet + policy + api-key + EIP-712 + Aster POST + persist).
1162
1228
  * Caller MUST hold the exchange lock before calling this function.
@@ -1233,13 +1299,15 @@ async function runApproveFlow(opts) {
1233
1299
  });
1234
1300
  if (!builderRes.ok) {
1235
1301
  const errText = await builderRes.text().catch(() => "");
1236
- // Aster builder POST failed — rollback api key (no Aster agent registered yet)
1302
+ // Aster builder POST failed — pre-venue (no Aster agent registered yet).
1303
+ // Rollback both: api key + orphan local OWS wallet.
1237
1304
  if (owsApiKeyId) {
1238
1305
  try {
1239
1306
  ows.revokeApiKey(owsApiKeyId);
1240
1307
  }
1241
1308
  catch { /* best effort */ }
1242
1309
  }
1310
+ tryDeleteOrphanWallet(ows, agentWalletName);
1243
1311
  throw new PerpError("EXCHANGE_ERROR", `Aster approveBuilder failed (${builderRes.status}): ${errText.slice(0, 200)}`, {
1244
1312
  remediation: "Check builder address and max-fee-rate parameters",
1245
1313
  });
@@ -1253,6 +1321,7 @@ async function runApproveFlow(opts) {
1253
1321
  }
1254
1322
  catch { /* best effort */ }
1255
1323
  }
1324
+ tryDeleteOrphanWallet(ows, agentWalletName);
1256
1325
  const errMsg = builderResp.msg ?? builderResp.message ?? String(builderResp.code);
1257
1326
  throw new PerpError("EXCHANGE_ERROR", `Aster approveBuilder failed: ${errMsg}`, {
1258
1327
  remediation: "Check builder address and max-fee-rate parameters",
@@ -1307,30 +1376,37 @@ async function runApproveFlow(opts) {
1307
1376
  asterPostSucceeded = true;
1308
1377
  }
1309
1378
  else {
1310
- // Aster POST body indicated failure — rollback api key (Aster did not register)
1379
+ // Aster POST body indicated failure — pre-venue (Aster did not register).
1380
+ // Rollback both api key + orphan local OWS wallet.
1311
1381
  if (owsApiKeyId) {
1312
1382
  try {
1313
1383
  ows.revokeApiKey(owsApiKeyId);
1314
1384
  }
1315
1385
  catch { /* best effort */ }
1316
1386
  }
1387
+ const cleanupOk = tryDeleteOrphanWallet(ows, agentWalletName);
1317
1388
  const errMsg = resp.msg ?? resp.message ?? String(resp.code);
1318
1389
  throw new PerpError("APPROVE_PARTIAL", `Aster approveAgent failed: ${errMsg}`, {
1319
- remediation: `Agent address for cleanup: ${agentEvmAddress}`,
1390
+ remediation: buildPreVenueRemediation({
1391
+ cleanupOk, dex: "aster", masterName, agentAddress: agentEvmAddress,
1392
+ }),
1320
1393
  });
1321
1394
  }
1322
1395
  }
1323
1396
  else {
1324
- // HTTP error — rollback api key (Aster did not register)
1397
+ // HTTP error — pre-venue (Aster did not register). Rollback both.
1325
1398
  if (owsApiKeyId) {
1326
1399
  try {
1327
1400
  ows.revokeApiKey(owsApiKeyId);
1328
1401
  }
1329
1402
  catch { /* best effort */ }
1330
1403
  }
1404
+ const cleanupOk = tryDeleteOrphanWallet(ows, agentWalletName);
1331
1405
  const errText = await agentRes.text().catch(() => "");
1332
1406
  throw new PerpError("APPROVE_PARTIAL", `Aster approveAgent failed (${agentRes.status}): ${errText.slice(0, 200)}`, {
1333
- remediation: `Agent address for cleanup: ${agentEvmAddress}`,
1407
+ remediation: buildPreVenueRemediation({
1408
+ cleanupOk, dex: "aster", masterName, agentAddress: agentEvmAddress,
1409
+ }),
1334
1410
  });
1335
1411
  }
1336
1412
  // Step 12: Persist agent meta
@@ -1551,13 +1627,26 @@ async function runHlApproveFlow(opts) {
1551
1627
  resp = JSON.parse(text);
1552
1628
  }
1553
1629
  catch {
1630
+ // Pre-venue parse failure — Hyperliquid did NOT register the agent.
1631
+ // Roll back the orphan local OWS wallet so retry doesn't collide with
1632
+ // "wallet name already exists". Errors during cleanup must NOT mask the
1633
+ // original network/parse error (SSOT Rule #2).
1634
+ const cleanupOk = tryDeleteOrphanWallet(ows, agentWalletName);
1554
1635
  throw new PerpError("EXCHANGE_ERROR", `HL exchange API parse error (${agentRes.status}): ${text.slice(0, 200)}`, {
1555
- remediation: "Check Hyperliquid API status",
1636
+ remediation: buildPreVenueRemediation({
1637
+ cleanupOk, dex: "hyperliquid", masterName, agentAddress: agentEvmAddress,
1638
+ hint: "Check Hyperliquid API status",
1639
+ }),
1556
1640
  });
1557
1641
  }
1558
1642
  if (resp?.status === "err") {
1643
+ // Pre-venue rejection — Hyperliquid did NOT register the agent. Roll
1644
+ // back the orphan local OWS wallet so retry can proceed cleanly.
1645
+ const cleanupOk = tryDeleteOrphanWallet(ows, agentWalletName);
1559
1646
  throw new PerpError("APPROVE_PARTIAL", `HL approveAgent failed: ${typeof resp.response === "string" ? resp.response : JSON.stringify(resp)}`, {
1560
- remediation: `Agent address for cleanup: ${agentEvmAddress}`,
1647
+ remediation: buildPreVenueRemediation({
1648
+ cleanupOk, dex: "hyperliquid", masterName, agentAddress: agentEvmAddress,
1649
+ }),
1561
1650
  });
1562
1651
  }
1563
1652
  // Step 6: Persist
@@ -1682,24 +1771,36 @@ async function runPacApproveFlow(opts) {
1682
1771
  // Pacifica REST envelope: success === true OR explicit data field
1683
1772
  if (resp.success === false || resp.error) {
1684
1773
  const errMsg = typeof resp.error === "string" ? resp.error : JSON.stringify(resp);
1774
+ // Pre-venue rejection — Pacifica did NOT register the agent.
1775
+ const cleanupOk = tryDeleteOrphanWallet(ows, agentWalletName);
1685
1776
  throw new PerpError("APPROVE_PARTIAL", `Pacifica bind_agent_wallet failed: ${errMsg}`, {
1686
- remediation: `Agent address for cleanup: ${agentSolanaAddress}`,
1777
+ remediation: buildPreVenueRemediation({
1778
+ cleanupOk, dex: "pacifica", masterName, agentAddress: agentSolanaAddress,
1779
+ }),
1687
1780
  });
1688
1781
  }
1689
1782
  pacPostSucceeded = true;
1690
1783
  }
1691
1784
  else {
1692
1785
  const errText = await httpRes.text().catch(() => "");
1786
+ // Pre-venue rejection — Pacifica did NOT register the agent.
1787
+ const cleanupOk = tryDeleteOrphanWallet(ows, agentWalletName);
1693
1788
  throw new PerpError("APPROVE_PARTIAL", `Pacifica bind_agent_wallet failed (${httpRes.status}): ${errText.slice(0, 200)}`, {
1694
- remediation: `Agent address for cleanup: ${agentSolanaAddress}`,
1789
+ remediation: buildPreVenueRemediation({
1790
+ cleanupOk, dex: "pacifica", masterName, agentAddress: agentSolanaAddress,
1791
+ }),
1695
1792
  });
1696
1793
  }
1697
1794
  }
1698
1795
  catch (httpErr) {
1699
1796
  if (httpErr instanceof PerpError)
1700
1797
  throw httpErr;
1798
+ // Network-level failure — Pacifica did NOT register the agent.
1799
+ const cleanupOk = tryDeleteOrphanWallet(ows, agentWalletName);
1701
1800
  throw new PerpError("APPROVE_PARTIAL", `Pacifica bind_agent_wallet network error: ${httpErr instanceof Error ? httpErr.message : String(httpErr)}`, {
1702
- remediation: `Agent address for cleanup: ${agentSolanaAddress}`,
1801
+ remediation: buildPreVenueRemediation({
1802
+ cleanupOk, dex: "pacifica", masterName, agentAddress: agentSolanaAddress,
1803
+ }),
1703
1804
  });
1704
1805
  }
1705
1806
  // Step 5: Persist
@@ -14,3 +14,22 @@ export declare function validateKey(chain: "solana" | "evm" | "apikey", key: str
14
14
  address: string;
15
15
  }>;
16
16
  export declare function registerInitCommand(program: Command): void;
17
+ /**
18
+ * Drive `perp setup` end-to-end without TTY. Required:
19
+ * - --passphrase or OWS_PASSPHRASE env (3-path resolver)
20
+ * Optional:
21
+ * - --wallet-name (default: "main")
22
+ * - --default-exchange (must be one of pacifica/hyperliquid/lighter/aster)
23
+ *
24
+ * Side effects:
25
+ * 1. Creates an OWS wallet via createWallet(name, passphrase)
26
+ * 2. Sets settings.owsActiveWallet (if unset) + settings.defaultExchange (if provided)
27
+ *
28
+ * Errors are returned as INVALID_PARAMS / PASSPHRASE_REQUIRED — never via
29
+ * readline. JSON envelope is emitted on stdout when --json is present.
30
+ */
31
+ export declare function runNonInteractiveSetup(opts: {
32
+ walletName?: string;
33
+ passphrase?: string;
34
+ defaultExchange?: string;
35
+ }): Promise<void>;
@@ -3,6 +3,10 @@ import { createInterface } from "readline";
3
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
4
4
  import { resolve } from "path";
5
5
  import { loadSettings, saveSettings } from "../settings.js";
6
+ import { PerpError } from "../errors.js";
7
+ import { jsonOk, jsonError, printJson } from "../utils.js";
8
+ import { resolvePassphrase } from "../agent-wallet/passphrase.js";
9
+ import { loadOws } from "../signer/ows-loader.js";
6
10
  const PERP_DIR = resolve(process.env.HOME || "~", ".perp");
7
11
  const ENV_FILE = resolve(PERP_DIR, ".env");
8
12
  // ── .env helpers ──────────────────────────────────────────────
@@ -66,11 +70,15 @@ function askChoice(rl, question, choices) {
66
70
  });
67
71
  }
68
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.
69
78
  export const EXCHANGE_ENV_MAP = {
70
79
  pacifica: { envKey: "PACIFICA_PRIVATE_KEY", chain: "solana", label: "Pacifica (Solana)" },
71
80
  hyperliquid: { envKey: "HL_PRIVATE_KEY", chain: "evm", label: "Hyperliquid (EVM)" },
72
81
  lighter: { envKey: "LIGHTER_PRIVATE_KEY", chain: "evm", label: "Lighter (EVM)" },
73
- aster: { envKey: "ASTER_API_KEY", envKeySecret: "ASTER_API_SECRET", chain: "apikey", label: "Aster (BNB Chain)" },
74
82
  };
75
83
  // ── Validate keys ─────────────────────────────────────────────
76
84
  export async function validateKey(chain, key) {
@@ -109,12 +117,34 @@ export async function validateKey(chain, key) {
109
117
  }
110
118
  // ── Commands ──────────────────────────────────────────────────
111
119
  export function registerInitCommand(program) {
112
- // ── perp setup (interactive) ──
120
+ // ── perp setup (interactive + non-interactive) ──
113
121
  program
114
122
  .command("setup")
115
123
  .alias("init")
116
- .description("Interactive setup wizard — configure keys & default exchange")
117
- .action(async () => {
124
+ .description("Setup wizard — interactive by default, scriptable with --non-interactive")
125
+ .option("--non-interactive", "Run without prompts; requires --passphrase or OWS_PASSPHRASE env")
126
+ .option("--wallet-name <name>", "OWS wallet alias to create (default: 'main')")
127
+ .option("--passphrase <pp>", "OWS vault passphrase (fallback: OWS_PASSPHRASE env / stdin)")
128
+ .option("--default-exchange <name>", "Default exchange (pacifica/hyperliquid/lighter/aster)")
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
+ };
141
+ // Non-interactive mode: skip all readline prompts, drive everything from
142
+ // flags + OWS_PASSPHRASE env. This is the agent-friendly onboarding path
143
+ // (CI / Docker / scripted init) — see plan v3.0 "agent-friendly five
144
+ // guarantees" P1 (no-prompt) and P3 (passphrase 3-path).
145
+ if (resolvedOpts.nonInteractive) {
146
+ return runNonInteractiveSetup(resolvedOpts);
147
+ }
118
148
  const rl = createInterface({ input: process.stdin, output: process.stdout });
119
149
  try {
120
150
  console.log(chalk.cyan.bold("\n perp-cli Setup Wizard\n"));
@@ -146,12 +176,28 @@ export function registerInitCommand(program) {
146
176
  console.log(` 1) ${chalk.cyan("Pacifica")} ${chalk.gray("— Solana perps (USDC on Solana)")}`);
147
177
  console.log(` 2) ${chalk.cyan("Hyperliquid")} ${chalk.gray("— EVM perps (USDC on Arbitrum)")}`);
148
178
  console.log(` 3) ${chalk.cyan("Lighter")} ${chalk.gray("— EVM perps (USDC on Ethereum/Arb)")}`);
149
- 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')")}`);
150
180
  console.log();
151
181
  const exchangeChoice = await ask(rl, " Which exchange(s)? (1,2,3,4 or 'all'): ");
152
- 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
+ }
153
194
  if (selected.length === 0) {
154
- 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
+ }
155
201
  rl.close();
156
202
  return;
157
203
  }
@@ -159,7 +205,6 @@ export function registerInitCommand(program) {
159
205
  // EVM exchanges can share the same key
160
206
  const evmExchanges = selected.filter((e) => EXCHANGE_ENV_MAP[e].chain === "evm");
161
207
  const solExchanges = selected.filter((e) => EXCHANGE_ENV_MAP[e].chain === "solana");
162
- const apikeyExchanges = selected.filter((e) => EXCHANGE_ENV_MAP[e].chain === "apikey");
163
208
  const newEnv = { ...env };
164
209
  if (evmExchanges.length > 0) {
165
210
  console.log(chalk.cyan.bold("\n EVM Private Key") + chalk.gray(` (for ${evmExchanges.join(", ")})`));
@@ -215,34 +260,9 @@ export function registerInitCommand(program) {
215
260
  }
216
261
  }
217
262
  }
218
- if (apikeyExchanges.length > 0) {
219
- for (const ex of apikeyExchanges) {
220
- const info = EXCHANGE_ENV_MAP[ex];
221
- console.log(chalk.cyan.bold(`\n ${info.label} API Credentials`));
222
- const apiKey = await ask(rl, ` ${info.label} API key: `);
223
- if (!apiKey) {
224
- console.log(chalk.gray(" Skipped."));
225
- continue;
226
- }
227
- const { valid, address } = await validateKey("apikey", apiKey);
228
- if (!valid) {
229
- console.log(chalk.red(" Invalid API key (must be at least 16 chars), skipped."));
230
- continue;
231
- }
232
- newEnv[info.envKey] = apiKey;
233
- console.log(` ${chalk.green("OK")} ${chalk.gray(address)}`);
234
- if (info.envKeySecret) {
235
- const secret = await ask(rl, ` ${info.label} API secret: `);
236
- if (secret) {
237
- newEnv[info.envKeySecret] = secret;
238
- console.log(` ${chalk.green("OK")} secret saved`);
239
- }
240
- else {
241
- console.log(chalk.yellow(" No secret provided — some operations may fail."));
242
- }
243
- }
244
- }
245
- }
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`.
246
266
  // Default exchange
247
267
  const settings = loadSettings();
248
268
  if (selected.length === 1) {
@@ -304,3 +324,109 @@ function parseExchangeChoice(input) {
304
324
  }
305
325
  return [...new Set(exchanges)];
306
326
  }
327
+ // ── Non-interactive setup (agent / CI / Docker) ──────────────────────────────
328
+ const VALID_EXCHANGES = ["pacifica", "hyperliquid", "lighter", "aster"];
329
+ /**
330
+ * Test seam: emit JSON envelopes when the global --json flag is present.
331
+ * Mirrors the inline pattern used elsewhere (see src/index.ts:87) instead of
332
+ * wiring isJson() through registerInitCommand for one call site.
333
+ */
334
+ function setupIsJson() {
335
+ return process.argv.includes("--json");
336
+ }
337
+ /**
338
+ * Drive `perp setup` end-to-end without TTY. Required:
339
+ * - --passphrase or OWS_PASSPHRASE env (3-path resolver)
340
+ * Optional:
341
+ * - --wallet-name (default: "main")
342
+ * - --default-exchange (must be one of pacifica/hyperliquid/lighter/aster)
343
+ *
344
+ * Side effects:
345
+ * 1. Creates an OWS wallet via createWallet(name, passphrase)
346
+ * 2. Sets settings.owsActiveWallet (if unset) + settings.defaultExchange (if provided)
347
+ *
348
+ * Errors are returned as INVALID_PARAMS / PASSPHRASE_REQUIRED — never via
349
+ * readline. JSON envelope is emitted on stdout when --json is present.
350
+ */
351
+ export async function runNonInteractiveSetup(opts) {
352
+ const isJson = setupIsJson();
353
+ try {
354
+ // Validate --default-exchange shape early so users hit a precise error
355
+ // before passphrase resolution.
356
+ if (opts.defaultExchange !== undefined &&
357
+ !VALID_EXCHANGES.includes(opts.defaultExchange)) {
358
+ throw new PerpError("INVALID_PARAMS", `Invalid --default-exchange "${opts.defaultExchange}"`, {
359
+ remediation: `Use one of: ${VALID_EXCHANGES.join(", ")}`,
360
+ });
361
+ }
362
+ // Resolve passphrase. resolvePassphrase already throws PASSPHRASE_REQUIRED
363
+ // when stdin is non-TTY and nothing was provided. In TTY mode it returns
364
+ // null — non-interactive mode must NOT fall back to a prompt.
365
+ const resolved = await resolvePassphrase({ flag: opts.passphrase });
366
+ if (resolved === null) {
367
+ throw new PerpError("PASSPHRASE_REQUIRED", "Non-interactive setup requires a passphrase", {
368
+ remediation: "Provide --passphrase <pp> or set OWS_PASSPHRASE env var",
369
+ });
370
+ }
371
+ const pp = resolved;
372
+ const walletName = opts.walletName ?? "main";
373
+ // Create OWS wallet
374
+ const ows = loadOws();
375
+ const w = ows.createWallet(walletName, pp);
376
+ // Update settings
377
+ const settings = loadSettings();
378
+ if (!settings.owsActiveWallet) {
379
+ settings.owsActiveWallet = walletName;
380
+ }
381
+ if (opts.defaultExchange) {
382
+ settings.defaultExchange = opts.defaultExchange;
383
+ }
384
+ saveSettings(settings);
385
+ if (isJson) {
386
+ printJson(jsonOk({
387
+ walletName: w.name,
388
+ walletId: w.id,
389
+ accounts: w.accounts,
390
+ owsActiveWallet: settings.owsActiveWallet,
391
+ defaultExchange: settings.defaultExchange ?? null,
392
+ mode: "non-interactive",
393
+ }));
394
+ return;
395
+ }
396
+ console.log(chalk.cyan.bold("\n perp-cli Setup (non-interactive) — Done\n"));
397
+ console.log(` Wallet: ${chalk.white.bold(w.name)}`);
398
+ console.log(` Active wallet: ${chalk.cyan(settings.owsActiveWallet)}`);
399
+ if (settings.defaultExchange) {
400
+ console.log(` Default exchange: ${chalk.cyan(settings.defaultExchange)}`);
401
+ }
402
+ console.log(chalk.gray(`\n Vault: ~/.ows/ (AES-256-GCM encrypted)\n`));
403
+ }
404
+ catch (err) {
405
+ if (isJson) {
406
+ if (err instanceof PerpError) {
407
+ printJson(jsonError(err.structured.code, err.structured.message, {
408
+ ...(err.structured.remediation
409
+ ? { details: { remediation: err.structured.remediation } }
410
+ : {}),
411
+ }));
412
+ }
413
+ else {
414
+ const msg = err instanceof Error ? err.message : String(err);
415
+ printJson(jsonError("UNKNOWN", msg));
416
+ }
417
+ process.exitCode = 1;
418
+ return;
419
+ }
420
+ if (err instanceof PerpError) {
421
+ console.error(chalk.red(`\n Error [${err.structured.code}]: ${err.structured.message}`));
422
+ if (err.structured.remediation) {
423
+ console.error(chalk.gray(` Remediation: ${err.structured.remediation}\n`));
424
+ }
425
+ }
426
+ else {
427
+ const msg = err instanceof Error ? err.message : String(err);
428
+ console.error(chalk.red(`\n Error: ${msg}\n`));
429
+ }
430
+ process.exitCode = 1;
431
+ }
432
+ }
@@ -324,16 +324,30 @@ export function registerWalletCommands(program, isJson, getAdapter, getPacificaA
324
324
  .description("Generate a new wallet (OWS encrypted vault, multi-chain)")
325
325
  .option("--words <count>", "Mnemonic word count (12 or 24)", "12")
326
326
  .option("--show-mnemonic", "Display the mnemonic phrase (CAUTION)")
327
+ .option("--passphrase <pp>", "Master OWS passphrase (fallback: OWS_PASSPHRASE env / stdin)")
327
328
  .option("--legacy <chain>", "Legacy mode: generate solana or evm key (unencrypted)")
328
- .action(async (name, opts) => {
329
+ .action(async (name, opts, command) => {
329
330
  // Legacy mode for backward compat
330
331
  if (opts.legacy) {
331
332
  return _legacyGenerate(opts.legacy, name, isJson);
332
333
  }
333
334
  const walletName = name || "default";
334
335
  try {
336
+ // Resolve vault passphrase via the standard 3-path resolver (--passphrase
337
+ // > OWS_PASSPHRASE env > stdin pipe). When stdin is a TTY and nothing was
338
+ // provided, the resolver returns null; fall back to "" to preserve the
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();
347
+ const { resolvePassphrase } = await import("../agent-wallet/passphrase.js");
348
+ const pp = (await resolvePassphrase({ flag: merged.passphrase ?? opts.passphrase })) ?? "";
335
349
  const ows = loadOws();
336
- const w = ows.createWallet(walletName, "", parseInt(opts.words));
350
+ const w = ows.createWallet(walletName, pp, parseInt(opts.words));
337
351
  // Set as active wallet
338
352
  const { loadSettings: ls, saveSettings: ss } = await import("../settings.js");
339
353
  const settings = ls();
@@ -393,9 +407,9 @@ export function registerWalletCommands(program, isJson, getAdapter, getPacificaA
393
407
  .option("--evm <hex>", "EVM (secp256k1) private key — use with --solana to import both curves")
394
408
  .option("--solana <key>", "Solana (ed25519) private key — accepts hex (64), base58 (Phantom export), or JSON byte array. Use with --evm to import both curves")
395
409
  .option("--mnemonic", "Import as mnemonic phrase instead of private key")
396
- .option("--passphrase <pp>", "OWS vault encryption passphrase (default: empty / OWS_PASSPHRASE env)")
410
+ .option("--passphrase <pp>", "Master OWS passphrase (fallback: OWS_PASSPHRASE env / stdin)")
397
411
  .option("--legacy <chain>", "Legacy mode: import to wallets.json (solana or evm)")
398
- .action(async (privateKey, opts) => {
412
+ .action(async (privateKey, opts, command) => {
399
413
  // Legacy mode (positional only)
400
414
  if (opts.legacy) {
401
415
  if (!privateKey) {
@@ -475,9 +489,17 @@ export function registerWalletCommands(program, isJson, getAdapter, getPacificaA
475
489
  throw new Error(`Invalid Solana key — expected hex (64 chars), base58 (Phantom export), or JSON byte array. Inner: ${msg}`);
476
490
  }
477
491
  };
478
- // Resolve vault passphrase (precedence: --passphrase > OWS_PASSPHRASE env > "")
479
- const pp = opts.passphrase
480
- ?? (("OWS_PASSPHRASE" in process.env) ? (process.env["OWS_PASSPHRASE"] ?? "") : "");
492
+ // Resolve vault passphrase via the standard 3-path resolver (--passphrase
493
+ // > OWS_PASSPHRASE env > stdin pipe). When stdin is a TTY and nothing was
494
+ // provided, the resolver returns null; fall back to "" to preserve the
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();
501
+ const { resolvePassphrase } = await import("../agent-wallet/passphrase.js");
502
+ const pp = (await resolvePassphrase({ flag: merged.passphrase ?? opts.passphrase })) ?? "";
481
503
  let w;
482
504
  if (opts.mnemonic) {
483
505
  w = ows.importWalletMnemonic(opts.name, privateKey, pp);
@@ -588,17 +610,32 @@ export function registerWalletCommands(program, isJson, getAdapter, getPacificaA
588
610
  .description("Set private key for an exchange")
589
611
  .option("--default", "Also set as default exchange")
590
612
  .action(async (exchange, key, opts) => {
591
- // Resolve alias (hl → hyperliquid, pac → pacifica, lt → lighter)
592
- 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" };
593
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
+ }
594
631
  const info = EXCHANGE_ENV_MAP[resolved];
595
632
  if (!info) {
596
633
  if (isJson()) {
597
634
  const { jsonError } = await import("../utils.js");
598
- 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'.`));
599
636
  }
600
637
  console.error(chalk.red(`\n Unknown exchange: ${exchange}`));
601
- 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`));
602
639
  process.exit(1);
603
640
  }
604
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.2",
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