@krunal202/apple-search-ads-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 apple-search-ads-cli contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/LLM_GUIDE.md ADDED
@@ -0,0 +1,251 @@
1
+ # LLM Guide — asa (Apple Search Ads CLI)
2
+
3
+ This guide tells a language model (or any automated agent) exactly how to use `asa`.
4
+ You can also print it at any time with:
5
+
6
+ ```bash
7
+ asa llm
8
+ ```
9
+
10
+ ## Output contract
11
+
12
+ - **Every command prints JSON to stdout.** Default is one compact line; add `--pretty` for indented JSON.
13
+ - **Every failure prints a JSON error to stderr and exits 1:**
14
+
15
+ ```json
16
+ {"error":"Not logged in","hint":"Run \"asa login\" first. See \"asa llm\" for the full guide."}
17
+ ```
18
+
19
+ Error objects may include `status` (HTTP status from Apple) and `details` (Apple's raw error body).
20
+ Always read the `hint` field — it tells you the next correct action.
21
+
22
+ ## Rules for agents
23
+
24
+ 1. **Start every session with `asa context`** (add `--live` to include current campaigns). It returns stored notes and recent mutations so you resume with full context instead of re-discovering state.
25
+ 2. Never guess IDs. Discover them with `list` commands (`asa campaigns list`, `asa adgroups list`, ...).
26
+ 3. When a command fails, read the JSON error, follow `hint`, and retry with corrected input — do not repeat the same call.
27
+ 4. Unknown command or option? Run `asa --help` or `asa <command> --help`. Suggestions are included in the error message.
28
+ 5. Any create/update accepts `--data '<json>'` to pass fields not covered by flags. `--data` is merged over flag-built values, so it wins on conflicts.
29
+ 6. `asa paste` and `asa campaigns create` default to `PAUSED` status so nothing spends money unexpectedly. Pass `--status ENABLED` explicitly to activate.
30
+ 7. After making a decision a future session should know (why a bid changed, what a test is for), record it with `asa remember "..."`.
31
+
32
+ ## Memory
33
+
34
+ Memory is stored per org under `~/.asa/memory/<orgId>/`.
35
+
36
+ ```bash
37
+ asa context [--live] [--limit 20] # notes + recent mutations (+ live campaign list)
38
+ asa remember "Dropped US bids to 1/3; IR/RU traffic was not converting" [--campaign <id>]
39
+ asa memory [--limit 50] # dump raw memory
40
+ asa memory --clear # erase memory for the active org
41
+ ```
42
+
43
+ Every mutating API call (create/update/delete/paste) is logged automatically with timestamp, method, path and success/failure — you do not need to log actions yourself, only *reasoning*.
44
+
45
+ ## Prerequisites
46
+
47
+ Create credentials in **Apple Search Ads UI → Account Settings → API**. This requires a user with an **API role** (API Account Manager for read/write) — plain Account Admin does not see the create option. Invite yourself via the User Management tab if needed. Apple issues a client ID (`SEARCHADS.…`), team ID, key ID, and you upload your own ES256 (prime256v1) public key; keep the matching `.p8` private key.
48
+
49
+ Note: App Store Connect keys (Issuer ID + `AuthKey_XXXX.p8`) are a different system and return `invalid_client` for this API.
50
+
51
+ ## Global options
52
+
53
+ - `--pretty` — indented JSON instead of one compact line
54
+ - `--org <orgId>` — override the active org for a single call (no state change)
55
+ - `--all` — on `list` commands, auto-paginate and return every item
56
+
57
+ ## Authentication
58
+
59
+ ```bash
60
+ asa login --client-id <id> --team-id <id> --key-id <id> --key-file ./key.p8 [--org <orgId>]
61
+ asa whoami # show stored credential summary + active org
62
+ asa logout # remove credentials, cached token and clipboard
63
+ ```
64
+
65
+ - In a TTY, omitted values are prompted interactively. In a non-interactive shell all values must be flags.
66
+ - On login, orgs are fetched automatically. With multiple orgs, pass `--org <orgId>` or pick when prompted.
67
+ - Credentials live in `~/.asa/config.json` (mode 600). The OAuth token is cached in `~/.asa/token.json` and refreshed automatically. Override the directory with `ASA_CONFIG_DIR`.
68
+
69
+ ### Organizations
70
+
71
+ ```bash
72
+ asa orgs list # orgs the credentials can access
73
+ asa orgs use <orgId> # set the active org (sent as X-AP-Context on every call)
74
+ ```
75
+
76
+ ## Campaigns
77
+
78
+ ```bash
79
+ asa campaigns list [--limit 20] [--offset 0]
80
+ asa campaigns get <id>
81
+
82
+ asa campaigns create \
83
+ --name "Summer Promo" \
84
+ --currency USD \
85
+ --adam-id 123456789 \
86
+ --countries US,GB \
87
+ [--budget 1000] [--daily-budget 50] [--status ENABLED] [--start-time 2026-09-20T00:00:00.000Z] [--end-time ...] [--data '<json>']
88
+
89
+ asa campaigns update <id> [--name ...] [--status ENABLED|PAUSED] [--budget 1500 --currency USD] \
90
+ [--daily-budget 60 --currency USD] [--countries US,CA] [--end-time ...] [--clear-end-time] [--data '<json>']
91
+
92
+ asa campaigns delete <id>
93
+ ```
94
+
95
+ Notes: `--adam-id` is the numeric App Store app ID. `--budget` is the optional lifetime budget; many accounts run with `--daily-budget` only. `--currency` is required whenever a budget flag is used.
96
+
97
+ ## Ad groups
98
+
99
+ API v5 scopes ad groups under campaigns, so every ad group command takes `--campaign`.
100
+
101
+ ```bash
102
+ asa adgroups list --campaign <id> [--limit 20] [--offset 0]
103
+ asa adgroups get <id> --campaign <id>
104
+
105
+ asa adgroups create --campaign <id> --name "Generic terms" \
106
+ [--bid 0.50 --currency USD] [--status ENABLED] [--data '<json>']
107
+
108
+ asa adgroups update <id> --campaign <id> [--name ...] [--status ...] [--bid 0.75 --currency USD] [--data '<json>']
109
+ asa adgroups delete <id> --campaign <id>
110
+ ```
111
+
112
+ `--bid` maps to the ad group's `defaultBidAmount` (CPT bid).
113
+
114
+ Targeting dimensions (age, gender, device class, country, app categories...), CPA goals, and automated keywords opt-in are not flag-covered — pass them via `--data`, e.g.:
115
+
116
+ ```bash
117
+ asa adgroups create --campaign 123 --name "iPhone US" --data '{"targetingDimensions":{"deviceClass":{"included":["IPHONE"]}}}'
118
+ ```
119
+
120
+ ## Keywords
121
+
122
+ API v5 scopes targeting keywords under campaign + ad group, so every keyword command takes both `--campaign` and `--adgroup`.
123
+
124
+ ```bash
125
+ asa keywords list --campaign <id> --adgroup <id> [--limit 20] [--offset 0]
126
+ asa keywords get <id> --campaign <id> --adgroup <id>
127
+
128
+ asa keywords create --campaign <id> --adgroup <id> --text "running shoes" \
129
+ [--match-type BROAD|EXACT] [--bid 0.40 --currency USD] [--status ENABLED]
130
+
131
+ # bulk create (all keyword writes are bulk calls in v5):
132
+ asa keywords create --campaign <id> --adgroup <id> --data '[{"text":"trail shoes","matchType":"EXACT","bidAmount":{"amount":"0.5","currency":"USD"}}]'
133
+
134
+ asa keywords update <id> --campaign <id> --adgroup <id> [--bid 0.60 --currency USD] [--status PAUSED] [--data '<json>']
135
+ asa keywords delete <id> --campaign <id> --adgroup <id>
136
+ ```
137
+
138
+ ## Stats / reports
139
+
140
+ ```bash
141
+ asa stats <level> --start YYYY-MM-DD --end YYYY-MM-DD [options]
142
+ ```
143
+
144
+ - `level`: `campaigns` | `adgroups` | `keywords` | `searchterms` | `ads`
145
+ - `--campaign <id>` — **required for every level except `campaigns`** (API v5 scopes reports under campaigns)
146
+ - `--granularity HOURLY|DAILY|WEEKLY|MONTHLY`
147
+ - `--condition field=OPERATOR=value` (repeatable). Operators: `EQUALS IN NOT_IN GREATER_THAN LESS_THAN STARTSWITH CONTAINS`. Comma-separate multiple values for `IN`.
148
+ - `--order-by field=ASCENDING|DESCENDING` (default `localSpend=DESCENDING`) — Apple requires a selector with orderBy on every report call
149
+ - `--group-by <fields>` comma-separated, e.g. `countryOrRegion`
150
+ - `--include-no-metrics` to include rows with no spend
151
+ - `--data '<json>'` for any other report body fields
152
+
153
+ Examples:
154
+
155
+ ```bash
156
+ asa stats campaigns --start 2026-09-01 --end 2026-09-15 --granularity DAILY
157
+ asa stats keywords --campaign 123 --start 2026-09-01 --end 2026-09-15 --condition adGroupId=EQUALS=456
158
+ asa stats searchterms --campaign 123 --start 2026-09-01 --end 2026-09-15 --group-by countryOrRegion
159
+ ```
160
+
161
+ Dates use the org's timezone (`timeZone: ORTZ`). Metric fields returned include `impressions`, `taps`, `ttr`, `spend`, `avgCPC`, `installs` (conversion fields depend on the account setup).
162
+
163
+ ## Apps
164
+
165
+ Use this to find the `adamId` needed to create a campaign:
166
+
167
+ ```bash
168
+ asa apps search "secure vpn" [--limit 10]
169
+ ```
170
+
171
+ ## Negative keywords
172
+
173
+ Negative keywords stop your ads from matching a term. Campaign-level applies to the whole campaign; add `--adgroup` to scope to one ad group (e.g. to keep competitor terms out of a discovery group).
174
+
175
+ ```bash
176
+ asa negatives list --campaign <id> [--adgroup <id>] [--all]
177
+
178
+ asa negatives create --campaign <id> --text free --match-type BROAD
179
+ asa negatives create --campaign <id> --adgroup <id> --data '[{"text":"crack","matchType":"BROAD"}]'
180
+
181
+ asa negatives delete --campaign <id> [--adgroup <id>] --id <negativeKeywordId>
182
+ asa negatives delete --campaign <id> --data '[123,456]'
183
+ ```
184
+
185
+ ## Copy / paste (duplicate resources)
186
+
187
+ Copies are stored locally in `~/.asa/clipboard.json`. Read-only fields (IDs, timestamps, serving state) are stripped automatically.
188
+
189
+ ```bash
190
+ asa copy campaign 123
191
+ asa paste [--name "Summer Promo Q4"] [--status ENABLED]
192
+
193
+ asa copy adgroup 456 --campaign 123
194
+ asa paste [--campaign 789] [--name ...] # --campaign pastes under a different campaign
195
+
196
+ asa copy keyword 321 --campaign 123 --adgroup 456
197
+ asa paste [--campaign 123] [--adgroup 654] # override either parent to paste elsewhere
198
+ ```
199
+
200
+ Paste always creates the resource `PAUSED` unless `--status ENABLED` is passed, appends ` - Copy` to the name unless `--name` is given, and drops `startTime`/`endTime` values that are in the past (Apple rejects them).
201
+
202
+ ## Typical workflows
203
+
204
+ Create a full structure:
205
+
206
+ ```bash
207
+ asa campaigns create --name "Brand" --daily-budget 50 --currency USD --adam-id 123456789 --countries US
208
+ # → read the new campaign id from the JSON response
209
+ asa adgroups create --campaign <campaignId> --name "Core" --bid 0.50 --currency USD
210
+ asa keywords create --campaign <campaignId> --adgroup <adgroupId> --data '[{"text":"my app","matchType":"EXACT"},{"text":"my app pro","matchType":"BROAD"}]'
211
+ asa campaigns update <campaignId> --status ENABLED
212
+ ```
213
+
214
+ Daily performance check:
215
+
216
+ ```bash
217
+ asa stats campaigns --start <today-7d> --end <today> --granularity DAILY
218
+ asa campaigns update <id> --status PAUSED # if over-performing budget
219
+ ```
220
+
221
+ Search-term mining loop (the core optimization cycle):
222
+
223
+ ```bash
224
+ # 1. see what queries triggered ads in the discovery ad group's campaign
225
+ asa stats searchterms --campaign <campaignId> --start <date> --end <date>
226
+ # 2. promote winners into the exact ad group
227
+ asa keywords create --campaign <campaignId> --adgroup <exactAdgroupId> --text "<winner>" --match-type EXACT
228
+ # 3. block them in discovery so it keeps finding new terms, and block junk everywhere
229
+ asa negatives create --campaign <campaignId> --adgroup <discoveryAdgroupId> --text "<winner>" --match-type EXACT
230
+ asa negatives create --campaign <campaignId> --text "<junk term>" --match-type BROAD
231
+ ```
232
+
233
+ Duplicate a campaign into another market:
234
+
235
+ ```bash
236
+ asa copy campaign 123
237
+ asa paste --name "Brand - UK"
238
+ asa campaigns update <newId> --countries GB --data '{}'
239
+ ```
240
+
241
+ ## Troubleshooting
242
+
243
+ | Symptom | Fix |
244
+ |---|---|
245
+ | `{"error":"Not logged in"...}` | Run `asa login` with all flags |
246
+ | HTTP 401 | Credentials wrong or key revoked — re-run `asa login` |
247
+ | HTTP 400 `invalid_client` on login | Wrong credential system: App Store Connect Issuer IDs don't work — use ASA UI → Account Settings → API credentials (`SEARCHADS.…` client ID) with an API-role user |
248
+ | HTTP 403 | Wrong org — `asa orgs list` then `asa orgs use <orgId>` |
249
+ | HTTP 404 | Stale ID — re-list the parent resource |
250
+ | HTTP 429 | Rate limited — wait and retry |
251
+ | `unknown command 'xyz'` | Check the suggestion in the error, or run `asa --help` |
package/README.md ADDED
@@ -0,0 +1,84 @@
1
+ # apple-search-ads-cli
2
+
3
+ A small, LLM-friendly CLI for the [Apple Search Ads API](https://developer.apple.com/documentation/apple_search_ads) (v5). JSON in, JSON out, structured errors with hints — built to be driven by humans and language-model agents alike.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm i -g @krunal202/apple-search-ads-cli # exposes the `asa` binary
9
+ ```
10
+
11
+ Or from source:
12
+
13
+ ```bash
14
+ npm install
15
+ npm link
16
+ ```
17
+
18
+ Requires Node.js ≥ 18. Single runtime dependency: `commander`.
19
+
20
+ ## Quickstart
21
+
22
+ ```bash
23
+ # 1. Get API credentials: Apple Search Ads → Account Settings → API (client ID, team ID, key ID, .p8 key)
24
+ asa login --client-id <id> --team-id <id> --key-id <id> --key-file ./AuthKey.p8
25
+
26
+ asa orgs list && asa orgs use <orgId>
27
+
28
+ asa campaigns list --pretty
29
+ asa campaigns create --name "Launch" --budget 1000 --currency USD --adam-id 123456789 --countries US
30
+ asa adgroups create --campaign <id> --name "Core" --bid 0.50 --currency USD
31
+ asa keywords create --campaign <id> --adgroup <id> --text "my app" --match-type EXACT
32
+
33
+ asa stats campaigns --start 2026-09-01 --end 2026-09-15 --granularity DAILY --pretty
34
+
35
+ asa copy campaign <id>
36
+ asa paste --name "Launch - Q4"
37
+
38
+ asa logout
39
+ ```
40
+
41
+ ## LLM integration
42
+
43
+ - `asa llm` prints the complete machine-readable usage guide (also in [LLM_GUIDE.md](LLM_GUIDE.md)).
44
+ - Output is single-line JSON by default (`--pretty` for humans).
45
+ - Errors are structured JSON on stderr with a `hint` field describing the corrective action; unknown commands include "did you mean" suggestions.
46
+ - Every create/update accepts `--data '<json>'` for fields not covered by flags.
47
+
48
+ ### Memory for agents
49
+
50
+ `asa` remembers what happened so an LLM starting a fresh session can pick up context:
51
+
52
+ - Every mutating API call is logged automatically per org (`~/.asa/memory/<orgId>/activity.jsonl`).
53
+ - `asa remember "note" [--campaign <id>]` stores decisions and rationale.
54
+ - `asa context [--live]` prints notes + recent activity (+ current campaigns) — the recommended first command of any agent session.
55
+ - `asa memory` / `asa memory --clear` inspects or erases the store.
56
+
57
+ ## Layout
58
+
59
+ ```
60
+ bin/asa.js executable entry
61
+ src/cli.js program wiring, error handling
62
+ src/config.js ~/.asa credential/token/clipboard storage
63
+ src/api/auth.js ES256 client-secret JWT + OAuth token exchange
64
+ src/api/client.js authenticated fetch wrapper (X-AP-Context, error mapping)
65
+ src/commands/ login/logout/orgs, campaigns, adgroups, keywords, negatives,
66
+ apps, stats, copy/paste, llm guide
67
+ test/ node:test unit tests (npm test)
68
+ ```
69
+
70
+ Not yet covered by the CLI: ad creative / custom product page management and budget orders (campaigns are served with the default product page ad).
71
+
72
+ ## License
73
+
74
+ MIT
75
+
76
+ ## Sponsors
77
+
78
+ Maintaining API coverage as Apple evolves the Search Ads API takes ongoing work. If this CLI saves you time, consider sponsoring:
79
+
80
+ - **[Buy Me a Coffee](https://buymeacoffee.com/krunaldoshi)**
81
+ - GitHub Sponsors (button on the repo page once enabled)
82
+ - Questions or sponsorship inquiries: [@krunaldoshi202](https://twitter.com/krunaldoshi202) on X
83
+
84
+ Sponsors can be listed here and in release notes; reach out for logo placement or priority feature requests.
package/bin/asa.js ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../src/cli.js';
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@krunal202/apple-search-ads-cli",
3
+ "version": "0.1.0",
4
+ "description": "LLM-friendly CLI for the Apple Search Ads API",
5
+ "type": "module",
6
+ "bin": {
7
+ "asa": "./bin/asa.js"
8
+ },
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src",
15
+ "LLM_GUIDE.md"
16
+ ],
17
+ "scripts": {
18
+ "test": "node --test"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/krunal202/apple-search-ads-cli.git"
23
+ },
24
+ "bugs": "https://github.com/krunal202/apple-search-ads-cli/issues",
25
+ "homepage": "https://github.com/krunal202/apple-search-ads-cli#readme",
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "license": "MIT",
30
+ "keywords": [
31
+ "apple-search-ads",
32
+ "cli",
33
+ "llm",
34
+ "ads"
35
+ ],
36
+ "dependencies": {
37
+ "commander": "^12.1.0"
38
+ }
39
+ }
@@ -0,0 +1,60 @@
1
+ import crypto from 'node:crypto';
2
+ import { getCredentials, readJson, writeJson } from '../config.js';
3
+ import { AsaError } from '../errors.js';
4
+
5
+ const TOKEN_URL = 'https://appleid.apple.com/auth/oauth2/token';
6
+ const JWT_TTL_SECONDS = 60 * 60 * 24 * 30;
7
+
8
+ export function buildClientSecret({ clientId, teamId, keyId, privateKey }) {
9
+ const now = Math.floor(Date.now() / 1000);
10
+ const header = { alg: 'ES256', kid: keyId, typ: 'JWT' };
11
+ const payload = {
12
+ iss: teamId,
13
+ iat: now,
14
+ exp: now + JWT_TTL_SECONDS,
15
+ aud: 'https://appleid.apple.com',
16
+ sub: clientId,
17
+ };
18
+ const encode = (obj) => Buffer.from(JSON.stringify(obj)).toString('base64url');
19
+ const signingInput = `${encode(header)}.${encode(payload)}`;
20
+ const signature = crypto.sign('sha256', Buffer.from(signingInput), {
21
+ key: privateKey,
22
+ dsaEncoding: 'ieee-p1363',
23
+ });
24
+ return `${signingInput}.${signature.toString('base64url')}`;
25
+ }
26
+
27
+ async function fetchAccessToken(credentials) {
28
+ const body = new URLSearchParams({
29
+ grant_type: 'client_credentials',
30
+ client_id: credentials.clientId,
31
+ client_secret: buildClientSecret(credentials),
32
+ scope: 'searchadsorg',
33
+ });
34
+ const res = await fetch(TOKEN_URL, { method: 'POST', body });
35
+ const data = await res.json().catch(() => ({}));
36
+ if (!res.ok || !data.access_token) {
37
+ throw new AsaError('Failed to obtain an Apple access token', {
38
+ status: res.status,
39
+ details: data,
40
+ hint: 'Check client ID, team ID, key ID and private key with "asa login".',
41
+ });
42
+ }
43
+ return {
44
+ accessToken: data.access_token,
45
+ expiresAt: Date.now() + (Number(data.expires_in ?? 3600) - 60) * 1000,
46
+ };
47
+ }
48
+
49
+ export async function getAccessToken() {
50
+ const cached = readJson('token.json');
51
+ if (cached && cached.expiresAt > Date.now()) return cached.accessToken;
52
+
53
+ const credentials = getCredentials();
54
+ if (!credentials) {
55
+ throw new AsaError('Not logged in', { hint: 'Run "asa login" first. See "asa llm" for the full guide.' });
56
+ }
57
+ const token = await fetchAccessToken(credentials);
58
+ writeJson('token.json', token);
59
+ return token.accessToken;
60
+ }
@@ -0,0 +1,79 @@
1
+ import { getOrgId } from '../config.js';
2
+ import { AsaError } from '../errors.js';
3
+ import { logActivity } from '../memory.js';
4
+ import { getAccessToken } from './auth.js';
5
+
6
+ const BASE_URL = 'https://api.searchads.apple.com/api/v5';
7
+
8
+ const STATUS_HINTS = {
9
+ 400: 'The request body or parameters are invalid. Check the command options or pass a raw body with --data.',
10
+ 401: 'Authentication failed. Run "asa login" to refresh credentials.',
11
+ 403: 'No access to this resource. Check the active org with "asa orgs list" and switch with "asa orgs use <orgId>".',
12
+ 404: 'Resource not found. Verify the ID, e.g. with "asa campaigns list".',
13
+ 429: 'Rate limited by Apple. Wait a moment and retry.',
14
+ };
15
+
16
+ let orgOverride;
17
+
18
+ export function setOrgOverride(orgId) {
19
+ orgOverride = orgId ? Number(orgId) : undefined;
20
+ }
21
+
22
+ export async function api(method, resource, { body, query, orgId } = {}) {
23
+ const token = await getAccessToken();
24
+ const org = getOrgId(orgId ?? orgOverride);
25
+ if (!org && resource !== '/acls') {
26
+ throw new AsaError('No organization selected', {
27
+ hint: 'Run "asa orgs list" then "asa orgs use <orgId>".',
28
+ });
29
+ }
30
+
31
+ const url = new URL(BASE_URL + resource);
32
+ for (const [key, value] of Object.entries(query ?? {})) {
33
+ if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
34
+ }
35
+
36
+ const headers = { Authorization: `Bearer ${token}` };
37
+ if (org) headers['X-AP-Context'] = `orgId=${org}`;
38
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
39
+
40
+ const res = await fetch(url, {
41
+ method,
42
+ headers,
43
+ body: body !== undefined ? JSON.stringify(body) : undefined,
44
+ });
45
+
46
+ const text = await res.text();
47
+ let data;
48
+ try {
49
+ data = JSON.parse(text);
50
+ } catch {
51
+ data = text;
52
+ }
53
+
54
+ const isMutation = method !== 'GET' && !resource.startsWith('/reports');
55
+ if (!res.ok) {
56
+ if (isMutation) logActivity({ method, resource, ok: false, status: res.status });
57
+ throw new AsaError(`Apple Search Ads API error (HTTP ${res.status})`, {
58
+ status: res.status,
59
+ details: data,
60
+ hint: STATUS_HINTS[res.status],
61
+ });
62
+ }
63
+ if (isMutation) logActivity({ method, resource, ok: true });
64
+ return data;
65
+ }
66
+
67
+ export async function apiAll(resource, { query, pageSize = 1000 } = {}) {
68
+ const items = [];
69
+ let offset = 0;
70
+ for (;;) {
71
+ const page = await api('GET', resource, { query: { ...query, limit: pageSize, offset } });
72
+ const batch = page.data ?? [];
73
+ items.push(...batch);
74
+ const total = page.pagination?.totalResults ?? items.length;
75
+ if (items.length >= total || batch.length === 0) break;
76
+ offset += batch.length;
77
+ }
78
+ return items;
79
+ }
package/src/cli.js ADDED
@@ -0,0 +1,70 @@
1
+ import { Command } from 'commander';
2
+ import { readFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { setOrgOverride } from './api/client.js';
6
+ import { registerAdGroups } from './commands/adgroups.js';
7
+ import { registerApps } from './commands/apps.js';
8
+ import { registerAuth, registerOrgs } from './commands/auth.js';
9
+ import { registerCampaigns } from './commands/campaigns.js';
10
+ import { registerClipboard } from './commands/clipboard.js';
11
+ import { printGuide } from './commands/guide.js';
12
+ import { registerKeywords } from './commands/keywords.js';
13
+ import { registerMemory } from './commands/memory.js';
14
+ import { registerNegatives } from './commands/negatives.js';
15
+ import { registerStats } from './commands/stats.js';
16
+ import { AsaError } from './errors.js';
17
+ import { printError, setPretty } from './output.js';
18
+
19
+ const pkg = JSON.parse(readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), 'utf8'));
20
+
21
+ const program = new Command();
22
+ program
23
+ .name('asa')
24
+ .description('CLI for the Apple Search Ads API. Output is JSON (one line) by default; add --pretty for indented JSON.\nRun "asa llm" for the full machine-readable usage guide.')
25
+ .version(pkg.version)
26
+ .option('--pretty', 'pretty-print JSON output')
27
+ .option('--org <orgId>', 'override the active organization for this call')
28
+ .showSuggestionAfterError(true);
29
+
30
+ program.hook('preAction', (cmd) => {
31
+ const opts = cmd.optsWithGlobals();
32
+ setPretty(opts.pretty);
33
+ setOrgOverride(opts.org);
34
+ });
35
+
36
+ registerAuth(program);
37
+ registerOrgs(program);
38
+ registerCampaigns(program);
39
+ registerAdGroups(program);
40
+ registerKeywords(program);
41
+ registerNegatives(program);
42
+ registerApps(program);
43
+ registerStats(program);
44
+ registerClipboard(program);
45
+ registerMemory(program);
46
+
47
+ program.command('llm').description('Print the full LLM usage guide').action(printGuide);
48
+
49
+ function configureRecursive(cmd) {
50
+ cmd.configureOutput({ writeErr: () => {} });
51
+ cmd.exitOverride();
52
+ for (const sub of cmd.commands) configureRecursive(sub);
53
+ }
54
+ configureRecursive(program);
55
+
56
+ try {
57
+ await program.parseAsync(process.argv);
58
+ } catch (err) {
59
+ if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
60
+ process.exit(0);
61
+ }
62
+ if (typeof err.code === 'string' && err.code.startsWith('commander.')) {
63
+ printError(new AsaError(err.message.trim(), {
64
+ hint: 'Run "asa --help" to list commands, "asa <command> --help" for options, or "asa llm" for the full guide.',
65
+ }));
66
+ } else {
67
+ printError(err);
68
+ }
69
+ process.exit(1);
70
+ }