@koda-sl/baker-cli 0.178.0 → 0.180.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -508,6 +508,10 @@ baker ads google campaigns create --customer-id 1234567890 --name "Brand — Sea
508
508
  # locations) or PRESENCE_OR_INTEREST (also people interested in them). Google applies
509
509
  # PRESENCE_OR_INTEREST when the flag is omitted, so pass PRESENCE for local/in-store/service-area
510
510
  # campaigns; --negative-geo-target-type does the same for excluded locations.
511
+ # --networks search|search-partners|display|partner-search (comma-separated) sets which networks
512
+ # the campaign serves on; every network not listed is turned OFF. A SEARCH campaign staged
513
+ # without --networks publishes on Google Search only — Google's own default would add Search
514
+ # partners and the Display network. Other channel types keep Google's behaviour.
511
515
  # Campaign URL options stage on create and update: --final-url-suffix (bare query params),
512
516
  # --tracking-template "https://tracker.example/?url={lpurl}" (must carry {lpurl}), and
513
517
  # --custom-param key=value (repeatable, up to 8; --clear-custom-params empties the set).
@@ -528,6 +532,9 @@ baker ads google keywords add --customer-id 1234567890 --ad-group-ref g_temp_<ad
528
532
  --text "brand name, brand shop:PHRASE, brand store" --match-type EXACT
529
533
  baker ads google keyword-lists add --customer-id 1234567890 --list-ref <list-id> --file negatives.txt
530
534
  # negatives.txt: one keyword[:MATCH_TYPE] per line; blank lines and "# comments" are skipped
535
+ # Send one keyword to its own page, then put it back on the ad's — never remove + re-add for this
536
+ baker ads google keywords update <criterion-id> --customer-id 1234567890 --final-url https://example.com/solar
537
+ baker ads google keywords update <criterion-id> --customer-id 1234567890 --clear-final-url
531
538
  # Responsive search ad
532
539
  baker ads google ads create --customer-id 1234567890 --ad-group-ref g_temp_<adgroup> \
533
540
  --headlines "Fast Widgets,Buy Online,Free Shipping" --descriptions "Best widgets around.,Ships tomorrow." \
@@ -5012,6 +5019,9 @@ This CLI is designed for AI agent consumption. Key patterns:
5012
5019
  - **0.177.0**: `baker ga4` can change what a property measures, not only report on it. Five staged write families — `key-event` (create|update|delete), `custom-dimension` and `custom-metric` (create|update|archive), `custom-event` (create|update|delete), `data-retention set` — plus `baker ga4 config` to read the property's current configuration and `baker ga4 draft` (list|show|amend|remove|clear) to review the staged set. `custom-event` is GA4's "Create event" rule: build a new event out of one the site already sends (a `form_submit` on `/quote` becomes `quote_request`), then mark it a key event so Google Ads imports it — the loop that previously ended with "do this by hand in Analytics". Staging follows Tag Manager: nothing is sent during the chat, the user reviews the whole set in the dashboard's **Analytics** tab, and it applies when they complete the chat. Unlike Tag Manager there is no version left over — an applied change is in effect at once, and there is no undo, so removals ask for confirmation first. Everything Google would reject is refused at stage time against a fresh read of the property rather than discovered at publish: a duplicate `eventName` or `parameterName` (including against another change staged on the same chat), an immutable field in an update, a currency metric with no restricted type, a retention window past 14 months on a standard property, a target the property does not have. Marking an event Google fires by itself (`page_view`, `scroll`, …) as a key event is a **warning**, not a refusal — it is legal, it just inflates conversions and poisons Smart Bidding. The one thing that is *not* a duplicate is the replacement of something the same chat has already staged the removal of: ops apply in stage order, so `custom-dimension archive 12` followed by a create on the same `parameterName` works, which is the only way to change a field GA4 marks immutable. Every `create` also takes a JSON array and stages the list in one request, because each stage costs a full read of the property. One chat stages changes for one property. **The OAuth scope changed:** Baker now requests `analytics.edit`, so every Google Analytics connection made before this is read-only until it is reconnected; the write commands refuse up front with that as the fix, and `baker ga4 config` reports `canWrite`. Reporting is unaffected. Not covered, and reported as such by `baker capabilities`: property create/rename/merge, data streams, the Google Ads link, and audiences.
5013
5020
  - **0.169.0**: `baker capabilities` — one call, before any platform work, that answers what Baker **cannot** do. Everything else (`baker schema`, the family docs, `--help`) describes what it can, so the only way to learn a capability was absent was to try it, usually after telling the user it would happen. Per surface (`google-ads`, `meta-ads`, `linkedin-ads`, `x-ads`, `tag-manager`, `ga4`, `gsc`) it reports: what is connected and which accounts/properties/containers were picked; whether writes are **live** or **simulated** for this company, and that Tag Manager has neither mode — completing the chat changes the real container; and a `limits` list separating *not built yet* from *the platform forbids it* from *we hold no permission for it*, because each leads to something different to tell the user. Naming one surface (`baker capabilities google-ads`) also returns `ops`: every change with its required and optional fields and the exact values each accepts — so "is there a field to demote a conversion action, and how is it spelled?" is one call instead of four guesses. Almost everything is derived rather than written down: the accounts and permissions come from the connection, the write mode from the company's gate, the writable entities and field contracts from the same schemas the backend parses with, and read-only vs read-write from the OAuth scopes the app actually requests (which is why "GA4 is read-only" is a fact, not a sentence). The handful of hand-written entries each carry a machine-checkable claim about the code behind them, and a coverage test fails when one stops holding. A rejected staged write on Google, Meta or LinkedIn now points at this command instead of leaving the agent to guess another field name.
5014
5021
  - **0.178.0**: `baker ads google keywords add --final-url` is now described and nudged as the exception it is. A keyword-level final URL overrides the ad's landing page for every click on that keyword, and the flag applies to every keyword in the call — so one URL pasted across a batch restates the ad's own destination on each row while making the change review read like a per-keyword routing decision. The flag's description says to leave it unset unless the destination is keyword-specific, and staging with it returns a hint: on a batch, that the shared page belongs on the ad (`ads create --final-url`) and the keywords should be re-staged without it; on a single keyword, to keep it only where that keyword's destination differs. The review surface changed to match — a staged keyword's final URL is now shown in full on its row in the change card (it used to be visible only on a keyword *update*), so a URL that adds nothing is visible to the user rather than buried.
5022
+ - **0.179.0**: `baker ads google keywords update --final-url` / `--clear-final-url` — changing where one keyword sends its clicks no longer costs the keyword its history. The write contract has always accepted `finalUrls` on a keyword update (`baker capabilities google-ads` reported it as a field of `google.keyword.update`), but no CLI flag reached it, so an agent that followed capabilities got "update needs at least one field" and the only route left was remove + re-add — a new criterion to Google, learning from zero, for a change that was only ever about the destination. `--clear-final-url` stages the empty set Google reads as "inherit the ad's landing page", which is the undo for the misuse `--final-url` already warns about. Same nudge as `add` on the way in.
5023
+
5024
+ A keyword's final URL now also shows on its row when the keyword is being **removed**. 0.178.0 read it from the stage-time snapshot, but the snapshot never asked Google for it, so the removal half of that change silently did nothing — the card said which keyword was going away but not which landing page it had been sending its clicks to. The batch hint says outright that `--final-url` has no per-keyword form rather than implying destinations could differ within one call, and the tool doc now lists each `keywords` subcommand's own flags instead of one shared set (`--ad-group-ref`, `--text` and `--match-type` are `add`-only; `remove` takes a target and nothing else).
5015
5025
 
5016
5026
  ## Publishing
5017
5027
 
package/dist/cli.js CHANGED
@@ -5910,7 +5910,7 @@ var biddingConfigSchema = z15.object({
5910
5910
  ctx.addIssue({ code: "custom", path: ["targetRoas"], message: "TARGET_ROAS needs targetRoas" });
5911
5911
  }
5912
5912
  });
5913
- var networkSettingsSchema = z15.object({
5913
+ var networkSettingsSchema = z15.strictObject({
5914
5914
  targetGoogleSearch: z15.boolean().optional(),
5915
5915
  targetSearchNetwork: z15.boolean().optional(),
5916
5916
  targetContentNetwork: z15.boolean().optional(),
@@ -9755,6 +9755,56 @@ function geoTargetTypeFromFlags(args) {
9755
9755
  Object.entries({ positiveGeoTargetType, negativeGeoTargetType }).filter(([, v]) => v !== void 0)
9756
9756
  );
9757
9757
  }
9758
+ var NETWORK_SETTING_KEYS = [
9759
+ "targetGoogleSearch",
9760
+ "targetSearchNetwork",
9761
+ "targetContentNetwork",
9762
+ "targetPartnerSearchNetwork"
9763
+ ];
9764
+ var NETWORK_FLAG_VALUES = {
9765
+ search: "targetGoogleSearch",
9766
+ "search-partners": "targetSearchNetwork",
9767
+ partners: "targetSearchNetwork",
9768
+ display: "targetContentNetwork",
9769
+ "partner-search": "targetPartnerSearchNetwork"
9770
+ };
9771
+ var networksArg = {
9772
+ networks: {
9773
+ type: "string",
9774
+ description: `Networks to serve on, comma-separated: search | search-partners | display | partner-search. Every network you do not list is turned OFF, so state the whole set. Start here: --networks search \u2014 a new Search campaign left unset publishes on Google Search only, because Google's own default adds Search partners and the Display network, which spends the same budget on surfaces the advertiser did not pick. Sets the same "Networks" as the Google Ads UI.`
9775
+ }
9776
+ };
9777
+ function networkSettingsFromFlags(args) {
9778
+ const raw = args.networks;
9779
+ if (typeof raw !== "string") {
9780
+ return void 0;
9781
+ }
9782
+ const names = raw.split(",").map((n) => n.trim().toLowerCase()).filter((n) => n.length > 0);
9783
+ const allowed = Object.keys(NETWORK_FLAG_VALUES).join(" | ");
9784
+ if (names.length === 0) {
9785
+ failWriteValidation(`--networks needs at least one of ${allowed}`);
9786
+ }
9787
+ const settings = Object.fromEntries(NETWORK_SETTING_KEYS.map((k) => [k, false]));
9788
+ for (const name of names) {
9789
+ const key = NETWORK_FLAG_VALUES[name];
9790
+ if (!key) {
9791
+ failWriteValidation(`--networks must list only ${allowed}`);
9792
+ }
9793
+ settings[key] = true;
9794
+ }
9795
+ return settings;
9796
+ }
9797
+ function networksHints(payload) {
9798
+ if (payload.networkSettings !== void 0) {
9799
+ return [];
9800
+ }
9801
+ if (payload.channelType !== "SEARCH") {
9802
+ return [];
9803
+ }
9804
+ return [
9805
+ "No --networks set, so this campaign publishes on Google Search only \u2014 Search partners and the Display network are off. Re-stage with --networks search,search-partners (or add display) to widen it, and tell the user which networks this campaign serves on."
9806
+ ];
9807
+ }
9758
9808
  function geoTargetTypeHints(payload) {
9759
9809
  if (payload.geoTargetTypeSetting !== void 0) {
9760
9810
  return [];
@@ -9808,6 +9858,7 @@ var campaignsCommand = defineCommand30({
9808
9858
  ...finalUrlSuffixArg,
9809
9859
  ...urlOptionsArgs,
9810
9860
  ...geoTargetTypeArgs,
9861
+ ...networksArg,
9811
9862
  objective: { type: "string", description: "Advisory UI objective (SALES, LEADS, \u2026)" },
9812
9863
  "start-date": { type: "string", description: "YYYY-MM-DD" },
9813
9864
  "end-date": { type: "string", description: "YYYY-MM-DD" },
@@ -9826,6 +9877,7 @@ var campaignsCommand = defineCommand30({
9826
9877
  budget: args["budget-ref"],
9827
9878
  bidding: biddingFromFlags(args),
9828
9879
  geoTargetTypeSetting: geoTargetTypeFromFlags(args),
9880
+ networkSettings: networkSettingsFromFlags(args),
9829
9881
  finalUrlSuffix: args["final-url-suffix"],
9830
9882
  trackingUrlTemplate: args["tracking-template"],
9831
9883
  urlCustomParameters: urlCustomParametersFromFlags(args),
@@ -9835,7 +9887,10 @@ var campaignsCommand = defineCommand30({
9835
9887
  euPoliticalAds: args["eu-political-ads"],
9836
9888
  status: args.status
9837
9889
  });
9838
- await stageCreate("google.campaign.create", customerId, payload, geoTargetTypeHints(payload));
9890
+ await stageCreate("google.campaign.create", customerId, payload, [
9891
+ ...geoTargetTypeHints(payload),
9892
+ ...networksHints(payload)
9893
+ ]);
9839
9894
  }
9840
9895
  }),
9841
9896
  update: defineCommand30({
@@ -9852,6 +9907,7 @@ var campaignsCommand = defineCommand30({
9852
9907
  ...finalUrlSuffixArg,
9853
9908
  ...urlOptionsArgs,
9854
9909
  ...geoTargetTypeArgs,
9910
+ ...networksArg,
9855
9911
  "eu-political-ads": {
9856
9912
  type: "boolean",
9857
9913
  description: "Corrects the campaign's EU political advertising declaration: --eu-political-ads if it contains EU political ads, --eu-political-ads=false if it does not. Existing campaigns without a declaration block location-targeting changes until one is set."
@@ -9877,6 +9933,7 @@ var campaignsCommand = defineCommand30({
9877
9933
  budget: args["budget-ref"],
9878
9934
  bidding: biddingFromFlags(args),
9879
9935
  geoTargetTypeSetting: geoTargetTypeFromFlags(args),
9936
+ networkSettings: networkSettingsFromFlags(args),
9880
9937
  finalUrlSuffix: args["final-url-suffix"],
9881
9938
  trackingUrlTemplate: args["tracking-template"],
9882
9939
  urlCustomParameters: urlCustomParametersFromFlags(args),
@@ -9968,11 +10025,24 @@ function keywordFinalUrlHints(finalUrl, count) {
9968
10025
  return [];
9969
10026
  }
9970
10027
  return count > 1 ? [
9971
- `--final-url applied the same landing page to all ${count} keywords. A keyword-level URL only earns its place when that keyword must land somewhere the ad doesn't \u2014 one URL shared by every keyword belongs on the ad instead. Re-stage without --final-url unless the destinations genuinely differ per keyword, and set the ad's final URL to this page.`
10028
+ `--final-url applied the same landing page to all ${count} keywords \u2014 the flag has no per-keyword form. A keyword-level URL only earns its place when that keyword must land somewhere the ad doesn't, so one URL shared by every keyword belongs on the ad instead: set the ad's final URL to this page and re-stage the keywords without --final-url. Keep it only if this page is genuinely each keyword's own destination and not the ad's.`
9972
10029
  ] : [
9973
10030
  "Keep --final-url only if this keyword's destination differs from the ad's final URL \u2014 otherwise drop it and let the keyword inherit the ad's landing page."
9974
10031
  ];
9975
10032
  }
10033
+ function keywordUpdateFinalUrls(args) {
10034
+ const finalUrl = args["final-url"];
10035
+ const clear = args["clear-final-url"] === true;
10036
+ if (typeof finalUrl !== "string" || finalUrl.length === 0) {
10037
+ return clear ? [] : void 0;
10038
+ }
10039
+ if (clear) {
10040
+ failWriteValidation(
10041
+ "--clear-final-url sends the keyword back to the ad's landing page \u2014 pass it OR --final-url, not both"
10042
+ );
10043
+ }
10044
+ return [finalUrl];
10045
+ }
9976
10046
  var keywordWriteSubcommands = {
9977
10047
  add: defineCommand30({
9978
10048
  meta: { name: "add", description: "Add keyword(s) to an ad group \u2014 one --text or a whole batch" },
@@ -10009,15 +10079,28 @@ var keywordWriteSubcommands = {
10009
10079
  }),
10010
10080
  update: defineCommand30({
10011
10081
  meta: { name: "update", description: "Update a keyword" },
10012
- args: { ...customerIdArg, "cpc-bid": { type: "string" }, status: { type: "string" } },
10082
+ args: {
10083
+ ...customerIdArg,
10084
+ "cpc-bid": { type: "string" },
10085
+ status: { type: "string" },
10086
+ "final-url": {
10087
+ type: "string",
10088
+ description: "Rarely needed: send THIS keyword to a page the ad doesn't go to. Leave unset unless the destination is keyword-specific \u2014 otherwise set the ad's final URL"
10089
+ },
10090
+ "clear-final-url": {
10091
+ type: "boolean",
10092
+ description: "Drop this keyword's own landing page so it inherits the ad's again"
10093
+ }
10094
+ },
10013
10095
  run: async ({ args }) => {
10014
10096
  const customerId = requireCustomerId(args);
10097
+ const finalUrls = keywordUpdateFinalUrls(args);
10015
10098
  await stageUpdate(
10016
10099
  "google.keyword.update",
10017
10100
  customerId,
10018
10101
  requireTarget(args, "keyword"),
10019
- { cpcBidMicros: microsFlag(args["cpc-bid"], "--cpc-bid"), status: args.status },
10020
- keywordKillHints(args.status)
10102
+ { cpcBidMicros: microsFlag(args["cpc-bid"], "--cpc-bid"), status: args.status, finalUrls },
10103
+ [...keywordKillHints(args.status), ...keywordFinalUrlHints(args["final-url"], 1)]
10021
10104
  );
10022
10105
  }
10023
10106
  }),