From d91200f51ee803593c0a23712e7c41fb5e9497cf Mon Sep 17 00:00:00 2001 From: Mikhail Fisher Date: Thu, 3 Sep 2026 16:20:11 +0200 Subject: [PATCH 1/6] feat: require explicit metrics and add scope flags --- docs/agent/asa-metrics.md | 11 ++++++++++- src/commands/asa/metrics/index.ts | 25 +++++++++++++++++++++---- src/lib/asa-flags.ts | 14 ++++++++++++++ test/commands/asa-idempotency.test.ts | 2 +- test/commands/asa-writes.test.ts | 6 ++++-- 5 files changed, 50 insertions(+), 8 deletions(-) diff --git a/docs/agent/asa-metrics.md b/docs/agent/asa-metrics.md index c71a392..4f0deda 100644 --- a/docs/agent/asa-metrics.md +++ b/docs/agent/asa-metrics.md @@ -19,7 +19,7 @@ that field. | Command | Flags | Notes | |---|---|---| -| `asa metrics` | `--entity`, `--date-from`, `--date-to` required; `--metric` (repeatable), `--group-by` (repeatable), `--order` (`asc`/`desc`, default `desc`), `--order-by`, `--by-days` (repeatable, max 16), `--order-by-day`, `--page` (default `1`), `--page-size` (default `100`, max `1000`) optional | One row per entity — `ad`, `ad-group`, `campaign`, or `keyword` — already aggregated over the period and already sorted server-side by `--order-by`. A top-N question is one call, `--order-by X --page-size N`; never paginate and sum yourself, and for a full breakdown take one big page (`--page-size 1000`) instead of looping. `--order` defaults to `desc`; pass `--order asc` for a "worst" question instead of "best." `--group-by` is one of `country`, `day`, `month`, `quarter`, `week`, `year`, and its coarseness sets the date-window cap (see [Date window caps](#date-window-caps)). Account-level totals are one call to `metrics overview` instead. | +| `asa metrics` | `--entity`, `--date-from`, `--date-to`, `--metric` (repeatable) required; `--app`, `--campaign`, `--ad-group` (repeatable scope filters), `--group-by` (repeatable), `--order` (`asc`/`desc`, default `desc`), `--order-by`, `--by-days` (repeatable, max 16), `--order-by-day`, `--page` (default `1`), `--page-size` (default `100`, max `1000`) optional | One row per entity — `ad`, `ad-group`, `campaign`, or `keyword` — already aggregated over the period and already sorted server-side by `--order-by`. A top-N question is one call, `--order-by X --page-size N`; never paginate and sum yourself, and for a full breakdown take one big page (`--page-size 1000`) instead of looping. `--order` defaults to `desc`; pass `--order asc` for a "worst" question instead of "best." `--group-by` is one of `country`, `day`, `month`, `quarter`, `week`, `year`, and its coarseness sets the date-window cap (see [Date window caps](#date-window-caps)). Account-level totals are one call to `metrics overview` instead. | | `asa metrics overview` | `--entity`, `--date-from`, `--date-to` required; `--metric` (repeatable, root names only), `--by-days` (repeatable, max 16), `--period-unit` (`day`/`week`/`month`/`quarter`/`year`, default `day`) optional | Returns one response, not a list — totals for the whole entity level plus a per-period series in the same call, no pagination, no client-side summing. That's the one-call answer to a trend question ("today vs. yesterday," "this week vs. last"). Has no `--group-by` and no `--order`/`--order-by`/`--order-by-day`. Shares the 5-per-minute metrics budget with `metrics` (see [The analytics pool](#the-analytics-pool)). Metric names here are root names only — see [Metric vocabulary](#metric-vocabulary). | | `asa search-terms list` | `--date-from` / `--date-to` (default: today); scope with `--ad-group` / `--campaign`; `--page` (default `1`), `--page-size` (default `100`, max `1000`) | The only list command in the `asa` topic that takes period flags — it draws on the same analytics pool as `metrics` (see [The analytics pool](#the-analytics-pool)), not the metadata store the other lists use. The full scope-filter set (`--app`, `--campaign-group`, `--search` included) is in `asa-management.md`. This file covers *reading* search terms; turning what you find into keywords or negative keywords is in `asa-management.md`. | | `asa competitors summary` | `--app-ids` (1–5 Apple App Store IDs, comma-separated) | Covers the last full month across every country — there are deliberately no period or country flags. The first call on a cold cache can take tens of seconds. | @@ -61,6 +61,15 @@ applies to it. one. A wrong name fails the call with an error that lists every valid name, so probing for names costs at most one call and should never be done on purpose. +`--metric` is **required** on `asa metrics`, and the list is not free: every metric named is +computed across the whole entity level before the page is cut, so name the columns you will +actually read rather than sweeping the catalog. `subscribers` and `paid_subscribers` count +unique profiles per entity and cost about twenty times a plain spend-and-installs call; the +same applies to `arppu` and `arpas`, which derive from them. On `--entity keyword` those names +are refused with `422 cli_metric_scope_too_wide` unless `--campaign` or `--ad-group` narrows +the call. Those two flags (plus `--app`) are also the cheapest way to make any keyword call +fast: cost follows the number of entities aggregated, not the page size. + Cohort roots — `revenue`, `arpu`, `arppu`, `arpas` (alias `cohort_arpas`), `roas`, `roi` — expand to `gross_`, `proceeds_`, and `net_` variants (`gross_roas`, `proceeds_revenue`, `net_arpu`, and so on). To rank by a cohort metric with `--order-by`, use the expanded name diff --git a/src/commands/asa/metrics/index.ts b/src/commands/asa/metrics/index.ts index d423782..4f6a1ca 100644 --- a/src/commands/asa/metrics/index.ts +++ b/src/commands/asa/metrics/index.ts @@ -1,7 +1,15 @@ import {Command, Flags} from '@oclif/core' import {asaWrite, createAsaClient} from '../../../lib/asa-client.js' -import {ASA_GROUP_BY_DIMENSIONS, ASA_METRIC_ENTITIES, asaPaginationFlags, byDaysFlag, MAX_BY_DAYS} from '../../../lib/asa-flags.js' +import { + ASA_GROUP_BY_DIMENSIONS, + ASA_METRIC_ENTITIES, + asaPaginationFlags, + byDaysFlag, + MAX_BY_DAYS, + metricsScopeBody, + metricsScopeFlags, +} from '../../../lib/asa-flags.js' import {type PaginatedResponse, paginationParams} from '../../../lib/flags.js' import {printList} from '../../../lib/output.js' @@ -15,7 +23,13 @@ day is grouped, 90 with no period grouping, 180 by week, 365 by month and coarse coarsening the grouping, not by splitting into more calls. Each page is also capped at 5000 breakdown rows (entities × countries × periods); over it the call fails with 422 cli_response_too_large — coarsen the grouping, narrow the window, or reduce page[size]. Budget: 5 metrics calls per minute, at most -2 per 10 seconds, one at a time.` +2 per 10 seconds, one at a time. + +--metric is required and every metric named is computed over the whole entity set, so ask for the +columns you actually read. subscribers and paid_subscribers (and arppu / arpas, which derive from them) +count unique profiles per entity and cost roughly twenty times the rest; on --entity keyword they are +refused unless --campaign or --ad-group scopes the call. Those scope flags are also the cheapest way to +make any keyword call fast, since cost follows the number of entities aggregated, not the page size.` static enableJsonFlag = true static examples = [ '<%= config.bin %> asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31', @@ -27,6 +41,7 @@ the grouping, narrow the window, or reduce page[size]. Budget: 5 metrics calls p static flags = { ...asaPaginationFlags, ...byDaysFlag, + ...metricsScopeFlags, 'date-from': Flags.string({description: 'Start of the period (YYYY-MM-DD)', required: true}), 'date-to': Flags.string({description: 'End of the period (YYYY-MM-DD)', required: true}), entity: Flags.string({description: 'What to report on', options: ASA_METRIC_ENTITIES, required: true}), @@ -37,8 +52,9 @@ the grouping, narrow the window, or reduce page[size]. Budget: 5 metrics calls p }), metric: Flags.string({ description: - 'Metric name (dashboard nomenclature, e.g. spend, taps, gross_roas), repeatable; omit for every metric; a wrong name fails listing all valid ones', + 'Metric name (dashboard nomenclature, e.g. spend, taps, gross_roas), repeatable and required; every metric asked for is computed over the whole entity set, so list only what you read; a wrong name fails listing all valid ones', multiple: true, + required: true, }), order: Flags.string({default: 'desc', description: 'Sort direction', options: ['asc', 'desc']}), 'order-by': Flags.string({ @@ -62,8 +78,9 @@ the grouping, narrow the window, or reduce page[size]. Budget: 5 metrics calls p date_from: flags['date-from'], date_to: flags['date-to'], entity: flags.entity, + metrics: flags.metric, order: flags.order, - ...(flags.metric === undefined ? {} : {metrics: flags.metric}), + ...metricsScopeBody(flags), ...(flags['by-days'] === undefined ? {} : {by_days: flags['by-days']}), ...(flags['group-by'] === undefined ? {} : {group_by: flags['group-by']}), ...(flags['order-by'] === undefined ? {} : {order_by: flags['order-by']}), diff --git a/src/lib/asa-flags.ts b/src/lib/asa-flags.ts index 179b4ca..3a9cee3 100644 --- a/src/lib/asa-flags.ts +++ b/src/lib/asa-flags.ts @@ -80,6 +80,20 @@ export const adScopeFlags = { ...searchFilter, } +export const metricsScopeFlags = { + 'ad-group': idFilter('ad group'), + app: idFilter('app'), + campaign: idFilter('campaign'), +} + +export function metricsScopeBody(flags: {'ad-group'?: string[]; app?: string[]; campaign?: string[]}) { + return { + ...(flags['ad-group'] === undefined ? {} : {ad_group_id: flags['ad-group']}), + ...(flags.app === undefined ? {} : {app_id: flags.app}), + ...(flags.campaign === undefined ? {} : {campaign_id: flags.campaign}), + } +} + export const statusFilter = (options: string[]) => ({ status: Flags.string({description: 'Keep only rows in this state', options}), }) diff --git a/test/commands/asa-idempotency.test.ts b/test/commands/asa-idempotency.test.ts index 4620bdc..7eef445 100644 --- a/test/commands/asa-idempotency.test.ts +++ b/test/commands/asa-idempotency.test.ts @@ -75,7 +75,7 @@ describe('asa idempotency', () => { it('metrics posts carry a key too, so a network retry cannot double-submit', async () => { fetchStub = mockFetch([EMPTY_LIST_RESPONSE]) - await runCommand('asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31') + await runCommand('asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --metric spend') expect(keyOf(fetchStub, 0)).to.match(UUID_RE) }) diff --git a/test/commands/asa-writes.test.ts b/test/commands/asa-writes.test.ts index 0e2d1bf..e5c8706 100644 --- a/test/commands/asa-writes.test.ts +++ b/test/commands/asa-writes.test.ts @@ -448,13 +448,15 @@ describe('asa writes', () => { expect(body.by_days).to.deep.equal([7, 90]) await runCommand( - 'asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --by-days 90 --order-by gross_roas --order-by-day 90', + 'asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --metric roas --by-days 90 --order-by gross_roas --order-by-day 90', ) const ranked = JSON.parse(fetchStub.getCall(1).args[1].body as string) expect(ranked).to.deep.include({order_by: 'gross_roas', order_by_day: 90}) const byDays = Array.from({length: 17}, (_, index) => `--by-days ${index}`).join(' ') - const {error} = await runCommand(`asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 ${byDays}`) + const {error} = await runCommand( + `asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --metric spend ${byDays}`, + ) expect(error?.message).to.contain('At most 16') expect(fetchStub.callCount).to.equal(2) }) From f78c5e9fb590c607010e264ee4f2d5ce7afc8668 Mon Sep 17 00:00:00 2001 From: Mikhail Fisher Date: Thu, 3 Sep 2026 17:42:23 +0200 Subject: [PATCH 2/6] docs: scope guard covers every entity and fix examples --- docs/agent/asa-metrics.md | 15 ++++++++------- src/commands/asa/metrics/index.ts | 13 +++++++------ 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/docs/agent/asa-metrics.md b/docs/agent/asa-metrics.md index 4f0deda..1299883 100644 --- a/docs/agent/asa-metrics.md +++ b/docs/agent/asa-metrics.md @@ -4,9 +4,10 @@ No `asa` command takes `--app` to select scope — the token's company already f The one exception is `--app` as a list filter, and among the four commands here it applies only to `search-terms list` (the full filter set is in `asa-management.md`). -`metrics` and `metrics overview` take no scope filter at all — no `--app`, `--campaign`, -`--ad-group`, `--search`, `--status`. A call covers the whole account at the `--entity` -level and date window you give it; narrow the answer by matching the returned rows against +`metrics` takes three scope filters and nothing else — `--app`, `--campaign`, `--ad-group`, +all repeatable. There is no `--search` and no `--status`. `metrics overview` takes no scope +filter at all: it always covers the whole account at the `--entity` level and date window you +give it. Where a filter is missing, narrow the answer by matching the returned rows against ids from a scoped list in `asa-management.md`, not by looking for a flag that isn't there. `asa-management.md`'s `list` and `get` commands return metadata only — no spend, no ROAS, @@ -64,10 +65,10 @@ names costs at most one call and should never be done on purpose. `--metric` is **required** on `asa metrics`, and the list is not free: every metric named is computed across the whole entity level before the page is cut, so name the columns you will actually read rather than sweeping the catalog. `subscribers` and `paid_subscribers` count -unique profiles per entity and cost about twenty times a plain spend-and-installs call; the -same applies to `arppu` and `arpas`, which derive from them. On `--entity keyword` those names -are refused with `422 cli_metric_scope_too_wide` unless `--campaign` or `--ad-group` narrows -the call. Those two flags (plus `--app`) are also the cheapest way to make any keyword call +unique profiles per entity and cost about seventeen times a plain spend-and-installs call; the +same applies to `arppu` and `arpas`, which derive from them. Whatever the `--entity`, those +names are refused with `422 cli_metric_scope_too_wide` unless `--campaign` or `--ad-group` +narrows the call. Those two flags (plus `--app`) are also the cheapest way to make any call fast: cost follows the number of entities aggregated, not the page size. Cohort roots — `revenue`, `arpu`, `arppu`, `arpas` (alias `cohort_arpas`), `roas`, `roi` — diff --git a/src/commands/asa/metrics/index.ts b/src/commands/asa/metrics/index.ts index 4f6a1ca..f6fca9f 100644 --- a/src/commands/asa/metrics/index.ts +++ b/src/commands/asa/metrics/index.ts @@ -27,16 +27,17 @@ the grouping, narrow the window, or reduce page[size]. Budget: 5 metrics calls p --metric is required and every metric named is computed over the whole entity set, so ask for the columns you actually read. subscribers and paid_subscribers (and arppu / arpas, which derive from them) -count unique profiles per entity and cost roughly twenty times the rest; on --entity keyword they are -refused unless --campaign or --ad-group scopes the call. Those scope flags are also the cheapest way to -make any keyword call fast, since cost follows the number of entities aggregated, not the page size.` +count unique profiles per entity and cost roughly seventeen times the rest; whatever the --entity, they +are refused unless --campaign or --ad-group scopes the call. Those scope flags are also the cheapest way +to make any call fast, since cost follows the number of entities aggregated, not the page size.` static enableJsonFlag = true static examples = [ - '<%= config.bin %> asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31', - '<%= config.bin %> asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --order-by spend --page-size 5', - '<%= config.bin %> asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --group-by country --page-size 1000', + '<%= config.bin %> asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --metric spend --metric adapty_installs', + '<%= config.bin %> asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --metric spend --order-by spend --page-size 5', + '<%= config.bin %> asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --metric spend --group-by country --page-size 1000', '<%= config.bin %> asa metrics --entity keyword --date-from 2026-07-01 --date-to 2026-07-31 --metric spend --metric roas', '<%= config.bin %> asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --metric roas --by-days 7 --by-days 90', + '<%= config.bin %> asa metrics --entity keyword --date-from 2026-07-01 --date-to 2026-07-31 --metric arpas --campaign 0f0e...', ] static flags = { ...asaPaginationFlags, From ca8c303a1e171c25d6de35544855234aec26a4c4 Mon Sep 17 00:00:00 2001 From: Mikhail Fisher Date: Mon, 7 Sep 2026 13:01:06 +0200 Subject: [PATCH 3/6] feat: add-as-keyword action flags and rule.json schema --- CLAUDE.md | 4 +- README.md | 134 ++++++- docs/agent/asa-management.md | 65 +++- .../references/asa-agent-playbook.md | 57 +++ skills/adapty-cli/references/cli-commands.md | 4 +- src/commands/asa/automations/create.ts | 25 +- src/commands/asa/automations/update.ts | 39 ++- src/lib/asa-flags.ts | 39 +++ src/lib/asa-keyword-action.ts | 203 +++++++++++ test/commands/asa-automations.test.ts | 329 ++++++++++++++++++ test/commands/asa-writes.test.ts | 11 +- 11 files changed, 894 insertions(+), 16 deletions(-) create mode 100644 src/lib/asa-keyword-action.ts create mode 100644 test/commands/asa-automations.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 791be88..7687f18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,8 +42,10 @@ src/ output.ts # printResponse(), printList() helpers (auto-formats snake_case keys) app-url.ts # dashboard base URL (ADAPTY_APP_URL): route building + rehosting API-issued links asa-client.ts # factory: ApiClient against the ASA service (errorFormat 'asa') - asa-flags.ts # shared asa flags: scope filters, period, money, batch caps + asa-flags.ts # shared asa flags: scope filters, period, money, batch caps, add-as-keyword action asa-confirm.ts # mutation preview + confirmation prompt (--yes; refuses when piped or --json) + asa-keyword-action.ts # add-as-keyword-to params: rebuild from flags per operate_with, reject + # the shapes the API would silently mis-resolve asa-schemas.ts # response typings for asa entities preview.ts # flow config normalization + render URL / gzip fragment building ``` diff --git a/README.md b/README.md index 2029bdd..f9642a0 100644 --- a/README.md +++ b/README.md @@ -199,12 +199,142 @@ adapty asa product-pages sync [--adam-id 123456] adapty asa automations list adapty asa automations get AUTOMATION_ID -adapty asa automations create --file rule.json [--run-now] -adapty asa automations update AUTOMATION_ID [--stop] [--start] [--name "..."] [--file rule.json] +adapty asa automations create --file rule.json [--run-now] [--target-ad-group UUID ...] +adapty asa automations update AUTOMATION_ID [--stop] [--start] [--name "..."] [--file rule.json] [--target-ad-group UUID ...] adapty asa automations run AUTOMATION_ID [--dry-run] adapty asa automations runs AUTOMATION_ID ``` +A rule file carries the whole rule: `name`, `status` (1 active, 0 stopped), `operate_with` (what the +rule iterates over), `apply_to` (where it looks), exactly one condition, exactly one action and a +`run_frequency`. The API stores one action and one condition per rule and rejects anything else. + +| Field | Shape | +| --------------- | ------------------------------------------------------------------------------------------- | +| `operate_with` | `search-term`, `targeting-keyword`, `campaign`, `ad-group` | +| `apply_to[]` | `{"internal_id": UUID, "type": "campaign-group" \| "app" \| "campaign" \| "ad-group" \| "targeting-keywords"}` | +| `conditions[0]` | `{"operator": ..., "args": number, "operand": {...}}`, or `{"operator": "and" \| "or", "args": [condition, ...]}` | +| `operator` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte` for a leaf; `and`, `or` to nest | +| `operand.field` | a metric name in dashboard nomenclature — the same vocabulary `asa metrics --metric` takes | +| `date_range_type` | `today`, `yesterday`, `last_1_d`, `last_3_d`, `last_7_d`, `last_14_d`, `last_28_d`, `last_30_d`, `last_60_d`, `last_90_d`, `custom` | +| `run_frequency` | `{"type": "daily", "hour": 8}`, `{"type": "hour", "value": 24, "start_time": 8}`, `{"type": "weekly", "weekdays": ["monday"], "hour": 8}`, `{"type": "monthly", "days": [1], "hour": 8}`, `{"type": "once", "date_time": "2026-10-01T09:00:00Z"}` — `hour` and `start_time` are UTC | + +The `add-as-keyword-to` action promotes what the rule found into a keyword in one or more target ad +groups. Its `params` differ by `operate_with`, and the API picks the variant by shape without a +discriminator: a key that belongs to another variant makes it silently choose that variant and drop +the rest, which is how a rule ends up as "Add as keyword to 0 ad groups". `negate` and +`skip_enable_duplicate_keywords` exist only on a `search-term` rule, `pause_in_original_ad_group` +only on a `targeting-keyword` one, and `targets` holds `internal_ids` — never `ids`. + +A full `search-term` rule — every term with 10+ taps over the last week becomes an exact keyword in +the target ad group at the term's own CPT, and is negated in the ad group it came from: + +```json +{ + "name": "Search term harvester", + "status": 1, + "operate_with": "search-term", + "apply_to": [{"internal_id": "CAMPAIGN_UUID", "type": "campaign"}], + "conditions": [ + { + "operator": "gte", + "args": 10, + "operand": { + "field": "taps", + "field_type": "base_field", + "date_range_type": "last_7_d", + "date_range_size": 0, + "date_range_offset": 0, + "by_days": null + } + } + ], + "actions": [ + { + "type": "add-as-keyword-to", + "params": { + "targets": {"type": "ad-group", "internal_ids": ["AD_GROUP_UUID"]}, + "cpt_bid": {"type": "search_term_current_cpt", "value": null}, + "match_type": "EXACT", + "negate": {"enabled": true, "type": "ad-group"}, + "skip_enable_duplicate_keywords": false + } + } + ], + "run_frequency": {"type": "daily", "hour": 8} +} +``` + +The same action on a `targeting-keyword` rule — a broad keyword that has earned installs graduates +into the exact ad group at its current bid and is paused where it was: + +```json +{ + "name": "Graduate broad keywords to exact", + "status": 1, + "operate_with": "targeting-keyword", + "apply_to": [{"internal_id": "SOURCE_AD_GROUP_UUID", "type": "ad-group"}], + "conditions": [ + { + "operator": "gte", + "args": 3, + "operand": { + "field": "total_installs", + "field_type": "base_field", + "date_range_type": "last_14_d", + "date_range_size": 0, + "date_range_offset": 0, + "by_days": null + } + } + ], + "actions": [ + { + "type": "add-as-keyword-to", + "params": { + "targets": {"type": "ad-group", "internal_ids": ["EXACT_AD_GROUP_UUID"]}, + "cpt_bid": {"type": "keyword_current_bid", "value": null}, + "match_type": "EXACT", + "pause_in_original_ad_group": true + } + } + ], + "run_frequency": {"type": "daily", "hour": 8} +} +``` + +Rather than hand-writing that `params` block, pass the action flags — they fill in or override +`actions[0].params` in the file, and the CLI refuses a rule the API would have quietly accepted and +broken: + +```sh +adapty asa automations create --file rule.json --target-ad-group AD_GROUP_UUID \ + --match-type EXACT --cpt-bid-type search_term_current_cpt --negate ad-group +adapty asa automations create --file rule.json --target-ad-group AD_GROUP_UUID \ + --match-type BROAD --cpt-bid-type set_to --cpt-bid 1.50 --no-negate --skip-enable-duplicates +adapty asa automations update AUTOMATION_ID --target-ad-group AD_GROUP_UUID \ + --match-type EXACT --cpt-bid-type search_term_current_cpt +``` + +| Flag | Where it lands | +| -------------------------- | --------------------------------------------------------------------- | +| `--target-ad-group` | `targets.internal_ids`, repeatable; a rule without one does nothing | +| `--match-type` | `match_type`: `BROAD` or `EXACT` | +| `--cpt-bid-type` | `cpt_bid.type`: `ad_group_default_bid`, `set_to`, `search_term_current_cpt`, `keyword_current_bid` | +| `--cpt-bid` | `cpt_bid.value`; required by `set_to`, rejected with the other types | +| `--negate` / `--no-negate` | `negate` (search-term rules): `ad-group` or `campaign`, or off | +| `--skip-enable-duplicates` | `skip_enable_duplicate_keywords` (search-term rules) | +| `--pause-original` | `pause_in_original_ad_group` (targeting-keyword rules) | + +`--cpt-bid-type` and `--match-type` have no defaults, here or in the API: a bid and a match type are +a spend decision and a reach decision, so when `params` are built from scratch the CLI asks for them +instead of guessing. On `update`, an action flag turns the call into a read-modify-write — the rule +is read, `actions[0].params` is rebuilt and the whole `actions` list is written back, since the API +replaces it wholesale. That also repairs a rule whose stored `params` carry the wrong shape: only +the keys that fit are kept, the strays are dropped, and anything missing has to come from a flag. A +dashboard edit made between the read and the write is overwritten. + + Metrics take an entity level, a period and an optional metric selection. Rows come back one per entity, aggregated and sorted server-side, so a top-N or a breakdown is a single call — use `--order-by` with a small `--page-size` for rankings, `metrics overview` for account totals and time series, and one big page (up to diff --git a/docs/agent/asa-management.md b/docs/agent/asa-management.md index 0d47ba8..c2f87c1 100644 --- a/docs/agent/asa-management.md +++ b/docs/agent/asa-management.md @@ -120,11 +120,72 @@ Invoicing Options. They map to `loc_invoice_details` in the request: advertiser |---|---|---| | `asa automations list` | pagination only, no scope filters | `status` in the response is `1` for active, `0` for stopped. | | `asa automations get ` | positional UUID | Same `status` convention as `list`. | -| `asa automations create` | `--file rule.json` (or `--file -` for stdin) | `--run-now` queues the rule's first run immediately after creation. | -| `asa automations update ` | one or more of `--stop`, `--start`, `--name`, `--file` | If you pass `--file`, that file must not carry `internal_id` — the CLI treats a JSON body with `internal_id` in it as an error, since that field is server-assigned. | +| `asa automations create` | `--file rule.json` (or `--file -` for stdin); for an `add-as-keyword-to` action also `--target-ad-group`, `--match-type`, `--cpt-bid-type`, `--cpt-bid`, `--negate` / `--no-negate`, `--skip-enable-duplicates`, `--pause-original` | `--run-now` queues the rule's first run immediately after creation. The file must carry exactly one action and exactly one condition — see [Rule files](#rule-files). | +| `asa automations update ` | one or more of `--stop`, `--start`, `--name`, `--file`, or any action flag from `create` | If you pass `--file`, that file must not carry `internal_id` — the CLI treats a JSON body with `internal_id` in it as an error, since that field is server-assigned. An action flag makes this a read-modify-write: two calls, and the whole `actions` list is replaced — see [Rule files](#rule-files). | | `asa automations run ` | `--dry-run` optional | Queued; the command prints a run id. `--dry-run` evaluates the rule and logs what it would do without touching Apple. | | `asa automations runs ` | positional UUID | Past runs for this automation, dry runs included. | +### Rule files + +A rule file is the whole rule. `name`, `status` (`1` active, `0` stopped), `operate_with` +(what the rule iterates over: `search-term`, `targeting-keyword`, `campaign`, `ad-group`), +`apply_to` (where it looks), exactly one `conditions` entry, exactly one `actions` entry +and a `run_frequency`. Anything else is rejected. + +| Field | Shape | +|---|---| +| `apply_to[]` | `{"internal_id": UUID, "type": "campaign-group" \| "app" \| "campaign" \| "ad-group" \| "targeting-keywords"}` | +| `conditions[0]` | a leaf `{"operator": "gte", "args": 10, "operand": {...}}`, or `{"operator": "and" \| "or", "args": [leaf, leaf]}` to combine | +| `operator` | `eq`, `neq`, `gt`, `gte`, `lt`, `lte` on a leaf; `and`, `or` to nest | +| `operand` | `{"field": metric, "field_type": "base_field", "date_range_type": ..., "date_range_size": 0, "date_range_offset": 0, "by_days": null}` — `field` is a metric name from the same vocabulary `asa metrics --metric` takes, `by_days` only for a cohort metric | +| `date_range_type` | `today`, `yesterday`, `last_1_d`, `last_3_d`, `last_7_d`, `last_14_d`, `last_28_d`, `last_30_d`, `last_60_d`, `last_90_d`, `custom` | +| `run_frequency` | `{"type": "daily", "hour": 8}`, `{"type": "hour", "value": 24, "start_time": 8}`, `{"type": "weekly", "weekdays": ["monday"], "hour": 8}`, `{"type": "monthly", "days": [1], "hour": 8}`, `{"type": "once", "date_time": "2026-10-01T09:00:00Z"}`; hours are UTC | + +**Never copy an action's `params` from another rule.** `params` is a union the API resolves +by shape, with no discriminator: a key that belongs to a different action makes it pick that +action's variant and silently drop everything else, and the call still answers `200`. An +`add-as-keyword-to` action given the `add-as-negative-keyword` shape (`{"target_type": ..., +"ids": [...]}`) becomes "Add as keyword to 0 ad groups" — the ad group is right there in +`ids` and the rule does nothing. `targets` on this action holds `internal_ids`, never `ids`. + +Which `params` keys `add-as-keyword-to` takes depends on `operate_with`: + +| `operate_with` | `params` | +|---|---| +| `search-term` | `targets`, `cpt_bid`, `match_type`, `negate`, `skip_enable_duplicate_keywords` | +| `targeting-keyword` | `targets`, `cpt_bid`, `match_type`, `pause_in_original_ad_group` | + +So don't write that block by hand — pass the action flags and let the CLI build it. They +fill in or override `actions[0].params`, and the CLI exits `2` on the mistakes the API +would have accepted: a flag that does not belong to the rule's `operate_with`, an action +that is not `add-as-keyword-to`, or a rule left with no target ad groups. + +```sh +adapty asa automations create --file rule.json --target-ad-group AD_GROUP_UUID \ + --match-type EXACT --cpt-bid-type search_term_current_cpt --negate ad-group +adapty asa automations run AUTOMATION_UUID --dry-run +``` + +| Flag | Lands in | Values | +|---|---|---| +| `--target-ad-group` | `targets.internal_ids` | repeatable UUID; a rule with none does nothing | +| `--match-type` | `match_type` | `BROAD`, `EXACT` | +| `--cpt-bid-type` | `cpt_bid.type` | `ad_group_default_bid`, `set_to`, `search_term_current_cpt`, `keyword_current_bid` | +| `--cpt-bid` | `cpt_bid.value` | required by `set_to`, rejected with every other type | +| `--negate` / `--no-negate` | `negate` | `ad-group`, `campaign`, or off — `search-term` rules only | +| `--skip-enable-duplicates` | `skip_enable_duplicate_keywords` | `search-term` rules only | +| `--pause-original` | `pause_in_original_ad_group` | `targeting-keyword` rules only | + +`--cpt-bid-type` and `--match-type` have no default in the CLI or in the API — a bid and a +reach setting are the user's decision. When `params` are built from scratch the CLI asks for +them instead of guessing, so get them from the user rather than picking one. + +On `update`, an action flag reads the rule, rebuilds `actions[0].params` and writes the whole +`actions` list back, because the API replaces `actions` wholesale rather than merging it. +That is also how a rule with the wrong `params` shape is repaired: the params are rebuilt +from scratch, only the keys that fit the expected shape are kept, and whatever is missing has +to come from a flag. A dashboard edit made between the read and the write is overwritten. + ## Scope filters Filters narrow the query itself, not the printed page: an unfiltered `asa keywords list` diff --git a/skills/adapty-cli/references/asa-agent-playbook.md b/skills/adapty-cli/references/asa-agent-playbook.md index 1e7f8bc..522cd64 100644 --- a/skills/adapty-cli/references/asa-agent-playbook.md +++ b/skills/adapty-cli/references/asa-agent-playbook.md @@ -187,6 +187,58 @@ Read `serving_status` / `serving_state_reasons` in the response: the command pri for the reasons above. For an existing LOC campaign, `asa campaigns update --invoice-*...` sets the Invoicing Options. +**"Automate it — harvest converting search terms into keywords"** — a rule, not a loop of writes. +Never copy the `params` of a neighbouring rule: `params` is a union the API resolves by shape with no +discriminator, so a key belonging to another action makes it choose that action and silently drop the +rest — that is how a rule ends up as "Add as keyword (EXACT) to 0 ad groups". Build the file for the +rule itself and let the flags write `params`: + +```sh +cat > rule.json <<'JSON' +{ + "name": "Search term harvester", + "status": 1, + "operate_with": "search-term", + "apply_to": [{"internal_id": "CAMPAIGN_UUID", "type": "campaign"}], + "conditions": [ + { + "operator": "gte", + "args": 10, + "operand": { + "field": "taps", + "field_type": "base_field", + "date_range_type": "last_7_d", + "date_range_size": 0, + "date_range_offset": 0, + "by_days": null + } + } + ], + "actions": [{"type": "add-as-keyword-to", "params": {}}], + "run_frequency": {"type": "daily", "hour": 8} +} +JSON +adapty asa automations create --file rule.json --target-ad-group AD_GROUP_UUID \ + --match-type EXACT --cpt-bid-type search_term_current_cpt --negate ad-group +adapty asa automations run AUTOMATION_ID --dry-run +``` + +`--cpt-bid-type` and `--match-type` have no defaults anywhere — a bid and a match type are the user's +call, so ask rather than pick. Which flags apply follows `operate_with`: `--negate` / +`--no-negate` / `--skip-enable-duplicates` on a `search-term` rule, `--pause-original` on a +`targeting-keyword` one, and the CLI exits 2 rather than sending a mismatch. Verify with +`--dry-run` before letting it write to Apple; `asa automations get ` shows the stored `params`. + +**"Fix a rule that adds keywords to 0 ad groups"** — read-modify-write on the same flags; the rule's +`params` are rebuilt from scratch, so the stray keys of the wrong shape are dropped instead of +patched over. Whatever the broken `params` did not supply has to come from a flag: + +```sh +adapty asa automations get AUTOMATION_ID --json +adapty asa automations update AUTOMATION_ID --target-ad-group AD_GROUP_UUID \ + --match-type EXACT --cpt-bid-type search_term_current_cpt +``` + ## What the failed sessions did wrong (do not repeat) - Looped `--page 1..4` to build an account total → burned the 5/min budget, hit 429s, gave up. @@ -196,3 +248,8 @@ Invoicing Options. - Added an unrequested previous-period comparison → doubled the calls; the user only asked for now. - Retried with a guessed `sleep 25` instead of the `Retry-After` value → wasted the retry inside the same window and struck the cool-down counter again. +- Copied an `add-as-keyword-to` action's `params` off a neighbouring `add-as-negative-keyword` rule → + 43 rules created that add keywords to 0 ad groups. The API answered 200 every time: it matched the + foreign shape, kept `targets.ids`, and dropped `cpt_bid`, `match_type`, `negate` and + `skip_enable_duplicate_keywords`. Right: `params` come from the action flags, never from another + rule of a different action type. diff --git a/skills/adapty-cli/references/cli-commands.md b/skills/adapty-cli/references/cli-commands.md index 93756cc..ad2ea8d 100644 --- a/skills/adapty-cli/references/cli-commands.md +++ b/skills/adapty-cli/references/cli-commands.md @@ -184,8 +184,8 @@ every `asa` command answers `402 ads_manager_subscription_required`. Start with | `asa creatives list` | the Apple `creative_id` an ad is created against; filter by `--app` | | `asa product-pages sync` | `--adam-id` optional; queued, 200 means already running or nothing to sync | | `asa automations list` / `get ` | `status` is 1 for active, 0 for stopped | -| `asa automations create` | `--file rule.json` (or `-` for stdin); `--run-now` queues the first run | -| `asa automations update ` | `--stop` / `--start` / `--name` / `--file`; the file must not carry `internal_id` | +| `asa automations create` | `--file rule.json` (or `-` for stdin), which must carry exactly one action and one condition; `--run-now` queues the first run; for an `add-as-keyword-to` action the params come from `--target-ad-group` (repeatable UUID), `--match-type`, `--cpt-bid-type`, `--cpt-bid`, `--negate` / `--no-negate`, `--skip-enable-duplicates` (search-term rules), `--pause-original` (targeting-keyword rules) — see README for the rule.json schema | +| `asa automations update ` | `--stop` / `--start` / `--name` / `--file`; the file must not carry `internal_id`; the same action flags as `create` — passing one reads the rule, rebuilds `actions[0].params` and writes the whole `actions` list back (two calls, overwrites a concurrent dashboard edit), which is also how a rule with the wrong `params` shape is repaired | | `asa automations run ` | queued, prints a run ID; `--dry-run` evaluates without touching Apple | | `asa automations runs ` | past runs, including dry runs | | `asa metrics` | `--entity`, `--date-from`, `--date-to`; `--metric` repeatable, `--group-by`, `--order-by`, `--by-days` (max 16), `--order-by-day`; one server-sorted row per entity — top-N is one call | diff --git a/src/commands/asa/automations/create.ts b/src/commands/asa/automations/create.ts index 611da57..3b59851 100644 --- a/src/commands/asa/automations/create.ts +++ b/src/commands/asa/automations/create.ts @@ -5,17 +5,27 @@ import type {AsaAutomationMutationDTO} from '../../../lib/asa-schemas.js' import {asaWrite, createAsaClient, noteReplay} from '../../../lib/asa-client.js' import {confirmFlags, confirmMutation} from '../../../lib/asa-confirm.js' -import {idempotencyFlags} from '../../../lib/asa-flags.js' +import {addKeywordActionFlags, idempotencyFlags} from '../../../lib/asa-flags.js' +import { + type AddKeywordActionFlags, + hasAddKeywordActionFlags, + rebuildAddKeywordAction, + requireSingleAction, +} from '../../../lib/asa-keyword-action.js' import {printResponse} from '../../../lib/output.js' export default class AsaAutomationsCreate extends Command { - static description = 'Create an automation rule from a JSON rule file' + static description = + 'Create an automation rule from a JSON rule file. The add-as-keyword action flags fill in or override actions[0].params, so the bid, the match type and the target ad groups need no hand-written JSON.' static enableJsonFlag = true static examples = [ '<%= config.bin %> asa automations create --file rule.json', '<%= config.bin %> asa automations create --file rule.json --run-now', + '<%= config.bin %> asa automations create --file rule.json --target-ad-group AD_GROUP_ID --match-type EXACT --cpt-bid-type search_term_current_cpt', + '<%= config.bin %> asa automations create --file rule.json --target-ad-group AD_GROUP_ID --match-type EXACT --cpt-bid-type set_to --cpt-bid 1.50 --negate ad-group', ] static flags = { + ...addKeywordActionFlags, ...confirmFlags, ...idempotencyFlags, file: Flags.string({description: 'JSON file with the rule body, or - to read stdin', required: true}), @@ -27,6 +37,7 @@ export default class AsaAutomationsCreate extends Command { const body = await this.readRule(flags.file) if (flags['run-now']) body.run_immediately = true + this.applyActionFlags(body, flags) const summary = flags['run-now'] ? 'Create automation rule and run it immediately' : 'Create automation rule' await confirmMutation(this, {body, method: 'POST', path: '/automations/', summary}, flags.yes) @@ -47,6 +58,16 @@ export default class AsaAutomationsCreate extends Command { return result } + private applyActionFlags(body: Record, flags: AddKeywordActionFlags): void { + try { + requireSingleAction(body.actions) + if (!hasAddKeywordActionFlags(flags)) return + body.actions = rebuildAddKeywordAction(body.actions, body.operate_with, flags) + } catch (error) { + this.error(error instanceof Error ? error.message : String(error), {exit: 2}) + } + } + private async readRule(path: string): Promise> { let raw: string try { diff --git a/src/commands/asa/automations/update.ts b/src/commands/asa/automations/update.ts index 8b47c67..7f7a88e 100644 --- a/src/commands/asa/automations/update.ts +++ b/src/commands/asa/automations/update.ts @@ -1,11 +1,17 @@ import {Args, Command, Flags} from '@oclif/core' import {readFile} from 'node:fs/promises' -import type {AsaAutomationMutationDTO} from '../../../lib/asa-schemas.js' +import type {ApiClient} from '../../../lib/api-client.js' +import type {AsaAutomationDTO, AsaAutomationMutationDTO} from '../../../lib/asa-schemas.js' import {asaWrite, createAsaClient, noteReplay} from '../../../lib/asa-client.js' import {confirmFlags, confirmMutation} from '../../../lib/asa-confirm.js' -import {idempotencyFlags} from '../../../lib/asa-flags.js' +import {addKeywordActionFlags, idempotencyFlags} from '../../../lib/asa-flags.js' +import { + type AddKeywordActionFlags, + hasAddKeywordActionFlags, + rebuildAddKeywordAction, +} from '../../../lib/asa-keyword-action.js' import {isValidUuid} from '../../../lib/flags.js' import {printResponse} from '../../../lib/output.js' @@ -13,13 +19,16 @@ export default class AsaAutomationsUpdate extends Command { static args = { automation_id: Args.string({description: 'Automation rule ID (UUID)', required: true}), } - static description = 'Change an automation rule: stop it, rename it, or replace parts of the rule' + static description = + 'Change an automation rule: stop it, rename it, or replace parts of the rule. An add-as-keyword action flag turns the call into a read-modify-write: the rule is read, actions[0].params is rebuilt from the flags and the whole actions list is written back — an edit someone makes in the dashboard in between is overwritten. This is also the way to repair a rule whose stored params carry the wrong shape.' static enableJsonFlag = true static examples = [ '<%= config.bin %> asa automations update UUID --stop', '<%= config.bin %> asa automations update UUID --file rule.json', + '<%= config.bin %> asa automations update UUID --target-ad-group AD_GROUP_ID --match-type EXACT --cpt-bid-type search_term_current_cpt', ] static flags = { + ...addKeywordActionFlags, ...confirmFlags, ...idempotencyFlags, file: Flags.string({description: 'JSON file with the parts to change, or - to read stdin'}), @@ -37,21 +46,24 @@ export default class AsaAutomationsUpdate extends Command { if (flags.start) body.status = 1 if (flags.stop) body.status = 0 - if (Object.keys(body).length === 0) { - this.error('Nothing to change. Pass --stop, --start, --name or --file.', {exit: 2}) + const actionFlags = hasAddKeywordActionFlags(flags) + if (Object.keys(body).length === 0 && !actionFlags) { + this.error('Nothing to change. Pass --stop, --start, --name, --file or an action flag.', {exit: 2}) } if ('internal_id' in body) { this.error('Remove internal_id from the file: the rule ID comes from the command line.', {exit: 2}) } + const client = await createAsaClient(this.config) + if (actionFlags) await this.rebuildAction(client, args.automation_id, body, flags) + await confirmMutation( this, {body, method: 'PUT', path: `/automations/${args.automation_id}/`, summary: 'Update automation rule'}, flags.yes, ) - const client = await createAsaClient(this.config) const {replayed, result} = await asaWrite( client, 'put', @@ -86,4 +98,19 @@ export default class AsaAutomationsUpdate extends Command { for await (const chunk of process.stdin) chunks.push(chunk as Buffer) return Buffer.concat(chunks).toString('utf8') } + + // Read-modify-write: the API replaces `actions` wholesale, so the whole list has to be sent back. + private async rebuildAction( + client: ApiClient, + automationId: string, + body: Record, + flags: AddKeywordActionFlags, + ): Promise { + const rule = await client.get(`/automations/${automationId}`) + try { + body.actions = rebuildAddKeywordAction(body.actions ?? rule.actions, body.operate_with ?? rule.operate_with, flags) + } catch (error) { + this.error(error instanceof Error ? error.message : String(error), {exit: 2}) + } + } } diff --git a/src/lib/asa-flags.ts b/src/lib/asa-flags.ts index 179b4ca..48dec73 100644 --- a/src/lib/asa-flags.ts +++ b/src/lib/asa-flags.ts @@ -3,6 +3,7 @@ import {Flags} from '@oclif/core' import type {QueryParams} from './api-client.js' import type {AsaLocInvoiceDetails, AsaMoney, AsaMutationError} from './asa-schemas.js' +import {CPT_BID_TYPES, KEYWORD_MATCH_TYPES, NEGATE_TYPES} from './asa-keyword-action.js' import {describeListedError} from './errors.js' import {isValidUuid} from './flags.js' @@ -149,6 +150,44 @@ export const currencyFlag = { currency: Flags.string({default: 'USD', description: 'Currency code for the amounts in this call'}), } +// Params of the add-as-keyword-to action, shared by `automations create` and `automations update`. +// The API picks the params variant by shape, so which of these apply depends on the rule's +// operate_with: --negate/--no-negate/--skip-enable-duplicates are search-term only, +// --pause-original is targeting-keyword only. See lib/asa-keyword-action.ts. +export const addKeywordActionFlags = { + 'cpt-bid': moneyFlag('Bid for the created keyword; goes with --cpt-bid-type set_to only'), + 'cpt-bid-type': Flags.string({ + description: 'Where the bid of the created keyword comes from; the API has no default', + options: CPT_BID_TYPES, + }), + 'match-type': Flags.string({ + description: 'Match type of the created keyword; the API has no default', + options: KEYWORD_MATCH_TYPES, + }), + negate: Flags.string({ + description: 'Also add the search term as a negative keyword at this level (search-term rules)', + exclusive: ['no-negate'], + options: NEGATE_TYPES, + }), + 'no-negate': Flags.boolean({ + description: 'Leave no negative keyword behind (search-term rules)', + exclusive: ['negate'], + }), + 'pause-original': Flags.boolean({ + allowNo: true, + description: 'Pause the source keyword in its own ad group (targeting-keyword rules)', + }), + 'skip-enable-duplicates': Flags.boolean({ + allowNo: true, + description: 'Skip a keyword that already exists in the target ad group instead of enabling it (search-term rules)', + }), + 'target-ad-group': Flags.string({ + description: 'Ad group the keyword is added to (UUID), repeatable; a rule without one does nothing', + multiple: true, + parse: parseId, + }), +} + export const idempotencyFlags = { 'idempotency-key': Flags.string({ description: diff --git a/src/lib/asa-keyword-action.ts b/src/lib/asa-keyword-action.ts new file mode 100644 index 0000000..a5cd387 --- /dev/null +++ b/src/lib/asa-keyword-action.ts @@ -0,0 +1,203 @@ +import {isValidUuid} from './flags.js' + +export const ADD_AS_KEYWORD_ACTION = 'add-as-keyword-to' + +export const CPT_BID_TYPES = ['ad_group_default_bid', 'keyword_current_bid', 'search_term_current_cpt', 'set_to'] +export const KEYWORD_MATCH_TYPES = ['BROAD', 'EXACT'] +export const NEGATE_TYPES = ['ad-group', 'campaign'] + +const SEARCH_TERM = 'search-term' +const TARGETING_KEYWORD = 'targeting-keyword' + +// KeywordMatchType also carries AUTO. --match-type never offers it — an automation creates a +// keyword, and AUTO is not something a keyword can be — but a rule that already stores AUTO keeps +// it rather than being pushed onto another match type by an edit that never mentioned match types. +const STORED_MATCH_TYPES = new Set(['AUTO', ...KEYWORD_MATCH_TYPES]) + +const SHARED_FLAGS = ['target-ad-group', 'match-type', 'cpt-bid-type', 'cpt-bid'] as const +const SEARCH_TERM_FLAGS = ['negate', 'no-negate', 'skip-enable-duplicates'] as const +const TARGETING_KEYWORD_FLAGS = ['pause-original'] as const + +export interface AddKeywordActionFlags { + 'cpt-bid'?: string + 'cpt-bid-type'?: string + 'match-type'?: string + negate?: string + 'no-negate'?: boolean + 'pause-original'?: boolean + 'skip-enable-duplicates'?: boolean + 'target-ad-group'?: string[] +} + +type ActionFlagName = keyof AddKeywordActionFlags + +const ALL_FLAGS: ActionFlagName[] = [...SHARED_FLAGS, ...SEARCH_TERM_FLAGS, ...TARGETING_KEYWORD_FLAGS] + +const MISSING_REASONS: Record = { + 'cpt-bid': 'the bid to set, required by --cpt-bid-type set_to', + 'cpt-bid-type': 'where the bid of the new keyword comes from; the API has no default', + 'match-type': 'BROAD or EXACT; the API has no default', + 'target-ad-group': 'the ad groups the keyword is added to; a rule with none does nothing', +} + +function dashed(name: string): string { + return `--${name}` +} + +function asRecord(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : {} +} + +function isFlagSet(flags: AddKeywordActionFlags, name: ActionFlagName): boolean { + const value = flags[name] + if (value === undefined) return false + if (Array.isArray(value)) return value.length > 0 + // --no-negate has no negated form of its own, so only a true means it was passed. + return name === 'no-negate' ? value === true : true +} + +export function hasAddKeywordActionFlags(flags: AddKeywordActionFlags): boolean { + return ALL_FLAGS.some((name) => isFlagSet(flags, name)) +} + +// Params are rebuilt, not patched: a rule may store the add-as-negative-keyword shape +// ({target_type, ids}) under an add-as-keyword-to action, and the API picks the params variant by +// shape without a discriminator — so every stray key silently changes which action gets run. +// Only what fits the expected shape is carried over; the rest has to come from the flags. +function storedInternalIds(params: Record): string[] { + const ids = asRecord(params.targets).internal_ids + return Array.isArray(ids) ? ids.filter((id): id is string => typeof id === 'string' && isValidUuid(id)) : [] +} + +function storedMatchType(params: Record): string | undefined { + const matchType = params.match_type + return typeof matchType === 'string' && STORED_MATCH_TYPES.has(matchType) ? matchType : undefined +} + +function storedCptBidType(params: Record): string | undefined { + const {type} = asRecord(params.cpt_bid) + return typeof type === 'string' && CPT_BID_TYPES.includes(type) ? type : undefined +} + +function storedCptBidValue(params: Record): number | undefined { + const {value} = asRecord(params.cpt_bid) + return typeof value === 'number' && Number.isFinite(value) ? value : undefined +} + +function storedBoolean(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined +} + +function storedNegate(params: Record): Record | undefined { + const {enabled, type} = asRecord(params.negate) + if (typeof enabled !== 'boolean') return undefined + if (type === null || type === undefined) return {enabled, type: null} + return typeof type === 'string' && NEGATE_TYPES.includes(type) ? {enabled, type} : undefined +} + +function resolveNegate(flags: AddKeywordActionFlags, params: Record): Record { + if (flags.negate !== undefined) return {enabled: true, type: flags.negate} + if (flags['no-negate'] === true) return {enabled: false, type: null} + return storedNegate(params) ?? {enabled: false, type: null} +} + +function isSearchTermRule(operateWith: unknown): boolean { + if (operateWith === SEARCH_TERM) return true + if (operateWith === TARGETING_KEYWORD) return false + const shown = typeof operateWith === 'string' && operateWith.length > 0 ? `"${operateWith}"` : 'missing' + throw new Error( + `${ADD_AS_KEYWORD_ACTION} runs on a "${SEARCH_TERM}" or "${TARGETING_KEYWORD}" rule only; this rule's operate_with is ${shown}.`, + ) +} + +function rejectForeignFlags(searchTerm: boolean, flags: AddKeywordActionFlags): void { + const foreign = (searchTerm ? TARGETING_KEYWORD_FLAGS : SEARCH_TERM_FLAGS).filter((name) => isFlagSet(flags, name)) + if (foreign.length === 0) return + + const applicable = [...SHARED_FLAGS, ...(searchTerm ? SEARCH_TERM_FLAGS : TARGETING_KEYWORD_FLAGS)] + throw new Error( + `${foreign.map((name) => dashed(name)).join(', ')} ${foreign.length === 1 ? 'has' : 'have'} no place in the params of an ` + + `operate_with "${searchTerm ? SEARCH_TERM : TARGETING_KEYWORD}" rule, and the API drops params it cannot ` + + `place. Applicable action flags: ${applicable.map((name) => dashed(name)).join(', ')}.`, + ) +} + +function missingFlagsError(missing: ActionFlagName[]): Error { + const lines = missing.map((name) => ` ${dashed(name)} — ${MISSING_REASONS[name]}`) + return new Error(`The ${ADD_AS_KEYWORD_ACTION} params are incomplete. Pass:\n${lines.join('\n')}`) +} + +export function buildAddKeywordParams( + operateWith: unknown, + stored: unknown, + flags: AddKeywordActionFlags, +): Record { + const searchTerm = isSearchTermRule(operateWith) + rejectForeignFlags(searchTerm, flags) + + const params = asRecord(stored) + const missing: ActionFlagName[] = [] + + const internalIds = flags['target-ad-group'] ?? storedInternalIds(params) + if (internalIds.length === 0) missing.push('target-ad-group') + + const matchType = flags['match-type'] ?? storedMatchType(params) + if (matchType === undefined) missing.push('match-type') + + const cptBidType = flags['cpt-bid-type'] ?? storedCptBidType(params) + if (cptBidType === undefined) missing.push('cpt-bid-type') + + const bid = flags['cpt-bid'] + if (bid !== undefined && cptBidType !== undefined && cptBidType !== 'set_to') { + throw new Error(`--cpt-bid goes with --cpt-bid-type set_to only; ${cptBidType} reads the bid off the entity.`) + } + + let cptBidValue: null | number = null + if (cptBidType === 'set_to') { + const resolved = bid === undefined ? storedCptBidValue(params) : Number(bid) + if (resolved === undefined) missing.push('cpt-bid') + else cptBidValue = resolved + } + + if (missing.length > 0) throw missingFlagsError(missing) + + const built: Record = { + cpt_bid: {type: cptBidType, value: cptBidValue}, + match_type: matchType, + targets: {internal_ids: internalIds, type: 'ad-group'}, + } + + if (searchTerm) { + built.negate = resolveNegate(flags, params) + built.skip_enable_duplicate_keywords = + flags['skip-enable-duplicates'] ?? storedBoolean(params.skip_enable_duplicate_keywords) ?? false + } else { + built.pause_in_original_ad_group = + flags['pause-original'] ?? storedBoolean(params.pause_in_original_ad_group) ?? false + } + + return built +} + +export function requireSingleAction(actions: unknown): Record { + if (!Array.isArray(actions) || actions.length !== 1) { + throw new Error('A rule needs exactly one action: the API stores one action and one condition per rule.') + } + + return asRecord(actions[0]) +} + +export function rebuildAddKeywordAction( + actions: unknown, + operateWith: unknown, + flags: AddKeywordActionFlags, +): Record[] { + const action = requireSingleAction(actions) + if (action.type !== ADD_AS_KEYWORD_ACTION) { + throw new Error( + `The action flags only apply to an ${ADD_AS_KEYWORD_ACTION} action; this rule's action is "${String(action.type)}".`, + ) + } + + return [{...action, params: buildAddKeywordParams(operateWith, action.params, flags)}] +} diff --git a/test/commands/asa-automations.test.ts b/test/commands/asa-automations.test.ts new file mode 100644 index 0000000..c5f4718 --- /dev/null +++ b/test/commands/asa-automations.test.ts @@ -0,0 +1,329 @@ +import {runCommand} from '@oclif/test' +import {expect} from 'chai' +import {mkdtemp, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import sinon from 'sinon' + +import {ASA_API_BASE, assertFetch, mockFetch, restoreFetch, TEST_APP_ID, TEST_RESOURCE_ID} from '../helpers/mock-fetch.js' + +const AD_GROUP_A = '770e8400-e29b-41d4-a716-446655440002' +const AD_GROUP_B = '880e8400-e29b-41d4-a716-446655440003' + +const CONDITION = { + args: 50, + operand: {date_range_type: 'last_7_d', field: 'taps', field_type: 'base_field'}, + operator: 'gte', +} + +const CREATED = {automation: {id: TEST_RESOURCE_ID, name: 'ST harvester'}} + +function rule(operateWith: string, params: unknown, actionType = 'add-as-keyword-to'): Record { + return { + actions: [{params, type: actionType}], + apply_to: [{internal_id: TEST_APP_ID, type: 'app'}], + conditions: [CONDITION], + id: TEST_RESOURCE_ID, + name: 'ST harvester', + operate_with: operateWith, + run_frequency: {hour: 8, type: 'daily'}, + status: 1, + } +} + +async function ruleFile(body: Record): Promise { + const dir = await mkdtemp(join(tmpdir(), 'asa-cli-')) + const path = join(dir, 'rule.json') + await writeFile(path, JSON.stringify(body)) + return path +} + +const sentBody = (stub: sinon.SinonStub, callIndex: number): Record => + JSON.parse(stub.getCall(callIndex).args[1].body as string) + +const sentParams = (stub: sinon.SinonStub, callIndex: number): Record => { + const {actions} = sentBody(stub, callIndex) as {actions: Array<{params: Record}>} + return actions[0].params +} + +describe('asa automations add-as-keyword action flags', () => { + let fetchStub: sinon.SinonStub + + beforeEach(() => { + process.env.ADAPTY_TOKEN = 'dev_live_test' + delete process.env.ADAPTY_ASA_API_URL + }) + + afterEach(() => { + restoreFetch(fetchStub) + delete process.env.ADAPTY_TOKEN + }) + + it('create collects every --target-ad-group into targets.internal_ids', async () => { + const path = await ruleFile(rule('search-term', {})) + fetchStub = mockFetch([CREATED]) + await runCommand( + `asa automations create --yes --file ${path} --target-ad-group ${AD_GROUP_A} --target-ad-group ${AD_GROUP_B} --match-type EXACT --cpt-bid-type search_term_current_cpt`, + ) + + assertFetch({base: ASA_API_BASE, callIndex: 0, method: 'POST', path: '/automations/', stub: fetchStub}) + expect(sentParams(fetchStub, 0)).to.deep.equal({ + cpt_bid: {type: 'search_term_current_cpt', value: null}, + match_type: 'EXACT', + negate: {enabled: false, type: null}, + skip_enable_duplicate_keywords: false, + targets: {internal_ids: [AD_GROUP_A, AD_GROUP_B], type: 'ad-group'}, + }) + }) + + it('create lets the flags win over the params already in the file', async () => { + const path = await ruleFile( + rule('search-term', { + cpt_bid: {type: 'ad_group_default_bid', value: null}, + match_type: 'BROAD', + negate: {enabled: false, type: null}, + skip_enable_duplicate_keywords: false, + targets: {internal_ids: [AD_GROUP_A], type: 'ad-group'}, + }), + ) + fetchStub = mockFetch([CREATED]) + await runCommand( + `asa automations create --yes --file ${path} --target-ad-group ${AD_GROUP_B} --match-type EXACT --cpt-bid-type set_to --cpt-bid 1.50 --negate campaign --skip-enable-duplicates`, + ) + + expect(sentParams(fetchStub, 0)).to.deep.equal({ + cpt_bid: {type: 'set_to', value: 1.5}, + match_type: 'EXACT', + negate: {enabled: true, type: 'campaign'}, + skip_enable_duplicate_keywords: true, + targets: {internal_ids: [AD_GROUP_B], type: 'ad-group'}, + }) + }) + + it('create keeps the params the file already carries when no flag overrides them', async () => { + const path = await ruleFile( + rule('targeting-keyword', { + cpt_bid: {type: 'keyword_current_bid', value: null}, + match_type: 'BROAD', + pause_in_original_ad_group: true, + targets: {internal_ids: [AD_GROUP_A], type: 'ad-group'}, + }), + ) + fetchStub = mockFetch([CREATED]) + await runCommand(`asa automations create --yes --file ${path} --target-ad-group ${AD_GROUP_B}`) + + expect(sentParams(fetchStub, 0)).to.deep.equal({ + cpt_bid: {type: 'keyword_current_bid', value: null}, + match_type: 'BROAD', + pause_in_original_ad_group: true, + targets: {internal_ids: [AD_GROUP_B], type: 'ad-group'}, + }) + }) + + it('create refuses a rule left without target ad groups', async () => { + const path = await ruleFile(rule('search-term', {})) + fetchStub = mockFetch([CREATED]) + const {error} = await runCommand( + `asa automations create --yes --file ${path} --match-type EXACT --cpt-bid-type search_term_current_cpt`, + ) + + expect(error?.oclif?.exit).to.equal(2) + expect(error?.message).to.contain('--target-ad-group') + expect(fetchStub.callCount).to.equal(0) + }) + + it('create refuses --negate on a targeting-keyword rule and names the applicable flags', async () => { + const path = await ruleFile(rule('targeting-keyword', {})) + fetchStub = mockFetch([CREATED]) + const {error} = await runCommand( + `asa automations create --yes --file ${path} --target-ad-group ${AD_GROUP_A} --match-type EXACT --cpt-bid-type keyword_current_bid --negate campaign`, + ) + + expect(error?.oclif?.exit).to.equal(2) + expect(error?.message).to.contain('--negate') + expect(error?.message).to.contain('--pause-original') + expect(fetchStub.callCount).to.equal(0) + }) + + it('create refuses --pause-original on a search-term rule', async () => { + const path = await ruleFile(rule('search-term', {})) + fetchStub = mockFetch([CREATED]) + const {error} = await runCommand( + `asa automations create --yes --file ${path} --target-ad-group ${AD_GROUP_A} --match-type EXACT --cpt-bid-type search_term_current_cpt --pause-original`, + ) + + expect(error?.oclif?.exit).to.equal(2) + expect(error?.message).to.contain('--pause-original') + expect(fetchStub.callCount).to.equal(0) + }) + + it('create refuses params built from scratch without --cpt-bid-type and --match-type', async () => { + const path = await ruleFile(rule('search-term', {})) + fetchStub = mockFetch([CREATED]) + const {error} = await runCommand( + `asa automations create --yes --file ${path} --target-ad-group ${AD_GROUP_A}`, + ) + + expect(error?.oclif?.exit).to.equal(2) + expect(error?.message).to.contain('--match-type') + expect(error?.message).to.contain('--cpt-bid-type') + expect(fetchStub.callCount).to.equal(0) + }) + + it('create requires --cpt-bid with --cpt-bid-type set_to and rejects it with the other types', async () => { + const path = await ruleFile(rule('search-term', {})) + fetchStub = mockFetch([CREATED]) + + const missing = await runCommand( + `asa automations create --yes --file ${path} --target-ad-group ${AD_GROUP_A} --match-type EXACT --cpt-bid-type set_to`, + ) + expect(missing.error?.oclif?.exit).to.equal(2) + expect(missing.error?.message).to.contain('--cpt-bid') + + const extra = await runCommand( + `asa automations create --yes --file ${path} --target-ad-group ${AD_GROUP_A} --match-type EXACT --cpt-bid-type search_term_current_cpt --cpt-bid 1.20`, + ) + expect(extra.error?.oclif?.exit).to.equal(2) + expect(extra.error?.message).to.contain('set_to') + expect(fetchStub.callCount).to.equal(0) + }) + + it('create refuses a rule whose single action is not add-as-keyword-to and names the actual type', async () => { + const path = await ruleFile(rule('targeting-keyword', {targets: {ids: [AD_GROUP_A], target_type: 'AD_GROUP'}}, 'add-as-negative-keyword')) + fetchStub = mockFetch([CREATED]) + const {error} = await runCommand( + `asa automations create --yes --file ${path} --target-ad-group ${AD_GROUP_B} --match-type EXACT --cpt-bid-type keyword_current_bid`, + ) + + expect(error?.oclif?.exit).to.equal(2) + expect(error?.message).to.contain('add-as-negative-keyword') + expect(fetchStub.callCount).to.equal(0) + }) + + it('create refuses a rule file that does not carry exactly one action', async () => { + const none = await ruleFile({conditions: [CONDITION], name: 'no actions', operate_with: 'search-term', status: 1}) + fetchStub = mockFetch([CREATED]) + const {error} = await runCommand(`asa automations create --yes --file ${none}`) + + expect(error?.oclif?.exit).to.equal(2) + expect(error?.message).to.contain('exactly one action') + expect(fetchStub.callCount).to.equal(0) + }) + + it('update reads the rule, rebuilds the action and sends the whole actions list', async () => { + fetchStub = mockFetch([ + rule('search-term', { + cpt_bid: {type: 'search_term_current_cpt', value: null}, + match_type: 'BROAD', + negate: {enabled: true, type: 'ad-group'}, + skip_enable_duplicate_keywords: true, + targets: {internal_ids: [AD_GROUP_A], type: 'ad-group'}, + }), + CREATED, + ]) + await runCommand(`asa automations update --yes ${TEST_RESOURCE_ID} --target-ad-group ${AD_GROUP_B}`) + + assertFetch({ + base: ASA_API_BASE, + callIndex: 0, + method: 'GET', + path: `/automations/${TEST_RESOURCE_ID}/`, + stub: fetchStub, + }) + assertFetch({ + base: ASA_API_BASE, + callIndex: 1, + method: 'PUT', + path: `/automations/${TEST_RESOURCE_ID}/`, + stub: fetchStub, + }) + + const body = sentBody(fetchStub, 1) as {actions: Array<{params: unknown; type: string}>} + expect(body.actions).to.have.length(1) + expect(body.actions[0].type).to.equal('add-as-keyword-to') + expect(body.actions[0].params).to.deep.equal({ + cpt_bid: {type: 'search_term_current_cpt', value: null}, + match_type: 'BROAD', + negate: {enabled: true, type: 'ad-group'}, + skip_enable_duplicate_keywords: true, + targets: {internal_ids: [AD_GROUP_B], type: 'ad-group'}, + }) + }) + + it('update rebuilds params that carry the negative-keyword shape and drops the stray keys', async () => { + fetchStub = mockFetch([ + rule('search-term', {pause_in_original_ad_group: false, targets: {ids: [AD_GROUP_A], target_type: 'AD_GROUP'}}), + CREATED, + ]) + await runCommand( + `asa automations update --yes ${TEST_RESOURCE_ID} --target-ad-group ${AD_GROUP_B} --match-type EXACT --cpt-bid-type search_term_current_cpt`, + ) + + expect(sentParams(fetchStub, 1)).to.deep.equal({ + cpt_bid: {type: 'search_term_current_cpt', value: null}, + match_type: 'EXACT', + negate: {enabled: false, type: null}, + skip_enable_duplicate_keywords: false, + targets: {internal_ids: [AD_GROUP_B], type: 'ad-group'}, + }) + }) + + it('update refuses a broken rule until the missing required flags are supplied', async () => { + fetchStub = mockFetch([ + rule('search-term', {pause_in_original_ad_group: false, targets: {ids: [AD_GROUP_A], target_type: 'AD_GROUP'}}), + CREATED, + ]) + const {error} = await runCommand(`asa automations update --yes ${TEST_RESOURCE_ID} --target-ad-group ${AD_GROUP_B}`) + + expect(error?.oclif?.exit).to.equal(2) + expect(error?.message).to.contain('--match-type') + expect(error?.message).to.contain('--cpt-bid-type') + expect(fetchStub.callCount).to.equal(1) + }) + + it('update refuses a rule whose single action is not add-as-keyword-to', async () => { + fetchStub = mockFetch([ + rule('search-term', {targets: {ids: [AD_GROUP_A], target_type: 'AD_GROUP'}}, 'add-as-negative-keyword'), + CREATED, + ]) + const {error} = await runCommand(`asa automations update --yes ${TEST_RESOURCE_ID} --target-ad-group ${AD_GROUP_B}`) + + expect(error?.oclif?.exit).to.equal(2) + expect(error?.message).to.contain('add-as-negative-keyword') + expect(fetchStub.callCount).to.equal(1) + }) + + it('update takes the action from --file over the stored rule', async () => { + const path = await ruleFile({ + actions: [ + { + params: { + cpt_bid: {type: 'keyword_current_bid', value: null}, + match_type: 'BROAD', + pause_in_original_ad_group: false, + targets: {internal_ids: [AD_GROUP_A], type: 'ad-group'}, + }, + type: 'add-as-keyword-to', + }, + ], + operate_with: 'targeting-keyword', + }) + fetchStub = mockFetch([rule('targeting-keyword', {}), CREATED]) + await runCommand(`asa automations update --yes ${TEST_RESOURCE_ID} --file ${path} --target-ad-group ${AD_GROUP_B} --pause-original`) + + expect(sentParams(fetchStub, 1)).to.deep.equal({ + cpt_bid: {type: 'keyword_current_bid', value: null}, + match_type: 'BROAD', + pause_in_original_ad_group: true, + targets: {internal_ids: [AD_GROUP_B], type: 'ad-group'}, + }) + }) + + it('update without action flags stays a single call', async () => { + fetchStub = mockFetch([CREATED]) + await runCommand(`asa automations update --yes ${TEST_RESOURCE_ID} --stop`) + + expect(fetchStub.callCount).to.equal(1) + expect(sentBody(fetchStub, 0)).to.deep.equal({status: 0}) + }) +}) diff --git a/test/commands/asa-writes.test.ts b/test/commands/asa-writes.test.ts index 0e2d1bf..ea4deb4 100644 --- a/test/commands/asa-writes.test.ts +++ b/test/commands/asa-writes.test.ts @@ -383,7 +383,16 @@ describe('asa writes', () => { it('automations create reads the rule from a file and can request the first run', async () => { const dir = await mkdtemp(join(tmpdir(), 'asa-cli-')) const path = join(dir, 'rule.json') - await writeFile(path, JSON.stringify({conditions: [], name: 'pause expensive', operate_with: 'targeting-keyword', status: 1})) + await writeFile( + path, + JSON.stringify({ + actions: [{params: {mode: 'value', type: 'increase_by', value: 10}, type: 'change-bid'}], + conditions: [], + name: 'pause expensive', + operate_with: 'targeting-keyword', + status: 1, + }), + ) fetchStub = mockFetch([{automation: {id: TEST_RESOURCE_ID, name: 'pause expensive'}}]) await runCommand(`asa automations create --yes --file ${path} --run-now`) const body = JSON.parse(fetchStub.getCall(0).args[1].body as string) From 7bc17a572ae331a46954e5cdf9aebafa9d99c3f4 Mon Sep 17 00:00:00 2001 From: Mikhail Fisher Date: Mon, 7 Sep 2026 15:04:07 +0200 Subject: [PATCH 4/6] fix: cpt bid value is a markup for current-bid types --- README.md | 2 +- docs/agent/asa-management.md | 2 +- .../references/asa-agent-playbook.md | 5 ++- src/lib/asa-flags.ts | 7 +++- src/lib/asa-keyword-action.ts | 32 +++++++++++----- test/commands/asa-automations.test.ts | 37 ++++++++++++++++--- 6 files changed, 67 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index f9642a0..0423b3d 100644 --- a/README.md +++ b/README.md @@ -321,7 +321,7 @@ adapty asa automations update AUTOMATION_ID --target-ad-group AD_GROUP_UUID \ | `--target-ad-group` | `targets.internal_ids`, repeatable; a rule without one does nothing | | `--match-type` | `match_type`: `BROAD` or `EXACT` | | `--cpt-bid-type` | `cpt_bid.type`: `ad_group_default_bid`, `set_to`, `search_term_current_cpt`, `keyword_current_bid` | -| `--cpt-bid` | `cpt_bid.value`; required by `set_to`, rejected with the other types | +| `--cpt-bid` | `cpt_bid.value`: the bid itself with `set_to` (required), a percent markup on the entity's own bid with `search_term_current_cpt` / `keyword_current_bid`, rejected with `ad_group_default_bid` | | `--negate` / `--no-negate` | `negate` (search-term rules): `ad-group` or `campaign`, or off | | `--skip-enable-duplicates` | `skip_enable_duplicate_keywords` (search-term rules) | | `--pause-original` | `pause_in_original_ad_group` (targeting-keyword rules) | diff --git a/docs/agent/asa-management.md b/docs/agent/asa-management.md index c2f87c1..78212ac 100644 --- a/docs/agent/asa-management.md +++ b/docs/agent/asa-management.md @@ -171,7 +171,7 @@ adapty asa automations run AUTOMATION_UUID --dry-run | `--target-ad-group` | `targets.internal_ids` | repeatable UUID; a rule with none does nothing | | `--match-type` | `match_type` | `BROAD`, `EXACT` | | `--cpt-bid-type` | `cpt_bid.type` | `ad_group_default_bid`, `set_to`, `search_term_current_cpt`, `keyword_current_bid` | -| `--cpt-bid` | `cpt_bid.value` | required by `set_to`, rejected with every other type | +| `--cpt-bid` | `cpt_bid.value` | the bid itself with `set_to` (required); a percent markup on the entity's own bid with `search_term_current_cpt` / `keyword_current_bid`; rejected with `ad_group_default_bid` | | `--negate` / `--no-negate` | `negate` | `ad-group`, `campaign`, or off — `search-term` rules only | | `--skip-enable-duplicates` | `skip_enable_duplicate_keywords` | `search-term` rules only | | `--pause-original` | `pause_in_original_ad_group` | `targeting-keyword` rules only | diff --git a/skills/adapty-cli/references/asa-agent-playbook.md b/skills/adapty-cli/references/asa-agent-playbook.md index 522cd64..4b96d2e 100644 --- a/skills/adapty-cli/references/asa-agent-playbook.md +++ b/skills/adapty-cli/references/asa-agent-playbook.md @@ -224,7 +224,10 @@ adapty asa automations run AUTOMATION_ID --dry-run ``` `--cpt-bid-type` and `--match-type` have no defaults anywhere — a bid and a match type are the user's -call, so ask rather than pick. Which flags apply follows `operate_with`: `--negate` / +call, so ask rather than pick. `--cpt-bid` means different things per bid type: the bid itself with +`set_to`, a percent markup on the entity's own bid with `search_term_current_cpt` / +`keyword_current_bid` (omit it for a plain copy), and nothing at all with `ad_group_default_bid`. +Which flags apply follows `operate_with`: `--negate` / `--no-negate` / `--skip-enable-duplicates` on a `search-term` rule, `--pause-original` on a `targeting-keyword` one, and the CLI exits 2 rather than sending a mismatch. Verify with `--dry-run` before letting it write to Apple; `asa automations get ` shows the stored `params`. diff --git a/src/lib/asa-flags.ts b/src/lib/asa-flags.ts index 48dec73..be29db8 100644 --- a/src/lib/asa-flags.ts +++ b/src/lib/asa-flags.ts @@ -155,7 +155,12 @@ export const currencyFlag = { // operate_with: --negate/--no-negate/--skip-enable-duplicates are search-term only, // --pause-original is targeting-keyword only. See lib/asa-keyword-action.ts. export const addKeywordActionFlags = { - 'cpt-bid': moneyFlag('Bid for the created keyword; goes with --cpt-bid-type set_to only'), + 'cpt-bid': Flags.string({ + description: + 'cpt_bid.value: the bid itself with --cpt-bid-type set_to, or a percent markup on the entity bid with ' + + 'search_term_current_cpt / keyword_current_bid; not accepted with ad_group_default_bid', + parse: parseMoney, + }), 'cpt-bid-type': Flags.string({ description: 'Where the bid of the created keyword comes from; the API has no default', options: CPT_BID_TYPES, diff --git a/src/lib/asa-keyword-action.ts b/src/lib/asa-keyword-action.ts index a5cd387..1d94684 100644 --- a/src/lib/asa-keyword-action.ts +++ b/src/lib/asa-keyword-action.ts @@ -9,6 +9,9 @@ export const NEGATE_TYPES = ['ad-group', 'campaign'] const SEARCH_TERM = 'search-term' const TARGETING_KEYWORD = 'targeting-keyword' +const BID_TYPE_IGNORING_VALUE = 'ad_group_default_bid' +const BID_TYPES_READING_MARKUP = new Set(['keyword_current_bid', 'search_term_current_cpt']) + // KeywordMatchType also carries AUTO. --match-type never offers it — an automation creates a // keyword, and AUTO is not something a keyword can be — but a rule that already stores AUTO keeps // it rather than being pushed onto another match type by an edit that never mentioned match types. @@ -101,6 +104,19 @@ function resolveNegate(flags: AddKeywordActionFlags, params: Record, +): null | number | undefined { + if (cptBidType === 'set_to') return bid === undefined ? storedCptBidValue(params) : Number(bid) + if (cptBidType !== undefined && BID_TYPES_READING_MARKUP.has(cptBidType)) { + return bid === undefined ? (storedCptBidValue(params) ?? null) : Number(bid) + } + + return null +} + function isSearchTermRule(operateWith: unknown): boolean { if (operateWith === SEARCH_TERM) return true if (operateWith === TARGETING_KEYWORD) return false @@ -148,21 +164,19 @@ export function buildAddKeywordParams( if (cptBidType === undefined) missing.push('cpt-bid-type') const bid = flags['cpt-bid'] - if (bid !== undefined && cptBidType !== undefined && cptBidType !== 'set_to') { - throw new Error(`--cpt-bid goes with --cpt-bid-type set_to only; ${cptBidType} reads the bid off the entity.`) + if (bid !== undefined && cptBidType === BID_TYPE_IGNORING_VALUE) { + throw new Error( + `--cpt-bid has no meaning with --cpt-bid-type ${BID_TYPE_IGNORING_VALUE}; that type copies the target ad group's default bid.`, + ) } - let cptBidValue: null | number = null - if (cptBidType === 'set_to') { - const resolved = bid === undefined ? storedCptBidValue(params) : Number(bid) - if (resolved === undefined) missing.push('cpt-bid') - else cptBidValue = resolved - } + const cptBidValue = resolveCptBidValue(cptBidType, bid, params) + if (cptBidValue === undefined) missing.push('cpt-bid') if (missing.length > 0) throw missingFlagsError(missing) const built: Record = { - cpt_bid: {type: cptBidType, value: cptBidValue}, + cpt_bid: {type: cptBidType, value: cptBidValue ?? null}, match_type: matchType, targets: {internal_ids: internalIds, type: 'ad-group'}, } diff --git a/test/commands/asa-automations.test.ts b/test/commands/asa-automations.test.ts index c5f4718..9919f79 100644 --- a/test/commands/asa-automations.test.ts +++ b/test/commands/asa-automations.test.ts @@ -170,7 +170,7 @@ describe('asa automations add-as-keyword action flags', () => { expect(fetchStub.callCount).to.equal(0) }) - it('create requires --cpt-bid with --cpt-bid-type set_to and rejects it with the other types', async () => { + it('create requires --cpt-bid with set_to and rejects it only with ad_group_default_bid', async () => { const path = await ruleFile(rule('search-term', {})) fetchStub = mockFetch([CREATED]) @@ -180,14 +180,41 @@ describe('asa automations add-as-keyword action flags', () => { expect(missing.error?.oclif?.exit).to.equal(2) expect(missing.error?.message).to.contain('--cpt-bid') - const extra = await runCommand( - `asa automations create --yes --file ${path} --target-ad-group ${AD_GROUP_A} --match-type EXACT --cpt-bid-type search_term_current_cpt --cpt-bid 1.20`, + const ignored = await runCommand( + `asa automations create --yes --file ${path} --target-ad-group ${AD_GROUP_A} --match-type EXACT --cpt-bid-type ad_group_default_bid --cpt-bid 1.20`, ) - expect(extra.error?.oclif?.exit).to.equal(2) - expect(extra.error?.message).to.contain('set_to') + expect(ignored.error?.oclif?.exit).to.equal(2) + expect(ignored.error?.message).to.contain('ad_group_default_bid') expect(fetchStub.callCount).to.equal(0) }) + it('create sends --cpt-bid as the percent markup of a current-bid type', async () => { + const path = await ruleFile(rule('search-term', {})) + fetchStub = mockFetch([CREATED]) + + const {error} = await runCommand( + `asa automations create --yes --file ${path} --target-ad-group ${AD_GROUP_A} --match-type EXACT --cpt-bid-type search_term_current_cpt --cpt-bid 20`, + ) + + expect(error).to.equal(undefined) + expect(sentParams(fetchStub, 0).cpt_bid).to.deep.equal({type: 'search_term_current_cpt', value: 20}) + }) + + it('update keeps the stored markup when the edit never mentions the bid', async () => { + const stored = rule('targeting-keyword', { + cpt_bid: {type: 'keyword_current_bid', value: 15}, + match_type: 'EXACT', + pause_in_original_ad_group: false, + targets: {internal_ids: [AD_GROUP_A], type: 'ad-group'}, + }) + fetchStub = mockFetch([stored, CREATED]) + + const {error} = await runCommand(`asa automations update --yes ${TEST_RESOURCE_ID} --target-ad-group ${AD_GROUP_B}`) + + expect(error).to.equal(undefined) + expect(sentParams(fetchStub, 1).cpt_bid).to.deep.equal({type: 'keyword_current_bid', value: 15}) + }) + it('create refuses a rule whose single action is not add-as-keyword-to and names the actual type', async () => { const path = await ruleFile(rule('targeting-keyword', {targets: {ids: [AD_GROUP_A], target_type: 'AD_GROUP'}}, 'add-as-negative-keyword')) fetchStub = mockFetch([CREATED]) From f5b636f50225a54c9d216e81b5404fbad61cad40 Mon Sep 17 00:00:00 2001 From: Mikhail Fisher Date: Mon, 7 Sep 2026 16:14:17 +0200 Subject: [PATCH 5/6] fix: name the field in listed api errors --- src/lib/errors.ts | 3 ++- test/lib/errors.test.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 91c62c9..ca70439 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -29,7 +29,8 @@ export function describeListedError(error: ListedError): {code: string | undefin const code = error.error_code ?? error.apple_error_code ?? error.validation_error_code ?? undefined const reason = error.message ?? error.apple_error_message ?? error.validation_error_message ?? code ?? 'rejected' const position = error.input_ref === null || error.input_ref === undefined ? '' : ` (item ${error.input_ref + 1})` - return {code, text: `${reason}${position}`} + const field = error.field_name ? `${error.field_name}: ` : '' + return {code, text: `${field}${reason}${position}`} } export class ApiError extends Error { diff --git a/test/lib/errors.test.ts b/test/lib/errors.test.ts index 6b70932..3b9afdc 100644 --- a/test/lib/errors.test.ts +++ b/test/lib/errors.test.ts @@ -76,7 +76,7 @@ describe('parseApiError', () => { 'asa', ) expect(error.errorCode).to.equal('first') - expect(error.detail).to.equal('too low; too long') + expect(error.detail).to.equal('bid_amount: too low; text: too long') expect(error.fieldErrors).to.deep.equal({bid_amount: ['too low'], text: ['too long']}) }) From f1f773c1581f23f846ebc1958bb4051780388e14 Mon Sep 17 00:00:00 2001 From: Mikhail Fisher Date: Mon, 7 Sep 2026 16:36:39 +0200 Subject: [PATCH 6/6] chore: bump version to 0.8.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7f2692f..3ed2d70 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "adapty", "description": "Adapty command line interface", - "version": "0.8.2", + "version": "0.8.3", "author": "Adapty team ", "bin": { "adapty": "./bin/run.js"