@indigoai-us/hq-cli 5.76.0 → 5.77.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.
@@ -25,7 +25,7 @@ import chalk from "chalk";
25
25
  import { randomUUID } from "node:crypto";
26
26
  import { ensureCognitoToken } from "../utils/cognito-session.js";
27
27
  import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
28
- import { AGENT_PRICE_CENTS, confirmChargeOrExit, parseBillingPayload, surfaceBillingRequired, } from "../utils/billing-gate.js";
28
+ import { AGENT_PRICE_CENTS, confirmChargeOrExit, parseBillingPayload, surfaceBillingBlocked, } from "../utils/billing-gate.js";
29
29
  /** Reasoning-effort values hq-pro accepts on `runtime-config`. */
30
30
  export const VALID_EFFORTS = new Set([
31
31
  "minimal",
@@ -64,7 +64,12 @@ export async function agentsRequest(opts) {
64
64
  const res = await vaultApiFetch(opts);
65
65
  if (!res.ok) {
66
66
  const body = (await res.json().catch(() => ({})));
67
- throw new AgentsHttpError(res.status, body.error ?? body.message ?? res.statusText, body.code, parseBillingPayload(body));
67
+ throw new AgentsHttpError(res.status,
68
+ // `message` FIRST: on a payment_failed envelope it carries the friendly
69
+ // decline copy ("Your card was declined…") while `error` is the generic
70
+ // "payment required" — error-first would feed surfaceBillingBlocked the
71
+ // generic string and lose the decline reason (mirrors outpostRequest).
72
+ body.message ?? body.error ?? res.statusText, body.code, parseBillingPayload(body));
68
73
  }
69
74
  return (await res.json());
70
75
  }
@@ -371,7 +376,7 @@ export function registerAgentsCommand(program) {
371
376
  if (err instanceof AgentsHttpError &&
372
377
  err.status === 402 &&
373
378
  err.billing) {
374
- await surfaceBillingRequired(token, err.billing);
379
+ await surfaceBillingBlocked(token, err.billing, err.message);
375
380
  process.exit(1);
376
381
  }
377
382
  throw err;
@@ -92,5 +92,66 @@ export declare function stripRedundantCompanyScope(prefix: string): {
92
92
  export declare function runFilesDelete(params: RunFilesDeleteParams, deps?: {
93
93
  confirm?: ConfirmFn;
94
94
  }): Promise<void>;
95
+ export interface FileVersionRow {
96
+ versionId: string;
97
+ isLatest: boolean;
98
+ lastModified: string;
99
+ size: number;
100
+ isDeleteMarker?: boolean;
101
+ }
102
+ export interface FilesVersionsResponse {
103
+ key: string;
104
+ versions: FileVersionRow[];
105
+ computedAt: string;
106
+ }
107
+ export interface FileTombstoneRow {
108
+ key: string;
109
+ deletedAt: string;
110
+ deletedBy: string;
111
+ deletedPrefix: string;
112
+ }
113
+ export interface FilesTrashResponse {
114
+ companyUid?: string;
115
+ personal?: true;
116
+ tombstones: FileTombstoneRow[];
117
+ cursor?: string | null;
118
+ truncated: boolean;
119
+ computedAt: string;
120
+ }
121
+ export interface FilesRestoreResponse {
122
+ key: string;
123
+ wasDeleted: boolean;
124
+ restoredFromVersionId: string;
125
+ newVersionId: string;
126
+ }
127
+ export declare class FilesRecoveryHttpError extends Error {
128
+ readonly status: number;
129
+ readonly code?: string | undefined;
130
+ constructor(status: number, message: string, code?: string | undefined);
131
+ }
132
+ /** Render the exact-key version history, including S3 delete markers. */
133
+ export declare function formatFilesVersionsTable(rows: FileVersionRow[]): string;
134
+ /** Render durable delete tombstones without implying the objects still exist. */
135
+ export declare function formatFilesTrashTable(rows: FileTombstoneRow[]): string;
136
+ export declare function runFilesVersions(params: {
137
+ key: string;
138
+ personal: boolean;
139
+ companySlug: string | undefined;
140
+ }): Promise<FilesVersionsResponse>;
141
+ export declare function runFilesRestore(params: {
142
+ key: string;
143
+ versionId?: string;
144
+ yes: boolean;
145
+ personal: boolean;
146
+ companySlug: string | undefined;
147
+ }, deps?: {
148
+ confirm?: ConfirmFn;
149
+ }): Promise<FilesRestoreResponse | undefined>;
150
+ export declare function runFilesTrash(params: {
151
+ prefix: string;
152
+ cursor?: string;
153
+ personal: boolean;
154
+ companySlug: string | undefined;
155
+ }): Promise<FilesTrashResponse>;
95
156
  export {};
96
157
  //# sourceMappingURL=files.d.ts.map
@@ -379,6 +379,69 @@ export function registerFilesCommand(program) {
379
379
  process.exit(1);
380
380
  }
381
381
  });
382
+ files
383
+ .command("versions <path>")
384
+ .description("List prior content versions and delete markers for one exact vault key. Use --personal for your personal vault.")
385
+ .option("--personal", "Target your own personal vault instead of a company vault (mutually exclusive with --company)")
386
+ .action(async (path, opts) => {
387
+ try {
388
+ const companySlug = files.opts().company;
389
+ const personal = opts.personal === true;
390
+ assertRecoveryScope(personal, companySlug);
391
+ await runFilesVersions({ key: path, personal, companySlug });
392
+ }
393
+ catch (err) {
394
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
395
+ process.exit(1);
396
+ }
397
+ });
398
+ files
399
+ .command("restore <path>")
400
+ .description("Restore a prior version or undelete an exact vault key. Prompts before overwriting unless --yes. Use --personal for your personal vault.")
401
+ .option("--personal", "Target your own personal vault instead of a company vault (mutually exclusive with --company)")
402
+ .option("--version <id>", "Content version ID to restore")
403
+ .option("-y, --yes", "Skip the overwrite confirmation prompt (for scripts)")
404
+ .action(async (path, opts) => {
405
+ try {
406
+ const companySlug = files.opts().company;
407
+ const personal = opts.personal === true;
408
+ assertRecoveryScope(personal, companySlug);
409
+ await runFilesRestore({
410
+ key: path,
411
+ versionId: opts.version,
412
+ yes: opts.yes === true,
413
+ personal,
414
+ companySlug,
415
+ });
416
+ }
417
+ catch (err) {
418
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
419
+ process.exit(1);
420
+ }
421
+ });
422
+ files
423
+ .command("trash")
424
+ .description("List deleted vault keys retained as tombstones. Use --personal for your personal vault.")
425
+ .option("--personal", "Target your own personal vault instead of a company vault (mutually exclusive with --company)")
426
+ .option("--prefix <prefix>", "Literal key prefix to filter tombstones")
427
+ .option("--cursor <cursor>", "Continue from an opaque tombstone cursor")
428
+ .action(async (opts) => {
429
+ try {
430
+ const companySlug = files.opts().company;
431
+ const personal = opts.personal === true;
432
+ assertRecoveryScope(personal, companySlug);
433
+ await runFilesTrash({
434
+ prefix: opts.prefix ?? "",
435
+ cursor: opts.cursor,
436
+ personal,
437
+ companySlug,
438
+ });
439
+ }
440
+ catch (err) {
441
+ console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
442
+ process.exit(1);
443
+ }
444
+ });
382
445
  // Return the `files` Commander group so callers (src/index.ts) can attach
383
446
  // additional subcommands (e.g. `hq files browse`/`hq files cat` from
384
447
  // files-browse.ts) onto the same group without re-creating it.
@@ -737,4 +800,215 @@ export async function runFilesDelete(params, deps = {}) {
737
800
  console.log(chalk.dim(`${result.skipped} object(s) under this prefix you can't delete were left untouched.`));
738
801
  }
739
802
  }
803
+ export class FilesRecoveryHttpError extends Error {
804
+ status;
805
+ code;
806
+ constructor(status, message, code) {
807
+ super(message);
808
+ this.status = status;
809
+ this.code = code;
810
+ this.name = "FilesRecoveryHttpError";
811
+ }
812
+ }
813
+ function assertRecoveryScope(personal, companySlug) {
814
+ if (personal && companySlug) {
815
+ throw new Error("Pass either --personal or --company, not both.");
816
+ }
817
+ }
818
+ function assertExactRecoveryKey(key) {
819
+ if (!key || key.includes("*")) {
820
+ throw new Error("Restore and version history require one exact, wildcard-free vault key.");
821
+ }
822
+ }
823
+ function quoteRecoveryShellArg(value) {
824
+ return "'" + value.replace(/'/g, "'\\''") + "'";
825
+ }
826
+ function formatBytes(bytes) {
827
+ if (bytes < 1024)
828
+ return String(bytes) + " B";
829
+ if (bytes < 1024 * 1024)
830
+ return (bytes / 1024).toFixed(1) + " KiB";
831
+ if (bytes < 1024 * 1024 * 1024) {
832
+ return (bytes / (1024 * 1024)).toFixed(1) + " MiB";
833
+ }
834
+ return (bytes / (1024 * 1024 * 1024)).toFixed(1) + " GiB";
835
+ }
836
+ /** Render the exact-key version history, including S3 delete markers. */
837
+ export function formatFilesVersionsTable(rows) {
838
+ if (rows.length === 0) {
839
+ return "No version history exists for that key.";
840
+ }
841
+ const cols = ["VERSION", "TYPE", "LATEST", "MODIFIED", "SIZE"];
842
+ const data = rows.map((row) => [
843
+ row.versionId,
844
+ row.isDeleteMarker ? "delete marker" : "content",
845
+ row.isLatest ? "yes" : "",
846
+ row.lastModified,
847
+ row.isDeleteMarker ? "—" : formatBytes(row.size),
848
+ ]);
849
+ const widths = cols.map((col, index) => Math.max(col.length, ...data.map((row) => row[index].length)));
850
+ const renderRow = (row) => row.map((cell, index) => cell.padEnd(widths[index])).join(" ");
851
+ return [
852
+ chalk.bold(renderRow(cols)),
853
+ chalk.dim(renderRow(widths.map((width) => "─".repeat(width)))),
854
+ ...data.map(renderRow),
855
+ ].join("\n");
856
+ }
857
+ /** Render durable delete tombstones without implying the objects still exist. */
858
+ export function formatFilesTrashTable(rows) {
859
+ if (rows.length === 0) {
860
+ return "Trash is empty.";
861
+ }
862
+ const cols = ["KEY", "DELETED", "DELETED BY", "DELETE PREFIX"];
863
+ const data = rows.map((row) => [
864
+ row.key,
865
+ row.deletedAt,
866
+ row.deletedBy,
867
+ row.deletedPrefix,
868
+ ]);
869
+ const widths = cols.map((col, index) => Math.max(col.length, ...data.map((row) => row[index].length)));
870
+ const renderRow = (row) => row.map((cell, index) => cell.padEnd(widths[index])).join(" ");
871
+ return [
872
+ chalk.bold(renderRow(cols)),
873
+ chalk.dim(renderRow(widths.map((width) => "─".repeat(width)))),
874
+ ...data.map(renderRow),
875
+ ].join("\n");
876
+ }
877
+ function throwRecoveryHttpError(status, body, statusText) {
878
+ throw new FilesRecoveryHttpError(status, body.message ?? body.error ?? statusText, body.code);
879
+ }
880
+ function formatFilesRecoveryError(err, key) {
881
+ if (err.status === 401) {
882
+ return "Not authenticated — please run hq login";
883
+ }
884
+ if (err.status === 403) {
885
+ return key
886
+ ? "Not authorized to modify '" + key + "' — you need write access on it"
887
+ : "Not authorized to view this vault path";
888
+ }
889
+ if (err.code === "FILES_RESTORE_VERSION_NOT_FOUND") {
890
+ return "That version is unavailable or is a delete marker.";
891
+ }
892
+ if (err.code === "FILES_RESTORE_NO_PRIOR_VERSION") {
893
+ return "There is no prior content version to restore.";
894
+ }
895
+ if (err.code === "FILES_RESTORE_NO_RECOVERABLE_VERSION") {
896
+ return "There is no recoverable content version for this key.";
897
+ }
898
+ if (err.status === 400) {
899
+ return "Invalid request: " + err.message;
900
+ }
901
+ if (err.status >= 500) {
902
+ return "Server error: " + err.message;
903
+ }
904
+ return err.message || "Request failed (" + String(err.status) + ")";
905
+ }
906
+ async function resolveRecoveryScope(params) {
907
+ assertRecoveryScope(params.personal, params.companySlug);
908
+ const token = await ensureCognitoToken();
909
+ if (params.personal) {
910
+ return { token, personal: true };
911
+ }
912
+ return {
913
+ token,
914
+ personal: false,
915
+ companyUid: await getCompanyUid(token, params.companySlug),
916
+ };
917
+ }
918
+ export async function runFilesVersions(params) {
919
+ assertExactRecoveryKey(params.key);
920
+ const scope = await resolveRecoveryScope(params);
921
+ const res = await vaultApiFetch({
922
+ token: scope.token,
923
+ path: "/v1/files/versions",
924
+ query: scope.personal
925
+ ? { personal: "1", key: params.key }
926
+ : { company: scope.companyUid, key: params.key },
927
+ });
928
+ if (!res.ok) {
929
+ const body = (await res.json().catch(() => ({})));
930
+ throwRecoveryHttpError(res.status, body, res.statusText);
931
+ }
932
+ const data = (await res.json());
933
+ console.log(chalk.green("Versions for '" + data.key + "':"));
934
+ console.log(formatFilesVersionsTable(data.versions));
935
+ return data;
936
+ }
937
+ export async function runFilesRestore(params, deps = {}) {
938
+ assertExactRecoveryKey(params.key);
939
+ if (params.versionId !== undefined && params.versionId.trim() === "") {
940
+ throw new Error("versionId must not be empty.");
941
+ }
942
+ const confirm = deps.confirm ?? realConfirm;
943
+ if (!params.yes) {
944
+ const ok = await confirm(params.personal
945
+ ? "Restore '" + params.key + "' in your personal vault? This overwrites current content."
946
+ : "Restore '" + params.key + "'? This overwrites current content in the shared vault.");
947
+ if (!ok) {
948
+ console.log(chalk.dim("Aborted — nothing was restored."));
949
+ return undefined;
950
+ }
951
+ }
952
+ const scope = await resolveRecoveryScope(params);
953
+ const body = scope.personal
954
+ ? { personal: true, key: params.key }
955
+ : { company: scope.companyUid, key: params.key };
956
+ if (params.versionId !== undefined)
957
+ body.versionId = params.versionId;
958
+ const res = await vaultApiFetch({
959
+ token: scope.token,
960
+ path: "/v1/files/restore",
961
+ method: "POST",
962
+ body,
963
+ });
964
+ if (!res.ok) {
965
+ const errBody = (await res.json().catch(() => ({})));
966
+ const err = new FilesRecoveryHttpError(res.status, errBody.message ?? errBody.error ?? res.statusText, errBody.code);
967
+ throw new Error(formatFilesRecoveryError(err, params.key));
968
+ }
969
+ const data = (await res.json());
970
+ const verb = data.wasDeleted ? "Undeleted" : "Restored";
971
+ console.log(chalk.green(verb + " '" + data.key + "' from version '" + data.restoredFromVersionId + "'."));
972
+ console.log(chalk.dim("New version: " + data.newVersionId));
973
+ return data;
974
+ }
975
+ export async function runFilesTrash(params) {
976
+ if (params.prefix.includes("*")) {
977
+ throw new Error("Trash prefix must be literal; wildcard expansion is not supported.");
978
+ }
979
+ const scope = await resolveRecoveryScope(params);
980
+ const query = scope.personal
981
+ ? { personal: "1" }
982
+ : { company: scope.companyUid };
983
+ if (params.prefix)
984
+ query.prefix = params.prefix;
985
+ if (params.cursor)
986
+ query.cursor = params.cursor;
987
+ const res = await vaultApiFetch({
988
+ token: scope.token,
989
+ path: "/v1/files/tombstones",
990
+ query,
991
+ });
992
+ if (!res.ok) {
993
+ const body = (await res.json().catch(() => ({})));
994
+ throwRecoveryHttpError(res.status, body, res.statusText);
995
+ }
996
+ const data = (await res.json());
997
+ console.log(formatFilesTrashTable(data.tombstones));
998
+ if (data.truncated && data.cursor) {
999
+ let continuation = "hq files";
1000
+ if (params.companySlug) {
1001
+ continuation += " --company " + quoteRecoveryShellArg(params.companySlug);
1002
+ }
1003
+ continuation += " trash";
1004
+ if (params.personal)
1005
+ continuation += " --personal";
1006
+ if (params.prefix) {
1007
+ continuation += " --prefix " + quoteRecoveryShellArg(params.prefix);
1008
+ }
1009
+ continuation += " --cursor " + quoteRecoveryShellArg(data.cursor);
1010
+ console.log(chalk.dim("More results: " + continuation));
1011
+ }
1012
+ return data;
1013
+ }
740
1014
  //# sourceMappingURL=files.js.map
@@ -31,7 +31,7 @@ import * as yaml from "js-yaml";
31
31
  import { loadCachedTokens } from "@indigoai-us/hq-cloud";
32
32
  import { ensureCognitoToken } from "../utils/cognito-session.js";
33
33
  import { vaultApiFetch } from "../utils/vault-api.js";
34
- import { OUTPOST_PRICE_CENTS, confirmChargeOrExit, parseBillingPayload, surfaceBillingRequired, } from "../utils/billing-gate.js";
34
+ import { OUTPOST_PRICE_CENTS, confirmChargeOrExit, parseBillingPayload, surfaceBillingBlocked, } from "../utils/billing-gate.js";
35
35
  /** Decode hq-pro's `{ capped, limit, outposts }` cap envelope, if present. */
36
36
  export function parseCappedPayload(body) {
37
37
  if (!body || typeof body !== "object")
@@ -825,7 +825,7 @@ export function registerOutpostsCommand(program, selfDeployOverrides = {}) {
825
825
  if (err instanceof OutpostHttpError &&
826
826
  err.status === 402 &&
827
827
  err.billing) {
828
- await surfaceBillingRequired(token, err.billing);
828
+ await surfaceBillingBlocked(token, err.billing, err.message);
829
829
  process.exit(1);
830
830
  }
831
831
  // Already at the per-person cap → say so and name the box they own,
@@ -74,4 +74,19 @@ export declare function mintPaymentLink(token: string, setup: BillingSetupAction
74
74
  * to act on. Never prints tokens or secrets.
75
75
  */
76
76
  export declare function surfaceBillingRequired(token: string, billing: BillingErrorPayload): Promise<string | null>;
77
+ /**
78
+ * Status-aware surface for a 402 billing block. hq-pro's envelope carries two
79
+ * distinct remediations that must never be conflated (mirroring the server's
80
+ * own P1-C classification):
81
+ * - `payment_failed` — a card EXISTS and the charge was DECLINED. The
82
+ * server's `message` already carries the friendly decline copy ("Your
83
+ * card was declined…", "insufficient funds", …). Telling this user
84
+ * "No card on file" sends them down the wrong remediation path entirely
85
+ * (observed live 2026-07-20: a declined $80 Outpost proration surfaced
86
+ * as "no card", triggering a hunt for a missing card that existed).
87
+ * The capture link still surfaces — as the way to UPDATE the card.
88
+ * - anything else (`billing_required`) — genuinely no usable card on
89
+ * file; the existing add-a-card copy is correct.
90
+ */
91
+ export declare function surfaceBillingBlocked(token: string, billing: BillingErrorPayload, serverMessage?: string): Promise<string | null>;
77
92
  //# sourceMappingURL=billing-gate.d.ts.map
@@ -121,4 +121,39 @@ export async function surfaceBillingRequired(token, billing) {
121
121
  console.log(chalk.dim("Once a card is added, re-run the same command."));
122
122
  return url;
123
123
  }
124
+ /**
125
+ * Status-aware surface for a 402 billing block. hq-pro's envelope carries two
126
+ * distinct remediations that must never be conflated (mirroring the server's
127
+ * own P1-C classification):
128
+ * - `payment_failed` — a card EXISTS and the charge was DECLINED. The
129
+ * server's `message` already carries the friendly decline copy ("Your
130
+ * card was declined…", "insufficient funds", …). Telling this user
131
+ * "No card on file" sends them down the wrong remediation path entirely
132
+ * (observed live 2026-07-20: a declined $80 Outpost proration surfaced
133
+ * as "no card", triggering a hunt for a missing card that existed).
134
+ * The capture link still surfaces — as the way to UPDATE the card.
135
+ * - anything else (`billing_required`) — genuinely no usable card on
136
+ * file; the existing add-a-card copy is correct.
137
+ */
138
+ export async function surfaceBillingBlocked(token, billing, serverMessage) {
139
+ if (billing.status !== "payment_failed") {
140
+ return surfaceBillingRequired(token, billing);
141
+ }
142
+ console.error(chalk.yellow(serverMessage?.trim() ||
143
+ "Your payment failed. Try a different card or contact your bank."));
144
+ if (!billing.setup)
145
+ return null;
146
+ try {
147
+ const url = await mintPaymentLink(token, billing.setup);
148
+ console.log("Update or replace the card here (safe to share with whoever owns billing):\n " +
149
+ chalk.cyan(url));
150
+ console.log(chalk.dim("Once the payment method is sorted, re-run the same command."));
151
+ return url;
152
+ }
153
+ catch {
154
+ // Link minting is best-effort on the decline path — the decline reason
155
+ // above is the essential part; the console billing page also works.
156
+ return null;
157
+ }
158
+ }
124
159
  //# sourceMappingURL=billing-gate.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.76.0",
3
+ "version": "5.77.0",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -363,6 +363,47 @@ describe("hq agents provision (billing gate)", () => {
363
363
  expect(printed).toContain("https://checkout.stripe.com/card");
364
364
  });
365
365
 
366
+ it("surfaces the DECLINE reason on 402 payment_failed (message-first decoding)", async () => {
367
+ // The payment_failed envelope carries BOTH the generic error AND the
368
+ // friendly decline message — the decode must prefer `message` or the
369
+ // decline copy is lost and the operator sees "add a card" for a card
370
+ // that exists and was declined.
371
+ fetchSpy
372
+ .mockResolvedValueOnce(
373
+ jsonResponse(402, {
374
+ error: "payment required",
375
+ message:
376
+ "Your card was declined. Try a different card or contact your bank.",
377
+ code: "PAYMENT_FAILED",
378
+ billing: {
379
+ status: "payment_failed",
380
+ setup: {
381
+ payerType: "company",
382
+ path: "/v1/billing/checkout/org",
383
+ method: "POST",
384
+ body: { companyUid: "cmp_acme" },
385
+ },
386
+ },
387
+ }),
388
+ )
389
+ .mockResolvedValueOnce(
390
+ jsonResponse(200, { url: "https://checkout.stripe.com/update" }),
391
+ );
392
+
393
+ const logSpyLocal = vi.spyOn(console, "log").mockImplementation(() => {});
394
+ const errSpyLocal = vi.spyOn(console, "error").mockImplementation(() => {});
395
+ await expect(
396
+ run(["agents", "--company", "acme", "provision", "Ops Bot", "--yes"]),
397
+ ).rejects.toThrow("process.exit(1)");
398
+
399
+ const printed = [...errSpyLocal.mock.calls, ...logSpyLocal.mock.calls]
400
+ .map((c) => c.map(String).join(" "))
401
+ .join("\n");
402
+ expect(printed).toContain("declined");
403
+ expect(printed).not.toContain("No card on file");
404
+ expect(printed).toContain("https://checkout.stripe.com/update");
405
+ });
406
+
366
407
  it("requires --api-key-env for --auth-mode apiKey (before any charge)", async () => {
367
408
  await expect(
368
409
  run([
@@ -31,7 +31,7 @@ import {
31
31
  AGENT_PRICE_CENTS,
32
32
  confirmChargeOrExit,
33
33
  parseBillingPayload,
34
- surfaceBillingRequired,
34
+ surfaceBillingBlocked,
35
35
  type BillingErrorPayload,
36
36
  } from "../utils/billing-gate.js";
37
37
 
@@ -125,7 +125,11 @@ export async function agentsRequest<T>(opts: {
125
125
  };
126
126
  throw new AgentsHttpError(
127
127
  res.status,
128
- body.error ?? body.message ?? res.statusText,
128
+ // `message` FIRST: on a payment_failed envelope it carries the friendly
129
+ // decline copy ("Your card was declined…") while `error` is the generic
130
+ // "payment required" — error-first would feed surfaceBillingBlocked the
131
+ // generic string and lose the decline reason (mirrors outpostRequest).
132
+ body.message ?? body.error ?? res.statusText,
129
133
  body.code,
130
134
  parseBillingPayload(body),
131
135
  );
@@ -592,7 +596,7 @@ export function registerAgentsCommand(program: Command): void {
592
596
  err.status === 402 &&
593
597
  err.billing
594
598
  ) {
595
- await surfaceBillingRequired(token, err.billing);
599
+ await surfaceBillingBlocked(token, err.billing, err.message);
596
600
  process.exit(1);
597
601
  }
598
602
  throw err;