@agentgrant.cash/cli 1.4.0 → 1.4.2

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 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 device token (`pfk_`) |
25
- | agent: `search`, `check`, `fetch`, `transfer`, `revoke` | Agent-mode backend (`GRANTCASH_AGENT_URL`) | `X-API-Key` (`grant_live_`) |
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 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`).
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`. 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.
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, generatePkce, startAgentDevice, waitForAgentDevice, agentPollIntervalMs, } from "../../lib/index.js";
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 for both engines.
3
+ * `grant login` — one connect, one credential (unified auth).
4
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.
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
- function maskKey(k) {
23
- if (!k)
24
- return "(none)";
25
- return k.length <= 12 ? "****" : `${k.slice(0, 9)}…${k.slice(-4)}`;
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
- // 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) ──
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 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)}`;
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
- // ── 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.
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
- // 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.";
61
+ ctx.save(loginCredentials(result.result));
151
62
  emit(ctx, {
152
63
  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
- });
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
- if (ctx.creds.money?.token) {
192
- await revokeDevice(ctx.urls.api, ctx.creds.money.token, ctx.creds.money.deviceId);
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 (money + agent) and where it routes")
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 agent = ctx.creds.agent;
89
+ const connected = Boolean(money?.accessJwt);
205
90
  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) },
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
- ` 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` +
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 (money side) ──
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 token = ctx.creds.money?.token;
226
- if (!token)
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, token);
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 token = ctx.creds.money?.token;
243
- if (!token)
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, token, deviceId);
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
- const key = ctx.creds.agent?.apiKey;
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: Boolean(ctx.creds.money?.token) },
50
- agent: { connected: Boolean(key) },
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
- token: creds.money?.token,
16
- refreshToken: creds.money?.refreshToken,
17
- // Persist rotated tokens into the `money` slot so a silent refresh survives
18
- // across invocations without disturbing the agent credential.
19
- onRefresh: (token, refreshToken, expiresAt) => saveCredentials({ ...creds, money: { ...(creds.money ?? {}), token, refreshToken, expiresAt } }, credsPath),
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?.token) {
28
+ if (!ctx.creds.money?.accessJwt) {
25
29
  fail("Not connected. Run `grant login` first.", ctx.json);
26
30
  }
27
31
  return ctx.creds;
@@ -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 a session-bound `X-API-Key` (`grant_live_`). The agent never holds a
6
- * private key; the backend's delegated, revocable, guardrailed session owns the
7
- * spend. Errors carry the backend's stable `err.code` + a `recoverable` flag so
8
- * a driving agent knows whether to retry (see SKILL.md error playbook).
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
- apiKey;
25
+ auth;
24
26
  f;
25
27
  constructor(opts) {
26
28
  this.base = opts.agent;
27
- this.apiKey = opts.apiKey;
29
+ this.auth = opts.auth;
28
30
  this.f = opts.fetchImpl ?? fetch;
29
31
  }
30
32
  get connected() {
31
- return Boolean(this.apiKey);
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
- if (!this.apiKey)
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["X-API-Key"] = this.apiKey;
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
+ }
@@ -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
- token;
8
- refreshToken;
9
- onRefresh;
6
+ auth;
10
7
  f;
11
8
  constructor(opts) {
12
9
  this.urls = opts.urls;
13
- this.token = opts.token;
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 && this.token)
21
- h.Authorization = `Bearer ${this.token}`;
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
- const res = await this.fetchWithTimeout(`${base}${path}`, { ...init, headers: this.headers(auth) });
48
- // Silent refresh: an expired CLI access token (pfk_) rotate once and retry.
49
- if (res.status === 401 &&
50
- auth &&
51
- !retried &&
52
- this.refreshToken &&
53
- this.token?.startsWith('pfk_')) {
54
- try {
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 {
@@ -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
- const money = new MoneyClient({
17
- api: urls.api,
18
- fiat: urls.fiat,
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
- onRefresh: (token, refreshToken, expiresAt) => {
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, token, refreshToken, expiresAt },
25
+ money: { ...ctx.creds.money, accessJwt, refreshToken, expiresAt },
25
26
  };
26
27
  save(next);
27
28
  },
28
29
  });
29
- const agent = new AgentClient({
30
- agent: urls.agent,
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 "./agent-device.js";
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";
@@ -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 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.
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
- token;
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.token = opts.token;
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 && this.token)
33
- h.Authorization = `Bearer ${this.token}`;
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 pfk_ access tokenrotate 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);
58
+ // Silent refresh: an expired access JWTshared 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
- * Two engines, one identity (one Privy app underneath): the MONEY side
5
- * (Perfolio backend gold, portfolio, cash) authenticates with a Bearer
6
- * device token (`pfk_`), and the AGENT side (Agent-mode backend pay-per-use
7
- * services) authenticates with an `X-API-Key` (`grant_live_`). Both credentials
8
- * live in one file so a single `grant login` connects everything.
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,10 +1,11 @@
1
1
  {
2
2
  "name": "@agentgrant.cash/cli",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
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": {
7
- "grant": "./dist/cli/index.js"
7
+ "grant": "./dist/cli/index.js",
8
+ "cli": "./dist/cli/index.js"
8
9
  },
9
10
  "files": [
10
11
  "dist",
@@ -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
- }