@haven_ai/cli 0.0.0-dev.202609042048.ad6cc9b → 0.0.0-dev.202609050705.51c3d92

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
@@ -26,7 +26,8 @@ The CLI talks to the hosted Haven backend by default. Point it elsewhere with
26
26
  ```bash
27
27
  # auth
28
28
  haven login --email you@example.com # password via prompt or HAVEN_PASSWORD
29
- haven whoami
29
+ haven whoami # user, session expiry, API URL
30
+ haven guide # the agent onboarding runbook
30
31
  haven logout
31
32
 
32
33
  # read
@@ -56,6 +57,62 @@ Add `--json` to any read command for machine-readable output:
56
57
  haven agents list --json | jq '.[] | select(.status == "active") | .name'
57
58
  ```
58
59
 
60
+ ## For agents and scripts
61
+
62
+ `--json` is a contract, not a formatting flag (#2525). Under it, **stdout
63
+ carries exactly one JSON value and nothing else** — every sentence meant for a
64
+ human goes to stderr. That holds for refusals too, which is the half a caller
65
+ cannot work around: parse stdout, branch on the exit code, and read stderr only
66
+ when a person is watching.
67
+
68
+ ```bash
69
+ haven agents list --json # success: the payload, unchanged
70
+ haven agents show missing --json # failure: one object, still parseable
71
+ ```
72
+
73
+ A failure is always:
74
+
75
+ ```json
76
+ { "ok": false, "error": { "code": "not_authenticated", "message": "Not authenticated.", "hint": "Run `haven login` ..." } }
77
+ ```
78
+
79
+ Success keeps whatever shape the command already returned — including the bare
80
+ arrays the list commands emit — so a script that parses a success today keeps
81
+ working. `login`, `logout` and the manage commands, which used to print only a
82
+ sentence, now emit an object as well.
83
+
84
+ ### Exit codes
85
+
86
+ | Code | Meaning | What a caller should do |
87
+ |---|---|---|
88
+ | `0` | Success | Continue. |
89
+ | `1` | Failed | Something broke that none of the below describes (a 5xx, an unexpected error). Retrying may help. |
90
+ | `2` | Usage | The command line was wrong — unknown command, missing argument, bad flag, or a `--safe` that matches nothing. Fix the argv; retrying it unchanged will not help. |
91
+ | `3` | Not authenticated | No stored session, or the backend rejected the one we have. Run `haven login`. |
92
+ | `4` | Refused | The session is fine and the backend said no anyway (403, 410, other 4xx). The message is the backend's, echoed verbatim. |
93
+ | `5` | Network | The backend could not be reached at all. Check connectivity and `--api`. |
94
+
95
+ **Why a 401 is `3` and not `4`.** The two overlap by definition — a 401 *is* the
96
+ backend refusing — and the split is made on what the caller does next: `3` means
97
+ re-authenticate, `4` means do not bother, the session was never the problem.
98
+ Collapsing them would leave an agent guessing which one it had.
99
+
100
+ ### `haven guide`
101
+
102
+ ```bash
103
+ haven guide # the agent onboarding runbook, as Markdown
104
+ haven guide --json # { ok, format, content }
105
+ ```
106
+
107
+ Prints the same text served at `/for-agents.md` — what Haven is, which steps
108
+ need a human, and what to say at each hand-off. It is compiled into the CLI, so
109
+ it works with no session and no network, which is exactly the situation it
110
+ describes how to get out of. The string is generated from
111
+ `packages/sdk/src/agent-guidance.ts` by
112
+ `node packages/cli/scripts/sync-agent-guidance.mjs` and byte-pinned to it by a
113
+ test; the copy exists so this package keeps **zero runtime dependencies** and
114
+ `npx @haven_ai/cli` stays a small install for an agent.
115
+
59
116
  ## Config
60
117
 
61
118
  - `--api <url>` or `HAVEN_API_URL` — backend URL (defaults to the hosted Haven
package/dist/cli.cjs CHANGED
@@ -1,10 +1,10 @@
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 readline = require('readline');
8
8
 
9
9
  // src/args.ts
10
10
  var VALUE_FLAGS = /* @__PURE__ */ new Set([
@@ -73,7 +73,10 @@ function helpText() {
73
73
  "Auth:",
74
74
  " login [--email <e>] Sign in (password via prompt or HAVEN_PASSWORD)",
75
75
  " logout Clear the saved session",
76
- " whoami Show the signed-in user",
76
+ " whoami Show the signed-in user, session expiry and API URL",
77
+ "",
78
+ "For agents:",
79
+ " guide Print the agent onboarding runbook (same text as /for-agents.md)",
77
80
  "",
78
81
  "Read:",
79
82
  " wallets list List your Haven wallets",
@@ -97,11 +100,14 @@ function helpText() {
97
100
  " contacts add <name> <address> | contacts remove <id>",
98
101
  "",
99
102
  "Options:",
100
- " --json Machine-readable output (for scripting)",
103
+ " --json One JSON value on stdout, prose on stderr, on every",
104
+ " command including refusals: { ok: false, error: { code, message, hint? } }",
101
105
  " --yes, -y Skip the confirmation prompt for destructive actions",
102
106
  " --api <url> Backend URL (default: HAVEN_API_URL or http://localhost:3001)",
103
107
  " --help, --version",
104
108
  "",
109
+ "Exit codes: 0 ok \xB7 1 failed \xB7 2 usage \xB7 3 not authenticated \xB7 4 refused \xB7 5 network",
110
+ "",
105
111
  "On-chain actions (deploy, budgets, approvers, send) are signed in the",
106
112
  "dashboard \u2014 this CLI reads and manages; it never holds your keys."
107
113
  ].join("\n");
@@ -244,53 +250,157 @@ function toCsv(headers, rows) {
244
250
  return lines.join("\r\n");
245
251
  }
246
252
 
253
+ // src/errors.ts
254
+ var EXIT = {
255
+ ok: 0,
256
+ /** Something failed and none of the specific codes below describe it. */
257
+ failed: 1,
258
+ /** The command line was wrong: unknown command, missing argument, bad flag. */
259
+ usage: 2,
260
+ /** No stored session, or the backend rejected the one we have. Log in again. */
261
+ notAuthenticated: 3,
262
+ /** Authenticated, and the backend refused anyway (403, 410, other 4xx). */
263
+ refused: 4,
264
+ /** The backend could not be reached at all. */
265
+ network: 5
266
+ };
267
+ var UsageError = class extends Error {
268
+ hint;
269
+ constructor(message, hint) {
270
+ super(message);
271
+ this.name = "UsageError";
272
+ this.hint = hint;
273
+ }
274
+ };
275
+ function toFailure(err) {
276
+ if (err instanceof UsageError) {
277
+ return { code: "usage", exit: EXIT.usage, message: err.message, hint: err.hint };
278
+ }
279
+ if (err instanceof CliApiError) {
280
+ if (err.status === 0) {
281
+ return {
282
+ code: "network",
283
+ exit: EXIT.network,
284
+ message: err.message,
285
+ hint: "Check the network and `--api`, then retry."
286
+ };
287
+ }
288
+ if (err.status === 401) {
289
+ return {
290
+ code: "not_authenticated",
291
+ exit: EXIT.notAuthenticated,
292
+ message: err.message,
293
+ hint: "Run `haven login` (or set HAVEN_EMAIL and HAVEN_PASSWORD)."
294
+ };
295
+ }
296
+ if (err.status >= 400 && err.status < 500) {
297
+ return { code: "refused", exit: EXIT.refused, message: err.message };
298
+ }
299
+ return { code: "failed", exit: EXIT.failed, message: err.message };
300
+ }
301
+ return { code: "failed", exit: EXIT.failed, message: err instanceof Error ? err.message : String(err) };
302
+ }
303
+
304
+ // src/output.ts
305
+ function createOutput(json, out, err) {
306
+ return {
307
+ json,
308
+ data(payload, human) {
309
+ out(json ? JSON.stringify(payload, null, 2) : human());
310
+ },
311
+ text(content, meta) {
312
+ out(json ? JSON.stringify({ ok: true, ...meta, content }, null, 2) : content);
313
+ },
314
+ note(line) {
315
+ if (json) err(line);
316
+ else out(line);
317
+ },
318
+ failure(failure) {
319
+ const body = {
320
+ ok: false,
321
+ error: {
322
+ code: failure.code,
323
+ message: failure.message,
324
+ ...failure.hint ? { hint: failure.hint } : {}
325
+ }
326
+ };
327
+ if (json) out(JSON.stringify(body, null, 2));
328
+ else err(failure.hint ? `${failure.message}
329
+ ${failure.hint}` : failure.message);
330
+ }
331
+ };
332
+ }
333
+
334
+ // src/agent-guidance-text.ts
335
+ 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.\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";
336
+
337
+ // src/token.ts
338
+ function sessionExpiry(token) {
339
+ const segment = token.split(".")[1];
340
+ if (!segment) return null;
341
+ try {
342
+ const json = Buffer.from(segment.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
343
+ const claims = JSON.parse(json);
344
+ if (typeof claims.exp !== "number" || !Number.isFinite(claims.exp)) return null;
345
+ return new Date(claims.exp * 1e3).toISOString();
346
+ } catch {
347
+ return null;
348
+ }
349
+ }
350
+
247
351
  // src/commands.ts
248
352
  var DEFAULT_API = "https://havenbackend-production-8a00.up.railway.app";
249
- var CLI_VERSION = "0.0.0-dev.202609042048.ad6cc9b";
353
+ var CLI_VERSION = "0.0.0-dev.202609050705.51c3d92";
250
354
  async function run(argv, deps = {}) {
355
+ const out = deps.out ?? ((l) => process.stdout.write(`${l}
356
+ `));
357
+ const err = deps.err ?? ((l) => process.stderr.write(`${l}
358
+ `));
359
+ const json = argv.includes("--json");
360
+ const o = createOutput(json, out, err);
251
361
  const d = {
252
362
  sessionStore: deps.sessionStore ?? createSessionStore(),
253
363
  makeApi: deps.makeApi ?? ((baseUrl, token) => createCliApi({ baseUrl, token })),
254
364
  promptPassword: deps.promptPassword ?? (() => Promise.reject(new Error("No password input available"))),
255
- out: deps.out ?? ((l) => process.stdout.write(`${l}
256
- `)),
257
- err: deps.err ?? ((l) => process.stderr.write(`${l}
258
- `)),
259
- env: deps.env ?? process.env
365
+ out,
366
+ err,
367
+ env: deps.env ?? process.env,
368
+ o
260
369
  };
261
370
  let args;
262
371
  try {
263
372
  args = parseArgs(argv);
264
373
  } catch (e) {
265
- d.err(e instanceof Error ? e.message : String(e));
266
- return 1;
374
+ return fail(d, new UsageError(e instanceof Error ? e.message : String(e), "Run `haven --help`."));
267
375
  }
268
376
  if (args.flags.version) {
269
- d.out(CLI_VERSION);
270
- return 0;
377
+ o.data({ version: CLI_VERSION }, () => CLI_VERSION);
378
+ return EXIT.ok;
271
379
  }
272
380
  if (args.flags.help || !args.command) {
273
- d.out(helpText());
274
- return 0;
381
+ o.data({ help: helpText() }, () => helpText());
382
+ return EXIT.ok;
275
383
  }
276
384
  try {
277
385
  return await dispatch(args, d);
278
386
  } catch (e) {
279
- if (e instanceof CliApiError) {
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;
387
+ return fail(d, e);
285
388
  }
286
389
  }
390
+ function fail(d, err) {
391
+ const failure = toFailure(err);
392
+ d.o.failure(failure);
393
+ return failure.exit;
394
+ }
287
395
  async function dispatch(args, d) {
288
396
  const key = args.sub ? `${args.command} ${args.sub}` : args.command;
289
397
  switch (key) {
398
+ case "guide":
399
+ return cmdGuide(args, d);
290
400
  case "login":
291
401
  return cmdLogin(args, d);
292
402
  case "logout":
293
- return cmdLogout(d);
403
+ return cmdLogout(args, d);
294
404
  case "whoami":
295
405
  return cmdWhoami(args, d);
296
406
  case "wallets list":
@@ -328,8 +438,7 @@ async function dispatch(args, d) {
328
438
  case "contacts remove":
329
439
  return cmdContactsRemove(args, d);
330
440
  default:
331
- d.err(`Unknown command: ${key}. Run \`haven --help\`.`);
332
- return 1;
441
+ throw new UsageError(`Unknown command: ${key}.`, "Run `haven --help` for the command list.");
333
442
  }
334
443
  }
335
444
  function baseUrlFor(args, d, session) {
@@ -337,40 +446,52 @@ function baseUrlFor(args, d, session) {
337
446
  }
338
447
  async function authed(args, d) {
339
448
  const session = await d.sessionStore.load();
340
- if (!session) throw new CliApiError("Not authenticated. Run `haven login` first.", 401);
449
+ if (!session) throw new CliApiError("Not authenticated.", 401);
341
450
  return { session, api: d.makeApi(baseUrlFor(args, d, session), session.token) };
342
451
  }
343
- function emit(d, json, data, human) {
344
- d.out(json ? JSON.stringify(data, null, 2) : human());
452
+ function emit(d, _json, data, human) {
453
+ d.o.data(data, human);
454
+ }
455
+ async function cmdGuide(_args, d) {
456
+ d.o.data({ ok: true, format: "markdown", content: HAVEN_AGENT_RUNBOOK_MD }, () => HAVEN_AGENT_RUNBOOK_MD);
457
+ return EXIT.ok;
345
458
  }
346
459
  async function cmdLogin(args, d) {
347
460
  const email = args.flags.email ?? d.env.HAVEN_EMAIL;
348
461
  if (!email) {
349
- d.err("Provide an email with --email (or HAVEN_EMAIL).");
350
- return 1;
462
+ throw new UsageError("An email is required.", "Pass --email <address>, or set HAVEN_EMAIL.");
351
463
  }
352
464
  const password = d.env.HAVEN_PASSWORD ?? await d.promptPassword();
353
465
  if (!password) {
354
- d.err("A password is required.");
355
- return 1;
466
+ throw new UsageError("A password is required.", "Set HAVEN_PASSWORD for a non-interactive run.");
356
467
  }
357
468
  const baseUrl = baseUrlFor(args, d, null);
358
469
  const api = d.makeApi(baseUrl);
359
470
  const res = await api.post("/auth/login", { email, password });
360
471
  await d.sessionStore.save({ token: res.token, apiBaseUrl: baseUrl, user: res.user });
361
- emit(d, args.flags.json, { user: res.user, apiBaseUrl: baseUrl }, () => `Signed in as ${res.user.email}.`);
362
- return 0;
472
+ emit(
473
+ d,
474
+ args.flags.json,
475
+ { ok: true, email: res.user.email, expires_at: sessionExpiry(res.token), user: res.user, apiBaseUrl: baseUrl },
476
+ () => `Signed in as ${res.user.email}.`
477
+ );
478
+ return EXIT.ok;
363
479
  }
364
- async function cmdLogout(d) {
480
+ async function cmdLogout(args, d) {
365
481
  await d.sessionStore.clear();
366
- d.out("Signed out.");
367
- return 0;
482
+ emit(d, args.flags.json, { ok: true, signed_out: true }, () => "Signed out.");
483
+ return EXIT.ok;
368
484
  }
369
485
  async function cmdWhoami(args, d) {
370
- const { api } = await authed(args, d);
486
+ const { session, api } = await authed(args, d);
371
487
  const user = await api.get("/auth/me");
372
- emit(d, args.flags.json, user, () => `${user.email}${user.name ? ` (${user.name})` : ""}`);
373
- return 0;
488
+ emit(
489
+ d,
490
+ args.flags.json,
491
+ { ...user, id: user.id, email: user.email, expires_at: sessionExpiry(session.token), api_url: session.apiBaseUrl },
492
+ () => `${user.email}${user.name ? ` (${user.name})` : ""}`
493
+ );
494
+ return EXIT.ok;
374
495
  }
375
496
  async function cmdWalletsList(args, d) {
376
497
  const { api } = await authed(args, d);
@@ -384,15 +505,15 @@ async function cmdWalletsList(args, d) {
384
505
  safes.map((s) => [s.name, chainName(s.chain_id), truncateAddress(s.safe_address), s.is_default ? "\u2713" : ""])
385
506
  )
386
507
  );
387
- return 0;
508
+ return EXIT.ok;
388
509
  }
389
510
  async function cmdWalletsBalances(args, d) {
390
511
  const { api } = await authed(args, d);
391
512
  const { safes } = await api.get("/user/safes");
392
513
  const safe = pickSafe(safes, args.flags.safe);
393
514
  if (!safe) {
394
- d.err(args.flags.safe ? `No wallet matches "${args.flags.safe}".` : "No Haven wallet found.");
395
- return 1;
515
+ if (args.flags.safe) throw new UsageError(`No wallet matches "${args.flags.safe}".`);
516
+ throw new CliApiError("No Haven wallet found.", 404);
396
517
  }
397
518
  const { balances } = await api.get(
398
519
  `/balances/${safe.safe_address}?chain_id=${safe.chain_id}`
@@ -406,7 +527,7 @@ async function cmdWalletsBalances(args, d) {
406
527
  balances.length === 0 ? " (no balances)" : table(["TOKEN", "BALANCE"], balances.map((b) => [b.symbol, b.formatted]))
407
528
  ].join("\n")
408
529
  );
409
- return 0;
530
+ return EXIT.ok;
410
531
  }
411
532
  function pickSafe(safes, ref) {
412
533
  if (!ref) return safes.find((s) => s.is_default) ?? safes[0];
@@ -425,14 +546,11 @@ async function cmdAgentsList(args, d) {
425
546
  agents.map((a) => [a.id, a.name, a.status, budgetSummary(a.allowances)])
426
547
  )
427
548
  );
428
- return 0;
549
+ return EXIT.ok;
429
550
  }
430
551
  async function cmdAgentsShow(args, d) {
431
552
  const id = args.positionals[0];
432
- if (!id) {
433
- d.err("Usage: haven agents show <id>");
434
- return 1;
435
- }
553
+ if (!id) throw new UsageError("Usage: haven agents show <id>");
436
554
  const { api } = await authed(args, d);
437
555
  const agent = await api.get(`/agents/${id}`);
438
556
  emit(
@@ -445,14 +563,11 @@ async function cmdAgentsShow(args, d) {
445
563
  `budget: ${budgetSummary(agent.allowances)}`
446
564
  ].join("\n")
447
565
  );
448
- return 0;
566
+ return EXIT.ok;
449
567
  }
450
568
  async function cmdBudgetShow(args, d) {
451
569
  const id = args.positionals[0];
452
- if (!id) {
453
- d.err("Usage: haven budget show <agentId>");
454
- return 1;
455
- }
570
+ if (!id) throw new UsageError("Usage: haven budget show <agentId>");
456
571
  const { api } = await authed(args, d);
457
572
  const agent = await api.get(`/agents/${id}`);
458
573
  const allowances = agent.allowances ?? [];
@@ -465,7 +580,7 @@ async function cmdBudgetShow(args, d) {
465
580
  allowances.map((a) => [a.token_symbol, a.allowance_amount, resetLabel(a.reset_period_min)])
466
581
  )
467
582
  );
468
- return 0;
583
+ return EXIT.ok;
469
584
  }
470
585
  function budgetSummary(allowances) {
471
586
  if (!allowances || allowances.length === 0) return "\u2014";
@@ -480,75 +595,64 @@ function resetLabel(mins) {
480
595
  }
481
596
  async function cmdAgentLifecycle(args, d, action) {
482
597
  const id = args.positionals[0];
483
- if (!id) {
484
- d.err(`Usage: haven agents ${action} <id>`);
485
- return 1;
486
- }
598
+ if (!id) throw new UsageError(`Usage: haven agents ${action} <id>`);
487
599
  const { api } = await authed(args, d);
488
600
  await api.post(`/agents/${id}/${action}`);
489
- d.out(`Agent ${id} ${action === "pause" ? "paused" : "resumed"}.`);
490
- return 0;
601
+ const status = action === "pause" ? "paused" : "resumed";
602
+ emit(d, args.flags.json, { ok: true, agent_id: id, status }, () => `Agent ${id} ${status}.`);
603
+ return EXIT.ok;
491
604
  }
492
605
  async function cmdAgentRevoke(args, d) {
493
606
  const id = args.positionals[0];
494
- if (!id) {
495
- d.err("Usage: haven agents revoke <id> --yes");
496
- return 1;
497
- }
607
+ if (!id) throw new UsageError("Usage: haven agents revoke <id> --yes");
498
608
  if (!args.flags.yes) {
499
- d.err(`This permanently revokes agent ${id}. Re-run with --yes to confirm.`);
500
- return 1;
609
+ throw new UsageError(
610
+ `This permanently revokes agent ${id}.`,
611
+ "Re-run with --yes to confirm. Revoke is terminal \u2014 the agent cannot go back to active."
612
+ );
501
613
  }
502
614
  const { api } = await authed(args, d);
503
615
  await api.post(`/agents/${id}/revoke`);
504
- d.out(`Agent ${id} revoked. To also remove its on-chain allowance, use the dashboard.`);
505
- return 0;
616
+ emit(
617
+ d,
618
+ args.flags.json,
619
+ { ok: true, agent_id: id, status: "revoked" },
620
+ () => `Agent ${id} revoked. To also remove its on-chain allowance, use the dashboard.`
621
+ );
622
+ return EXIT.ok;
506
623
  }
507
624
  async function cmdAgentRotateKey(args, d) {
508
625
  const id = args.positionals[0];
509
- if (!id) {
510
- d.err("Usage: haven agents rotate-key <id>");
511
- return 1;
512
- }
626
+ if (!id) throw new UsageError("Usage: haven agents rotate-key <id>");
513
627
  const { api } = await authed(args, d);
514
628
  const res = await api.post(`/agents/${id}/rotate-key`);
515
- if (args.flags.json) {
516
- d.out(JSON.stringify(res, null, 2));
517
- } else {
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;
629
+ d.o.note("New API key (shown once \u2014 store it now; the old key stops working):");
630
+ emit(d, args.flags.json, res, () => res.api_key);
631
+ return EXIT.ok;
522
632
  }
523
633
  async function cmdAgentRename(args, d) {
524
634
  const [id, ...nameParts] = args.positionals;
525
635
  const name = nameParts.join(" ").trim();
526
- if (!id || !name) {
527
- d.err("Usage: haven agents rename <id> <name>");
528
- return 1;
529
- }
636
+ if (!id || !name) throw new UsageError("Usage: haven agents rename <id> <name>");
530
637
  const { api } = await authed(args, d);
531
638
  await api.put(`/agents/${id}`, { name });
532
- d.out(`Agent ${id} renamed to "${name}".`);
533
- return 0;
639
+ emit(d, args.flags.json, { ok: true, agent_id: id, name }, () => `Agent ${id} renamed to "${name}".`);
640
+ return EXIT.ok;
534
641
  }
535
642
  async function cmdWalletRename(args, d) {
536
643
  const [id, ...nameParts] = args.positionals;
537
644
  const name = nameParts.join(" ").trim();
538
- if (!id || !name) {
539
- d.err("Usage: haven wallets rename <id> <name>");
540
- return 1;
541
- }
645
+ if (!id || !name) throw new UsageError("Usage: haven wallets rename <id> <name>");
542
646
  const { api } = await authed(args, d);
543
647
  await api.put(`/user/safes/${id}`, { name });
544
- d.out(`Wallet ${id} renamed to "${name}".`);
545
- return 0;
648
+ emit(d, args.flags.json, { ok: true, safe_id: id, name }, () => `Wallet ${id} renamed to "${name}".`);
649
+ return EXIT.ok;
546
650
  }
547
651
  async function resolveSafeId(args, api) {
548
652
  if (!args.flags.safe) return void 0;
549
653
  const { safes } = await api.get("/user/safes");
550
654
  const safe = pickSafe(safes, args.flags.safe);
551
- if (!safe) throw new CliApiError(`No wallet matches "${args.flags.safe}".`, 1);
655
+ if (!safe) throw new UsageError(`No wallet matches "${args.flags.safe}".`);
552
656
  return safe.id;
553
657
  }
554
658
  async function cmdActivityList(args, d) {
@@ -577,7 +681,7 @@ async function cmdActivityList(args, d) {
577
681
  ])
578
682
  )
579
683
  );
580
- return 0;
684
+ return EXIT.ok;
581
685
  }
582
686
  async function cmdActivityExport(args, d) {
583
687
  if (args.flags.format === "sie") return exportSie(args, d);
@@ -619,8 +723,8 @@ async function cmdActivityExport(args, d) {
619
723
  t.hash,
620
724
  t.chainId != null ? String(t.chainId) : ""
621
725
  ]);
622
- d.out(toCsv(headers, rows));
623
- return 0;
726
+ d.o.text(toCsv(headers, rows), { format: "csv", rows: rows.length });
727
+ return EXIT.ok;
624
728
  }
625
729
  function exportType(t) {
626
730
  if (t.activityType === "delegate_sweep") return "allowance funding";
@@ -640,8 +744,8 @@ async function exportSie(args, d) {
640
744
  if (args.flags.to) params.set("to", args.flags.to);
641
745
  if (args.flags.company) params.set("company", args.flags.company);
642
746
  const content = await api.getText(`/accounting/export?${params.toString()}`);
643
- d.out(content);
644
- return 0;
747
+ d.o.text(content, { format: "sie" });
748
+ return EXIT.ok;
645
749
  }
646
750
  async function cmdCatalogList(args, d) {
647
751
  const { api } = await authed(args, d);
@@ -655,7 +759,7 @@ async function cmdCatalogList(args, d) {
655
759
  entries.map((e) => [e.name, e.category, e.rail, e.price_display ?? "\u2014", e.status])
656
760
  )
657
761
  );
658
- return 0;
762
+ return EXIT.ok;
659
763
  }
660
764
  async function cmdContactsList(args, d) {
661
765
  const { api } = await authed(args, d);
@@ -666,36 +770,28 @@ async function cmdContactsList(args, d) {
666
770
  contacts,
667
771
  () => contacts.length === 0 ? "No contacts yet." : table(["ID", "NAME", "ADDRESS"], contacts.map((c) => [c.id, c.name, truncateAddress(c.address)]))
668
772
  );
669
- return 0;
773
+ return EXIT.ok;
670
774
  }
671
775
  async function cmdContactsAdd(args, d) {
672
776
  const [address, ...nameParts] = [...args.positionals].reverse();
673
777
  const name = nameParts.reverse().join(" ").trim();
674
- if (!name || !address) {
675
- d.err("Usage: haven contacts add <name> <address>");
676
- return 1;
677
- }
778
+ if (!name || !address) throw new UsageError("Usage: haven contacts add <name> <address>");
678
779
  const { api } = await authed(args, d);
679
780
  const contact = await api.post("/contacts", { name, address });
680
781
  emit(d, args.flags.json, contact, () => `Added contact "${contact.name}" (${truncateAddress(contact.address)}).`);
681
- return 0;
782
+ return EXIT.ok;
682
783
  }
683
784
  async function cmdContactsRemove(args, d) {
684
785
  const id = args.positionals[0];
685
- if (!id) {
686
- d.err("Usage: haven contacts remove <id>");
687
- return 1;
688
- }
786
+ if (!id) throw new UsageError("Usage: haven contacts remove <id>");
689
787
  const { api } = await authed(args, d);
690
788
  await api.del(`/contacts/${id}`);
691
- d.out(`Contact ${id} removed.`);
692
- return 0;
789
+ emit(d, args.flags.json, { ok: true, contact_id: id, removed: true }, () => `Contact ${id} removed.`);
790
+ return EXIT.ok;
693
791
  }
694
-
695
- // src/cli.ts
696
- function promptPassword() {
792
+ function promptPasswordWith(streams) {
697
793
  return new Promise((resolve2, reject) => {
698
- const { stdin, stdout } = process;
794
+ const { stdin, stderr } = streams;
699
795
  if (!stdin.isTTY) {
700
796
  let data = "";
701
797
  stdin.setEncoding("utf8");
@@ -704,18 +800,18 @@ function promptPassword() {
704
800
  stdin.on("error", reject);
705
801
  return;
706
802
  }
707
- stdout.write("Password: ");
803
+ stderr.write("Password: ");
708
804
  readline.emitKeypressEvents(stdin);
709
805
  stdin.setRawMode(true);
710
806
  let value = "";
711
807
  const onKey = (char, key) => {
712
808
  if (key.name === "return" || key.name === "enter") {
713
809
  cleanup();
714
- stdout.write("\n");
810
+ stderr.write("\n");
715
811
  resolve2(value);
716
812
  } else if (key.ctrl && key.name === "c") {
717
813
  cleanup();
718
- stdout.write("\n");
814
+ stderr.write("\n");
719
815
  reject(new Error("Cancelled"));
720
816
  } else if (key.name === "backspace") {
721
817
  value = value.slice(0, -1);
@@ -732,9 +828,20 @@ function promptPassword() {
732
828
  stdin.on("keypress", onKey);
733
829
  });
734
830
  }
831
+
832
+ // src/cli.ts
833
+ function promptPassword() {
834
+ return promptPasswordWith({ stdin: process.stdin, stdout: process.stdout, stderr: process.stderr });
835
+ }
735
836
  run(process.argv.slice(2), { promptPassword }).then((code) => process.exit(code)).catch((err) => {
736
- process.stderr.write(`${err instanceof Error ? err.message : String(err)}
837
+ const message = err instanceof Error ? err.message : String(err);
838
+ if (process.argv.includes("--json")) {
839
+ process.stdout.write(`${JSON.stringify({ ok: false, error: { code: "failed", message } }, null, 2)}
840
+ `);
841
+ } else {
842
+ process.stderr.write(`${message}
737
843
  `);
844
+ }
738
845
  process.exit(1);
739
846
  });
740
847
  //# sourceMappingURL=cli.cjs.map