@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.
- package/.env.example +21 -0
- package/README.md +48 -0
- package/dist/cli/commands/agent.js +139 -0
- package/dist/cli/commands/auth.js +248 -0
- package/dist/cli/commands/meta.js +77 -0
- package/dist/cli/commands/money.js +85 -0
- package/dist/cli/commands/portfolio.js +224 -0
- package/dist/cli/index.js +94 -0
- package/dist/cli/money-helpers.js +189 -0
- package/dist/cli/perfolio-commands/account.js +272 -0
- package/dist/cli/perfolio-commands/borrow.js +75 -0
- package/dist/cli/perfolio-commands/discover.js +30 -0
- package/dist/cli/perfolio-commands/earn.js +193 -0
- package/dist/cli/perfolio-commands/hyperliquid.js +408 -0
- package/dist/cli/perfolio-commands/loans.js +34 -0
- package/dist/cli/perfolio-commands/market.js +76 -0
- package/dist/cli/perfolio-commands/polymarket.js +304 -0
- package/dist/cli/perfolio-commands/session.js +19 -0
- package/dist/cli/perfolio-commands/trade.js +94 -0
- package/dist/cli/perfolio-commands/tx.js +22 -0
- package/dist/lib/agent-client.js +166 -0
- package/dist/lib/agent-device.js +173 -0
- package/dist/lib/amounts.js +45 -0
- package/dist/lib/assets.js +47 -0
- package/dist/lib/client.js +284 -0
- package/dist/lib/config.js +46 -0
- package/dist/lib/context.js +35 -0
- package/dist/lib/currency.js +91 -0
- package/dist/lib/device.js +163 -0
- package/dist/lib/errors.js +59 -0
- package/dist/lib/format.js +22 -0
- package/dist/lib/index.js +24 -0
- package/dist/lib/kyc-status.js +28 -0
- package/dist/lib/money-client.js +157 -0
- package/dist/lib/money-input.js +176 -0
- package/dist/lib/output.js +45 -0
- package/dist/lib/polygon-balance.js +125 -0
- package/dist/lib/portfolio-format.js +224 -0
- package/dist/lib/relay.js +19 -0
- package/dist/lib/sign.js +29 -0
- package/dist/lib/tx-wait.js +35 -0
- package/dist/lib/types.js +10 -0
- package/dist/lib/verify.js +38 -0
- package/package.json +37 -0
- package/skills/grant-cash/SKILL.md +152 -0
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { buildCtx, requireAuth, out, fail, resolveCash, loadDisplayPrefs } from '../money-helpers.js';
|
|
2
|
+
import { describeCashResolution, formatMoney, displayDescriptor, fetchPusdBalanceUsd, DEFAULT_PREFS, } from '../../lib/index.js';
|
|
3
|
+
/** pUSD (the prediction balance) is a USD-pegged stablecoin → 6 decimals on the wire. */
|
|
4
|
+
const PREDICTION_DECIMALS = 6;
|
|
5
|
+
/** Friendly message shown when the betting path isn't enabled yet (503). */
|
|
6
|
+
const BETTING_DISABLED_MSG = 'Prediction betting isn\'t available yet — market browsing is read-only for now.';
|
|
7
|
+
/** True if an error is the backend "trading not configured" 503. Exported for unit testing. */
|
|
8
|
+
export function isTradingDisabled(err) {
|
|
9
|
+
const e = err;
|
|
10
|
+
return e?.status === 503 || e?.code === 'POLYMARKET_TRADING_NOT_CONFIGURED';
|
|
11
|
+
}
|
|
12
|
+
/** Render a market list into human-readable lines. Exported for unit testing. */
|
|
13
|
+
export function formatMarkets(markets, count) {
|
|
14
|
+
if (!markets.length)
|
|
15
|
+
return 'No markets found.';
|
|
16
|
+
const lines = markets.map((m) => {
|
|
17
|
+
const top = [...m.outcomes].sort((a, b) => b.price - a.price)[0];
|
|
18
|
+
const odds = top ? `${top.label} ${Math.round(top.price * 100)}%` : '—';
|
|
19
|
+
const q = m.question.length > 52 ? `${m.question.slice(0, 49)}…` : m.question;
|
|
20
|
+
return ` ${m.id.padEnd(10)} ${q.padEnd(54)} ${odds}`;
|
|
21
|
+
});
|
|
22
|
+
const more = count > markets.length ? `\n …and ${count - markets.length} more (use --search to narrow)` : '';
|
|
23
|
+
return `${markets.length} markets:\n${lines.join('\n')}${more}`;
|
|
24
|
+
}
|
|
25
|
+
/** Signed money in the user's currency, e.g. +₹104 / −$0.40. */
|
|
26
|
+
function signedMoney(n, prefs) {
|
|
27
|
+
const s = n >= 0 ? '+' : '−';
|
|
28
|
+
return `${s}${formatMoney(Math.abs(n), prefs)}`;
|
|
29
|
+
}
|
|
30
|
+
/** Signed percent, e.g. +50% / −5%. */
|
|
31
|
+
function signedPct(n) {
|
|
32
|
+
const s = n >= 0 ? '+' : '−';
|
|
33
|
+
return `${s}${Math.abs(Math.round(n))}%`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Render the prediction portfolio: a per-position table showing where each
|
|
37
|
+
* position has moved (entry → now, %) plus portfolio totals. Money is localized
|
|
38
|
+
* to the user's currency (defaults to USD for callers without prefs). Exported for tests.
|
|
39
|
+
*/
|
|
40
|
+
export function formatPositions(r, prefs = DEFAULT_PREFS) {
|
|
41
|
+
if (!r.positions.length)
|
|
42
|
+
return 'No open prediction positions.';
|
|
43
|
+
const rows = r.positions.map((p) => {
|
|
44
|
+
const q = p.market.length > 40 ? `${p.market.slice(0, 37)}…` : p.market;
|
|
45
|
+
const move = `${p.avgPrice.toFixed(2)}→${p.currentPrice.toFixed(2)}`; // entry → now
|
|
46
|
+
const arrow = p.pnl > 0 ? '▲' : p.pnl < 0 ? '▽' : '–';
|
|
47
|
+
const redeem = p.redeemable ? ' ⟳ redeemable' : '';
|
|
48
|
+
return (` ${q.padEnd(42)} ${p.outcome.padEnd(4)} ${String(p.shares).padStart(7)} sh ` +
|
|
49
|
+
`${move.padEnd(11)} ${signedPct(p.pnlPercent).padStart(5)} ${arrow} ` +
|
|
50
|
+
`${formatMoney(p.value, prefs).padStart(9)}${redeem}`);
|
|
51
|
+
});
|
|
52
|
+
const totals = ` ${'─'.repeat(78)}\n` +
|
|
53
|
+
` invested ${formatMoney(r.investedUsd, prefs)} · value ${formatMoney(r.valueUsd, prefs)} · ` +
|
|
54
|
+
`P&L ${signedMoney(r.pnlUsd, prefs)} (${signedPct(r.pnlPercent)})`;
|
|
55
|
+
return `Your predictions — worth ${formatMoney(r.valueUsd, prefs)}\n${rows.join('\n')}\n${totals}`;
|
|
56
|
+
}
|
|
57
|
+
/** Render prediction trade history (most recent first). Money localized. Exported for tests. */
|
|
58
|
+
export function formatActivity(activity, prefs = DEFAULT_PREFS) {
|
|
59
|
+
if (!activity.length)
|
|
60
|
+
return 'No prediction activity yet.';
|
|
61
|
+
return activity
|
|
62
|
+
.map((a) => {
|
|
63
|
+
const date = a.timestamp ? new Date(a.timestamp * 1000).toISOString().slice(0, 10) : '—';
|
|
64
|
+
const verb = (a.side || a.type).toUpperCase().padEnd(6);
|
|
65
|
+
const q = a.market.length > 38 ? `${a.market.slice(0, 35)}…` : a.market;
|
|
66
|
+
const px = a.price ? ` @ ${a.price.toFixed(2)}` : '';
|
|
67
|
+
const amt = a.amountUsd ? ` ${formatMoney(a.amountUsd, prefs)}` : '';
|
|
68
|
+
return ` ${date} ${verb} ${String(a.shares).padStart(7)} ${a.outcome.padEnd(4)} ${q.padEnd(40)}${px}${amt}`;
|
|
69
|
+
})
|
|
70
|
+
.join('\n');
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Friendly one-liner for a funding-intent status. The backend relays an opaque
|
|
74
|
+
* tracker payload, so we defensively pull a `status` string (top-level or nested
|
|
75
|
+
* under `data`) and map the common terminal states to plain language; anything
|
|
76
|
+
* unrecognized degrades to "still on its way". Exported for tests.
|
|
77
|
+
*/
|
|
78
|
+
export function formatFundStatus(raw) {
|
|
79
|
+
const o = (raw && typeof raw === 'object' ? raw : {});
|
|
80
|
+
const d = o.data;
|
|
81
|
+
// status can live at o.status, o.data.status, or as a bare string in o.data.
|
|
82
|
+
const statusRaw = (d && typeof d === 'object' ? d.status : undefined) ??
|
|
83
|
+
(typeof d === 'string' ? d : undefined) ??
|
|
84
|
+
o.status ??
|
|
85
|
+
'pending';
|
|
86
|
+
const status = String(statusRaw).toLowerCase();
|
|
87
|
+
if (/(success|filled|complete|completed|done)/.test(status)) {
|
|
88
|
+
return '✓ Funds have landed — your prediction balance is ready to bet.';
|
|
89
|
+
}
|
|
90
|
+
if (/(fail|refund|expired|cancel)/.test(status)) {
|
|
91
|
+
return `This funding didn’t go through (${status}). Your cash is safe — run "polymarket deposit <amount>" again.`;
|
|
92
|
+
}
|
|
93
|
+
return 'Still on its way — funds usually land within 1–2 minutes. Check again shortly.';
|
|
94
|
+
}
|
|
95
|
+
export function registerPolymarket(program) {
|
|
96
|
+
const pm = program
|
|
97
|
+
.command('polymarket')
|
|
98
|
+
.description('Prediction markets — bet on real-world events (politics, sports, crypto, economics)');
|
|
99
|
+
pm.command('markets')
|
|
100
|
+
.description('Browse or search prediction markets')
|
|
101
|
+
.option('--search <q>', 'filter markets by keyword (e.g. bitcoin, election, fed)')
|
|
102
|
+
.option('--limit <n>', 'max markets to show (default 20)', (v) => parseInt(v, 10))
|
|
103
|
+
.action(async (opts, cmd) => {
|
|
104
|
+
const ctx = buildCtx(cmd);
|
|
105
|
+
const data = await ctx.client.polymarketMarkets(opts.search, opts.limit);
|
|
106
|
+
if (ctx.json) {
|
|
107
|
+
out(ctx, '', data);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
out(ctx, formatMarkets(data.markets, data.count), data);
|
|
111
|
+
});
|
|
112
|
+
pm.command('positions')
|
|
113
|
+
.description('Your open prediction positions + portfolio totals')
|
|
114
|
+
.action(async (_o, cmd) => {
|
|
115
|
+
const ctx = buildCtx(cmd);
|
|
116
|
+
requireAuth(ctx);
|
|
117
|
+
const data = await ctx.client.polymarketPositions();
|
|
118
|
+
if (ctx.json) {
|
|
119
|
+
out(ctx, '', data);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const prefs = await loadDisplayPrefs(ctx);
|
|
123
|
+
out(ctx, formatPositions(data, prefs), data);
|
|
124
|
+
});
|
|
125
|
+
pm.command('history')
|
|
126
|
+
.description('Your prediction trade history (bets, sells, redeems)')
|
|
127
|
+
.option('--limit <n>', 'max events to show (default 25)', (v) => parseInt(v, 10))
|
|
128
|
+
.action(async (opts, cmd) => {
|
|
129
|
+
const ctx = buildCtx(cmd);
|
|
130
|
+
requireAuth(ctx);
|
|
131
|
+
const data = await ctx.client.polymarketActivity(opts.limit ?? 25);
|
|
132
|
+
if (ctx.json) {
|
|
133
|
+
out(ctx, '', data);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const prefs = await loadDisplayPrefs(ctx);
|
|
137
|
+
out(ctx, formatActivity(data.activity, prefs), data);
|
|
138
|
+
});
|
|
139
|
+
pm.command('bet')
|
|
140
|
+
.description('Bet on a prediction market (buy outcome shares)')
|
|
141
|
+
.argument('<marketId>', 'market id (from `polymarket markets`)')
|
|
142
|
+
.requiredOption('--side <yes|no>', 'which outcome to back')
|
|
143
|
+
.requiredOption('--amount <cash>', 'amount to bet (your currency; prefix $/₹ to force one, or pass --usd)', (v) => v)
|
|
144
|
+
.option('--usd', 'interpret --amount as US dollars, ignoring your display currency')
|
|
145
|
+
.option('--limit <price>', 'limit price 0–1 (default: market order)', (v) => parseFloat(v))
|
|
146
|
+
.option('--max-slippage <pct>', 'max slippage on a market bet, in percent (e.g. 5 = 5%)', (v) => parseFloat(v))
|
|
147
|
+
.action(async (marketId, opts, cmd) => {
|
|
148
|
+
const ctx = buildCtx(cmd);
|
|
149
|
+
requireAuth(ctx);
|
|
150
|
+
if (opts.side !== 'yes' && opts.side !== 'no')
|
|
151
|
+
fail('--side must be "yes" or "no"', ctx.json);
|
|
152
|
+
if (opts.maxSlippage !== undefined && !(opts.maxSlippage >= 0))
|
|
153
|
+
fail('--max-slippage must be a non-negative number (percent)', ctx.json);
|
|
154
|
+
// The stake is a cash value in the user's display currency (a bare "200" = ₹200
|
|
155
|
+
// for an INR user, not $200). Resolve to a USD amount for the backend — refuses
|
|
156
|
+
// rather than guess if the FX rate is unavailable. `--usd` forces dollars.
|
|
157
|
+
const m = await resolveCash(ctx, opts.amount, PREDICTION_DECIMALS, { usd: !!opts.usd });
|
|
158
|
+
try {
|
|
159
|
+
const data = await ctx.client.polymarketBet({
|
|
160
|
+
marketId,
|
|
161
|
+
side: opts.side,
|
|
162
|
+
amount: m.usdString,
|
|
163
|
+
limitPrice: opts.limit,
|
|
164
|
+
maxSlippageBps: opts.maxSlippage !== undefined ? Math.round(opts.maxSlippage * 100) : undefined,
|
|
165
|
+
});
|
|
166
|
+
out(ctx, `✓ Bet placed: ${describeCashResolution(m)} on ${data.side.toUpperCase()} — ${data.shares} shares @ ${data.price}.`, { ...data, stake: describeCashResolution(m) });
|
|
167
|
+
}
|
|
168
|
+
catch (err) {
|
|
169
|
+
if (isTradingDisabled(err))
|
|
170
|
+
fail(BETTING_DISABLED_MSG, ctx.json);
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
pm.command('cashout')
|
|
175
|
+
.description('Sell your shares in a market (before resolution)')
|
|
176
|
+
.argument('<marketId>', 'market id')
|
|
177
|
+
.option('--shares <n>', 'shares to sell (default: all)', (v) => parseFloat(v))
|
|
178
|
+
.action(async (marketId, opts, cmd) => {
|
|
179
|
+
const ctx = buildCtx(cmd);
|
|
180
|
+
requireAuth(ctx);
|
|
181
|
+
try {
|
|
182
|
+
const data = await ctx.client.polymarketSell({ marketId, shares: opts.shares });
|
|
183
|
+
out(ctx, `✓ Cash-out submitted for ${marketId}`, data);
|
|
184
|
+
}
|
|
185
|
+
catch (err) {
|
|
186
|
+
if (isTradingDisabled(err))
|
|
187
|
+
fail(BETTING_DISABLED_MSG, ctx.json);
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
pm.command('redeem')
|
|
192
|
+
.description('Claim winnings on a resolved market')
|
|
193
|
+
.argument('<marketId>', 'market id')
|
|
194
|
+
.action(async (marketId, _o, cmd) => {
|
|
195
|
+
const ctx = buildCtx(cmd);
|
|
196
|
+
requireAuth(ctx);
|
|
197
|
+
try {
|
|
198
|
+
const data = await ctx.client.polymarketRedeem({ marketId });
|
|
199
|
+
out(ctx, `✓ Redemption submitted for ${marketId}`, data);
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
if (isTradingDisabled(err))
|
|
203
|
+
fail(BETTING_DISABLED_MSG, ctx.json);
|
|
204
|
+
throw err;
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
pm.command('wallet')
|
|
208
|
+
.description('Your prediction balance — how much you have available to bet, and readiness')
|
|
209
|
+
.action(async (_o, cmd) => {
|
|
210
|
+
const ctx = buildCtx(cmd);
|
|
211
|
+
requireAuth(ctx);
|
|
212
|
+
try {
|
|
213
|
+
const [data, prefs] = await Promise.all([
|
|
214
|
+
ctx.client.polymarketDepositWallet(),
|
|
215
|
+
loadDisplayPrefs(ctx),
|
|
216
|
+
]);
|
|
217
|
+
// Read the live prediction balance on-chain (no backend dependency). Best-effort:
|
|
218
|
+
// a failed read shows "unavailable" rather than breaking the readiness report.
|
|
219
|
+
const balanceUsd = data.address ? await fetchPusdBalanceUsd(data.address) : null;
|
|
220
|
+
const rendered = balanceUsd != null ? formatMoney(balanceUsd, prefs) : null;
|
|
221
|
+
if (!data.deployed) {
|
|
222
|
+
out(ctx, 'Your prediction account isn’t set up yet. Run `polymarket provision` (one-time, no signing), then add funds with `polymarket deposit <amount>`.', { ...data, balanceUsd, rendered, display: displayDescriptor(prefs) });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const balLine = rendered != null
|
|
226
|
+
? `Available to bet: ${rendered}`
|
|
227
|
+
: 'Available to bet: (balance unavailable right now — try again in a moment)';
|
|
228
|
+
out(ctx, `${balLine}\nReady to place bets ✓` +
|
|
229
|
+
(balanceUsd === 0 ? '\nAdd funds with `polymarket deposit <amount>` before betting.' : ''), { ...data, balanceUsd, rendered, display: displayDescriptor(prefs) });
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
if (isTradingDisabled(err))
|
|
233
|
+
fail(BETTING_DISABLED_MSG, ctx.json);
|
|
234
|
+
throw err;
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
pm.command('provision')
|
|
238
|
+
.description('Set up your prediction account (one-time, gasless, no signing)')
|
|
239
|
+
.action(async (_o, cmd) => {
|
|
240
|
+
const ctx = buildCtx(cmd);
|
|
241
|
+
requireAuth(ctx);
|
|
242
|
+
try {
|
|
243
|
+
const data = await ctx.client.polymarketProvision();
|
|
244
|
+
out(ctx, '✓ Your prediction account is ready. Add funds with `polymarket deposit <amount>`, then place a bet.', data);
|
|
245
|
+
}
|
|
246
|
+
catch (err) {
|
|
247
|
+
if (isTradingDisabled(err))
|
|
248
|
+
fail(BETTING_DISABLED_MSG, ctx.json);
|
|
249
|
+
throw err;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
pm.command('deposit')
|
|
253
|
+
.description('Add cash to your prediction balance — autonomous, no signing')
|
|
254
|
+
.argument('<amount>', 'amount of cash to add (your currency; prefix $/₹ to force one)')
|
|
255
|
+
.action(async (amount, _o, cmd) => {
|
|
256
|
+
const ctx = buildCtx(cmd);
|
|
257
|
+
requireAuth(ctx);
|
|
258
|
+
// The amount is a cash value in the user's display currency → resolve to USD.
|
|
259
|
+
const m = await resolveCash(ctx, amount, PREDICTION_DECIMALS);
|
|
260
|
+
try {
|
|
261
|
+
const data = await ctx.client.polymarketDeposit(m.usdString);
|
|
262
|
+
out(ctx, `✓ Adding ${describeCashResolution(m)} to your prediction balance — it’ll be ready to bet in ~1–2 min.` +
|
|
263
|
+
(data.requestId ? `\nTrack it with: polymarket fund-status ${data.requestId}` : ''), { ...data, amount: describeCashResolution(m) });
|
|
264
|
+
}
|
|
265
|
+
catch (err) {
|
|
266
|
+
if (isTradingDisabled(err))
|
|
267
|
+
fail(BETTING_DISABLED_MSG, ctx.json);
|
|
268
|
+
throw err;
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
pm.command('fund-status')
|
|
272
|
+
.description('Check whether funds you added have landed in your prediction balance')
|
|
273
|
+
.argument('<requestId>', 'tracking id (from `polymarket deposit`)')
|
|
274
|
+
.action(async (requestId, _o, cmd) => {
|
|
275
|
+
const ctx = buildCtx(cmd);
|
|
276
|
+
requireAuth(ctx);
|
|
277
|
+
try {
|
|
278
|
+
const data = await ctx.client.polymarketFundStatus(requestId);
|
|
279
|
+
out(ctx, formatFundStatus(data), data);
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
if (isTradingDisabled(err))
|
|
283
|
+
fail(BETTING_DISABLED_MSG, ctx.json);
|
|
284
|
+
throw err;
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
pm.command('withdraw')
|
|
288
|
+
.description('Move cash from your prediction balance back to your account — autonomous, no signing')
|
|
289
|
+
.argument('<amount>', 'amount of cash to move back (your currency; prefix $/₹ to force one)')
|
|
290
|
+
.action(async (amount, _o, cmd) => {
|
|
291
|
+
const ctx = buildCtx(cmd);
|
|
292
|
+
requireAuth(ctx);
|
|
293
|
+
const m = await resolveCash(ctx, amount, PREDICTION_DECIMALS);
|
|
294
|
+
try {
|
|
295
|
+
const data = await ctx.client.polymarketWithdraw(m.usdString);
|
|
296
|
+
out(ctx, `✓ Moving ${describeCashResolution(m)} from your prediction balance back to your account — it lands automatically in a moment.`, { ...data, amount: describeCashResolution(m) });
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
if (isTradingDisabled(err))
|
|
300
|
+
fail(BETTING_DISABLED_MSG, ctx.json);
|
|
301
|
+
throw err;
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { buildCtx, requireAuth, out } from '../money-helpers.js';
|
|
2
|
+
export function registerSession(program) {
|
|
3
|
+
const session = program.command('session').description('Manage agent access (session keys)');
|
|
4
|
+
// Agent access (the session key) is now installed automatically during
|
|
5
|
+
// `grant login`. This command just points users there rather than driving
|
|
6
|
+
// the old widget-relay grant flow.
|
|
7
|
+
session.command('grant').description('How to enable agent access')
|
|
8
|
+
.action((_opts, cmd) => {
|
|
9
|
+
const ctx = buildCtx(cmd);
|
|
10
|
+
out(ctx, 'Agent access is set up automatically when you connect. Run `grant login` (or `grant login --force` to re-run setup).', { hint: 'run `grant login`' });
|
|
11
|
+
});
|
|
12
|
+
session.command('status').description('Session-key / agent-access status')
|
|
13
|
+
.action(async (_opts, cmd) => {
|
|
14
|
+
const ctx = buildCtx(cmd);
|
|
15
|
+
requireAuth(ctx);
|
|
16
|
+
const m = await ctx.client.getModuleStatus();
|
|
17
|
+
out(ctx, `Agent access: ${m?.status ?? 'unknown'}`, m);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { buildCtx, requireAuth, resolver, reportTx, resolveCash, resolveCashTargetQty, out, fail } from '../money-helpers.js';
|
|
2
|
+
import { assertDecimal, describeCashResolution } from '../../lib/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Resolve the sell-side `sellAmount` (always in `from`-token units) for a trade.
|
|
5
|
+
*
|
|
6
|
+
* Three shapes:
|
|
7
|
+
* - source is cash (buy): the amount is fiat → interpreted in the user's currency
|
|
8
|
+
* and converted to USD ("buy gold 50" = ₹50 for an INR user, not $50).
|
|
9
|
+
* - source is an asset + `--amount`: a quantity of that asset, passed through.
|
|
10
|
+
* - source is an asset + `--for <cash>`: a CASH TARGET — sized to an asset
|
|
11
|
+
* quantity from the live price ("sell ₹100 of gold" → ~₹100 worth of gold).
|
|
12
|
+
*/
|
|
13
|
+
async function resolveSellSide(ctx, from, spec) {
|
|
14
|
+
// Cash source → the amount is cash to spend (buy).
|
|
15
|
+
if (from.class === 'stable') {
|
|
16
|
+
if (spec.forCash)
|
|
17
|
+
fail('Use --amount for cash; --for is for selling an asset by cash value.', ctx.json);
|
|
18
|
+
if (!spec.amount)
|
|
19
|
+
fail('Missing --amount (cash to spend).', ctx.json);
|
|
20
|
+
const m = await resolveCash(ctx, spec.amount, from.decimals);
|
|
21
|
+
return { sellAmount: m.usdString, note: describeCashResolution(m) };
|
|
22
|
+
}
|
|
23
|
+
// Asset source, cash target → size the quantity from the live price.
|
|
24
|
+
if (spec.forCash) {
|
|
25
|
+
const r = await resolveCashTargetQty(ctx, from, spec.forCash);
|
|
26
|
+
return {
|
|
27
|
+
sellAmount: r.quantity,
|
|
28
|
+
note: `${describeCashResolution(r)} of ${from.friendly} (≈ ${r.quantity} ${from.friendly})`,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
// Asset source, explicit quantity.
|
|
32
|
+
if (!spec.amount)
|
|
33
|
+
fail(`Specify how much: --amount <${from.friendly} quantity> or --for <cash value>.`, ctx.json);
|
|
34
|
+
const sellAmount = assertDecimal(spec.amount, from.decimals);
|
|
35
|
+
return { sellAmount, note: `${sellAmount} ${from.friendly}` };
|
|
36
|
+
}
|
|
37
|
+
/** Enforce that exactly one of --amount / --for was given. */
|
|
38
|
+
function oneOfAmountOrFor(ctx, opts) {
|
|
39
|
+
if (opts.amount && opts.for)
|
|
40
|
+
fail('Pass either --amount (a quantity) or --for (a cash value), not both.', ctx.json);
|
|
41
|
+
if (!opts.amount && !opts.for)
|
|
42
|
+
fail('Specify --amount <quantity> or --for <cash value>.', ctx.json);
|
|
43
|
+
return { amount: opts.amount, forCash: opts.for };
|
|
44
|
+
}
|
|
45
|
+
export function registerTrade(program) {
|
|
46
|
+
program.command('quote').description('Preview a trade (no execution)')
|
|
47
|
+
.requiredOption('--from <asset>').requiredOption('--to <asset>')
|
|
48
|
+
.option('--amount <n>', 'quantity of the "from" asset')
|
|
49
|
+
.option('--for <cash>', 'cash value to sell (e.g. 100, "$50", "₹100")')
|
|
50
|
+
.action(async (opts, cmd) => {
|
|
51
|
+
const ctx = buildCtx(cmd);
|
|
52
|
+
requireAuth(ctx);
|
|
53
|
+
const r = await resolver(ctx);
|
|
54
|
+
const from = r.resolve(opts.from);
|
|
55
|
+
const to = r.resolve(opts.to);
|
|
56
|
+
const { sellAmount } = await resolveSellSide(ctx, from, oneOfAmountOrFor(ctx, opts));
|
|
57
|
+
const data = await ctx.client.swapQuote({ sellToken: from.symbol, buyToken: to.symbol, sellAmount });
|
|
58
|
+
out(ctx, JSON.stringify(data, null, 2), data);
|
|
59
|
+
});
|
|
60
|
+
// wait-by-default: commander's `--no-wait` sets opts.wait=false; default true.
|
|
61
|
+
const trade = async (cmd, fromTerm, toTerm, spec, wait) => {
|
|
62
|
+
const ctx = buildCtx(cmd);
|
|
63
|
+
requireAuth(ctx);
|
|
64
|
+
const r = await resolver(ctx);
|
|
65
|
+
const from = r.resolve(fromTerm);
|
|
66
|
+
const to = r.resolve(toTerm);
|
|
67
|
+
const { sellAmount, note } = await resolveSellSide(ctx, from, spec);
|
|
68
|
+
const res = (await ctx.client.swap({
|
|
69
|
+
sellToken: from.symbol, buyToken: to.symbol, sellAmount,
|
|
70
|
+
}));
|
|
71
|
+
await reportTx(ctx, res, wait, `${fromTerm} → ${toTerm} (${note})`);
|
|
72
|
+
};
|
|
73
|
+
program.command('buy <asset>').description('Buy an asset with cash')
|
|
74
|
+
.requiredOption('--amount <cash>', 'amount of cash to spend (your currency; prefix $/₹ to force one)')
|
|
75
|
+
.option('--no-wait', 'return immediately without waiting for on-chain confirmation')
|
|
76
|
+
.action((asset, opts, cmd) => trade(cmd, 'cash', asset, { amount: opts.amount }, opts.wait !== false));
|
|
77
|
+
program.command('sell <asset>').description('Sell an asset — by quantity (--amount) or by cash value (--for)')
|
|
78
|
+
.option('--amount <n>', 'quantity of the asset to sell (e.g. 0.1 bitcoin)')
|
|
79
|
+
.option('--for <cash>', 'cash value to sell instead (e.g. 100, "$50", "₹100")')
|
|
80
|
+
.option('--no-wait', 'return immediately without waiting for on-chain confirmation')
|
|
81
|
+
.action((asset, opts, cmd) => {
|
|
82
|
+
const ctx = buildCtx(cmd);
|
|
83
|
+
trade(cmd, asset, 'cash', oneOfAmountOrFor(ctx, opts), opts.wait !== false);
|
|
84
|
+
});
|
|
85
|
+
program.command('convert').description('Convert one asset into another — by quantity (--amount) or cash value (--for)')
|
|
86
|
+
.requiredOption('--from <asset>').requiredOption('--to <asset>')
|
|
87
|
+
.option('--amount <n>', 'quantity of the "from" asset')
|
|
88
|
+
.option('--for <cash>', 'cash value of "from" to convert (e.g. 100, "$50", "₹100")')
|
|
89
|
+
.option('--no-wait', 'return immediately without waiting for on-chain confirmation')
|
|
90
|
+
.action((opts, cmd) => {
|
|
91
|
+
const ctx = buildCtx(cmd);
|
|
92
|
+
trade(cmd, opts.from, opts.to, oneOfAmountOrFor(ctx, opts), opts.wait !== false);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { buildCtx, requireAuth, out } from '../money-helpers.js';
|
|
2
|
+
export function registerTx(program) {
|
|
3
|
+
const tx = program.command('tx').description('Transaction status and history');
|
|
4
|
+
tx.command('status <txHash>').description('Check whether a transaction confirmed on-chain')
|
|
5
|
+
.action(async (txHash, _o, cmd) => {
|
|
6
|
+
const ctx = buildCtx(cmd);
|
|
7
|
+
requireAuth(ctx);
|
|
8
|
+
const data = await ctx.client.getTxStatus(txHash);
|
|
9
|
+
const terminal = data.status === 'success' || data.status === 'failed';
|
|
10
|
+
const human = `${data.type ?? 'transaction'} ${data.txHash}\n` +
|
|
11
|
+
`Status: ${data.status}${terminal ? '' : ' (still in progress — check again shortly)'}`;
|
|
12
|
+
out(ctx, human, data);
|
|
13
|
+
});
|
|
14
|
+
tx.command('history').description('Recent transactions')
|
|
15
|
+
.option('--limit <n>', 'how many to show', '20')
|
|
16
|
+
.action(async (opts, cmd) => {
|
|
17
|
+
const ctx = buildCtx(cmd);
|
|
18
|
+
requireAuth(ctx);
|
|
19
|
+
const data = await ctx.client.getTxHistory(Number(opts.limit) || 20);
|
|
20
|
+
out(ctx, JSON.stringify(data, null, 2), data);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { AppError } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* Agent side — the Agent-mode backend client (pay-per-use services, x402).
|
|
4
|
+
*
|
|
5
|
+
* Auth with a session-bound `X-API-Key` (`grant_live_`). The agent never holds a
|
|
6
|
+
* private key; the backend's delegated, revocable, guardrailed session owns the
|
|
7
|
+
* spend. Errors carry the backend's stable `err.code` + a `recoverable` flag so
|
|
8
|
+
* a driving agent knows whether to retry (see SKILL.md error playbook).
|
|
9
|
+
*/
|
|
10
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
11
|
+
export class AgentClient {
|
|
12
|
+
base;
|
|
13
|
+
apiKey;
|
|
14
|
+
f;
|
|
15
|
+
constructor(opts) {
|
|
16
|
+
this.base = opts.agent;
|
|
17
|
+
this.apiKey = opts.apiKey;
|
|
18
|
+
this.f = opts.fetchImpl ?? fetch;
|
|
19
|
+
}
|
|
20
|
+
get connected() {
|
|
21
|
+
return Boolean(this.apiKey);
|
|
22
|
+
}
|
|
23
|
+
async request(method, path, opts = {}) {
|
|
24
|
+
const headers = { Accept: "application/json" };
|
|
25
|
+
if (opts.body !== undefined)
|
|
26
|
+
headers["Content-Type"] = "application/json";
|
|
27
|
+
if (opts.auth) {
|
|
28
|
+
if (!this.apiKey)
|
|
29
|
+
throw new AppError("Not connected to your agent yet. Run `grant login`.", {
|
|
30
|
+
code: "NOT_CONNECTED",
|
|
31
|
+
recoverable: false,
|
|
32
|
+
});
|
|
33
|
+
headers["X-API-Key"] = this.apiKey;
|
|
34
|
+
}
|
|
35
|
+
if (opts.idempotent)
|
|
36
|
+
headers["Idempotency-Key"] = randomId();
|
|
37
|
+
let res;
|
|
38
|
+
try {
|
|
39
|
+
res = await this.f(`${this.base}${path}`, {
|
|
40
|
+
method,
|
|
41
|
+
headers,
|
|
42
|
+
...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
|
|
43
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
throw new AppError(e instanceof Error ? e.message : String(e), {
|
|
48
|
+
code: "NETWORK",
|
|
49
|
+
recoverable: true,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const json = (await res.json().catch(() => ({})));
|
|
53
|
+
// A 4xx without the agent error envelope means we never reached the API
|
|
54
|
+
// (wrong/dead base URL, platform edge 404). Point at the misconfig.
|
|
55
|
+
if (res.status >= 400 && res.status < 500 && !json?.error?.code) {
|
|
56
|
+
throw new AppError(`No Grant Cash agent API at ${this.base} (HTTP ${res.status}) — check GRANTCASH_AGENT_URL.`, { status: res.status, code: "BACKEND_UNREACHABLE", recoverable: false });
|
|
57
|
+
}
|
|
58
|
+
if (!res.ok || json?.error?.code) {
|
|
59
|
+
throw new AppError(json?.error?.message ?? `Request failed (${res.status})`, {
|
|
60
|
+
status: res.status,
|
|
61
|
+
code: json?.error?.code ?? `HTTP_${res.status}`,
|
|
62
|
+
recoverable: res.status >= 500,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return json;
|
|
66
|
+
}
|
|
67
|
+
// ── reads ──
|
|
68
|
+
/**
|
|
69
|
+
* Spendable funds + session state. Hits `GET /balance` (api-key auth), which
|
|
70
|
+
* returns the credit-aware status shape (active + USDC/USDT, not_set_up,
|
|
71
|
+
* credit_exhausted, or sign-up credit). NOT `/me` — that route is JWT-only and
|
|
72
|
+
* carries identity, not funds.
|
|
73
|
+
*/
|
|
74
|
+
balance() {
|
|
75
|
+
return this.request("GET", "/balance", { auth: true });
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Find a payable endpoint in the curated catalog. Hits `GET /marketplace/x402`
|
|
79
|
+
* (the grouped, public catalog route) — NOT `/marketplace`, which does not
|
|
80
|
+
* exist on the backend.
|
|
81
|
+
*/
|
|
82
|
+
search(query, limit) {
|
|
83
|
+
const p = new URLSearchParams();
|
|
84
|
+
if (query)
|
|
85
|
+
p.set("q", query);
|
|
86
|
+
if (limit)
|
|
87
|
+
p.set("limit", String(limit));
|
|
88
|
+
const qs = p.toString();
|
|
89
|
+
return this.request("GET", `/marketplace/x402${qs ? `?${qs}` : ""}`, { auth: true });
|
|
90
|
+
}
|
|
91
|
+
/** Exact pay contract (requestSchema) for a URL — no spend. */
|
|
92
|
+
check(url) {
|
|
93
|
+
return this.request("GET", `/marketplace/check?url=${encodeURIComponent(url)}`, { auth: true });
|
|
94
|
+
}
|
|
95
|
+
/** Recent transactions, newest first. */
|
|
96
|
+
transactions(limit) {
|
|
97
|
+
const q = limit ? `?limit=${limit}` : "";
|
|
98
|
+
return this.request("GET", `/transactions${q}`, { auth: true });
|
|
99
|
+
}
|
|
100
|
+
/** One transaction by id. */
|
|
101
|
+
tx(id) {
|
|
102
|
+
return this.request("GET", `/transactions/${encodeURIComponent(id)}`, {
|
|
103
|
+
auth: true,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/** Exact POST /x402/pay contract for a catalog entry by slug (requestSchema + price). No spend. */
|
|
107
|
+
schema(slug) {
|
|
108
|
+
return this.request("GET", `/marketplace/x402/${encodeURIComponent(slug)}`, { auth: true });
|
|
109
|
+
}
|
|
110
|
+
// ── catalog + account management ──
|
|
111
|
+
/** Ingest any x402 origin's endpoints into the catalog so they become searchable/payable. */
|
|
112
|
+
discover(origin) {
|
|
113
|
+
return this.request("POST", "/marketplace/discover", {
|
|
114
|
+
body: { origin },
|
|
115
|
+
auth: true,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
/** Claim an invite / bonus credit by code. Mints or tops up the session's spendable funds. */
|
|
119
|
+
redeem(code) {
|
|
120
|
+
return this.request("POST", "/onboard/redeem", {
|
|
121
|
+
body: { code },
|
|
122
|
+
auth: true,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/** One-time link (+ code) to secure a full account and add funds. Gated by the session key. */
|
|
126
|
+
linkCode() {
|
|
127
|
+
return this.request("POST", "/account/link-code", { body: {}, auth: true });
|
|
128
|
+
}
|
|
129
|
+
// ── spends (session-owned, guardrailed) ──
|
|
130
|
+
/**
|
|
131
|
+
* Pay + run an endpoint via the session. Body matches `POST /x402/pay`'s
|
|
132
|
+
* schema: `method` is required (uppercased), `expectedPriceMinor` (the spend
|
|
133
|
+
* cap) and `asset` are required, and `body` is a RAW STRING passthrough — not
|
|
134
|
+
* a parsed object. The agent never signs; the session owns the spend.
|
|
135
|
+
*/
|
|
136
|
+
fetchPay(b) {
|
|
137
|
+
return this.request("POST", "/x402/pay", {
|
|
138
|
+
body: b,
|
|
139
|
+
auth: true,
|
|
140
|
+
idempotent: true,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Gasless transfer to an address. Hits `POST /transactions` (there is no
|
|
145
|
+
* `/transfer` route); body is `{ asset, amount, recipient }` where `amount` is
|
|
146
|
+
* the minor-units decimal string.
|
|
147
|
+
*/
|
|
148
|
+
transfer(b) {
|
|
149
|
+
return this.request("POST", "/transactions", {
|
|
150
|
+
body: { asset: b.asset, amount: b.amount, recipient: b.recipient },
|
|
151
|
+
auth: true,
|
|
152
|
+
idempotent: true,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
/** Stop all spend on this key — revoke the session (terminal). */
|
|
156
|
+
revoke() {
|
|
157
|
+
return this.request("POST", "/sessions/revoke", {
|
|
158
|
+
body: {},
|
|
159
|
+
auth: true,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/* Local idempotency id — avoids importing node:crypto at module top for tree-shake. */
|
|
164
|
+
function randomId() {
|
|
165
|
+
return (Math.random().toString(36).slice(2) + Date.now().toString(36));
|
|
166
|
+
}
|