diff --git a/.github/workflows/onTag.yml b/.github/workflows/onTag.yml index 1031f46..effd1f0 100644 --- a/.github/workflows/onTag.yml +++ b/.github/workflows/onTag.yml @@ -35,3 +35,17 @@ jobs: PACKAGE_NAME=$(node -p "require('./package.json').name") PACKAGE_VERSION=$(node -p "require('./package.json').version") echo "Published ${PACKAGE_NAME}@${PACKAGE_VERSION}" + + notify-apple-ads-cli: + name: Sync Apple Ads agent docs + runs-on: ubuntu-latest + needs: publish + steps: + - name: Dispatch released docs + env: + ADAPTY_CLI_REF: ${{ github.ref_type == 'tag' && github.ref_name || github.sha }} + GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }} + run: | + gh api --method POST repos/adaptyteam/apple-ads-cli/dispatches \ + -f event_type=adapty-cli-released \ + -f "client_payload[ref]=${ADAPTY_CLI_REF}" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 688cbcd..2e51290 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,4 +22,5 @@ jobs: cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm build + - run: pnpm check:agent-docs - run: pnpm test diff --git a/docs/agent/asa-management.md b/docs/agent/asa-management.md new file mode 100644 index 0000000..0316ca6 --- /dev/null +++ b/docs/agent/asa-management.md @@ -0,0 +1,173 @@ +# Apple Search Ads — Managing Campaigns + +Everything under `adapty asa` is scoped to the company behind the token, not to one app. +No command in this file takes `--app` to select scope — the one exception is `--app` as a +list filter, covered in [Scope filters](#scope-filters). Ids are the UUIDs printed by the +matching list command; never invent one. + +All `list` commands paginate: `--page` (default `1`), `--page-size` (default `100`, max +`1000`) — prefer one big page over a pagination loop. Counting entities costs nothing +extra: every list response carries `meta.pagination.count`, so `--page-size 1` answers +"how many X do I have" without walking pages. + +Every `list` and `get` command in this file returns metadata only, no metrics. Every number +— spend, ROAS, or anything else — comes from `asa metrics`. + +## Account and discovery + +| Command | Flags | Notes | +|---|---|---| +| `asa whoami` | none | Company, how access was granted, Apple connection state. Run this first. No connected Apple Ads account or no active Ads Manager subscription answers `402 ads_manager_subscription_required` on every other `asa` command. | +| `asa connect` | `--no-wait` optional | Prints the Apple authorization link and waits for the link to be completed; `--no-wait` returns immediately instead of waiting. | +| `asa apps list` | pagination only | Apps promoted in Apple Search Ads. Its rows supply `--adam-id` for `campaigns create`. | +| `asa orgs list` | pagination only | Apple Search Ads organizations. Each row carries two identifiers, not interchangeable: `internal_id` (a UUID) and `org_id` (Apple's numeric id). `--org` on `campaigns create` takes `internal_id` — passing the numeric `org_id` fails with "Invalid org ID format." | + +## Campaigns + +| Command | Flags | Notes | +|---|---|---| +| `asa campaigns list` | scope filters only | Metadata only. | +| `asa campaigns get ` | positional UUID | Metadata only. | +| `asa campaigns create` | `--org`, `--name`, `--adam-id`, `--country` (repeatable), `--daily-budget`; optional `--status` (`ENABLED`/`PAUSED`, no default), `--budget` (lifetime), `--target-cpa`, `--bidding-strategy`, `--supply-source` (repeatable, default `APPSTORE_SEARCH_RESULTS`), `--billing-event` (`IMPRESSIONS`/`TAPS`, default `TAPS`), `--ad-channel-type` (`DISPLAY`/`SEARCH`, default `SEARCH`) | `--org` takes the UUID (`internal_id`) from `asa orgs list`, not that row's numeric `org_id`; `--adam-id` comes from `asa apps list`. `--status` has no default — pass `--status PAUSED` to launch without spending until you enable it. | +| `asa campaigns update ` | at least one of `--name`, `--status`, `--country`, `--daily-budget`, `--budget`, `--target-cpa`, `--bidding-strategy` | | + +## Ad groups + +| Command | Flags | Notes | +|---|---|---| +| `asa ad-groups list` | scope filters only | Metadata only. | +| `asa ad-groups get ` | positional UUID | Metadata only. | +| `asa ad-groups create` | `--campaign`, `--name`, `--default-bid` | Apple also requires a pricing model and a start time; the CLI defaults `--pricing-model` to `CPC` (the only other option is `CPM`) and `--start-time` to today if you don't pass them. | +| `asa ad-groups update ` | at least one field | The campaign is resolved server-side and is never passed on update. | + +## Ads + +| Command | Flags | Notes | +|---|---|---| +| `asa ads list` | scope filters only | Ads hang off ad groups, not apps directly — there is no `--app`; filter by `--ad-group` or `--campaign` instead. Metadata only. | +| `asa ads get ` | positional UUID | `serving_state_reasons` in the response explains a non-running ad. | +| `asa ads create` | `--ad-group`, `--creative-id`, `--name` | The creative id comes from `asa creatives list`. | +| `asa ads update ` | `--name` and/or `--status` | The creative and the parent ad group are fixed at creation and cannot be changed. | + +## Keywords + +| Command | Flags | Notes | +|---|---|---| +| `asa keywords list` | scope filters only | Metadata only. Filter by `--ad-group` — unfiltered, this is the widest read in the surface. | +| `asa keywords add` | `--ad-group` plus `--text` (repeatable) and/or `--from-file`; optional `--bid`, `--match-type` (`BROAD`/`EXACT`, default `BROAD`), `--status` (`ACTIVE`/`PAUSED`, default `ACTIVE`) | Batch call, capped at 100 keywords per call — the skill's own practice caps a single call lower, at 15 (see `SKILL.md`'s `## Never`). `--from-file` reads one keyword per line, trims each line, drops blank lines, and combines the result with any `--text` values. Default match type is `BROAD`, which widens spend beyond exact matches; pass `--match-type EXACT` to narrow it. | +| `asa keywords update [...]` | one or more positional ids | The same change (e.g. `--bid`, `--status`) is applied to every id in the list. `--text` is only valid when a single id is given — you cannot bulk-rename keyword text. | + +## Negative keywords + +| Command | Flags | Notes | +|---|---|---| +| `asa negative-keywords list` | scope filters only | Metadata only. `ad_group_id` is empty (`null`) on campaign-level rows; `--campaign-level-only` keeps only those rows. | +| `asa negative-keywords add` | exactly one of `--ad-group` / `--campaign`, plus `--text` (repeatable); optional `--match-type` (`BROAD`/`EXACT`, default `EXACT`), `--status` (`ACTIVE`/`PAUSED`, default `ACTIVE`) | `--all-ad-groups` applies the negative keyword to every ad group in the campaign and requires `--campaign`. Same 100-item batch cap as `keywords add`, and the same 15-per-call practice (see `SKILL.md`'s `## Never`). Default match type here is `EXACT` — the opposite of `keywords add`'s `BROAD` default. | + +## Product pages + +| Command | Flags | Notes | +|---|---|---| +| `asa product-pages list` | see Scope filters | Read-only, metadata only. | +| `asa product-pages sync [--adam-id]` | `--adam-id` optional | Queued rather than awaited; the response's `replayed` field is what tells the two outcomes apart. `replayed: false` means this call just queued a sync (the CLI prints "Sync queued."). `replayed: true` means one was already running and this call queued nothing new (the CLI prints "Already running; nothing new was queued."). The response also carries `state`, `sync_id`, and `accepted_at`. | + +## Creatives + +| Command | Flags | Notes | +|---|---|---| +| `asa creatives list` | see Scope filters | Yields the Apple `creative_id` that `ads create` needs. | + +## Automations + +| Command | Flags | Notes | +|---|---|---| +| `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 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. | + +## Scope filters + +Filters narrow the query itself, not the printed page: an unfiltered `asa keywords list` +pages through the whole account, while one ad group's list is a handful of rows. Always +scope a read. + +| Filter | Lists that accept it | +|---|---| +| `--campaign-group` | campaigns, ad groups, ads, keywords, negative keywords, search terms, product pages, creatives — not `apps list`, `orgs list`, or `automations list` | +| `--app` | campaigns, ad groups, keywords, negative keywords, search terms, product pages, creatives | +| `--campaign` | ad groups, keywords, negative keywords, search terms, ads | +| `--ad-group` | keywords, negative keywords, search terms, ads | +| `--status` | campaigns, ad groups, ads (`ENABLED`/`PAUSED`), keywords (`ACTIVE`/`PAUSED`) | +| `--search` | campaigns, ad groups, keywords, negative keywords, search terms, ads — not product pages, creatives, `apps list`, `orgs list`, or `automations list` | +| `--campaign-level-only` | negative keywords only | + +Id filters (`--campaign-group`, `--app`, `--campaign`, `--ad-group`) are repeatable and take +UUIDs. `--app`, `--campaign`, and `--ad-group` take the UUIDs printed by the matching list +command (`apps list`, `campaigns list`, `ad-groups list`). There is no `campaign-groups +list` — organizations and campaign groups are the same thing here, so `--campaign-group` +takes the `internal_id` UUIDs printed by `asa orgs list`. An id owned by another company +matches nothing — the page comes back empty rather than erroring, so an empty result is +not proof the entity doesn't exist anywhere, only that it isn't yours. + +`asa ads list` has no `--app` filter, because ads hang off ad groups, not apps — use +`--ad-group` or `--campaign` to scope it instead. + +## Status + +``` +Campaigns, ad groups, ads: --status ENABLED | PAUSED +Keywords: --status ACTIVE | PAUSED +``` + +The keyword enum is different from every other entity's — `ACTIVE`/`PAUSED`, not +`ENABLED`/`PAUSED`. There is no `DISABLED` value anywhere in the `asa` surface, on any +entity. + +## Request budgets + +Every `asa` command is rate limited per company, not per token: + +| Commands | Budget | +|---|---| +| catalog lists and gets, automation reads | 120/min | +| `keywords list` | 30/min, burst 5 per 10s, its own 2-concurrent pool, 60s server timeout | +| all writes | 20/min | +| `whoami` | 60/min | + +`keywords list` is the heaviest metadata read in the surface — its own budget is on top of +the account-size reason to filter it in Scope filters. `metrics`, `metrics overview`, +`search-terms list`, and `competitors summary` share a separate analytics pool with its own +budget and its own `429 cli_analytics_busy`; that pool and its numbers live in the metrics +reference, not here. + +A budget running out answers `429 cli_rate_limit_exceeded` with the wait in `Retry-After` — +a different code from `cli_analytics_busy` (that other pool's concurrency cap) and from +`cli_cooldown_active` (the token-wide escalation described next). The CLI already waits +out and retries the first 429 of any command on its own — the exact `Retry-After`, up to +60 seconds, cool-downs excluded — so a 429 that reaches you means the retry also failed and +the budget is genuinely exhausted; don't loop, wait for the window to reset or reduce the +call. Twenty rejections within 5 minutes escalate any of these commands into +`429 cli_cooldown_active`, a cool-down scoped to the token (5m → 30m → 3h) that stops every +`asa` command from that token, not just the one that tripped it. + +## Writes and idempotency + +- Every mutating command prints the exact request body it will send and waits for + confirmation. `--yes` skips the prompt. Under `--json` or in a pipe, the command refuses + rather than hanging. +- Every write sends an `Idempotency-Key`. It is auto-generated per invocation; one network + error is retried automatically with the same key, so a request that died on the wire is + never applied twice. +- `--idempotency-key ` pins the key yourself. A repeat with the same key and body + within 24 hours replays the stored result and prints "Already applied earlier — showing + the stored result." instead of creating a second entity. +- Same key, different body: `422 cli_idempotency_key_reuse`. Concurrent duplicate: + `409 cli_idempotency_in_progress`. +- Keyword and negative-keyword calls are batches: one bad id fails the whole batch before + Apple is called; Apple may still reject individual items within an otherwise valid batch, + each with its own reason. +- Money flags take a bare amount (`--daily-budget 50`); `--currency` defaults to `USD`. +- There is no delete command in the `asa` topic. diff --git a/docs/agent/asa-metrics.md b/docs/agent/asa-metrics.md new file mode 100644 index 0000000..f674ed6 --- /dev/null +++ b/docs/agent/asa-metrics.md @@ -0,0 +1,129 @@ +# Apple Search Ads — Metrics and Analytics + +No `asa` command takes `--app` to select scope — the token's company already fixes that. +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 +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, +no counts of any kind. Every number comes from the commands in this file. To count entities +instead of reading their numbers — "how many active campaigns do I have" — skip metrics +entirely: every list response carries `meta.pagination.count`; pass `--page-size 1` and read +that field. + +## Commands + +| 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 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. | + +## Date window caps + +`metrics` and `metrics overview` cap the date window by how coarse the call's grouping is: + +| Grouping | Max window | +|---|---| +| `day`, or no grouping at all | 90 days | +| `week` | 180 days | +| `month`, `quarter`, or `year` | 365 days | + +On `metrics` this is set by `--group-by`; on `metrics overview` by `--period-unit`. A window +too wide for the grouping you asked for is fixed by coarsening that flag, never by splitting +the request into more calls — a year of data is one call at `--group-by month` (or +`--period-unit month`), not four 90-day calls. + +## Metric vocabulary + +`--metric` and `--order-by` take the dashboard's own metric names — never invent or guess +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. + +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 +— `--order-by gross_roas`, not `--order-by roas`. There is no `ltv` metric; see [Cohort +windows](#cohort-windows). + +`metrics overview` accepts the root names only (`revenue`, `roas`, `arpu`, …) — no +`gross_`/`proceeds_`/`net_` variants, and no keyword-only names. + +**Apple spend metrics:** `spend`, `local_spend`, `impressions`, `taps`, `ttr`, `avg_cpt`, +`avg_cpm`, `ipm`, `total_installs`, `total_new_downloads`, `total_redownloads`, +`tap_installs`, `tap_new_downloads`, `tap_redownloads`, `view_installs`, +`view_new_downloads`, `view_redownloads`, `total_avg_cpi`, `total_install_rate`, +`tap_install_cpi`, `tap_install_rate`. + +**Adapty attribution metrics:** `adapty_installs`, `trials_started`, `trials_converted`, +`subscriptions_started`, `non_subscriptions`, `paid`, `conversion`, `paid_subscribers`, +`subscribers`, `adapty_install_cr`, `trial_cr`, `trials_converted_cr`, +`subscriptions_started_cr`, `non_subscriptions_cr`, `paid_cr`, `conversion_cr`, +`cost_per_adapty_install`, `cost_per_trial`, `cost_per_trials_converted`, +`cost_per_subscriptions_started`, `cost_per_non_subscriptions`, `cost_per_paid`, +`cost_per_conversion`. + +**Cohort (revenue) metrics**, per gross/proceeds/net: `gross_revenue`, `proceeds_revenue`, +`net_revenue`, and the same triple for `arpu`, `arppu`, `arpas`, `roas`, `roi`. + +**Keyword-only:** `rank`, `search_popularity`, `impression_midpoint`. + +## Cohort windows + +The single most misunderstood part of the surface: there is no `ltv` metric. Lifetime value +is a cohort metric read at a renewal window, so `--by-days` is how you ask for a day-7 or +day-90 value. + +- `--by-days` is repeatable, at most 16 windows per call, on both `metrics` and `metrics + overview`. Omit it entirely to get the dashboard's default figures instead of cohort + values. +- `--order-by-day` ranks rows by one of those windows — the way to get top campaigns by + day-90 ROAS in a single call. Its value must be one of the windows passed to `--by-days`. +- Ranking by a cohort metric takes the expanded name — `--order-by gross_roas`, not + `--order-by roas` — see [Metric vocabulary](#metric-vocabulary). + +```sh +adapty asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 \ + --metric roas --by-days 7 --by-days 90 --order-by gross_roas --order-by-day 90 +``` + +## The analytics pool + +Four commands draw on one 2-concurrent-query pool per company: `metrics`, `metrics +overview`, `search-terms list`, and `competitors summary`. A slot held by one is a slot the +others can't use. On top of that shared concurrency, each pair also carries its own +per-minute budget: + +| Commands | Per-minute budget | +|---|---| +| `metrics`, `metrics overview` | 5/min, burst at most 2 per 10s | +| `search-terms list`, `competitors summary` | 30/min | + +Three 429 codes, not one: + +- `cli_analytics_busy` — the 2-concurrent pool is full; wait about 5 seconds. +- `cli_rate_limit_exceeded` — the per-minute window (5/min or 30/min, whichever pair) is + full. +- `cli_cooldown_active` — stop entirely; tell the user when to retry. + +Every refusal carries the wait in `Retry-After`. The CLI already absorbs the first 429 of +any single command on its own — it waits the exact `Retry-After` (up to 60s; cool-downs +excluded) and retries once. So a 429 that reaches you is the *second* attempt failing: the +budget is genuinely exhausted for now. Don't loop on it — cut the number of calls in the +plan, or tell the user when to retry. + +20 rejections within 5 minutes put the token — not the whole company — into an escalating +cool-down: `cli_cooldown_active`, 5m → 30m → 3h. Retrying during the pause does not extend +it; the fix is calling less, not retrying harder. + +These limits are specific to the four commands above. They do not apply to the management +commands in `asa-management.md` — campaigns, ad groups, ads, keywords, negative keywords, +product pages, creatives, automations all run outside this pool. `asa keywords list` looks +like it belongs here (it's the heaviest metadata read in the topic) but runs on its own, +separate 2-concurrent pool with its own 30/min budget — documented in `asa-management.md`, +shared with nothing in this file. diff --git a/docs/agent/skills/adapty-cli-setup/SKILL.md b/docs/agent/skills/adapty-cli-setup/SKILL.md new file mode 100644 index 0000000..afcece6 --- /dev/null +++ b/docs/agent/skills/adapty-cli-setup/SKILL.md @@ -0,0 +1,170 @@ +--- +name: adapty-cli-setup +license: MIT +description: Use when the Adapty CLI needs installing or authenticating before any Apple Search Ads work — a fresh Cowork or cloud session, "adapty: command not found", AuthRequiredError, an expired auth code, connecting an Apple Search Ads account, or a 402 ads_manager_subscription_required. Triggers on "install the Adapty CLI", "set up adapty", "adapty auth login", "connect Apple Search Ads", "not authenticated", or any Apple Ads request made in a session where the CLI is not yet installed. +--- + +# Adapty CLI setup + +Get from a blank machine to `adapty asa whoami` printing a company with Apple connected. Then hand +off to `apple-ads`. Nothing here spends money. + +**A cloud session starts on a clean machine.** No CLI, no token, no config — nothing carries over +from a previous session, and nothing in this one carries into the next. Never skip the run because +setup "was already done". + +## Run this + +Two scripts do the whole job. Run step 1, surface what it prints, run step 2. Do not improvise +around them and do not hand-roll the install or the login. + +**Step 1 — install the latest CLI and start auth.** Run this whole block as one command: + +```bash +set -u; D=${TMPDIR:-/tmp}/adapty-setup; mkdir -p $D +npm install -g adapty@latest >$D/npm.log 2>&1 || { npm config set prefix ~/.npm-global; export PATH=~/.npm-global/bin:$PATH; npm install -g adapty@latest >>$D/npm.log 2>&1; } +GB="$(npm prefix -g 2>/dev/null)/bin"; [ -d "$GB" ] && export PATH="$GB:$PATH"; adapty --version +OUT=$(timeout 30 adapty asa whoami 2>&1); RC=$? +if [ $RC -eq 0 ]; then echo "$OUT"; echo READY_AUTHED; exit 0; fi +case "$OUT" in *ads_manager_subscription_required*|*402*) echo "$OUT"; echo READY_AUTHED_NO_SUB; exit 0;; esac +rm -f $D/auth.out $D/auth.pid; setsid nohup bash -c 'echo $$ > "$1"; exec adapty auth login' _ $D/auth.pid >$D/auth.out 2>&1 /dev/null && echo "NEED_AUTH url=$U code=${U##*code=} pid=$(cat $D/auth.pid)" || echo ERROR waiter_died +``` + + +It installs `adapty@latest`, retries with a user prefix on `EACCES`, puts the global bin dir on +`PATH`, verifies the `asa` topic exists, kills any waiter left from an earlier attempt, and ends in +exactly one status line: + +| Status line | What to do | +| --- | --- | +| `READY_AUTHED` | Already authenticated. Skip to **Apple connection** below. | +| `READY_AUTHED_NO_SUB` | Authenticated, but the company has no Ads Manager subscription. Setup is **done** — do not mint a code. Say so and stop; see the `402` row in **Failure modes**. | +| `NEED_AUTH url= code= pid=` | **Surface the url and code to the user immediately**, then run step 2. | +| `ERROR ` | Read the reason against **Failure modes** below. Do not retry blindly. | + +This is the single-file edition: the commands are inline, so there are no script files to locate. + +**On `NEED_AUTH`, sending the link is the very next thing you do — before any other tool call, any +summary, any commentary.** The code is minted server-side when the script starts and expires within +minutes; every second between minting and the user clicking is spent against that TTL. Post the full +clickable URL and the code as its own short message, not buried in a paragraph. + +**Step 2 — wait for approval and confirm.** Poll authentication state, never a process. This block +returns after ~30s on purpose — run it again on `STILL_WAITING` rather than lengthening it, because a +tool call that times out and gets killed can take the auth waiter down with it: + +```bash +D=${TMPDIR:-/tmp}/adapty-setup +for i in $(seq 1 10); do + OUT=$(timeout 30 adapty asa whoami 2>&1); RC=$? + if [ $RC -eq 0 ] && [ -n "$OUT" ]; then echo "$OUT"; echo AUTHED; exit 0; fi + case "$OUT" in *ads_manager_subscription_required*|*402*) echo "$OUT"; echo AUTHED_NO_SUB; exit 0;; esac + grep -qi 'expired\|not found\|invalid' $D/auth.out 2>/dev/null && { echo EXPIRED; exit 0; } + ps -p $(cat $D/auth.pid 2>/dev/null) >/dev/null 2>&1 || { sleep 4; timeout 30 adapty asa whoami 2>/dev/null && echo AUTHED || echo EXPIRED; exit 0; } + sleep 3 +done +echo STILL_WAITING +``` + +| Status line | What to do | +| --- | --- | +| `AUTHED` | Account details are printed above it. Continue to **Apple connection**. | +| `AUTHED_NO_SUB` | Authenticated; no Ads Manager subscription. Setup is done. Stop and say so. | +| `STILL_WAITING` | The user has not clicked yet. Run step 2 again. **Never mint a new code while the old waiter is alive** — that invalidates the link they are about to click. | +| `EXPIRED` | The code is dead. Go back to step 1 for a fresh code; the old one is not reusable. | + +**One link per session is the target.** `EXPIRED` arriving within seconds of `NEED_AUTH`, before the +user could plausibly have clicked, is a bug signal rather than a real expiry — the user has been +given a link that still works. Say what happened and ask whether they clicked it before you mint +anything; a second link makes the first one they were about to click the wrong one. Repeat step 2 +instead. Only a genuine `STILL_WAITING` timeout, or a server rejection, justifies a new code. + +## Apple connection + +`asa whoami` reports `Apple Credentials Status`. If it is not active: + +```bash +adapty asa connect # prints the authorization link and waits +adapty asa connect --no-wait # prints and returns immediately +``` + +Use `--no-wait` when the person authorizing Apple is not the one at this session, then confirm with +`adapty asa whoami` rather than assuming the link was followed. + +Apple authorization belongs to a **company**, not a user. Every `asa` command is scoped to the +token's company and there is no `--app` to switch it, so a `404` on an object that plainly exists in +the dashboard usually means the token is scoped elsewhere. + +## Failure modes + +| What you see | What it means | What to do | +| --- | --- | --- | +| `ERROR node_missing` / `node_too_old` | Node.js below 18 | Install Node 18+; nothing else will work | +| `ERROR npm_install_failed`, log shows a network, DNS or registry error | **In Cowork or any sandbox: egress is off, or the domains are not allowlisted.** The most common cloud failure, and not fixable from the shell | Settings → Capabilities → enable code execution → allow network egress → an access mode that permits package managers → add **both** `adapty.io` and `*.adapty.io` (a wildcard does not cover the apex domain). **Settings apply when a task starts**, so after changing them the user must start a new task; changing them mid-conversation does nothing | +| `ERROR npm_install_failed`, log shows `EACCES` or a write error | Install failed even with a user prefix | Read `$TMPDIR/adapty-setup/npm.log`. Never re-run under `sudo` | +| `ERROR adapty_not_on_path` | Installed, but the global bin dir is not on `PATH` | Export the path the error prints | +| `ERROR asa_topic_missing` | CLI older than 0.4.0 | `npm install -g adapty@latest` | +| `ERROR no_code_minted` | Login started but printed no code | Read `$TMPDIR/adapty-setup/auth.out` | +| `User code not found or expired` in the browser | The waiter died, or the TTL ran out | Step 1 again, and surface the new code faster | +| `AuthRequiredError` after `AUTHED` | Almost always a stale `ADAPTY_TOKEN` | `unset ADAPTY_TOKEN`, re-check | +| `402 ads_manager_subscription_required` | Authenticated fine; the company has no Ads Manager subscription | **Not a setup bug.** Say so plainly and stop. No flag works around it | + +## ADAPTY_TOKEN + +`ADAPTY_TOKEN` is read **before** the stored config and wins outright when set. Two consequences: + +- A stale exported token silently overrides a fresh `auth login`, so unset it before debugging any + auth failure that makes no sense. +- It dies with the shell. A token that works in one command and not the next was never exported, or + was exported in a different process. + +`adapty auth login` works in a cloud session — the browser can be anywhere. **Never tell the user +browser login is unavailable here, and never ask them for a token because the session is remote.** +A token is something they may volunteer; it is never something you request as a substitute for +printing the link. + +## Never + +- **Never ask for a token, or part of one.** A truncated token authenticates nothing. Comparing the + last four characters of two tokens is a debugging trick for spotting a stale `ADAPTY_TOKEN`, not an + input to any command. +- **Never mint a second code while the first waiter is alive.** It kills the link the user is + clicking. +- **Never trust `pgrep -f` to tell you the waiter is alive** — it matches your own shell wrapper. + Use `ps -p `. +- **Never treat a dead pid as proof the code expired.** Authentication state is the signal: a + successful `adapty asa whoami` means authenticated no matter what any process is doing, and a pid + can be recorded wrong. `$!` after `setsid` is setsid's own pid, and setsid exits the instant it + forks — capture the pid from inside the child (`echo $$` before `exec`) instead. +- **Never print a second authorization link without saying why.** Two live links means the user + clicks the wrong one. +- **Never treat `adapty auth status` as proof of a working token.** It never touches the network; + `adapty asa whoami` is the first call that asks the server. +- **Never present a `402` as a broken install**, and never hunt for a flag that bypasses it. +- **Never echo a full token into chat**, and never write one into a file that could be committed. +- **Never run a write command from this skill.** Setup is reads plus `asa connect`. A campaign, bid, + budget or keyword means `apple-ads` owns the next step. + +## Handing off + +Once `asa whoami` shows a company and active Apple credentials, setup is done — say so in one line. + +Anything past that point belongs to Apple Search Ads proper, and **this skill deliberately does not +cover it.** If the `apple-ads` skill is installed, open it: it carries the rules that keep a write +from costing money by accident — `--idempotency-key` per write, the scope-filter matrix for lists, 15 +keywords per call, the shared analytics budget, and the rule that `--yes` never goes on a command the +user runs themselves. + +If `apple-ads` is **not** installed, say so before running anything that writes. Reads +(`metrics`, any `list`, `whoami`) are safe to do directly. But every `asa` write reaches Apple within +seconds, spends real money, and cannot be undone — there is no delete in the `asa` surface and +`--status PAUSED` is the only stop. Do not reconstruct those rules from memory; get the skill, or +hand the user the command to run themselves without `--yes` so the CLI shows them the request body +first. + +`apple-ads` ships in the same plugin as this skill: +https://github.com/adaptyteam/apple-ads-cli diff --git a/package.json b/package.json index eecef78..1913e97 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ }, "scripts": { "build": "shx rm -rf dist && tsc -b", + "check:agent-docs": "oclif manifest && node scripts/check-agent-docs.mjs", "lint": "eslint", "postpack": "shx rm -f oclif.manifest.json", "posttest": "pnpm run lint", diff --git a/scripts/check-agent-docs.mjs b/scripts/check-agent-docs.mjs new file mode 100644 index 0000000..2b188ab --- /dev/null +++ b/scripts/check-agent-docs.mjs @@ -0,0 +1,153 @@ +#!/usr/bin/env node + +import {existsSync, readFileSync} from 'node:fs' +import {relative} from 'node:path' +import {fileURLToPath} from 'node:url' + +const ROOT = fileURLToPath(new URL('..', import.meta.url)) +const MANIFEST = fileURLToPath(new URL('../oclif.manifest.json', import.meta.url)) +const INVENTORY_DOCS = [ + fileURLToPath(new URL('../docs/agent/asa-management.md', import.meta.url)), + fileURLToPath(new URL('../docs/agent/asa-metrics.md', import.meta.url)), +] +const EXAMPLE_DOCS = [ + ...INVENTORY_DOCS, + fileURLToPath(new URL('../docs/agent/skills/adapty-cli-setup/SKILL.md', import.meta.url)), +] + +const errors = [] +const fail = (file, line, message) => errors.push(`${relative(ROOT, file)}:${line}: ${message}`) + +if (!existsSync(MANIFEST)) { + console.error('oclif.manifest.json is missing; run `pnpm build && pnpm exec oclif manifest` first.') + process.exit(1) +} + +const manifest = JSON.parse(readFileSync(MANIFEST, 'utf8')) +const commands = new Map( + Object.entries(manifest.commands).filter( + ([id, command]) => id.startsWith('asa:') && Array.isArray(command.relativePath), + ), +) +const documented = new Map() + +function cells(line) { + return line + .split('|') + .slice(1, -1) + .map((cell) => cell.trim()) +} + +function commandReference(cell) { + const code = cell.match(/`(asa(?:\s+[a-z][a-z-]*){1,2}(?:\s+[^`]*)?)`/)?.[1] + if (!code) return null + + const commandText = code.match(/^asa(?:\s+[a-z][a-z-]*){1,2}/)?.[0] + if (!commandText) return null + + return {code, id: commandText.replaceAll(' ', ':')} +} + +function resolveFlag(command, writtenName) { + if (command.flags?.[writtenName]) return writtenName + if (writtenName.startsWith('no-')) { + const positiveName = writtenName.slice(3) + if (command.flags?.[positiveName]?.allowNo) return positiveName + } + + return null +} + +for (const file of INVENTORY_DOCS) { + const lines = readFileSync(file, 'utf8').split('\n') + + for (const [index, line] of lines.entries()) { + if (!line.startsWith('|')) continue + const row = cells(line) + if (row.length < 3) continue + + const reference = commandReference(row[0]) + if (!reference) continue + + const command = commands.get(reference.id) + if (!command) { + fail(file, index + 1, `documents unknown command ${reference.id.replaceAll(':', ' ')}`) + continue + } + + if (documented.has(reference.id)) { + fail(file, index + 1, `documents ${reference.id.replaceAll(':', ' ')} more than once`) + continue + } + + documented.set(reference.id, {file, line: index + 1}) + const writtenFlags = new Set() + for (const match of row[1].matchAll(/`--([a-z][a-z0-9-]*)/g)) { + const resolved = resolveFlag(command, match[1]) + if (resolved) { + writtenFlags.add(resolved) + } else { + fail(file, index + 1, `${reference.id.replaceAll(':', ' ')} has no --${match[1]} flag`) + } + } + + for (const [name, flag] of Object.entries(command.flags ?? {})) { + if (flag.required && !writtenFlags.has(name)) { + fail(file, index + 1, `${reference.id.replaceAll(':', ' ')} is missing required flag --${name}`) + } + } + + const requiredArguments = Object.values(command.args ?? {}).filter((argument) => argument.required).length + const writtenArguments = [...reference.code.matchAll(/<[^>]+>/g)].length + if (writtenArguments < requiredArguments) { + fail( + file, + index + 1, + `${reference.id.replaceAll(':', ' ')} documents ${writtenArguments} positional argument(s), manifest requires ${requiredArguments}`, + ) + } + } +} + +for (const id of commands.keys()) { + if (!documented.has(id)) fail(MANIFEST, 1, `${id.replaceAll(':', ' ')} is missing from the agent docs`) +} + +function checkExamples(file, line, lineNumber) { + for (const match of line.matchAll(/\badapty\s+asa(?:\s+[a-z][a-z-]*){1,2}/g)) { + const words = match[0].replace(/^adapty\s+/, '').split(/\s+/) + let id = words.join(':') + while (words.length > 2 && !commands.has(id)) { + words.pop() + id = words.join(':') + } + + const command = commands.get(id) + if (!command) { + fail(file, lineNumber, `example uses unknown command ${match[0]}`) + continue + } + + const example = line.slice(match.index) + for (const flag of example.matchAll(/--([a-z][a-z0-9-]*)/g)) { + if (!resolveFlag(command, flag[1])) { + fail(file, lineNumber, `${id.replaceAll(':', ' ')} example uses unknown flag --${flag[1]}`) + } + } + } +} + +for (const file of EXAMPLE_DOCS) { + const lines = readFileSync(file, 'utf8').split('\n') + for (const [index, line] of lines.entries()) { + checkExamples(file, line, index + 1) + } +} + +if (errors.length > 0) { + for (const error of errors) console.error(`ERROR ${error}`) + console.error(`\n${errors.length} agent documentation drift error(s)`) + process.exit(1) +} + +console.log(`Agent docs match ${commands.size} executable Apple Ads commands in oclif.manifest.json.`) diff --git a/skills/adapty-cli/SKILL.md b/skills/adapty-cli/SKILL.md index d19ea4b..7dbdac3 100644 --- a/skills/adapty-cli/SKILL.md +++ b/skills/adapty-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: adapty-cli -description: Use when setting up or managing Adapty in-app subscriptions, paywalls, placements, or Apple Search Ads campaigns via CLI. +description: Use when setting up or managing Adapty in-app subscriptions, paywalls, or placements via CLI. --- # Adapty CLI Skill @@ -19,7 +19,7 @@ npx adapty@latest --- -Three modes: **Setup** (new users, quiz-driven), **Manage** (existing users, direct commands) and **Apple Search Ads** (`adapty asa`, ad spend). +Two modes: **Setup** (new users, quiz-driven) and **Manage** (existing users, direct commands). ## Mode: Setup (New to Adapty) @@ -158,55 +158,10 @@ Key notes: --- -## Mode: Apple Search Ads (`adapty asa`) - -Ad spend, not subscriptions. The `asa` topic manages Apple Search Ads campaigns, keywords, ads and -automations, and reads their performance. Full reference in `references/cli-commands.md`. - -**Before answering any performance question, read `references/asa-agent-playbook.md`** — it maps the -common questions (spend, trends, top-N, geo, wasted keywords, LTV, search terms, competitors) to the -single command that answers each, lists every valid metric name, and gives the request budgets. The -short version: - -- One question → one call. Totals and trends = `asa metrics overview`; per-entity ranking = - `asa metrics --order-by ... --page-size N`. The server aggregates and sorts — never loop pages to - sum things yourself; a page holds up to 1000 rows if you really need them all. -- Metrics budget is 5 calls/min (max 2 per 10s, 2 concurrent). Plan inside it; the CLI absorbs one - 429 by itself (waits `Retry-After`, retries once), so a surfaced 429 means back off for real. - Don't add comparisons the user didn't ask for. -- Metric names are fixed and listed in the playbook; a wrong name fails with the full valid list, so - never spend calls probing. -- Date window caps: 90 days at day grain, 180 by week, 365 by month — widen by coarsening - `--group-by`/`--period-unit`, not by splitting into several calls. - -**These commands spend money and change a live ad account.** Treat every write as irreversible: - -- **Confirm before any write.** State plainly what will change — which campaign, which budget, how many - keywords — and get an explicit yes. The command asks too: it prints the request body it is about to send and - waits. Pass `--yes` only after the user has agreed; there is no undo and no delete. -- **Never invent IDs or budgets.** Read them first (`asa orgs list`, `asa campaigns list`) or ask. -- **Re-runs are safe when the key is pinned.** Every write sends an auto-generated `Idempotency-Key`, and one - network error is retried with the same key, so a call is never applied twice by accident. In scripts pass - `--idempotency-key` so the whole pipeline can be re-run: a repeat replays the stored result (the CLI prints - "Already applied earlier") instead of applying again. -- **Prefer the smallest step.** Add a handful of keywords, check the result, then continue. A 100-item batch - that Apple partially rejects is harder to reason about than three small ones. -- **A dry run is available for automations only**: `asa automations run --dry-run` evaluates a rule and - logs what it would do without touching Apple. Use it before enabling a rule that changes bids. -- **Metrics are cheap, writes are not.** Reads and `--dry-run` are safe to run freely; anything else is not. - -Key notes that differ from the rest of the CLI: - -- No required `--app`: scope comes from the token's company. `--app` exists on lists only, as a filter -- **Filter every list you can.** `--campaign-group`, `--app`, `--campaign`, `--ad-group`, `--status`, `--search` - narrow the query itself, so a scoped read is cheap and an unscoped one pages the whole account. `asa keywords - list` without `--ad-group` is still the widest read in the surface -- `asa whoami` first — it reports whether Apple Ads is connected and whether the company may use the CLI -- A 402 means the company has no Ads Manager subscription; a 404 means the entity is not theirs or absent -- A 429 carries the wait in `Retry-After`. Metrics and the search-terms list share one - analytics pool (2 concurrent queries per company, `cli_analytics_busy`); a burst of 429s triggers an - escalating token cool-down (`cli_cooldown_active`, 5m → 30m → 3h) — fix the request, don't hammer -- Keywords are always batches, capped at 100 per call, and a partial rejection is reported per item +## Apple Ads + +Apple Ads campaign management now lives in the dedicated agent skills, playbooks, and vertical guides at +[adaptyteam/apple-ads-cli](https://github.com/adaptyteam/apple-ads-cli). ---