@flowapt/flowiq-cli 0.6.6 → 0.6.8

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
@@ -555,6 +555,28 @@ flowiq tag remove <org_id> vip,old-promo --confirm # remove tag(s) from ALL con
555
555
  matches — re-run until `matched 0`. Idempotent and safe to re-run.
556
556
  - **Undo** any tag with `flowiq tag remove <org> <tag> --confirm`.
557
557
 
558
+ ### Publishing (maintainers) — publish from a CLEAN checkout
559
+
560
+ `npm publish` packs the **working tree**, not your branch. This repo's local
561
+ tree is routinely dozens of commits behind `origin/main` with other sessions'
562
+ work in it, so `cd cli && npm publish` from it ships a stale CLI under a version
563
+ number you can never reuse. That has happened twice — **0.6.5** (8 Sep 2026) and
564
+ **0.6.7** (9 Sep 2026) both shipped a local tree while the real work sat on
565
+ `origin/main`.
566
+
567
+ A `prepublishOnly` hook now refuses when `cli/` differs from `origin/main`:
568
+
569
+ ```bash
570
+ # the safe recipe
571
+ git clone https://github.com/MattFlowapt/flow-v1.git /tmp/pub
572
+ cd /tmp/pub/cli && npm publish
573
+
574
+ # or bring the local tree level first
575
+ git checkout origin/main -- cli/ && cd cli && npm publish
576
+ ```
577
+
578
+ `FLOWIQ_ALLOW_DIRTY_PUBLISH=1` overrides it, for a genuine emergency only.
579
+
558
580
  ### Links — `flowiq links shorten|list` (v0.4.8)
559
581
 
560
582
  Short links with campaign UTM tags — the terminal half of the in-app **URL
@@ -564,16 +586,53 @@ minted here is identical to one minted in the dialog.
564
586
  ```bash
565
587
  # Dry run FIRST — shows the exact destination each short link will carry
566
588
  flowiq links shorten <org> --url "https://shop.co.za/product/x" \
567
- --campaign 13Aug_Seeds --content 13Aug_Seeds
589
+ --campaign "Spring Promotion" # → utm_campaign=9Sep_SpringPromotion
568
590
 
569
591
  flowiq links shorten <org> --url "https://shop.co.za/product/x" \
570
592
  --url "https://shop.co.za/product/y" \
571
- --campaign 13Aug_Seeds --content 13Aug_Seeds --domain linklnk.io --commit
593
+ --campaign "Spring Promotion" --content ViewMore --domain linklnk.io --commit
594
+
595
+ # a send going out later — give the send date, not today's
596
+ flowiq links shorten <org> --file ./slide2.txt \
597
+ --campaign "Heritage Day" --date 2026-09-24 --commit
572
598
 
573
- flowiq links shorten <org> --file ./slide2.txt --campaign 13Aug_Seeds_VM --content 13Aug_Seeds_VM --commit
574
- flowiq links list <org> --campaign 13Aug_Seeds
599
+ flowiq links list <org> --campaign 9Sep_SpringPromotion
575
600
  ```
576
601
 
602
+ #### Campaign naming — `Date_Campaign` (you do not have to type it) (v0.6.7)
603
+
604
+ The house convention is **`Date_Campaign`**: the date the broadcast **goes out**
605
+ (`9Sep` — no leading zero, three letters, `Sep` never `Sept`), an underscore,
606
+ then the campaign title in PascalCase. A broadcast going out today for the
607
+ Spring Promotion is **`9Sep_SpringPromotion`**.
608
+
609
+ `--campaign` is normalised to it, so pass the campaign in plain words:
610
+
611
+ | you type | you get |
612
+ |---|---|
613
+ | `--campaign "Spring Promotion"` | `9Sep_SpringPromotion` (today's date prefixed) |
614
+ | `--campaign spring-promotion` | `9Sep_SpringPromotion` |
615
+ | `--campaign 10JunFathersDay` | `10Jun_FathersDay` (underscore inserted) |
616
+ | `--campaign 5Sept_BraaiDayOffer` | `5Sep_BraaiDayOffer` (`Sept` → `Sep`) |
617
+ | `--campaign "Heritage Day" --date 24/9/2026` | `24Sep_HeritageDay` |
618
+
619
+ - The resolved tag is **printed before anything is minted**, and `shorten` is
620
+ dry-run by default, so you always see it first.
621
+ - `--date` takes `9Sep`, `2026-09-24` or `24/9/2026` — use it whenever the send
622
+ is not today.
623
+ - `--content` is the **per-link** tag (which button, which product), so it gets
624
+ no date: `--content ViewMore`. Omit it and it defaults to the campaign.
625
+ - `--raw-campaign` uses your string exactly as typed, for the rare tag that is
626
+ deliberately outside the convention.
627
+ - **Not applied** to the agent's own auto-shortened links (minted server-side as
628
+ `ai_<org>` — a different namespace) or to `flowiq bc --campaign`, which names
629
+ the local `.flowiq/campaigns/<x>.json` file and the `bc-<x>` contact tag
630
+ rather than a UTM.
631
+
632
+ Why it exists: of 1,042 distinct date-prefixed campaign tags minted in the 12
633
+ months to 9 Sep 2026, only **27 (2.6%)** matched the convention — 924 fused the
634
+ date onto the title (`10JunFathersDay`) and 5 wrote `Sept`.
635
+
577
636
  - **`utm_source=whatsapp` and `utm_medium=whatsapp_paid` are FIXED** (the dialog
578
637
  sets them too) — a link built any other way stops matching the attribution
579
638
  queries. You supply `--campaign` and `--content`, and they must be **supplied
@@ -982,17 +1041,44 @@ flowiq report deck pull <org_id> 2026-08 # → ./.flowiq/repor
982
1041
  - `send --client` needs an approved deck AND `report.email.recipients` in the org's reporting config (Control center → Report delivery); it marks the deck `sent`. `--test-to` never changes status. Fees on the slides are the org's actual Meta billing when the token can read it, otherwise the rate-card estimate, and the footnote says which.
983
1042
  - Non-store orgs need `config.deck.outcome` (`source: handover | ticket_status | tag | keyword`, labels) — the revenue slides become outcome slides. Every verb except `status` and `pull` is audited.
984
1043
 
985
- ### WhatsApp templates — `flowiq templates pull|list|create|status` (alias `tpl`)
1044
+ ### WhatsApp templates — `flowiq templates pull|list|show|create|status` (alias `tpl`)
986
1045
 
987
- Read an org's live templates straight from Meta (read-only), and submit new
988
- ones through the `create-meta-template` edge function.
1046
+ Read an org's live templates straight from Meta (read-only), render any single
1047
+ row **including an unsubmitted DRAFT**, and submit new ones through the
1048
+ `create-meta-template` edge function.
989
1049
 
990
1050
  ```bash
991
1051
  flowiq templates pull <organization_id> # → ./.flowiq/templates/<slug>.json (Meta-side truth)
992
- flowiq templates create <organization_id> --request-file req.json
993
1052
  flowiq templates status <organization_id> --name booking # poll approval
1053
+ flowiq templates show <organization_id> heritage_day_v2 # render ONE row, drafts included
1054
+ flowiq templates create <organization_id> --request-file req.json
994
1055
  ```
995
1056
 
1057
+ **`show` is the only way to read a DRAFT from the terminal (v0.6.7).** A draft
1058
+ never reaches Meta, so `templates pull` cannot see it and neither can the
1059
+ broadcast introspector — before this, reviewing one meant opening the dialog.
1060
+ `show` prints the body with its examples substituted (as the customer will read
1061
+ it), then every carousel card: media URL, source filename, whether the Meta
1062
+ asset handle is present, card body, and each button's resolved URL. `--raw`
1063
+ also prints the escaped body so invisible whitespace is visible; `--json` gives
1064
+ the row.
1065
+
1066
+ It closes with **Checks** — cheap structural rules that Meta would otherwise
1067
+ catch only at review, or not at all until send:
1068
+
1069
+ - body variables not sequential from `{{1}}`, or missing an example
1070
+ - a card URL button using anything but `{{1}}` (Meta numbers each card's
1071
+ parameters per card, so `{{2}}` on a card breaks that card's link at send)
1072
+ - more than one variable in a card URL (Meta supports exactly one)
1073
+ - a bold/italic/strikethrough marker sitting against a space, which WhatsApp
1074
+ does not render — the customer sees the literal `_` or `*`
1075
+ - carousel outside 2–10 cards, cards that do not share one shape, missing media,
1076
+ body/card text over Meta's 1024 / 160 limits
1077
+
1078
+ Checks are structural only. They cannot tell you a card names a product your
1079
+ store does not sell — `show` renders the text so you can read it against the
1080
+ shop.
1081
+
996
1082
  `req.json` holds `{ template_request, template_data?, media_header?, cards_media? }`.
997
1083
  Media headers: pass `media_header.file_url` (a public URL) — the edge function
998
1084
  uploads it to Meta server-side. Approval is async; re-`pull` for the
@@ -1237,7 +1323,7 @@ pairs with reasoning models like `gpt-5.6-luna`), agent `--rename`,
1237
1323
  the tool-flag columns (`woo_order_build`, `woo_tip_field`, `woo_order_note_field`,
1238
1324
  `view_cart_tool`, `restock_tool`, `block_tool_status`, `postal_code_tool_status`,
1239
1325
  `shopify_products_web_chat`, `ticket_tool_status`, `product_lookup`,
1240
- `collapse_product_variants`),
1326
+ `collapse_product_variants`, `email_request_tool`),
1241
1327
  `discount.enabled`, and the `flowiq test` contact
1242
1328
  (`settings.test_contact_number` / `settings.test_contact_name`). Anything else is
1243
1329
  rejected; every change is reported before → after.
@@ -1258,6 +1344,15 @@ agent answers product questions with "I can't pull the live menu". Found on Ouma
1258
1344
  Bets Gebak (11 Aug 2026): 25 product rows, 0 embeddings, and an unfunded OpenAI
1259
1345
  account — enabling this flag restored product answers immediately.
1260
1346
 
1347
+ **`--tool email_request_tool=true` (added 9 Sep 2026).** Turns on the `email_request_to_team`
1348
+ tool: once the agent has a customer's name, email and/or phone, preferred contact
1349
+ method and what they need, it emails that (plus the last few messages) to the
1350
+ addresses in `agents.settings.email_request.to`, from flowiq@flowapt.com with Reply-To
1351
+ set to the customer, and leaves an internal note on the thread. No ticket, no bot-off,
1352
+ no staff WhatsApp — built for businesses that want requests in an inbox rather than a
1353
+ human escalation (GIB Financial Services). The recipient list is set in the Tool
1354
+ Library (Agents → Tool Library → Email Request to Team); the CLI cannot set it yet.
1355
+
1261
1356
  **`--tool collapse_product_variants=true` (added 4 Aug 2026).** Not a tool toggle —
1262
1357
  it changes what `get_product_info` RETURNS. OFF (the default) the result cap counts
1263
1358
  **variant rows**, so on a catalogue with several packaging/size variants per product
package/TEAM-GUIDE.md CHANGED
@@ -101,10 +101,31 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
101
101
  | Remove a conflicting built-in tool from one agent | `flowiq agent config <org_id> --disable-base-tool get_product_info` (repeatable; safety/delivery tools cannot be disabled) |
102
102
  | Agent says an in-stock product "isn't showing" | `flowiq agent config <org_id> --tool collapse_product_variants=true` — the search cap counts VARIANT rows until this is on |
103
103
  | Agent can't quote ANY price / "I can't pull the live menu" | `flowiq agent config <org_id> --tool product_lookup=true` — name-based fuzzy lookup that works without embeddings or a live OpenAI key (semantic `get_product_info` needs both) |
104
+ | Client wants leads/requests emailed to their team instead of a human escalation | `flowiq agent config <org_id> --tool email_request_tool=true`, then set the recipient list in Agents → Tool Library → Email Request to Team (no CLI path for the addresses yet) |
104
105
  | Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
105
106
  | Set which contact `flowiq test` uses (use a FAKE number!) | `flowiq agent config <org_id> --test-contact-number 27000000001 --test-contact-name "QA Bot"` |
106
- | **Make short links for a campaign** (with UTM tracking) | `flowiq links shorten <org_id> --url "https://shop.co.za/product/x" --campaign 13Aug_Seeds --content 13Aug_Seeds` (dry run) → `… --commit`. `utm_source`/`utm_medium` are set for you; campaign + content must be given together |
107
- | Short links for every URL in a message | `flowiq links shorten <org_id> --file ./message.txt --campaign X --content X --commit` |
107
+ > **Publishing the CLI (maintainers only):** publish from a clean clone, never
108
+ > from your working tree — `npm publish` packs whatever is on disk. A
109
+ > `prepublishOnly` check now stops you if `cli/` differs from `origin/main`.
110
+
111
+ ### Campaign naming — `Date_Campaign`
112
+
113
+ Every campaign tag is **the date the broadcast goes out, then the campaign
114
+ title**: a Spring Promotion going out on 9 September is `9Sep_SpringPromotion`.
115
+
116
+ **You do not have to type it that way.** Pass `--campaign "Spring Promotion"`
117
+ and the CLI builds the tag, prints it, and only mints once you add `--commit`.
118
+ Use `--date` when the send is not today (`--date 24/9/2026`), and `--content`
119
+ for a second link in the same send (`--content ViewMore`).
120
+
121
+ Getting this right is what makes a campaign's clicks and revenue group together
122
+ in reporting — a tag typed a different way each time splits one campaign into
123
+ several.
124
+
125
+ | **Make short links for a campaign** (with UTM tracking) | `flowiq links shorten <org_id> --url "https://shop.co.za/product/x" --campaign "Spring Promotion"` (dry run) → `… --commit`. **Just type the campaign in plain words** — the CLI names it for you as `9Sep_SpringPromotion` and prints it before minting. `utm_source`/`utm_medium` are set for you |
126
+ | A campaign going out on a later date | Add `--date`: `--campaign "Heritage Day" --date 24/9/2026` → `24Sep_HeritageDay`. Without it you get today's date |
127
+ | A second link in the same send (e.g. a VIEW MORE button) | Same `--campaign`, different `--content`: `--content ViewMore`. Content is the per-link tag and gets no date |
128
+ | Short links for every URL in a message | `flowiq links shorten <org_id> --file ./message.txt --campaign "Spring Promotion" --commit` |
108
129
  | Check which links a campaign has, and their clicks | `flowiq links list <org_id> --campaign 13Aug_Seeds` |
109
130
  | A link already looks short (`linklnk.io/abc123`) — can I re-tag it? | No: paste the DESTINATION url instead. Re-shortening keeps the old campaign tag and splits the click count, so the CLI refuses it |
110
131
  | Send a template broadcast to a CSV of people | `flowiq bc map <org_id> --template … --csv …` → `flowiq bc send … ` (dry-run) → `… --commit`. **v0.6.0: the commit imports every row as a contact (tag `bc-<campaign>` + the row's values as attributes) and fires ONE python broadcast** — it returns in seconds with a `broadcastId`; python sends in the background. Check delivery with `bc status`, re-fire failures with `bc retry`. A campaign that already fired refuses a re-run (`--resend` deliberately resends to everyone tagged). |
@@ -134,6 +155,8 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
134
155
  | Read a contact's chat | `flowiq m pull <contact_id>` then open the JSON. Ask for more than exists (`--count 100`) and you get the entire history — it says "that is ALL of them" when there is nothing older |
135
156
  | Export an org's full chat history | `flowiq export chats <org_id>` |
136
157
  | Check / create WhatsApp templates | `flowiq tpl pull <org_id>` / `flowiq tpl create <org_id> --request-file req.json` |
158
+ | **Read a template you have not submitted yet (a DRAFT)** | `flowiq tpl show <org_id> <template_name>` — the ONLY way to see a draft from the terminal (`pull` reads Meta, and a draft never gets there). Renders the message as the customer will read it, every carousel card, and a **Checks** list of the things Meta would bounce it for |
159
+ | Check a carousel before submitting it | `flowiq tpl show <org_id> <name>` and read **Checks**. It catches a card link using the wrong `{{n}}`, a missing greeting example, a bold/italic marker against a space (WhatsApp shows the literal `_`), wrong card counts and over-length text. It cannot know a card names a product you do not stock — read the card text against the shop yourself |
137
160
  | Manage Shopify/Woo platform webhooks | `flowiq wh pull <org_id>` → edit → `flowiq wh push <slug>` |
138
161
  | Manage outbound messaging webhooks (incl. their auth) | `flowiq mw pull <org_id>` → `flowiq mw push <slug> --dry-run` → push |
139
162
  | Create a brand-new client org | `flowiq org create --name "Client Name"` → then `agent create` on the printed id |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowapt/flowiq-cli",
3
- "version": "0.6.6",
3
+ "version": "0.6.8",
4
4
  "description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, fine-tuning, pin-board tasks, webhooks, templates, agent-updates, chat exports, and live agent testing without ever touching service-role credentials.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,9 @@
16
16
  "node": ">=18"
17
17
  },
18
18
  "scripts": {
19
- "start": "node bin/flowiq.js"
19
+ "start": "node bin/flowiq.js",
20
+ "prepublishOnly": "node scripts/prepublish-check.mjs",
21
+ "test": "node --test src/*.test.mjs"
20
22
  },
21
23
  "dependencies": {
22
24
  "commander": "^12.1.0",
@@ -0,0 +1,164 @@
1
+ // Campaign / UTM naming convention — Date_Campaign (Matt, 9 Sep 2026).
2
+ // ====================================================================
3
+ // Date = the physical date the broadcast GOES OUT, as `9Sep` (no
4
+ // leading zero, three-letter month, "Sep" never "Sept").
5
+ // Campaign = the broadcast title / campaign, PascalCase, no spaces.
6
+ // → `9Sep_SpringPromotion`
7
+ //
8
+ // Staff should not have to remember this, so the CLI resolves it for them:
9
+ // `--campaign "Spring Promotion"` on a send going out today becomes
10
+ // `9Sep_SpringPromotion`, and the resolved value is always PRINTED before a
11
+ // --commit so it can be seen and overridden.
12
+ //
13
+ // Measured 9 Sep 2026, why this exists: of 1,042 distinct date-prefixed
14
+ // campaign tags minted in 12 months, only 27 (2.6%) matched the convention —
15
+ // 924 fused the date to the title (`10JunFathersDay`) and 5 wrote `Sept`.
16
+ //
17
+ // SCOPE: this is the UTM campaign tag on a STAFF-minted short link
18
+ // (`flowiq links shorten`). It is deliberately NOT applied to:
19
+ // • agent auto-shortened links, tagged `ai_<org>` by api/_url-shorten-engine.js
20
+ // (5,337 `ai_flw`, 3,649 `ai_gf`, … — a different namespace, not a campaign)
21
+ // • `flowiq bc --campaign`, which names the LOCAL .flowiq/campaigns/<x>.json
22
+ // file and the `bc-<x>` contact tag, not a UTM.
23
+ // `--raw-campaign` bypasses normalisation entirely when you genuinely need it.
24
+
25
+ export const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
26
+
27
+ const MONTH_ALIASES = new Map([
28
+ ["sept", "Sep"], ["january", "Jan"], ["february", "Feb"], ["march", "Mar"],
29
+ ["april", "Apr"], ["june", "Jun"], ["july", "Jul"], ["august", "Aug"],
30
+ ["september", "Sep"], ["october", "Oct"], ["november", "Nov"], ["december", "Dec"],
31
+ ]);
32
+
33
+ /** `9Sep` for the given Date (default: today, local time). */
34
+ export function dateToken(d = new Date()) {
35
+ return `${d.getDate()}${MONTHS[d.getMonth()]}`;
36
+ }
37
+
38
+ /**
39
+ * Accepts `9Sep`, `09Sep`, `9Sept`, `9 September`, `2026-09-09`, `09/09/2026`.
40
+ * Returns a canonical `9Sep`, or null if it is not a date at all.
41
+ */
42
+ export function parseDateToken(input) {
43
+ const raw = String(input ?? "").trim();
44
+ if (!raw) return null;
45
+ const iso = raw.match(/^(\d{4})-(\d{2})-(\d{2})$/);
46
+ if (iso) {
47
+ const m = Number(iso[2]);
48
+ if (m < 1 || m > 12) return null;
49
+ return `${Number(iso[3])}${MONTHS[m - 1]}`;
50
+ }
51
+ const slash = raw.match(/^(\d{1,2})[/](\d{1,2})[/](\d{2,4})$/); // D/M/YYYY (SA order)
52
+ if (slash) {
53
+ const m = Number(slash[2]);
54
+ if (m < 1 || m > 12) return null;
55
+ return `${Number(slash[1])}${MONTHS[m - 1]}`;
56
+ }
57
+ const dm = raw.match(/^(\d{1,2})\s*([A-Za-z]+)$/);
58
+ if (dm) {
59
+ const day = Number(dm[1]);
60
+ if (day < 1 || day > 31) return null;
61
+ const mon = canonicalMonth(dm[2]);
62
+ return mon ? `${day}${mon}` : null;
63
+ }
64
+ return null;
65
+ }
66
+
67
+ // Longest-first alternation, so `10JunFathersDay` splits as Jun|FathersDay and
68
+ // not as the 9-letter non-month "JunFathers" (which a greedy [A-Za-z]{3,9} does,
69
+ // silently leaving the whole tag title-side and prefixing TODAY's date on top).
70
+ const MONTH_ALT = [...MONTH_ALIASES.keys(), ...MONTHS.map((m) => m.toLowerCase())]
71
+ .sort((a, b) => b.length - a.length)
72
+ .join("|");
73
+ const LEAD_RE = new RegExp(`^(\\d{1,2})\\s*(${MONTH_ALT})([_\\-\\s]*)(.*)$`, "i");
74
+
75
+ function canonicalMonth(word) {
76
+ const w = String(word || "").toLowerCase();
77
+ if (MONTH_ALIASES.has(w)) return MONTH_ALIASES.get(w);
78
+ const hit = MONTHS.find((m) => m.toLowerCase() === w);
79
+ return hit || null;
80
+ }
81
+
82
+ /** "Spring Promotion" / "spring-promotion" / "spring_promotion" → "SpringPromotion". */
83
+ export function pascal(input) {
84
+ return String(input ?? "")
85
+ .replace(/[^A-Za-z0-9]+/g, " ")
86
+ .trim()
87
+ .split(/\s+/)
88
+ .filter(Boolean)
89
+ .map((w) => (/^[A-Z0-9]+$/.test(w) ? w : w[0].toUpperCase() + w.slice(1)))
90
+ .join("");
91
+ }
92
+
93
+ export const CAMPAIGN_RE = /^\d{1,2}(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)_[A-Za-z0-9]+$/;
94
+
95
+ export function isConforming(value) {
96
+ return CAMPAIGN_RE.test(String(value ?? ""));
97
+ }
98
+
99
+ /**
100
+ * Resolve whatever the operator typed into `Date_Campaign`.
101
+ *
102
+ * @param {string} input what they passed to --campaign
103
+ * @param {object} [opts]
104
+ * @param {string} [opts.date] --date override (any parseDateToken form)
105
+ * @param {Date} [opts.now] injectable clock (tests)
106
+ * @returns {{value:string, changed:boolean, notes:string[], error?:string}}
107
+ */
108
+ export function resolveCampaign(input, opts = {}) {
109
+ const notes = [];
110
+ const original = String(input ?? "").trim();
111
+ if (!original) return { value: "", changed: false, notes, error: "campaign is empty" };
112
+
113
+ let overrideDate = null;
114
+ if (opts.date) {
115
+ overrideDate = parseDateToken(opts.date);
116
+ if (!overrideDate) {
117
+ return { value: original, changed: false, notes, error: `--date "${opts.date}" is not a date I understand (try 9Sep, 2026-09-09 or 9/9/2026)` };
118
+ }
119
+ }
120
+
121
+ // Split a leading date off the front, however it was written / fused.
122
+ let datePart = null;
123
+ let rest = original;
124
+ const lead = original.match(LEAD_RE);
125
+ if (lead) {
126
+ const mon = canonicalMonth(lead[2]);
127
+ if (mon) {
128
+ datePart = `${Number(lead[1])}${mon}`;
129
+ rest = lead[4];
130
+ if (lead[2] !== mon) notes.push(`month "${lead[2]}" → "${mon}"`);
131
+ if (!lead[3].includes("_")) notes.push("inserted the missing underscore after the date");
132
+ }
133
+ }
134
+
135
+ if (overrideDate) {
136
+ if (datePart && datePart !== overrideDate) notes.push(`date ${datePart} → ${overrideDate} (--date)`);
137
+ datePart = overrideDate;
138
+ }
139
+ if (!datePart) {
140
+ datePart = dateToken(opts.now || new Date());
141
+ notes.push(`prefixed today's send date "${datePart}"`);
142
+ }
143
+
144
+ const titlePart = pascal(rest);
145
+ if (!titlePart) {
146
+ return { value: original, changed: false, notes, error: `no campaign title found in "${original}" — expected something like "Spring Promotion"` };
147
+ }
148
+ if (titlePart !== rest) notes.push(`title "${rest}" → "${titlePart}"`);
149
+
150
+ const value = `${datePart}_${titlePart}`;
151
+ return { value, changed: value !== original, notes };
152
+ }
153
+
154
+ /** utm_content is per-LINK (which button / which product), so no date is imposed. */
155
+ export function resolveContent(input) {
156
+ const original = String(input ?? "").trim();
157
+ if (!original) return { value: "", changed: false, notes: [] };
158
+ const value = pascal(original);
159
+ return {
160
+ value: value || original,
161
+ changed: !!value && value !== original,
162
+ notes: value && value !== original ? [`content "${original}" → "${value}"`] : [],
163
+ };
164
+ }
@@ -0,0 +1,76 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { resolveCampaign, resolveContent, dateToken, parseDateToken, isConforming, pascal } from "./campaign-naming.js";
4
+
5
+ const NOW = new Date(2026, 8, 9); // 9 Sep 2026, local
6
+
7
+ test("Matt's worked example", () => {
8
+ const r = resolveCampaign("Spring Promotion", { now: NOW });
9
+ assert.equal(r.value, "9Sep_SpringPromotion");
10
+ assert.ok(r.changed);
11
+ });
12
+
13
+ test("already correct is left alone and reports no change", () => {
14
+ const r = resolveCampaign("9Sep_SpringPromotion", { now: NOW });
15
+ assert.equal(r.value, "9Sep_SpringPromotion");
16
+ assert.equal(r.changed, false);
17
+ assert.deepEqual(r.notes, []);
18
+ });
19
+
20
+ test("the house habit: fused date, no underscore (924 tags in 12 months)", () => {
21
+ assert.equal(resolveCampaign("10JunFathersDay", { now: NOW }).value, "10Jun_FathersDay");
22
+ });
23
+
24
+ test("Sept is corrected to Sep, and the date is kept", () => {
25
+ const r = resolveCampaign("5Sept_BraaiDayOffer", { now: NOW });
26
+ assert.equal(r.value, "5Sep_BraaiDayOffer");
27
+ assert.ok(r.notes.some((n) => n.includes("Sept")));
28
+ });
29
+
30
+ test("leading zero is dropped", () => {
31
+ assert.equal(resolveCampaign("09Sep_Foo", { now: NOW }).value, "9Sep_Foo");
32
+ });
33
+
34
+ test("--date overrides a send scheduled for another day", () => {
35
+ assert.equal(resolveCampaign("Heritage Day", { date: "2026-09-24", now: NOW }).value, "24Sep_HeritageDay");
36
+ assert.equal(resolveCampaign("24Sep_HeritageDay", { date: "25Sep", now: NOW }).value, "25Sep_HeritageDay");
37
+ });
38
+
39
+ test("--date accepts SA d/m/yyyy and long month names", () => {
40
+ assert.equal(parseDateToken("24/09/2026"), "24Sep");
41
+ assert.equal(parseDateToken("24 September"), "24Sep");
42
+ assert.equal(parseDateToken("2026-09-24"), "24Sep");
43
+ assert.equal(parseDateToken("banana"), null);
44
+ assert.equal(parseDateToken("24/13/2026"), null);
45
+ });
46
+
47
+ test("separators and casing all collapse to PascalCase", () => {
48
+ assert.equal(pascal("spring-promotion"), "SpringPromotion");
49
+ assert.equal(pascal("spring_promotion"), "SpringPromotion");
50
+ assert.equal(pascal(" spring promotion "), "SpringPromotion");
51
+ assert.equal(pascal("R500 promo"), "R500Promo");
52
+ });
53
+
54
+ test("an acronym keeps its case", () => {
55
+ assert.equal(resolveCampaign("CTWA Launch", { now: NOW }).value, "9Sep_CTWALaunch");
56
+ });
57
+
58
+ test("empty and title-less inputs error rather than minting rubbish", () => {
59
+ assert.ok(resolveCampaign("", { now: NOW }).error);
60
+ assert.ok(resolveCampaign("9Sep", { now: NOW }).error);
61
+ assert.ok(resolveCampaign("Spring", { date: "nonsense", now: NOW }).error);
62
+ });
63
+
64
+ test("utm_content is per-link and gets no date", () => {
65
+ assert.equal(resolveContent("View More").value, "ViewMore");
66
+ assert.equal(resolveContent("").value, "");
67
+ });
68
+
69
+ test("dateToken / isConforming", () => {
70
+ assert.equal(dateToken(NOW), "9Sep");
71
+ assert.equal(dateToken(new Date(2026, 11, 25)), "25Dec");
72
+ assert.ok(isConforming("9Sep_SpringPromotion"));
73
+ assert.ok(!isConforming("9Sept_SpringPromotion"));
74
+ assert.ok(!isConforming("SpringPromotion"));
75
+ assert.ok(!isConforming("9Sep_Spring_Promotion"));
76
+ });
@@ -45,11 +45,21 @@ async function clearConv(orgId, agent) {
45
45
  return http.post("agent-test", { action: "clear", organization_id: orgId, agent_id: agent });
46
46
  }
47
47
 
48
+ // A bare string is ONE needle, not a list of characters. Iterating a string
49
+ // with for..of yields its characters, so a pack written as
50
+ // `"expect": "fund rules"` used to report ten single-letter checks that all
51
+ // trivially passed (and, for expectNot, all trivially failed) — a wrong
52
+ // pass/fail signal that still looked like a real result.
53
+ function needles(v) {
54
+ if (v == null) return [];
55
+ return Array.isArray(v) ? v : [v];
56
+ }
57
+
48
58
  function checkExpectations(turn, replyText) {
49
59
  const results = [];
50
60
  const hay = (replyText || "").toLowerCase();
51
- for (const e of turn.expect || []) results.push({ kind: "expect", needle: e, ok: hay.includes(String(e).toLowerCase()) });
52
- for (const e of turn.expectNot || []) results.push({ kind: "expectNot", needle: e, ok: !hay.includes(String(e).toLowerCase()) });
61
+ for (const e of needles(turn.expect)) results.push({ kind: "expect", needle: e, ok: hay.includes(String(e).toLowerCase()) });
62
+ for (const e of needles(turn.expectNot)) results.push({ kind: "expectNot", needle: e, ok: !hay.includes(String(e).toLowerCase()) });
53
63
  return results;
54
64
  }
55
65
 
@@ -5,16 +5,26 @@
5
5
  // in the dialog: utm_source=whatsapp, utm_medium=whatsapp_paid fixed, campaign +
6
6
  // content yours.
7
7
  //
8
- // flowiq links shorten <org> --url <u> [--url <u2>…] --campaign 13Aug_Seeds --content 13Aug_Seeds
9
- // flowiq links shorten <org> --file ./message.txt --campaign X --content X --domain linklnk.io
8
+ // flowiq links shorten <org> --url <u> [--url <u2>…] --campaign "Spring Promotion"
9
+ // flowiq links shorten <org> --file ./message.txt --campaign X --content ViewMore --domain linklnk.io
10
10
  // flowiq links list <org> [--campaign X] [--limit 25]
11
11
  //
12
+ // NAMING — you do not have to get the tag right yourself. The house convention
13
+ // is `Date_Campaign` (`9Sep_SpringPromotion`: the date the broadcast GOES OUT,
14
+ // then the campaign title in PascalCase), and --campaign is normalised to it:
15
+ // today's date is prefixed when you omit one, `Sept`→`Sep`, a fused
16
+ // `10JunFathersDay` gains its underscore, and spaces/hyphens become PascalCase.
17
+ // The resolved tag is printed before anything is minted (shorten is dry-run by
18
+ // default). `--date <9Sep|2026-09-24|24/9/2026>` for a send going out later;
19
+ // `--raw-campaign` to bypass normalisation entirely.
20
+ //
12
21
  // Dry-run FIRST (`--dry-run`) on anything going into a real campaign: it shows
13
22
  // the exact destination URL each short link will carry, and whether an identical
14
23
  // row already exists (so you can see you are about to reuse an OLD campaign tag).
15
24
 
16
25
  import fs from "node:fs";
17
26
  import { http } from "../http.js";
27
+ import { resolveCampaign, resolveContent, isConforming } from "../campaign-naming.js";
18
28
 
19
29
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
20
30
  const URL_PATTERN = /(https?:\/\/[^\s\)]+)/g;
@@ -59,14 +69,50 @@ export async function shorten(orgId, opts = {}) {
59
69
 
60
70
  const dryRun = opts.dryRun !== false && opts.commit !== true;
61
71
 
72
+ // ── Campaign naming (Date_Campaign) ───────────────────────────────────────
73
+ // Normalise what was typed into the house convention so staff do not have to
74
+ // carry it in their heads. --raw-campaign opts out; the agent's own `ai_<org>`
75
+ // links are minted server-side and never come through here.
76
+ let campaign = opts.campaign;
77
+ let content = opts.content;
78
+ const namingNotes = [];
79
+ if (campaign && !opts.rawCampaign) {
80
+ const r = resolveCampaign(campaign, { date: opts.date });
81
+ if (r.error) {
82
+ console.error(`Error: ${r.error}`);
83
+ console.error(`Convention is Date_Campaign, e.g. --campaign "Spring Promotion" → 9Sep_SpringPromotion.`);
84
+ console.error(`Pass --raw-campaign to use "${campaign}" exactly as typed.`);
85
+ process.exit(1);
86
+ }
87
+ if (r.changed) namingNotes.push(`campaign "${campaign}" → ${r.value}`, ...r.notes.map((n) => ` · ${n}`));
88
+ campaign = r.value;
89
+ } else if (campaign && opts.rawCampaign && !isConforming(campaign)) {
90
+ namingNotes.push(`campaign "${campaign}" used raw (--raw-campaign) — does not match Date_Campaign`);
91
+ }
92
+ // utm_content is per-LINK (which button / which product). Default it to the
93
+ // campaign, which is what the paired-flags API expects and what most sends do.
94
+ if (campaign && !content) {
95
+ content = campaign;
96
+ namingNotes.push(`content defaulted to ${content} (pass --content for a per-link tag, e.g. ViewMore)`);
97
+ } else if (content && !opts.rawCampaign) {
98
+ const rc = resolveContent(content);
99
+ if (rc.changed) namingNotes.push(`content "${content}" → ${rc.value}`);
100
+ content = rc.value;
101
+ }
102
+ if (namingNotes.length && !opts.json) {
103
+ console.log("Naming (Date_Campaign):");
104
+ for (const n of namingNotes) console.log(` ${n}`);
105
+ console.log("");
106
+ }
107
+
62
108
  let resp;
63
109
  try {
64
110
  resp = await http.post("links", {
65
111
  organization_id: orgId,
66
112
  action: "shorten",
67
113
  urls: unique,
68
- campaign: opts.campaign,
69
- content: opts.content,
114
+ campaign,
115
+ content,
70
116
  domain: opts.domain,
71
117
  dry_run: dryRun,
72
118
  });
@@ -154,3 +154,204 @@ export async function list() {
154
154
  }
155
155
  }
156
156
  }
157
+
158
+ // ── show ────────────────────────────────────────────────────────────────────
159
+ // Render ONE template row — including a DRAFT, which nothing else in the CLI
160
+ // can see. `templates pull` reads Meta and a draft never reaches Meta;
161
+ // api/cli/_introspect.js also starts from Meta by name, so it is blind too.
162
+ // Until this existed, reviewing a draft meant the dialog or raw SQL.
163
+ const MONTH_RE = /\{\{(\d+)\}\}/g;
164
+
165
+ function substitute(text, examples) {
166
+ return String(text ?? "").replace(MONTH_RE, (m, n) => {
167
+ const v = examples?.[n];
168
+ return v ? String(v) : m;
169
+ });
170
+ }
171
+
172
+ function indent(text, pad = " ") {
173
+ return String(text ?? "").split("\n").map((l) => pad + l).join("\n");
174
+ }
175
+
176
+ /** Cheap structural checks — the ones that are free while rendering. */
177
+ function lintDraft(d) {
178
+ const warn = [];
179
+ const body = (d.components || []).find((c) => c.type === "BODY");
180
+
181
+ if (body?.text) {
182
+ const nums = [...new Set([...body.text.matchAll(/\{\{(\d+)\}\}/g)].map((m) => Number(m[1])))].sort((a, b) => a - b);
183
+ if (nums.some((n, i) => n !== i + 1)) {
184
+ warn.push(`BODY variables are not sequential from {{1}} — found ${nums.map((n) => `{{${n}}}`).join(", ")}. Meta rejects this as "BODY is missing expected field(s) (example)".`);
185
+ }
186
+ for (const n of nums) {
187
+ if (!d.parameterExamples?.[n]) {
188
+ warn.push(`BODY {{${n}}} has no example — Meta's reviewers will see the placeholder text instead of a real value.`);
189
+ }
190
+ }
191
+ // WhatsApp formatting: a marker pair only renders when neither marker sits
192
+ // against whitespace on the inside. `👇_ Swipe …below._` shipped in this very
193
+ // template and renders as literal underscores. Deliberately only checked on
194
+ // lines that look like a PAIR (>=2 of the same marker), so a lone `*` used
195
+ // as a bullet or a `~` in a URL is not flagged.
196
+ for (const [mark, name] of [["_", "an italic"], ["*", "a bold"], ["~", "a strikethrough"]]) {
197
+ for (const line of body.text.split("\n")) {
198
+ // Iterate CODE POINTS, not UTF-16 units: `👇` is a surrogate pair, so
199
+ // mixing [...line] positions with line[i] lookups reads the wrong char
200
+ // and the check silently never fires (which is how 👇_ shipped).
201
+ const chars = [...line];
202
+ const idxs = chars.reduce((a, ch, i) => (ch === mark ? (a.push(i), a) : a), []);
203
+ if (idxs.length < 2) continue;
204
+ const opensBad = /\s/.test(chars[idxs[0] + 1] || "");
205
+ const closesBad = /\s/.test(chars[idxs[idxs.length - 1] - 1] || "");
206
+ if (opensBad || closesBad) {
207
+ warn.push(`BODY line has ${name} marker "${mark}" against a space (${opensBad ? "after the opening" : "before the closing"} ${mark}) — WhatsApp will not render it and the customer sees the literal ${mark}: ${JSON.stringify(line.trim().slice(0, 70))}`);
208
+ break;
209
+ }
210
+ }
211
+ }
212
+ if (/\s$/.test(body.text)) warn.push("BODY ends with trailing whitespace/newline.");
213
+ if (body.text.length > 1024) warn.push(`BODY is ${body.text.length} chars (Meta limit 1024).`);
214
+ }
215
+
216
+ const cards = Array.isArray(d.carouselCards) ? d.carouselCards : [];
217
+ if (d.templateMode === "carousel") {
218
+ if (cards.length < 2 || cards.length > 10) warn.push(`Carousel has ${cards.length} card(s) — Meta requires 2-10.`);
219
+ const urlVars = new Set();
220
+ cards.forEach((c, i) => {
221
+ if (!c.media?.media_url && !c.media?.asset_handle) warn.push(`Card ${i + 1} has no media.`);
222
+ if ((c.body || "").length > 160) warn.push(`Card ${i + 1} body is ${c.body.length} chars (Meta limit 160).`);
223
+ for (const b of c.buttons || []) {
224
+ if (b.type === "URL" && b.url) {
225
+ const vars = [...b.url.matchAll(/\{\{(\d+)\}\}/g)].map((m) => m[1]);
226
+ vars.forEach((v) => urlVars.add(v));
227
+ if (vars.length > 1) warn.push(`Card ${i + 1} button "${b.text}" has ${vars.length} URL variables — Meta supports exactly 1.`);
228
+ if (vars.length === 1 && vars[0] !== "1") {
229
+ warn.push(`Card ${i + 1} button "${b.text}" uses {{${vars[0]}}} — each card's URL variable must be {{1}} (Meta numbers card params per card). This card's link will break at send.`);
230
+ }
231
+ }
232
+ }
233
+ });
234
+ const shapes = new Set(cards.map((c) => `${c.headerFormat}|${(c.buttons || []).map((b) => b.type).join(",")}`));
235
+ if (shapes.size > 1) warn.push(`Cards do not share one shape (${[...shapes].join(" vs ")}) — Meta requires every card to have the same header format and button set.`);
236
+ }
237
+ return warn;
238
+ }
239
+
240
+ function renderDraft(d, opts) {
241
+ console.log(` mode: ${d.templateMode || "standard"}`);
242
+ console.log(` language: ${d.language || "(unset)"} parameters: ${d.parameterFormat || "POSITIONAL"}`);
243
+ const ex = d.parameterExamples || {};
244
+ const exKeys = Object.keys(ex);
245
+ if (exKeys.length) {
246
+ console.log(` examples: ${exKeys.map((k) => `{{${k}}} = ${ex[k] === "" ? "(EMPTY)" : JSON.stringify(ex[k])}`).join(" ")}`);
247
+ }
248
+
249
+ for (const c of d.components || []) {
250
+ console.log(`\n ${c.type}${c.format ? ` (${c.format})` : ""}`);
251
+ if (c.text) {
252
+ console.log(indent(substitute(c.text, ex)));
253
+ if (opts.raw) { console.log("\n raw:"); console.log(indent(JSON.stringify(c.text))); }
254
+ }
255
+ for (const b of c.buttons || []) {
256
+ console.log(` [${b.type}] ${b.text}${b.url ? ` → ${b.url}` : ""}${b.phone_number ? ` → ${b.phone_number}` : ""}`);
257
+ }
258
+ }
259
+
260
+ const cards = Array.isArray(d.carouselCards) ? d.carouselCards : [];
261
+ if (cards.length) {
262
+ console.log(`\n CAROUSEL — ${cards.length} card(s)`);
263
+ cards.forEach((c, i) => {
264
+ console.log(`\n ── card ${i + 1} ──`);
265
+ console.log(` header: ${c.headerFormat || "?"}`);
266
+ if (c.media) {
267
+ console.log(` media: ${c.media.media_url || "(none)"}`);
268
+ if (c.media.original_filename) console.log(` file: ${c.media.original_filename}${c.media.file_type ? ` (${c.media.file_type})` : ""}`);
269
+ console.log(` asset handle: ${c.media.asset_handle ? "present" : "MISSING"}`);
270
+ }
271
+ if (c.body) console.log(` body: ${substitute(c.body, ex)}`);
272
+ for (const b of c.buttons || []) {
273
+ console.log(` [${b.type}] ${b.text}${b.url ? ` → ${b.url}` : ""}${b.example ? ` example: ${b.example}` : ""}`);
274
+ }
275
+ });
276
+ }
277
+ }
278
+
279
+ export async function show(orgId, name, opts = {}) {
280
+ if (!UUID_RE.test(orgId)) {
281
+ console.error(`Error: "${orgId}" is not a valid organization UUID. Find it with: flowiq org list <name>`);
282
+ process.exit(1);
283
+ }
284
+ if (!name) {
285
+ console.error("Error: pass the template name, e.g. flowiq templates show <org> heritage_day_v2");
286
+ process.exit(1);
287
+ }
288
+
289
+ let resp;
290
+ try {
291
+ resp = await http.get("meta-templates", { organization_id: orgId, name, full: "1" });
292
+ } catch (e) {
293
+ console.error(`Show failed: ${e.message}`);
294
+ process.exit(1);
295
+ }
296
+
297
+ const rows = resp.templates || [];
298
+ if (!rows.length) {
299
+ console.error(`No template on this org matching "${name}". List them with: flowiq templates status ${orgId}`);
300
+ process.exit(1);
301
+ }
302
+ // Prefer an exact name match; otherwise if the substring is ambiguous, say so.
303
+ let row = rows.find((r) => r.template_name === name);
304
+ if (!row) {
305
+ if (rows.length > 1) {
306
+ console.error(`"${name}" matches ${rows.length} templates — name one exactly:`);
307
+ for (const r of rows) console.error(` ${r.template_name} [${r.status || "no status"}]`);
308
+ process.exit(1);
309
+ }
310
+ row = rows[0];
311
+ }
312
+
313
+ if (opts.json) { console.log(JSON.stringify(row, null, 2)); return; }
314
+
315
+ // Version skew: `template_data` only comes back when the server understands
316
+ // ?full=1. An older api/cli deploy answers the row without it, and rendering
317
+ // that silently produces a confident, EMPTY draft — so refuse instead.
318
+ if (!("template_data" in row)) {
319
+ console.error(`The API did not return this template's content.`);
320
+ console.error(`api/cli/meta-templates.js on the server is older than this CLI (it needs the ?full=1 branch, shipped with CLI v0.6.7).`);
321
+ console.error(`Everything else still works — retry once the Vercel deploy of origin/main has landed.`);
322
+ process.exit(1);
323
+ }
324
+ const td = row.template_data || {};
325
+ const isDraft = td.is_draft === true || row.status === "DRAFT";
326
+ console.log(`${row.template_name} [${row.status || "no status"}]${row.category ? ` ${row.category}` : ""}`);
327
+ console.log(` org: ${resp.organization_id}`);
328
+ console.log(` created: ${row.created_at || "?"}${row.updated_at && row.updated_at !== row.created_at ? ` updated: ${row.updated_at}` : ""}`);
329
+
330
+ if (isDraft) {
331
+ const d = td.draft || {};
332
+ console.log(` source: DRAFT (never submitted to Meta — this content exists only here)`);
333
+ renderDraft(d, opts);
334
+ const warn = lintDraft(d);
335
+ console.log(`\n Checks`);
336
+ if (!warn.length) {
337
+ console.log(` ✓ nothing obvious — these are structural checks only; Meta's review is still the authority.`);
338
+ } else {
339
+ for (const w of warn) console.log(` ⚠ ${w}`);
340
+ }
341
+ console.log(`\n This is a draft: edit it in Broadcasts → Templates, then submit with`);
342
+ console.log(` flowiq templates create ${orgId} --request-file <file> (submitting is irreversible — it burns the name).`);
343
+ return;
344
+ }
345
+
346
+ // A LIVE row's template_data is only the send-time MAPPING; the structure
347
+ // (body text, buttons, cards) lives at Meta, so point at the tool that reads it.
348
+ console.log(` source: live row — template_data here is the send-time mapping, not the message structure`);
349
+ if (td.meta_template_id) console.log(` meta id: ${td.meta_template_id}`);
350
+ if (td.meta_status) console.log(` meta: ${td.meta_status}${td.meta_category ? ` / ${td.meta_category}` : ""}`);
351
+ if (td.parameter_format) console.log(` params: ${td.parameter_format}`);
352
+ if (td.header) console.log(` header: ${JSON.stringify(td.header)}`);
353
+ if (td.body_params && Object.keys(td.body_params).length) console.log(` body: ${JSON.stringify(td.body_params)}`);
354
+ if (td.button_params && Object.keys(td.button_params).length) console.log(` buttons: ${JSON.stringify(td.button_params)}`);
355
+ if (td.carousel?.cards) console.log(` carousel: ${td.carousel.cards.length} card(s) with stored header media`);
356
+ console.log(`\n For the message structure as Meta holds it: flowiq templates pull ${orgId}`);
357
+ }
package/src/index.js CHANGED
@@ -269,7 +269,7 @@ export function run(argv) {
269
269
  // templates (read WhatsApp templates from Meta; create via /cli/meta-templates)
270
270
  const templates = program.command("templates")
271
271
  .alias("tpl")
272
- .description("Read an org's WhatsApp templates from Meta (pull/list); create + submit to Meta (create/status)");
272
+ .description("Read an org's WhatsApp templates (pull/list/show — show also reads DRAFTS); create + submit to Meta (create/status)");
273
273
  templates.command("pull <organization_id>")
274
274
  .description("Fetch every live WhatsApp template from Meta into a local JSON snapshot")
275
275
  .action((orgId) => templatesCmd.pull(orgId));
@@ -284,6 +284,11 @@ export function run(argv) {
284
284
  .description("List the org's template rows (name + status) to poll Meta approval")
285
285
  .option("--name <substr>", "filter by template name substring")
286
286
  .action((orgId, opts) => templatesCmd.status(orgId, opts));
287
+ templates.command("show <organization_id> <name>")
288
+ .description("Render ONE template row in full — including a DRAFT, which `pull` cannot see (drafts never reach Meta)")
289
+ .option("--raw", "also print the raw body text (escaped), so invisible whitespace is visible")
290
+ .option("--json", "raw JSON row output")
291
+ .action((orgId, name, opts) => templatesCmd.show(orgId, name, opts));
287
292
 
288
293
  // org (read-only org summary for the prompt-builder skill, creds stripped)
289
294
  const org = program.command("org").description("Org info (read) + create a new organization");
@@ -625,8 +630,10 @@ export function run(argv) {
625
630
  .description("Shorten one or more URLs, tagging each with utm_campaign/utm_content")
626
631
  .option("--url <url>", "URL to shorten (repeatable)", (v, acc) => (acc || []).concat([v]), [])
627
632
  .option("--file <path>", "shorten every URL found in this text file")
628
- .option("--campaign <value>", "utm_campaign (e.g. 13Aug_Seeds) — must be paired with --content")
629
- .option("--content <value>", "utm_content — must be paired with --campaign")
633
+ .option("--campaign <value>", "campaign title — normalised to the house Date_Campaign convention (\"Spring Promotion\" → 9Sep_SpringPromotion)")
634
+ .option("--content <value>", "utm_content, the per-link tag (e.g. ViewMore); defaults to the campaign")
635
+ .option("--date <value>", "send date for the campaign tag when it is not today: 9Sep | 2026-09-24 | 24/9/2026")
636
+ .option("--raw-campaign", "use --campaign exactly as typed, bypassing the Date_Campaign normalisation")
630
637
  .option("--domain <host>", "short domain: chatcart.io (default) | linklnk.io | yapi.store")
631
638
  .option("--commit", "actually mint the links (omit = dry-run preview)")
632
639
  .option("--json", "raw JSON output")