@agentgrant.cash/cli 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/.env.example +21 -0
  2. package/README.md +48 -0
  3. package/dist/cli/commands/agent.js +139 -0
  4. package/dist/cli/commands/auth.js +248 -0
  5. package/dist/cli/commands/meta.js +77 -0
  6. package/dist/cli/commands/money.js +85 -0
  7. package/dist/cli/commands/portfolio.js +224 -0
  8. package/dist/cli/index.js +94 -0
  9. package/dist/cli/money-helpers.js +189 -0
  10. package/dist/cli/perfolio-commands/account.js +272 -0
  11. package/dist/cli/perfolio-commands/borrow.js +75 -0
  12. package/dist/cli/perfolio-commands/discover.js +30 -0
  13. package/dist/cli/perfolio-commands/earn.js +193 -0
  14. package/dist/cli/perfolio-commands/hyperliquid.js +408 -0
  15. package/dist/cli/perfolio-commands/loans.js +34 -0
  16. package/dist/cli/perfolio-commands/market.js +76 -0
  17. package/dist/cli/perfolio-commands/polymarket.js +304 -0
  18. package/dist/cli/perfolio-commands/session.js +19 -0
  19. package/dist/cli/perfolio-commands/trade.js +94 -0
  20. package/dist/cli/perfolio-commands/tx.js +22 -0
  21. package/dist/lib/agent-client.js +166 -0
  22. package/dist/lib/agent-device.js +173 -0
  23. package/dist/lib/amounts.js +45 -0
  24. package/dist/lib/assets.js +47 -0
  25. package/dist/lib/client.js +284 -0
  26. package/dist/lib/config.js +46 -0
  27. package/dist/lib/context.js +35 -0
  28. package/dist/lib/currency.js +91 -0
  29. package/dist/lib/device.js +163 -0
  30. package/dist/lib/errors.js +59 -0
  31. package/dist/lib/format.js +22 -0
  32. package/dist/lib/index.js +24 -0
  33. package/dist/lib/kyc-status.js +28 -0
  34. package/dist/lib/money-client.js +157 -0
  35. package/dist/lib/money-input.js +176 -0
  36. package/dist/lib/output.js +45 -0
  37. package/dist/lib/polygon-balance.js +125 -0
  38. package/dist/lib/portfolio-format.js +224 -0
  39. package/dist/lib/relay.js +19 -0
  40. package/dist/lib/sign.js +29 -0
  41. package/dist/lib/tx-wait.js +35 -0
  42. package/dist/lib/types.js +10 -0
  43. package/dist/lib/verify.js +38 -0
  44. package/package.json +37 -0
  45. package/skills/grant-cash/SKILL.md +152 -0
@@ -0,0 +1,224 @@
1
+ import { buildContext, emit, ui, formatPortfolioView, computeNetWorthUsd, formatMoney, formatAgentFunds, resolveDisplayPrefs, displayDescriptor, fetchPusdBalanceUsd, DEFAULT_PREFS, } from "../../lib/index.js";
2
+ /**
3
+ * Combined worth in one place: INVESTMENTS (money side — gold, cash, bitcoin,
4
+ * ethereum, perps, borrowed PLUS the prediction balance + open bets and earn/
5
+ * staked positions the backend summary can't see) and SPENDING (agent side —
6
+ * live-service funds + their status). Each side is fetched independently: if one
7
+ * isn't connected or briefly fails, the other still shows.
8
+ */
9
+ /** The earn vaults to probe for staked value, with their display labels. */
10
+ const EARN_PROBE = [
11
+ { symbol: "USDT", label: "Cash", priced: "stable" },
12
+ { symbol: "USDC", label: "USDC", priced: "stable" },
13
+ { symbol: "WETH", label: "Ethereum", priced: "eth" },
14
+ ];
15
+ // Independent deadlines per pool so a slow probe can't stall the whole view.
16
+ // Predictions gets more headroom: in the fallback path it does a positions call
17
+ // plus a deposit-wallet lookup plus an on-chain pUSD read against public RPCs,
18
+ // and trimming it too early was hiding deposited-but-unbet balances entirely.
19
+ const PREDICTION_DEADLINE_MS = 9_000;
20
+ const EARN_DEADLINE_MS = 5_000;
21
+ function settledValue(r) {
22
+ return r.status === "fulfilled" ? r.value : null;
23
+ }
24
+ /** Resolve `p`, but give up with `fallback` after `ms` so a slow probe can't stall the view. */
25
+ function withDeadline(p, ms, fallback) {
26
+ return Promise.race([p, new Promise((resolve) => setTimeout(() => resolve(fallback), ms))]);
27
+ }
28
+ /** Build display prefs from the user's profile + FX rate. USD defaults on any failure. */
29
+ async function loadDisplayPrefs(ctx) {
30
+ try {
31
+ const user = (await ctx.money.getUser());
32
+ const currency = typeof user?.currency === "string" && user.currency.trim()
33
+ ? user.currency.trim().toUpperCase()
34
+ : "USD";
35
+ let rate = 1;
36
+ if (currency !== "USD") {
37
+ try {
38
+ const fx = await ctx.money.getFiatRate(currency);
39
+ if (fx && typeof fx.price === "number" && fx.price > 0)
40
+ rate = fx.price;
41
+ }
42
+ catch {
43
+ /* FX unavailable → render USD */
44
+ }
45
+ }
46
+ return resolveDisplayPrefs(user, rate);
47
+ }
48
+ catch {
49
+ return DEFAULT_PREFS;
50
+ }
51
+ }
52
+ /** Live ETH/USD price for valuing a WETH earn position. null when unavailable. */
53
+ async function ethUsdPrice(ctx) {
54
+ try {
55
+ const arr = (await ctx.money.getCryptoPrices());
56
+ const found = Array.isArray(arr) ? arr.find((e) => e?.currency === "ETH") : undefined;
57
+ return typeof found?.price === "number" && found.price > 0 ? found.price : null;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ /**
64
+ * Prediction available cash + open-bet value. Best-effort; {} on any failure.
65
+ *
66
+ * Available cash comes from the SAME source the predictions screen uses: the
67
+ * backend `/polymarket/positions` `cashUsd` field (an on-chain pUSD read done
68
+ * server-side via a reliable RPC). For older backends that don't return it we
69
+ * fall back to reading the deposit wallet's pUSD balance directly from a public
70
+ * Polygon RPC. Both the positions call and the deposit-wallet lookup run in
71
+ * parallel so the fallback adds no latency in the common case.
72
+ */
73
+ export async function gatherPredictions(ctx) {
74
+ const [positionsR, walletR] = await Promise.allSettled([
75
+ ctx.money.polymarketPositions(),
76
+ ctx.money.polymarketDepositWallet(),
77
+ ]);
78
+ const extras = {};
79
+ const positions = settledValue(positionsR);
80
+ // Open-bet value (only when there are live positions).
81
+ if (positions && positions.valueUsd > 0) {
82
+ extras.predictionPositionsUsd = positions.valueUsd;
83
+ extras.predictionPnlUsd = positions.pnlUsd;
84
+ }
85
+ // Available-to-bet cash: prefer the backend's server-side value (matches the
86
+ // predictions screen), else read the deposit wallet's pUSD balance directly.
87
+ if (positions && typeof positions.cashUsd === "number") {
88
+ extras.predictionAvailableUsd = positions.cashUsd;
89
+ }
90
+ else {
91
+ const wallet = settledValue(walletR);
92
+ if (wallet?.address) {
93
+ const bal = await fetchPusdBalanceUsd(wallet.address);
94
+ if (typeof bal === "number")
95
+ extras.predictionAvailableUsd = bal;
96
+ }
97
+ }
98
+ return extras;
99
+ }
100
+ /** Earn (staked) positions across the vaults, valued in USD. Best-effort; [] on any failure. */
101
+ async function gatherEarn(ctx) {
102
+ const [ethPriceR, ...earnRs] = await Promise.allSettled([
103
+ ethUsdPrice(ctx),
104
+ ...EARN_PROBE.map((p) => ctx.money.getEarnPosition(p.symbol)),
105
+ ]);
106
+ const ethPrice = settledValue(ethPriceR);
107
+ const earn = [];
108
+ earnRs.forEach((r, i) => {
109
+ const p = settledValue(r);
110
+ if (!p)
111
+ return;
112
+ const probe = EARN_PROBE[i];
113
+ const amount = Number(p.assetsFormatted);
114
+ if (!Number.isFinite(amount) || amount <= 0)
115
+ return;
116
+ const price = probe.priced === "eth" ? (typeof ethPrice === "number" ? ethPrice : null) : 1;
117
+ if (price == null || price <= 0)
118
+ return; // can't value WETH without a price → skip
119
+ const earnedTok = p.earnedFormatted != null ? Number(p.earnedFormatted) : null;
120
+ earn.push({
121
+ label: probe.label,
122
+ valueUsd: amount * price,
123
+ earnedUsd: earnedTok != null && Number.isFinite(earnedTok) ? earnedTok * price : null,
124
+ apy: typeof p.netApy === "number" ? p.netApy : null,
125
+ });
126
+ });
127
+ return earn;
128
+ }
129
+ /**
130
+ * Gather the money pools the backend summary can't see — the on-chain prediction
131
+ * balance + open bets, and earn (staked) positions. Both run concurrently under
132
+ * independent deadlines: every probe is best-effort, a failure or slow backend
133
+ * silently omits that line, and neither can stall the command.
134
+ */
135
+ async function gatherExtras(ctx) {
136
+ const [pred, earn] = await Promise.all([
137
+ withDeadline(gatherPredictions(ctx), PREDICTION_DEADLINE_MS, {}),
138
+ withDeadline(gatherEarn(ctx), EARN_DEADLINE_MS, []),
139
+ ]);
140
+ return earn.length ? { ...pred, earn } : pred;
141
+ }
142
+ async function gather(cmd) {
143
+ const ctx = buildContext(cmd);
144
+ const [prefsR, moneyR, extrasR, agentR] = await Promise.allSettled([
145
+ ctx.money.connected ? loadDisplayPrefs(ctx) : Promise.resolve(DEFAULT_PREFS),
146
+ ctx.money.connected
147
+ ? ctx.money.getPortfolio("full")
148
+ : Promise.resolve(null),
149
+ ctx.money.connected ? gatherExtras(ctx) : Promise.resolve({}),
150
+ ctx.agent.connected
151
+ ? ctx.agent.balance()
152
+ : Promise.resolve(null),
153
+ ]);
154
+ return {
155
+ ctx,
156
+ prefs: prefsR.status === "fulfilled" ? prefsR.value : DEFAULT_PREFS,
157
+ money: moneyR.status === "fulfilled" ? moneyR.value : null,
158
+ extras: extrasR.status === "fulfilled" ? extrasR.value : {},
159
+ moneyFailed: moneyR.status === "rejected",
160
+ agent: agentR.status === "fulfilled" ? agentR.value : null,
161
+ agentFailed: agentR.status === "rejected",
162
+ };
163
+ }
164
+ /** Indent every line of a block by two spaces. */
165
+ function indent(block) {
166
+ return block
167
+ .split("\n")
168
+ .map((l) => (l ? ` ${l}` : l))
169
+ .join("\n");
170
+ }
171
+ export function registerPortfolio(program) {
172
+ const run = async (cmd) => {
173
+ const g = await gather(cmd);
174
+ const { ctx, prefs, money, extras, moneyFailed, agent, agentFailed } = g;
175
+ emit(ctx, {
176
+ investments: money
177
+ ? {
178
+ ...money,
179
+ // One canonical net worth across ALL pools (holdings + perps + earn +
180
+ // predictions − debt). The backend sub-totals exclude predictions+earn;
181
+ // this is the honest figure programmatic consumers should read.
182
+ netWorthUsd: computeNetWorthUsd(money, extras),
183
+ netWorthDisplay: formatMoney(computeNetWorthUsd(money, extras), prefs),
184
+ extras,
185
+ display: displayDescriptor(prefs),
186
+ }
187
+ : null,
188
+ spending: agent,
189
+ }, () => {
190
+ if (!ctx.money.connected && !ctx.agent.connected)
191
+ return "Not connected yet. Run `grant login`.";
192
+ const lines = [ui.title("Your Grant Cash"), ""];
193
+ // ── Investments (money side) ──
194
+ lines.push(ui.title("Investments"));
195
+ if (!ctx.money.connected) {
196
+ lines.push(ui.dim(" Not connected."));
197
+ }
198
+ else if (money) {
199
+ lines.push(indent(formatPortfolioView(money, prefs, extras)));
200
+ }
201
+ else {
202
+ lines.push(ui.amber(` ${moneyFailed ? "Temporarily unavailable." : "—"}`));
203
+ }
204
+ lines.push("");
205
+ // ── Spending (agent side) ──
206
+ lines.push(ui.title("Spending") + ui.dim(" — live pay-per-use services"));
207
+ if (!ctx.agent.connected) {
208
+ lines.push(ui.dim(" Not connected."));
209
+ }
210
+ else if (agent) {
211
+ lines.push(formatAgentFunds(agent));
212
+ }
213
+ else {
214
+ lines.push(ui.amber(` ${agentFailed ? "Temporarily unavailable." : "—"}`));
215
+ }
216
+ return lines.join("\n");
217
+ });
218
+ };
219
+ program
220
+ .command("portfolio")
221
+ .alias("balance")
222
+ .description("Your combined worth — investments (gold, cash, bitcoin, ethereum, perps, predictions, earn) and agent spending money")
223
+ .action((_opts, cmd) => run(cmd));
224
+ }
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { Command } from "commander";
5
+ import { isJsonMode, printError } from "../lib/index.js";
6
+ /**
7
+ * Load `.env` from the PACKAGE ROOT only (never the cwd), so an installed CLI
8
+ * can't pick up a stray `.env` in whatever folder it's run from and silently
9
+ * repoint at the wrong backend. Already-exported shell vars win.
10
+ */
11
+ function loadDotEnv(path) {
12
+ let raw;
13
+ try {
14
+ raw = readFileSync(path, "utf8");
15
+ }
16
+ catch {
17
+ return;
18
+ }
19
+ for (const line of raw.split("\n")) {
20
+ const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
21
+ if (!m)
22
+ continue;
23
+ const key = m[1];
24
+ if (process.env[key] !== undefined)
25
+ continue;
26
+ let val = m[2].trim();
27
+ if ((val.startsWith('"') && val.endsWith('"')) ||
28
+ (val.startsWith("'") && val.endsWith("'"))) {
29
+ // Quoted value: keep everything inside the quotes verbatim (a `#` here is data).
30
+ val = val.slice(1, -1);
31
+ }
32
+ else {
33
+ // Unquoted value: strip a trailing inline comment (whitespace + `#…`), the
34
+ // dotenv convention. Prevents `KEY=http://x # note` from capturing the note.
35
+ val = val.replace(/\s+#.*$/, "").trim();
36
+ }
37
+ process.env[key] = val;
38
+ }
39
+ }
40
+ loadDotEnv(fileURLToPath(new URL("../../.env", import.meta.url)));
41
+ import { registerAuth } from "./commands/auth.js";
42
+ import { registerPortfolio } from "./commands/portfolio.js";
43
+ import { registerMoney } from "./commands/money.js";
44
+ import { registerAgent } from "./commands/agent.js";
45
+ import { registerMeta } from "./commands/meta.js";
46
+ // ── full investing surface ported from the standalone Perfolio CLI ──
47
+ import { registerTrade } from "./perfolio-commands/trade.js";
48
+ import { registerMarket } from "./perfolio-commands/market.js";
49
+ import { registerDiscover } from "./perfolio-commands/discover.js";
50
+ import { registerBorrow } from "./perfolio-commands/borrow.js";
51
+ import { registerEarn } from "./perfolio-commands/earn.js";
52
+ import { registerHyperliquid } from "./perfolio-commands/hyperliquid.js";
53
+ import { registerPolymarket } from "./perfolio-commands/polymarket.js";
54
+ import { registerAccount } from "./perfolio-commands/account.js";
55
+ import { registerTx } from "./perfolio-commands/tx.js";
56
+ import { registerSession } from "./perfolio-commands/session.js";
57
+ import { registerLoans } from "./perfolio-commands/loans.js";
58
+ let version = "0.0.0";
59
+ try {
60
+ version =
61
+ JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version ??
62
+ version;
63
+ }
64
+ catch {
65
+ /* fall back */
66
+ }
67
+ const program = new Command();
68
+ program
69
+ .name("grant")
70
+ .description("Grant Cash — one CLI for your money (gold, cash, bitcoin, ethereum, perps, prediction markets, borrow, earn) and your agent (pay-per-use services).")
71
+ .version(version)
72
+ .option("--json", "machine-readable JSON output")
73
+ .option("--creds-file <path>", "override ~/.grant-cash/credentials.json");
74
+ registerAuth(program);
75
+ registerPortfolio(program);
76
+ registerMoney(program);
77
+ registerAgent(program);
78
+ // ── investing surface (gold, cash, bitcoin, ethereum, perps, predictions, …) ──
79
+ registerTrade(program);
80
+ registerMarket(program);
81
+ registerDiscover(program);
82
+ registerBorrow(program);
83
+ registerEarn(program);
84
+ registerHyperliquid(program);
85
+ registerPolymarket(program);
86
+ registerLoans(program);
87
+ registerAccount(program);
88
+ registerTx(program);
89
+ registerSession(program);
90
+ registerMeta(program);
91
+ program.parseAsync(process.argv).catch((err) => {
92
+ const json = isJsonMode(Boolean(program.opts().json));
93
+ process.exit(printError(json, err));
94
+ });
@@ -0,0 +1,189 @@
1
+ import pc from "picocolors";
2
+ import { PerfolioClient, AssetResolver, baseUrls, resolveCredsPath, loadCredentials, saveCredentials, openBrowser, waitForSign, resolveDisplayPrefs, DEFAULT_PREFS, resolveCashToUsd, resolveCashTargetToAssetQty, } from "../lib/index.js";
3
+ import { waitForTx } from "../lib/verify.js";
4
+ /** PerfolioClient needs a `widget` base; derive it from the API origin if unset. */
5
+ function widgetUrl(api) {
6
+ return process.env.GRANTCASH_WIDGET_URL || api.replace(/\/api\/?$/, "/widget");
7
+ }
8
+ export function buildCtx(cmd) {
9
+ const opts = cmd.optsWithGlobals();
10
+ const credsPath = resolveCredsPath(opts.credsFile);
11
+ const creds = loadCredentials(credsPath);
12
+ const urls = baseUrls();
13
+ const client = new PerfolioClient({
14
+ urls: { api: urls.api, fiat: urls.fiat, app: urls.app, widget: widgetUrl(urls.api) },
15
+ token: creds.money?.token,
16
+ refreshToken: creds.money?.refreshToken,
17
+ // Persist rotated tokens into the `money` slot so a silent refresh survives
18
+ // across invocations without disturbing the agent credential.
19
+ onRefresh: (token, refreshToken, expiresAt) => saveCredentials({ ...creds, money: { ...(creds.money ?? {}), token, refreshToken, expiresAt } }, credsPath),
20
+ });
21
+ return { client, creds, credsPath, json: Boolean(opts.json) };
22
+ }
23
+ export function requireAuth(ctx) {
24
+ if (!ctx.creds.money?.token) {
25
+ fail("Not connected. Run `grant login` first.", ctx.json);
26
+ }
27
+ return ctx.creds;
28
+ }
29
+ /** Load assets once and build a resolver. */
30
+ export async function resolver(ctx) {
31
+ const assets = await ctx.client.getAssets();
32
+ return new AssetResolver(assets);
33
+ }
34
+ /**
35
+ * Resolve a user-entered CASH amount into a USD decimal string for the backend.
36
+ * A bare number is interpreted in the user's display currency; an explicit
37
+ * currency prefix ("$10", "₹50", "100 EUR") always wins. Non-USD amounts are
38
+ * converted via the live FX rate; refuses (throws) if that rate is unavailable.
39
+ */
40
+ /**
41
+ * Force a bare numeric amount to USD by prefixing `$`. An amount the user already
42
+ * qualified with a currency ($/₹/€/ISO code) is left untouched — an explicit
43
+ * currency always wins (same rule money-input enforces). So `--usd` + "50" → "$50",
44
+ * but `--usd` + "₹50" stays ₹50.
45
+ */
46
+ export function forceUsdAmount(raw) {
47
+ const s = String(raw).trim();
48
+ return /^\d[\d,]*(\.\d+)?$/.test(s) ? `$${s}` : s;
49
+ }
50
+ export async function resolveCash(ctx, raw, decimals, opts) {
51
+ const prefs = await loadDisplayPrefs(ctx);
52
+ const amount = opts?.usd ? forceUsdAmount(raw) : raw;
53
+ return resolveCashToUsd(amount, prefs, decimals, async (ccy) => {
54
+ const fx = await ctx.client.getFiatRate(ccy);
55
+ return fx.price;
56
+ });
57
+ }
58
+ /**
59
+ * Live USD unit price for a spot asset, used to size a cash-target sell:
60
+ * gold → gold spot; btc/eth → /prices/crypto; stable → 1:1. Throws if the
61
+ * price can't be resolved (caller refuses to guess a quantity).
62
+ */
63
+ export async function assetUsdPrice(ctx, asset) {
64
+ if (asset.class === "stable")
65
+ return 1;
66
+ if (asset.class === "gold") {
67
+ const g = (await ctx.client.getGoldPrice());
68
+ const p = typeof g?.price === "number" ? g.price : NaN;
69
+ if (!(p > 0))
70
+ throw new Error("Could not get the live gold price to size this sale.");
71
+ return p;
72
+ }
73
+ if (asset.class === "btc" || asset.class === "eth") {
74
+ const wanted = asset.class === "btc" ? "BTC" : "ETH";
75
+ const arr = (await ctx.client.getCryptoPrices());
76
+ const found = Array.isArray(arr) ? arr.find((e) => e?.currency === wanted) : undefined;
77
+ const p = typeof found?.price === "number" ? found.price : NaN;
78
+ if (!(p > 0))
79
+ throw new Error(`Could not get the live ${asset.friendly} price to size this sale.`);
80
+ return p;
81
+ }
82
+ throw new Error(`Don't know how to price "${asset.friendly}" for a cash-target sale.`);
83
+ }
84
+ /**
85
+ * Resolve a cash-denominated SELL/CONVERT target ("sell ₹100 of gold") into a
86
+ * quantity of the asset to sell. Refuses (throws) if either the FX rate or the
87
+ * asset price is unavailable — never guesses a money quantity.
88
+ */
89
+ export async function resolveCashTargetQty(ctx, asset, cashRaw) {
90
+ const [prefs, unitPrice] = await Promise.all([loadDisplayPrefs(ctx), assetUsdPrice(ctx, asset)]);
91
+ return resolveCashTargetToAssetQty(cashRaw, prefs, asset.decimals, async (ccy) => {
92
+ const fx = await ctx.client.getFiatRate(ccy);
93
+ return fx.price;
94
+ }, unitPrice);
95
+ }
96
+ /**
97
+ * Resolve the user's money-display preferences (currency, USD-vs-local, gold
98
+ * unit, experience mode) plus the live FX rate. Any failure falls back to safe
99
+ * USD defaults rather than breaking a read command.
100
+ */
101
+ export async function loadDisplayPrefs(ctx) {
102
+ try {
103
+ const user = await ctx.client.getUser();
104
+ const currency = typeof user?.currency === "string" && user.currency.trim()
105
+ ? user.currency.trim().toUpperCase()
106
+ : "USD";
107
+ let rate = 1;
108
+ if (currency !== "USD") {
109
+ try {
110
+ const fx = await ctx.client.getFiatRate(currency);
111
+ if (fx && typeof fx.price === "number" && fx.price > 0)
112
+ rate = fx.price;
113
+ }
114
+ catch {
115
+ /* FX unavailable → render USD */
116
+ }
117
+ }
118
+ return resolveDisplayPrefs(user, rate);
119
+ }
120
+ catch {
121
+ return DEFAULT_PREFS;
122
+ }
123
+ }
124
+ export function out(ctx, human, data) {
125
+ if (ctx.json)
126
+ console.log(JSON.stringify(data, null, 2));
127
+ else
128
+ console.log(human);
129
+ }
130
+ export function fail(msg, json) {
131
+ if (json)
132
+ console.error(JSON.stringify({ success: false, error: msg }));
133
+ else
134
+ console.error(pc.red(`✖ ${msg}`));
135
+ process.exit(1);
136
+ }
137
+ /**
138
+ * Report the outcome of a write. With `wait` (the default), poll the tx to a
139
+ * terminal state and report success/failure honestly — never "submitted". A
140
+ * failed tx exits non-zero; a timeout is reported as still-pending.
141
+ */
142
+ export async function reportTx(ctx, res, wait, label) {
143
+ const txHash = res?.txHash;
144
+ const tail = txHash ? ` (tx ${txHash})` : "";
145
+ if (!wait) {
146
+ out(ctx, `Submitted: ${label}${tail}.\nNot yet confirmed — check with \`grant tx status${txHash ? ` ${txHash}` : ""}\`.`, { submitted: true, confirmed: false, status: res?.status ?? "submitted", txHash });
147
+ return;
148
+ }
149
+ const r = await waitForTx(ctx.client, txHash);
150
+ if (r.status === "success") {
151
+ out(ctx, `✓ ${label} confirmed${tail}.`, { success: true, confirmed: true, status: "success", txHash });
152
+ return;
153
+ }
154
+ if (r.status === "failed") {
155
+ fail(`${label} failed on-chain${tail}.`, ctx.json);
156
+ }
157
+ out(ctx, `${label}: still pending after waiting${tail}. Re-check with \`grant tx status${txHash ? ` ${txHash}` : ""}\`.`, { submitted: true, confirmed: false, status: r.status, txHash });
158
+ }
159
+ /**
160
+ * Browser-sign hand-off for user-signed operations (withdrawal, gift, HL agent
161
+ * approval). The CLI prepared a self-describing sign task; here we hand it to
162
+ * the relay, open the browser for the user to sign + execute, then (for on-chain
163
+ * ops) confirm the resulting tx. The agent never holds a key — the user's Privy
164
+ * wallet signs.
165
+ */
166
+ export async function runSignFlow(ctx, opts) {
167
+ const start = await ctx.client.signStart(opts.task);
168
+ openBrowser(start.approveUrl);
169
+ if (ctx.json) {
170
+ out(ctx, "", { status: "awaiting_browser", approveUrl: start.approveUrl });
171
+ }
172
+ else {
173
+ console.log(`\nApprove this ${opts.label} in your browser (sign with your wallet):\n\n ${start.approveUrl}\n\nWaiting for you to approve…`);
174
+ }
175
+ const r = await waitForSign(baseUrls().api, start.sid, start.pollInterval, start.expiresIn * 1000);
176
+ if (r.status !== "complete") {
177
+ fail(r.status === "denied" ? `${opts.label} was declined in the browser.` : `${opts.label} timed out. Try again.`, ctx.json);
178
+ return false;
179
+ }
180
+ if (opts.confirmTx === false) {
181
+ out(ctx, `✓ ${opts.label} approved.`, { success: true, ...r.result });
182
+ return true;
183
+ }
184
+ const tail = r.result.txHash ? ` (tx ${r.result.txHash})` : "";
185
+ if (!ctx.json)
186
+ console.log(`\nSigned ✓${tail}. Confirming on-chain…`);
187
+ await reportTx(ctx, { txHash: r.result.txHash, status: r.result.status }, true, opts.label);
188
+ return true;
189
+ }