@wrangled/cli 0.3.21 → 0.3.23

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
@@ -35,9 +35,10 @@ To read the whole library with your own tools, such as "every email we have" or
35
35
 
36
36
  ## Know-how library: Creative Cartel
37
37
 
38
- For what has worked for others, such as "how do members test new hooks" or "what do people say about advertorials", search the know-how library: \`${cli} knowledge search "<question>"\`. Today it holds Creative Cartel's library (\`creative-cartel\`), copied into this server every Monday afternoon. It works only when the key has library access (\`${cli} whoami\` says); without it the answer is \`knowledge_not_allowed\`. It finds snippets by their words and by their meaning, and returns them short, never whole documents: each at most 280 characters, with its title, link, kind, caveat, evidence and the day its library was copied in. Evidence \`reported member statement\` is a member's claim, not a checked result; weigh it that way and say so when you use it. The library writes no answer for you: read the snippets and decide. Use your own words: saving text that repeats 12 words in a row from anything in the library is refused with \`copies_library_text\`.
38
+ For what has worked for others, such as "how do members test new hooks" or "what do people say about advertorials", search the know-how library: \`${cli} knowledge search "<question>"\`. Today it holds Creative Cartel's library (\`creative-cartel\`) the team's own Exodus copy and ad frameworks (\`exodus\`), and Meta's advertising standards (\`meta-policy\`), all copied into this server every Monday afternoon. It works only when the key has library access (\`${cli} whoami\` says); without it the answer is \`knowledge_not_allowed\`. It finds snippets by their words and by their meaning, and returns them short, never whole documents: each at most 280 characters, with its title, link, kind, caveat, evidence and the day its library was copied in. Evidence \`reported member statement\` is a member's claim, not a checked result; weigh it that way and say so when you use it. Evidence \`team method\` is the team's own framework: reason with it, never paste it into client work. The library writes no answer for you: read the snippets and decide. Use your own words: saving text that repeats 12 words in a row from anything in the library is refused with \`copies_library_text\`.
39
39
 
40
40
  - The question is 1 to 4000 characters. \`--limit\` is 1 to 12 (default 5). \`--kind\` (message, transcript, document, note) keeps one kind of source. \`--library <slug>\` keeps one library.
41
+ - \`--library meta-policy\` searches Meta's own advertising standards, copied from Meta's site every Monday. Before you write or judge an ad, check its claims there, such as income, health or before-and-after claims: \`${cli} knowledge search --library meta-policy "income claims"\`. Its evidence is \`meta_published_policy\`, Meta's published rule; Meta's reviewers still decide each ad.
41
42
  - \`knowledge_not_synced\` means the library has not been copied into this server yet, so nothing was searched; the weekly sync fills it.
42
43
 
43
44
  ## Query workflow: schema first
@@ -87,7 +88,7 @@ A webhook address lets a tool such as a form or webinar platform push events int
87
88
  1. \`${cli} tools list\` shows each tool id (\`provider:tool\`, such as \`kie:nano-banana\`), whether it runs sync or async, and what it makes. \`--format json\` adds each tool's description, cost, and input JSON Schema.
88
89
  2. \`${cli} tools run kie:nano-banana --input '{"prompt": "...", "aspectRatio": "1:1"}' --idempotency-key <key>\` starts a run and waits for it (up to \`--wait\` seconds, default 300). Tools cost money. Always pass an idempotency key, and reuse it when you retry, so a retry returns the same run instead of paying twice.
89
90
  3. A run that is still going prints its id. Check it with \`${cli} tools get <run-id>\`. Image results list an \`asset\` link. Save the file with \`${cli} creatives download <asset-link>\`, or fetch the link with your API key as a bearer token; it redirects to a copy that works for 10 minutes.
90
- 4. Record a succeeded run in the ledger with its lineage: \`${cli} creatives create --run <run-id> --lineage '{"persona": "...", "painPoint": "...", "desiredOutcome": "...", "angle": "...", "sourceAd": {"metaAdId": "..."}}'\`. Each output gets a SKU such as \`DLC-000123\`. The SKU goes in the Meta ad name.
91
+ 4. Record a succeeded run in the ledger with its lineage: \`${cli} creatives create --run <run-id> --lineage '{"persona": "...", "painPoint": "...", "desiredOutcome": "...", "angle": "...", "sourceAd": {"metaAdId": "..."}}'\`. Each output gets a SKU such as \`DLC-000123\`. The SKU goes in the Meta ad name. Lineage is free-text notes; give the ad's marketing tags with \`--tags '<json>'\` or \`--tags @tags.json\`, the same fields as ad tags (see Ad tags), such as \`{"offer_key": "course", "landing_funnel_key": "webinar_to_course", "landing_step_key": "registration_page", "persona": "near_retirement", "angle": "story", "format": "image"}\`. Every output of the run gets them. Values come from \`${cli} grammar\`; persona and concept from this client's lists, and offer, funnel and step from its confirmed map (\`${cli} funnels show\`), where the step must be in that funnel and \`reply\` or \`no_link\` take no step. A value off them is refused with the ones to use. Recording the same run again returns its creatives as they are, tags included; \`${cli} creatives tag <sku> --file tags.json\` is how to change a creative's tags. Your tags read proposed until an Owner or Admin confirms them on the creative's page in Content; after that only a person changes them.
91
92
  5. \`${cli} creatives list\` and \`${cli} creatives show <sku>\` read the ledger. \`${cli} creatives download <sku>\` saves a creative's image or video as \`<sku>.<ext>\` in the current folder; \`--out <path>\` names the file or a folder, and \`--open\` opens it. It also takes an asset path or link, such as a \`meta_ad_creatives\` \`image_path\`. \`--format json\` prints \`{ path, bytes, contentType }\`. Copy has no file; its text is in \`${cli} creatives show\`. For performance, query the \`creatives\` table or the \`creative_performance_daily\` view with \`${cli} warehouse query\`.
92
93
 
93
94
  ## Landing pages
@@ -178,13 +179,19 @@ To have the server tag ads for you, run \`${cli} ads autotag\` (\`--limit <n>\`,
178
179
 
179
180
  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\`.
180
181
 
182
+ ## Content tags
183
+
184
+ Emails, text messages and landing pages in the content library get the same grammar's tags, by content item id. \`${cli} content tags [<item-id>]\` lists them (\`--kind email|text|page\`, \`--status proposed|confirmed|rejected|replaced\`, \`--limit <n>\`, \`--json\`); by default the live ones. Each row names the version of the item's words it describes (\`contentHash\`). Only the parts that fit the kind are filled: an email's or text's job (invite, reminder, last chance and so on), no job for a page, and no secondary hook for a text, whose format (sms or mms) comes from whether it carries a picture.
185
+
186
+ To have the server tag them, run \`${cli} content autotag\` (\`--limit <n>\`, 1 to 100, default 25; \`--kind email,text\` to narrow; item ids as arguments for those items; \`--json\`). It takes the items whose current words have no tags yet, the ones that brought in the most sales first, then the most clicks, then the newest, so a repeat run spends nothing on what it already tagged. Items that read the same, such as one email sent to several lists, are asked about once and share the tags. An email whose body was never stored is asked only whether it is marketing, its hook and its job. When an item's words change, its next run replaces its tags, even confirmed ones, since a person confirmed the old words. \`${cli} content to-check\` lists the emails and texts whose tags wait for a person, most sales first, then most clicks; the same rows are in the \`content_tags\` table.
187
+
181
188
  ## Ad words
182
189
 
183
190
  Tag ads, emails, texts and pages only with the words \`${cli} grammar\` lists (\`--format json\` for the same lists as data): each part's question and allowed values. Persona (who it talks to) and Concept (the one idea it sells) are this client's own lists; the other parts are shared by every client. A persona or concept marked not yet confirmed may be used. A retired one is never used for new tags; an old tag with it counts as the value it was merged into. To suggest new personas and concepts, run \`${cli} grammar propose --file values.json\` with \`{"values": [{"part": "persona", "key": "age_50s_retirement", "label": "In their 50s, worried about retirement", "definition": "People in their 50s who fear their savings will not last.", "examples": ["<SKU>"]}]}\`. You only suggest: a value already on the list is left as it is, and a person confirms, renames, merges or retires values in the app. The \`grammar\` playbook (\`${cli} skill install grammar\` installs it as the \`${GRAMMAR_SKILL_NAME}\` skill; \`${cli} docs grammar\` prints it) walks an agent from the client's ads to a first draft.
184
191
 
185
192
  ## What customers keep saying
186
193
 
187
- 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.
194
+ Every hour an AI reads what is new in the workspace's Facebook and Instagram comments, Google reviews, Reddit posts and comments, recorded calls and meetings (Fathom, Zoom, CallRail) and chats (GoHighLevel texts and chat, Closebot), pulls out openers (lines that would open an ad), objections (why people hesitate), pains (problems they live with), desires (results they want) and phrases (exact wording worth copying into an ad), and groups the ones that say the same thing. From calls and chats only the customer's words count, and names, emails, phone numbers and handles are taken out first. \`${cli} voice\` lists the groups, most common first (\`--kind opener|objection|pain|desire|phrase\`, \`--limit <n>\` up to 200, \`--json\`), each with how many sources said it (a conversation counts once), their likes, and up to 3 real quotes. \`${cli} voice --since <YYYY-MM-DD> --sort useful\` lists single lines said since that day instead, the most usable in an ad first: specific, first-person, recent and often said; \`--json\` adds each line's link. In \`--json\`, each quote's \`kind\` (a line's \`source\`) and \`ref\` go 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.
188
195
 
189
196
  ## The weekly story
190
197
 
@@ -259,7 +266,8 @@ ${cli} tools get <run-id> show a tool run, waiting for it to finish (-
259
266
  ${cli} creatives list list creatives (--status, --kind, --limit)
260
267
  ${cli} creatives show <sku> show one creative with its spec and lineage
261
268
  ${cli} creatives download <sku | asset-path | asset-link> [--out <path>] [--open] save a creative's image or video
262
- ${cli} creatives create --run <run-id> [--lineage '<json>' | @file.json] record a run's output with a new SKU
269
+ ${cli} creatives create --run <run-id> [--lineage '<json>' | @file.json] [--tags '<json>' | @file.json] record a run's output with a new SKU
270
+ ${cli} creatives tag <sku> --file <tags.json> replace a creative's marketing tags, proposed until a person confirms them
263
271
  ${cli} content create --kind <kind> [--body ...] [--file <path>] [--note <why>] [--plan <plan-id>] save made content with a new SKU
264
272
  ${cli} content search [--text <words>] [--mode word|meaning] [--kind <kind>] [--platform <p>] [--origin <o>] [--from <date>] [--to <date>] [--sort <column>] [--order asc|desc] [--limit <n>] find content with its numbers
265
273
  ${cli} content pull <dir> [--kind <kind>] [--platform <p>] [--origin <o>] [--media] [--full] copy the library to a folder of Markdown files; rerun for changes
@@ -290,6 +298,8 @@ ${cli} facts [--kind <kind>] [--subject <type>[:<ref>]] [--status <status>] [--j
290
298
  ${cli} facts propose --file <fact.json> suggest a fact for a person to confirm
291
299
  ${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
292
300
  ${cli} ads autotag [--limit <n>] [--ad <id>] have the server propose tags for the biggest spenders not yet tagged
301
+ ${cli} content tags [<item-id>] [--kind email|text|page] | content to-check the marketing tags of emails, texts and landing pages, and those waiting for a person
302
+ ${cli} content autotag [<item-id> ...] [--kind email|text|page] [--limit <n>] have the server propose tags for the emails, texts and pages worth most not yet tagged
293
303
  ${cli} grammar [--format json] every list ads are tagged from, this client's personas and concepts included
294
304
  ${cli} grammar propose --file <values.json> suggest personas and concepts for a person to confirm
295
305
  ${cli} voice [--kind opener|objection|phrase] [--limit <n>] [--json] what customers keep saying, grouped and counted, with real quotes
package/dist/main.js CHANGED
@@ -15,6 +15,7 @@ import { SKILL_NAME_PATTERN } from "./skill-zip.js";
15
15
  import { installSkillFolder, operatorConnection, packSkill, parseSkillRef, readSkillFolder, unpackSkill, } from "./skills.js";
16
16
  const DEFAULT_WAIT_SECONDS = 300;
17
17
  const SERVER_WAIT_CAP = 50;
18
+ const VOICE_USAGE = "voice [--kind opener|objection|pain|desire|phrase] [--since <YYYY-MM-DD>] [--sort common|useful] [--limit <n>] [--json]";
18
19
  const commands = {
19
20
  login: {
20
21
  usage: `login (paste the key when asked, pipe it on stdin, or set ${envPrefix}_API_KEY)`,
@@ -500,7 +501,7 @@ const commands = {
500
501
  tool: creative.spec.tool,
501
502
  prompt: creative.spec.input?.prompt,
502
503
  };
503
- const shown = ["sku", "kind", "status", "tool", "prompt", "lineage", "assetUrl", "copyText", "metaAdId", "toolRunId", "createdAt"];
504
+ const shown = ["sku", "kind", "status", "tool", "prompt", "lineage", "tags", "tagsStatus", "tagsConfirmedBy", "assetUrl", "copyText", "metaAdId", "toolRunId", "createdAt"];
504
505
  console.log(renderTable(["field", "value"], shown.filter((f) => fields[f] != null).map((f) => ({ field: f, value: fields[f] }))));
505
506
  },
506
507
  },
@@ -522,18 +523,33 @@ const commands = {
522
523
  },
523
524
  },
524
525
  "creatives create": {
525
- usage: "creatives create --run <tool-run-id> [--lineage '<json>' | @file.json]",
526
+ usage: "creatives create --run <tool-run-id> [--lineage '<json>' | @file.json] [--tags '<json>' | @file.json]",
526
527
  async run(args, options) {
527
- if (args.length > 0 || !options.run)
528
- throw new CliError("Usage: creatives create --run <tool-run-id> [--lineage '<json>' | @file.json]", exitCodes.usage);
528
+ if (args.length > 0 || !options.run) {
529
+ throw new CliError("Usage: creatives create --run <tool-run-id> [--lineage '<json>' | @file.json] [--tags '<json>' | @file.json]", exitCodes.usage);
530
+ }
529
531
  const lineage = options.lineage === undefined ? undefined : await parseJson(options.lineage, "--lineage");
532
+ const tags = options.tags === undefined ? undefined : await parseJson(options.tags, "--tags");
530
533
  const { creatives } = await api(await resolveConnection(options.apiUrl), "/api/v1/creatives", {
531
534
  runId: options.run,
532
535
  ...(lineage === undefined ? {} : { lineage }),
536
+ ...(tags === undefined ? {} : { tags }),
533
537
  });
534
538
  if (options.format === "json")
535
539
  return printJson({ creatives });
536
- console.log(renderTable(["sku", "kind", "status", "assetUrl"], creatives));
540
+ console.log(renderTable(["sku", "kind", "status", "tagsStatus", "assetUrl"], creatives));
541
+ },
542
+ },
543
+ "creatives tag": {
544
+ usage: "creatives tag <sku> --file <tags.json> | < tags.json replace a creative's marketing tags; a person confirms them in the app",
545
+ async run(args, options) {
546
+ if (args.length !== 1)
547
+ throw new CliError("Usage: creatives tag <sku> --file <tags.json> | < tags.json", exitCodes.usage);
548
+ const tags = await readToolInput(options);
549
+ const { creative } = await api(await resolveConnection(options.apiUrl), `/api/v1/creatives/${encodeURIComponent(args[0])}/tags`, { tags }, "PATCH");
550
+ if (options.format === "json")
551
+ return printJson({ creative });
552
+ console.log(`Tagged ${creative.sku}. The tags read ${creative.tagsStatus} until a person confirms them in the app.`);
537
553
  },
538
554
  },
539
555
  "content create": {
@@ -1110,21 +1126,30 @@ const commands = {
1110
1126
  },
1111
1127
  },
1112
1128
  voice: {
1113
- usage: "voice [--kind opener|objection|phrase] [--limit <n>] [--json] what customers keep saying, most common first",
1129
+ usage: `${VOICE_USAGE} what customers keep saying, most common first, or with --sort useful the most usable single lines`,
1114
1130
  async run(args, options) {
1115
1131
  if (args.length > 0)
1116
- throw new CliError("Usage: voice [--kind opener|objection|phrase] [--limit <n>] [--json]", exitCodes.usage);
1132
+ throw new CliError(`Usage: ${VOICE_USAGE}`, exitCodes.usage);
1117
1133
  const query = new URLSearchParams();
1118
1134
  if (options.kind)
1119
1135
  query.set("kind", options.kind);
1136
+ if (options.since)
1137
+ query.set("since", options.since);
1138
+ if (options.sort)
1139
+ query.set("sort", options.sort);
1120
1140
  if (options.limit)
1121
1141
  query.set("limit", options.limit);
1122
- const { clusters } = await api(await resolveConnection(options.apiUrl), `${VOICE_PATH}${query.size ? `?${query}` : ""}`);
1142
+ const read = await api(await resolveConnection(options.apiUrl), `${VOICE_PATH}${query.size ? `?${query}` : ""}`);
1123
1143
  if (options.format === "json")
1124
- return printJson({ clusters });
1125
- if (clusters.length === 0)
1144
+ return printJson(read);
1145
+ if ("lines" in read) {
1146
+ if (read.lines.length === 0)
1147
+ return console.log("Nothing said in that time yet.");
1148
+ return console.log(renderTable(VOICE_LINE_COLUMNS, read.lines.map(voiceLineRow)));
1149
+ }
1150
+ if (read.clusters.length === 0)
1126
1151
  return console.log("Nothing grouped yet.");
1127
- console.log(renderTable(VOICE_COLUMNS, clusters.map(voiceRow)));
1152
+ console.log(renderTable(VOICE_COLUMNS, read.clusters.map(voiceRow)));
1128
1153
  },
1129
1154
  },
1130
1155
  "ads tags": {
@@ -1186,6 +1211,63 @@ const commands = {
1186
1211
  console.log("Stopped early: the run's tool spending cap was reached.");
1187
1212
  },
1188
1213
  },
1214
+ "content tags": {
1215
+ usage: "content tags [<item-id>] [--kind email|text|page] [--status <status>] [--limit <n>] [--json] the marketing tags of emails, texts and landing pages",
1216
+ async run(args, options) {
1217
+ if (args.length > 1)
1218
+ throw new CliError("Usage: content tags [<item-id>] [--kind email|text|page] [--status <status>] [--limit <n>] [--json]", exitCodes.usage);
1219
+ const filters = { item: args[0], kind: options.kind, status: options.status, limit: options.limit };
1220
+ const query = new URLSearchParams(Object.entries(filters).filter((entry) => entry[1] !== undefined));
1221
+ const { tags } = await api(await resolveConnection(options.apiUrl), `${CONTENT_TAGS_PATH}${query.size ? `?${query}` : ""}`);
1222
+ if (options.format === "json")
1223
+ return printJson({ tags });
1224
+ if (tags.length === 0)
1225
+ return console.log("No content tags.");
1226
+ console.log(renderTable(CONTENT_TAG_COLUMNS, tags.map(contentTagRow)));
1227
+ },
1228
+ },
1229
+ "content to-check": {
1230
+ usage: "content to-check [--limit <n>] [--json] the emails and texts whose tags wait for a person, most sales first, then most clicks",
1231
+ async run(args, options) {
1232
+ if (args.length > 0)
1233
+ throw new CliError("Usage: content to-check [--limit <n>] [--json]", exitCodes.usage);
1234
+ const query = new URLSearchParams({ to_check: "1", ...(options.limit ? { limit: options.limit } : {}) });
1235
+ const { tags } = await api(await resolveConnection(options.apiUrl), `${CONTENT_TAGS_PATH}?${query}`);
1236
+ if (options.format === "json")
1237
+ return printJson({ tags });
1238
+ if (tags.length === 0)
1239
+ return console.log("No email or text tags wait for a person.");
1240
+ console.log(renderTable(["item", "kind", "revenue", "clicks", ...CONTENT_TAG_COLUMNS.slice(4)], tags.map((t) => ({ ...contentTagRow(t), revenue: t.revenue, clicks: t.clicks }))));
1241
+ },
1242
+ },
1243
+ "content autotag": {
1244
+ usage: "content autotag [<item-id> ...] [--kind email|text|page] [--limit <n>] [--json] propose marketing tags for the emails, texts and landing pages worth most that have none",
1245
+ async run(args, options) {
1246
+ const body = {
1247
+ ...(args.length > 0 ? { item_ids: args } : {}),
1248
+ ...(options.kind ? { kinds: options.kind.split(",") } : {}),
1249
+ ...(options.limit ? { limit: Number(options.limit) } : {}),
1250
+ };
1251
+ const result = await api(await resolveConnection(options.apiUrl), CONTENT_AUTOTAG_PATH, body);
1252
+ if (options.format === "json")
1253
+ return printJson(result);
1254
+ const plural = (n) => `${n} ${n === 1 ? "item" : "items"}`;
1255
+ console.log(`Proposed tags for ${plural(result.proposed.length)}, asking about ${result.groups} ${result.groups === 1 ? "set of words" : "sets of words"} for $${result.costUsd.toFixed(4)}. A person confirms or rejects them in the app.`);
1256
+ if (result.proposed.length > 0)
1257
+ console.log(renderTable(["item", "id", "confidence"], result.proposed.map((p) => ({ item: p.itemId, id: p.id, confidence: p.confidence }))));
1258
+ if (result.kept.length > 0)
1259
+ console.log(`Kept as they are, since a person confirmed their tags: ${result.kept.join(", ")}.`);
1260
+ for (const reason of ["not_marketing", "gate_unclear", "no_content", "not_found", "changed"]) {
1261
+ const ids = result.skipped.filter((s) => s.reason === reason).map((s) => s.itemId);
1262
+ if (ids.length > 0)
1263
+ console.log(`${CONTENT_SKIP_REASONS[reason]}: ${ids.join(", ")}.`);
1264
+ }
1265
+ for (const failure of result.failed)
1266
+ console.log(`Failed for ${failure.itemIds.join(", ")}: ${failure.error}`);
1267
+ if (result.stopped === "tool_cap")
1268
+ console.log("Stopped early: the run's tool spending cap was reached.");
1269
+ },
1270
+ },
1189
1271
  "facts show": {
1190
1272
  usage: "facts show <id> one fact with its value and evidence",
1191
1273
  async run(args, options) {
@@ -1716,12 +1798,17 @@ const AD_TAGS_PATH = "/api/v1/ad-tags";
1716
1798
  const AD_AUTOTAG_PATH = "/api/v1/ad-tags/autotag";
1717
1799
  const SKIP_REASONS = { not_marketing: "Not marketing, so only that answer was saved", gate_unclear: "Could not tell whether it is marketing, so only that was saved", no_content: "Nothing to read", not_found: "No such ad" };
1718
1800
  const AD_TAG_COLUMNS = ["id", "ad", "status", "confidence", "offer", "persona", "angle", "hook", "format", "author"];
1801
+ const CONTENT_TAGS_PATH = "/api/v1/content-tags";
1802
+ const CONTENT_SKIP_REASONS = { ...SKIP_REASONS, not_found: "No such email, text or landing page", changed: "Its words changed while it was being tagged, so nothing was saved; the next run tags them" };
1803
+ const CONTENT_AUTOTAG_PATH = "/api/v1/content-tags/autotag";
1804
+ const CONTENT_TAG_COLUMNS = ["id", "item", "kind", "status", "confidence", "hook", "job", "offer", "angle", "format"];
1719
1805
  const GRAMMAR_PATH = "/api/v1/grammar";
1720
1806
  const VOICE_LISTS_PATH = "/api/v1/voice-lists";
1721
1807
  const VOICE_SUGGESTIONS_PATH = "/api/v1/voice-lists/suggestions";
1722
1808
  const FACT_COLUMNS = ["id", "kind", "subject", "key", "status", "summary"];
1723
1809
  const VOICE_PATH = "/api/v1/voice";
1724
1810
  const VOICE_COLUMNS = ["ref", "kind", "said by", "likes", "label", "top quote"];
1811
+ const VOICE_LINE_COLUMNS = ["date", "kind", "source", "quote", "ref"];
1725
1812
  function knowledgeBlock(r, i) {
1726
1813
  const lines = [`${i + 1}. ${r.title}`, ` ${r.kind}, ${r.evidence.replaceAll("_", " ")} (${r.source}${r.syncedAt ? `, copied in ${r.syncedAt.slice(0, 10)}` : ""})`];
1727
1814
  if (r.url)
@@ -1735,9 +1822,16 @@ function adTagRow(t) {
1735
1822
  const { offer_key, persona, angle, hook_kind, hook_spot, format, style } = t.tags;
1736
1823
  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 };
1737
1824
  }
1825
+ function contentTagRow(t) {
1826
+ const { hook_kind, hook_spot, job, offer_key, angle, format } = t.tags;
1827
+ return { id: t.id, item: t.itemId, kind: t.kind, status: t.status, confidence: t.confidence, hook: [hook_kind, hook_spot].filter(Boolean).join(" in ") || null, job, offer: offer_key, angle, format };
1828
+ }
1738
1829
  function voiceRow(c) {
1739
1830
  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 };
1740
1831
  }
1832
+ function voiceLineRow(l) {
1833
+ return { date: l.date.slice(0, 10), kind: l.kind, source: l.source, quote: excerpt(l.quote, 80), ref: l.ref };
1834
+ }
1741
1835
  function factRow(f) {
1742
1836
  return { id: f.id, kind: f.kind, subject: `${f.subjectType}:${f.subjectRef}`, key: f.key, status: f.status, summary: excerpt(f.summary, 80) };
1743
1837
  }
@@ -1852,9 +1946,11 @@ Options:
1852
1946
  --from, --to <n> with content diff, the two versions to compare (default: the one before the current one, and the current one)
1853
1947
  --sort <column> how content search sorts: recent (default) or a content_scorecard number, such as click_rate or roas
1854
1948
  --order desc|asc content search's sort direction (default: desc, best or newest first)
1855
- --limit <n> how many results content search, creatives list, campaigns, voice and ads tags return, or how many ads ads autotag tags
1856
- --library <slug> with knowledge search, only this library, such as creative-cartel
1949
+ --limit <n> how many results content search, creatives list, campaigns, voice, ads tags, content tags and content to-check
1950
+ return, or how many ads ads autotag tags and how many items content autotag tags
1951
+ --library <slug> with knowledge search, only this library: creative-cartel, exodus or meta-policy
1857
1952
  --ad <id> with ads tags, only this Meta ad's tags; with ads autotag, only this ad
1953
+ --kind email|text|page with content tags and content autotag, only that kind (content autotag takes a comma list)
1858
1954
  --max-seconds <n> stop a runner's run after this many seconds, if that comes before its deadline
1859
1955
  --workspace <slug> for worker promote and worker drop: the workspace whose hand they change
1860
1956
  -h, --help show this help
@@ -1888,6 +1984,7 @@ function parseOptions(argv) {
1888
1984
  limit: { type: "string" },
1889
1985
  run: { type: "string" },
1890
1986
  lineage: { type: "string" },
1987
+ tags: { type: "string" },
1891
1988
  sources: { type: "string" },
1892
1989
  codex: { type: "boolean", default: false },
1893
1990
  agent: { type: "string" },
@@ -1904,6 +2001,7 @@ function parseOptions(argv) {
1904
2001
  force: { type: "boolean", default: false },
1905
2002
  yes: { type: "boolean", default: false },
1906
2003
  from: { type: "string" },
2004
+ since: { type: "string" },
1907
2005
  to: { type: "string" },
1908
2006
  grain: { type: "string" },
1909
2007
  channel: { type: "string" },
@@ -1981,6 +2079,7 @@ function parseOptions(argv) {
1981
2079
  limit: values.limit,
1982
2080
  run: values.run,
1983
2081
  lineage: values.lineage,
2082
+ tags: values.tags,
1984
2083
  sources: values.sources,
1985
2084
  codex: values.codex,
1986
2085
  agent: values.agent ?? (process.env[AGENT_ENV] || "meta_ads"),
@@ -1998,6 +2097,7 @@ function parseOptions(argv) {
1998
2097
  force: values.force,
1999
2098
  yes: values.yes,
2000
2099
  from: values.from,
2100
+ since: values.since,
2001
2101
  to: values.to,
2002
2102
  grain: values.grain,
2003
2103
  channel: values.channel,
package/dist/skill-zip.js CHANGED
@@ -41,10 +41,16 @@ export function unsafeFileName(name) {
41
41
  return `empty or . segment in ${name}`;
42
42
  return null;
43
43
  }
44
- /** Zips the files; each name is a `/`-separated path inside the skill folder. */
44
+ /**
45
+ * The time stamped on every zipped file: the earliest a zip can hold. The library recognises a skill version by its
46
+ * zip's sha256, so the same files must zip to the same bytes on every run. fflate reads the date in local time, and
47
+ * this one is built in local time, so it encodes the same in every time zone.
48
+ */
49
+ const ZIP_MTIME = new Date(1980, 0, 1);
50
+ /** Zips the files; each name is a `/`-separated path inside the skill folder. The same files always give the same bytes. */
45
51
  export function zipSkillFiles(files) {
46
52
  const unix = (mode) => ({ os: 3, attrs: ((0o100000 | mode) << 16) >>> 0 });
47
- return zipSync(Object.fromEntries(files.map((f) => [f.name, [f.data, unix(f.executable ? 0o755 : 0o644)]])), { level: 9 });
53
+ return zipSync(Object.fromEntries(files.map((f) => [f.name, [f.data, unix(f.executable ? 0o755 : 0o644)]])), { level: 9, mtime: ZIP_MTIME });
48
54
  }
49
55
  /** Reads and checks a skill's zip without trusting any size or name it declares. Throws SkillZipError naming the problem. */
50
56
  export function readSkillZip(zip, skillName) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrangled/cli",
3
- "version": "0.3.21",
3
+ "version": "0.3.23",
4
4
  "description": "Command-line access to your marketing data warehouse, for people and coding agents",
5
5
  "type": "module",
6
6
  "bin": {