@haven_ai/cli 0.1.34-alpha.0 → 0.1.36-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +293 -10
- package/dist/cli.cjs +943 -128
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +943 -128
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +925 -119
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +69 -1
- package/dist/index.d.ts +69 -1
- package/dist/index.js +925 -119
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/cli.cjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
-
var readline = require('readline');
|
|
5
4
|
var promises = require('fs/promises');
|
|
6
5
|
var os = require('os');
|
|
7
6
|
var path = require('path');
|
|
7
|
+
var child_process = require('child_process');
|
|
8
|
+
var readline = require('readline');
|
|
8
9
|
|
|
9
10
|
// src/args.ts
|
|
10
11
|
var VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
@@ -18,24 +19,54 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
|
18
19
|
"--format",
|
|
19
20
|
"--from",
|
|
20
21
|
"--to",
|
|
21
|
-
"--company"
|
|
22
|
+
"--company",
|
|
23
|
+
"--name",
|
|
24
|
+
"--budget",
|
|
25
|
+
"--token",
|
|
26
|
+
"--period",
|
|
27
|
+
"--status",
|
|
28
|
+
"--amount",
|
|
29
|
+
"--recipient",
|
|
30
|
+
"--expires",
|
|
31
|
+
"--poll"
|
|
22
32
|
]);
|
|
23
33
|
function parseArgs(argv) {
|
|
24
34
|
const positionals = [];
|
|
25
|
-
const flags = {
|
|
35
|
+
const flags = {
|
|
36
|
+
json: false,
|
|
37
|
+
help: false,
|
|
38
|
+
version: false,
|
|
39
|
+
yes: false,
|
|
40
|
+
noWait: false,
|
|
41
|
+
run: false,
|
|
42
|
+
wait: false,
|
|
43
|
+
hashes: false
|
|
44
|
+
};
|
|
26
45
|
for (let i = 0; i < argv.length; i += 1) {
|
|
27
46
|
const arg = argv[i];
|
|
28
47
|
if (arg === "--json") flags.json = true;
|
|
29
48
|
else if (arg === "--help" || arg === "-h") flags.help = true;
|
|
30
49
|
else if (arg === "--version" || arg === "-v") flags.version = true;
|
|
31
50
|
else if (arg === "--yes" || arg === "-y") flags.yes = true;
|
|
51
|
+
else if (arg === "--no-wait") flags.noWait = true;
|
|
52
|
+
else if (arg === "--run") flags.run = true;
|
|
53
|
+
else if (arg === "--wait") flags.wait = true;
|
|
54
|
+
else if (arg === "--hashes") flags.hashes = true;
|
|
32
55
|
else if (VALUE_FLAGS.has(arg)) {
|
|
33
56
|
const value = argv[++i];
|
|
34
57
|
if (value === void 0 || value.startsWith("--")) {
|
|
35
58
|
throw new Error(`Missing value for ${arg}`);
|
|
36
59
|
}
|
|
37
60
|
if (arg === "--api") flags.api = value;
|
|
38
|
-
else if (arg === "--
|
|
61
|
+
else if (arg === "--name") flags.name = value;
|
|
62
|
+
else if (arg === "--budget") flags.budget = value;
|
|
63
|
+
else if (arg === "--token") flags.token = value;
|
|
64
|
+
else if (arg === "--status") flags.status = value;
|
|
65
|
+
else if (arg === "--period") {
|
|
66
|
+
const n = Number(value);
|
|
67
|
+
if (!Number.isInteger(n) || n < 0) throw new Error("--period must be a whole number of minutes");
|
|
68
|
+
flags.period = n;
|
|
69
|
+
} else if (arg === "--email") flags.email = value;
|
|
39
70
|
else if (arg === "--safe") flags.safe = value;
|
|
40
71
|
else if (arg === "--agent") flags.agent = value;
|
|
41
72
|
else if (arg === "--limit") {
|
|
@@ -49,9 +80,19 @@ function parseArgs(argv) {
|
|
|
49
80
|
} else if (arg === "--direction") {
|
|
50
81
|
if (value !== "in" && value !== "out") throw new Error('--direction must be "in" or "out"');
|
|
51
82
|
flags.direction = value;
|
|
83
|
+
} else if (arg === "--recipient") {
|
|
84
|
+
flags.recipient = value;
|
|
85
|
+
} else if (arg === "--amount") {
|
|
86
|
+
flags.amount = value;
|
|
87
|
+
} else if (arg === "--expires") {
|
|
88
|
+
const n = Number(value);
|
|
89
|
+
if (!Number.isInteger(n) || n <= 0) throw new Error("--expires must be a unix timestamp in seconds");
|
|
90
|
+
flags.expires = n;
|
|
52
91
|
} else if (arg === "--format") {
|
|
53
92
|
if (value !== "csv" && value !== "sie") throw new Error('--format must be "csv" or "sie"');
|
|
54
93
|
flags.format = value;
|
|
94
|
+
} else if (arg === "--poll") {
|
|
95
|
+
flags.poll = value;
|
|
55
96
|
} else if (arg === "--from") flags.from = value;
|
|
56
97
|
else if (arg === "--to") flags.to = value;
|
|
57
98
|
else if (arg === "--company") flags.company = value;
|
|
@@ -66,21 +107,40 @@ function parseArgs(argv) {
|
|
|
66
107
|
}
|
|
67
108
|
function helpText() {
|
|
68
109
|
return [
|
|
69
|
-
"haven \u2014
|
|
110
|
+
"haven \u2014 set up and run a Haven agent from the terminal. It never signs.",
|
|
70
111
|
"",
|
|
71
112
|
"Usage: haven <command> [subcommand] [options]",
|
|
72
113
|
"",
|
|
73
114
|
"Auth:",
|
|
74
|
-
" login
|
|
115
|
+
" login Sign in. Opens a browser device-code approval by default \u2014",
|
|
116
|
+
" it prints a code and a link, and never asks for a password",
|
|
117
|
+
" login --email <e> Password path instead (prompt, or HAVEN_PASSWORD)",
|
|
118
|
+
" login --no-wait Print the link and exit instead of polling. Under --json,",
|
|
119
|
+
" pass it: you get the object back at once (it carries",
|
|
120
|
+
" device_code) and finish with `login --poll <device_code>`",
|
|
121
|
+
" login --poll <code> One poll round of a started device flow: exit 0 approved,",
|
|
122
|
+
" 3 still pending (the object carries retry_after), 4 denied",
|
|
75
123
|
" logout Clear the saved session",
|
|
76
|
-
" whoami Show the signed-in user",
|
|
124
|
+
" whoami Show the signed-in user, session expiry and API URL",
|
|
125
|
+
"",
|
|
126
|
+
"For agents:",
|
|
127
|
+
" guide Print the agent onboarding runbook (same text as /for-agents.md)",
|
|
128
|
+
" agents connect --name <n> --budget <amount> --token USDC --period <minutes>",
|
|
129
|
+
" Create a connection setup; prints the connector command and",
|
|
130
|
+
" the approval link for your human. --run executes it here.",
|
|
131
|
+
" agents connect --status <setupId> [--wait] Read one setup's status",
|
|
77
132
|
"",
|
|
78
133
|
"Read:",
|
|
79
134
|
" wallets list List your Haven wallets",
|
|
80
135
|
" wallets balances [--safe <id|address>] Token balances for a wallet",
|
|
136
|
+
" wallets funding [--safe <id|address>] [--wait] The paste-ready funding",
|
|
137
|
+
" instruction: what to send, where, on which chain.",
|
|
138
|
+
" --wait polls until the account counts as funded.",
|
|
81
139
|
" agents list List your agents",
|
|
82
140
|
" agents show <id> Show one agent + its budget",
|
|
83
141
|
" budget show <agentId> Show an agent's configured budget",
|
|
142
|
+
" budget show <agentId> --hashes Its delegation hashes \u2014 the second",
|
|
143
|
+
" argument `budget revoke` takes, printed nowhere else",
|
|
84
144
|
" activity list [--safe <id|address>] [--agent <id>] [--direction in|out] [--limit <n>] [--offset <n>]",
|
|
85
145
|
" activity export [filters] Emit CSV to stdout (--format csv, default)",
|
|
86
146
|
" activity export --format sie [--from <ISO>] [--to <ISO>] [--company <name>]",
|
|
@@ -89,19 +149,33 @@ function helpText() {
|
|
|
89
149
|
" contacts list List your address book",
|
|
90
150
|
"",
|
|
91
151
|
"Manage (backend-only \u2014 no on-chain signing):",
|
|
92
|
-
" agents pause
|
|
152
|
+
" agents pause <id> Stop the agent spending; keeps its budget",
|
|
153
|
+
" agents resume <id> Let it spend again",
|
|
93
154
|
" agents revoke <id> --yes Permanently revoke an agent",
|
|
94
155
|
" agents rotate-key <id> Issue a new API key (shown once)",
|
|
95
156
|
" agents rename <id> <name>",
|
|
96
157
|
" wallets rename <id> <name>",
|
|
97
|
-
" contacts add <name> <address>
|
|
158
|
+
" contacts add <name> <address> Save an address under a name",
|
|
159
|
+
" contacts remove <id> Forget one",
|
|
160
|
+
"",
|
|
161
|
+
"Budgets (construct-and-hand-off \u2014 the CLI never signs, #2539):",
|
|
162
|
+
" budget grant <agentId> --amount <n> --token USDC --period <minutes>",
|
|
163
|
+
" [--recipient <address>] [--expires <unix-s>] [--wait]",
|
|
164
|
+
" Prints a signing link; the human signs in the dashboard.",
|
|
165
|
+
" budget revoke <agentId> <delegationHash> [--wait]",
|
|
166
|
+
" Prints a revocation link; the human signs in the dashboard.",
|
|
98
167
|
"",
|
|
99
168
|
"Options:",
|
|
100
|
-
" --json
|
|
169
|
+
" --json One JSON value on stdout, prose on stderr, on every",
|
|
170
|
+
" command including refusals: { ok: false, error: { code, message, hint? } }",
|
|
101
171
|
" --yes, -y Skip the confirmation prompt for destructive actions",
|
|
102
|
-
" --api <url> Backend URL
|
|
172
|
+
" --api <url> Backend URL. Default: HAVEN_API_URL, else Haven's hosted",
|
|
173
|
+
" production backend \u2014 NOT localhost. On any other",
|
|
174
|
+
" deployment an omitted flag connects to production.",
|
|
103
175
|
" --help, --version",
|
|
104
176
|
"",
|
|
177
|
+
"Exit codes: 0 ok \xB7 1 failed \xB7 2 usage \xB7 3 not authenticated \xB7 4 refused \xB7 5 network",
|
|
178
|
+
"",
|
|
105
179
|
"On-chain actions (deploy, budgets, approvers, send) are signed in the",
|
|
106
180
|
"dashboard \u2014 this CLI reads and manages; it never holds your keys."
|
|
107
181
|
].join("\n");
|
|
@@ -110,10 +184,20 @@ function helpText() {
|
|
|
110
184
|
// src/api.ts
|
|
111
185
|
var CliApiError = class extends Error {
|
|
112
186
|
status;
|
|
113
|
-
|
|
187
|
+
/**
|
|
188
|
+
* The parsed response body, when there was one (#2526).
|
|
189
|
+
*
|
|
190
|
+
* The message is built FROM `body.error` for humans, and the device flow
|
|
191
|
+
* needs the same value as DATA: `authorization_pending` and `slow_down` are
|
|
192
|
+
* control signals in a poll loop, and branching on them by matching the
|
|
193
|
+
* message string would make the loop depend on prose that is free to change.
|
|
194
|
+
*/
|
|
195
|
+
body;
|
|
196
|
+
constructor(message, status, body) {
|
|
114
197
|
super(message);
|
|
115
198
|
this.name = "CliApiError";
|
|
116
199
|
this.status = status;
|
|
200
|
+
this.body = body;
|
|
117
201
|
}
|
|
118
202
|
};
|
|
119
203
|
function createCliApi({ baseUrl, token, fetchImpl = fetch }) {
|
|
@@ -142,7 +226,7 @@ function createCliApi({ baseUrl, token, fetchImpl = fetch }) {
|
|
|
142
226
|
const payload = text ? safeParse(text) : void 0;
|
|
143
227
|
if (!res.ok) {
|
|
144
228
|
const message = (payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string" ? payload.error : null) ?? `Request failed (HTTP ${res.status}).`;
|
|
145
|
-
throw new CliApiError(message, res.status);
|
|
229
|
+
throw new CliApiError(message, res.status, payload);
|
|
146
230
|
}
|
|
147
231
|
return payload;
|
|
148
232
|
}
|
|
@@ -244,63 +328,349 @@ function toCsv(headers, rows) {
|
|
|
244
328
|
return lines.join("\r\n");
|
|
245
329
|
}
|
|
246
330
|
|
|
331
|
+
// src/errors.ts
|
|
332
|
+
var EXIT = {
|
|
333
|
+
ok: 0,
|
|
334
|
+
/** Something failed and none of the specific codes below describe it. */
|
|
335
|
+
failed: 1,
|
|
336
|
+
/** The command line was wrong: unknown command, missing argument, bad flag. */
|
|
337
|
+
usage: 2,
|
|
338
|
+
/** No stored session, or the backend rejected the one we have. Log in again. */
|
|
339
|
+
notAuthenticated: 3,
|
|
340
|
+
/** Authenticated, and the backend refused anyway (403, 410, other 4xx). */
|
|
341
|
+
refused: 4,
|
|
342
|
+
/** The backend could not be reached at all. */
|
|
343
|
+
network: 5
|
|
344
|
+
};
|
|
345
|
+
var UsageError = class extends Error {
|
|
346
|
+
hint;
|
|
347
|
+
constructor(message, hint) {
|
|
348
|
+
super(message);
|
|
349
|
+
this.name = "UsageError";
|
|
350
|
+
this.hint = hint;
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
var HavenCliError = class extends Error {
|
|
354
|
+
exit;
|
|
355
|
+
hint;
|
|
356
|
+
constructor(message, exit, hint) {
|
|
357
|
+
super(message);
|
|
358
|
+
this.name = "HavenCliError";
|
|
359
|
+
this.exit = exit;
|
|
360
|
+
this.hint = hint;
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
function toFailure(err) {
|
|
364
|
+
if (err instanceof UsageError) {
|
|
365
|
+
return { code: "usage", exit: EXIT.usage, message: err.message, hint: err.hint };
|
|
366
|
+
}
|
|
367
|
+
if (err instanceof HavenCliError) {
|
|
368
|
+
const code = err.exit === EXIT.notAuthenticated ? "not_authenticated" : err.exit === EXIT.refused ? "refused" : err.exit === EXIT.network ? "network" : "failed";
|
|
369
|
+
return { code, exit: err.exit, message: err.message, hint: err.hint };
|
|
370
|
+
}
|
|
371
|
+
if (err instanceof CliApiError) {
|
|
372
|
+
if (err.status === 0) {
|
|
373
|
+
return {
|
|
374
|
+
code: "network",
|
|
375
|
+
exit: EXIT.network,
|
|
376
|
+
message: err.message,
|
|
377
|
+
hint: "Check the network and `--api`, then retry."
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
if (err.status === 401) {
|
|
381
|
+
return {
|
|
382
|
+
code: "not_authenticated",
|
|
383
|
+
exit: EXIT.notAuthenticated,
|
|
384
|
+
message: err.message,
|
|
385
|
+
// #2618: the device flow is named FIRST because it is the path an
|
|
386
|
+
// agent can actually run — an agent never holds its user's password,
|
|
387
|
+
// which is the runbook's first rule; the previous hint led with the
|
|
388
|
+
// credentials as if they were the normal route. HAVEN_EMAIL and
|
|
389
|
+
// HAVEN_PASSWORD remain for a HUMAN scripting non-interactively.
|
|
390
|
+
hint: "Run `haven login` (device flow \u2014 it never asks for a password; under --json pass --no-wait, then resume with `login --poll <device_code>`). HAVEN_EMAIL and HAVEN_PASSWORD are the human, non-interactive path."
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
if (err.status >= 400 && err.status < 500) {
|
|
394
|
+
return { code: "refused", exit: EXIT.refused, message: err.message };
|
|
395
|
+
}
|
|
396
|
+
return { code: "failed", exit: EXIT.failed, message: err.message };
|
|
397
|
+
}
|
|
398
|
+
return { code: "failed", exit: EXIT.failed, message: err instanceof Error ? err.message : String(err) };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// src/output.ts
|
|
402
|
+
function createOutput(json, out, err) {
|
|
403
|
+
return {
|
|
404
|
+
json,
|
|
405
|
+
data(payload, human) {
|
|
406
|
+
out(json ? JSON.stringify(payload, null, 2) : human());
|
|
407
|
+
},
|
|
408
|
+
text(content, meta) {
|
|
409
|
+
out(json ? JSON.stringify({ ok: true, ...meta, content }, null, 2) : content);
|
|
410
|
+
},
|
|
411
|
+
note(line) {
|
|
412
|
+
if (json) err(line);
|
|
413
|
+
else out(line);
|
|
414
|
+
},
|
|
415
|
+
failure(failure) {
|
|
416
|
+
const body = {
|
|
417
|
+
ok: false,
|
|
418
|
+
error: {
|
|
419
|
+
code: failure.code,
|
|
420
|
+
message: failure.message,
|
|
421
|
+
...failure.hint ? { hint: failure.hint } : {}
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
if (json) out(JSON.stringify(body, null, 2));
|
|
425
|
+
else err(failure.hint ? `${failure.message}
|
|
426
|
+
${failure.hint}` : failure.message);
|
|
427
|
+
}
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// src/agent-guidance-text.ts
|
|
432
|
+
var HAVEN_AGENT_RUNBOOK_MD = "# Haven for agents \u2014 set your user up to let you pay\n\nYou are an AI agent whose user has no Haven account yet, or has one but has not connected you.\n\n> Links below are paths on this same host: resolve them against the URL you fetched this file from.\n\n## What Haven is, in three sentences\n\nYour user gives you a **budget on their own account** \u2014 not their wallet, and not a key to their funds. The budget is a delegation they sign, enforced on-chain: a payment over it, to the wrong recipient, or past its expiry is refused at execution time, not by a dashboard promise. Haven constructs and relays the payments; you get an agent credential and a signing key made on your machine, and your user can revoke the budget without you and without Haven.\n\n## The sequence\n\nFour of the six steps are your user's \u2014 each needs a human signature or a human decision. The rest is yours. If they already have a funded account, start at step 3.\n\n1. **HUMAN \u2014 create the account.** Name, email, password, then a passkey (Face ID / Touch ID) or a wallet. Never offer to enter any of it: you must not have their password, and the passkey is bound to their device. With a terminal, `npx @haven_ai/cli@<channel> login --api <api-url>` afterwards gets you a scoped session for steps 3-4 (that flag or `HAVEN_API_URL`, on the first command \u2014 the session then remembers the backend; **the CLI's built-in default is Haven's hosted production backend**, so on any other deployment an omitted flag connects you somewhere real and wrong rather than failing) \u2014 they approve a code in the browser, you never hold their password. The `<channel>` in that command is the tag your deployment names \u2014 read it from `/.well-known/haven.json` (`packages.cli.channel`), never a tag you pick. Do not hold the process open while you wait: under `--json`, pass `--no-wait` to get the link object back at once, then poll it with `haven login --poll <device_code>` \u2014 one round per invocation, exit 3 while it is still pending, 0 once approved. It can set up agents and read the account; it cannot sign, approve a budget, move funds, or rotate any agent's keys.\n2. **HUMAN \u2014 fund it.** USDC only, no ETH: Haven sponsors the gas. `haven wallets funding` prints the address, the amount **and which chain** in one place; without a CLI session, the dashboard's funding card shows the address and amount and its Receive-funds screen names the chain. Read the chain off whichever you used and put it in your message \u2014 never assume one: a testnet deployment and production both call themselves Haven.\n3. **HUMAN \u2014 create the agent, set its budget**, and paste you the **setup prompt** it hands back. With a CLI session (step 1) you can do this step yourself: `haven agents connect --name <n> --budget <amount> --token USDC --period <minutes>` prints the same connector command and approval link; add `--run` to do step 4 too.\n4. **YOU \u2014 run the connector command** in that prompt (below). It makes your signing key locally, registering only the public address.\n5. **HUMAN \u2014 approve the budget** with their passkey, in the Haven tab they created the agent in: it advances to the approval step by itself once your run registers.\n6. **YOU \u2014 verify, then pay.**\n\n## Budget changes later (second token, raise, revoke)\n\nThe first budget rides the setup above. After the agent exists, a CLI session can construct the LATER changes \u2014 a second token, a bigger amount, a recipient pin, a stop \u2014 and hand your user a link; the human still signs, every time:\n\n```\nhaven budget grant <agentId> --amount <n> --token USDC --period <minutes> [--recipient <address>] [--wait]\nhaven budget revoke <agentId> <delegationHash> [--wait]\n```\n\nThe CLI never signs: it prints a dashboard link, your user opens it and signs with their passkey or wallet. `--wait` polls until the human's signature lands. The hash for `revoke` is in `haven agents show <agentId>`.\n\n## Hand-off scripts\n\nSend these as your own message, `<host>` replaced by the host you fetched this file from. Say what you cannot do, not only what they must.\n\n**Before signup** (step 1):\n\n> I can do everything except the parts that need your signature. Please open `<host>/signup?next=/agents&via=agent` \u2014 name, email, password, then a passkey (Face ID / Touch ID) or a wallet. That is your account's key: I should not have it, and I will never ask for your password. Already have one? `<host>/login?next=/agents`.\n\n**At the passkey step:**\n\n> The passkey must be made on your own device \u2014 it is what keeps the account yours, so nobody, Haven included, can move your funds without you. If this browser cannot, open `<host>/onboarding?next=/agents` on your phone and finish there.\n\n**At funding** (step 2):\n\n> Your Haven account needs USDC before I can pay for anything \u2014 USDC only, no ETH: Haven sponsors the gas. Before you send anything, let me get you the exact address **and network** \u2014 `haven wallets funding` prints both, and the funding card on `<host>/dashboard` shows the same. Please do not send to an address or a chain I have not confirmed with you; a small amount first is fine.\n\n**At the budget** (step 3):\n\n> On `<host>/agents`, create an agent for me and set a budget \u2014 say 25 USDC per day. That is the limit I cannot exceed. It hands back a setup prompt: paste it to me and I run it here.\n\n**At budget approval** (step 5) \u2014 the moment your run reports that approval is required, before anything else. Send the first if your run carried `approval.url`, the second if it did not.\n\n> Setup is done on my side. Approve the budget here: <approval.url>. Approve it with your passkey; nothing can be spent until you do.\n\n> Setup is done on my side. Go back to the Haven tab where you created the agent \u2014 it should now be asking you to approve the budget. Approve it with your passkey; nothing can be spent until you do.\n\n## What you run\n\nThe setup prompt carries a **connector command** shaped like this:\n\n```\nnpx -y @haven_ai/connect@<channel> --setup EXAMPLE-SETUP-TOKEN-NOT-REAL --api <api-url> --ack-local-tools\n```\n\nRun the prompt's version, not this one: the token is one-time, the API URL is the backend's own, and `<channel>` is the npm tag your prompt names \u2014 never a tag you pick. The rules below come from that prompt, so \"me\" in them is your user, not Haven.\n\n- If you are an AI agent running this command yourself rather than a human pasting it, you should append --json: the connector then emits one machine-readable, secret-free result object on stdout with progress on stderr, and returns promptly instead of blocking while it waits for the budget approval.\n- When a --json outcome reports approval.required: true, your first action must be to relay the approval instruction to me in your own reply \u2014 if the outcome carries approval.url, give me that link; otherwise tell me to return to Haven and approve this agent's budget \u2014 before verifying the connection, restarting anything, or any other step. Never build that link yourself: relay the one the outcome gave you or none at all. Any restart the outcome asks for is a separate instruction to give me afterwards, once the approval is done.\n- If you ran the command without --json, the connector waits for the approval itself and prints its next steps when it finishes: relay the budget-approval instruction to me \u2014 the approval link if those steps printed one, otherwise that you need to return to Haven and approve this agent's budget \u2014 only if those printed next steps still ask for it. If they report the budget as already approved, there is nothing for me to approve.\n- Only two changes to the command above are permitted, and no others: appending --json, and \u2014 only if the connector refuses because it could not determine the agent runtime \u2014 re-running it once with --runtime <name> added, naming the harness you are running in, using one of the values that refusal lists. Never invent a runtime name and never change anything else.\n- Do not print private keys, API keys, credential file contents, or config secrets in chat or logs.\n\nIf the approval wait times out, nothing is lost \u2014 your agent is registered and the budget is still waiting to be approved. Send your user the `approval.url` your run reported, or, if it carried none, ask them to finish it in that same Haven tab. The outcome carries no setup id, so never assemble an approval link out of parts \u2014 relay the whole one it gave you or none at all.\n\n## How to verify\n\nCall `haven_get_agent`, one of the Haven MCP tools the connector wires into your runtime in step 4. It returns identity plus `spend_authority_readiness`:\n\n- `ready` \u2014 a budget is live; you can pay.\n- `needs_approval` \u2014 the connector finished, nobody approved yet. Ask your user again, in their Haven tab; there is no queue to wait in.\n- `revoked` \u2014 the credential is not active; ask your user to create a new agent.\n\n`ready` covers hosted identity and the budget only, not your local signer. Check that with `npx -y @haven_ai/connect@<channel> --doctor`, the same tag your prompt named \u2014 a separate command, so the two-changes rule does not bind it.\n\n## If you cannot open a browser\n\nNothing here needs you to. Steps 1-3 are links: hand your user the full `<host>/\u2026` URL and ask them to say when it is done. Step 5 is a link only when your run reported one in `approval.url` \u2014 otherwise it is the tab they already have open, as above. Then poll `haven_get_agent` until it reads `ready`. Do not route around the sign-in wall \u2014 it makes the account theirs, not yours.\n\n## Vocabulary\n\n| Term | What it is |\n|---|---|\n| **setup prompt** | The text the dashboard hands your user to paste to you. Carries the command and its rules. |\n| **connector command** | The `npx -y @haven_ai/connect@\u2026` line you run. One-time token, one use. |\n| **agent credential** | Your API key (`sk_agent_\u2026`), written to `~/.haven`. It identifies you; alone it cannot move money. |\n| **delegate key** | Your signing key, made on this machine and never sent anywhere. |\n| **budget** | The on-chain delegation your user signed. It authorises the payment; Haven constructs and relays it. |\n\nNext: [your agent hit a 402](/402.md) \xB7 [everything agent-readable](/llms.txt)\n";
|
|
433
|
+
|
|
434
|
+
// src/token.ts
|
|
435
|
+
function sessionExpiry(token) {
|
|
436
|
+
const segment = token.split(".")[1];
|
|
437
|
+
if (!segment) return null;
|
|
438
|
+
try {
|
|
439
|
+
const json = Buffer.from(segment.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
|
|
440
|
+
const claims = JSON.parse(json);
|
|
441
|
+
if (typeof claims.exp !== "number" || !Number.isFinite(claims.exp)) return null;
|
|
442
|
+
return new Date(claims.exp * 1e3).toISOString();
|
|
443
|
+
} catch {
|
|
444
|
+
return null;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// src/amount.ts
|
|
449
|
+
function decimalLabel(decimals) {
|
|
450
|
+
return decimals === 1 ? "1 decimal place" : `${decimals} decimal places`;
|
|
451
|
+
}
|
|
452
|
+
function parseTokenAmount(input, decimals, tokenSymbol) {
|
|
453
|
+
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36) {
|
|
454
|
+
return { ok: false, message: `Unusable token decimals: ${String(decimals)}` };
|
|
455
|
+
}
|
|
456
|
+
const trimmed = String(input ?? "").trim();
|
|
457
|
+
const human = trimmed.startsWith(".") ? `0${trimmed}` : trimmed;
|
|
458
|
+
const label = tokenSymbol ? ` ${tokenSymbol}` : "";
|
|
459
|
+
if (human === "") return { ok: false, message: "Enter an amount greater than 0" };
|
|
460
|
+
if (!/^\d+(?:\.\d+)?$/.test(human)) {
|
|
461
|
+
return { ok: false, message: `Enter a valid${label} amount \u2014 digits, with an optional decimal point` };
|
|
462
|
+
}
|
|
463
|
+
const [whole, fraction = ""] = human.split(".");
|
|
464
|
+
if (fraction.length > decimals) {
|
|
465
|
+
return {
|
|
466
|
+
ok: false,
|
|
467
|
+
message: `${tokenSymbol ?? "This token"} supports up to ${decimalLabel(decimals)}`
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
const atomic = BigInt(`${whole}${fraction.padEnd(decimals, "0")}`);
|
|
471
|
+
if (atomic === 0n) return { ok: false, message: "Enter an amount greater than 0" };
|
|
472
|
+
return { ok: true, human, atomic: atomic.toString() };
|
|
473
|
+
}
|
|
474
|
+
function splitConnectorCommand(command) {
|
|
475
|
+
const argv = [];
|
|
476
|
+
let current = "";
|
|
477
|
+
let quoted = false;
|
|
478
|
+
let started = false;
|
|
479
|
+
for (let i = 0; i < command.length; i += 1) {
|
|
480
|
+
const ch = command[i];
|
|
481
|
+
if (quoted) {
|
|
482
|
+
if (ch === "'") quoted = false;
|
|
483
|
+
else current += ch;
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
if (ch === "'") {
|
|
487
|
+
quoted = true;
|
|
488
|
+
started = true;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (ch === " " || ch === " ") {
|
|
492
|
+
if (started) argv.push(current);
|
|
493
|
+
current = "";
|
|
494
|
+
started = false;
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (ch === "\\") {
|
|
498
|
+
if (command[i + 1] === "'") {
|
|
499
|
+
current += "'";
|
|
500
|
+
started = true;
|
|
501
|
+
i += 1;
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
throw new Error("Refusing to run a connector command with a backslash escape");
|
|
505
|
+
}
|
|
506
|
+
if (ch === '"' || ch === "`" || ch === "$" || ch === "|" || ch === "&" || ch === ";" || ch === ">" || ch === "<" || ch === "(" || ch === ")" || ch === "\n") {
|
|
507
|
+
throw new Error(`Refusing to run a connector command containing ${JSON.stringify(ch)}`);
|
|
508
|
+
}
|
|
509
|
+
current += ch;
|
|
510
|
+
started = true;
|
|
511
|
+
}
|
|
512
|
+
if (quoted) throw new Error("Refusing to run a connector command with an unterminated quote");
|
|
513
|
+
if (started) argv.push(current);
|
|
514
|
+
if (argv.length === 0) throw new Error("The backend returned an empty connector command");
|
|
515
|
+
return argv;
|
|
516
|
+
}
|
|
517
|
+
var nodeSpawner = (command, args, onStderr) => new Promise((resolve2, reject) => {
|
|
518
|
+
const child = child_process.spawn(command, args, { shell: false, stdio: ["ignore", "pipe", "pipe"] });
|
|
519
|
+
let stdout = "";
|
|
520
|
+
let stderr = "";
|
|
521
|
+
child.stdout.setEncoding("utf8");
|
|
522
|
+
child.stderr.setEncoding("utf8");
|
|
523
|
+
child.stdout.on("data", (chunk) => {
|
|
524
|
+
stdout += chunk;
|
|
525
|
+
});
|
|
526
|
+
child.stderr.on("data", (chunk) => {
|
|
527
|
+
stderr += chunk;
|
|
528
|
+
onStderr(chunk);
|
|
529
|
+
});
|
|
530
|
+
child.on("error", reject);
|
|
531
|
+
child.on("close", (code) => resolve2({ stdout, stderr, exitCode: code ?? 1 }));
|
|
532
|
+
});
|
|
533
|
+
function parseOutcome(stdout) {
|
|
534
|
+
const noise = [];
|
|
535
|
+
let outcome = null;
|
|
536
|
+
let depth = 0;
|
|
537
|
+
let start = -1;
|
|
538
|
+
let inString = false;
|
|
539
|
+
let escaped = false;
|
|
540
|
+
let plain = "";
|
|
541
|
+
for (let i = 0; i < stdout.length; i += 1) {
|
|
542
|
+
const ch = stdout[i];
|
|
543
|
+
if (depth === 0) {
|
|
544
|
+
if (ch === "{") {
|
|
545
|
+
depth = 1;
|
|
546
|
+
start = i;
|
|
547
|
+
inString = false;
|
|
548
|
+
escaped = false;
|
|
549
|
+
} else {
|
|
550
|
+
plain += ch;
|
|
551
|
+
}
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (inString) {
|
|
555
|
+
if (escaped) escaped = false;
|
|
556
|
+
else if (ch === "\\") escaped = true;
|
|
557
|
+
else if (ch === '"') inString = false;
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
if (ch === '"') inString = true;
|
|
561
|
+
else if (ch === "{") depth += 1;
|
|
562
|
+
else if (ch === "}") {
|
|
563
|
+
depth -= 1;
|
|
564
|
+
if (depth === 0) {
|
|
565
|
+
const candidate = stdout.slice(start, i + 1);
|
|
566
|
+
try {
|
|
567
|
+
outcome = JSON.parse(candidate);
|
|
568
|
+
} catch {
|
|
569
|
+
plain += candidate;
|
|
570
|
+
}
|
|
571
|
+
start = -1;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
if (depth > 0 && start >= 0) plain += stdout.slice(start);
|
|
576
|
+
for (const line of plain.split("\n")) {
|
|
577
|
+
const text = line.trim();
|
|
578
|
+
if (text) noise.push(text);
|
|
579
|
+
}
|
|
580
|
+
return { outcome, noise: noise.join("\n") };
|
|
581
|
+
}
|
|
582
|
+
function isRefusal(outcome) {
|
|
583
|
+
return Boolean(outcome?.error);
|
|
584
|
+
}
|
|
585
|
+
function relayLine(outcome) {
|
|
586
|
+
if (!outcome) return null;
|
|
587
|
+
if (outcome.error) {
|
|
588
|
+
const message = outcome.error.message?.trim();
|
|
589
|
+
const next = outcome.error.next_action ?? outcome.next_action;
|
|
590
|
+
return message ? `${message}${next ? ` (next: ${next})` : ""}` : `The connector refused${next ? `: ${next}` : "."}`;
|
|
591
|
+
}
|
|
592
|
+
if (outcome.approval?.required) {
|
|
593
|
+
return outcome.approval.url ? `Approve the budget to finish: ${outcome.approval.url}` : "Approve the budget in your Haven tab to finish.";
|
|
594
|
+
}
|
|
595
|
+
return null;
|
|
596
|
+
}
|
|
597
|
+
async function runConnector(connectorCommand, spawner, onStderr) {
|
|
598
|
+
const argv = splitConnectorCommand(connectorCommand);
|
|
599
|
+
const args = [...argv.slice(1), "--json"];
|
|
600
|
+
const { stdout, stderr, exitCode } = await spawner(argv[0], args, onStderr);
|
|
601
|
+
const { outcome, noise } = parseOutcome(stdout);
|
|
602
|
+
return { outcome, exitCode, stderr, stdoutNoise: noise };
|
|
603
|
+
}
|
|
604
|
+
|
|
247
605
|
// src/commands.ts
|
|
248
606
|
var DEFAULT_API = "https://havenbackend-production-8a00.up.railway.app";
|
|
249
|
-
var CLI_VERSION = "0.1.
|
|
607
|
+
var CLI_VERSION = "0.1.36-alpha.0";
|
|
250
608
|
async function run(argv, deps = {}) {
|
|
609
|
+
const out = deps.out ?? ((l) => process.stdout.write(`${l}
|
|
610
|
+
`));
|
|
611
|
+
const err = deps.err ?? ((l) => process.stderr.write(`${l}
|
|
612
|
+
`));
|
|
613
|
+
const json = argv.includes("--json");
|
|
614
|
+
const o = createOutput(json, out, err);
|
|
251
615
|
const d = {
|
|
252
616
|
sessionStore: deps.sessionStore ?? createSessionStore(),
|
|
253
617
|
makeApi: deps.makeApi ?? ((baseUrl, token) => createCliApi({ baseUrl, token })),
|
|
254
618
|
promptPassword: deps.promptPassword ?? (() => Promise.reject(new Error("No password input available"))),
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
env: deps.env ?? process.env
|
|
619
|
+
sleep: deps.sleep ?? ((ms) => new Promise((resolve2) => setTimeout(resolve2, ms))),
|
|
620
|
+
spawner: deps.spawner ?? nodeSpawner,
|
|
621
|
+
out,
|
|
622
|
+
err,
|
|
623
|
+
env: deps.env ?? process.env,
|
|
624
|
+
o
|
|
260
625
|
};
|
|
261
626
|
let args;
|
|
262
627
|
try {
|
|
263
628
|
args = parseArgs(argv);
|
|
264
629
|
} catch (e) {
|
|
265
|
-
d
|
|
266
|
-
return 1;
|
|
630
|
+
return fail(d, new UsageError(e instanceof Error ? e.message : String(e), "Run `haven --help`."));
|
|
267
631
|
}
|
|
268
632
|
if (args.flags.version) {
|
|
269
|
-
|
|
270
|
-
return
|
|
633
|
+
o.data({ version: CLI_VERSION }, () => CLI_VERSION);
|
|
634
|
+
return EXIT.ok;
|
|
271
635
|
}
|
|
272
636
|
if (args.flags.help || !args.command) {
|
|
273
|
-
|
|
274
|
-
return
|
|
637
|
+
o.data({ help: helpText() }, () => helpText());
|
|
638
|
+
return EXIT.ok;
|
|
275
639
|
}
|
|
276
640
|
try {
|
|
277
641
|
return await dispatch(args, d);
|
|
278
642
|
} catch (e) {
|
|
279
|
-
|
|
280
|
-
d.err(e.message);
|
|
281
|
-
return e.status === 401 ? 2 : 1;
|
|
282
|
-
}
|
|
283
|
-
d.err(e instanceof Error ? e.message : String(e));
|
|
284
|
-
return 1;
|
|
643
|
+
return fail(d, e);
|
|
285
644
|
}
|
|
286
645
|
}
|
|
646
|
+
function fail(d, err) {
|
|
647
|
+
const failure = toFailure(err);
|
|
648
|
+
d.o.failure(failure);
|
|
649
|
+
return failure.exit;
|
|
650
|
+
}
|
|
287
651
|
async function dispatch(args, d) {
|
|
288
652
|
const key = args.sub ? `${args.command} ${args.sub}` : args.command;
|
|
289
653
|
switch (key) {
|
|
654
|
+
case "guide":
|
|
655
|
+
return cmdGuide(args, d);
|
|
290
656
|
case "login":
|
|
291
657
|
return cmdLogin(args, d);
|
|
292
658
|
case "logout":
|
|
293
|
-
return cmdLogout(d);
|
|
659
|
+
return cmdLogout(args, d);
|
|
294
660
|
case "whoami":
|
|
295
661
|
return cmdWhoami(args, d);
|
|
296
662
|
case "wallets list":
|
|
297
663
|
return cmdWalletsList(args, d);
|
|
298
664
|
case "wallets balances":
|
|
299
665
|
return cmdWalletsBalances(args, d);
|
|
666
|
+
case "wallets funding":
|
|
667
|
+
return cmdWalletsFunding(args, d);
|
|
300
668
|
case "agents list":
|
|
301
669
|
return cmdAgentsList(args, d);
|
|
302
670
|
case "agents show":
|
|
303
671
|
return cmdAgentsShow(args, d);
|
|
672
|
+
case "agents connect":
|
|
673
|
+
return cmdAgentsConnect(args, d);
|
|
304
674
|
case "agents pause":
|
|
305
675
|
return cmdAgentLifecycle(args, d, "pause");
|
|
306
676
|
case "agents resume":
|
|
@@ -313,6 +683,10 @@ async function dispatch(args, d) {
|
|
|
313
683
|
return cmdAgentRename(args, d);
|
|
314
684
|
case "budget show":
|
|
315
685
|
return cmdBudgetShow(args, d);
|
|
686
|
+
case "budget grant":
|
|
687
|
+
return cmdBudgetGrant(args, d);
|
|
688
|
+
case "budget revoke":
|
|
689
|
+
return cmdBudgetRevoke(args, d);
|
|
316
690
|
case "wallets rename":
|
|
317
691
|
return cmdWalletRename(args, d);
|
|
318
692
|
case "activity list":
|
|
@@ -328,8 +702,7 @@ async function dispatch(args, d) {
|
|
|
328
702
|
case "contacts remove":
|
|
329
703
|
return cmdContactsRemove(args, d);
|
|
330
704
|
default:
|
|
331
|
-
|
|
332
|
-
return 1;
|
|
705
|
+
throw new UsageError(`Unknown command: ${key}.`, "Run `haven --help` for the command list.");
|
|
333
706
|
}
|
|
334
707
|
}
|
|
335
708
|
function baseUrlFor(args, d, session) {
|
|
@@ -337,40 +710,167 @@ function baseUrlFor(args, d, session) {
|
|
|
337
710
|
}
|
|
338
711
|
async function authed(args, d) {
|
|
339
712
|
const session = await d.sessionStore.load();
|
|
340
|
-
if (!session) throw new CliApiError("Not authenticated.
|
|
713
|
+
if (!session) throw new CliApiError("Not authenticated.", 401);
|
|
341
714
|
return { session, api: d.makeApi(baseUrlFor(args, d, session), session.token) };
|
|
342
715
|
}
|
|
343
|
-
function emit(d,
|
|
344
|
-
d.
|
|
716
|
+
function emit(d, _json, data, human) {
|
|
717
|
+
d.o.data(data, human);
|
|
718
|
+
}
|
|
719
|
+
async function cmdGuide(_args, d) {
|
|
720
|
+
d.o.data({ ok: true, format: "markdown", content: HAVEN_AGENT_RUNBOOK_MD }, () => HAVEN_AGENT_RUNBOOK_MD);
|
|
721
|
+
return EXIT.ok;
|
|
722
|
+
}
|
|
723
|
+
async function deviceLogin(args, d, baseUrl) {
|
|
724
|
+
const api = d.makeApi(baseUrl);
|
|
725
|
+
const label = d.env.HAVEN_CLIENT_LABEL ?? `Haven CLI on ${d.env.HOSTNAME ?? "this machine"}`;
|
|
726
|
+
const start = await api.post("/auth/device/start", { client_label: label });
|
|
727
|
+
const deadline = Date.now() + start.expires_in * 1e3;
|
|
728
|
+
d.o.data(
|
|
729
|
+
{
|
|
730
|
+
ok: true,
|
|
731
|
+
verification_url: start.verification_url,
|
|
732
|
+
user_code: start.user_code,
|
|
733
|
+
device_code: start.device_code,
|
|
734
|
+
expires_at: new Date(deadline).toISOString()
|
|
735
|
+
},
|
|
736
|
+
() => `Open ${start.verification_url}
|
|
737
|
+
and approve the code ${start.user_code}.
|
|
738
|
+
It expires in ${Math.round(start.expires_in / 60)} minutes.`
|
|
739
|
+
);
|
|
740
|
+
if (args.flags.noWait) return EXIT.ok;
|
|
741
|
+
const jsonWaitMs = args.flags.json ? 3e4 : start.expires_in * 1e3;
|
|
742
|
+
const waitUntil = Date.now() + jsonWaitMs;
|
|
743
|
+
let interval = start.interval * 1e3;
|
|
744
|
+
for (; ; ) {
|
|
745
|
+
if (Date.now() >= deadline) {
|
|
746
|
+
throw new HavenCliError("The code expired before it was approved.", EXIT.notAuthenticated);
|
|
747
|
+
}
|
|
748
|
+
if (Date.now() >= waitUntil) {
|
|
749
|
+
d.o.data(
|
|
750
|
+
{ status: "pending", device_code: start.device_code, retry_after: Math.round(interval / 1e3) },
|
|
751
|
+
() => "Still waiting for approval \u2014 run the same command again to keep waiting."
|
|
752
|
+
);
|
|
753
|
+
return EXIT.notAuthenticated;
|
|
754
|
+
}
|
|
755
|
+
await d.sleep(interval);
|
|
756
|
+
let res = null;
|
|
757
|
+
try {
|
|
758
|
+
res = await api.post("/auth/device/token", {
|
|
759
|
+
device_code: start.device_code
|
|
760
|
+
});
|
|
761
|
+
} catch (err) {
|
|
762
|
+
const code = deviceErrorCode(err);
|
|
763
|
+
if (code === "authorization_pending") continue;
|
|
764
|
+
if (code === "slow_down") {
|
|
765
|
+
interval += 5e3;
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
if (code === "access_denied") {
|
|
769
|
+
throw new HavenCliError("The request was denied.", EXIT.refused);
|
|
770
|
+
}
|
|
771
|
+
if (code === "expired_token") {
|
|
772
|
+
throw new HavenCliError("The code expired before it was approved.", EXIT.notAuthenticated);
|
|
773
|
+
}
|
|
774
|
+
throw err;
|
|
775
|
+
}
|
|
776
|
+
await d.sessionStore.save({ token: res.token, apiBaseUrl: baseUrl, user: res.user });
|
|
777
|
+
emit(
|
|
778
|
+
d,
|
|
779
|
+
args.flags.json,
|
|
780
|
+
{ ok: true, email: res.user.email, expires_at: sessionExpiry(res.token), user: res.user, apiBaseUrl: baseUrl },
|
|
781
|
+
() => `Signed in as ${res.user.email}.`
|
|
782
|
+
);
|
|
783
|
+
return EXIT.ok;
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
function deviceErrorCode(err) {
|
|
787
|
+
const body = err?.body;
|
|
788
|
+
return typeof body?.error === "string" ? body.error : null;
|
|
789
|
+
}
|
|
790
|
+
var POLL_RETRY_AFTER_SECONDS = 5;
|
|
791
|
+
async function devicePollOnce(args, d, baseUrl, deviceCode) {
|
|
792
|
+
const api = d.makeApi(baseUrl);
|
|
793
|
+
let res = null;
|
|
794
|
+
try {
|
|
795
|
+
res = await api.post("/auth/device/token", {
|
|
796
|
+
device_code: deviceCode
|
|
797
|
+
});
|
|
798
|
+
} catch (err) {
|
|
799
|
+
const code = deviceErrorCode(err);
|
|
800
|
+
if (code === "authorization_pending") {
|
|
801
|
+
d.o.data(
|
|
802
|
+
{ status: "pending", device_code: deviceCode, retry_after: POLL_RETRY_AFTER_SECONDS },
|
|
803
|
+
() => "Not approved yet \u2014 poll again shortly."
|
|
804
|
+
);
|
|
805
|
+
return EXIT.notAuthenticated;
|
|
806
|
+
}
|
|
807
|
+
if (code === "slow_down") {
|
|
808
|
+
d.o.data(
|
|
809
|
+
{
|
|
810
|
+
status: "pending",
|
|
811
|
+
device_code: deviceCode,
|
|
812
|
+
retry_after: POLL_RETRY_AFTER_SECONDS + 5
|
|
813
|
+
},
|
|
814
|
+
() => "Polling too fast \u2014 wait a moment longer, then poll again."
|
|
815
|
+
);
|
|
816
|
+
return EXIT.notAuthenticated;
|
|
817
|
+
}
|
|
818
|
+
if (code === "access_denied") {
|
|
819
|
+
throw new HavenCliError("The request was denied.", EXIT.refused);
|
|
820
|
+
}
|
|
821
|
+
if (code === "expired_token") {
|
|
822
|
+
throw new HavenCliError("The code expired before it was approved.", EXIT.notAuthenticated);
|
|
823
|
+
}
|
|
824
|
+
throw err;
|
|
825
|
+
}
|
|
826
|
+
await d.sessionStore.save({ token: res.token, apiBaseUrl: baseUrl, user: res.user });
|
|
827
|
+
emit(
|
|
828
|
+
d,
|
|
829
|
+
args.flags.json,
|
|
830
|
+
{ ok: true, email: res.user.email, expires_at: sessionExpiry(res.token), user: res.user, apiBaseUrl: baseUrl },
|
|
831
|
+
() => `Signed in as ${res.user.email}.`
|
|
832
|
+
);
|
|
833
|
+
return EXIT.ok;
|
|
345
834
|
}
|
|
346
835
|
async function cmdLogin(args, d) {
|
|
836
|
+
if (args.flags.poll) {
|
|
837
|
+
return devicePollOnce(args, d, baseUrlFor(args, d, null), args.flags.poll);
|
|
838
|
+
}
|
|
347
839
|
const email = args.flags.email ?? d.env.HAVEN_EMAIL;
|
|
348
840
|
if (!email) {
|
|
349
|
-
|
|
350
|
-
return 1;
|
|
841
|
+
return deviceLogin(args, d, baseUrlFor(args, d, null));
|
|
351
842
|
}
|
|
352
843
|
const password = d.env.HAVEN_PASSWORD ?? await d.promptPassword();
|
|
353
844
|
if (!password) {
|
|
354
|
-
|
|
355
|
-
return 1;
|
|
845
|
+
throw new UsageError("A password is required.", "Set HAVEN_PASSWORD for a non-interactive run.");
|
|
356
846
|
}
|
|
357
847
|
const baseUrl = baseUrlFor(args, d, null);
|
|
358
848
|
const api = d.makeApi(baseUrl);
|
|
359
849
|
const res = await api.post("/auth/login", { email, password });
|
|
360
850
|
await d.sessionStore.save({ token: res.token, apiBaseUrl: baseUrl, user: res.user });
|
|
361
|
-
emit(
|
|
362
|
-
|
|
851
|
+
emit(
|
|
852
|
+
d,
|
|
853
|
+
args.flags.json,
|
|
854
|
+
{ ok: true, email: res.user.email, expires_at: sessionExpiry(res.token), user: res.user, apiBaseUrl: baseUrl },
|
|
855
|
+
() => `Signed in as ${res.user.email}.`
|
|
856
|
+
);
|
|
857
|
+
return EXIT.ok;
|
|
363
858
|
}
|
|
364
|
-
async function cmdLogout(d) {
|
|
859
|
+
async function cmdLogout(args, d) {
|
|
365
860
|
await d.sessionStore.clear();
|
|
366
|
-
d.
|
|
367
|
-
return
|
|
861
|
+
emit(d, args.flags.json, { ok: true, signed_out: true }, () => "Signed out.");
|
|
862
|
+
return EXIT.ok;
|
|
368
863
|
}
|
|
369
864
|
async function cmdWhoami(args, d) {
|
|
370
|
-
const { api } = await authed(args, d);
|
|
865
|
+
const { session, api } = await authed(args, d);
|
|
371
866
|
const user = await api.get("/auth/me");
|
|
372
|
-
emit(
|
|
373
|
-
|
|
867
|
+
emit(
|
|
868
|
+
d,
|
|
869
|
+
args.flags.json,
|
|
870
|
+
{ ...user, id: user.id, email: user.email, expires_at: sessionExpiry(session.token), api_url: session.apiBaseUrl },
|
|
871
|
+
() => `${user.email}${user.name ? ` (${user.name})` : ""}`
|
|
872
|
+
);
|
|
873
|
+
return EXIT.ok;
|
|
374
874
|
}
|
|
375
875
|
async function cmdWalletsList(args, d) {
|
|
376
876
|
const { api } = await authed(args, d);
|
|
@@ -384,15 +884,15 @@ async function cmdWalletsList(args, d) {
|
|
|
384
884
|
safes.map((s) => [s.name, chainName(s.chain_id), truncateAddress(s.safe_address), s.is_default ? "\u2713" : ""])
|
|
385
885
|
)
|
|
386
886
|
);
|
|
387
|
-
return
|
|
887
|
+
return EXIT.ok;
|
|
388
888
|
}
|
|
389
889
|
async function cmdWalletsBalances(args, d) {
|
|
390
890
|
const { api } = await authed(args, d);
|
|
391
891
|
const { safes } = await api.get("/user/safes");
|
|
392
892
|
const safe = pickSafe(safes, args.flags.safe);
|
|
393
893
|
if (!safe) {
|
|
394
|
-
|
|
395
|
-
|
|
894
|
+
if (args.flags.safe) throw new UsageError(`No wallet matches "${args.flags.safe}".`);
|
|
895
|
+
throw new CliApiError("No Haven wallet found.", 404);
|
|
396
896
|
}
|
|
397
897
|
const { balances } = await api.get(
|
|
398
898
|
`/balances/${safe.safe_address}?chain_id=${safe.chain_id}`
|
|
@@ -406,13 +906,72 @@ async function cmdWalletsBalances(args, d) {
|
|
|
406
906
|
balances.length === 0 ? " (no balances)" : table(["TOKEN", "BALANCE"], balances.map((b) => [b.symbol, b.formatted]))
|
|
407
907
|
].join("\n")
|
|
408
908
|
);
|
|
409
|
-
return
|
|
909
|
+
return EXIT.ok;
|
|
410
910
|
}
|
|
411
911
|
function pickSafe(safes, ref) {
|
|
412
912
|
if (!ref) return safes.find((s) => s.is_default) ?? safes[0];
|
|
413
913
|
const lower = ref.toLowerCase();
|
|
414
914
|
return safes.find((s) => s.id === ref || s.safe_address.toLowerCase() === lower);
|
|
415
915
|
}
|
|
916
|
+
async function cmdWalletsFunding(args, d) {
|
|
917
|
+
const { api } = await authed(args, d);
|
|
918
|
+
const { safes } = await api.get("/user/safes");
|
|
919
|
+
const safe = pickSafe(safes, args.flags.safe);
|
|
920
|
+
if (!safe) {
|
|
921
|
+
if (args.flags.safe) throw new UsageError(`No wallet matches "${args.flags.safe}".`);
|
|
922
|
+
throw new CliApiError("No Haven wallet found.", 404);
|
|
923
|
+
}
|
|
924
|
+
const funding = await api.get(`/user/safes/${safe.id}/funding`);
|
|
925
|
+
if (!args.flags.wait) {
|
|
926
|
+
emitFunding(d, funding);
|
|
927
|
+
return EXIT.ok;
|
|
928
|
+
}
|
|
929
|
+
const started = Date.now();
|
|
930
|
+
const pollMs = Number(d.env.HAVEN_FUNDING_POLL_MS ?? "5000");
|
|
931
|
+
const waitMs = Number(d.env.HAVEN_FUNDING_WAIT_MS ?? String(2 * 60 * 60 * 1e3));
|
|
932
|
+
if (!Number.isFinite(pollMs) || pollMs <= 0 || !Number.isFinite(waitMs) || waitMs <= 0) {
|
|
933
|
+
throw new UsageError("HAVEN_FUNDING_POLL_MS / HAVEN_FUNDING_WAIT_MS must be positive numbers of milliseconds");
|
|
934
|
+
}
|
|
935
|
+
let current = funding;
|
|
936
|
+
for (; ; ) {
|
|
937
|
+
const spent = Date.now() - started;
|
|
938
|
+
if (spent >= waitMs) {
|
|
939
|
+
throw new HavenCliError(
|
|
940
|
+
`Still not funded after ${elapsedLabel(spent)} \u2014 the transfer may not have landed yet. Check the explorer link, then re-run.`,
|
|
941
|
+
EXIT.failed
|
|
942
|
+
);
|
|
943
|
+
}
|
|
944
|
+
await d.sleep(pollMs);
|
|
945
|
+
current = await api.get(`/user/safes/${safe.id}/funding`);
|
|
946
|
+
if (current.funded) break;
|
|
947
|
+
d.o.note(`Still waiting after ${elapsedLabel(Date.now() - started)} \u2014 funded: no.`);
|
|
948
|
+
}
|
|
949
|
+
d.o.note(`Account shows funded after ${elapsedLabel(Date.now() - started)}.`);
|
|
950
|
+
emitFunding(d, current);
|
|
951
|
+
return EXIT.ok;
|
|
952
|
+
}
|
|
953
|
+
function emitFunding(d, funding) {
|
|
954
|
+
emit(d, d.o.json, funding, () => {
|
|
955
|
+
const token = funding.tokens.find((t) => t.minimum_useful_human !== null);
|
|
956
|
+
const asset = token ? `at least ${token.minimum_useful_human} ${token.symbol}` : funding.native.needed ? funding.native.symbol : "USDC";
|
|
957
|
+
const gas = funding.native.needed ? ` plus ${funding.native.symbol} for gas` : " \u2014 no gas token needed; Haven sponsors it";
|
|
958
|
+
const faucet = funding.faucet_url !== void 0 ? ` faucet: ${funding.faucet_url},` : "";
|
|
959
|
+
return [
|
|
960
|
+
`Send ${asset} on ${funding.chain.name} to ${funding.account_address}${gas}.`,
|
|
961
|
+
`Explorer: ${funding.chain.explorer_url}.${faucet ? faucet.slice(0, -1) : ""}`,
|
|
962
|
+
funding.funded ? "The account already counts as funded." : "Nothing has arrived yet."
|
|
963
|
+
].join("\n");
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
function elapsedLabel(ms) {
|
|
967
|
+
const total = Math.floor(ms / 1e3);
|
|
968
|
+
const h = Math.floor(total / 3600);
|
|
969
|
+
const m = Math.floor(total % 3600 / 60);
|
|
970
|
+
const s = total % 60;
|
|
971
|
+
if (h > 0) return `${h}h${String(m).padStart(2, "0")}m`;
|
|
972
|
+
if (m > 0) return `${m}m${String(s).padStart(2, "0")}s`;
|
|
973
|
+
return `${s}s`;
|
|
974
|
+
}
|
|
416
975
|
async function cmdAgentsList(args, d) {
|
|
417
976
|
const { api } = await authed(args, d);
|
|
418
977
|
const { agents } = await api.get("/agents");
|
|
@@ -425,14 +984,11 @@ async function cmdAgentsList(args, d) {
|
|
|
425
984
|
agents.map((a) => [a.id, a.name, a.status, budgetSummary(a.allowances)])
|
|
426
985
|
)
|
|
427
986
|
);
|
|
428
|
-
return
|
|
987
|
+
return EXIT.ok;
|
|
429
988
|
}
|
|
430
989
|
async function cmdAgentsShow(args, d) {
|
|
431
990
|
const id = args.positionals[0];
|
|
432
|
-
if (!id)
|
|
433
|
-
d.err("Usage: haven agents show <id>");
|
|
434
|
-
return 1;
|
|
435
|
-
}
|
|
991
|
+
if (!id) throw new UsageError("Usage: haven agents show <id>");
|
|
436
992
|
const { api } = await authed(args, d);
|
|
437
993
|
const agent = await api.get(`/agents/${id}`);
|
|
438
994
|
emit(
|
|
@@ -445,15 +1001,26 @@ async function cmdAgentsShow(args, d) {
|
|
|
445
1001
|
`budget: ${budgetSummary(agent.allowances)}`
|
|
446
1002
|
].join("\n")
|
|
447
1003
|
);
|
|
448
|
-
return
|
|
1004
|
+
return EXIT.ok;
|
|
449
1005
|
}
|
|
1006
|
+
var HASH_DISCOVERY_HINT = "haven budget show <agentId> --hashes";
|
|
450
1007
|
async function cmdBudgetShow(args, d) {
|
|
451
1008
|
const id = args.positionals[0];
|
|
452
|
-
if (!id)
|
|
453
|
-
d.err("Usage: haven budget show <agentId>");
|
|
454
|
-
return 1;
|
|
455
|
-
}
|
|
1009
|
+
if (!id) throw new UsageError("Usage: haven budget show <agentId> [--hashes]");
|
|
456
1010
|
const { api } = await authed(args, d);
|
|
1011
|
+
if (args.flags.hashes) {
|
|
1012
|
+
const { delegations } = await api.get(`/agents/${id}/delegations`);
|
|
1013
|
+
emit(
|
|
1014
|
+
d,
|
|
1015
|
+
args.flags.json,
|
|
1016
|
+
delegations,
|
|
1017
|
+
() => delegations.length === 0 ? `No delegations on agent ${id}.` : table(
|
|
1018
|
+
["DELEGATION HASH", "STATUS", "VERSION"],
|
|
1019
|
+
delegations.map((r) => [r.delegation_hash, r.status, String(r.version)])
|
|
1020
|
+
)
|
|
1021
|
+
);
|
|
1022
|
+
return EXIT.ok;
|
|
1023
|
+
}
|
|
457
1024
|
const agent = await api.get(`/agents/${id}`);
|
|
458
1025
|
const allowances = agent.allowances ?? [];
|
|
459
1026
|
emit(
|
|
@@ -465,12 +1032,148 @@ async function cmdBudgetShow(args, d) {
|
|
|
465
1032
|
allowances.map((a) => [a.token_symbol, a.allowance_amount, resetLabel(a.reset_period_min)])
|
|
466
1033
|
)
|
|
467
1034
|
);
|
|
468
|
-
return
|
|
1035
|
+
return EXIT.ok;
|
|
469
1036
|
}
|
|
470
1037
|
function budgetSummary(allowances) {
|
|
471
1038
|
if (!allowances || allowances.length === 0) return "\u2014";
|
|
472
1039
|
return allowances.map((a) => `${a.allowance_amount} ${a.token_symbol}`).join(", ");
|
|
473
1040
|
}
|
|
1041
|
+
var BUDGET_WAIT_TIMEOUT_S = 15 * 60;
|
|
1042
|
+
var BUDGET_WAIT_INTERVAL_MS = 5e3;
|
|
1043
|
+
async function cmdBudgetGrant(args, d) {
|
|
1044
|
+
const id = args.positionals[0];
|
|
1045
|
+
if (!id) {
|
|
1046
|
+
throw new UsageError("Usage: haven budget grant <agentId> --amount 25 --token USDC --period <minutes> [--recipient <address>] [--expires <unix-seconds>] [--wait]");
|
|
1047
|
+
}
|
|
1048
|
+
if (!args.flags.amount || !args.flags.token || args.flags.period === void 0) {
|
|
1049
|
+
throw new UsageError("--amount, --token and --period are required (period is whole minutes, at least 1)");
|
|
1050
|
+
}
|
|
1051
|
+
if (args.flags.period < 1) {
|
|
1052
|
+
throw new UsageError("--period must be at least 1 minute for a budget grant. A recurring budget is the only shape this rail has; `haven agents connect --period 0` is a different field on a different route.");
|
|
1053
|
+
}
|
|
1054
|
+
const { api } = await authed(args, d);
|
|
1055
|
+
const agent = await api.get(`/agents/${id}`);
|
|
1056
|
+
if (agent.account_type !== "delegator_hybrid") {
|
|
1057
|
+
throw new HavenCliError(
|
|
1058
|
+
`Agent ${id} is not on the delegation rail \u2014 budgets are managed from the dashboard for this account.`,
|
|
1059
|
+
EXIT.refused
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
if (!agent.safe_address || !agent.safe_chain_id) {
|
|
1063
|
+
throw new HavenCliError(`Agent ${id} has no wallet assigned yet \u2014 connect it first.`, EXIT.refused);
|
|
1064
|
+
}
|
|
1065
|
+
const { balances } = await api.get(
|
|
1066
|
+
`/balances/${agent.safe_address}?chain_id=${agent.safe_chain_id}`
|
|
1067
|
+
);
|
|
1068
|
+
const wanted = args.flags.token.trim().toUpperCase();
|
|
1069
|
+
const token = balances.find((b) => b.symbol.toUpperCase() === wanted);
|
|
1070
|
+
if (!token || !token.address) {
|
|
1071
|
+
const known = balances.map((b) => b.symbol).join(", ");
|
|
1072
|
+
throw new UsageError(`Unknown token ${args.flags.token} on this wallet's chain. Available: ${known || "none"}`);
|
|
1073
|
+
}
|
|
1074
|
+
const amount = parseTokenAmount(args.flags.amount, token.decimals, token.symbol);
|
|
1075
|
+
if (!amount.ok) throw new UsageError(amount.message);
|
|
1076
|
+
const built = await api.post(`/agents/${id}/delegations/build`, {
|
|
1077
|
+
token_address: token.address,
|
|
1078
|
+
recipient_address: args.flags.recipient ?? null,
|
|
1079
|
+
budget_atomic: amount.atomic,
|
|
1080
|
+
period_seconds: args.flags.period * 60,
|
|
1081
|
+
...args.flags.expires !== void 0 ? { expires_at: args.flags.expires } : {}
|
|
1082
|
+
});
|
|
1083
|
+
const emitGrant = (status) => emit(
|
|
1084
|
+
d,
|
|
1085
|
+
args.flags.json,
|
|
1086
|
+
{ ...built, agent_id: id, status: status ?? "pending" },
|
|
1087
|
+
() => [
|
|
1088
|
+
`Budget of ${args.flags.amount} ${token.symbol} per ${args.flags.period} minutes built for agent ${id}.`,
|
|
1089
|
+
args.flags.recipient ? `Recipient pin: ${args.flags.recipient}` : null,
|
|
1090
|
+
"Open this link and sign \u2014 the budget goes live the moment you do:",
|
|
1091
|
+
built.signing_url,
|
|
1092
|
+
`Delegation: ${built.delegation_hash} (version ${built.version})`,
|
|
1093
|
+
status === "active" ? "Signed and active." : "Waiting for your signature. Run the same command with --wait to poll until it is active."
|
|
1094
|
+
].filter((line) => line !== null).join("\n")
|
|
1095
|
+
);
|
|
1096
|
+
emitGrant();
|
|
1097
|
+
if (!args.flags.wait) return EXIT.ok;
|
|
1098
|
+
const deadline = Date.now() + BUDGET_WAIT_TIMEOUT_S * 1e3;
|
|
1099
|
+
while (Date.now() < deadline) {
|
|
1100
|
+
await d.sleep(BUDGET_WAIT_INTERVAL_MS);
|
|
1101
|
+
const { delegations } = await api.get(`/agents/${id}/delegations`);
|
|
1102
|
+
const mine = delegations.find((row) => row.delegation_hash === built.delegation_hash);
|
|
1103
|
+
if (mine?.status === "active") {
|
|
1104
|
+
emitGrant("active");
|
|
1105
|
+
return EXIT.ok;
|
|
1106
|
+
}
|
|
1107
|
+
if (mine?.status === "revoked" || mine?.status === "replaced") {
|
|
1108
|
+
throw new HavenCliError(
|
|
1109
|
+
`Delegation ${built.delegation_hash} is now ${mine.status} without being signed.`,
|
|
1110
|
+
EXIT.refused
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
throw new HavenCliError(
|
|
1115
|
+
`Timed out waiting for delegation ${built.delegation_hash} to go active (${BUDGET_WAIT_TIMEOUT_S / 60} minutes). The signing link stays valid until it expires \u2014 sign it, or rerun with --wait.`,
|
|
1116
|
+
EXIT.failed
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
async function cmdBudgetRevoke(args, d) {
|
|
1120
|
+
const [id, hash] = args.positionals;
|
|
1121
|
+
if (!id || !hash) throw new UsageError("Usage: haven budget revoke <agentId> <delegationHash> [--wait]");
|
|
1122
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(hash)) {
|
|
1123
|
+
throw new UsageError(`The delegation hash must be 0x followed by 64 hex characters \u2014 \`${HASH_DISCOVERY_HINT}\` lists them, or the dashboard.`);
|
|
1124
|
+
}
|
|
1125
|
+
const { api } = await authed(args, d);
|
|
1126
|
+
const { delegations } = await api.get(`/agents/${id}/delegations`);
|
|
1127
|
+
const row = delegations.find((r) => r.delegation_hash.toLowerCase() === hash.toLowerCase());
|
|
1128
|
+
if (!row) {
|
|
1129
|
+
throw new HavenCliError(`No delegation ${hash} on agent ${id}.`, EXIT.refused);
|
|
1130
|
+
}
|
|
1131
|
+
if (row.status === "revoked") {
|
|
1132
|
+
throw new HavenCliError(`Delegation ${hash} is already revoked.`, EXIT.refused);
|
|
1133
|
+
}
|
|
1134
|
+
if (row.status === "replaced") {
|
|
1135
|
+
throw new HavenCliError(
|
|
1136
|
+
`Delegation ${hash} was already replaced by a newer grant \u2014 nothing to revoke.`,
|
|
1137
|
+
EXIT.refused
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
const prepared = await api.post(`/agents/${id}/delegations/${hash}/revoke`, {});
|
|
1141
|
+
const revokeUrl = prepared.revocation_url;
|
|
1142
|
+
if (!revokeUrl) {
|
|
1143
|
+
throw new HavenCliError(
|
|
1144
|
+
"The backend did not return a revocation link \u2014 it may be older than #2539. Finish the revocation in the dashboard.",
|
|
1145
|
+
EXIT.failed
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
const emitRevoke = (status) => emit(
|
|
1149
|
+
d,
|
|
1150
|
+
args.flags.json,
|
|
1151
|
+
{ agent_id: id, delegation_hash: hash, status, ...prepared },
|
|
1152
|
+
() => [
|
|
1153
|
+
"Revocation prepared \u2014 one signature, sponsored (no gas).",
|
|
1154
|
+
"Open this link and sign to stop this budget:",
|
|
1155
|
+
revokeUrl,
|
|
1156
|
+
`Delegation: ${hash}`,
|
|
1157
|
+
status === "revoked" ? "Revoked." : "The budget keeps working until you sign. Run the same command with --wait to poll until it is revoked."
|
|
1158
|
+
].filter((line) => line !== null).join("\n")
|
|
1159
|
+
);
|
|
1160
|
+
emitRevoke("pending_revoke");
|
|
1161
|
+
if (!args.flags.wait) return EXIT.ok;
|
|
1162
|
+
const deadline = Date.now() + BUDGET_WAIT_TIMEOUT_S * 1e3;
|
|
1163
|
+
while (Date.now() < deadline) {
|
|
1164
|
+
await d.sleep(BUDGET_WAIT_INTERVAL_MS);
|
|
1165
|
+
const { delegations: after } = await api.get(`/agents/${id}/delegations`);
|
|
1166
|
+
const mine = after.find((r) => r.delegation_hash.toLowerCase() === hash.toLowerCase());
|
|
1167
|
+
if (mine?.status === "revoked") {
|
|
1168
|
+
emitRevoke("revoked");
|
|
1169
|
+
return EXIT.ok;
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
throw new HavenCliError(
|
|
1173
|
+
`Timed out waiting for delegation ${hash} to be revoked (${BUDGET_WAIT_TIMEOUT_S / 60} minutes). The link stays valid \u2014 sign it, or rerun with --wait.`,
|
|
1174
|
+
EXIT.failed
|
|
1175
|
+
);
|
|
1176
|
+
}
|
|
474
1177
|
function resetLabel(mins) {
|
|
475
1178
|
if (mins === 0) return "one-time";
|
|
476
1179
|
if (mins === 1440) return "daily";
|
|
@@ -480,75 +1183,64 @@ function resetLabel(mins) {
|
|
|
480
1183
|
}
|
|
481
1184
|
async function cmdAgentLifecycle(args, d, action) {
|
|
482
1185
|
const id = args.positionals[0];
|
|
483
|
-
if (!id) {
|
|
484
|
-
d.err(`Usage: haven agents ${action} <id>`);
|
|
485
|
-
return 1;
|
|
486
|
-
}
|
|
1186
|
+
if (!id) throw new UsageError(`Usage: haven agents ${action} <id>`);
|
|
487
1187
|
const { api } = await authed(args, d);
|
|
488
1188
|
await api.post(`/agents/${id}/${action}`);
|
|
489
|
-
|
|
490
|
-
|
|
1189
|
+
const status = action === "pause" ? "paused" : "resumed";
|
|
1190
|
+
emit(d, args.flags.json, { ok: true, agent_id: id, status }, () => `Agent ${id} ${status}.`);
|
|
1191
|
+
return EXIT.ok;
|
|
491
1192
|
}
|
|
492
1193
|
async function cmdAgentRevoke(args, d) {
|
|
493
1194
|
const id = args.positionals[0];
|
|
494
|
-
if (!id)
|
|
495
|
-
d.err("Usage: haven agents revoke <id> --yes");
|
|
496
|
-
return 1;
|
|
497
|
-
}
|
|
1195
|
+
if (!id) throw new UsageError("Usage: haven agents revoke <id> --yes");
|
|
498
1196
|
if (!args.flags.yes) {
|
|
499
|
-
|
|
500
|
-
|
|
1197
|
+
throw new UsageError(
|
|
1198
|
+
`This permanently revokes agent ${id}.`,
|
|
1199
|
+
"Re-run with --yes to confirm. Revoke is terminal \u2014 the agent cannot go back to active."
|
|
1200
|
+
);
|
|
501
1201
|
}
|
|
502
1202
|
const { api } = await authed(args, d);
|
|
503
1203
|
await api.post(`/agents/${id}/revoke`);
|
|
504
|
-
|
|
505
|
-
|
|
1204
|
+
emit(
|
|
1205
|
+
d,
|
|
1206
|
+
args.flags.json,
|
|
1207
|
+
{ ok: true, agent_id: id, status: "revoked" },
|
|
1208
|
+
() => `Agent ${id} revoked. To also remove its on-chain allowance, use the dashboard.`
|
|
1209
|
+
);
|
|
1210
|
+
return EXIT.ok;
|
|
506
1211
|
}
|
|
507
1212
|
async function cmdAgentRotateKey(args, d) {
|
|
508
1213
|
const id = args.positionals[0];
|
|
509
|
-
if (!id)
|
|
510
|
-
d.err("Usage: haven agents rotate-key <id>");
|
|
511
|
-
return 1;
|
|
512
|
-
}
|
|
1214
|
+
if (!id) throw new UsageError("Usage: haven agents rotate-key <id>");
|
|
513
1215
|
const { api } = await authed(args, d);
|
|
514
1216
|
const res = await api.post(`/agents/${id}/rotate-key`);
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
d.out("New API key (shown once \u2014 store it now; the old key stops working):");
|
|
519
|
-
d.out(res.api_key);
|
|
520
|
-
}
|
|
521
|
-
return 0;
|
|
1217
|
+
d.o.note("New API key (shown once \u2014 store it now; the old key stops working):");
|
|
1218
|
+
emit(d, args.flags.json, res, () => res.api_key);
|
|
1219
|
+
return EXIT.ok;
|
|
522
1220
|
}
|
|
523
1221
|
async function cmdAgentRename(args, d) {
|
|
524
1222
|
const [id, ...nameParts] = args.positionals;
|
|
525
1223
|
const name = nameParts.join(" ").trim();
|
|
526
|
-
if (!id || !name)
|
|
527
|
-
d.err("Usage: haven agents rename <id> <name>");
|
|
528
|
-
return 1;
|
|
529
|
-
}
|
|
1224
|
+
if (!id || !name) throw new UsageError("Usage: haven agents rename <id> <name>");
|
|
530
1225
|
const { api } = await authed(args, d);
|
|
531
1226
|
await api.put(`/agents/${id}`, { name });
|
|
532
|
-
d.
|
|
533
|
-
return
|
|
1227
|
+
emit(d, args.flags.json, { ok: true, agent_id: id, name }, () => `Agent ${id} renamed to "${name}".`);
|
|
1228
|
+
return EXIT.ok;
|
|
534
1229
|
}
|
|
535
1230
|
async function cmdWalletRename(args, d) {
|
|
536
1231
|
const [id, ...nameParts] = args.positionals;
|
|
537
1232
|
const name = nameParts.join(" ").trim();
|
|
538
|
-
if (!id || !name)
|
|
539
|
-
d.err("Usage: haven wallets rename <id> <name>");
|
|
540
|
-
return 1;
|
|
541
|
-
}
|
|
1233
|
+
if (!id || !name) throw new UsageError("Usage: haven wallets rename <id> <name>");
|
|
542
1234
|
const { api } = await authed(args, d);
|
|
543
1235
|
await api.put(`/user/safes/${id}`, { name });
|
|
544
|
-
d.
|
|
545
|
-
return
|
|
1236
|
+
emit(d, args.flags.json, { ok: true, safe_id: id, name }, () => `Wallet ${id} renamed to "${name}".`);
|
|
1237
|
+
return EXIT.ok;
|
|
546
1238
|
}
|
|
547
1239
|
async function resolveSafeId(args, api) {
|
|
548
1240
|
if (!args.flags.safe) return void 0;
|
|
549
1241
|
const { safes } = await api.get("/user/safes");
|
|
550
1242
|
const safe = pickSafe(safes, args.flags.safe);
|
|
551
|
-
if (!safe) throw new
|
|
1243
|
+
if (!safe) throw new UsageError(`No wallet matches "${args.flags.safe}".`);
|
|
552
1244
|
return safe.id;
|
|
553
1245
|
}
|
|
554
1246
|
async function cmdActivityList(args, d) {
|
|
@@ -577,7 +1269,7 @@ async function cmdActivityList(args, d) {
|
|
|
577
1269
|
])
|
|
578
1270
|
)
|
|
579
1271
|
);
|
|
580
|
-
return
|
|
1272
|
+
return EXIT.ok;
|
|
581
1273
|
}
|
|
582
1274
|
async function cmdActivityExport(args, d) {
|
|
583
1275
|
if (args.flags.format === "sie") return exportSie(args, d);
|
|
@@ -619,8 +1311,8 @@ async function cmdActivityExport(args, d) {
|
|
|
619
1311
|
t.hash,
|
|
620
1312
|
t.chainId != null ? String(t.chainId) : ""
|
|
621
1313
|
]);
|
|
622
|
-
d.
|
|
623
|
-
return
|
|
1314
|
+
d.o.text(toCsv(headers, rows), { format: "csv", rows: rows.length });
|
|
1315
|
+
return EXIT.ok;
|
|
624
1316
|
}
|
|
625
1317
|
function exportType(t) {
|
|
626
1318
|
if (t.activityType === "delegate_sweep") return "allowance funding";
|
|
@@ -640,8 +1332,8 @@ async function exportSie(args, d) {
|
|
|
640
1332
|
if (args.flags.to) params.set("to", args.flags.to);
|
|
641
1333
|
if (args.flags.company) params.set("company", args.flags.company);
|
|
642
1334
|
const content = await api.getText(`/accounting/export?${params.toString()}`);
|
|
643
|
-
d.
|
|
644
|
-
return
|
|
1335
|
+
d.o.text(content, { format: "sie" });
|
|
1336
|
+
return EXIT.ok;
|
|
645
1337
|
}
|
|
646
1338
|
async function cmdCatalogList(args, d) {
|
|
647
1339
|
const { api } = await authed(args, d);
|
|
@@ -655,7 +1347,127 @@ async function cmdCatalogList(args, d) {
|
|
|
655
1347
|
entries.map((e) => [e.name, e.category, e.rail, e.price_display ?? "\u2014", e.status])
|
|
656
1348
|
)
|
|
657
1349
|
);
|
|
658
|
-
return
|
|
1350
|
+
return EXIT.ok;
|
|
1351
|
+
}
|
|
1352
|
+
async function resolveWalletAndToken(args, api, symbol) {
|
|
1353
|
+
const { safes } = await api.get("/user/safes");
|
|
1354
|
+
if (safes.length === 0) {
|
|
1355
|
+
throw new HavenCliError("No wallet on this account yet \u2014 finish onboarding first.", EXIT.refused);
|
|
1356
|
+
}
|
|
1357
|
+
const safe = args.flags.safe ? safes.find((s) => s.id === args.flags.safe || s.safe_address === args.flags.safe) : safes.find((s) => s.is_default) ?? safes[0];
|
|
1358
|
+
if (!safe) throw new UsageError(`No wallet matches --safe ${args.flags.safe}`);
|
|
1359
|
+
const { balances } = await api.get(
|
|
1360
|
+
`/balances/${safe.safe_address}?chain_id=${safe.chain_id}`
|
|
1361
|
+
);
|
|
1362
|
+
const wanted = symbol.trim().toUpperCase();
|
|
1363
|
+
const token = balances.find((b) => b.symbol.toUpperCase() === wanted);
|
|
1364
|
+
if (!token) {
|
|
1365
|
+
const known = balances.map((b) => b.symbol).join(", ");
|
|
1366
|
+
throw new UsageError(`Unknown token ${symbol} on this wallet's chain. Available: ${known || "none"}`);
|
|
1367
|
+
}
|
|
1368
|
+
return { safeId: safe.id, token };
|
|
1369
|
+
}
|
|
1370
|
+
var SETTLED = /* @__PURE__ */ new Set(["active", "expired", "cancelled", "failed"]);
|
|
1371
|
+
async function pollSetup(api, setupId, d, wait) {
|
|
1372
|
+
let status = await api.get(`/agent-connection-setups/${setupId}`);
|
|
1373
|
+
if (!wait) return status;
|
|
1374
|
+
const deadline = new Date(status.expires_at).getTime();
|
|
1375
|
+
while (!SETTLED.has(status.status) && Date.now() < deadline) {
|
|
1376
|
+
await d.sleep(5e3);
|
|
1377
|
+
status = await api.get(`/agent-connection-setups/${setupId}`);
|
|
1378
|
+
}
|
|
1379
|
+
return status;
|
|
1380
|
+
}
|
|
1381
|
+
async function cmdAgentsConnect(args, d) {
|
|
1382
|
+
const { api } = await authed(args, d);
|
|
1383
|
+
if (args.flags.status) {
|
|
1384
|
+
const status = await pollSetup(api, args.flags.status, d, args.flags.wait);
|
|
1385
|
+
emit(
|
|
1386
|
+
d,
|
|
1387
|
+
args.flags.json,
|
|
1388
|
+
status,
|
|
1389
|
+
() => [
|
|
1390
|
+
`setup ${status.setup_id}: ${status.status}`,
|
|
1391
|
+
status.agent_id ? `agent: ${status.agent_id}` : null,
|
|
1392
|
+
SETTLED.has(status.status) ? null : `approve: ${status.approval_url}`
|
|
1393
|
+
].filter(Boolean).join("\n")
|
|
1394
|
+
);
|
|
1395
|
+
return EXIT.ok;
|
|
1396
|
+
}
|
|
1397
|
+
const name = args.flags.name?.trim();
|
|
1398
|
+
if (!name) throw new UsageError("Usage: haven agents connect --name <name> --budget <amount> --token USDC --period <minutes>");
|
|
1399
|
+
if (!args.flags.budget || !args.flags.token || args.flags.period === void 0) {
|
|
1400
|
+
throw new UsageError("--budget, --token and --period are required (period is whole minutes; 0 means one-time)");
|
|
1401
|
+
}
|
|
1402
|
+
const { safeId, token } = await resolveWalletAndToken(args, api, args.flags.token);
|
|
1403
|
+
const amount = parseTokenAmount(args.flags.budget, token.decimals, token.symbol);
|
|
1404
|
+
if (!amount.ok) throw new UsageError(amount.message);
|
|
1405
|
+
const setup = await api.post("/agent-connection-setups", {
|
|
1406
|
+
name,
|
|
1407
|
+
safe_id: safeId,
|
|
1408
|
+
allowances: [
|
|
1409
|
+
{
|
|
1410
|
+
token_address: token.address ?? "0x0000000000000000000000000000000000000000",
|
|
1411
|
+
token_symbol: token.symbol,
|
|
1412
|
+
// ATOMIC on the way in, human on the way back (#2295). Converted here
|
|
1413
|
+
// exactly once, from the decimals the backend just told us.
|
|
1414
|
+
allowance_amount: amount.atomic,
|
|
1415
|
+
reset_period_min: args.flags.period
|
|
1416
|
+
}
|
|
1417
|
+
],
|
|
1418
|
+
// How this setup was made, for connect attribution (#2302). The route
|
|
1419
|
+
// already accepts any slug, so nothing backend-side had to change.
|
|
1420
|
+
source: "cli",
|
|
1421
|
+
// #2522: the hand-off marker, set only when an agent is driving this CLI
|
|
1422
|
+
// and says so. Never inferred — a guess here mislabels a human's own run.
|
|
1423
|
+
...d.env.HAVEN_AGENT_DRIVEN === "1" ? { via: "agent" } : {}
|
|
1424
|
+
});
|
|
1425
|
+
if (!args.flags.run) {
|
|
1426
|
+
emit(
|
|
1427
|
+
d,
|
|
1428
|
+
args.flags.json,
|
|
1429
|
+
setup,
|
|
1430
|
+
() => [
|
|
1431
|
+
"Run this where the agent runs:",
|
|
1432
|
+
"",
|
|
1433
|
+
setup.connector_command,
|
|
1434
|
+
"",
|
|
1435
|
+
`Then approve the budget: ${setup.approval_url}`,
|
|
1436
|
+
`Setup ${setup.setup_id} expires ${setup.expires_at}.`
|
|
1437
|
+
].join("\n")
|
|
1438
|
+
);
|
|
1439
|
+
return EXIT.ok;
|
|
1440
|
+
}
|
|
1441
|
+
const run2 = await runConnector(setup.connector_command, d.spawner, (chunk) => d.err(chunk.trimEnd()));
|
|
1442
|
+
const relay = relayLine(run2.outcome);
|
|
1443
|
+
const merged = {
|
|
1444
|
+
setup_id: setup.setup_id,
|
|
1445
|
+
approval_url: setup.approval_url,
|
|
1446
|
+
connector_command: setup.connector_command,
|
|
1447
|
+
connector_exit_code: run2.exitCode,
|
|
1448
|
+
outcome: run2.outcome,
|
|
1449
|
+
relay
|
|
1450
|
+
};
|
|
1451
|
+
if (isRefusal(run2.outcome)) {
|
|
1452
|
+
emitConnectResult(d, args.flags.json, merged, relay);
|
|
1453
|
+
return EXIT.refused;
|
|
1454
|
+
}
|
|
1455
|
+
if (!run2.outcome) {
|
|
1456
|
+
throw new HavenCliError(
|
|
1457
|
+
`The connector produced no outcome (exit ${run2.exitCode}).${run2.stdoutNoise ? ` Output: ${run2.stdoutNoise}` : ""}`,
|
|
1458
|
+
run2.exitCode === 0 ? EXIT.failed : EXIT.failed
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
emitConnectResult(d, args.flags.json, merged, relay);
|
|
1462
|
+
return EXIT.ok;
|
|
1463
|
+
}
|
|
1464
|
+
function emitConnectResult(d, json, merged, relay) {
|
|
1465
|
+
emit(
|
|
1466
|
+
d,
|
|
1467
|
+
json,
|
|
1468
|
+
merged,
|
|
1469
|
+
() => [relay, relay ? "" : null, `setup ${merged.setup_id}: ${merged.outcome?.outcome ?? "unknown"}`].filter((line) => line !== null).join("\n")
|
|
1470
|
+
);
|
|
659
1471
|
}
|
|
660
1472
|
async function cmdContactsList(args, d) {
|
|
661
1473
|
const { api } = await authed(args, d);
|
|
@@ -666,36 +1478,28 @@ async function cmdContactsList(args, d) {
|
|
|
666
1478
|
contacts,
|
|
667
1479
|
() => contacts.length === 0 ? "No contacts yet." : table(["ID", "NAME", "ADDRESS"], contacts.map((c) => [c.id, c.name, truncateAddress(c.address)]))
|
|
668
1480
|
);
|
|
669
|
-
return
|
|
1481
|
+
return EXIT.ok;
|
|
670
1482
|
}
|
|
671
1483
|
async function cmdContactsAdd(args, d) {
|
|
672
1484
|
const [address, ...nameParts] = [...args.positionals].reverse();
|
|
673
1485
|
const name = nameParts.reverse().join(" ").trim();
|
|
674
|
-
if (!name || !address)
|
|
675
|
-
d.err("Usage: haven contacts add <name> <address>");
|
|
676
|
-
return 1;
|
|
677
|
-
}
|
|
1486
|
+
if (!name || !address) throw new UsageError("Usage: haven contacts add <name> <address>");
|
|
678
1487
|
const { api } = await authed(args, d);
|
|
679
1488
|
const contact = await api.post("/contacts", { name, address });
|
|
680
1489
|
emit(d, args.flags.json, contact, () => `Added contact "${contact.name}" (${truncateAddress(contact.address)}).`);
|
|
681
|
-
return
|
|
1490
|
+
return EXIT.ok;
|
|
682
1491
|
}
|
|
683
1492
|
async function cmdContactsRemove(args, d) {
|
|
684
1493
|
const id = args.positionals[0];
|
|
685
|
-
if (!id)
|
|
686
|
-
d.err("Usage: haven contacts remove <id>");
|
|
687
|
-
return 1;
|
|
688
|
-
}
|
|
1494
|
+
if (!id) throw new UsageError("Usage: haven contacts remove <id>");
|
|
689
1495
|
const { api } = await authed(args, d);
|
|
690
1496
|
await api.del(`/contacts/${id}`);
|
|
691
|
-
d.
|
|
692
|
-
return
|
|
1497
|
+
emit(d, args.flags.json, { ok: true, contact_id: id, removed: true }, () => `Contact ${id} removed.`);
|
|
1498
|
+
return EXIT.ok;
|
|
693
1499
|
}
|
|
694
|
-
|
|
695
|
-
// src/cli.ts
|
|
696
|
-
function promptPassword() {
|
|
1500
|
+
function promptPasswordWith(streams) {
|
|
697
1501
|
return new Promise((resolve2, reject) => {
|
|
698
|
-
const { stdin,
|
|
1502
|
+
const { stdin, stderr } = streams;
|
|
699
1503
|
if (!stdin.isTTY) {
|
|
700
1504
|
let data = "";
|
|
701
1505
|
stdin.setEncoding("utf8");
|
|
@@ -704,18 +1508,18 @@ function promptPassword() {
|
|
|
704
1508
|
stdin.on("error", reject);
|
|
705
1509
|
return;
|
|
706
1510
|
}
|
|
707
|
-
|
|
1511
|
+
stderr.write("Password: ");
|
|
708
1512
|
readline.emitKeypressEvents(stdin);
|
|
709
1513
|
stdin.setRawMode(true);
|
|
710
1514
|
let value = "";
|
|
711
1515
|
const onKey = (char, key) => {
|
|
712
1516
|
if (key.name === "return" || key.name === "enter") {
|
|
713
1517
|
cleanup();
|
|
714
|
-
|
|
1518
|
+
stderr.write("\n");
|
|
715
1519
|
resolve2(value);
|
|
716
1520
|
} else if (key.ctrl && key.name === "c") {
|
|
717
1521
|
cleanup();
|
|
718
|
-
|
|
1522
|
+
stderr.write("\n");
|
|
719
1523
|
reject(new Error("Cancelled"));
|
|
720
1524
|
} else if (key.name === "backspace") {
|
|
721
1525
|
value = value.slice(0, -1);
|
|
@@ -732,9 +1536,20 @@ function promptPassword() {
|
|
|
732
1536
|
stdin.on("keypress", onKey);
|
|
733
1537
|
});
|
|
734
1538
|
}
|
|
1539
|
+
|
|
1540
|
+
// src/cli.ts
|
|
1541
|
+
function promptPassword() {
|
|
1542
|
+
return promptPasswordWith({ stdin: process.stdin, stdout: process.stdout, stderr: process.stderr });
|
|
1543
|
+
}
|
|
735
1544
|
run(process.argv.slice(2), { promptPassword }).then((code) => process.exit(code)).catch((err) => {
|
|
736
|
-
|
|
1545
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1546
|
+
if (process.argv.includes("--json")) {
|
|
1547
|
+
process.stdout.write(`${JSON.stringify({ ok: false, error: { code: "failed", message } }, null, 2)}
|
|
737
1548
|
`);
|
|
1549
|
+
} else {
|
|
1550
|
+
process.stderr.write(`${message}
|
|
1551
|
+
`);
|
|
1552
|
+
}
|
|
738
1553
|
process.exit(1);
|
|
739
1554
|
});
|
|
740
1555
|
//# sourceMappingURL=cli.cjs.map
|