hedge-broker 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,30 @@ All notable changes to `hedge-broker` are documented here. This project follows
4
4
  [Semantic Versioning](https://semver.org) and
5
5
  [Keep a Changelog](https://keepachangelog.com).
6
6
 
7
+ ## [0.2.0] - 2026-07-06
8
+
9
+ ### Added
10
+
11
+ - `submit` now covers the full create schema: `--website`, `--fein`, `--entity-type`, `--naics`, `--business-phone`, `--business-email`, `--contact-first`, `--contact-last`, `--contact-email`, `--contact-phone`, `--address`, `--address2`, `--city`, `--zip`, `--tiv`, `--vehicles`, `--payroll`, `--insured-id`, `--producer-email`.
12
+ - `submit --body <file|->` sends a full JSON request body from a file or stdin; explicit flags take precedence over matching top-level keys, applicant flags merge over the body's applicant, and address flags merge into its mailing_address.
13
+ - `submit` sends an `Idempotency-Key` header: a random UUID per invocation by default, or your own via `--idempotency-key <key>`. Replay engages only when the same key is re-sent within 24h (per brokerage), so scripted retries that must not double-create should pass their own key.
14
+ - `submit` fails fast with a clear message when a partial mailing address is given; the API requires `--address`, `--city`, `--state`, and `--zip` to resolve together (fields supplied via `--body` count).
15
+ - `submissions` filters: `--limit`, `--offset`, `--updated-since` (alongside the existing `--status` and `--search`).
16
+ - `policy <policyId>`: policy detail (term, net premium, total billed, commission, payment plan, documents).
17
+ - `policy-doc <policyId> <kind> [-o file]`: download the binder, policy, or declarations PDF.
18
+ - `documents <submissionId>`: list a submission's finalized documents.
19
+ - `download <documentId> [-o file]`: download a finalized document PDF (filename from Content-Disposition when `-o` is omitted).
20
+ - `market-requirements <marketId> --lob <slug> [--state ST] [--programs keys]`: what a market needs to quote a line.
21
+ - `status` now renders the live per-carrier marketing table (carrier, line, status, quote premium), a status roll-up, the effective date, and the documents on file.
22
+ - `policies` table gains effective and expiration columns; `payments` gains an invoice_url column.
23
+
24
+ ### Fixed
25
+
26
+ - `submit --state` now actually sends the state (top-level `primary_state`, or the mailing address state when a mailing address is present). In 0.1.0 the flag was accepted but dropped.
27
+ - Non-JSON API error bodies (HTML 502s from a proxy) no longer crash uploads with a SyntaxError; the raw text is surfaced instead.
28
+ - API validation errors (FastAPI `detail` arrays/objects) render as readable `loc: message` lines instead of `[object Object]`.
29
+ - Filenames taken from a server's Content-Disposition header are sanitized (path separators, control characters, and Windows-invalid characters stripped) before writing to disk.
30
+
7
31
  ## [0.1.0] - 2026-07-06
8
32
 
9
33
  ### Added
package/README.md CHANGED
@@ -48,17 +48,22 @@ hedge status <submission-id>
48
48
  | Command | What it does |
49
49
  | --- | --- |
50
50
  | `hedge appetite <class> [--state ST] [--lob slug]` | Which markets have appetite for a class of business. |
51
- | `hedge submit --insured <name> --narrative <text> [--lob a,b] [--state ST] [--effective YYYY-MM-DD]` | Create a submission (does not market it yet). |
51
+ | `hedge market-requirements <marketId> --lob <slug> [--state ST] [--programs keys]` | What a market needs to quote a line: the application package plus per-program requirements. Market ids come from `hedge appetite --json`. |
52
+ | `hedge submit --insured <name> --narrative <text> [flags]` | Create a submission (does not market it yet). Applicant, contact, address, and exposure flags are listed in `hedge submit --help`; `--body <file\|->` sends a full JSON body, with explicit flags taking precedence. |
52
53
  | `hedge upload <submissionId> <file.pdf> [--name label]` | Attach an ACORD, loss runs, or supplement. |
53
54
  | `hedge requirements <submissionId>` | What the submission still needs (per market, forms, carrier questions). |
54
55
  | `hedge finalize <submissionId>` | Start marketing the submission to carriers. |
55
- | `hedge status <submissionId>` | Submission detail plus live marketing and quote status. |
56
- | `hedge submissions [--status state] [--search q]` | List your brokerage's submissions. |
56
+ | `hedge status <submissionId>` | Submission detail plus the live per-carrier marketing table, status roll-up, and documents on file. |
57
+ | `hedge submissions [--status state] [--search q] [--limit n] [--offset n] [--updated-since iso]` | List your brokerage's submissions, with filters and paging. |
58
+ | `hedge documents <submissionId>` | List a submission's finalized documents (ACORDs, quotes, binders). |
59
+ | `hedge download <documentId> [-o file]` | Download a finalized document PDF. |
57
60
  | `hedge quotes <submissionId>` | List instant-quote carrier sessions and their open questions. |
58
61
  | `hedge answer <submissionId> <sessionId> --set k=v [--set k=v ...]` | Answer a carrier session's questions. |
59
62
  | `hedge request-quote <submissionId> <sessionId>` | Close a carrier session, request an indication, then a quote. |
60
- | `hedge policies` | List bound policies. |
61
- | `hedge payments` | List payment and invoice status. |
63
+ | `hedge policies` | List bound policies with term dates. |
64
+ | `hedge policy <policyId>` | Policy detail: term, premium, commission, payment plan, documents. Policy ids come from `hedge policies --json`. |
65
+ | `hedge policy-doc <policyId> <kind> [-o file]` | Download a policy PDF; kind is `binder`, `policy`, or `declarations`. |
66
+ | `hedge payments` | List payment and invoice status, with invoice links. |
62
67
  | `hedge whoami` | Show the signed-in broker and brokerage. |
63
68
  | `hedge login` / `hedge logout` | Sign in and out. |
64
69
 
@@ -78,15 +83,39 @@ hedge appetite "roofing contractor" --state CA
78
83
  hedge submit \
79
84
  --insured "Peak Roofing LLC" \
80
85
  --narrative "Residential re-roofing, no hot tar, no work over 3 stories" \
81
- --lob commercial_general_liability \
82
- --state CA \
83
- --effective 2026-08-01
86
+ --lob commercial_general_liability,commercial_property \
87
+ --effective 2026-08-01 \
88
+ --website https://peakroofing.example.com \
89
+ --fein 12-3456789 \
90
+ --entity-type llc \
91
+ --naics 238160 \
92
+ --business-phone "415-555-0100" \
93
+ --business-email office@peakroofing.example.com \
94
+ --contact-first Dana --contact-last Rivera \
95
+ --contact-email dana@peakroofing.example.com \
96
+ --contact-phone "415-555-0101" \
97
+ --address "1200 Harrison St" --address2 "Suite 4" \
98
+ --city Oakland --state CA --zip 94612 \
99
+ --tiv 250000 --payroll 900000 --vehicles 3
84
100
  hedge upload <submission-id> ./acord-125.pdf --name "ACORD 125"
85
101
  hedge requirements <submission-id>
86
102
  hedge finalize <submission-id>
87
103
  hedge status <submission-id>
104
+ hedge documents <submission-id>
105
+ hedge download <document-id> -o quote.pdf
88
106
  ```
89
107
 
108
+ When a mailing address is in play, `--state` fills the mailing address state; the address must be complete (`--address`, `--city`, `--state`, `--zip`, counting fields supplied via `--body`). With `--state` alone it is sent as the submission's top-level `primary_state`.
109
+
110
+ Prefer building the request in a file (or another program)? Send a full JSON body. Explicit flags still win: top-level flag values replace matching body keys, applicant flags merge over the body's applicant, and address flags merge into its mailing_address:
111
+
112
+ ```bash
113
+ hedge submit --body ./submission.json
114
+ cat submission.json | hedge submit --body - --effective 2026-09-01
115
+ ```
116
+
117
+ Every `submit` sends an `Idempotency-Key` header (a random UUID each run). Replay only engages when the same key is re-sent within 24 hours, so a scripted retry that must not double-create should pass its own key with `--idempotency-key <key>`.
118
+
90
119
  ## Signing in
91
120
 
92
121
  The CLI signs in with OAuth 2.1, so there are no API keys to copy around for the interactive flow, and it works over SSH.
@@ -98,7 +127,7 @@ The CLI signs in with OAuth 2.1, so there are no API keys to copy around for the
98
127
 
99
128
  Credentials are stored per profile at `~/.config/taven-cli/hedge.<profile>.json` with `0600` permissions. Access tokens are refreshed automatically. Sign out with `hedge logout`.
100
129
 
101
- The CLI requests the `broker_mcp` and `broker_submit` scopes. Submitting on a brokerage's behalf requires that the brokerage has connected apps and programmatic submission enabled by Hedge. Read commands (appetite, submissions, status, requirements, policies, payments) work with either scope. `submit`, `upload`, `finalize`, and the carrier-quote commands require `broker_submit`.
130
+ The CLI requests the `broker_mcp` and `broker_submit` scopes. Submitting on a brokerage's behalf requires that the brokerage has connected apps and programmatic submission enabled by Hedge. Read commands (appetite, market-requirements, submissions, status, requirements, documents, download, quotes, policies, policy, policy-doc, payments) work with either scope. `submit`, `upload`, `finalize`, `answer`, and `request-quote` require `broker_submit`.
102
131
 
103
132
  ## License
104
133
 
package/dist/index.js CHANGED
@@ -103,7 +103,7 @@ async function deviceLogin(opts) {
103
103
  }
104
104
  throw new Error(pb.error === "access_denied" ? "Access was denied" : pb.error || `Login failed (${poll.status})`);
105
105
  }
106
- throw new Error("Login timed out \u2014 run the command again");
106
+ throw new Error("Login timed out. Run the command again");
107
107
  }
108
108
  async function loopbackLogin(opts) {
109
109
  const { meta, clientId } = opts;
@@ -178,6 +178,7 @@ function sleep(ms) {
178
178
  }
179
179
 
180
180
  // src/core/http.ts
181
+ import { writeFileSync as writeFileSync2 } from "fs";
181
182
  var ApiError = class extends Error {
182
183
  constructor(status, message) {
183
184
  super(message);
@@ -185,11 +186,37 @@ var ApiError = class extends Error {
185
186
  }
186
187
  status;
187
188
  };
189
+ function renderErrorDetail(detail, fallback) {
190
+ if (detail == null) return fallback;
191
+ if (typeof detail === "string") return detail.trim() || fallback;
192
+ if (Array.isArray(detail)) {
193
+ const lines = detail.map((entry) => {
194
+ if (entry && typeof entry === "object" && "msg" in entry) {
195
+ const loc = Array.isArray(entry.loc) ? entry.loc.join(".") : "";
196
+ return (loc ? loc + ": " : "") + String(entry.msg);
197
+ }
198
+ return JSON.stringify(entry);
199
+ });
200
+ return lines.join("\n") || fallback;
201
+ }
202
+ if (typeof detail === "object") return JSON.stringify(detail);
203
+ return String(detail);
204
+ }
205
+ function apiErrorFrom(status, parsed, fallback) {
206
+ let detail = void 0;
207
+ if (parsed && typeof parsed === "object") {
208
+ const obj = parsed;
209
+ detail = obj.detail ?? obj.error;
210
+ } else if (typeof parsed === "string") {
211
+ detail = parsed;
212
+ }
213
+ return new ApiError(status, renderErrorDetail(detail, fallback));
214
+ }
188
215
  async function bearer(cfg) {
189
216
  const tok = loadToken(cfg);
190
- if (!tok) throw new ApiError(401, "Not signed in \u2014 run `login` first");
217
+ if (!tok) throw new ApiError(401, "Not signed in. Run `login` first");
191
218
  if (tok.expires_at - 60 > Math.floor(Date.now() / 1e3)) return tok.access_token;
192
- if (!tok.refresh_token) throw new ApiError(401, "Session expired \u2014 run `login` again");
219
+ if (!tok.refresh_token) throw new ApiError(401, "Session expired. Run `login` again");
193
220
  try {
194
221
  const r = await refresh(tok.token_endpoint, tok.client_id, tok.refresh_token);
195
222
  const updated = {
@@ -203,7 +230,7 @@ async function bearer(cfg) {
203
230
  return updated.access_token;
204
231
  } catch {
205
232
  clearToken(cfg);
206
- throw new ApiError(401, "Session expired \u2014 run `login` again");
233
+ throw new ApiError(401, "Session expired. Run `login` again");
207
234
  }
208
235
  }
209
236
  async function apiRequest(opts, method, path, init) {
@@ -230,10 +257,7 @@ async function apiRequest(opts, method, path, init) {
230
257
  parsed = text;
231
258
  }
232
259
  }
233
- if (!res.ok) {
234
- const detail = parsed && typeof parsed === "object" && "detail" in parsed && parsed.detail || parsed && typeof parsed === "object" && "error" in parsed && parsed.error || (typeof parsed === "string" ? parsed : `Request failed (${res.status})`);
235
- throw new ApiError(res.status, String(detail));
236
- }
260
+ if (!res.ok) throw apiErrorFrom(res.status, parsed, `Request failed (${res.status})`);
237
261
  return parsed;
238
262
  }
239
263
  async function multipartRequest(opts, method, path, form) {
@@ -243,13 +267,60 @@ async function multipartRequest(opts, method, path, form) {
243
267
  else headers.Authorization = `Bearer ${await bearer(opts.cfg)}`;
244
268
  const res = await fetch(url, { method, headers, body: form });
245
269
  const text = await res.text();
246
- let parsed = text ? JSON.parse(text) : void 0;
247
- if (!res.ok) {
248
- const detail = parsed && typeof parsed === "object" && "detail" in parsed && parsed.detail || `Upload failed (${res.status})`;
249
- throw new ApiError(res.status, String(detail));
270
+ let parsed = void 0;
271
+ if (text) {
272
+ try {
273
+ parsed = JSON.parse(text);
274
+ } catch {
275
+ parsed = text;
276
+ }
250
277
  }
278
+ if (!res.ok) throw apiErrorFrom(res.status, parsed, `Upload failed (${res.status})`);
251
279
  return parsed;
252
280
  }
281
+ function sanitizeFilename(name) {
282
+ const last = name.replace(/\\/g, "/").split("/").pop() ?? "";
283
+ const cleaned = last.replace(/[:*?"<>|\u0000-\u001f]/g, "_").trim();
284
+ if (!cleaned || cleaned === "." || cleaned === "..") return "";
285
+ return cleaned;
286
+ }
287
+ function dispositionFilename(header) {
288
+ if (!header) return void 0;
289
+ const star = /filename\*=(?:UTF-8'')?"?([^";]+)"?/i.exec(header);
290
+ if (star) {
291
+ try {
292
+ return decodeURIComponent(star[1].trim());
293
+ } catch {
294
+ }
295
+ }
296
+ const plain = /filename="?([^";]+)"?/i.exec(header);
297
+ return plain ? plain[1].trim() : void 0;
298
+ }
299
+ async function downloadRequest(opts, method, path, outPath, fallbackName) {
300
+ const url = opts.apiBase.replace(/\/$/, "") + path;
301
+ const headers = {};
302
+ if (opts.apiKey) headers[opts.apiKeyHeader ?? "X-Api-Key"] = opts.apiKey;
303
+ else headers.Authorization = `Bearer ${await bearer(opts.cfg)}`;
304
+ const res = await fetch(url, { method, headers });
305
+ if (!res.ok) {
306
+ const text = await res.text();
307
+ let parsed = void 0;
308
+ if (text) {
309
+ try {
310
+ parsed = JSON.parse(text);
311
+ } catch {
312
+ parsed = text;
313
+ }
314
+ }
315
+ throw apiErrorFrom(res.status, parsed, `Download failed (${res.status})`);
316
+ }
317
+ const serverName = dispositionFilename(res.headers.get("content-disposition"));
318
+ const safeName = serverName ? sanitizeFilename(serverName) : "";
319
+ const target = outPath ?? (safeName || void 0) ?? fallbackName;
320
+ if (!target) throw new ApiError(500, "No output filename (pass -o <file>)");
321
+ writeFileSync2(target, Buffer.from(await res.arrayBuffer()));
322
+ return target;
323
+ }
253
324
 
254
325
  // src/core/output.ts
255
326
  function printJson(data) {
@@ -377,18 +448,104 @@ function registerAuth(program2) {
377
448
  }
378
449
 
379
450
  // src/commands/submissions.ts
451
+ import { randomUUID } from "crypto";
380
452
  import { readFileSync as readFileSync2 } from "fs";
381
453
  import { basename } from "path";
454
+ var POLICY_DOC_KINDS = ["binder", "policy", "declarations"];
455
+ function intFlag(value, flag) {
456
+ if (!/^\d+$/.test(String(value).trim())) throw new Error(`${flag} expects a whole number, got "${value}"`);
457
+ return Number.parseInt(String(value).trim(), 10);
458
+ }
459
+ function readJsonBody(source) {
460
+ const raw = source === "-" ? readFileSync2(0, "utf8") : readFileSync2(source, "utf8");
461
+ let parsed;
462
+ try {
463
+ parsed = JSON.parse(raw);
464
+ } catch (e) {
465
+ throw new Error(`--body is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
466
+ }
467
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("--body must be a JSON object");
468
+ return parsed;
469
+ }
470
+ function formatBytes(size) {
471
+ const n = Number(size);
472
+ if (!Number.isFinite(n) || n <= 0) return "";
473
+ if (n < 1024) return `${n} B`;
474
+ if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
475
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
476
+ }
382
477
  function registerSubmissions(program2) {
383
- program2.command("submit").description("Create a submission (does not market it \u2014 run `finalize` when ready)").requiredOption("--insured <name>", "insured business name").requiredOption("--narrative <text>", "operations description of the risk").option("--lob <slugs>", "comma-separated lines of business, e.g. commercial_general_liability,workers_compensation").option("--effective <date>", "effective date, YYYY-MM-DD").option("--state <ST>", "primary state (2-letter)").action(async (opts) => {
478
+ program2.command("submit").description("Create a submission (does not market it; run `finalize` when ready)").option("--insured <name>", "insured business name (required unless --body supplies applicant.insured_name)").option("--narrative <text>", "operations description of the risk (required unless --body supplies narrative)").option("--lob <slugs>", "comma-separated lines of business, e.g. commercial_general_liability,workers_compensation").option("--effective <date>", "effective date, YYYY-MM-DD").option("--state <ST>", "primary state (2-letter); with any address flag it becomes the mailing address state instead").option("--website <url>", "insured website").option("--fein <id>", "FEIN (or SSN for a sole proprietor)").option("--entity-type <type>", "legal entity type, e.g. llc, corporation").option("--naics <code>", "NAICS code").option("--business-phone <phone>", "insured business phone").option("--business-email <email>", "insured business email").option("--contact-first <name>", "primary contact first name").option("--contact-last <name>", "primary contact last name").option("--contact-email <email>", "primary contact email").option("--contact-phone <phone>", "primary contact phone").option("--address <line1>", "mailing address line 1").option("--address2 <line2>", "mailing address line 2").option("--city <city>", "mailing address city").option("--zip <zip>", "mailing address ZIP code").option("--tiv <int>", "total insured property value in dollars (property_tiv_total)").option("--vehicles <int>", "number of vehicles (auto_vehicle_count)").option("--payroll <int>", "total annual payroll in dollars (wc_total_annual_payroll)").option("--insured-id <uuid>", "start from an existing insured in your book").option("--producer-email <email>", "producing broker to attribute (required for brokerage API-client credentials)").option(
479
+ "--body <file|->",
480
+ "full JSON request body from a file, or - for stdin. Precedence: flags win; a top-level flag value replaces the matching body key, applicant flags merge over the body's applicant, and address flags merge into its mailing_address"
481
+ ).option(
482
+ "--idempotency-key <key>",
483
+ "Idempotency-Key header value (default: a random UUID per invocation). Re-send the same key within 24h to replay the original response instead of creating a duplicate"
484
+ ).action(async (opts) => {
384
485
  const ctx = makeCtx(program2.opts());
385
- const body = {
386
- applicant: { insured_name: opts.insured, ...opts.state ? { mailing_address: void 0 } : {} },
387
- narrative: opts.narrative,
388
- lines_of_business: opts.lob ? String(opts.lob).split(",").map((s) => s.trim()) : []
389
- };
486
+ const body = opts.body ? readJsonBody(opts.body) : { lines_of_business: [] };
487
+ const applicant = {};
488
+ if (opts.insured) applicant.insured_name = opts.insured;
489
+ if (opts.businessPhone) applicant.business_phone = opts.businessPhone;
490
+ if (opts.businessEmail) applicant.business_email = opts.businessEmail;
491
+ if (opts.website) applicant.website = opts.website;
492
+ if (opts.fein) applicant.fein_or_ssn = opts.fein;
493
+ if (opts.entityType) applicant.entity_type = opts.entityType;
494
+ if (opts.naics) applicant.naics = opts.naics;
495
+ if (opts.contactFirst) applicant.contact_first_name = opts.contactFirst;
496
+ if (opts.contactLast) applicant.contact_last_name = opts.contactLast;
497
+ if (opts.contactEmail) applicant.contact_email = opts.contactEmail;
498
+ if (opts.contactPhone) applicant.contact_phone = opts.contactPhone;
499
+ const addressFlags = {};
500
+ if (opts.address) addressFlags.line1 = opts.address;
501
+ if (opts.address2) addressFlags.line2 = opts.address2;
502
+ if (opts.city) addressFlags.city = opts.city;
503
+ if (opts.zip) addressFlags.zip = opts.zip;
504
+ const hasAddressFlags = Object.keys(addressFlags).length > 0;
505
+ const state = opts.state ? String(opts.state).trim().toUpperCase() : void 0;
506
+ const bodyApplicant = body.applicant && typeof body.applicant === "object" && !Array.isArray(body.applicant) ? body.applicant : void 0;
507
+ const bodyAddress = bodyApplicant?.mailing_address && typeof bodyApplicant.mailing_address === "object" && !Array.isArray(bodyApplicant.mailing_address) ? bodyApplicant.mailing_address : void 0;
508
+ let mailing = bodyAddress ? { ...bodyAddress } : void 0;
509
+ if (hasAddressFlags) mailing = { ...mailing ?? {}, ...addressFlags };
510
+ if (state) {
511
+ if (mailing) {
512
+ mailing.state = state;
513
+ if (body.primary_state != null) body.primary_state = state;
514
+ } else {
515
+ body.primary_state = state;
516
+ }
517
+ }
518
+ if (hasAddressFlags) {
519
+ const flagFor = { line1: "--address", city: "--city", state: "--state", zip: "--zip" };
520
+ const missing = Object.keys(flagFor).filter((k) => !mailing?.[k]).map((k) => flagFor[k]);
521
+ if (missing.length) {
522
+ throw new Error(
523
+ "--address, --city, --state and --zip must be provided together (missing " + missing.join(", ") + "; fields already in --body's mailing_address count)"
524
+ );
525
+ }
526
+ }
527
+ if (mailing && (hasAddressFlags || state)) applicant.mailing_address = mailing;
528
+ if (Object.keys(applicant).length > 0) {
529
+ body.applicant = { ...bodyApplicant ?? {}, ...applicant };
530
+ }
531
+ if (opts.narrative) body.narrative = opts.narrative;
532
+ if (opts.lob) body.lines_of_business = String(opts.lob).split(",").map((s) => s.trim()).filter(Boolean);
390
533
  if (opts.effective) body.effective_date = opts.effective;
391
- const res = await apiRequest(ctx.client, "POST", "/broker/submissions", { body });
534
+ if (opts.tiv != null) body.property_tiv_total = intFlag(opts.tiv, "--tiv");
535
+ if (opts.vehicles != null) body.auto_vehicle_count = intFlag(opts.vehicles, "--vehicles");
536
+ if (opts.payroll != null) body.wc_total_annual_payroll = intFlag(opts.payroll, "--payroll");
537
+ if (opts.insuredId) body.insured_id = opts.insuredId;
538
+ if (opts.producerEmail) body.producer_email = opts.producerEmail;
539
+ const finalApplicant = body.applicant;
540
+ if (!finalApplicant?.insured_name) throw new Error("--insured is required (or supply applicant.insured_name via --body)");
541
+ if (!body.narrative) throw new Error("--narrative is required (or supply narrative via --body)");
542
+ const res = await apiRequest(ctx.client, "POST", "/broker/submissions", {
543
+ body,
544
+ // Random per invocation unless the caller supplies a key. The API
545
+ // replays the original response when the same key is re-sent within
546
+ // 24h (per brokerage), so scripted retries should pass their own key.
547
+ headers: { "Idempotency-Key": opts.idempotencyKey ?? randomUUID() }
548
+ });
392
549
  if (ctx.json) return printJson(res);
393
550
  process.stdout.write(kv({ submission_id: res.submission_id, state: res.state, status: res.status_label }) + "\n");
394
551
  process.stdout.write("\nNext: hedge upload " + res.submission_id + " <file.pdf>, hedge requirements " + res.submission_id + ", hedge finalize " + res.submission_id + "\n");
@@ -414,6 +571,17 @@ function registerSubmissions(program2) {
414
571
  ready: m.ready ? "yes" : "no",
415
572
  needs_from_you: (m.needs_from_you || []).join("; ")
416
573
  })), ["market", "ready", "needs_from_you"]) + "\n");
574
+ } else {
575
+ const hint = typeof r.marketing_hint === "string" && r.marketing_hint ? r.marketing_hint : null;
576
+ if (r.marketing_status === "matching") {
577
+ process.stdout.write("\nNo markets yet - " + (hint ?? "Hedge is matching carrier markets now (usually ~5 minutes after finalize); re-run this in a few minutes.") + "\n");
578
+ } else if (r.marketing_status === "not_started") {
579
+ process.stdout.write("\nNo markets yet - " + (hint ?? "run hedge finalize " + submissionId + " to start marketing.") + "\n");
580
+ } else if (r.marketing_status === "no_markets_matched") {
581
+ process.stdout.write("\nNo markets attached - " + (hint ?? "Hedge is reviewing options for this risk and will follow up.") + "\n");
582
+ } else {
583
+ process.stdout.write("\nNo markets yet. If you just finalized, matching usually completes within ~5 minutes - re-run this shortly or use hedge status " + submissionId + ".\n");
584
+ }
417
585
  }
418
586
  if (r.carrier_api_sessions?.length) {
419
587
  process.stdout.write("\nInstant-quote carriers:\n" + table(r.carrier_api_sessions.map((s) => ({
@@ -429,12 +597,29 @@ function registerSubmissions(program2) {
429
597
  const ctx = makeCtx(program2.opts());
430
598
  const s = await apiRequest(ctx.client, "GET", `/broker/submissions/${submissionId}`);
431
599
  if (ctx.json) return printJson(s);
432
- process.stdout.write(kv({ insured: s.insured_name, lines: (s.lines || []).join(", "), state: s.state, status: s.status_label, premium: s.premium }) + "\n");
600
+ process.stdout.write(kv({ insured: s.insured_name, lines: (s.lines || []).join(", "), state: s.state, status: s.status_label, premium: s.premium, effective: s.effective_date }) + "\n");
601
+ if (s.markets?.length) {
602
+ process.stdout.write("\nMarkets:\n" + table(s.markets.flatMap((m) => (m.lines || []).map((l) => ({
603
+ carrier: m.carrier_name,
604
+ line: l.lob_label ?? l.lob_slug,
605
+ status: l.status_label ?? l.status,
606
+ quote: l.quote_premium ?? ""
607
+ }))), ["carrier", "line", "status", "quote"]) + "\n");
608
+ } else {
609
+ process.stdout.write("\nNo markets attached yet. Matching usually completes within ~5 minutes of finalize - re-run this shortly (or check hedge requirements " + submissionId + ").\n");
610
+ }
611
+ const byStatus = s.status_summary?.by_status;
612
+ if (byStatus && Object.keys(byStatus).length) {
613
+ process.stdout.write("\nSummary: " + Object.entries(byStatus).map(([k, v]) => `${k}=${v}`).join(", ") + "\n");
614
+ }
615
+ if (s.documents?.length) {
616
+ process.stdout.write("\nDocuments: " + s.documents.map((d) => d.display_name).join(", ") + "\n");
617
+ }
433
618
  });
434
- program2.command("submissions").description("List your brokerage's submissions").option("--status <state>", "filter by state").option("--search <q>", "filter by insured name").action(async (opts) => {
619
+ program2.command("submissions").description("List your brokerage's submissions").option("--status <state>", "filter by state").option("--search <q>", "filter by insured name").option("--limit <n>", "max rows to return (1-500)").option("--offset <n>", "skip the first n rows (for paging)").option("--updated-since <iso>", "only rows updated at or after this ISO timestamp").action(async (opts) => {
435
620
  const ctx = makeCtx(program2.opts());
436
621
  const rows = await apiRequest(ctx.client, "GET", "/broker/submissions", {
437
- query: { status: opts.status, search: opts.search }
622
+ query: { status: opts.status, search: opts.search, limit: opts.limit, offset: opts.offset, updated_since: opts.updatedSince }
438
623
  });
439
624
  if (ctx.json) return printJson(rows);
440
625
  process.stdout.write(table(rows.map((r) => ({
@@ -445,27 +630,131 @@ function registerSubmissions(program2) {
445
630
  premium: r.premium ?? ""
446
631
  })), ["id", "insured", "lines", "status", "premium"]) + "\n");
447
632
  });
448
- program2.command("finalize <submissionId>").description("Start marketing the submission to carriers").action(async (submissionId) => {
633
+ program2.command("finalize <submissionId>").description("Start marketing the submission to carriers").option("--wait", "poll until markets attach (usually ~5 minutes), then print them").option("--timeout <minutes>", "how long --wait polls before giving up", "8").action(async (submissionId, opts) => {
449
634
  const ctx = makeCtx(program2.opts());
450
635
  const r = await apiRequest(ctx.client, "POST", `/broker/submissions/${submissionId}/finalize`);
451
- if (ctx.json) return printJson(r);
452
- process.stdout.write("Marketing started. Track with: hedge status " + submissionId + "\n");
636
+ if (ctx.json && !opts.wait) return printJson(r);
637
+ if (!opts.wait) {
638
+ const wait = Number(r.typical_wait_seconds) > 0 ? Math.round(Number(r.typical_wait_seconds) / 60) : 5;
639
+ process.stdout.write(
640
+ `Marketing started. Markets usually attach within ~${wait} minutes.
641
+ Track with: hedge status ${submissionId} (or use finalize --wait next time)
642
+ `
643
+ );
644
+ return;
645
+ }
646
+ const timeoutMin = intFlag(opts.timeout, "--timeout");
647
+ const deadline = Date.now() + Math.max(1, timeoutMin) * 6e4;
648
+ process.stdout.write("Marketing started - waiting for markets to attach (usually ~5 minutes)");
649
+ let markets = [];
650
+ while (Date.now() < deadline) {
651
+ await new Promise((resolve) => setTimeout(resolve, 15e3));
652
+ process.stdout.write(".");
653
+ const s = await apiRequest(ctx.client, "GET", `/broker/submissions/${submissionId}`);
654
+ if (s.markets?.length) {
655
+ markets = s.markets;
656
+ break;
657
+ }
658
+ }
659
+ process.stdout.write("\n");
660
+ if (!markets.length) {
661
+ process.stdout.write(
662
+ `Still matching after ${timeoutMin} minutes - this can occasionally take longer.
663
+ Check in with: hedge status ${submissionId} or hedge requirements ${submissionId}
664
+ `
665
+ );
666
+ return;
667
+ }
668
+ const rows = markets.flatMap((m) => (m.lines || []).map((l) => ({
669
+ carrier: m.carrier_name,
670
+ line: l.lob_label ?? l.lob_slug,
671
+ status: l.status_label ?? l.status
672
+ })));
673
+ if (ctx.json) return printJson({ finalize: r, markets });
674
+ process.stdout.write(`Matched ${markets.length} market(s):
675
+
676
+ ` + table(rows, ["carrier", "line", "status"]) + "\n");
677
+ process.stdout.write("\nNext: hedge requirements " + submissionId + " shows what each market still needs from you.\n");
678
+ });
679
+ program2.command("documents <submissionId>").description("List a submission's finalized documents (ACORDs, quotes, binders)").action(async (submissionId) => {
680
+ const ctx = makeCtx(program2.opts());
681
+ const rows = await apiRequest(ctx.client, "GET", `/broker/submissions/${submissionId}/finalized-documents`);
682
+ if (ctx.json) return printJson(rows);
683
+ process.stdout.write(table(rows.map((d) => ({
684
+ id: d.id,
685
+ name: d.display_name,
686
+ kind: d.source_label ?? d.source,
687
+ size: formatBytes(d.size_bytes ?? d.size),
688
+ added: String(d.created_at ?? "").slice(0, 10)
689
+ })), ["id", "name", "kind", "size", "added"]) + "\n");
690
+ });
691
+ program2.command("download <documentId>").description("Download a finalized document PDF").option("-o, --output <file>", "output file (default: the server's filename, else <documentId>.pdf)").action(async (documentId, opts) => {
692
+ const ctx = makeCtx(program2.opts());
693
+ const out = await downloadRequest(ctx.client, "GET", `/broker/finalized-documents/${documentId}/pdf`, opts.output, `${documentId}.pdf`);
694
+ process.stdout.write("Saved " + out + "\n");
453
695
  });
454
696
  program2.command("policies").description("List bound policies").action(async () => {
455
697
  const ctx = makeCtx(program2.opts());
456
698
  const rows = await apiRequest(ctx.client, "GET", "/broker/policies");
457
699
  if (ctx.json) return printJson(rows);
458
- process.stdout.write(table(rows.map((r) => ({ insured: r.insured_name, carrier: r.carrier_name, policy: r.policy_number, premium: r.premium ?? "", status: r.status_label ?? r.status })), ["insured", "carrier", "policy", "premium", "status"]) + "\n");
700
+ process.stdout.write(table(rows.map((r) => ({
701
+ insured: r.insured_name,
702
+ carrier: r.carrier_name,
703
+ policy: r.policy_number,
704
+ premium: r.premium ?? "",
705
+ effective: r.effective_date ?? "",
706
+ expiration: r.expiration_date ?? "",
707
+ status: r.status_label ?? r.status
708
+ })), ["insured", "carrier", "policy", "premium", "effective", "expiration", "status"]) + "\n");
709
+ });
710
+ program2.command("policy <policyId>").description("Policy detail: term, premium, commission, payment plan, documents").action(async (policyId) => {
711
+ const ctx = makeCtx(program2.opts());
712
+ const p = await apiRequest(ctx.client, "GET", `/broker/policies/${policyId}`);
713
+ if (ctx.json) return printJson(p);
714
+ process.stdout.write(kv({
715
+ insured: p.insured_name,
716
+ policy: p.policy_number,
717
+ carrier: p.carrier_name,
718
+ lines: (p.lines || []).join(", "),
719
+ term: [p.effective_date, p.expiration_date].filter(Boolean).join(" to "),
720
+ net_premium: p.net_premium,
721
+ total_billed: p.total_billed,
722
+ commission_rate: p.commission_rate,
723
+ commission_amount: p.commission_amount,
724
+ payment_plan: p.payment_plan
725
+ }) + "\n");
726
+ if (p.documents?.length) {
727
+ process.stdout.write("\nDocuments: " + p.documents.map((d) => d.kind).join(", ") + "\n");
728
+ process.stdout.write("Download with: hedge policy-doc " + policyId + " <kind>\n");
729
+ }
730
+ });
731
+ program2.command("policy-doc <policyId> <kind>").description("Download a policy PDF; kind is one of binder, policy, declarations").option("-o, --output <file>", "output file (default <policyId>-<kind>.pdf)").action(async (policyId, kind, opts) => {
732
+ if (!POLICY_DOC_KINDS.includes(kind)) throw new Error(`kind must be one of ${POLICY_DOC_KINDS.join(", ")} (got "${kind}")`);
733
+ const ctx = makeCtx(program2.opts());
734
+ const out = await downloadRequest(ctx.client, "GET", `/broker/policies/${policyId}/document/${kind}`, opts.output ?? `${policyId}-${kind}.pdf`);
735
+ process.stdout.write("Saved " + out + "\n");
459
736
  });
460
737
  program2.command("payments").description("List payment / invoice status").action(async () => {
461
738
  const ctx = makeCtx(program2.opts());
462
739
  const rows = await apiRequest(ctx.client, "GET", "/broker/payments");
463
740
  if (ctx.json) return printJson(rows);
464
- process.stdout.write(table(rows.map((r) => ({ insured: r.insured_name, status: r.status_label ?? r.status, premium: r.premium ?? "" })), ["insured", "status", "premium"]) + "\n");
741
+ process.stdout.write(table(rows.map((r) => ({
742
+ insured: r.insured_name,
743
+ status: r.status_label ?? r.status,
744
+ premium: r.premium ?? "",
745
+ invoice_url: r.invoice_url ?? ""
746
+ })), ["insured", "status", "premium", "invoice_url"]) + "\n");
465
747
  });
466
748
  }
467
749
 
468
750
  // src/commands/appetite.ts
751
+ function requirementRows(items) {
752
+ return items.map((q) => ({
753
+ requirement: q.label + (q.conditional ? " (conditional)" : ""),
754
+ severity: q.severity,
755
+ provided_by: q.provided_by
756
+ }));
757
+ }
469
758
  function registerAppetite(program2) {
470
759
  program2.command("appetite <class>").description('Which markets have appetite for a class, e.g. hedge appetite "roofing contractor" --state CA').option("--state <ST>", "2-letter state").option("--lob <slug>", "line of business filter").action(async (klass, opts) => {
471
760
  const ctx = makeCtx(program2.opts());
@@ -481,6 +770,38 @@ function registerAppetite(program2) {
481
770
  }));
482
771
  process.stdout.write(table(rows, ["market", "lines", "programs", "turnaround"]) + "\n");
483
772
  });
773
+ program2.command("market-requirements <marketId>").description("What a market needs to quote a line: the application package plus per-program requirements (market ids come from `hedge appetite --json`)").requiredOption("--lob <slug>", "line of business, e.g. commercial_general_liability").option("--state <ST>", "2-letter state").option("--programs <keys>", "comma-separated program keys (matched_program_keys from the appetite result) to narrow the panel").action(async (marketId, opts) => {
774
+ const ctx = makeCtx(program2.opts());
775
+ const r = await apiRequest(ctx.client, "GET", `/broker/markets/${marketId}/requirements`, {
776
+ query: { lob: opts.lob, state: opts.state, programs: opts.programs }
777
+ });
778
+ if (ctx.json) return printJson(r);
779
+ process.stdout.write(kv({
780
+ market: r.market_name,
781
+ line: r.lob?.label ?? r.lob?.slug,
782
+ state: r.state ?? "",
783
+ channel: r.market_channel
784
+ }) + "\n");
785
+ if (r.baseline?.length) {
786
+ process.stdout.write("\nApplication package (Hedge prepares these):\n" + table(requirementRows(r.baseline), ["requirement", "severity", "provided_by"]) + "\n");
787
+ }
788
+ for (const p of r.programs || []) {
789
+ const lines = (p.lines || []).map((l) => l.label ?? l.slug).join(", ");
790
+ process.stdout.write(`
791
+ Program: ${p.display_name} [${p.channel}]${lines ? " (" + lines + ")" : ""}
792
+ `);
793
+ if (p.has_authored_requirements && p.requirements?.length) {
794
+ process.stdout.write(table(requirementRows(p.requirements), ["requirement", "severity", "provided_by"]) + "\n");
795
+ } else {
796
+ process.stdout.write("Standard application package; no carrier-specific requirements.\n");
797
+ }
798
+ }
799
+ if (r.other_program_count) {
800
+ process.stdout.write(`
801
+ ${r.other_program_count} more program(s) use the standard package.
802
+ `);
803
+ }
804
+ });
484
805
  }
485
806
 
486
807
  // src/commands/quotes.ts
@@ -512,21 +833,21 @@ function registerQuotes(program2) {
512
833
  process.stdout.write(`status: ${res.status} | remaining questions: ${(res.missing_required_questions_json || []).length}
513
834
  `);
514
835
  });
515
- program2.command("request-quote <submissionId> <sessionId>").description("Close a carrier session \u2014 request an indication then a quote").action(async (submissionId, sessionId) => {
836
+ program2.command("request-quote <submissionId> <sessionId>").description("Close a carrier session and request an indication then a quote").action(async (submissionId, sessionId) => {
516
837
  const ctx = makeCtx(program2.opts());
517
838
  const res = await apiRequest(ctx.client, "POST", `/broker/submissions/${submissionId}/api-quotes/sessions/${sessionId}/close`);
518
839
  if (ctx.json) return printJson(res);
519
840
  process.stdout.write(`outcome: ${res.outcome} | status: ${res.status}${res.quote_pdf_url ? " | quote: " + res.quote_pdf_url : ""}
520
841
  `);
521
842
  if ((res.missing_required_questions_json || []).length)
522
- process.stdout.write(`still needs ${(res.missing_required_questions_json || []).length} answers \u2014 use \`hedge answer\`
843
+ process.stdout.write(`still needs ${(res.missing_required_questions_json || []).length} answers; use \`hedge answer\`
523
844
  `);
524
845
  });
525
846
  }
526
847
 
527
848
  // src/index.ts
528
849
  var program = new Command();
529
- program.name("hedge").description("Submit risks to Hedge and track them from your terminal.").version("0.1.0").option("--staging", "use the staging environment").option("--json", "output raw JSON (for scripting)");
850
+ program.name("hedge").description("Submit risks to Hedge and track them from your terminal.").version("0.3.0").option("--staging", "use the staging environment").option("--json", "output raw JSON (for scripting)");
530
851
  registerAuth(program);
531
852
  registerSubmissions(program);
532
853
  registerAppetite(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hedge-broker",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Command-line tool for the Hedge broker portal. Submit risks, check appetite, track quotes and policies.",
5
5
  "type": "module",
6
6
  "bin": { "hedge": "dist/index.js" },