@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
package/.env.example ADDED
@@ -0,0 +1,21 @@
1
+ # Grant Cash CLI — backend routing.
2
+ #
3
+ # Copy to `.env` (loaded from the PACKAGE ROOT only — never the cwd). Every value
4
+ # is optional; the defaults point at production. Set these to repoint at dev /
5
+ # staging without touching code. You do NOT need to know the final URLs to build
6
+ # the CLI — wire them here whenever they're ready.
7
+
8
+ # ── Money side (Perfolio backend — gold, portfolio, cash) ──
9
+ GRANTCASH_API_URL=https://api.perfolio.ai/api
10
+ GRANTCASH_FIAT_URL=https://api-fiat.perfolio.ai/api
11
+ GRANTCASH_APP_URL=https://app.perfolio.ai
12
+
13
+ # ── Agent side (Agent-mode backend — pay-per-use services, x402) ──
14
+ # Set this to wherever the agent-mode backend lives (placeholder default).
15
+ GRANTCASH_AGENT_URL=https://api-agent.perfolio.ai
16
+
17
+ # ── Misc ──
18
+ # Override the merged credentials file (default ~/.grant-cash/credentials.json).
19
+ # GRANTCASH_CREDS_FILE=/path/to/credentials.json
20
+ # Label the connecting client (shown to the user during login).
21
+ # GRANTCASH_CLIENT="Grant Cash CLI"
package/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # Grant Cash CLI (`grant`)
2
+
3
+ One CLI for your **money** (gold, via the Perfolio backend) and your **agent**
4
+ (pay-per-use services, via the Agent-mode backend). Two engines, one wallet, one
5
+ identity — behind a single plain-language surface. The user never sees which
6
+ backend answered.
7
+
8
+ Built on the Perfolio CLI's structure (Commander + modular `cli/commands` +
9
+ `lib/*`), merged with the agent CLI's LLM-friendly output and verb set.
10
+
11
+ ## Quick start
12
+
13
+ ```bash
14
+ npm install
15
+ cp .env.example .env # set the backend URLs (optional — prod defaults shipped)
16
+ npm run dev -- status # run from source
17
+ npm run build && ./dist/cli/index.js status
18
+ ```
19
+
20
+ ## How routing works
21
+
22
+ | Surface | Routes to | Auth |
23
+ |---|---|---|
24
+ | money: `portfolio`, `price`, `buy`, `sell`, `activity` | Perfolio backend (`GRANTCASH_API_URL`) | Bearer device token (`pfk_`) |
25
+ | agent: `search`, `check`, `fetch`, `transfer`, `revoke` | Agent-mode backend (`GRANTCASH_AGENT_URL`) | `X-API-Key` (`grant_live_`) |
26
+
27
+ `grant login` runs the money-side device handshake (browser sign-in + limit
28
+ approval) and links the agent key in one step. Credentials for both live in one
29
+ file (`~/.grant-cash/credentials.json`).
30
+
31
+ ## Configuration
32
+
33
+ All URLs are env-driven — see [`.env.example`](.env.example). You do **not** need
34
+ final URLs to build; set them whenever they're ready.
35
+
36
+ ## Skill file
37
+
38
+ [`skills/grant-cash/SKILL.md`](skills/grant-cash/SKILL.md) documents the full
39
+ command surface, workflow, and error playbook for an AI assistant.
40
+
41
+ ## Status
42
+
43
+ Scaffold of the merged architecture with a working command set across both
44
+ engines. Money side is gold-focused by design; borrow/earn/perps endpoints are a
45
+ one-method-each extension on `MoneyClient`. The unified `login` links the agent
46
+ key via `--agent-key`/`GRANTCASH_AGENT_KEY` today; once the backends share one
47
+ connect (the planned `device/complete` carrying the partner key), a single poll
48
+ will return both credentials and only that step changes.
@@ -0,0 +1,139 @@
1
+ import { buildContext, emit, ui, usdcMinor, NotConnectedError } from "../../lib/index.js";
2
+ /**
3
+ * Agent side — pay-per-use services routed to the Agent-mode backend. Every
4
+ * spend is owned by the session (delegated, revocable, guardrailed); the CLI
5
+ * carries only the API key. Verbs mirror the agent CLI so a model that knows it
6
+ * drives this with no relearning: search → check → fetch.
7
+ */
8
+ export function registerAgent(program) {
9
+ program
10
+ .command("search <query>")
11
+ .alias("marketplace")
12
+ .description("Find a pay-per-use service the agent can use")
13
+ .option("--limit <n>", "max results", "10")
14
+ .action(async (query, opts, cmd) => {
15
+ const ctx = buildContext(cmd);
16
+ if (!ctx.agent.connected)
17
+ throw new NotConnectedError("agent");
18
+ const data = await ctx.agent.search(query, Math.max(1, Number(opts.limit) || 10));
19
+ emit(ctx, { results: data }, () => `${ui.title("Services")} ${ui.dim("(use --json for details)")}`);
20
+ });
21
+ program
22
+ .command("check <url>")
23
+ .description("See the exact contract for a service before paying (no charge)")
24
+ .action(async (url, _opts, cmd) => {
25
+ const ctx = buildContext(cmd);
26
+ if (!ctx.agent.connected)
27
+ throw new NotConnectedError("agent");
28
+ const data = await ctx.agent.check(url);
29
+ emit(ctx, { contract: data }, () => `${ui.title("Contract")} ${ui.dim("(use --json for the schema)")}`);
30
+ });
31
+ program
32
+ .command("fetch <url>")
33
+ .alias("pay")
34
+ .description("Pay for and run a service")
35
+ .option("-m, --method <method>", "HTTP method (GET|POST|PUT|DELETE|PATCH)")
36
+ .option("-b, --body <json>", "request body, passed through verbatim")
37
+ .option("--price <minor>", "spend cap in USDC minor units (inferred from the service if omitted)")
38
+ .option("--asset <asset>", "settlement asset (USDC|USDT)", "USDC")
39
+ .action(async (url, opts, cmd) => {
40
+ const ctx = buildContext(cmd);
41
+ if (!ctx.agent.connected)
42
+ throw new NotConnectedError("agent");
43
+ // Resolve the spend cap, method, and rail from the same free `check`
44
+ // endpoint, so a model can call `fetch` without first running `check` and
45
+ // we never POST without the backend-required `expectedPriceMinor`. An
46
+ // explicit --price still wins; an explicit --method still wins.
47
+ let method = (opts.method ? String(opts.method) : "GET").toUpperCase();
48
+ const asset = String(opts.asset || "USDC").toUpperCase();
49
+ let price = opts.price;
50
+ try {
51
+ const chk = (await ctx.agent.check(url));
52
+ if (chk?.found) {
53
+ if (!opts.method && typeof chk.method === "string")
54
+ method = chk.method.toUpperCase();
55
+ if (!price && chk.priceMinor != null)
56
+ price = String(chk.priceMinor);
57
+ }
58
+ }
59
+ catch {
60
+ // check unreachable — with an explicit --price we still pay; else fail below.
61
+ }
62
+ if (!price)
63
+ throw new Error("Couldn't determine the price — run `grant check <url>` or pass --price <USDC minor units>.");
64
+ const data = await ctx.agent.fetchPay({
65
+ url,
66
+ method,
67
+ expectedPriceMinor: price,
68
+ asset,
69
+ // Raw string passthrough — the backend forwards it verbatim. We do NOT
70
+ // JSON.parse: the x402/pay schema expects `body` as a string.
71
+ ...(opts.body ? { body: String(opts.body) } : {}),
72
+ });
73
+ emit(ctx, { result: data }, () => ui.green("✓ Done."));
74
+ });
75
+ program
76
+ .command("discover <origin>")
77
+ .alias("add")
78
+ .description("Ingest any x402 service origin so the agent can find + pay its endpoints")
79
+ .action(async (origin, _opts, cmd) => {
80
+ const ctx = buildContext(cmd);
81
+ if (!ctx.agent.connected)
82
+ throw new NotConnectedError("agent");
83
+ const data = await ctx.agent.discover(origin);
84
+ emit(ctx, { result: data }, () => ui.green(`✓ Ingested ${origin}. Find its services with \`grant search\`.`));
85
+ });
86
+ program
87
+ .command("schema <slug>")
88
+ .description("See the exact pay contract for a catalog service by slug (no charge)")
89
+ .action(async (slug, _opts, cmd) => {
90
+ const ctx = buildContext(cmd);
91
+ if (!ctx.agent.connected)
92
+ throw new NotConnectedError("agent");
93
+ const data = await ctx.agent.schema(slug);
94
+ emit(ctx, { schema: data }, () => `${ui.title("Contract")} ${ui.dim("(use --json for the schema)")}`);
95
+ });
96
+ program
97
+ .command("redeem <code>")
98
+ .description("Claim an invite or bonus credit for your agent spending")
99
+ .action(async (code, _opts, cmd) => {
100
+ const ctx = buildContext(cmd);
101
+ if (!ctx.agent.connected)
102
+ throw new NotConnectedError("agent");
103
+ const data = await ctx.agent.redeem(code);
104
+ emit(ctx, { result: data }, () => ui.green("✓ Redeemed. Check your spending balance with `grant portfolio`."));
105
+ });
106
+ program
107
+ .command("upgrade")
108
+ .description("Get a one-time link to secure a full account and add spending funds")
109
+ .action(async (_opts, cmd) => {
110
+ const ctx = buildContext(cmd);
111
+ if (!ctx.agent.connected)
112
+ throw new NotConnectedError("agent");
113
+ const data = await ctx.agent.linkCode();
114
+ emit(ctx, data, () => `${ui.title("Secure your account")}\n Open this link (works once, expires in ~15 min):\n\n ${ui.green(data.url)}\n code: ${data.code}`);
115
+ });
116
+ program
117
+ .command("transfer <recipient> <amount>")
118
+ .description("Send money to an address (amount in USDC minor units)")
119
+ .option("--asset <asset>", "USDC|USDT", "USDC")
120
+ .action(async (recipient, amount, opts, cmd) => {
121
+ const ctx = buildContext(cmd);
122
+ if (!ctx.agent.connected)
123
+ throw new NotConnectedError("agent");
124
+ const asset = String(opts.asset || "USDC").toUpperCase();
125
+ const data = await ctx.agent.transfer({ recipient, amount, asset });
126
+ emit(ctx, { result: data }, () => ui.green(`✓ Sent ${usdcMinor(amount)} ${asset} to ${recipient}.`));
127
+ });
128
+ program
129
+ .command("revoke")
130
+ .alias("stop")
131
+ .description("Stop all agent spending immediately (revoke the session)")
132
+ .action(async (_opts, cmd) => {
133
+ const ctx = buildContext(cmd);
134
+ if (!ctx.agent.connected)
135
+ throw new NotConnectedError("agent");
136
+ const data = await ctx.agent.revoke();
137
+ emit(ctx, { revoked: true, result: data }, () => ui.red("■ Stopped. Your agent can no longer spend until you reconnect."));
138
+ });
139
+ }
@@ -0,0 +1,248 @@
1
+ import { buildContext, emit, ui, startDevice, waitForDevice, moneyPollIntervalMs, revokeDevice, listDevices, detectClient, openBrowser, clearCredentials, AppError, generatePkce, startAgentDevice, waitForAgentDevice, agentPollIntervalMs, } from "../../lib/index.js";
2
+ /**
3
+ * `grant login` — one connect for both engines.
4
+ *
5
+ * Runs TWO device handshakes in a single pass so the user thinks about one
6
+ * connect, not two backends:
7
+ * - MONEY side (Perfolio): `cli/device/*`, sid relayed to the browser via the
8
+ * connect URL, mints a `pfk_` Bearer token.
9
+ * - AGENT side (Agent-mode): PKCE S256 `onboard/privy-cli/device/*`, its sid
10
+ * relayed alongside as `&agentSid=<sid>`, mints a `grant_live_` X-API-Key.
11
+ *
12
+ * The agent sid is generated first so it can be carried on the SAME browser link
13
+ * the user opens; the one logged-in browser session completes both backends. We
14
+ * then poll each backend independently (each with its own secret) and write BOTH
15
+ * credentials to the existing merged file. An explicit `--agent-key`/env key
16
+ * still short-circuits the agent handshake (manual linking).
17
+ *
18
+ * Partial-failure is never silent: if money connects but spending stays
19
+ * pending/expired we report "investing connected, spending pending" plus a retry
20
+ * hint. A `complete` with no apiKey = already provisioned → keep the existing key.
21
+ */
22
+ function maskKey(k) {
23
+ if (!k)
24
+ return "(none)";
25
+ return k.length <= 12 ? "****" : `${k.slice(0, 9)}…${k.slice(-4)}`;
26
+ }
27
+ export function registerAuth(program) {
28
+ program
29
+ .command("login")
30
+ .description("Connect Grant Cash — your money and your agent, in one step")
31
+ .option("--no-open", "do not auto-open the browser; just print the URL")
32
+ .option("--agent-key <key>", "link your agent key (grant_live_…) in the same step")
33
+ .option("--timeout <seconds>", "how long to wait for the browser", "300")
34
+ .action(async (opts, cmd) => {
35
+ const ctx = buildContext(cmd);
36
+ const client = detectClient();
37
+ const timeoutMs = Math.max(30, Number(opts.timeout) || 300) * 1000;
38
+ // An explicit key (flag/env) means "link this, skip the agent handshake".
39
+ const manualAgentKey = opts.agentKey || process.env.GRANTCASH_AGENT_KEY;
40
+ const runAgentFlow = !manualAgentKey;
41
+ // ── start BOTH handshakes (agent first so its sid rides the same link) ──
42
+ const start = await startDevice(ctx.urls.api, client, client);
43
+ // Agent side is best-effort to start: if the agent backend is unreachable
44
+ // we still let the money connect succeed and report spending as pending.
45
+ let agentPkce;
46
+ let agentSid;
47
+ let agentPollMs = 3000;
48
+ let agentStartError;
49
+ if (runAgentFlow) {
50
+ agentPkce = generatePkce();
51
+ try {
52
+ const aStart = await startAgentDevice(ctx.urls.agent, agentPkce, client);
53
+ agentSid = aStart.sid;
54
+ agentPollMs = agentPollIntervalMs(aStart.pollInterval);
55
+ }
56
+ catch (e) {
57
+ agentStartError = e;
58
+ }
59
+ }
60
+ // Open the Grant Cash connect screen (GRANTCASH_APP_URL) carrying the
61
+ // one-time money sid — NOT the backend's own URL — so sign-in happens in
62
+ // the Grant Cash UI. The agent sid rides the SAME link as `&agentSid` so a
63
+ // single logged-in browser completes both backends. The sids are the only
64
+ // secrets; minting still requires a logged-in browser (and, for the agent,
65
+ // the PKCE verifier held only in this process), so the link alone is useless.
66
+ let connectUrl = `${ctx.urls.app.replace(/\/$/, "")}/connect?sid=${encodeURIComponent(start.sid)}&client=${encodeURIComponent(client)}`;
67
+ if (agentSid)
68
+ connectUrl += `&agentSid=${encodeURIComponent(agentSid)}`;
69
+ if (!ctx.json) {
70
+ process.stdout.write(`\n${ui.title("Connect Grant Cash")}\n` +
71
+ `Open this link to sign in and approve your limits:\n\n ${ui.green(connectUrl)}\n\n` +
72
+ ui.dim("Waiting for you to finish in the browser…") +
73
+ "\n");
74
+ }
75
+ if (opts.open !== false)
76
+ openBrowser(connectUrl);
77
+ // ── poll BOTH backends (each with its own secret) ──
78
+ // Money is the primary identity; the agent (spending) side is best-effort.
79
+ // Both polls run concurrently, but we SETTLE on money: the moment it
80
+ // connects we stop blocking on the agent poll (a slow/cold spending backend
81
+ // or a stale agent sid must never leave the terminal "still polling" after
82
+ // the browser has finished). The browser completes the agent device in
83
+ // step 1 — BEFORE it mints money in step 3 — so the agent key is almost
84
+ // always already waiting by the time money lands; a short grace collects it.
85
+ const moneyP = waitForDevice(ctx.urls.api, start.sid, moneyPollIntervalMs(start.pollInterval), timeoutMs);
86
+ const agentAbort = new AbortController();
87
+ const agentP = agentSid && agentPkce
88
+ ? waitForAgentDevice(ctx.urls.agent, agentSid, agentPkce.verifier, agentPollMs, timeoutMs, fetch, agentAbort.signal)
89
+ : Promise.resolve({ status: "expired" });
90
+ const result = await moneyP;
91
+ // Money is the primary identity — if it didn't complete, the whole login
92
+ // failed (mirrors the original behavior). Abort the agent poll first so the
93
+ // process can exit promptly instead of running to the agent timeout.
94
+ if (result.status !== "complete") {
95
+ agentAbort.abort();
96
+ throw new AppError(result.status === "denied"
97
+ ? "Connection was declined in the browser."
98
+ : "Connection timed out. Run `grant login` again.", { code: result.status === "denied" ? "DENIED" : "EXPIRED", recoverable: true });
99
+ }
100
+ // Money connected — settle now. Give the agent poll a short grace to also
101
+ // land, then stop regardless and report whatever we have (a grace timeout
102
+ // → "spending pending", surfaced with a retry hint below). Aborting after
103
+ // the race stops the background loop so the CLI exits without hanging.
104
+ const AGENT_SETTLE_GRACE_MS = 15_000;
105
+ const agentResult = await Promise.race([
106
+ agentP.catch(() => ({ status: "pending" })),
107
+ new Promise((res) => setTimeout(() => res({ status: "pending" }), AGENT_SETTLE_GRACE_MS)),
108
+ ]);
109
+ agentAbort.abort();
110
+ // ── resolve the agent credential ──
111
+ // Precedence: explicit manual key > freshly minted key > already-provisioned
112
+ // (complete without apiKey → keep the key already on disk).
113
+ const existingAgentKey = ctx.creds.agent?.apiKey;
114
+ let agentKey = manualAgentKey;
115
+ let agentState;
116
+ if (manualAgentKey) {
117
+ agentState = "linked";
118
+ }
119
+ else if (agentResult.status === "complete") {
120
+ if (agentResult.apiKey) {
121
+ agentKey = agentResult.apiKey; // one-time pickup of the new key
122
+ agentState = "minted";
123
+ }
124
+ else {
125
+ agentKey = existingAgentKey; // already provisioned — keep what we have
126
+ agentState = "already";
127
+ }
128
+ }
129
+ else {
130
+ agentKey = existingAgentKey; // never clobber an existing key on a miss
131
+ agentState = agentResult.status === "expired" ? "expired" : "pending";
132
+ }
133
+ const spendingConnected = Boolean(agentKey);
134
+ const next = {
135
+ ...ctx.creds,
136
+ money: {
137
+ token: result.result.token,
138
+ refreshToken: result.result.refreshToken,
139
+ expiresAt: result.result.expiresAt,
140
+ email: result.result.email,
141
+ walletAddress: result.result.walletAddress,
142
+ deviceId: result.result.deviceId,
143
+ },
144
+ ...(agentKey ? { agent: { apiKey: agentKey } } : {}),
145
+ };
146
+ ctx.save(next);
147
+ // Retry hint for the spending side when it didn't land this pass.
148
+ const retryHint = agentStartError
149
+ ? "Run `grant login` again to finish spending setup (the spending backend was unreachable)."
150
+ : "Run `grant login` again to finish spending setup.";
151
+ emit(ctx, {
152
+ connected: true,
153
+ money: { email: result.result.email, wallet: result.result.walletAddress },
154
+ agent: {
155
+ connected: spendingConnected,
156
+ state: agentState,
157
+ ...(agentState === "pending" || agentState === "expired"
158
+ ? { hint: retryHint }
159
+ : {}),
160
+ },
161
+ }, () => {
162
+ const moneyLine = `\n Money: ${result.result.email || result.result.walletAddress}`;
163
+ let agentLine;
164
+ switch (agentState) {
165
+ case "linked":
166
+ agentLine = `\n Agent: ${ui.green("linked")}`;
167
+ break;
168
+ case "minted":
169
+ agentLine = `\n Agent: ${ui.green("connected")}`;
170
+ break;
171
+ case "already":
172
+ agentLine = `\n Agent: ${ui.green("connected (already set up)")}`;
173
+ break;
174
+ default:
175
+ agentLine =
176
+ `\n Agent: ${ui.amber("spending pending")}` +
177
+ `\n${ui.dim(" " + retryHint)}`;
178
+ }
179
+ const header = spendingConnected || agentState === "linked"
180
+ ? ui.green("✓ Connected.")
181
+ : ui.green("✓ Investing connected.") +
182
+ ui.amber(" Spending still pending.");
183
+ return `\n${header}${moneyLine}${agentLine}\n`;
184
+ });
185
+ });
186
+ program
187
+ .command("logout")
188
+ .description("Disconnect — revoke this device and clear stored credentials")
189
+ .action(async (_opts, cmd) => {
190
+ const ctx = buildContext(cmd);
191
+ if (ctx.creds.money?.token) {
192
+ await revokeDevice(ctx.urls.api, ctx.creds.money.token, ctx.creds.money.deviceId);
193
+ }
194
+ clearCredentials(ctx.credsPath);
195
+ emit(ctx, { disconnected: true }, () => ui.dim("Disconnected. Credentials cleared."));
196
+ });
197
+ program
198
+ .command("status")
199
+ .alias("whoami")
200
+ .description("Show what's connected (money + agent) and where it routes")
201
+ .action(async (_opts, cmd) => {
202
+ const ctx = buildContext(cmd);
203
+ const money = ctx.creds.money;
204
+ const agent = ctx.creds.agent;
205
+ emit(ctx, {
206
+ money: {
207
+ connected: Boolean(money?.token),
208
+ email: money?.email,
209
+ wallet: money?.walletAddress,
210
+ },
211
+ agent: { connected: Boolean(agent?.apiKey), key: maskKey(agent?.apiKey) },
212
+ routes: { api: ctx.urls.api, fiat: ctx.urls.fiat, agent: ctx.urls.agent },
213
+ }, () => `${ui.title("Grant Cash")}\n` +
214
+ ` Money: ${money?.token ? ui.green(money.email || money.walletAddress || "connected") : ui.amber("not connected")}\n` +
215
+ ` Agent: ${agent?.apiKey ? ui.green(maskKey(agent.apiKey)) : ui.amber("not connected")}\n` +
216
+ ui.dim(` → money ${ctx.urls.api}\n → agent ${ctx.urls.agent}`));
217
+ });
218
+ // ── manage connected agents/devices (money side) ──
219
+ const devices = program.command("devices").description("Manage connected agents/devices");
220
+ devices
221
+ .command("list")
222
+ .description("List devices connected to your account")
223
+ .action(async (_opts, cmd) => {
224
+ const ctx = buildContext(cmd);
225
+ const token = ctx.creds.money?.token;
226
+ if (!token)
227
+ throw new AppError("Not connected. Run `grant login` first.", { code: "NOT_CONNECTED" });
228
+ const list = await listDevices(ctx.urls.api, token);
229
+ emit(ctx, { devices: list }, () => {
230
+ if (!list.length)
231
+ return ui.dim("No devices connected.");
232
+ const rows = list.map((d) => ` ${d.deviceId.slice(0, 10).padEnd(11)} ${(d.clientName || d.deviceName || "—").padEnd(20)} ` +
233
+ ui.dim(`last used ${d.lastUsedAt ? new Date(d.lastUsedAt).toISOString().slice(0, 10) : "—"}`));
234
+ return `${ui.title("Connected devices")}\n${rows.join("\n")}`;
235
+ });
236
+ });
237
+ devices
238
+ .command("revoke <deviceId>")
239
+ .description("Revoke a connected device by id")
240
+ .action(async (deviceId, _opts, cmd) => {
241
+ const ctx = buildContext(cmd);
242
+ const token = ctx.creds.money?.token;
243
+ if (!token)
244
+ throw new AppError("Not connected. Run `grant login` first.", { code: "NOT_CONNECTED" });
245
+ await revokeDevice(ctx.urls.api, token, deviceId);
246
+ emit(ctx, { revoked: true, deviceId }, () => ui.green(`✓ Revoked device ${deviceId}.`));
247
+ });
248
+ }
@@ -0,0 +1,77 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { buildContext, emit, ui } from "../../lib/index.js";
3
+ /** The agent-facing workflow + error playbook, so a model can self-orient. */
4
+ const WORKFLOW = [
5
+ "status — confirm what's connected (money + agent) before acting.",
6
+ "portfolio — combined worth: gold, cash, bitcoin, ethereum, perps, predictions, earn + agent spending money.",
7
+ "prices / price — live prices. assets / markets — what you can hold / borrow against.",
8
+ "buy <asset> --amount <cash> / sell <asset> --for <cash> / convert — trade gold, cash, bitcoin, ethereum. quote — preview.",
9
+ "borrow / repay / add-collateral / remove-collateral / leverage / close — cash borrowed against an asset.",
10
+ "earn list / position / deposit / withdraw — yield on cash & crypto via vaults.",
11
+ "hl … — Hyperliquid perps (markets, status, open, close, leverage, tpsl, deposit, withdraw).",
12
+ "polymarket markets / bet / cashout / redeem / wallet / provision / deposit / withdraw / positions / history — prediction markets.",
13
+ "kyc / fiat / withdraw / gift / beneficiaries / settings — identity, cash-out, gifting, preferences.",
14
+ "search <q> — find a pay-per-use service. discover <origin> — ingest a new x402 origin. check <url> / schema <slug> — see exact contract (no charge).",
15
+ "fetch <url> -b '<json>' — pay for and run a service. transfer <addr> <minor> — send money. redeem <code> — claim credit. upgrade — secure full account.",
16
+ "devices list / devices revoke <id> — manage connected agents.",
17
+ "activity — one feed of money moves + live-service spend. tx status / history — on-chain tx state.",
18
+ "revoke — stop ALL agent spend immediately (terminal).",
19
+ ];
20
+ const ERROR_CODES = [
21
+ { code: "NOT_CONNECTED", recoverable: false, action: "Run `grant login`." },
22
+ { code: "NO_SESSION", recoverable: false, action: "No spending permission set — `grant login` and approve limits." },
23
+ { code: "GUARDRAIL_DENIED", recoverable: false, action: "STOP. The user's caps forbid this — don't retry. Tell the user." },
24
+ { code: "SIGNER_REVOKED", recoverable: false, action: "STOP. The session was revoked — re-authorize via `grant login`." },
25
+ { code: "SCHEMA_VALIDATION_FAILED", recoverable: true, action: "Free reject. Run `grant check <url>` and rebuild --body, then retry." },
26
+ { code: "BACKEND_UNREACHABLE", recoverable: false, action: "Wrong/dead base URL — check GRANTCASH_* env / `grant config show`." },
27
+ { code: "NETWORK", recoverable: true, action: "Transient — retry once." },
28
+ ];
29
+ export function registerMeta(program) {
30
+ program
31
+ .command("instructions")
32
+ .description("How an agent should drive this CLI (workflow + error playbook)")
33
+ .action((_opts, cmd) => {
34
+ const ctx = buildContext(cmd);
35
+ emit(ctx, { workflow: WORKFLOW, errorCodes: ERROR_CODES }, () => `${ui.title("Grant Cash — agent workflow")}\n` +
36
+ WORKFLOW.map((w, i) => ` ${i + 1}. ${w}`).join("\n") +
37
+ `\n\n${ui.title("Error playbook")}\n` +
38
+ ERROR_CODES.map((e) => ` ${e.code} ${ui.dim(e.recoverable ? "(retry ok)" : "(terminal)")} — ${e.action}`).join("\n"));
39
+ });
40
+ const config = program.command("config").description("Inspect or set CLI configuration");
41
+ config
42
+ .command("show")
43
+ .description("Show resolved routes and connection state")
44
+ .action((_opts, cmd) => {
45
+ const ctx = buildContext(cmd);
46
+ const key = ctx.creds.agent?.apiKey;
47
+ emit(ctx, {
48
+ routes: ctx.urls,
49
+ money: { connected: Boolean(ctx.creds.money?.token) },
50
+ agent: { connected: Boolean(key) },
51
+ credsPath: ctx.credsPath,
52
+ }, () => `${ui.title("Routes")}\n` +
53
+ ` money api ${ctx.urls.api}\n` +
54
+ ` money fiat ${ctx.urls.fiat}\n` +
55
+ ` agent ${ctx.urls.agent}\n` +
56
+ ui.dim(` creds ${ctx.credsPath}`));
57
+ });
58
+ config
59
+ .command("set-agent-key <key>")
60
+ .description("Link your agent key (grant_live_…)")
61
+ .action((key, _opts, cmd) => {
62
+ const ctx = buildContext(cmd);
63
+ const next = { ...ctx.creds, agent: { apiKey: key } };
64
+ ctx.save(next);
65
+ emit(ctx, { linked: true }, () => ui.green("✓ Agent key linked."));
66
+ });
67
+ program
68
+ .command("install")
69
+ .description("Where to find the skill file + how to connect an assistant")
70
+ .action((_opts, cmd) => {
71
+ const ctx = buildContext(cmd);
72
+ const skill = fileURLToPath(new URL("../../../skills/grant-cash/SKILL.md", import.meta.url));
73
+ emit(ctx, { skillFile: skill }, () => `${ui.title("Connect an assistant")}\n` +
74
+ ` Skill file: ${skill}\n` +
75
+ ui.dim(" Point your assistant at the skill file, or use the Grant Cash MCP."));
76
+ });
77
+ }
@@ -0,0 +1,85 @@
1
+ import { buildContext, emit, ui, usdcMinor, } from "../../lib/index.js";
2
+ /**
3
+ * Money side — gold price + the combined activity feed.
4
+ *
5
+ * Buying/selling moved to the generalized multi-asset `trade` commands
6
+ * (`grant buy <asset>` / `grant sell <asset>` / `grant convert`), ported from
7
+ * the standalone Perfolio CLI so the merged CLI trades gold, bitcoin, ethereum
8
+ * and cash — not just gold. `price` (today's gold price) and `activity` (the
9
+ * unified money + spending feed) stay here.
10
+ */
11
+ /** A compact one-line render for a money (Perfolio) tx-history row. Defensive about shape. */
12
+ function moneyRow(raw) {
13
+ const t = (raw ?? {});
14
+ const date = typeof t.createdAt === "string" ? t.createdAt.slice(0, 10) : "—";
15
+ const type = t.type ?? "tx";
16
+ const status = t.status ?? "—";
17
+ const meta = t.metadata ?? {};
18
+ const detail = [meta.sellToken, meta.buyToken].filter(Boolean).join("→") || t.txHash || "";
19
+ return ` ${date} ${type.padEnd(10)} ${status.padEnd(11)} ${detail}`;
20
+ }
21
+ /** A compact one-line render for an agent (spending) tx row. Defensive about shape. */
22
+ function agentRow(raw) {
23
+ const t = (raw ?? {});
24
+ const ts = typeof t.createdAt === "number" ? new Date(t.createdAt).toISOString().slice(0, 10) : "—";
25
+ const amount = typeof t.amount === "string" ? t.amount : usdcMinor(t.amount);
26
+ const asset = t.asset ?? "USDC";
27
+ const status = t.status ?? "—";
28
+ const who = t.vendorSlug ?? t.recipient ?? "";
29
+ return ` ${ts} ${String(amount).padStart(8)} ${asset} ${status.padEnd(11)} ${who}`;
30
+ }
31
+ function asArray(v, key) {
32
+ if (Array.isArray(v))
33
+ return v;
34
+ const obj = (v ?? {});
35
+ return Array.isArray(obj[key]) ? obj[key] : [];
36
+ }
37
+ export function registerMoney(program) {
38
+ program
39
+ .command("price")
40
+ .alias("gold")
41
+ .description("Today's gold price (see `grant prices` for every asset)")
42
+ .action(async (_opts, cmd) => {
43
+ const ctx = buildContext(cmd);
44
+ const data = await ctx.money.getGoldPrice();
45
+ emit(ctx, { gold: data }, () => {
46
+ const p = data.price ?? data.usd;
47
+ return `${ui.title("Gold")} ${p != null ? ui.green(`$${p}`) : JSON.stringify(data)}`;
48
+ });
49
+ });
50
+ program
51
+ .command("activity")
52
+ .alias("history")
53
+ .description("One feed — money moves and your agent's live work")
54
+ .option("--limit <n>", "how many items", "20")
55
+ .action(async (opts, cmd) => {
56
+ const ctx = buildContext(cmd);
57
+ const limit = Math.max(1, Number(opts.limit) || 20);
58
+ const [moneyR, agentR] = await Promise.allSettled([
59
+ ctx.money.connected ? ctx.money.getTxHistory(limit) : Promise.resolve(null),
60
+ ctx.agent.connected ? ctx.agent.transactions(limit) : Promise.resolve(null),
61
+ ]);
62
+ const money = moneyR.status === "fulfilled" ? moneyR.value : null;
63
+ const agent = agentR.status === "fulfilled" ? agentR.value : null;
64
+ emit(ctx, { money, agent }, () => {
65
+ if (!ctx.money.connected && !ctx.agent.connected)
66
+ return "Not connected yet. Run `grant login`.";
67
+ const lines = [ui.title("Activity")];
68
+ const moneyRows = asArray(money, "data");
69
+ const agentRows = asArray(agent, "transactions");
70
+ if (moneyRows.length) {
71
+ lines.push(ui.dim(" Money"));
72
+ for (const r of moneyRows)
73
+ lines.push(moneyRow(r));
74
+ }
75
+ if (agentRows.length) {
76
+ lines.push(ui.dim(" Spending"));
77
+ for (const r of agentRows)
78
+ lines.push(agentRow(r));
79
+ }
80
+ if (!moneyRows.length && !agentRows.length)
81
+ lines.push(ui.dim(" Nothing yet."));
82
+ return lines.join("\n");
83
+ });
84
+ });
85
+ }