@wrangled/cli 0.3.11 → 0.3.13

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
@@ -166,6 +166,14 @@ 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
+ ## Ad tags
170
+
171
+ Ads not made here (built by hand or by an agency, names in no pattern) get the marketing grammar's tags (docs/marketing-grammar.md) by Meta ad id. \`${cli} ads tags\` lists them (\`--ad <id>\`, \`--status proposed|confirmed|rejected|replaced\`, \`--limit <n>\`, \`--json\`); by default the live ones, proposed and confirmed. A proposed tag is usable at once, but not yet confirmed; only an Owner or Admin confirms, rejects or changes tags, in the app.
172
+
173
+ To suggest tags, write them as JSON and run \`${cli} ads tag --file tags.json\` (or pipe it on stdin), up to 500 ads at a time: \`{"ads": [{"ad_id": "120214567890123", "tags": {"angle": "discovery", "desire": "money", "hook_spot": "spoken", "hook_text": "$29,000 is the smallest lead", "hook_kind": "big_win", "format": "video", "style": "presenter", "tag_reasons": {"worry": "none"}}, "confidence": "medium", "evidence": {"ad_name": "County lead v2", "transcript_start": "$29,000 is the smallest lead"}}]}\`. The tag fields are \`marketing\`, \`offer_key\`, \`landing_funnel_key\`, \`landing_step_key\`, \`persona\`, \`awareness\`, \`concept\`, \`angle\`, \`desire\`, \`hook_spot\`, \`hook_text\`, \`hook_kind\`, \`hook2_spot\`, \`hook2_kind\`, \`format\`, \`style\`, \`speaker\`, \`cta\`, \`proof\` and \`worry\`; leave out what you did not judge. A part with no value goes in \`tag_reasons\` as \`none\` (nothing on its list applies) or \`unclear\` (too little to judge, never \`high\` confidence). Confidence is \`high\`, \`medium\` or \`low\`. A value off its list is refused with the allowed values. New tags replace an ad's proposed ones, which stay as \`replaced\`; an ad whose tags a person confirmed is kept as it is.
174
+
175
+ For what worked, query the \`ad_performance_tagged\` view: every Meta ad's daily results with its live tags next to them, \`tags_source\` (\`ad_tags\`, or null when untagged) and \`tags_status\`.
176
+
169
177
  ## What customers keep saying
170
178
 
171
179
  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.
@@ -272,6 +280,7 @@ ${cli} funnels evidence prices paid, buyer paths, GoHighLevel funnels, calenda
272
280
  ${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
273
281
  ${cli} facts [--kind <kind>] [--subject <type>[:<ref>]] [--status <status>] [--json] | facts show <id> settled facts about the data, to treat as true
274
282
  ${cli} facts propose --file <fact.json> suggest a fact for a person to confirm
283
+ ${cli} ads tags [--ad <id>] [--status <status>] [--limit <n>] | ads tag --file <tags.json> the marketing tags of ads not made here, and suggesting them
275
284
  ${cli} voice [--kind opener|objection|phrase] [--limit <n>] [--json] what customers keep saying, grouped and counted, with real quotes
276
285
  ${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
286
  ${cli} voice suggest competitor|subreddit|search_phrase "<value>" [--note <why>] suggest a competitor, subreddit or Reddit search phrase for a person to approve
package/dist/main.js CHANGED
@@ -1027,12 +1027,16 @@ const commands = {
1027
1027
  const counts = options.countsFrom === undefined || options.countsFrom === "fix" ? "it counts for past dates too, as a fix" : `it counts from ${options.countsFrom}, as a change`;
1028
1028
  console.log(saved.applied
1029
1029
  ? `Map version ${saved.version.version} is live${based}: ${counts}. A person can undo it in the app.`
1030
- : `Saved version ${saved.version.version}${based}. It is on hold: it counts nothing until every step can be counted.`);
1030
+ : saved.held === "undone_by_person"
1031
+ ? `Saved version ${saved.version.version}${based}. It is on hold: it counts nothing until a person puts it live.`
1032
+ : `Saved version ${saved.version.version}${based}. It is on hold: it counts nothing until every step can be counted.`);
1031
1033
  if (saved.reapplied.length > 0)
1032
1034
  console.log(`\nAnswers put back into the map: ${saved.reapplied.join(", ")}`);
1033
1035
  if (saved.asked.length > 0)
1034
1036
  console.log(`\nQuestions open for a person, biggest money at stake first: ${saved.asked.join(", ")}`);
1035
- const [left, warnings] = [saved.warnings.filter((w) => w.message.startsWith("question ")), saved.warnings.filter((w) => !w.message.startsWith("question "))];
1037
+ const [left, warnings] = [saved.warnings.filter((w) => w.message.startsWith("question ")), saved.warnings.filter((w) => !w.message.startsWith("question ") && w.path !== "")];
1038
+ for (const w of saved.warnings.filter((w) => w.path === ""))
1039
+ console.log(`\n${w.message}`);
1036
1040
  if (warnings.length > 0)
1037
1041
  console.log(`\n${saved.applied ? "Not in synced data yet" : "To fill in"}:\n${warnings.map((w) => `- ${w.path}: ${w.message}`).join("\n")}`);
1038
1042
  if (left.length > 0)
@@ -1117,6 +1121,39 @@ const commands = {
1117
1121
  console.log(renderTable(VOICE_COLUMNS, clusters.map(voiceRow)));
1118
1122
  },
1119
1123
  },
1124
+ "ads tags": {
1125
+ usage: "ads tags [--ad <id>] [--status <status>] [--limit <n>] [--json] the marketing tags of ads not made here, by Meta ad id",
1126
+ async run(args, options) {
1127
+ if (args.length > 0)
1128
+ throw new CliError("Usage: ads tags [--ad <id>] [--status <status>] [--limit <n>] [--json]", exitCodes.usage);
1129
+ const query = new URLSearchParams();
1130
+ if (options.ad)
1131
+ query.set("ad", options.ad);
1132
+ if (options.status)
1133
+ query.set("status", options.status);
1134
+ if (options.limit)
1135
+ query.set("limit", options.limit);
1136
+ const { tags } = await api(await resolveConnection(options.apiUrl), `${AD_TAGS_PATH}${query.size ? `?${query}` : ""}`);
1137
+ if (options.format === "json")
1138
+ return printJson({ tags });
1139
+ if (tags.length === 0)
1140
+ return console.log("No ad tags.");
1141
+ console.log(renderTable(AD_TAG_COLUMNS, tags.map(adTagRow)));
1142
+ },
1143
+ },
1144
+ "ads tag": {
1145
+ usage: "ads tag --file <tags.json> | < tags.json suggest tags for up to 500 ads; a person confirms them in the app",
1146
+ async run(args, options) {
1147
+ if (args.length > 0)
1148
+ throw new CliError("Usage: ads tag --file <tags.json> | < tags.json", exitCodes.usage);
1149
+ const result = await api(await resolveConnection(options.apiUrl), AD_TAGS_PATH, await readToolInput(options));
1150
+ if (options.format === "json")
1151
+ return printJson(result);
1152
+ console.log(`Proposed tags for ${result.proposed.length} ${result.proposed.length === 1 ? "ad" : "ads"}. A person confirms or rejects them in the app.`);
1153
+ if (result.kept.length > 0)
1154
+ console.log(`Kept as they are, since a person confirmed their tags: ${result.kept.map((k) => k.adId).join(", ")}.`);
1155
+ },
1156
+ },
1120
1157
  "facts show": {
1121
1158
  usage: "facts show <id> one fact with its value and evidence",
1122
1159
  async run(args, options) {
@@ -1618,6 +1655,8 @@ function printReport(title, report, labels, numbers) {
1618
1655
  }
1619
1656
  const agentPath = (options) => `/api/v1/agents/${encodeURIComponent(options.agent)}`;
1620
1657
  const FACTS_PATH = "/api/v1/facts";
1658
+ const AD_TAGS_PATH = "/api/v1/ad-tags";
1659
+ const AD_TAG_COLUMNS = ["id", "ad", "status", "confidence", "offer", "persona", "angle", "hook", "format", "author"];
1621
1660
  const VOICE_LISTS_PATH = "/api/v1/voice-lists";
1622
1661
  const VOICE_SUGGESTIONS_PATH = "/api/v1/voice-lists/suggestions";
1623
1662
  const FACT_COLUMNS = ["id", "kind", "subject", "key", "status", "summary"];
@@ -1632,6 +1671,10 @@ function knowledgeBlock(r, i) {
1632
1671
  lines.push(` Caveat: ${r.caveat}`);
1633
1672
  return lines.join("\n");
1634
1673
  }
1674
+ function adTagRow(t) {
1675
+ const { offer_key, persona, angle, hook_kind, hook_spot, format, style } = t.tags;
1676
+ return { id: t.id, ad: t.adId, status: t.status, confidence: t.confidence, offer: offer_key, persona, angle, hook: [hook_kind, hook_spot].filter(Boolean).join(" in ") || null, format: [format, style].filter(Boolean).join(", ") || null, author: t.author };
1677
+ }
1635
1678
  function voiceRow(c) {
1636
1679
  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
1680
  }
@@ -1747,7 +1790,8 @@ Options:
1747
1790
  --from, --to <n> with content diff, the two versions to compare (default: the one before the current one, and the current one)
1748
1791
  --sort <column> how content search sorts: recent (default) or a content_scorecard number, such as click_rate or roas
1749
1792
  --order desc|asc content search's sort direction (default: desc, best or newest first)
1750
- --limit <n> how many results content search, creatives list, campaigns and voice return
1793
+ --limit <n> how many results content search, creatives list, campaigns, voice and ads tags return
1794
+ --ad <id> with ads tags, only this Meta ad's tags
1751
1795
  --max-seconds <n> stop a runner's run after this many seconds, if that comes before its deadline
1752
1796
  --workspace <slug> for worker promote and worker drop: the workspace whose hand they change
1753
1797
  -h, --help show this help
@@ -1777,6 +1821,7 @@ function parseOptions(argv) {
1777
1821
  wait: { type: "string", default: String(DEFAULT_WAIT_SECONDS) },
1778
1822
  status: { type: "string" },
1779
1823
  kind: { type: "string" },
1824
+ ad: { type: "string" },
1780
1825
  limit: { type: "string" },
1781
1826
  run: { type: "string" },
1782
1827
  lineage: { type: "string" },
@@ -1867,6 +1912,7 @@ function parseOptions(argv) {
1867
1912
  wait,
1868
1913
  status: values.status,
1869
1914
  kind: values.kind,
1915
+ ad: values.ad,
1870
1916
  limit: values.limit,
1871
1917
  run: values.run,
1872
1918
  lineage: values.lineage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrangled/cli",
3
- "version": "0.3.11",
3
+ "version": "0.3.13",
4
4
  "description": "Command-line access to your marketing data warehouse, for people and coding agents",
5
5
  "type": "module",
6
6
  "bin": {