Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .mocharc.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"require": [
"ts-node/register"
"ts-node/register",
"test/helpers/isolate-config.ts"
],
"watch-extensions": [
"ts"
Expand Down
17 changes: 16 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ src/
placements/ # list, get, create, update (audiences[] or deprecated --paywall-id)
segments/ # list, get
access-levels/ # list, get, create, update
asa/ # Apple Search Ads: whoami, connect, orgs, apps, campaigns, ad-groups, keywords,
# negative-keywords, search-terms, ads, product-pages, creatives, automations, metrics,
# competitors
lib/
api-client.ts # HTTP client (fetch-based, bearer auth)
config.ts # ~/.config/adapty/config.json read/write
Expand All @@ -32,23 +35,35 @@ src/
errors.ts # ApiError, NetworkError, AuthRequiredError
flags.ts # shared flags: --app (UUID), pagination
output.ts # printResponse(), printList() helpers (auto-formats snake_case keys)
asa-client.ts # factory: ApiClient against the ASA service (errorFormat 'asa')
asa-flags.ts # shared asa flags: scope filters, period, money, batch caps
asa-confirm.ts # mutation preview + confirmation prompt (--yes; refuses when piped or --json)
asa-schemas.ts # response typings for asa entities
```

## Conventions

- oclif topic separator is space (e.g. `adapty apps list`, not `adapty apps:list`)
- All resource commands scoped under `--app APP_ID` (UUID, validated)
- All resource commands scoped under `--app APP_ID` (UUID, validated) — except `asa`, which is scoped by the
token's company (`--app` there is only a list filter)
- `list` commands use shared pagination flags (--page, --page-size)
- Commands support `--json` flag via oclif's `enableJsonFlag = true`
- Auth token stored at `~/.config/adapty/config.json` (mode 0o600)
- `ADAPTY_TOKEN` env overrides stored token
- `ADAPTY_API_URL` env overrides default API base URL
- API base: `https://api-admin.adapty.io/api/v1/developer`
- `asa` topic talks to its own service: base `https://api-asa-admin.adapty.io/api/v1/cli`, overridden by
`ADAPTY_ASA_API_URL`; same bearer token, but errors follow the ASA shape (per-item `errors[]`, FastAPI
`detail`, `Retry-After` on 429)

## Key Patterns

- Each command: single class extending `Command` in its own file
- `createAuthenticatedClient(config)` — factory for token-aware ApiClient
- `createAsaClient(config)` — same, against the ASA service; asa writes print the request body and ask for
confirmation before sending (`asa-confirm.ts`)
- All asa POST/PUT go through `asaWrite()` (`asa-client.ts`): auto `Idempotency-Key` (pin with
`--idempotency-key`), one retry on NetworkError with the same key, replayed responses get a printed note
- `PaginatedResponse<T>` — standard list response wrapper
- Human output via `printResponse()`/`printList()` (auto-formats snake_case → labels); JSON output via oclif flag
- All entities use `title` field (not `name`) in API requests and responses
Expand Down
139 changes: 134 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,21 +86,150 @@ adapty access-levels create --app UUID [flags]
adapty access-levels update --app UUID ACCESS_LEVEL_ID [flags]
```

### Apple Search Ads

Apple Search Ads commands live under `adapty asa` and talk to the ASA service rather than the Developer
API. They take **no `--app`**: the scope is the company behind your token. A connected Apple Ads account and
an active Ads Manager subscription are required — `adapty asa whoami` tells you where you stand.

```sh
adapty asa whoami # company, how access was granted, Apple connection state
adapty asa connect [--no-wait] # link an Apple Search Ads account
adapty asa apps list # apps promoted in Apple Search Ads
adapty asa orgs list # Apple Search Ads organizations
```

Every list takes scope filters, and they narrow the query rather than the printed page — `asa keywords list`
unfiltered pages through the whole account, while one ad group is a handful of rows, so scope the read:

```sh
adapty asa campaigns list --app APP_UUID --status PAUSED
adapty asa ad-groups list --campaign CAMPAIGN_UUID
adapty asa keywords list --ad-group AD_GROUP_UUID --status ACTIVE
adapty asa keywords list --ad-group AD_GROUP_UUID --ad-group OTHER_UUID # repeatable
adapty asa creatives list --app APP_UUID
```

`--campaign-group`, `--app`, `--campaign`, `--ad-group` are repeatable and take the UUIDs printed by the
matching list command; `--search` matches names case-insensitively. Each list accepts only the filters that
make sense for it: `--ad-group` starts at keywords, negative keywords, search terms and ads, `--status` is
`ENABLED`/`PAUSED` everywhere except keywords, which are `ACTIVE`/`PAUSED`, and `asa ads list` has no `--app`
because ads hang off ad groups. An id belonging to another company simply matches nothing.

Campaign structure. These lists return metadata only — numbers come from `asa metrics`, and only
`asa search-terms list` takes `--date-from` / `--date-to` (default: today):

```sh
adapty asa campaigns list
adapty asa campaigns get CAMPAIGN_ID
adapty asa campaigns create --org UUID --name "Winter push" --adam-id 123456 --country US --daily-budget 50
adapty asa campaigns update CAMPAIGN_ID [--status PAUSED] [--daily-budget 80] [--country US]

adapty asa ad-groups list
adapty asa ad-groups get AD_GROUP_ID
adapty asa ad-groups create --campaign UUID --name "Brand terms" --default-bid 1.20
adapty asa ad-groups update AD_GROUP_ID [--default-bid 1.50] [--status PAUSED]

adapty asa ads list
adapty asa ads get AD_ID
adapty asa ads create --ad-group UUID --creative-id 4321 --name "Summer ad"
adapty asa ads update AD_ID [--name "..."] [--status PAUSED]
```

Keywords are always applied as a batch, at most 100 per call, and a partial rejection is reported per item:

```sh
adapty asa keywords list
adapty asa keywords add --ad-group UUID --text "running shoes" --text "trail shoes" [--bid 1.20] [--match-type EXACT]
adapty asa keywords add --ad-group UUID --from-file keywords.txt
adapty asa keywords update KEYWORD_ID [KEYWORD_ID...] [--bid 2.00] [--status PAUSED]

adapty asa negative-keywords list
adapty asa negative-keywords add --ad-group UUID --text free
adapty asa negative-keywords add --campaign UUID [--all-ad-groups] --text free

adapty asa search-terms list [--date-from ... --date-to ...]
```

Product pages and rule-based automations:

```sh
adapty asa product-pages list
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 run AUTOMATION_ID [--dry-run]
adapty asa automations runs AUTOMATION_ID
```

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
1000 rows) when you genuinely need every row; never sum pages client-side:

```sh
adapty asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31
adapty asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --order-by spend --page-size 5
adapty asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --group-by country --page-size 1000
adapty asa metrics --entity keyword --date-from 2026-07-01 --date-to 2026-07-31 --metric spend --metric roas
adapty asa metrics --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 --metric roas --by-days 7 --by-days 90
adapty asa metrics overview --entity campaign --date-from 2026-07-01 --date-to 2026-07-31 [--period-unit week]
```

There is no `ltv` metric: lifetime value is a cohort metric read at a renewal window, so `--by-days` is how you
ask for day-7 or day-90 values — on either route, up to 16 windows per call. `--order-by-day` ranks the rows by
one of those windows, which is how you get the top campaigns by day-90 ROAS in a single call.

Competitor summary takes 1–5 Apple App Store IDs and covers the last full month across every country — there
are no period or country flags on purpose. The first call on a cold cache can take tens of seconds:

```sh
adapty asa competitors summary --app-ids 111111111,2222222
```

Writes go straight to Apple and take seconds, so every writing command first prints the exact request body and
asks for a yes. `--yes` skips the question for scripts; in a pipe or under `--json` the command refuses instead
of waiting for input that will never come. There is no undo — the CLI has no delete.

Every write also carries an idempotency key. The CLI generates one per invocation and retries once on a
network error, so a request that died on the wire is never applied twice. Pass `--idempotency-key` to pin the
key yourself: re-running a script with the same key within 24 hours replays the stored result — the CLI prints
"Already applied earlier — showing the stored result." — instead of creating a second entity. The same key
with a different body is rejected (`422 cli_idempotency_key_reuse`), and a concurrent duplicate answers
`409 cli_idempotency_in_progress`.

Analytics is rate limited per company: the metrics routes get 5 calls a minute (at most 2 in any 10 seconds)
and share a pool of two concurrent queries with the search-terms list — a busy pool answers
`429 cli_analytics_busy`, an exhausted window `429 cli_rate_limit_exceeded`, both with the exact wait in
`Retry-After`. The CLI absorbs a single 429 on its own — it waits the announced `Retry-After` (up to 60
seconds; cool-downs are never waited out) and retries once — so a 429 that reaches you means the retry failed
too. A burst of 429s puts the token into an escalating cool-down (`cli_cooldown_active`, 5 minutes →
30 minutes → 3 hours); retries during the pause don't extend it, but the cure is fixing the failing request,
not waiting out the pause in a loop. An automation run is queued rather than awaited: `run` prints a run ID
and the outcome shows up in `adapty asa automations runs`.

### Global Flags

| Flag | Description |
| ------------- | -------------------------------------- |
| `--json` | Output as JSON |
| `--help` | Show help |
| `--page` | Page number (default: 1) |
| `--page-size` | Items per page (default: 20, max: 100) |
| `--page-size` | Items per page (default: 20, max: 100; `asa` commands: default 100, max 1000) |

## Environment Variables

| Variable | Description |
| ---------------- | ------------------------------------------------------------------------------- |
| `ADAPTY_TOKEN` | Override stored auth token |
| `ADAPTY_API_URL` | Override API base URL (default: `https://api-admin.adapty.io/api/v1/developer`) |
| Variable | Description |
| -------------------- | --------------------------------------------------------------------------------------- |
| `ADAPTY_TOKEN` | Override stored auth token |
| `ADAPTY_API_URL` | Override Developer API base URL (default: `https://api-admin.adapty.io/api/v1/developer`) |
| `ADAPTY_ASA_API_URL` | Override Apple Search Ads base URL (default: `https://api-asa-admin.adapty.io/api/v1/cli`) |

The two API URLs are independent: pointing `ADAPTY_API_URL` at a staging host leaves `adapty asa` on the ASA
default, and the other way round.

## Claude Code Skill

Expand Down
41 changes: 40 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "adapty",
"description": "Adapty command line interface",
"version": "0.3.0",
"version": "0.4.0",
"author": "Adapty team <support@adapty.io>",
"bin": {
"adapty": "./bin/run.js"
Expand Down Expand Up @@ -82,6 +82,45 @@
},
"segments": {
"description": "List segments"
},
"asa": {
"description": "Apple Search Ads: campaigns, keywords, metrics and automations (scoped by the token's company, no --app)"
},
"asa:apps": {
"description": "Apps promoted in Apple Search Ads"
},
"asa:orgs": {
"description": "Apple Search Ads organizations"
},
"asa:campaigns": {
"description": "Manage Apple Search Ads campaigns"
},
"asa:ad-groups": {
"description": "Manage ad groups"
},
"asa:keywords": {
"description": "Manage targeting keywords"
},
"asa:negative-keywords": {
"description": "Manage negative keywords"
},
"asa:search-terms": {
"description": "Search terms your ads matched"
},
"asa:ads": {
"description": "Manage ads"
},
"asa:product-pages": {
"description": "Custom product pages"
},
"asa:creatives": {
"description": "Creatives that back ads"
},
"asa:automations": {
"description": "Rule-based automations"
},
"asa:metrics": {
"description": "Metrics and overviews"
}
}
},
Expand Down
56 changes: 54 additions & 2 deletions skills/adapty-cli/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: adapty-cli
description: Use when setting up or managing Adapty in-app subscriptions, paywalls, or placements via CLI.
description: Use when setting up or managing Adapty in-app subscriptions, paywalls, placements, or Apple Search Ads campaigns via CLI.
---

# Adapty CLI Skill
Expand All @@ -19,7 +19,7 @@ npx adapty@latest

---

Two modes: **Setup** (new users, quiz-driven) and **Manage** (existing users, direct commands).
Three modes: **Setup** (new users, quiz-driven), **Manage** (existing users, direct commands) and **Apple Search Ads** (`adapty asa`, ad spend).

## Mode: Setup (New to Adapty)

Expand Down Expand Up @@ -158,6 +158,58 @@ 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 <id> --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

---

## Adapty Concepts

- **Product** — subscription or one-time purchase mapped to store product IDs. Has a period, grants an access level.
Expand Down
Loading
Loading