@wrangled/cli 0.3.9 → 0.3.11

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/dist/guide.js CHANGED
@@ -62,7 +62,7 @@ For "how is the funnel doing?", "which campaigns are working?" and "where does t
62
62
 
63
63
  ## Webhook addresses
64
64
 
65
- A webhook address lets a tool such as a form or webinar platform push events into the warehouse's \`raw_events\` table. \`${cli} intake list\` shows each address with whether it is on, how many days it keeps events, the events of the last 24 hours, the last event's time, and the rows it holds. \`${cli} intake create <label>\` makes one and prints its link, address and token. The link is the one URL to paste into the sending tool: the address with the token on the end, or for Mailgun the bare address. Add \`--kind ghl_workflow\` for a GoHighLevel workflow's Webhook step: its events also land, typed, in \`ghl_events\`. Add \`--kind aloware\` for Aloware's call and text webhooks: they also land, typed, in \`aloware_calls\` and \`aloware_texts\`. Add \`--kind webinarfuel\` or \`--kind stealth_seminar\` for a webinar tool: its sign-ups, attendance, replays and offer clicks land in \`webinar_events\`. Add \`--kind mailgun --signing-key-stdin\`, with Mailgun's HTTP webhook signing key piped on stdin, for a Mailgun account's webhooks: each event's signature is checked and alone proves the sender, so Mailgun posts to the address itself with no token, and its events also land, typed, in \`mailgun_events\`. When the account's key changes, pipe the new one to \`${cli} intake signing-key <id> --signing-key-stdin\`; deleting the workspace's last Mailgun address deletes the key. The link and token are shown only then and after \`${cli} intake rotate <id>\`. Paste the link into the sending tool, or, when the tool can set a header, give it the address and \`Authorization: Bearer <token>\` instead, which keeps the token out of request logs. Never paste either anywhere else. Events are kept for 90 days unless \`--retention-days\` or \`${cli} intake retention <id> <days>\` says otherwise (1 to 3650). \`${cli} intake delete <id> --yes\` deletes the address and every event it saved. A website visit tag shows in \`${cli} intake list\` as an address of kind \`visit_tag\`, and each page view it records lands in \`site_visits\` (host, path, referrer host, UTM tags, click ids, country, device type, a bot flag and a random visitor id). \`${cli} intake create\` cannot make one: a tag has no token and needs its list of allowed sites, so an operator makes it with the server's \`npm run visit-tag:create\` script.
65
+ A webhook address lets a tool such as a form or webinar platform push events into the warehouse's \`raw_events\` table. \`${cli} intake list\` shows each address with whether it is on, how many days it keeps events, the events of the last 24 hours, the last event's time, and the rows it holds. \`${cli} intake create <label>\` makes one and prints its link, address and token. The link is the one URL to paste into the sending tool: the address with the token on the end, or for Mailgun the bare address. Add \`--kind ghl_workflow\` for a GoHighLevel workflow's Webhook step: its events also land, typed, in \`ghl_events\`. Add \`--kind aloware\` for Aloware's call and text webhooks: they also land, typed, in \`aloware_calls\` and \`aloware_texts\`. Add \`--kind webinarfuel\` or \`--kind stealth_seminar\` for a webinar tool: its sign-ups, attendance, replays and offer clicks land in \`webinar_events\`. Add \`--kind mailgun --signing-key-stdin\`, with Mailgun's HTTP webhook signing key piped on stdin, for a Mailgun account's webhooks: each event's signature is checked and alone proves the sender, so Mailgun posts to the address itself with no token, and its events also land, typed, in \`mailgun_events\`. When the account's key changes, pipe the new one to \`${cli} intake signing-key <id> --signing-key-stdin\`; deleting the workspace's last Mailgun address deletes the key. Add \`--kind thrivecart_events\` for a ThriveCart account's webhook: affiliate commissions earned, taken back and paid out also land, typed, in \`thrivecart_affiliate_commissions\`, where they stay after their events pass the retention, and \`daily_spend\` counts them. The link and token are shown only then and after \`${cli} intake rotate <id>\`. Paste the link into the sending tool, or, when the tool can set a header, give it the address and \`Authorization: Bearer <token>\` instead, which keeps the token out of request logs. Never paste either anywhere else. Events are kept for 90 days unless \`--retention-days\` or \`${cli} intake retention <id> <days>\` says otherwise (1 to 3650). \`${cli} intake delete <id> --yes\` deletes the address and every event it saved. A website visit tag shows in \`${cli} intake list\` as an address of kind \`visit_tag\`, and each page view it records lands in \`site_visits\` (host, path, referrer host, UTM tags, click ids, country, device type, a bot flag and a random visitor id). \`${cli} intake create\` cannot make one: a tag has no token and needs its list of allowed sites, so an operator makes it with the server's \`npm run visit-tag:create\` script.
66
66
 
67
67
  ## Limits
68
68
 
@@ -166,6 +166,10 @@ Kinds are \`exclusion\`, \`gap\`, \`baseline\`, \`change\`, \`definition\`, \`ca
166
166
 
167
167
  To suggest a fact, write it as JSON and run \`${cli} facts propose --file fact.json\` (or pipe it on stdin): \`{"kind": "gap", "subject_type": "table", "subject_ref": "meta_insights_daily", "key": "history_start", "value": {"table": "meta_insights_daily", "from": "2026-04-07"}, "summary": "Facebook ad numbers only go back to April 7, 2026.", "evidence": {"query": "SELECT min(date) FROM meta_insights_daily"}}\`. You only suggest: a person confirms, fixes or rejects it in the app. A fact with the same kind, subject and key already open is refused as \`fact_exists\`. Evidence is counts, queries and ids, never names, emails or phones.
168
168
 
169
+ ## What customers keep saying
170
+
171
+ Every hour an AI reads the workspace's new Facebook and Instagram comments, Google reviews and Reddit posts and comments, pulls out openers (lines that would open an ad), objections (why people hesitate) and phrases (customers' own words for their problem or result), and groups the ones that say the same thing. Names, emails, phone numbers and handles are taken out first. \`${cli} voice\` lists the groups, most common first (\`--kind opener|objection|phrase\`, \`--limit <n>\` up to 200, \`--json\`), each with how many rows said it, their likes, and up to 3 real quotes. In \`--json\`, each quote's \`{kind, ref, quote}\` goes straight into a creative's \`researchSources\`, and a group itself can be cited as its \`ref\`, \`voice_clusters:<id>\`. The same rows are in the \`voice_clusters\` and \`voice_insights\` tables.
172
+
169
173
  ## The weekly story
170
174
 
171
175
  Each workspace keeps a weekly story: a one-screen read of what the data says this week, why, what is working, what the data cannot tell us, and what to do next. \`${cli} story\` prints the newest version, \`${cli} story list\` every version newest first, and \`${cli} story show <version>\` one of them (each takes \`--json\`). Every save is a new version; nothing is ever changed or deleted.
@@ -225,7 +229,7 @@ ${cli} sources list list data sources and their health
225
229
  ${cli} sources show <id> show one source's health, tables and last sync runs
226
230
  ${cli} sources settings <id> [<key>=<value> ...] show a key source's settings, or check them with its saved key and save them
227
231
  ${cli} intake list list webhook addresses and their health
228
- ${cli} intake create <label> [--kind generic|ghl_workflow|aloware|webinarfuel|stealth_seminar|mailgun] [--retention-days <n>] [--signing-key-stdin] make a webhook address; prints its link and token once
232
+ ${cli} intake create <label> [--kind generic|ghl_workflow|aloware|webinarfuel|stealth_seminar|mailgun|thrivecart_events] [--retention-days <n>] [--signing-key-stdin] make a webhook address; prints its link and token once
229
233
  ${cli} intake rotate <id> replace an address's token; prints the new link and token once
230
234
  ${cli} intake signing-key <id> --signing-key-stdin replace the workspace's signing key for that address's kind (mailgun)
231
235
  ${cli} intake disable <id> | enable <id> turn an address off or back on
@@ -268,6 +272,9 @@ ${cli} funnels evidence prices paid, buyer paths, GoHighLevel funnels, calenda
268
272
  ${cli} funnels draft --file <map.json> [--note <why>] [--evidence '<json>' | @file.json] [--based-on <n>] [--counts-from fix|<date>] [--questions @file.json] save the whole map; it goes live when every step can be counted
269
273
  ${cli} facts [--kind <kind>] [--subject <type>[:<ref>]] [--status <status>] [--json] | facts show <id> settled facts about the data, to treat as true
270
274
  ${cli} facts propose --file <fact.json> suggest a fact for a person to confirm
275
+ ${cli} voice [--kind opener|objection|phrase] [--limit <n>] [--json] what customers keep saying, grouped and counted, with real quotes
276
+ ${cli} voice lists where that voice comes from: the competitors whose ads, and the subreddits and Reddit search phrases whose posts, are saved, and suggestions waiting
277
+ ${cli} voice suggest competitor|subreddit|search_phrase "<value>" [--note <why>] suggest a competitor, subreddit or Reddit search phrase for a person to approve
271
278
  ${cli} story [--json] | story list | story show <version> the weekly story, its versions, and the rows behind each sentence
272
279
  ${cli} story submit --file <story.json> save the next version of the weekly story
273
280
  ${cli} worker list | worker runs <hand-id | skill> [--limit <n>] | worker run-now <hand-id | skill> this workspace's scheduled hands, their runs and costs, and Run now
package/dist/main.js CHANGED
@@ -1099,6 +1099,24 @@ const commands = {
1099
1099
  console.log(renderTable(FACT_COLUMNS, facts.map(factRow)));
1100
1100
  },
1101
1101
  },
1102
+ voice: {
1103
+ usage: "voice [--kind opener|objection|phrase] [--limit <n>] [--json] what customers keep saying, most common first",
1104
+ async run(args, options) {
1105
+ if (args.length > 0)
1106
+ throw new CliError("Usage: voice [--kind opener|objection|phrase] [--limit <n>] [--json]", exitCodes.usage);
1107
+ const query = new URLSearchParams();
1108
+ if (options.kind)
1109
+ query.set("kind", options.kind);
1110
+ if (options.limit)
1111
+ query.set("limit", options.limit);
1112
+ const { clusters } = await api(await resolveConnection(options.apiUrl), `${VOICE_PATH}${query.size ? `?${query}` : ""}`);
1113
+ if (options.format === "json")
1114
+ return printJson({ clusters });
1115
+ if (clusters.length === 0)
1116
+ return console.log("Nothing grouped yet.");
1117
+ console.log(renderTable(VOICE_COLUMNS, clusters.map(voiceRow)));
1118
+ },
1119
+ },
1102
1120
  "facts show": {
1103
1121
  usage: "facts show <id> one fact with its value and evidence",
1104
1122
  async run(args, options) {
@@ -1121,6 +1139,42 @@ const commands = {
1121
1139
  console.log(`Proposed fact ${fact.id}. A person confirms or rejects it in the app.`);
1122
1140
  },
1123
1141
  },
1142
+ "voice lists": {
1143
+ usage: "voice lists where the voice comes from: the competitors whose ads, and the subreddits and Reddit search phrases whose posts, this workspace saves, and the suggestions waiting",
1144
+ async run(args, options) {
1145
+ if (args.length > 0)
1146
+ throw new CliError("Usage: voice lists", exitCodes.usage);
1147
+ const lists = await api(await resolveConnection(options.apiUrl), VOICE_LISTS_PATH);
1148
+ if (options.format === "json")
1149
+ return printJson(lists);
1150
+ const rows = [
1151
+ ...lists.competitors.map((c) => ({ list: "competitor", entry: `${c.name} (Page ${c.pageId})`, status: "listed", note: null })),
1152
+ ...lists.subreddits.map((s) => ({ list: "subreddit", entry: `r/${s}`, status: "listed", note: null })),
1153
+ ...lists.phrases.map((p) => ({ list: "search_phrase", entry: p, status: "listed", note: null })),
1154
+ ...lists.suggestions.map((s) => ({ list: s.kind, entry: s.kind === "subreddit" ? `r/${s.value}` : s.value, status: "suggested", note: s.note })),
1155
+ ];
1156
+ if (rows.length === 0)
1157
+ return console.log("No competitors, subreddits or phrases yet, and no suggestions.");
1158
+ console.log(renderTable(["list", "entry", "status", "note"], rows));
1159
+ },
1160
+ },
1161
+ "voice suggest": {
1162
+ usage: "voice suggest competitor|subreddit|search_phrase <value> [--note <why>] suggest a competitor, subreddit or Reddit search phrase; a person approves it in the app",
1163
+ async run(args, options) {
1164
+ const [kind, value, ...rest] = args;
1165
+ if (!kind || !["competitor", "subreddit", "search_phrase"].includes(kind) || value === undefined || rest.length > 0) {
1166
+ throw new CliError('Usage: voice suggest competitor|subreddit|search_phrase "<value>" [--note <why>]; put a value with spaces in quotes', exitCodes.usage);
1167
+ }
1168
+ const result = await api(await resolveConnection(options.apiUrl), VOICE_SUGGESTIONS_PATH, { kind, value, note: options.note });
1169
+ if (options.format === "json")
1170
+ return printJson(result);
1171
+ if (result.outcome === "already_listed")
1172
+ return console.log(`That ${kind.replace("_", " ")} is already on the list.`);
1173
+ if (result.outcome === "already_suggested")
1174
+ return console.log(`That ${kind.replace("_", " ")} is already suggested (${result.suggestion.id}), waiting for a person.`);
1175
+ console.log(`Suggested ${kind.replace("_", " ")} ${result.suggestion.value} (${result.suggestion.id}). A person approves or dismisses it in the app.`);
1176
+ },
1177
+ },
1124
1178
  story: {
1125
1179
  usage: "story the newest weekly story, with the rows behind each sentence",
1126
1180
  async run(args, options) {
@@ -1564,7 +1618,11 @@ function printReport(title, report, labels, numbers) {
1564
1618
  }
1565
1619
  const agentPath = (options) => `/api/v1/agents/${encodeURIComponent(options.agent)}`;
1566
1620
  const FACTS_PATH = "/api/v1/facts";
1621
+ const VOICE_LISTS_PATH = "/api/v1/voice-lists";
1622
+ const VOICE_SUGGESTIONS_PATH = "/api/v1/voice-lists/suggestions";
1567
1623
  const FACT_COLUMNS = ["id", "kind", "subject", "key", "status", "summary"];
1624
+ const VOICE_PATH = "/api/v1/voice";
1625
+ const VOICE_COLUMNS = ["ref", "kind", "said by", "likes", "label", "top quote"];
1568
1626
  function knowledgeBlock(r, i) {
1569
1627
  const lines = [`${i + 1}. ${r.title}`, ` ${r.kind}, ${r.evidence.replaceAll("_", " ")}`];
1570
1628
  if (r.url)
@@ -1574,6 +1632,9 @@ function knowledgeBlock(r, i) {
1574
1632
  lines.push(` Caveat: ${r.caveat}`);
1575
1633
  return lines.join("\n");
1576
1634
  }
1635
+ function voiceRow(c) {
1636
+ return { ref: c.ref, kind: c.kind, "said by": c.supportCount, likes: c.likeSum, label: excerpt(c.label, 60), "top quote": c.evidence[0] ? excerpt(c.evidence[0].quote, 60) : null };
1637
+ }
1577
1638
  function factRow(f) {
1578
1639
  return { id: f.id, kind: f.kind, subject: `${f.subjectType}:${f.subjectRef}`, key: f.key, status: f.status, summary: excerpt(f.summary, 80) };
1579
1640
  }
@@ -1634,7 +1695,7 @@ Options:
1634
1695
  --wait <seconds> how long tools run and tools get wait for a run (default: ${DEFAULT_WAIT_SECONDS}; 0 returns at once)
1635
1696
  --agent <kind> which agent the agent commands use: meta_ads (default) or p3_refresh; in a scheduled hand's run, skill
1636
1697
  (the run's skill's agent) is the default
1637
- --kind <kind> what sends to a new intake address: generic (default), ghl_workflow, aloware, webinarfuel, stealth_seminar or mailgun
1698
+ --kind <kind> what sends to a new intake address: generic (default), ghl_workflow, aloware, webinarfuel, stealth_seminar, mailgun or thrivecart_events
1638
1699
  --retention-days <n> how many days intake create keeps each event (default: 90)
1639
1700
  --signing-key-stdin read the sender's signing key for intake create or intake signing-key from stdin (mailgun needs one)
1640
1701
  --table <name> the table import csv loads the file into, or import remove drops, as import_<name>;
@@ -1686,7 +1747,7 @@ Options:
1686
1747
  --from, --to <n> with content diff, the two versions to compare (default: the one before the current one, and the current one)
1687
1748
  --sort <column> how content search sorts: recent (default) or a content_scorecard number, such as click_rate or roas
1688
1749
  --order desc|asc content search's sort direction (default: desc, best or newest first)
1689
- --limit <n> how many results content search, creatives list and campaigns return
1750
+ --limit <n> how many results content search, creatives list, campaigns and voice return
1690
1751
  --max-seconds <n> stop a runner's run after this many seconds, if that comes before its deadline
1691
1752
  --workspace <slug> for worker promote and worker drop: the workspace whose hand they change
1692
1753
  -h, --help show this help
package/dist/playbooks.js CHANGED
@@ -595,7 +595,7 @@ ${cli} funnels show --format json > current.json
595
595
 
596
596
  \`evidence.json\` is \`{"evidence": {...}, "text": "..."}\`. The parts below are under \`.evidence\`, each either \`{"status": "ok", "rows": [...]}\` or \`{"status": "not_connected", "note": "..."}\`. Amounts are in whole currency units (1500 is 1,500.00), over the last \`.evidence.windowDays\` days unless a part says otherwise.
597
597
 
598
- - **prices**: the prices people pay, on each platform, with how many buyers and payments. A GoHighLevel or Shopify row has the product's \`name\`; a Stripe charge has none. For GoHighLevel, \`payments\` counts orders, not instalments. A GoHighLevel sale taken through Stripe shows twice, as a GoHighLevel row naming the product and a Stripe row with only the amount, and the two amounts can differ a little (1497 and 1500).
598
+ - **prices**: the prices people pay, on each platform, with how many buyers and payments. A GoHighLevel or Shopify row has the product's \`name\`, a Kajabi row the offer's title, with the Kajabi offer id as \`productId\`; a Stripe charge has none. For GoHighLevel, \`payments\` counts orders, not instalments. A GoHighLevel or Kajabi sale taken through Stripe shows twice, as a row naming the product and a Stripe row with only the amount, and the two amounts can differ a little (1497 and 1500).
599
599
  - **buyerPaths**: the most common orders in which one person paid, with the median days between each payment. A small payment minutes after a big one is an order bump or upsell; a large payment weeks later is a back-end offer. A path such as \`[1500, 497, 3500]\` with medians \`[0, 60]\` reads: course, then an upsell the same day, then an event about two months later. A GoHighLevel checkout with an order bump is one payment of both prices together: 1994 is 1497 + 497. Before you take an amount for a new offer, check whether it is a sum of prices you already know.
600
600
  - **funnels**, **calendars**, **pipelineStages**, **tags**: what GoHighLevel holds. Tags often name purchases and memberships.
601
601
  - **webinars**: sign-ups, viewers and offer clicks per webinar id.
@@ -641,8 +641,8 @@ Start from the shape that fits, then rename and add steps to match what you read
641
641
 
642
642
  Write \`map.json\` as \`{"offers": [...], "funnels": [...]}\`, in this order.
643
643
 
644
- 1. **Offers**, front end first, numbered by \`stack_position\` from 1. Each has an \`offer_key\` (lowercase, digits and underscores), \`name\`, \`tier\` (\`front_end\` or \`back_end\`), \`delivery\` (course, event, coaching, service, product, membership, webinar, lead_magnet or other), \`price\`, \`currency\`, an optional \`price_note\` (plain words on the price: other prices, payment plans, doubts), and \`matches\`: how a sale is recognised. A match is \`{"platform": ..., "match_type": ..., "value": "..."}\`: platform \`stripe\`, \`ghl\` or \`shopify\`, and match_type \`product_id\`, \`price_id\`, \`amount\` (whole units, with a \`currency\`), \`name_contains\` (any part of the product's name, ignoring case) or \`name_starts_with\` (the start of the product's name, ignoring case). Use \`name_starts_with\` for a family of products whose names begin with one code, such as event tickets named "OCA Dallas" and "OCA Austin"; \`name_contains\` "OCA" would also catch "Local Coaching". Stripe charges carry no product, so match them by \`amount\`. Prefer a GoHighLevel or Shopify name match on the product's name when one price is shared by two offers, as a $497 bump and a $497 guest ticket can be. An offer nobody pays for, such as a free webinar, may have no \`matches\`.
645
- - **Price.** A free webinar's \`price\` is 0 when its page or GoHighLevel says it is free. When GoHighLevel and Stripe both show a sale, \`price\` is the GoHighLevel price that names the product, \`price_note\` gives the Stripe amount, and the offer matches both: a GoHighLevel name match and the Stripe \`amount\`. Any other price you cannot find in the evidence is \`null\`, with a \`price_note\` saying why.
644
+ 1. **Offers**, front end first, numbered by \`stack_position\` from 1. Each has an \`offer_key\` (lowercase, digits and underscores), \`name\`, \`tier\` (\`front_end\` or \`back_end\`), \`delivery\` (course, event, coaching, service, product, membership, webinar, lead_magnet or other), \`price\`, \`currency\`, an optional \`price_note\` (plain words on the price: other prices, payment plans, doubts), and \`matches\`: how a sale is recognised. A match is \`{"platform": ..., "match_type": ..., "value": "..."}\`: platform \`stripe\`, \`ghl\`, \`shopify\` or \`kajabi\`, and match_type \`product_id\`, \`price_id\`, \`amount\` (whole units, with a \`currency\`), \`name_contains\` (any part of the product's name, ignoring case) or \`name_starts_with\` (the start of the product's name, ignoring case). Use \`name_starts_with\` for a family of products whose names begin with one code, such as event tickets named "OCA Dallas" and "OCA Austin"; \`name_contains\` "OCA" would also catch "Local Coaching". Stripe charges carry no product, so match them by \`amount\`. For Kajabi, \`product_id\` is the offer id and a name match reads the offer's title; Kajabi has no \`price_id\`. Prefer a GoHighLevel or Shopify name match on the product's name when one price is shared by two offers, as a $497 bump and a $497 guest ticket can be. An offer nobody pays for, such as a free webinar, may have no \`matches\`.
645
+ - **Price.** A free webinar's \`price\` is 0 when its page or GoHighLevel says it is free. When GoHighLevel or Kajabi and Stripe both show a sale, \`price\` is the GoHighLevel or Kajabi price that names the product, \`price_note\` gives the Stripe amount, and the offer matches both: a GoHighLevel name match or the Kajabi offer, and the Stripe \`amount\`. A sale Stripe holds counts only under Stripe. Any other price you cannot find in the evidence is \`null\`, with a \`price_note\` saying why.
646
646
  2. **Funnels and steps.** A funnel has a \`funnel_key\`, \`name\`, \`kind\` (\`front_end\` or \`back_end\`), \`entry_offer_key\`, \`window_days\` (how many days after one step a sale still counts for the next; 365 when left out) and \`steps\`. A step has a \`step_key\`, \`position\` (1, 2, 3 in the order people walk them), \`step_type\`, \`name\`, and optionally the \`offer_key\` it sells, a \`wait_note\` (plain words on the wait before it, such as "the months after the course"), \`assets\` and \`rules\`.
647
647
  - Step types: \`ad\`, \`landing_page\`, \`vsl\`, \`optin\`, \`application\`, \`booking\`, \`confirmation\`, \`sales_call\`, \`checkout\`, \`order_bump\`, \`upsell\`, \`downsell\`, \`thank_you\`, \`webinar_registration\`, \`webinar\`, \`event\`, \`email_nurture\`, \`sms_nurture\`, \`onboarding\`, \`other\`.
648
648
  3. **Assets** on each step: \`{"asset_type": ..., "ref": ...}\` with refs from the evidence: \`meta_campaign\` (campaign id), \`url\` (page address), \`content_item\` (content item id), \`ghl_funnel_step\` (step id), \`ghl_calendar\`, \`ghl_pipeline_stage\`, \`ghl_tag\`, \`webinar\` (webinar id). Give the step after Traffic its page, the funnel's first page, as a \`url\`, a \`content_item\` from \`pages\` or a \`ghl_funnel_step\`: the Traffic step finds its ads from it. Only that step's pages count, so a checkout page that two funnels share is never mistaken for a first page.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrangled/cli",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "description": "Command-line access to your marketing data warehouse, for people and coding agents",
5
5
  "type": "module",
6
6
  "bin": {