@agentgrant.cash/cli 1.4.1 → 1.4.3
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/README.md +9 -9
- package/dist/cli/commands/auth.js +55 -172
- package/dist/cli/commands/meta.js +4 -12
- package/dist/cli/money-helpers.js +11 -7
- package/dist/lib/agent-client.js +22 -10
- package/dist/lib/auth-session.js +81 -0
- package/dist/lib/client.js +13 -26
- package/dist/lib/context.js +9 -10
- package/dist/lib/index.js +1 -1
- package/dist/lib/money-client.js +18 -31
- package/dist/lib/types.js +6 -5
- package/package.json +1 -1
- package/skills/grant-cash/SKILL.md +19 -1
- package/dist/lib/agent-device.js +0 -173
package/README.md
CHANGED
|
@@ -21,12 +21,13 @@ npm run build && ./dist/cli/index.js status
|
|
|
21
21
|
|
|
22
22
|
| Surface | Routes to | Auth |
|
|
23
23
|
|---|---|---|
|
|
24
|
-
| money: `portfolio`, `price`, `buy`, `sell`, `activity` | Perfolio backend (`GRANTCASH_API_URL`) | Bearer
|
|
25
|
-
| agent: `search`, `check`, `fetch`, `transfer`, `revoke` | Agent-mode backend (`GRANTCASH_AGENT_URL`) | `
|
|
24
|
+
| money: `portfolio`, `price`, `buy`, `sell`, `activity` | Perfolio backend (`GRANTCASH_API_URL`) | `Authorization: Bearer <accessJwt>` |
|
|
25
|
+
| agent: `search`, `check`, `fetch`, `transfer`, `revoke` | Agent-mode backend (`GRANTCASH_AGENT_URL`) | `Authorization: Bearer <accessJwt>` |
|
|
26
26
|
|
|
27
|
-
`grant login` runs
|
|
28
|
-
approval) and
|
|
29
|
-
|
|
27
|
+
`grant login` runs ONE perfolio device handshake (browser sign-in + limit
|
|
28
|
+
approval) and stores a single credential — the perfolio-signed access JWT plus
|
|
29
|
+
the opaque `pfr_` refresh token, in `~/.grant-cash/credentials.json`. That one
|
|
30
|
+
JWT is sent to BOTH backends; a 401 from either silently refreshes it once.
|
|
30
31
|
|
|
31
32
|
## Configuration
|
|
32
33
|
|
|
@@ -42,7 +43,6 @@ command surface, workflow, and error playbook for an AI assistant.
|
|
|
42
43
|
|
|
43
44
|
Scaffold of the merged architecture with a working command set across both
|
|
44
45
|
engines. Money side is gold-focused by design; borrow/earn/perps endpoints are a
|
|
45
|
-
one-method-each extension on `MoneyClient`.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
will return both credentials and only that step changes.
|
|
46
|
+
one-method-each extension on `MoneyClient`. Unified auth (CONTRACT v1): one
|
|
47
|
+
perfolio login mints one access JWT that both backends verify locally against
|
|
48
|
+
perfolio's JWKS — the CLI holds a single credential and sends it everywhere.
|
|
@@ -1,71 +1,49 @@
|
|
|
1
|
-
import { buildContext, emit, ui, startDevice, waitForDevice, moneyPollIntervalMs, revokeDevice, listDevices, detectClient, openBrowser, clearCredentials, AppError,
|
|
1
|
+
import { buildContext, emit, ui, startDevice, waitForDevice, moneyPollIntervalMs, revokeDevice, listDevices, detectClient, openBrowser, clearCredentials, AppError, } from "../../lib/index.js";
|
|
2
2
|
/**
|
|
3
|
-
* `grant login` — one connect
|
|
3
|
+
* `grant login` — one connect, one credential (unified auth).
|
|
4
4
|
*
|
|
5
|
-
* Runs
|
|
6
|
-
* connect
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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.
|
|
5
|
+
* Runs a SINGLE perfolio device handshake: `cli/device/*`, the one-time `sid`
|
|
6
|
+
* relayed to the browser via the connect URL. The logged-in browser completes it
|
|
7
|
+
* server-side; the CLI polls until perfolio returns the credential. The result is
|
|
8
|
+
* the perfolio-signed access JWT (`accessJwt`) plus the opaque `pfr_` refresh
|
|
9
|
+
* token — that ONE JWT is sent as `Authorization: Bearer` to BOTH backends
|
|
10
|
+
* (money + agent). The old agent-side `grant_live_` PKCE leg is retired: there is
|
|
11
|
+
* no second handshake, no `--agent-key`, no separate spending credential.
|
|
21
12
|
*/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Map a completed perfolio device flow to the stored credential. A fresh login
|
|
15
|
+
* REPLACES the whole credentials file — no stale agent key is carried over (the
|
|
16
|
+
* `grant_live_` leg is gone) — so the file holds exactly one perfolio credential.
|
|
17
|
+
*/
|
|
18
|
+
export function loginCredentials(result) {
|
|
19
|
+
return {
|
|
20
|
+
money: {
|
|
21
|
+
accessJwt: result.accessJwt,
|
|
22
|
+
refreshToken: result.refreshToken,
|
|
23
|
+
expiresAt: result.expiresAt,
|
|
24
|
+
email: result.email,
|
|
25
|
+
walletAddress: result.walletAddress,
|
|
26
|
+
deviceId: result.deviceId,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
26
29
|
}
|
|
27
30
|
export function registerAuth(program) {
|
|
28
31
|
program
|
|
29
32
|
.command("login")
|
|
30
33
|
.description("Connect Grant Cash — your money and your agent, in one step")
|
|
31
34
|
.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
35
|
.option("--timeout <seconds>", "how long to wait for the browser", "300")
|
|
34
36
|
.action(async (opts, cmd) => {
|
|
35
37
|
const ctx = buildContext(cmd);
|
|
36
38
|
const client = detectClient();
|
|
37
39
|
const timeoutMs = Math.max(30, Number(opts.timeout) || 300) * 1000;
|
|
38
|
-
//
|
|
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) ──
|
|
40
|
+
// ── one perfolio handshake ──
|
|
42
41
|
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
42
|
// Open the Grant Cash connect screen (GRANTCASH_APP_URL) carrying the
|
|
61
|
-
// one-time
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
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)}`;
|
|
43
|
+
// one-time sid — NOT the backend's own URL — so sign-in happens in the
|
|
44
|
+
// Grant Cash UI. The sid is the only secret; minting still requires a
|
|
45
|
+
// logged-in browser, so the link alone is useless.
|
|
46
|
+
const connectUrl = `${ctx.urls.app.replace(/\/$/, "")}/connect?sid=${encodeURIComponent(start.sid)}&client=${encodeURIComponent(client)}`;
|
|
69
47
|
if (!ctx.json) {
|
|
70
48
|
process.stdout.write(`\n${ui.title("Connect Grant Cash")}\n` +
|
|
71
49
|
`Open this link to sign in and approve your limits:\n\n ${ui.green(connectUrl)}\n\n` +
|
|
@@ -74,122 +52,29 @@ export function registerAuth(program) {
|
|
|
74
52
|
}
|
|
75
53
|
if (opts.open !== false)
|
|
76
54
|
openBrowser(connectUrl);
|
|
77
|
-
|
|
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.
|
|
55
|
+
const result = await waitForDevice(ctx.urls.api, start.sid, moneyPollIntervalMs(start.pollInterval), timeoutMs);
|
|
94
56
|
if (result.status !== "complete") {
|
|
95
|
-
agentAbort.abort();
|
|
96
57
|
throw new AppError(result.status === "denied"
|
|
97
58
|
? "Connection was declined in the browser."
|
|
98
59
|
: "Connection timed out. Run `grant login` again.", { code: result.status === "denied" ? "DENIED" : "EXPIRED", recoverable: true });
|
|
99
60
|
}
|
|
100
|
-
|
|
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.";
|
|
61
|
+
ctx.save(loginCredentials(result.result));
|
|
151
62
|
emit(ctx, {
|
|
152
63
|
connected: true,
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
});
|
|
64
|
+
email: result.result.email,
|
|
65
|
+
wallet: result.result.walletAddress,
|
|
66
|
+
}, () => `\n${ui.green("✓ Connected.")}` +
|
|
67
|
+
`\n ${result.result.email || result.result.walletAddress}` +
|
|
68
|
+
`\n${ui.dim(" Money and agent both ready.")}\n`);
|
|
185
69
|
});
|
|
186
70
|
program
|
|
187
71
|
.command("logout")
|
|
188
72
|
.description("Disconnect — revoke this device and clear stored credentials")
|
|
189
73
|
.action(async (_opts, cmd) => {
|
|
190
74
|
const ctx = buildContext(cmd);
|
|
191
|
-
|
|
192
|
-
|
|
75
|
+
const jwt = ctx.creds.money?.accessJwt;
|
|
76
|
+
if (jwt) {
|
|
77
|
+
await revokeDevice(ctx.urls.api, jwt, ctx.creds.money?.deviceId);
|
|
193
78
|
}
|
|
194
79
|
clearCredentials(ctx.credsPath);
|
|
195
80
|
emit(ctx, { disconnected: true }, () => ui.dim("Disconnected. Credentials cleared."));
|
|
@@ -197,35 +82,33 @@ export function registerAuth(program) {
|
|
|
197
82
|
program
|
|
198
83
|
.command("status")
|
|
199
84
|
.alias("whoami")
|
|
200
|
-
.description("Show what's connected
|
|
85
|
+
.description("Show what's connected and where it routes")
|
|
201
86
|
.action(async (_opts, cmd) => {
|
|
202
87
|
const ctx = buildContext(cmd);
|
|
203
88
|
const money = ctx.creds.money;
|
|
204
|
-
const
|
|
89
|
+
const connected = Boolean(money?.accessJwt);
|
|
205
90
|
emit(ctx, {
|
|
206
|
-
money
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
},
|
|
211
|
-
agent: { connected: Boolean(agent?.apiKey), key: maskKey(agent?.apiKey) },
|
|
91
|
+
// One credential now drives both engines — `money`/`agent` connectivity
|
|
92
|
+
// are the same JWT. Kept as separate keys for backward-compatible output.
|
|
93
|
+
connected,
|
|
94
|
+
money: { connected, email: money?.email, wallet: money?.walletAddress },
|
|
95
|
+
agent: { connected },
|
|
212
96
|
routes: { api: ctx.urls.api, fiat: ctx.urls.fiat, agent: ctx.urls.agent },
|
|
213
97
|
}, () => `${ui.title("Grant Cash")}\n` +
|
|
214
|
-
`
|
|
215
|
-
` Agent: ${agent?.apiKey ? ui.green(maskKey(agent.apiKey)) : ui.amber("not connected")}\n` +
|
|
98
|
+
` ${connected ? ui.green(money?.email || money?.walletAddress || "connected") : ui.amber("not connected")}\n` +
|
|
216
99
|
ui.dim(` → money ${ctx.urls.api}\n → agent ${ctx.urls.agent}`));
|
|
217
100
|
});
|
|
218
|
-
// ── manage connected agents/devices
|
|
101
|
+
// ── manage connected agents/devices ──
|
|
219
102
|
const devices = program.command("devices").description("Manage connected agents/devices");
|
|
220
103
|
devices
|
|
221
104
|
.command("list")
|
|
222
105
|
.description("List devices connected to your account")
|
|
223
106
|
.action(async (_opts, cmd) => {
|
|
224
107
|
const ctx = buildContext(cmd);
|
|
225
|
-
const
|
|
226
|
-
if (!
|
|
108
|
+
const jwt = ctx.creds.money?.accessJwt;
|
|
109
|
+
if (!jwt)
|
|
227
110
|
throw new AppError("Not connected. Run `grant login` first.", { code: "NOT_CONNECTED" });
|
|
228
|
-
const list = await listDevices(ctx.urls.api,
|
|
111
|
+
const list = await listDevices(ctx.urls.api, jwt);
|
|
229
112
|
emit(ctx, { devices: list }, () => {
|
|
230
113
|
if (!list.length)
|
|
231
114
|
return ui.dim("No devices connected.");
|
|
@@ -239,10 +122,10 @@ export function registerAuth(program) {
|
|
|
239
122
|
.description("Revoke a connected device by id")
|
|
240
123
|
.action(async (deviceId, _opts, cmd) => {
|
|
241
124
|
const ctx = buildContext(cmd);
|
|
242
|
-
const
|
|
243
|
-
if (!
|
|
125
|
+
const jwt = ctx.creds.money?.accessJwt;
|
|
126
|
+
if (!jwt)
|
|
244
127
|
throw new AppError("Not connected. Run `grant login` first.", { code: "NOT_CONNECTED" });
|
|
245
|
-
await revokeDevice(ctx.urls.api,
|
|
128
|
+
await revokeDevice(ctx.urls.api, jwt, deviceId);
|
|
246
129
|
emit(ctx, { revoked: true, deviceId }, () => ui.green(`✓ Revoked device ${deviceId}.`));
|
|
247
130
|
});
|
|
248
131
|
}
|
|
@@ -43,11 +43,12 @@ export function registerMeta(program) {
|
|
|
43
43
|
.description("Show resolved routes and connection state")
|
|
44
44
|
.action((_opts, cmd) => {
|
|
45
45
|
const ctx = buildContext(cmd);
|
|
46
|
-
|
|
46
|
+
// One perfolio JWT drives both engines now — connectivity is the same token.
|
|
47
|
+
const connected = Boolean(ctx.creds.money?.accessJwt);
|
|
47
48
|
emit(ctx, {
|
|
48
49
|
routes: ctx.urls,
|
|
49
|
-
money: { connected
|
|
50
|
-
agent: { connected
|
|
50
|
+
money: { connected },
|
|
51
|
+
agent: { connected },
|
|
51
52
|
credsPath: ctx.credsPath,
|
|
52
53
|
}, () => `${ui.title("Routes")}\n` +
|
|
53
54
|
` money api ${ctx.urls.api}\n` +
|
|
@@ -55,15 +56,6 @@ export function registerMeta(program) {
|
|
|
55
56
|
` agent ${ctx.urls.agent}\n` +
|
|
56
57
|
ui.dim(` creds ${ctx.credsPath}`));
|
|
57
58
|
});
|
|
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
59
|
program
|
|
68
60
|
.command("install")
|
|
69
61
|
.description("Where to find the skill file + how to connect an assistant")
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import pc from "picocolors";
|
|
2
|
-
import { PerfolioClient, AssetResolver, baseUrls, resolveCredsPath, loadCredentials, saveCredentials, openBrowser, waitForSign, resolveDisplayPrefs, DEFAULT_PREFS, resolveCashToUsd, resolveCashTargetToAssetQty, } from "../lib/index.js";
|
|
2
|
+
import { PerfolioClient, AssetResolver, TokenHolder, refreshAccessToken, baseUrls, resolveCredsPath, loadCredentials, saveCredentials, openBrowser, waitForSign, resolveDisplayPrefs, DEFAULT_PREFS, resolveCashToUsd, resolveCashTargetToAssetQty, } from "../lib/index.js";
|
|
3
3
|
import { waitForTx } from "../lib/verify.js";
|
|
4
4
|
/** PerfolioClient needs a `widget` base; derive it from the API origin if unset. */
|
|
5
5
|
function widgetUrl(api) {
|
|
@@ -10,18 +10,22 @@ export function buildCtx(cmd) {
|
|
|
10
10
|
const credsPath = resolveCredsPath(opts.credsFile);
|
|
11
11
|
const creds = loadCredentials(credsPath);
|
|
12
12
|
const urls = baseUrls();
|
|
13
|
+
// Shared perfolio access JWT + refresh (unified auth). Persist rotated tokens
|
|
14
|
+
// into the `money` slot so a silent refresh survives across invocations.
|
|
15
|
+
const auth = new TokenHolder({
|
|
16
|
+
accessJwt: creds.money?.accessJwt,
|
|
17
|
+
refreshToken: creds.money?.refreshToken,
|
|
18
|
+
refresh: (rt) => refreshAccessToken(urls.api, rt),
|
|
19
|
+
onRefresh: ({ accessJwt, refreshToken, expiresAt }) => saveCredentials({ ...creds, money: { ...(creds.money ?? {}), accessJwt, refreshToken, expiresAt } }, credsPath),
|
|
20
|
+
});
|
|
13
21
|
const client = new PerfolioClient({
|
|
14
22
|
urls: { api: urls.api, fiat: urls.fiat, app: urls.app, widget: widgetUrl(urls.api) },
|
|
15
|
-
|
|
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),
|
|
23
|
+
auth,
|
|
20
24
|
});
|
|
21
25
|
return { client, creds, credsPath, json: Boolean(opts.json) };
|
|
22
26
|
}
|
|
23
27
|
export function requireAuth(ctx) {
|
|
24
|
-
if (!ctx.creds.money?.
|
|
28
|
+
if (!ctx.creds.money?.accessJwt) {
|
|
25
29
|
fail("Not connected. Run `grant login` first.", ctx.json);
|
|
26
30
|
}
|
|
27
31
|
return ctx.creds;
|
package/dist/lib/agent-client.js
CHANGED
|
@@ -2,10 +2,12 @@ import { AppError } from "./errors.js";
|
|
|
2
2
|
/**
|
|
3
3
|
* Agent side — the Agent-mode backend client (pay-per-use services, x402).
|
|
4
4
|
*
|
|
5
|
-
* Auth with
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* Auth with the shared perfolio access JWT (unified auth): `Authorization: Bearer
|
|
6
|
+
* <accessJwt>` — the SAME JWT the money client sends. A 401 triggers one shared
|
|
7
|
+
* refresh via the {@link TokenHolder} (used by both clients), then a single retry.
|
|
8
|
+
* The agent never holds a private key; the backend's delegated, revocable,
|
|
9
|
+
* guardrailed session owns the spend. Errors carry the backend's stable `err.code`
|
|
10
|
+
* + a `recoverable` flag so a driving agent knows whether to retry (see SKILL.md).
|
|
9
11
|
*/
|
|
10
12
|
const REQUEST_TIMEOUT_MS = 60_000;
|
|
11
13
|
/**
|
|
@@ -20,27 +22,31 @@ const RECOVERABLE_CODES = new Set([
|
|
|
20
22
|
]);
|
|
21
23
|
export class AgentClient {
|
|
22
24
|
base;
|
|
23
|
-
|
|
25
|
+
auth;
|
|
24
26
|
f;
|
|
25
27
|
constructor(opts) {
|
|
26
28
|
this.base = opts.agent;
|
|
27
|
-
this.
|
|
29
|
+
this.auth = opts.auth;
|
|
28
30
|
this.f = opts.fetchImpl ?? fetch;
|
|
29
31
|
}
|
|
30
32
|
get connected() {
|
|
31
|
-
return Boolean(this.
|
|
33
|
+
return Boolean(this.auth?.token);
|
|
32
34
|
}
|
|
33
|
-
async request(method, path, opts = {}) {
|
|
35
|
+
async request(method, path, opts = {}, retried = false) {
|
|
34
36
|
const headers = { Accept: "application/json" };
|
|
35
37
|
if (opts.body !== undefined)
|
|
36
38
|
headers["Content-Type"] = "application/json";
|
|
39
|
+
// Capture the token used for THIS request so the 401 path can detect whether
|
|
40
|
+
// another client already rotated it (then we just retry, no second refresh).
|
|
41
|
+
let usedToken;
|
|
37
42
|
if (opts.auth) {
|
|
38
|
-
|
|
43
|
+
usedToken = this.auth?.token;
|
|
44
|
+
if (!usedToken)
|
|
39
45
|
throw new AppError("Not connected to your agent yet. Run `grant login`.", {
|
|
40
46
|
code: "NOT_CONNECTED",
|
|
41
47
|
recoverable: false,
|
|
42
48
|
});
|
|
43
|
-
headers
|
|
49
|
+
headers.Authorization = `Bearer ${usedToken}`;
|
|
44
50
|
}
|
|
45
51
|
if (opts.idempotent)
|
|
46
52
|
headers["Idempotency-Key"] = randomId();
|
|
@@ -59,6 +65,12 @@ export class AgentClient {
|
|
|
59
65
|
recoverable: true,
|
|
60
66
|
});
|
|
61
67
|
}
|
|
68
|
+
// Silent refresh: an expired access JWT → shared refresh once, then retry.
|
|
69
|
+
if (res.status === 401 && opts.auth && !retried && this.auth) {
|
|
70
|
+
const fresh = await this.auth.refreshIfStale(usedToken);
|
|
71
|
+
if (fresh)
|
|
72
|
+
return this.request(method, path, opts, true);
|
|
73
|
+
}
|
|
62
74
|
const json = (await res.json().catch(() => ({})));
|
|
63
75
|
// A 4xx without the agent error envelope means we never reached the API
|
|
64
76
|
// (wrong/dead base URL, platform edge 404). Point at the misconfig.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { AppError } from "./errors.js";
|
|
2
|
+
export class TokenHolder {
|
|
3
|
+
accessJwt;
|
|
4
|
+
refreshToken;
|
|
5
|
+
doRefresh;
|
|
6
|
+
onRefresh;
|
|
7
|
+
/** The in-flight refresh, if any — coalesces concurrent callers (single-flight). */
|
|
8
|
+
inFlight;
|
|
9
|
+
constructor(opts) {
|
|
10
|
+
this.accessJwt = opts.accessJwt;
|
|
11
|
+
this.refreshToken = opts.refreshToken;
|
|
12
|
+
this.doRefresh = opts.refresh;
|
|
13
|
+
this.onRefresh = opts.onRefresh;
|
|
14
|
+
}
|
|
15
|
+
/** The current access JWT to send as `Authorization: Bearer`. */
|
|
16
|
+
get token() {
|
|
17
|
+
return this.accessJwt;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Refresh only if the caller's token is still the live one. If another client
|
|
21
|
+
* already rotated the token (current !== usedToken), return the current token
|
|
22
|
+
* without a second refresh — the caller should just retry with it.
|
|
23
|
+
*/
|
|
24
|
+
async refreshIfStale(usedToken) {
|
|
25
|
+
if (this.accessJwt && this.accessJwt !== usedToken)
|
|
26
|
+
return this.accessJwt;
|
|
27
|
+
return this.refresh();
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Rotate the access JWT via the refresh token. Single-flight: concurrent calls
|
|
31
|
+
* share one network round-trip. Returns the new JWT, or `undefined` if there is
|
|
32
|
+
* no refresh token or the refresh failed (the old token is left in place).
|
|
33
|
+
*/
|
|
34
|
+
refresh() {
|
|
35
|
+
if (this.inFlight)
|
|
36
|
+
return this.inFlight;
|
|
37
|
+
const rt = this.refreshToken;
|
|
38
|
+
if (!rt)
|
|
39
|
+
return Promise.resolve(undefined);
|
|
40
|
+
this.inFlight = (async () => {
|
|
41
|
+
try {
|
|
42
|
+
const tokens = await this.doRefresh(rt);
|
|
43
|
+
this.accessJwt = tokens.accessJwt;
|
|
44
|
+
this.refreshToken = tokens.refreshToken;
|
|
45
|
+
this.onRefresh?.(tokens);
|
|
46
|
+
return tokens.accessJwt;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
this.inFlight = undefined;
|
|
53
|
+
}
|
|
54
|
+
})();
|
|
55
|
+
return this.inFlight;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* `POST {api}/cli/token/refresh` — exchange a `pfr_` refresh token for a fresh
|
|
60
|
+
* perfolio access JWT. Per CONTRACT v1 the payload is `{ accessJwt, refreshToken,
|
|
61
|
+
* expiresAt }`; it arrives inside perfolio's standard `{ success, data }` envelope.
|
|
62
|
+
* The legacy `token` field is accepted as a fallback during the migration window.
|
|
63
|
+
*/
|
|
64
|
+
export async function refreshAccessToken(api, refreshToken, f = fetch) {
|
|
65
|
+
const res = await f(`${api}/cli/token/refresh`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { "Content-Type": "application/json" },
|
|
68
|
+
body: JSON.stringify({ refreshToken }),
|
|
69
|
+
});
|
|
70
|
+
const j = (await res.json().catch(() => ({})));
|
|
71
|
+
const data = j.data ?? {};
|
|
72
|
+
const accessJwt = data.accessJwt ?? data.token;
|
|
73
|
+
if (!res.ok || j.success === false || !accessJwt) {
|
|
74
|
+
throw new AppError(j.error || "Refresh failed", { status: res.status, code: "REFRESH_FAILED" });
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
accessJwt,
|
|
78
|
+
refreshToken: data.refreshToken ?? refreshToken,
|
|
79
|
+
expiresAt: typeof data.expiresAt === "number" ? data.expiresAt : 0,
|
|
80
|
+
};
|
|
81
|
+
}
|
package/dist/lib/client.js
CHANGED
|
@@ -1,24 +1,19 @@
|
|
|
1
1
|
import { NoSessionError } from './errors.js';
|
|
2
|
-
import { refreshCliToken } from './device.js';
|
|
3
2
|
/** Per-request network timeout. Generous enough for MEE submit, short enough to not hang. */
|
|
4
3
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
5
4
|
export class PerfolioClient {
|
|
6
5
|
urls;
|
|
7
|
-
|
|
8
|
-
refreshToken;
|
|
9
|
-
onRefresh;
|
|
6
|
+
auth;
|
|
10
7
|
f;
|
|
11
8
|
constructor(opts) {
|
|
12
9
|
this.urls = opts.urls;
|
|
13
|
-
this.
|
|
14
|
-
this.refreshToken = opts.refreshToken;
|
|
15
|
-
this.onRefresh = opts.onRefresh;
|
|
10
|
+
this.auth = opts.auth;
|
|
16
11
|
this.f = opts.fetchImpl ?? fetch;
|
|
17
12
|
}
|
|
18
|
-
headers(auth) {
|
|
13
|
+
headers(token, auth) {
|
|
19
14
|
const h = { 'Content-Type': 'application/json' };
|
|
20
|
-
if (auth &&
|
|
21
|
-
h.Authorization = `Bearer ${
|
|
15
|
+
if (auth && token)
|
|
16
|
+
h.Authorization = `Bearer ${token}`;
|
|
22
17
|
return h;
|
|
23
18
|
}
|
|
24
19
|
/** fetch with an abort-based timeout + one retry on a transient network failure. */
|
|
@@ -44,23 +39,15 @@ export class PerfolioClient {
|
|
|
44
39
|
}
|
|
45
40
|
}
|
|
46
41
|
async request(base, path, init, auth, retried = false) {
|
|
47
|
-
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
this.
|
|
54
|
-
|
|
55
|
-
const t = await refreshCliToken(this.urls.api, this.refreshToken, this.f);
|
|
56
|
-
this.token = t.token;
|
|
57
|
-
this.refreshToken = t.refreshToken;
|
|
58
|
-
this.onRefresh?.(t.token, t.refreshToken, t.expiresAt);
|
|
42
|
+
// Capture the token used for THIS request so the 401 path can detect whether
|
|
43
|
+
// another client already rotated it (then we just retry, no second refresh).
|
|
44
|
+
const usedToken = auth ? this.auth?.token : undefined;
|
|
45
|
+
const res = await this.fetchWithTimeout(`${base}${path}`, { ...init, headers: this.headers(usedToken, auth) });
|
|
46
|
+
// Silent refresh: an expired access JWT → shared refresh once, then retry.
|
|
47
|
+
if (res.status === 401 && auth && !retried && this.auth) {
|
|
48
|
+
const fresh = await this.auth.refreshIfStale(usedToken);
|
|
49
|
+
if (fresh)
|
|
59
50
|
return this.request(base, path, init, auth, true);
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
/* refresh failed — fall through to the normal error below */
|
|
63
|
-
}
|
|
64
51
|
}
|
|
65
52
|
let json = { success: false };
|
|
66
53
|
try {
|
package/dist/lib/context.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { baseUrls, resolveCredsPath, loadCredentials, saveCredentials, } from "./config.js";
|
|
2
2
|
import { MoneyClient } from "./money-client.js";
|
|
3
3
|
import { AgentClient } from "./agent-client.js";
|
|
4
|
+
import { TokenHolder, refreshAccessToken } from "./auth-session.js";
|
|
4
5
|
import { isJsonMode } from "./output.js";
|
|
5
6
|
export function buildContext(cmd) {
|
|
6
7
|
// Global opts live on the root program; optsWithGlobals merges them in.
|
|
@@ -13,23 +14,21 @@ export function buildContext(cmd) {
|
|
|
13
14
|
saveCredentials(next, credsPath);
|
|
14
15
|
ctx.creds = next;
|
|
15
16
|
};
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
token: creds.money?.token,
|
|
17
|
+
// One JWT, one refresh, shared by both backend clients (CONTRACT v1).
|
|
18
|
+
const auth = new TokenHolder({
|
|
19
|
+
accessJwt: creds.money?.accessJwt,
|
|
20
20
|
refreshToken: creds.money?.refreshToken,
|
|
21
|
-
|
|
21
|
+
refresh: (rt) => refreshAccessToken(urls.api, rt),
|
|
22
|
+
onRefresh: ({ accessJwt, refreshToken, expiresAt }) => {
|
|
22
23
|
const next = {
|
|
23
24
|
...ctx.creds,
|
|
24
|
-
money: { ...ctx.creds.money,
|
|
25
|
+
money: { ...ctx.creds.money, accessJwt, refreshToken, expiresAt },
|
|
25
26
|
};
|
|
26
27
|
save(next);
|
|
27
28
|
},
|
|
28
29
|
});
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
apiKey: creds.agent?.apiKey,
|
|
32
|
-
});
|
|
30
|
+
const money = new MoneyClient({ api: urls.api, fiat: urls.fiat, auth });
|
|
31
|
+
const agent = new AgentClient({ agent: urls.agent, auth });
|
|
33
32
|
const ctx = { urls, creds, credsPath, json, money, agent, save };
|
|
34
33
|
return ctx;
|
|
35
34
|
}
|
package/dist/lib/index.js
CHANGED
|
@@ -4,7 +4,7 @@ export * from "./dotenv.js";
|
|
|
4
4
|
export * from "./errors.js";
|
|
5
5
|
export * from "./output.js";
|
|
6
6
|
export * from "./device.js";
|
|
7
|
-
export * from "./
|
|
7
|
+
export * from "./auth-session.js";
|
|
8
8
|
export * from "./money-client.js";
|
|
9
9
|
export * from "./agent-client.js";
|
|
10
10
|
export * from "./tx-wait.js";
|
package/dist/lib/money-client.js
CHANGED
|
@@ -1,36 +1,31 @@
|
|
|
1
1
|
import { AppError, NoSessionError } from "./errors.js";
|
|
2
|
-
import { refreshCliToken } from "./device.js";
|
|
3
2
|
/**
|
|
4
3
|
* Money side — the Perfolio backend client (gold, portfolio, cash).
|
|
5
4
|
*
|
|
6
|
-
* Bearer auth with
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* endpoints later is a one-line method
|
|
5
|
+
* Bearer auth with the shared perfolio access JWT (unified auth): a 401 triggers
|
|
6
|
+
* one shared refresh via the {@link TokenHolder} (used by BOTH backend clients),
|
|
7
|
+
* then the request is retried with the new JWT. Kept deliberately gold-focused per
|
|
8
|
+
* the product scope; adding borrow/earn/perps endpoints later is a one-line method.
|
|
10
9
|
*/
|
|
11
10
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
12
11
|
export class MoneyClient {
|
|
13
12
|
api;
|
|
14
13
|
fiat;
|
|
15
|
-
|
|
16
|
-
refreshToken;
|
|
17
|
-
onRefresh;
|
|
14
|
+
auth;
|
|
18
15
|
f;
|
|
19
16
|
constructor(opts) {
|
|
20
17
|
this.api = opts.api;
|
|
21
18
|
this.fiat = opts.fiat;
|
|
22
|
-
this.
|
|
23
|
-
this.refreshToken = opts.refreshToken;
|
|
24
|
-
this.onRefresh = opts.onRefresh;
|
|
19
|
+
this.auth = opts.auth;
|
|
25
20
|
this.f = opts.fetchImpl ?? fetch;
|
|
26
21
|
}
|
|
27
22
|
get connected() {
|
|
28
|
-
return Boolean(this.token);
|
|
23
|
+
return Boolean(this.auth?.token);
|
|
29
24
|
}
|
|
30
|
-
headers(auth) {
|
|
25
|
+
headers(token, auth) {
|
|
31
26
|
const h = { "Content-Type": "application/json" };
|
|
32
|
-
if (auth &&
|
|
33
|
-
h.Authorization = `Bearer ${
|
|
27
|
+
if (auth && token)
|
|
28
|
+
h.Authorization = `Bearer ${token}`;
|
|
34
29
|
return h;
|
|
35
30
|
}
|
|
36
31
|
async fetchWithTimeout(url, init) {
|
|
@@ -53,26 +48,18 @@ export class MoneyClient {
|
|
|
53
48
|
}
|
|
54
49
|
}
|
|
55
50
|
async request(base, path, init, auth, retried = false) {
|
|
51
|
+
// Capture the token used for THIS request so the 401 path can detect whether
|
|
52
|
+
// another client already rotated it (then we just retry, no second refresh).
|
|
53
|
+
const usedToken = auth ? this.auth?.token : undefined;
|
|
56
54
|
const res = await this.fetchWithTimeout(`${base}${path}`, {
|
|
57
55
|
...init,
|
|
58
|
-
headers: this.headers(auth),
|
|
56
|
+
headers: this.headers(usedToken, auth),
|
|
59
57
|
});
|
|
60
|
-
// Silent refresh: an expired
|
|
61
|
-
if (res.status === 401 &&
|
|
62
|
-
auth
|
|
63
|
-
|
|
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);
|
|
58
|
+
// Silent refresh: an expired access JWT → shared refresh once, then retry.
|
|
59
|
+
if (res.status === 401 && auth && !retried && this.auth) {
|
|
60
|
+
const fresh = await this.auth.refreshIfStale(usedToken);
|
|
61
|
+
if (fresh)
|
|
71
62
|
return this.request(base, path, init, auth, true);
|
|
72
|
-
}
|
|
73
|
-
catch {
|
|
74
|
-
/* fall through to the normal error */
|
|
75
|
-
}
|
|
76
63
|
}
|
|
77
64
|
let json = {};
|
|
78
65
|
try {
|
package/dist/lib/types.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared types for the merged Grant Cash CLI.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* One identity, one credential (unified auth): `grant login` runs a single
|
|
5
|
+
* perfolio device flow and stores the perfolio-signed access JWT (`accessJwt`)
|
|
6
|
+
* plus the opaque `pfr_` refresh token. That ONE JWT is sent as
|
|
7
|
+
* `Authorization: Bearer <accessJwt>` to BOTH backends — the MONEY side
|
|
8
|
+
* (Perfolio — gold, portfolio, cash) and the AGENT side (Agent-mode — pay-per-use
|
|
9
|
+
* services). The retired `grant_live_` X-API-Key path is gone.
|
|
9
10
|
*/
|
|
10
11
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agentgrant.cash/cli",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.3",
|
|
4
4
|
"description": "Grant Cash — one CLI for your money (gold) and your agent (pay-per-use services). Routes to the Perfolio backend and the Agent-mode backend behind a single, plain-language surface.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,6 +14,24 @@ These are **two sides of the same Grant Cash account**, not two separate apps or
|
|
|
14
14
|
|
|
15
15
|
> Note for assistants: do **not** describe this as a "portfolio service" plus an "agent service", and never mention internal system names. It is one product — Grant Cash — with an Investments side and a Spending side. Speak plainly: gold, cash, bitcoin, ethereum, "your worth", "spending money".
|
|
16
16
|
|
|
17
|
+
## Set up (do this first)
|
|
18
|
+
|
|
19
|
+
**If Grant Cash isn't connected on this machine yet**, set it up with one command:
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
npx @agentgrant.cash/cli onboard
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
This installs the Grant Cash skill and starts sign-in, then prints a **connect link**. Give that link to the user and ask them to open it, **sign in if needed, and approve their spending limits**. (Already signed in to Grant Cash in their browser? They just approve — no new sign-in.)
|
|
26
|
+
|
|
27
|
+
**Then confirm whose account you're on — always, before moving any money:**
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
grant status # shows the connected account (email) + what's enabled
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Read the connected account back to the user and confirm it's theirs. A machine may already have an **older or different login cached**, so `status` reporting "connected" is not proof on its own. If the account isn't one the user recognises, run `grant logout` then `grant onboard` again for a fresh sign-in.
|
|
34
|
+
|
|
17
35
|
## Install / run
|
|
18
36
|
|
|
19
37
|
This CLI is published on npm as **`@agentgrant.cash/cli`**. Run it without installing:
|
|
@@ -47,7 +65,7 @@ Every **cash** amount is in the user's display currency — a bare `50` means
|
|
|
47
65
|
|
|
48
66
|
## Workflow
|
|
49
67
|
|
|
50
|
-
1. `grant status` — confirm you're connected (
|
|
68
|
+
1. `grant status` — confirm you're connected **and that the account is the user's** (see *Set up* — re-confirm a cached login before acting).
|
|
51
69
|
2. `grant portfolio` — combined worth: the Investments side (gold, cash, bitcoin, ethereum, leveraged positions, prediction balance, earnings) and the Spending side, in one place.
|
|
52
70
|
3. Invest: `grant prices`, then `grant buy <asset> --amount <cash>` / `grant sell <asset> --for <cash>`.
|
|
53
71
|
4. Spend: `grant search "<q>"` → `grant check <url>` (no charge) → `grant fetch <url> -b '<json>'`.
|
package/dist/lib/agent-device.js
DELETED
|
@@ -1,173 +0,0 @@
|
|
|
1
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import { AppError } from "./errors.js";
|
|
3
|
-
/**
|
|
4
|
-
* Generate a PKCE S256 pair. The verifier is 43 base64url chars (32 random
|
|
5
|
-
* bytes), well within RFC 7636's 43–128 range.
|
|
6
|
-
*/
|
|
7
|
-
export function generatePkce() {
|
|
8
|
-
const verifier = base64url(randomBytes(32)); // 32 bytes → 43 base64url chars
|
|
9
|
-
const challenge = base64url(createHash("sha256").update(verifier).digest());
|
|
10
|
-
return { verifier, challenge, method: "S256" };
|
|
11
|
-
}
|
|
12
|
-
function base64url(buf) {
|
|
13
|
-
return buf
|
|
14
|
-
.toString("base64")
|
|
15
|
-
.replace(/\+/g, "-")
|
|
16
|
-
.replace(/\//g, "_")
|
|
17
|
-
.replace(/=+$/, "");
|
|
18
|
-
}
|
|
19
|
-
const JSON_HEADERS = { "Content-Type": "application/json", Accept: "application/json" };
|
|
20
|
-
/**
|
|
21
|
-
* POST /onboard/privy-cli/device/start — open the agent-side handshake.
|
|
22
|
-
* Sends only the public challenge; keep the returned `sid` paired with the
|
|
23
|
-
* verifier from the same {@link generatePkce} call.
|
|
24
|
-
*/
|
|
25
|
-
export async function startAgentDevice(agent, pkce, clientName, f = fetch) {
|
|
26
|
-
const base = agent.replace(/\/$/, "");
|
|
27
|
-
let res;
|
|
28
|
-
try {
|
|
29
|
-
res = await f(`${base}/onboard/privy-cli/device/start`, {
|
|
30
|
-
method: "POST",
|
|
31
|
-
headers: JSON_HEADERS,
|
|
32
|
-
body: JSON.stringify({
|
|
33
|
-
clientName,
|
|
34
|
-
code_challenge: pkce.challenge,
|
|
35
|
-
code_challenge_method: pkce.method,
|
|
36
|
-
}),
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
catch (e) {
|
|
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
|
-
// A 4xx without a recognizable body means we never reached the agent API
|
|
47
|
-
// (wrong/dead base URL) — point at the misconfig, same as AgentClient.
|
|
48
|
-
if (res.status >= 400 && res.status < 500 && !j?.sid) {
|
|
49
|
-
throw new AppError(`No Grant Cash agent API at ${base} (HTTP ${res.status}) — check GRANTCASH_AGENT_URL.`, { status: res.status, code: "BACKEND_UNREACHABLE", recoverable: false });
|
|
50
|
-
}
|
|
51
|
-
if (!res.ok || !j?.sid) {
|
|
52
|
-
throw new AppError(j?.error?.message || `Could not start spending setup (HTTP ${res.status})`, { status: res.status, code: j?.error?.code ?? "AGENT_DEVICE_START_FAILED" });
|
|
53
|
-
}
|
|
54
|
-
return {
|
|
55
|
-
sid: j.sid,
|
|
56
|
-
pollInterval: typeof j.pollInterval === "number" ? j.pollInterval : 3,
|
|
57
|
-
expiresIn: typeof j.expiresIn === "number" ? j.expiresIn : 300,
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* POST /onboard/privy-cli/device/poll — one poll with the secret verifier.
|
|
62
|
-
*
|
|
63
|
-
* Resolves a single tick:
|
|
64
|
-
* - 200 { status:"pending" | "complete" | "expired" } → returned as-is
|
|
65
|
-
* (complete may omit `apiKey` = already provisioned → keep existing key).
|
|
66
|
-
* - 401 PRIVY_DEVICE_PKCE_FAILED → throw (terminal; the verifier was rejected).
|
|
67
|
-
* - 429 → throw a RATE_LIMITED AppError carrying `retryInMs` from details so
|
|
68
|
-
* {@link waitForAgentDevice} can back off instead of hammering.
|
|
69
|
-
*/
|
|
70
|
-
export async function pollAgentDevice(agent, sid, verifier, f = fetch) {
|
|
71
|
-
const base = agent.replace(/\/$/, "");
|
|
72
|
-
let res;
|
|
73
|
-
try {
|
|
74
|
-
res = await f(`${base}/onboard/privy-cli/device/poll`, {
|
|
75
|
-
method: "POST",
|
|
76
|
-
headers: JSON_HEADERS,
|
|
77
|
-
body: JSON.stringify({ sid, code_verifier: verifier }),
|
|
78
|
-
});
|
|
79
|
-
}
|
|
80
|
-
catch (e) {
|
|
81
|
-
throw new AppError(e instanceof Error ? e.message : String(e), {
|
|
82
|
-
code: "NETWORK",
|
|
83
|
-
recoverable: true,
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
const j = (await res.json().catch(() => ({})));
|
|
87
|
-
if (res.status === 429) {
|
|
88
|
-
const retryInMs = j?.details?.retryInMs;
|
|
89
|
-
throw new AppError("Too many attempts — backing off.", {
|
|
90
|
-
status: 429,
|
|
91
|
-
code: "RATE_LIMITED",
|
|
92
|
-
recoverable: true,
|
|
93
|
-
details: retryInMs ? { retryInMs } : undefined,
|
|
94
|
-
});
|
|
95
|
-
}
|
|
96
|
-
if (res.status === 401) {
|
|
97
|
-
throw new AppError(j?.error?.message || "Spending setup could not be verified (PKCE check failed).", {
|
|
98
|
-
status: 401,
|
|
99
|
-
code: j?.error?.code || j?.code || "PRIVY_DEVICE_PKCE_FAILED",
|
|
100
|
-
recoverable: false,
|
|
101
|
-
});
|
|
102
|
-
}
|
|
103
|
-
if (!res.ok) {
|
|
104
|
-
throw new AppError(j?.error?.message || `Spending poll failed (HTTP ${res.status})`, {
|
|
105
|
-
status: res.status,
|
|
106
|
-
code: j?.error?.code ?? `HTTP_${res.status}`,
|
|
107
|
-
recoverable: res.status >= 500,
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
if (j.status === "complete") {
|
|
111
|
-
return { status: "complete", apiKey: j.apiKey, sessionStatus: j.sessionStatus };
|
|
112
|
-
}
|
|
113
|
-
if (j.status === "expired")
|
|
114
|
-
return { status: "expired" };
|
|
115
|
-
return { status: "pending" };
|
|
116
|
-
}
|
|
117
|
-
/**
|
|
118
|
-
* Poll the agent device until a terminal status or the link expires.
|
|
119
|
-
*
|
|
120
|
-
* Never throws on the network/rate-limit path: a transient network blip or a
|
|
121
|
-
* 429 (honoring `details.retryInMs`) just backs off and retries within the
|
|
122
|
-
* window. A 401 PRIVY_DEVICE_PKCE_FAILED is terminal and DOES propagate. On
|
|
123
|
-
* window exhaustion returns `{ status: "expired" }`.
|
|
124
|
-
*/
|
|
125
|
-
export async function waitForAgentDevice(agent, sid, verifier, intervalMs, timeoutMs, f = fetch, signal) {
|
|
126
|
-
// When a signal is supplied, thread it into each fetch AND check it in the
|
|
127
|
-
// loop guard — so `grant login` can stop waiting on the best-effort spending
|
|
128
|
-
// side the moment money (the primary identity) settles, instead of letting
|
|
129
|
-
// this loop keep the process alive until the full timeout.
|
|
130
|
-
const ff = signal
|
|
131
|
-
? ((input, init) => f(input, { ...init, signal }))
|
|
132
|
-
: f;
|
|
133
|
-
const start = Date.now();
|
|
134
|
-
while (!signal?.aborted && Date.now() - start < timeoutMs) {
|
|
135
|
-
try {
|
|
136
|
-
const r = await pollAgentDevice(agent, sid, verifier, ff);
|
|
137
|
-
if (r.status !== "pending")
|
|
138
|
-
return r;
|
|
139
|
-
await sleep(intervalMs);
|
|
140
|
-
}
|
|
141
|
-
catch (e) {
|
|
142
|
-
if (signal?.aborted)
|
|
143
|
-
break;
|
|
144
|
-
const err = e;
|
|
145
|
-
// PKCE failure is terminal — surface it.
|
|
146
|
-
if (err?.code === "PRIVY_DEVICE_PKCE_FAILED")
|
|
147
|
-
throw err;
|
|
148
|
-
// Rate-limited: back off by the server hint (or the normal interval).
|
|
149
|
-
if (err?.code === "RATE_LIMITED") {
|
|
150
|
-
const retryInMs = err.details?.retryInMs ?? intervalMs;
|
|
151
|
-
await sleep(Math.max(intervalMs, retryInMs));
|
|
152
|
-
continue;
|
|
153
|
-
}
|
|
154
|
-
// Transient network error: wait one interval and retry within the window.
|
|
155
|
-
await sleep(intervalMs);
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return { status: "expired" };
|
|
159
|
-
}
|
|
160
|
-
function sleep(ms) {
|
|
161
|
-
return new Promise((res) => setTimeout(res, ms));
|
|
162
|
-
}
|
|
163
|
-
/**
|
|
164
|
-
* Resolve the agent-side poll interval in MILLISECONDS.
|
|
165
|
-
*
|
|
166
|
-
* The Agent-mode backend (`onboard/privy-cli/device/start`) reports
|
|
167
|
-
* `pollInterval` in SECONDS (e.g. 2), so it is multiplied by 1000. This differs
|
|
168
|
-
* from the money backend, which already reports milliseconds (see
|
|
169
|
-
* `moneyPollIntervalMs` in `device.ts`). Falls back to 3s.
|
|
170
|
-
*/
|
|
171
|
-
export function agentPollIntervalMs(pollInterval) {
|
|
172
|
-
return (typeof pollInterval === "number" && pollInterval > 0 ? pollInterval : 3) * 1000;
|
|
173
|
-
}
|