@haven_ai/cli 0.0.0-dev.202609051302.7cc48d5 → 0.0.0-dev.202609051933.cc65a9d

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
@@ -60,8 +60,45 @@ haven agents rotate-key <id> # new API key, shown once
60
60
  haven agents rename <id> <name>
61
61
  haven wallets rename <id> <name>
62
62
  haven contacts add <name> <address> | contacts remove <id>
63
+
64
+ # set an agent up without the dashboard modal (#2527)
65
+ haven agents connect --name <name> --budget <amount> --token USDC --period <minutes>
66
+ haven agents connect --name <name> --budget 25 --token USDC --period 1440 --run
67
+ haven agents connect --status <setupId> [--wait]
63
68
  ```
64
69
 
70
+ ### `haven agents connect`
71
+
72
+ Does what the dashboard's connect modal does, from a terminal: creates the
73
+ setup and prints the connector command, the approval link, and when the setup
74
+ expires. `--budget` is in **whole tokens** as you would say it (`25` is 25
75
+ USDC); the CLI reads the token's decimals from your wallet's own balances and
76
+ converts, and it **refuses** an amount with more precision than the token has
77
+ rather than rounding it away.
78
+
79
+ The connector command is **printed, never composed** — it is the same string
80
+ the dashboard shows for the same setup, because both render what the backend
81
+ built. `--run` executes it for you as a child process with exactly `--json`
82
+ appended and nothing else changed, streams the connector's output, and puts the
83
+ thing you have to act on first.
84
+
85
+ If the connector refuses — it cannot tell which runtime to wire, or the machine
86
+ is already wired to a different agent — you get **exit 4** with the refusal
87
+ object intact, including any ids or suggested name it carried. That is a
88
+ message to relay to your user, not a problem to solve: `haven agents connect`
89
+ deliberately has no `--replace` and no `--name` for the connector, because
90
+ choosing between replacing an existing wiring and installing alongside it is
91
+ the human's decision.
92
+
93
+ Two flags the issue sketched and this does not have, so you are not left
94
+ looking for them: **`--recipient`** (a recipient pin lives in the delegation's
95
+ caveat enforcers and is set when the human approves the budget — no API field
96
+ takes one here) and **`haven agents create`** (`POST /agents` requires a
97
+ delegate address, and a CLI an agent drives must never hold a signing key —
98
+ `connect` is the path that generates one locally, on your machine).
99
+
100
+ Approving the budget stays with the human, in the browser, every time.
101
+
65
102
  Add `--json` to any read command for machine-readable output:
66
103
 
67
104
  ```bash
package/dist/cli.cjs CHANGED
@@ -4,6 +4,7 @@
4
4
  var promises = require('fs/promises');
5
5
  var os = require('os');
6
6
  var path = require('path');
7
+ var child_process = require('child_process');
7
8
  var readline = require('readline');
8
9
 
9
10
  // src/args.ts
@@ -18,11 +19,24 @@ 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"
22
28
  ]);
23
29
  function parseArgs(argv) {
24
30
  const positionals = [];
25
- const flags = { json: false, help: false, version: false, yes: false, noWait: false };
31
+ const flags = {
32
+ json: false,
33
+ help: false,
34
+ version: false,
35
+ yes: false,
36
+ noWait: false,
37
+ run: false,
38
+ wait: false
39
+ };
26
40
  for (let i = 0; i < argv.length; i += 1) {
27
41
  const arg = argv[i];
28
42
  if (arg === "--json") flags.json = true;
@@ -30,13 +44,23 @@ function parseArgs(argv) {
30
44
  else if (arg === "--version" || arg === "-v") flags.version = true;
31
45
  else if (arg === "--yes" || arg === "-y") flags.yes = true;
32
46
  else if (arg === "--no-wait") flags.noWait = true;
47
+ else if (arg === "--run") flags.run = true;
48
+ else if (arg === "--wait") flags.wait = true;
33
49
  else if (VALUE_FLAGS.has(arg)) {
34
50
  const value = argv[++i];
35
51
  if (value === void 0 || value.startsWith("--")) {
36
52
  throw new Error(`Missing value for ${arg}`);
37
53
  }
38
54
  if (arg === "--api") flags.api = value;
39
- else if (arg === "--email") flags.email = value;
55
+ else if (arg === "--name") flags.name = value;
56
+ else if (arg === "--budget") flags.budget = value;
57
+ else if (arg === "--token") flags.token = value;
58
+ else if (arg === "--status") flags.status = value;
59
+ else if (arg === "--period") {
60
+ const n = Number(value);
61
+ if (!Number.isInteger(n) || n < 0) throw new Error("--period must be a whole number of minutes");
62
+ flags.period = n;
63
+ } else if (arg === "--email") flags.email = value;
40
64
  else if (arg === "--safe") flags.safe = value;
41
65
  else if (arg === "--agent") flags.agent = value;
42
66
  else if (arg === "--limit") {
@@ -357,7 +381,7 @@ ${failure.hint}` : failure.message);
357
381
  }
358
382
 
359
383
  // src/agent-guidance-text.ts
360
- 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 login` afterwards gets you a scoped session for steps 3-4 \u2014 they approve a code in the browser, you never hold their password. 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 on Base, to the address the dashboard shows.\n3. **HUMAN \u2014 create the agent, set its budget**, and paste you the **setup prompt** it hands back.\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## 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 on Base before I can pay for anything \u2014 USDC only, no ETH: Haven sponsors the gas. The dashboard shows the address to send it to; 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";
384
+ 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 login` afterwards gets you a scoped session for steps 3-4 \u2014 they approve a code in the browser, you never hold their password. 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 on Base, to the address the dashboard shows.\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## 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 on Base before I can pay for anything \u2014 USDC only, no ETH: Haven sponsors the gas. The dashboard shows the address to send it to; 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";
361
385
 
362
386
  // src/token.ts
363
387
  function sessionExpiry(token) {
@@ -373,9 +397,166 @@ function sessionExpiry(token) {
373
397
  }
374
398
  }
375
399
 
400
+ // src/amount.ts
401
+ function decimalLabel(decimals) {
402
+ return decimals === 1 ? "1 decimal place" : `${decimals} decimal places`;
403
+ }
404
+ function parseTokenAmount(input, decimals, tokenSymbol) {
405
+ if (!Number.isInteger(decimals) || decimals < 0 || decimals > 36) {
406
+ return { ok: false, message: `Unusable token decimals: ${String(decimals)}` };
407
+ }
408
+ const trimmed = String(input ?? "").trim();
409
+ const human = trimmed.startsWith(".") ? `0${trimmed}` : trimmed;
410
+ const label = tokenSymbol ? ` ${tokenSymbol}` : "";
411
+ if (human === "") return { ok: false, message: "Enter an amount greater than 0" };
412
+ if (!/^\d+(?:\.\d+)?$/.test(human)) {
413
+ return { ok: false, message: `Enter a valid${label} amount \u2014 digits, with an optional decimal point` };
414
+ }
415
+ const [whole, fraction = ""] = human.split(".");
416
+ if (fraction.length > decimals) {
417
+ return {
418
+ ok: false,
419
+ message: `${tokenSymbol ?? "This token"} supports up to ${decimalLabel(decimals)}`
420
+ };
421
+ }
422
+ const atomic = BigInt(`${whole}${fraction.padEnd(decimals, "0")}`);
423
+ if (atomic === 0n) return { ok: false, message: "Enter an amount greater than 0" };
424
+ return { ok: true, human, atomic: atomic.toString() };
425
+ }
426
+ function splitConnectorCommand(command) {
427
+ const argv = [];
428
+ let current = "";
429
+ let quoted = false;
430
+ let started = false;
431
+ for (let i = 0; i < command.length; i += 1) {
432
+ const ch = command[i];
433
+ if (quoted) {
434
+ if (ch === "'") quoted = false;
435
+ else current += ch;
436
+ continue;
437
+ }
438
+ if (ch === "'") {
439
+ quoted = true;
440
+ started = true;
441
+ continue;
442
+ }
443
+ if (ch === " " || ch === " ") {
444
+ if (started) argv.push(current);
445
+ current = "";
446
+ started = false;
447
+ continue;
448
+ }
449
+ if (ch === "\\") {
450
+ if (command[i + 1] === "'") {
451
+ current += "'";
452
+ started = true;
453
+ i += 1;
454
+ continue;
455
+ }
456
+ throw new Error("Refusing to run a connector command with a backslash escape");
457
+ }
458
+ if (ch === '"' || ch === "`" || ch === "$" || ch === "|" || ch === "&" || ch === ";" || ch === ">" || ch === "<" || ch === "(" || ch === ")" || ch === "\n") {
459
+ throw new Error(`Refusing to run a connector command containing ${JSON.stringify(ch)}`);
460
+ }
461
+ current += ch;
462
+ started = true;
463
+ }
464
+ if (quoted) throw new Error("Refusing to run a connector command with an unterminated quote");
465
+ if (started) argv.push(current);
466
+ if (argv.length === 0) throw new Error("The backend returned an empty connector command");
467
+ return argv;
468
+ }
469
+ var nodeSpawner = (command, args, onStderr) => new Promise((resolve2, reject) => {
470
+ const child = child_process.spawn(command, args, { shell: false, stdio: ["ignore", "pipe", "pipe"] });
471
+ let stdout = "";
472
+ let stderr = "";
473
+ child.stdout.setEncoding("utf8");
474
+ child.stderr.setEncoding("utf8");
475
+ child.stdout.on("data", (chunk) => {
476
+ stdout += chunk;
477
+ });
478
+ child.stderr.on("data", (chunk) => {
479
+ stderr += chunk;
480
+ onStderr(chunk);
481
+ });
482
+ child.on("error", reject);
483
+ child.on("close", (code) => resolve2({ stdout, stderr, exitCode: code ?? 1 }));
484
+ });
485
+ function parseOutcome(stdout) {
486
+ const noise = [];
487
+ let outcome = null;
488
+ let depth = 0;
489
+ let start = -1;
490
+ let inString = false;
491
+ let escaped = false;
492
+ let plain = "";
493
+ for (let i = 0; i < stdout.length; i += 1) {
494
+ const ch = stdout[i];
495
+ if (depth === 0) {
496
+ if (ch === "{") {
497
+ depth = 1;
498
+ start = i;
499
+ inString = false;
500
+ escaped = false;
501
+ } else {
502
+ plain += ch;
503
+ }
504
+ continue;
505
+ }
506
+ if (inString) {
507
+ if (escaped) escaped = false;
508
+ else if (ch === "\\") escaped = true;
509
+ else if (ch === '"') inString = false;
510
+ continue;
511
+ }
512
+ if (ch === '"') inString = true;
513
+ else if (ch === "{") depth += 1;
514
+ else if (ch === "}") {
515
+ depth -= 1;
516
+ if (depth === 0) {
517
+ const candidate = stdout.slice(start, i + 1);
518
+ try {
519
+ outcome = JSON.parse(candidate);
520
+ } catch {
521
+ plain += candidate;
522
+ }
523
+ start = -1;
524
+ }
525
+ }
526
+ }
527
+ if (depth > 0 && start >= 0) plain += stdout.slice(start);
528
+ for (const line of plain.split("\n")) {
529
+ const text = line.trim();
530
+ if (text) noise.push(text);
531
+ }
532
+ return { outcome, noise: noise.join("\n") };
533
+ }
534
+ function isRefusal(outcome) {
535
+ return Boolean(outcome?.error);
536
+ }
537
+ function relayLine(outcome) {
538
+ if (!outcome) return null;
539
+ if (outcome.error) {
540
+ const message = outcome.error.message?.trim();
541
+ const next = outcome.error.next_action ?? outcome.next_action;
542
+ return message ? `${message}${next ? ` (next: ${next})` : ""}` : `The connector refused${next ? `: ${next}` : "."}`;
543
+ }
544
+ if (outcome.approval?.required) {
545
+ return outcome.approval.url ? `Approve the budget to finish: ${outcome.approval.url}` : "Approve the budget in your Haven tab to finish.";
546
+ }
547
+ return null;
548
+ }
549
+ async function runConnector(connectorCommand, spawner, onStderr) {
550
+ const argv = splitConnectorCommand(connectorCommand);
551
+ const args = [...argv.slice(1), "--json"];
552
+ const { stdout, stderr, exitCode } = await spawner(argv[0], args, onStderr);
553
+ const { outcome, noise } = parseOutcome(stdout);
554
+ return { outcome, exitCode, stderr, stdoutNoise: noise };
555
+ }
556
+
376
557
  // src/commands.ts
377
558
  var DEFAULT_API = "https://havenbackend-production-8a00.up.railway.app";
378
- var CLI_VERSION = "0.0.0-dev.202609051302.7cc48d5";
559
+ var CLI_VERSION = "0.0.0-dev.202609051933.cc65a9d";
379
560
  async function run(argv, deps = {}) {
380
561
  const out = deps.out ?? ((l) => process.stdout.write(`${l}
381
562
  `));
@@ -388,6 +569,7 @@ async function run(argv, deps = {}) {
388
569
  makeApi: deps.makeApi ?? ((baseUrl, token) => createCliApi({ baseUrl, token })),
389
570
  promptPassword: deps.promptPassword ?? (() => Promise.reject(new Error("No password input available"))),
390
571
  sleep: deps.sleep ?? ((ms) => new Promise((resolve2) => setTimeout(resolve2, ms))),
572
+ spawner: deps.spawner ?? nodeSpawner,
391
573
  out,
392
574
  err,
393
575
  env: deps.env ?? process.env,
@@ -437,6 +619,8 @@ async function dispatch(args, d) {
437
619
  return cmdAgentsList(args, d);
438
620
  case "agents show":
439
621
  return cmdAgentsShow(args, d);
622
+ case "agents connect":
623
+ return cmdAgentsConnect(args, d);
440
624
  case "agents pause":
441
625
  return cmdAgentLifecycle(args, d, "pause");
442
626
  case "agents resume":
@@ -844,6 +1028,126 @@ async function cmdCatalogList(args, d) {
844
1028
  );
845
1029
  return EXIT.ok;
846
1030
  }
1031
+ async function resolveWalletAndToken(args, api, symbol) {
1032
+ const { safes } = await api.get("/user/safes");
1033
+ if (safes.length === 0) {
1034
+ throw new HavenCliError("No wallet on this account yet \u2014 finish onboarding first.", EXIT.refused);
1035
+ }
1036
+ 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];
1037
+ if (!safe) throw new UsageError(`No wallet matches --safe ${args.flags.safe}`);
1038
+ const { balances } = await api.get(
1039
+ `/balances/${safe.safe_address}?chain_id=${safe.chain_id}`
1040
+ );
1041
+ const wanted = symbol.trim().toUpperCase();
1042
+ const token = balances.find((b) => b.symbol.toUpperCase() === wanted);
1043
+ if (!token) {
1044
+ const known = balances.map((b) => b.symbol).join(", ");
1045
+ throw new UsageError(`Unknown token ${symbol} on this wallet's chain. Available: ${known || "none"}`);
1046
+ }
1047
+ return { safeId: safe.id, token };
1048
+ }
1049
+ var SETTLED = /* @__PURE__ */ new Set(["active", "expired", "cancelled", "failed"]);
1050
+ async function pollSetup(api, setupId, d, wait) {
1051
+ let status = await api.get(`/agent-connection-setups/${setupId}`);
1052
+ if (!wait) return status;
1053
+ const deadline = new Date(status.expires_at).getTime();
1054
+ while (!SETTLED.has(status.status) && Date.now() < deadline) {
1055
+ await d.sleep(5e3);
1056
+ status = await api.get(`/agent-connection-setups/${setupId}`);
1057
+ }
1058
+ return status;
1059
+ }
1060
+ async function cmdAgentsConnect(args, d) {
1061
+ const { api } = await authed(args, d);
1062
+ if (args.flags.status) {
1063
+ const status = await pollSetup(api, args.flags.status, d, args.flags.wait);
1064
+ emit(
1065
+ d,
1066
+ args.flags.json,
1067
+ status,
1068
+ () => [
1069
+ `setup ${status.setup_id}: ${status.status}`,
1070
+ status.agent_id ? `agent: ${status.agent_id}` : null,
1071
+ SETTLED.has(status.status) ? null : `approve: ${status.approval_url}`
1072
+ ].filter(Boolean).join("\n")
1073
+ );
1074
+ return EXIT.ok;
1075
+ }
1076
+ const name = args.flags.name?.trim();
1077
+ if (!name) throw new UsageError("Usage: haven agents connect --name <name> --budget <amount> --token USDC --period <minutes>");
1078
+ if (!args.flags.budget || !args.flags.token || args.flags.period === void 0) {
1079
+ throw new UsageError("--budget, --token and --period are required (period is whole minutes; 0 means one-time)");
1080
+ }
1081
+ const { safeId, token } = await resolveWalletAndToken(args, api, args.flags.token);
1082
+ const amount = parseTokenAmount(args.flags.budget, token.decimals, token.symbol);
1083
+ if (!amount.ok) throw new UsageError(amount.message);
1084
+ const setup = await api.post("/agent-connection-setups", {
1085
+ name,
1086
+ safe_id: safeId,
1087
+ allowances: [
1088
+ {
1089
+ token_address: token.address ?? "0x0000000000000000000000000000000000000000",
1090
+ token_symbol: token.symbol,
1091
+ // ATOMIC on the way in, human on the way back (#2295). Converted here
1092
+ // exactly once, from the decimals the backend just told us.
1093
+ allowance_amount: amount.atomic,
1094
+ reset_period_min: args.flags.period
1095
+ }
1096
+ ],
1097
+ // How this setup was made, for connect attribution (#2302). The route
1098
+ // already accepts any slug, so nothing backend-side had to change.
1099
+ source: "cli",
1100
+ // #2522: the hand-off marker, set only when an agent is driving this CLI
1101
+ // and says so. Never inferred — a guess here mislabels a human's own run.
1102
+ ...d.env.HAVEN_AGENT_DRIVEN === "1" ? { via: "agent" } : {}
1103
+ });
1104
+ if (!args.flags.run) {
1105
+ emit(
1106
+ d,
1107
+ args.flags.json,
1108
+ setup,
1109
+ () => [
1110
+ "Run this where the agent runs:",
1111
+ "",
1112
+ setup.connector_command,
1113
+ "",
1114
+ `Then approve the budget: ${setup.approval_url}`,
1115
+ `Setup ${setup.setup_id} expires ${setup.expires_at}.`
1116
+ ].join("\n")
1117
+ );
1118
+ return EXIT.ok;
1119
+ }
1120
+ const run2 = await runConnector(setup.connector_command, d.spawner, (chunk) => d.err(chunk.trimEnd()));
1121
+ const relay = relayLine(run2.outcome);
1122
+ const merged = {
1123
+ setup_id: setup.setup_id,
1124
+ approval_url: setup.approval_url,
1125
+ connector_command: setup.connector_command,
1126
+ connector_exit_code: run2.exitCode,
1127
+ outcome: run2.outcome,
1128
+ relay
1129
+ };
1130
+ if (isRefusal(run2.outcome)) {
1131
+ emitConnectResult(d, args.flags.json, merged, relay);
1132
+ return EXIT.refused;
1133
+ }
1134
+ if (!run2.outcome) {
1135
+ throw new HavenCliError(
1136
+ `The connector produced no outcome (exit ${run2.exitCode}).${run2.stdoutNoise ? ` Output: ${run2.stdoutNoise}` : ""}`,
1137
+ run2.exitCode === 0 ? EXIT.failed : EXIT.failed
1138
+ );
1139
+ }
1140
+ emitConnectResult(d, args.flags.json, merged, relay);
1141
+ return EXIT.ok;
1142
+ }
1143
+ function emitConnectResult(d, json, merged, relay) {
1144
+ emit(
1145
+ d,
1146
+ json,
1147
+ merged,
1148
+ () => [relay, relay ? "" : null, `setup ${merged.setup_id}: ${merged.outcome?.outcome ?? "unknown"}`].filter((line) => line !== null).join("\n")
1149
+ );
1150
+ }
847
1151
  async function cmdContactsList(args, d) {
848
1152
  const { api } = await authed(args, d);
849
1153
  const { contacts } = await api.get("/contacts");