@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,91 @@
1
+ /**
2
+ * Display-currency + experience-mode formatting — the single source of truth
3
+ * shared by the CLI and the MCP server.
4
+ *
5
+ * The platform stores all monetary values internally in USD. The user's profile
6
+ * (GET /api/user) carries their display preferences:
7
+ * - currency ISO code of their local currency (e.g. INR, AED, EUR)
8
+ * - displayCurrency 'USD' (show USD) | 'local' (convert to `currency`)
9
+ * - goldDisplayUnit 'oz' | 'g'
10
+ * - experienceMode 'simple' (plain language, local currency) | 'advanced'
11
+ *
12
+ * The FX rate (local units per 1 USD) comes from GET /api/prices/fiat/{currency}.
13
+ * Conversion is a single multiply; presentation is delegated to Intl so symbols
14
+ * and per-currency decimal conventions (e.g. JPY has 0) are correct and we don't
15
+ * hand-maintain a symbol table.
16
+ */
17
+ /** 1 troy ounce = 31.1034768 grams (matches the web app's GRAMS_PER_TROY_OUNCE). */
18
+ export const GRAMS_PER_TROY_OUNCE = 31.1034768;
19
+ /** Safe USD-only defaults — what every caller falls back to with no profile. */
20
+ export const DEFAULT_PREFS = {
21
+ currency: 'USD',
22
+ displayCurrency: 'USD',
23
+ goldUnit: 'oz',
24
+ mode: 'simple',
25
+ rate: 1,
26
+ };
27
+ /**
28
+ * Build DisplayPrefs from a raw GET /api/user payload (defensively — the field
29
+ * may be absent on legacy rows) plus a fetched FX rate. Unknown / malformed
30
+ * values fall back to the safe USD defaults rather than throwing.
31
+ */
32
+ export function resolveDisplayPrefs(user, rate = 1) {
33
+ const u = (user && typeof user === 'object' ? user : {});
34
+ const currency = typeof u.currency === 'string' && u.currency.trim() ? u.currency.trim().toUpperCase() : 'USD';
35
+ const displayCurrency = u.displayCurrency === 'local' ? 'local' : 'USD';
36
+ const goldUnit = u.goldDisplayUnit === 'g' ? 'g' : 'oz';
37
+ const mode = u.experienceMode === 'advanced' ? 'advanced' : 'simple';
38
+ const safeRate = typeof rate === 'number' && Number.isFinite(rate) && rate > 0 ? rate : 1;
39
+ return { currency, displayCurrency, goldUnit, mode, rate: safeRate };
40
+ }
41
+ /** True when money should be shown in the user's local currency (not USD). */
42
+ export function usesLocalCurrency(prefs) {
43
+ return (prefs.displayCurrency === 'local' &&
44
+ !!prefs.currency &&
45
+ prefs.currency.toUpperCase() !== 'USD' &&
46
+ prefs.rate > 0);
47
+ }
48
+ /**
49
+ * Format a USD amount for display per the user's preferences. Converts to the
50
+ * local currency when configured; otherwise renders USD. Always falls back to a
51
+ * valid USD string if the target currency code is not recognized by Intl.
52
+ */
53
+ export function formatMoney(amountUsd, prefs = DEFAULT_PREFS) {
54
+ const usd = Number.isFinite(amountUsd) ? amountUsd : 0;
55
+ const local = usesLocalCurrency(prefs);
56
+ const code = local ? prefs.currency.toUpperCase() : 'USD';
57
+ const value = local ? usd * prefs.rate : usd;
58
+ try {
59
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency: code }).format(value);
60
+ }
61
+ catch {
62
+ // Unrecognized ISO code → never crash a balance read; show USD instead.
63
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(usd);
64
+ }
65
+ }
66
+ /** Trim a number to at most `maxDp` decimals without trailing zeros. */
67
+ function trim(n, maxDp) {
68
+ if (!Number.isFinite(n))
69
+ return '0';
70
+ return Number(n.toFixed(maxDp)).toLocaleString('en-US', { maximumFractionDigits: maxDp });
71
+ }
72
+ export function displayDescriptor(prefs = DEFAULT_PREFS) {
73
+ return {
74
+ currency: prefs.currency,
75
+ displayCurrency: prefs.displayCurrency,
76
+ goldUnit: prefs.goldUnit,
77
+ rate: prefs.rate,
78
+ usesLocal: usesLocalCurrency(prefs),
79
+ };
80
+ }
81
+ /**
82
+ * Format a gold amount (given in troy ounces) per the user's unit preference.
83
+ * Grams are shown with more precision than ounces since 1 oz ≈ 31 g.
84
+ */
85
+ export function formatGold(ounces, prefs = DEFAULT_PREFS) {
86
+ const oz = Number.isFinite(ounces) ? ounces : 0;
87
+ if (prefs.goldUnit === 'g') {
88
+ return `${trim(oz * GRAMS_PER_TROY_OUNCE, 2)} g`;
89
+ }
90
+ return `${trim(oz, 4)} oz`;
91
+ }
@@ -0,0 +1,163 @@
1
+ import { spawn } from "node:child_process";
2
+ import { AppError } from "./errors.js";
3
+ const JSON_HEADERS = { "Content-Type": "application/json" };
4
+ export async function startDevice(api, clientName, deviceName, f = fetch) {
5
+ const res = await f(`${api}/cli/device/start`, {
6
+ method: "POST",
7
+ headers: JSON_HEADERS,
8
+ body: JSON.stringify({ clientName, deviceName }),
9
+ });
10
+ const j = (await res.json().catch(() => ({})));
11
+ if (!res.ok || !j.success || !j.data)
12
+ throw new AppError(j.error || `Could not start login (HTTP ${res.status})`, {
13
+ status: res.status,
14
+ });
15
+ return j.data;
16
+ }
17
+ /**
18
+ * One poll tick. Resolves only on a real backend answer (HTTP 200 + success
19
+ * envelope) — `pending` | `denied` | `expired` | `complete`. Any transient
20
+ * failure (429 from the shared `/api` limiter, a 5xx, a malformed body, or a
21
+ * thrown fetch when the dev backend restarts) is raised as an AppError so
22
+ * {@link waitForDevice} can back off and retry inside the window instead of
23
+ * mistaking a blip for a terminal "expired" and giving up. A 429 carries
24
+ * `retryInMs` (from the body or the `Retry-After` header) for the backoff.
25
+ *
26
+ * This mirrors the agent side (`pollAgentDevice`/`waitForAgentDevice`); the two
27
+ * halves of the handshake must be equally fault-tolerant.
28
+ */
29
+ export async function pollDevice(api, sid, f = fetch) {
30
+ let res;
31
+ try {
32
+ res = await f(`${api}/cli/device/poll`, {
33
+ method: "POST",
34
+ headers: JSON_HEADERS,
35
+ body: JSON.stringify({ sid }),
36
+ });
37
+ }
38
+ catch (e) {
39
+ // Network blip / dev backend mid-restart — transient, retry.
40
+ throw new AppError(e instanceof Error ? e.message : String(e), {
41
+ code: "NETWORK",
42
+ recoverable: true,
43
+ });
44
+ }
45
+ const j = (await res.json().catch(() => ({})));
46
+ if (res.status === 429) {
47
+ const retryAfter = Number(res.headers.get("retry-after"));
48
+ const retryInMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : undefined;
49
+ throw new AppError("Too many attempts — backing off.", {
50
+ status: 429,
51
+ code: "RATE_LIMITED",
52
+ recoverable: true,
53
+ details: retryInMs ? { retryInMs } : undefined,
54
+ });
55
+ }
56
+ // Non-2xx or a malformed/unsuccessful envelope = transient (the backend
57
+ // reports a genuine terminal state as 200 + success). Retry within the window.
58
+ if (!res.ok || !j.success || !j.data) {
59
+ throw new AppError(j.error || `Poll failed (HTTP ${res.status})`, {
60
+ status: res.status,
61
+ code: `HTTP_${res.status}`,
62
+ recoverable: true,
63
+ });
64
+ }
65
+ return j.data;
66
+ }
67
+ /**
68
+ * Poll until a genuine terminal status or the link expires.
69
+ *
70
+ * Never bails on a transient failure: network errors and rate-limits (honoring
71
+ * `retryInMs`) just back off and retry until the window closes. Only a real
72
+ * backend answer (`complete` / `denied` / `expired`) ends the loop early. On
73
+ * window exhaustion returns `{ status: "expired" }`.
74
+ */
75
+ export async function waitForDevice(api, sid, intervalMs, timeoutMs, f = fetch) {
76
+ const start = Date.now();
77
+ while (Date.now() - start < timeoutMs) {
78
+ try {
79
+ const r = await pollDevice(api, sid, f);
80
+ if (r.status !== "pending")
81
+ return r;
82
+ await sleep(intervalMs);
83
+ }
84
+ catch (e) {
85
+ const err = e;
86
+ const retryInMs = err.details?.retryInMs ?? intervalMs;
87
+ // Rate-limited → wait at least the server's hint; other transients → one interval.
88
+ await sleep(err.code === "RATE_LIMITED" ? Math.max(intervalMs, retryInMs) : intervalMs);
89
+ }
90
+ }
91
+ return { status: "expired" };
92
+ }
93
+ function sleep(ms) {
94
+ return new Promise((res) => setTimeout(res, ms));
95
+ }
96
+ /**
97
+ * Resolve the money-side poll interval in MILLISECONDS.
98
+ *
99
+ * The Perfolio money backend (`cli/device/start`) reports `pollInterval` already
100
+ * in milliseconds (e.g. 3000) — the same contract the published @perfolio/cli
101
+ * consumes as-is — so it is used verbatim. This deliberately differs from the
102
+ * agent backend, which reports SECONDS (see {@link agentPollIntervalMs}).
103
+ *
104
+ * Multiplying this value by 1000 was the cause of the "browser connected but CLI
105
+ * polls forever" bug: 3000 became 3,000,000ms (~50 min), so the CLI polled once
106
+ * and then slept past the whole login window. Falls back to 3s.
107
+ */
108
+ export function moneyPollIntervalMs(pollInterval) {
109
+ return typeof pollInterval === "number" && pollInterval > 0 ? pollInterval : 3000;
110
+ }
111
+ export async function refreshCliToken(api, refreshToken, f = fetch) {
112
+ const res = await f(`${api}/cli/token/refresh`, {
113
+ method: "POST",
114
+ headers: JSON_HEADERS,
115
+ body: JSON.stringify({ refreshToken }),
116
+ });
117
+ const j = (await res.json().catch(() => ({})));
118
+ if (!res.ok || !j.success || !j.data)
119
+ throw new AppError(j.error || "Refresh failed", { status: res.status });
120
+ return j.data;
121
+ }
122
+ /** List the devices/agents connected to this account (Bearer auth). */
123
+ export async function listDevices(api, token, f = fetch) {
124
+ const res = await f(`${api}/cli/devices`, { headers: { Authorization: `Bearer ${token}` } });
125
+ const j = (await res.json().catch(() => ({})));
126
+ if (!res.ok || !j.success || !j.data)
127
+ throw new Error(j.error || "Could not list devices");
128
+ return j.data.devices;
129
+ }
130
+ export async function revokeDevice(api, token, deviceId, f = fetch) {
131
+ await f(`${api}/cli/token/revoke`, {
132
+ method: "POST",
133
+ headers: { ...JSON_HEADERS, Authorization: `Bearer ${token}` },
134
+ body: JSON.stringify(deviceId ? { deviceId } : {}),
135
+ }).catch(() => { });
136
+ }
137
+ /** Best-effort label for the agent/client making the connection. */
138
+ export function detectClient(env = process.env) {
139
+ if (env.GRANTCASH_CLIENT)
140
+ return env.GRANTCASH_CLIENT;
141
+ if (env.CLAUDECODE || env.CLAUDE_CODE)
142
+ return "Claude Code";
143
+ if (env.CURSOR_TRACE_ID)
144
+ return "Cursor";
145
+ if (env.TERM_PROGRAM)
146
+ return env.TERM_PROGRAM;
147
+ return "Grant Cash CLI";
148
+ }
149
+ /** Open a URL in the user's default browser (best-effort; never throws). */
150
+ export function openBrowser(url) {
151
+ const cmd = process.platform === "darwin"
152
+ ? "open"
153
+ : process.platform === "win32"
154
+ ? "cmd"
155
+ : "xdg-open";
156
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
157
+ try {
158
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
159
+ }
160
+ catch {
161
+ /* headless — the URL is printed for manual open */
162
+ }
163
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Unified error model across both backends.
3
+ *
4
+ * Carries a machine `code` and a `recoverable` flag (from the agent side's LLM
5
+ * playbook) plus an HTTP `status` (from the money side). `friendlyError` maps the
6
+ * shared connection/availability states to one plain-language line so the same
7
+ * 401/403/428/503 reads the same no matter which backend produced it.
8
+ */
9
+ export class AppError extends Error {
10
+ status;
11
+ code;
12
+ recoverable;
13
+ details;
14
+ constructor(message, opts = {}) {
15
+ super(message);
16
+ this.name = "AppError";
17
+ this.status = opts.status;
18
+ this.code = opts.code;
19
+ this.recoverable = opts.recoverable;
20
+ this.details = opts.details;
21
+ }
22
+ }
23
+ /** Money side returns 428 when no spending session is installed. */
24
+ export class NoSessionError extends AppError {
25
+ constructor() {
26
+ super("No spending permission set up yet. Run `grant login` and approve the limits in your browser.", { status: 428, code: "NO_SESSION", recoverable: false });
27
+ }
28
+ }
29
+ /** No credential of the kind a command needs. */
30
+ export class NotConnectedError extends AppError {
31
+ constructor(which) {
32
+ const msg = which === "agent"
33
+ ? "Not connected to your agent yet. Run `grant login`."
34
+ : which === "money"
35
+ ? "Not signed in yet. Run `grant login`."
36
+ : "Not connected yet. Run `grant login`.";
37
+ super(msg, { code: "NOT_CONNECTED", recoverable: false });
38
+ }
39
+ }
40
+ /**
41
+ * Plain-language override for shared connection states. Returns null when there
42
+ * is no special-cased message (the caller then uses the backend's own message).
43
+ */
44
+ export function friendlyError(status, code) {
45
+ if (code === "NOT_CONNECTED" || code === "NO_SESSION")
46
+ return null; // already friendly
47
+ switch (status) {
48
+ case 401:
49
+ return "Your session expired. Run `grant login` again.";
50
+ case 403:
51
+ return "That's outside the limits you set, or the session was stopped. Nothing was charged.";
52
+ case 428:
53
+ return "No spending permission set up yet. Run `grant login` and approve the limits.";
54
+ case 503:
55
+ return "That service is briefly unavailable. Try again in a moment.";
56
+ default:
57
+ return null;
58
+ }
59
+ }
@@ -0,0 +1,22 @@
1
+ /** Display formatting. Plain money, no tickers/chains (Grant Cash house style). */
2
+ export function money(n, currency = "USD") {
3
+ const symbol = currency === "USD" ? "$" : "";
4
+ const abs = Math.abs(n);
5
+ const digits = abs > 0 && abs < 100 ? 2 : 0;
6
+ const body = new Intl.NumberFormat("en-US", {
7
+ minimumFractionDigits: digits,
8
+ maximumFractionDigits: digits,
9
+ }).format(abs);
10
+ return `${n < 0 ? "−" : ""}${symbol}${body}${currency === "USD" ? "" : " " + currency}`;
11
+ }
12
+ /** Agent-side amounts arrive as USDC minor units (1e6). */
13
+ export function usdcMinor(minor) {
14
+ if (minor === undefined || minor === null)
15
+ return "—";
16
+ const n = Number(minor) / 1e6;
17
+ return `$${n.toFixed(n < 0.01 ? 4 : 2)}`;
18
+ }
19
+ export function pct(n, digits = 1) {
20
+ const sign = n > 0 ? "+" : n < 0 ? "−" : "";
21
+ return `${sign}${Math.abs(n).toFixed(digits)}%`;
22
+ }
@@ -0,0 +1,24 @@
1
+ export * from "./types.js";
2
+ export * from "./config.js";
3
+ export * from "./errors.js";
4
+ export * from "./output.js";
5
+ export * from "./device.js";
6
+ export * from "./agent-device.js";
7
+ export * from "./money-client.js";
8
+ export * from "./agent-client.js";
9
+ export * from "./tx-wait.js";
10
+ export * from "./format.js";
11
+ export * from "./currency.js";
12
+ export * from "./portfolio-format.js";
13
+ export * from "./context.js";
14
+ // ── ported Perfolio money engine (full investing surface) ──
15
+ export * from "./client.js";
16
+ export * from "./assets.js";
17
+ export * from "./amounts.js";
18
+ export * from "./money-input.js";
19
+ export * from "./kyc-status.js";
20
+ export * from "./polygon-balance.js";
21
+ export * from "./sign.js";
22
+ export * from "./relay.js";
23
+ // NOTE: ./verify.js (waitForTx for PerfolioClient) is deliberately NOT barreled
24
+ // here — it would collide with tx-wait.js's waitForTx. Import it directly.
@@ -0,0 +1,28 @@
1
+ /** Pure KYC status classifiers — mirror the web app's kyc-status semantics. */
2
+ function norm(s) {
3
+ return typeof s === 'string' ? s.trim().toLowerCase() : '';
4
+ }
5
+ const APPROVED = new Set(['approved', 'verified', 'completed', 'success', 'active']);
6
+ const REJECTED = new Set(['rejected', 'declined', 'failed', 'denied']);
7
+ const SUBMITTED = new Set(['submitted', 'in_review', 'processing', 'pending_review', 'under_review']);
8
+ export function isKycApproved(status) { return APPROVED.has(norm(status)); }
9
+ export function isKycRejected(status) { return REJECTED.has(norm(status)); }
10
+ export function isKycSubmitted(status) { return SUBMITTED.has(norm(status)); }
11
+ export function isKycPending(status) {
12
+ const n = norm(status);
13
+ return !APPROVED.has(n) && !REJECTED.has(n) && !SUBMITTED.has(n);
14
+ }
15
+ /** Extract a status string from a raw value (string | {status} | {data:{status}}) and classify it. */
16
+ export function classifyKyc(raw) {
17
+ let status;
18
+ if (typeof raw === 'string')
19
+ status = raw;
20
+ else if (raw && typeof raw === 'object') {
21
+ const o = raw;
22
+ const inner = (o.data && typeof o.data === 'object') ? o.data : o;
23
+ status = typeof inner.status === 'string' ? inner.status : 'unknown';
24
+ }
25
+ else
26
+ status = 'unknown';
27
+ return { status, approved: isKycApproved(status) };
28
+ }
@@ -0,0 +1,157 @@
1
+ import { AppError, NoSessionError } from "./errors.js";
2
+ import { refreshCliToken } from "./device.js";
3
+ /**
4
+ * Money side — the Perfolio backend client (gold, portfolio, cash).
5
+ *
6
+ * Bearer auth with a `pfk_` device token; transparently refreshes once on a 401
7
+ * using the refresh token, then persists the rotated pair via onRefresh. Kept
8
+ * deliberately gold-focused per the product scope; adding borrow/earn/perps
9
+ * endpoints later is a one-line method each.
10
+ */
11
+ const REQUEST_TIMEOUT_MS = 30_000;
12
+ export class MoneyClient {
13
+ api;
14
+ fiat;
15
+ token;
16
+ refreshToken;
17
+ onRefresh;
18
+ f;
19
+ constructor(opts) {
20
+ this.api = opts.api;
21
+ this.fiat = opts.fiat;
22
+ this.token = opts.token;
23
+ this.refreshToken = opts.refreshToken;
24
+ this.onRefresh = opts.onRefresh;
25
+ this.f = opts.fetchImpl ?? fetch;
26
+ }
27
+ get connected() {
28
+ return Boolean(this.token);
29
+ }
30
+ headers(auth) {
31
+ const h = { "Content-Type": "application/json" };
32
+ if (auth && this.token)
33
+ h.Authorization = `Bearer ${this.token}`;
34
+ return h;
35
+ }
36
+ async fetchWithTimeout(url, init) {
37
+ const attempt = async () => {
38
+ const ctrl = new AbortController();
39
+ const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
40
+ try {
41
+ return await this.f(url, { ...init, signal: ctrl.signal });
42
+ }
43
+ finally {
44
+ clearTimeout(timer);
45
+ }
46
+ };
47
+ try {
48
+ return await attempt();
49
+ }
50
+ catch {
51
+ await new Promise((r) => setTimeout(r, 800));
52
+ return attempt();
53
+ }
54
+ }
55
+ async request(base, path, init, auth, retried = false) {
56
+ const res = await this.fetchWithTimeout(`${base}${path}`, {
57
+ ...init,
58
+ headers: this.headers(auth),
59
+ });
60
+ // Silent refresh: an expired pfk_ access token → rotate once and retry.
61
+ if (res.status === 401 &&
62
+ auth &&
63
+ !retried &&
64
+ this.refreshToken &&
65
+ this.token?.startsWith("pfk_")) {
66
+ try {
67
+ const t = await refreshCliToken(this.api, this.refreshToken, this.f);
68
+ this.token = t.token;
69
+ this.refreshToken = t.refreshToken;
70
+ this.onRefresh?.(t.token, t.refreshToken, t.expiresAt);
71
+ return this.request(base, path, init, auth, true);
72
+ }
73
+ catch {
74
+ /* fall through to the normal error */
75
+ }
76
+ }
77
+ let json = {};
78
+ try {
79
+ json = (await res.json());
80
+ }
81
+ catch {
82
+ /* non-JSON */
83
+ }
84
+ if (res.status === 428)
85
+ throw new NoSessionError();
86
+ if (!res.ok || json.success === false) {
87
+ throw new AppError(json.error || json.message || `HTTP ${res.status}`, {
88
+ status: res.status,
89
+ code: json.code ?? json.error,
90
+ details: json.details,
91
+ recoverable: res.status >= 500,
92
+ });
93
+ }
94
+ return json.data;
95
+ }
96
+ get(base, path, auth = true) {
97
+ return this.request(base, path, { method: "GET" }, auth);
98
+ }
99
+ post(base, path, body, auth = true) {
100
+ return this.request(base, path, { method: "POST", body: JSON.stringify(body) }, auth);
101
+ }
102
+ // ── reads ──
103
+ getUser() {
104
+ return this.get(this.api, "/user");
105
+ }
106
+ /** Portfolio summary; 'full' adds the multi-asset + perps breakdown. */
107
+ getPortfolio(view) {
108
+ const q = view === "full" ? "?view=full" : "";
109
+ return this.get(this.api, `/portfolio/summary${q}`);
110
+ }
111
+ getBalance() {
112
+ return this.get(this.api, "/balances/me");
113
+ }
114
+ getGoldPrice() {
115
+ return this.get(this.api, "/prices/gold", false);
116
+ }
117
+ getCryptoPrices() {
118
+ return this.get(this.api, "/prices/crypto", false);
119
+ }
120
+ /** Local units per 1 USD, for display-currency conversion. */
121
+ getFiatRate(currency) {
122
+ return this.get(this.api, `/prices/fiat/${encodeURIComponent(currency.toUpperCase())}`, false);
123
+ }
124
+ getTxHistory(limit = 20) {
125
+ return this.get(this.api, `/tx/history?limit=${limit}`);
126
+ }
127
+ getTxStatus(txHash) {
128
+ return this.get(this.api, `/tx/status/${txHash}`);
129
+ }
130
+ // ── gold buy / sell (session-key, agent-signed) ──
131
+ /** Swap cash → gold (buy) or gold → cash (sell), via the DeFi session. */
132
+ swap(b) {
133
+ return this.post(this.api, "/tx/swap", b);
134
+ }
135
+ // ── extra pools the /portfolio/summary endpoint doesn't include ──
136
+ // The combined `portfolio` view folds these in so a user sees their WHOLE net
137
+ // worth (prediction balance + open bets + staked/earn) from the terminal.
138
+ /** Prediction deposit-wallet address + deployed status. */
139
+ polymarketDepositWallet() {
140
+ return this.get(this.api, "/polymarket/deposit-wallet");
141
+ }
142
+ /** Open prediction positions + portfolio totals (incl. spendable cashUsd). */
143
+ polymarketPositions() {
144
+ return this.get(this.api, "/polymarket/positions");
145
+ }
146
+ /** The user's earn (vault) position for an asset symbol. */
147
+ getEarnPosition(asset) {
148
+ return this.get(this.api, `/protocol/vault-position?asset=${encodeURIComponent(asset)}`);
149
+ }
150
+ // ── fiat / account (money side) ──
151
+ kycStatus() {
152
+ return this.get(this.fiat, "/kyc/status");
153
+ }
154
+ getAccounts() {
155
+ return this.get(this.fiat, "/accounts");
156
+ }
157
+ }