adloop 0.4.2__tar.gz → 0.5.0__tar.gz

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.
Files changed (29) hide show
  1. {adloop-0.4.2 → adloop-0.5.0}/PKG-INFO +38 -21
  2. {adloop-0.4.2 → adloop-0.5.0}/README.md +37 -20
  3. {adloop-0.4.2 → adloop-0.5.0}/pyproject.toml +1 -1
  4. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/__init__.py +5 -1
  5. adloop-0.5.0/src/adloop/ads/client.py +98 -0
  6. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ads/forecast.py +115 -1
  7. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ads/read.py +99 -0
  8. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ads/write.py +172 -21
  9. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/cli.py +50 -9
  10. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/server.py +122 -1
  11. adloop-0.4.2/src/adloop/ads/client.py +0 -41
  12. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/__main__.py +0 -0
  13. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ads/__init__.py +0 -0
  14. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ads/currency.py +0 -0
  15. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ads/gaql.py +0 -0
  16. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ads/pmax.py +0 -0
  17. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/auth.py +0 -0
  18. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/bundled_credentials.json +0 -0
  19. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/config.py +0 -0
  20. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/crossref.py +0 -0
  21. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ga4/__init__.py +0 -0
  22. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ga4/client.py +0 -0
  23. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ga4/reports.py +0 -0
  24. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/ga4/tracking.py +0 -0
  25. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/safety/__init__.py +0 -0
  26. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/safety/audit.py +0 -0
  27. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/safety/guards.py +0 -0
  28. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/safety/preview.py +0 -0
  29. {adloop-0.4.2 → adloop-0.5.0}/src/adloop/tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.4.2
3
+ Version: 0.5.0
4
4
  Summary: Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped.
5
5
  Keywords: mcp,google-ads,google-analytics,ga4,cursor,marketing
6
6
  Author: Daniel Klose
@@ -66,7 +66,7 @@ Every tool exists because of an actual problem hit while running real Google Ads
66
66
 
67
67
  The best features come from real workflows. If you're using AdLoop and find yourself wishing it could do something it can't, **open an issue describing your situation** — not just "add feature X" but "I was trying to do Y and couldn't because Z." The context matters more than the request.
68
68
 
69
- ## All 38 Tools
69
+ ## All 43 Tools
70
70
 
71
71
  > **Quick start:** `pip install adloop` or `git clone https://github.com/kLOsk/adloop.git && cd adloop && uv sync && uv run adloop init`
72
72
 
@@ -94,7 +94,10 @@ The best features come from real workflows. If you're using AdLoop and find your
94
94
  | `get_ad_performance` | Ad copy analysis — headlines, descriptions, CTR |
95
95
  | `get_keyword_performance` | Keywords — quality scores, competitive metrics |
96
96
  | `get_search_terms` | What users actually searched before clicking |
97
- | `get_negative_keywords` | List existing negative keywords for a campaign or all campaigns |
97
+ | `get_negative_keywords` | List direct campaign-level negative keywords |
98
+ | `get_negative_keyword_lists` | List all shared negative keyword lists (SharedSets) — names, IDs, status, keyword count |
99
+ | `get_negative_keyword_list_keywords` | List the keywords inside a specific shared negative keyword list |
100
+ | `get_negative_keyword_list_campaigns` | List which campaigns a shared negative keyword list is attached to |
98
101
  | `get_recommendations` | Google's auto-generated recommendations with type, estimated impact, and campaign context |
99
102
  | `get_pmax_performance` | Performance Max campaign metrics with network breakdown + asset group ad strength |
100
103
  | `get_asset_performance` | Per-asset details for PMax — field type, serving status, content |
@@ -123,6 +126,7 @@ These tools call both APIs internally and return unified results with auto-gener
123
126
 
124
127
  | Tool | What It Does |
125
128
  |------|-------------|
129
+ | `discover_keywords` | Discover new keyword ideas from seed keywords and/or a URL using Google Ads Keyword Planner. Returns avg monthly searches, competition level, and top-of-page bid range. |
126
130
  | `estimate_budget` | Forecast clicks, impressions, and cost for a set of keywords using Google Ads Keyword Planner. Supports geo/language targeting. Essential for budget planning before launching campaigns. |
127
131
 
128
132
  ### Google Ads Write Tools
@@ -140,7 +144,8 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
140
144
  | `draft_structured_snippets` | Create campaign structured snippet assets using official header values and 3-10 snippet values. |
141
145
  | `draft_image_assets` | Create campaign image assets from local PNG, JPEG, or GIF files. |
142
146
  | `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy — blocks BROAD match on Manual CPC campaigns. |
143
- | `add_negative_keywords` | Propose negative keywords to reduce wasted spend |
147
+ | `add_negative_keywords` | Propose negative keywords directly on a campaign |
148
+ | `propose_negative_keyword_list` | Draft a shared negative keyword list (SharedSet) and attach it to a campaign — reusable across multiple campaigns |
144
149
  | `pause_entity` | Pause a campaign, ad group, ad, or keyword |
145
150
  | `enable_entity` | Re-enable a paused entity |
146
151
  | `remove_entity` | Permanently remove an entity (irreversible — prefers pause). Supports keywords, negative keywords, ads, ad groups, campaigns. |
@@ -154,7 +159,7 @@ AdLoop ships with orchestration rules that teach the AI *how* to combine these t
154
159
  - **Claude Code**: `.claude/rules/adloop.md` (synced from Cursor rules via `scripts/sync-rules.py`)
155
160
 
156
161
  The rules include:
157
- - **Orchestration patterns** for common workflows (performance review, conversion diagnosis, campaign creation, negative keyword hygiene, tracking validation, budget planning, landing page analysis)
162
+ - **Orchestration patterns** for common workflows (performance review, conversion diagnosis, campaign creation, negative keyword hygiene, keyword discovery, tracking validation, budget planning, landing page analysis)
158
163
  - **GAQL quick reference** with syntax, common queries, and gotchas
159
164
  - **Safety rules** including Broad Match + Manual CPC prevention and pre-write validation
160
165
  - **Ad copy character limit guidance** (30-char headlines are shorter than you think)
@@ -224,6 +229,25 @@ The wizard:
224
229
 
225
230
  - Python 3.11+
226
231
  - A Google Ads account with an MCC (Manager Account)
232
+ - A Google Ads Developer Token (see below)
233
+
234
+ ### Google Ads Developer Token
235
+
236
+ A developer token is **always required** — even when using AdLoop's built-in OAuth credentials. The built-in credentials handle Google sign-in; the developer token is a separate key that grants API access to your Google Ads data.
237
+
238
+ 1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
239
+ 2. In the MCC, go to **Tools & Settings → API Center**
240
+ 3. Your **developer token** is shown there. Copy it — the wizard will ask for it.
241
+
242
+ **Access levels** — your token's access level determines what it can do:
243
+
244
+ | Level | How to Get | What It Allows |
245
+ |-------|-----------|----------------|
246
+ | **Test Account** | Default for new tokens | Can only access test accounts — **not production accounts**. If you see `DEVELOPER_TOKEN_NOT_APPROVED`, this is why. |
247
+ | **Explorer** | Automatic after first API call with a production account | 2,880 operations/day on production accounts. Enough to get started. |
248
+ | **Basic** | [Apply through API Center](https://ads.google.com/aw/apicenter) | 15,000 operations/day. Apply if you need more. |
249
+
250
+ > **Getting `DEVELOPER_TOKEN_NOT_APPROVED`?** Your token is at "Test Account" level. Go to [API Center](https://ads.google.com/aw/apicenter) in your MCC and check your access level. If it shows "Test Account", you need to apply for Basic access or wait for Explorer access to be granted after your first production API call.
227
251
 
228
252
  ### Headless Servers
229
253
 
@@ -253,17 +277,7 @@ When you run `adloop init`, choose "No" when asked about built-in credentials. T
253
277
 
254
278
  > Service accounts are also supported — just place the service account key JSON at the same `credentials_path`. AdLoop detects the file type automatically.
255
279
 
256
- #### Step 3 — Google Ads Developer Token
257
-
258
- 1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
259
- 2. In the MCC, go to **Tools & Settings → API Center**
260
- 3. Your **developer token** is shown there. Copy it.
261
-
262
- Access levels:
263
- - **Explorer** (automatic) — 2,880 operations/day on production accounts. Enough to get started.
264
- - **Basic** (requires application) — 15,000 operations/day. Apply through the same API Center page if you need more.
265
-
266
- #### Step 4 — Connect to Your Editor
280
+ #### Step 3 — Connect to Your Editor
267
281
 
268
282
  **Cursor** — Add to your project's `.cursor/mcp.json`:
269
283
 
@@ -313,6 +327,7 @@ Ask your AI assistant things like:
313
327
  - *"Draft a new responsive search ad for my main campaign."*
314
328
  - *"Which landing pages get paid traffic but don't convert?"*
315
329
  - *"Is my tracking set up correctly? Compare my codebase events against GA4."*
330
+ - *"What keywords should I target for [product]? Find ideas and estimate the budget."*
316
331
  - *"How much budget would I need for these keywords in Germany?"*
317
332
  - *"Create a new search campaign for [product feature] with a €20/day budget."*
318
333
 
@@ -338,7 +353,7 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
338
353
  ```
339
354
  src/adloop/
340
355
  ├── __init__.py # Entry point — routes 'adloop init' to wizard, otherwise starts MCP server
341
- ├── server.py # FastMCP server — 38 tool registrations with safety annotations
356
+ ├── server.py # FastMCP server — 43 tool registrations with safety annotations
342
357
  ├── config.py # Config loader (~/.adloop/config.yaml)
343
358
  ├── auth.py # OAuth 2.0 flow (bundled + custom credentials, headless fallback) + service accounts
344
359
  ├── cli.py # Interactive 'adloop init' setup wizard
@@ -349,12 +364,12 @@ src/adloop/
349
364
  │ ├── reports.py # Account summaries, reports, realtime
350
365
  │ └── tracking.py # Event discovery
351
366
  ├── ads/
352
- │ ├── client.py # Google Ads API client (version-pinned)
367
+ │ ├── client.py # Google Ads API client (version-pinned) + retry/backoff for rate limits
353
368
  │ ├── gaql.py # GAQL query execution with human-readable error parsing
354
- │ ├── read.py # Campaign, ad, keyword, search term, negative keyword, recommendations, audience reads
369
+ │ ├── read.py # Campaign, ad, keyword, search term, negative keyword, shared sets, recommendations, audience reads
355
370
  │ ├── pmax.py # Performance Max tools — campaign/asset group performance, asset labels, top combinations
356
371
  │ ├── write.py # Draft campaign, RSA, keywords; pause, enable, remove, confirm
357
- │ └── forecast.py # Budget estimation via Keyword Planner API
372
+ │ └── forecast.py # Budget estimation + keyword discovery via Keyword Planner API
358
373
  └── safety/
359
374
  ├── guards.py # Budget caps, bid limits, blocked operations, Broad Match safety
360
375
  ├── preview.py # Change plans and previews
@@ -369,7 +384,9 @@ What's been shipped and what's next:
369
384
  - ~~Google Ads read + write tools with safety layer~~ ✓
370
385
  - ~~Cross-reference intelligence (campaign→conversion mapping, landing page analysis, attribution comparison)~~ ✓
371
386
  - ~~Tracking utilities (validate events against GA4, generate gtag code)~~ ✓
372
- - ~~Budget estimation via Keyword Planner~~ ✓
387
+ - ~~Budget estimation + keyword discovery via Keyword Planner~~ ✓
388
+ - ~~Shared negative keyword lists (SharedSet API)~~ ✓
389
+ - ~~Retry/backoff for API rate limits~~ ✓
373
390
  - ~~Setup wizard (`adloop init`)~~ ✓
374
391
  - ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
375
392
  - ~~PyPI package~~ ✓ — `pip install adloop`
@@ -42,7 +42,7 @@ Every tool exists because of an actual problem hit while running real Google Ads
42
42
 
43
43
  The best features come from real workflows. If you're using AdLoop and find yourself wishing it could do something it can't, **open an issue describing your situation** — not just "add feature X" but "I was trying to do Y and couldn't because Z." The context matters more than the request.
44
44
 
45
- ## All 38 Tools
45
+ ## All 43 Tools
46
46
 
47
47
  > **Quick start:** `pip install adloop` or `git clone https://github.com/kLOsk/adloop.git && cd adloop && uv sync && uv run adloop init`
48
48
 
@@ -70,7 +70,10 @@ The best features come from real workflows. If you're using AdLoop and find your
70
70
  | `get_ad_performance` | Ad copy analysis — headlines, descriptions, CTR |
71
71
  | `get_keyword_performance` | Keywords — quality scores, competitive metrics |
72
72
  | `get_search_terms` | What users actually searched before clicking |
73
- | `get_negative_keywords` | List existing negative keywords for a campaign or all campaigns |
73
+ | `get_negative_keywords` | List direct campaign-level negative keywords |
74
+ | `get_negative_keyword_lists` | List all shared negative keyword lists (SharedSets) — names, IDs, status, keyword count |
75
+ | `get_negative_keyword_list_keywords` | List the keywords inside a specific shared negative keyword list |
76
+ | `get_negative_keyword_list_campaigns` | List which campaigns a shared negative keyword list is attached to |
74
77
  | `get_recommendations` | Google's auto-generated recommendations with type, estimated impact, and campaign context |
75
78
  | `get_pmax_performance` | Performance Max campaign metrics with network breakdown + asset group ad strength |
76
79
  | `get_asset_performance` | Per-asset details for PMax — field type, serving status, content |
@@ -99,6 +102,7 @@ These tools call both APIs internally and return unified results with auto-gener
99
102
 
100
103
  | Tool | What It Does |
101
104
  |------|-------------|
105
+ | `discover_keywords` | Discover new keyword ideas from seed keywords and/or a URL using Google Ads Keyword Planner. Returns avg monthly searches, competition level, and top-of-page bid range. |
102
106
  | `estimate_budget` | Forecast clicks, impressions, and cost for a set of keywords using Google Ads Keyword Planner. Supports geo/language targeting. Essential for budget planning before launching campaigns. |
103
107
 
104
108
  ### Google Ads Write Tools
@@ -116,7 +120,8 @@ All write operations follow a **draft → preview → confirm** workflow. Nothin
116
120
  | `draft_structured_snippets` | Create campaign structured snippet assets using official header values and 3-10 snippet values. |
117
121
  | `draft_image_assets` | Create campaign image assets from local PNG, JPEG, or GIF files. |
118
122
  | `draft_keywords` | Propose keyword additions with match types. Proactively checks bidding strategy — blocks BROAD match on Manual CPC campaigns. |
119
- | `add_negative_keywords` | Propose negative keywords to reduce wasted spend |
123
+ | `add_negative_keywords` | Propose negative keywords directly on a campaign |
124
+ | `propose_negative_keyword_list` | Draft a shared negative keyword list (SharedSet) and attach it to a campaign — reusable across multiple campaigns |
120
125
  | `pause_entity` | Pause a campaign, ad group, ad, or keyword |
121
126
  | `enable_entity` | Re-enable a paused entity |
122
127
  | `remove_entity` | Permanently remove an entity (irreversible — prefers pause). Supports keywords, negative keywords, ads, ad groups, campaigns. |
@@ -130,7 +135,7 @@ AdLoop ships with orchestration rules that teach the AI *how* to combine these t
130
135
  - **Claude Code**: `.claude/rules/adloop.md` (synced from Cursor rules via `scripts/sync-rules.py`)
131
136
 
132
137
  The rules include:
133
- - **Orchestration patterns** for common workflows (performance review, conversion diagnosis, campaign creation, negative keyword hygiene, tracking validation, budget planning, landing page analysis)
138
+ - **Orchestration patterns** for common workflows (performance review, conversion diagnosis, campaign creation, negative keyword hygiene, keyword discovery, tracking validation, budget planning, landing page analysis)
134
139
  - **GAQL quick reference** with syntax, common queries, and gotchas
135
140
  - **Safety rules** including Broad Match + Manual CPC prevention and pre-write validation
136
141
  - **Ad copy character limit guidance** (30-char headlines are shorter than you think)
@@ -200,6 +205,25 @@ The wizard:
200
205
 
201
206
  - Python 3.11+
202
207
  - A Google Ads account with an MCC (Manager Account)
208
+ - A Google Ads Developer Token (see below)
209
+
210
+ ### Google Ads Developer Token
211
+
212
+ A developer token is **always required** — even when using AdLoop's built-in OAuth credentials. The built-in credentials handle Google sign-in; the developer token is a separate key that grants API access to your Google Ads data.
213
+
214
+ 1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
215
+ 2. In the MCC, go to **Tools & Settings → API Center**
216
+ 3. Your **developer token** is shown there. Copy it — the wizard will ask for it.
217
+
218
+ **Access levels** — your token's access level determines what it can do:
219
+
220
+ | Level | How to Get | What It Allows |
221
+ |-------|-----------|----------------|
222
+ | **Test Account** | Default for new tokens | Can only access test accounts — **not production accounts**. If you see `DEVELOPER_TOKEN_NOT_APPROVED`, this is why. |
223
+ | **Explorer** | Automatic after first API call with a production account | 2,880 operations/day on production accounts. Enough to get started. |
224
+ | **Basic** | [Apply through API Center](https://ads.google.com/aw/apicenter) | 15,000 operations/day. Apply if you need more. |
225
+
226
+ > **Getting `DEVELOPER_TOKEN_NOT_APPROVED`?** Your token is at "Test Account" level. Go to [API Center](https://ads.google.com/aw/apicenter) in your MCC and check your access level. If it shows "Test Account", you need to apply for Basic access or wait for Explorer access to be granted after your first production API call.
203
227
 
204
228
  ### Headless Servers
205
229
 
@@ -229,17 +253,7 @@ When you run `adloop init`, choose "No" when asked about built-in credentials. T
229
253
 
230
254
  > Service accounts are also supported — just place the service account key JSON at the same `credentials_path`. AdLoop detects the file type automatically.
231
255
 
232
- #### Step 3 — Google Ads Developer Token
233
-
234
- 1. **Create an MCC** (free) at [ads.google.com/home/tools/manager-accounts](https://ads.google.com/home/tools/manager-accounts/) if you don't have one. Link your regular Google Ads account to it.
235
- 2. In the MCC, go to **Tools & Settings → API Center**
236
- 3. Your **developer token** is shown there. Copy it.
237
-
238
- Access levels:
239
- - **Explorer** (automatic) — 2,880 operations/day on production accounts. Enough to get started.
240
- - **Basic** (requires application) — 15,000 operations/day. Apply through the same API Center page if you need more.
241
-
242
- #### Step 4 — Connect to Your Editor
256
+ #### Step 3 — Connect to Your Editor
243
257
 
244
258
  **Cursor** — Add to your project's `.cursor/mcp.json`:
245
259
 
@@ -289,6 +303,7 @@ Ask your AI assistant things like:
289
303
  - *"Draft a new responsive search ad for my main campaign."*
290
304
  - *"Which landing pages get paid traffic but don't convert?"*
291
305
  - *"Is my tracking set up correctly? Compare my codebase events against GA4."*
306
+ - *"What keywords should I target for [product]? Find ideas and estimate the budget."*
292
307
  - *"How much budget would I need for these keywords in Germany?"*
293
308
  - *"Create a new search campaign for [product feature] with a €20/day budget."*
294
309
 
@@ -314,7 +329,7 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
314
329
  ```
315
330
  src/adloop/
316
331
  ├── __init__.py # Entry point — routes 'adloop init' to wizard, otherwise starts MCP server
317
- ├── server.py # FastMCP server — 38 tool registrations with safety annotations
332
+ ├── server.py # FastMCP server — 43 tool registrations with safety annotations
318
333
  ├── config.py # Config loader (~/.adloop/config.yaml)
319
334
  ├── auth.py # OAuth 2.0 flow (bundled + custom credentials, headless fallback) + service accounts
320
335
  ├── cli.py # Interactive 'adloop init' setup wizard
@@ -325,12 +340,12 @@ src/adloop/
325
340
  │ ├── reports.py # Account summaries, reports, realtime
326
341
  │ └── tracking.py # Event discovery
327
342
  ├── ads/
328
- │ ├── client.py # Google Ads API client (version-pinned)
343
+ │ ├── client.py # Google Ads API client (version-pinned) + retry/backoff for rate limits
329
344
  │ ├── gaql.py # GAQL query execution with human-readable error parsing
330
- │ ├── read.py # Campaign, ad, keyword, search term, negative keyword, recommendations, audience reads
345
+ │ ├── read.py # Campaign, ad, keyword, search term, negative keyword, shared sets, recommendations, audience reads
331
346
  │ ├── pmax.py # Performance Max tools — campaign/asset group performance, asset labels, top combinations
332
347
  │ ├── write.py # Draft campaign, RSA, keywords; pause, enable, remove, confirm
333
- │ └── forecast.py # Budget estimation via Keyword Planner API
348
+ │ └── forecast.py # Budget estimation + keyword discovery via Keyword Planner API
334
349
  └── safety/
335
350
  ├── guards.py # Budget caps, bid limits, blocked operations, Broad Match safety
336
351
  ├── preview.py # Change plans and previews
@@ -345,7 +360,9 @@ What's been shipped and what's next:
345
360
  - ~~Google Ads read + write tools with safety layer~~ ✓
346
361
  - ~~Cross-reference intelligence (campaign→conversion mapping, landing page analysis, attribution comparison)~~ ✓
347
362
  - ~~Tracking utilities (validate events against GA4, generate gtag code)~~ ✓
348
- - ~~Budget estimation via Keyword Planner~~ ✓
363
+ - ~~Budget estimation + keyword discovery via Keyword Planner~~ ✓
364
+ - ~~Shared negative keyword lists (SharedSet API)~~ ✓
365
+ - ~~Retry/backoff for API rate limits~~ ✓
349
366
  - ~~Setup wizard (`adloop init`)~~ ✓
350
367
  - ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
351
368
  - ~~PyPI package~~ ✓ — `pip install adloop`
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.4.2"
3
+ version = "0.5.0"
4
4
  description = "Stop switching between Google Ads, GA4, and your code editor to figure out why conversions dropped."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -2,7 +2,7 @@
2
2
 
3
3
  import sys
4
4
 
5
- __version__ = "0.4.2"
5
+ __version__ = "0.5.0"
6
6
 
7
7
 
8
8
  def main() -> None:
@@ -11,6 +11,10 @@ def main() -> None:
11
11
  Routes to the setup wizard when called as ``adloop init``,
12
12
  otherwise starts the MCP server.
13
13
  """
14
+ if len(sys.argv) > 1 and sys.argv[1] in ("--version", "-V"):
15
+ print(f"adloop {__version__}")
16
+ return
17
+
14
18
  if len(sys.argv) > 1 and sys.argv[1] == "init":
15
19
  from adloop.cli import run_init_wizard
16
20
 
@@ -0,0 +1,98 @@
1
+ """Google Ads API client wrapper — thin layer over the google-ads library."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import random
6
+ import time
7
+ from typing import TYPE_CHECKING, Any, Callable, TypeVar
8
+
9
+ _T = TypeVar("_T")
10
+
11
+ if TYPE_CHECKING:
12
+ from google.ads.googleads.client import GoogleAdsClient
13
+
14
+ from adloop.config import AdLoopConfig
15
+
16
+ # Pin the API version so library upgrades don't silently break field names,
17
+ # enum values, or mutate operation structures. Bump this deliberately when
18
+ # migrating to a new API version — never let it float to the library default.
19
+ GOOGLE_ADS_API_VERSION = "v23"
20
+
21
+
22
+ def get_ads_client(config: AdLoopConfig) -> GoogleAdsClient:
23
+ """Return an authenticated Google Ads API client pinned to a specific API version."""
24
+ from google.ads.googleads.client import GoogleAdsClient
25
+
26
+ from adloop.auth import get_ads_credentials
27
+
28
+ credentials = get_ads_credentials(config)
29
+
30
+ client_config = {
31
+ "developer_token": config.ads.developer_token,
32
+ "use_proto_plus": True,
33
+ "version": GOOGLE_ADS_API_VERSION,
34
+ }
35
+
36
+ if config.ads.login_customer_id:
37
+ client_config["login_customer_id"] = config.ads.login_customer_id.replace("-", "")
38
+
39
+ client = GoogleAdsClient(credentials=credentials, **client_config)
40
+ return client
41
+
42
+
43
+ def normalize_customer_id(customer_id: str) -> str:
44
+ """Strip dashes from customer ID for API calls (123-456-7890 -> 1234567890)."""
45
+ return customer_id.replace("-", "")
46
+
47
+
48
+ def _is_rate_limit_error(exc: Exception) -> bool:
49
+ """Return True if the exception is a 429 / RESOURCE_EXHAUSTED rate-limit error.
50
+
51
+ Checks the gRPC status code on GoogleAdsException first (reliable),
52
+ then falls back to string matching for non-gRPC exceptions.
53
+ """
54
+ try:
55
+ from google.ads.googleads.errors import GoogleAdsException
56
+
57
+ if isinstance(exc, GoogleAdsException):
58
+ from grpc import StatusCode
59
+
60
+ return exc.error.code() == StatusCode.RESOURCE_EXHAUSTED
61
+ except ImportError:
62
+ pass
63
+
64
+ msg = str(exc).upper()
65
+ return (
66
+ "RESOURCE_EXHAUSTED" in msg
67
+ or "RATE_LIMIT" in msg
68
+ or "QUOTA_EXCEEDED" in msg
69
+ )
70
+
71
+
72
+ def call_with_retry(
73
+ fn: Callable[..., _T],
74
+ /,
75
+ *args: Any,
76
+ max_attempts: int = 4,
77
+ base_delay: float = 1.0,
78
+ **kwargs: Any,
79
+ ) -> _T:
80
+ """Call fn(*args, **kwargs) with exponential backoff on 429 / RESOURCE_EXHAUSTED.
81
+
82
+ Retries up to max_attempts times (default 4 — i.e. 3 retries after the
83
+ initial attempt). Each wait is base_delay * 2^attempt seconds plus up to
84
+ 1 second of random jitter to avoid thundering-herd.
85
+
86
+ All other exceptions are re-raised immediately without retrying.
87
+
88
+ Note: uses time.sleep (blocking). This is safe because FastMCP runs sync
89
+ tool functions in a thread executor — only the worker thread blocks.
90
+ """
91
+ for attempt in range(max_attempts):
92
+ try:
93
+ return fn(*args, **kwargs)
94
+ except Exception as exc:
95
+ if not _is_rate_limit_error(exc) or attempt == max_attempts - 1:
96
+ raise
97
+ delay = base_delay * (2**attempt) + random.uniform(0, 1)
98
+ time.sleep(delay)
@@ -1,4 +1,4 @@
1
- """Budget estimation via Google Ads Keyword Planner forecast metrics."""
1
+ """Budget estimation and keyword discovery via Google Ads Keyword Planner."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -154,3 +154,117 @@ def estimate_budget(
154
154
  "keywords_used": len([kw for kw in keywords if kw.get("text")]),
155
155
  "insights": insights,
156
156
  }
157
+
158
+
159
+ _COMPETITION_LABELS = {0: "UNSPECIFIED", 1: "LOW", 2: "MEDIUM", 3: "HIGH"}
160
+
161
+
162
+ def discover_keywords(
163
+ config: AdLoopConfig,
164
+ *,
165
+ seed_keywords: list[str] = [], # noqa: B006 — mutable default required for MCP JSON schema (array, not anyOf)
166
+ url: str = "",
167
+ geo_target_id: str = "2276",
168
+ language_id: str = "1000",
169
+ page_size: int = 50,
170
+ customer_id: str = "",
171
+ ) -> dict:
172
+ """Discover new keyword ideas using Google Ads Keyword Planner.
173
+
174
+ Mirrors the "Discover new keywords" workflow in the Keyword Planner UI:
175
+ - Start with keywords: provide seed_keywords (one or more terms)
176
+ - Start with a website: provide url (a landing page or full site URL)
177
+ - Both together: keywords + url for more targeted ideas
178
+
179
+ Returns keyword ideas with avg monthly searches, competition level,
180
+ and top-of-page bid range.
181
+
182
+ seed_keywords: list of seed terms, e.g. ["running shoes", "trail running"]
183
+ url: a page or site URL to extract keyword ideas from
184
+ geo_target_id: geo target constant (2276=Germany, 2840=USA, 2826=UK)
185
+ language_id: language constant (1000=English, 1001=German, 1002=French)
186
+ page_size: max number of keyword ideas to return (default 50, max 1000)
187
+ """
188
+ from adloop.ads.client import call_with_retry, get_ads_client, normalize_customer_id
189
+
190
+ seed_keywords = list(seed_keywords)
191
+ if not seed_keywords and not url:
192
+ return {"error": "Provide at least one of: seed_keywords or url"}
193
+
194
+ client = get_ads_client(config)
195
+ cid = normalize_customer_id(customer_id or config.ads.customer_id)
196
+ googleads_service = client.get_service("GoogleAdsService")
197
+ kp_service = client.get_service("KeywordPlanIdeaService")
198
+
199
+ request = client.get_type("GenerateKeywordIdeasRequest")
200
+ request.customer_id = cid
201
+ request.language = googleads_service.language_constant_path(language_id)
202
+ request.geo_target_constants.append(
203
+ googleads_service.geo_target_constant_path(geo_target_id)
204
+ )
205
+ request.keyword_plan_network = (
206
+ client.enums.KeywordPlanNetworkEnum.GOOGLE_SEARCH
207
+ )
208
+ request.page_size = min(max(1, page_size), 1000)
209
+
210
+ if seed_keywords and url:
211
+ request.keyword_and_url_seed.url = url
212
+ request.keyword_and_url_seed.keywords.extend(seed_keywords)
213
+ elif seed_keywords:
214
+ request.keyword_seed.keywords.extend(seed_keywords)
215
+ else:
216
+ request.url_seed.url = url
217
+
218
+ response = call_with_retry(kp_service.generate_keyword_ideas, request=request)
219
+
220
+ ideas = []
221
+ for idea in response:
222
+ metrics = idea.keyword_idea_metrics
223
+ avg_monthly = getattr(metrics, "avg_monthly_searches", None)
224
+ competition_value = getattr(metrics, "competition", 0)
225
+ competition_label = _COMPETITION_LABELS.get(int(competition_value), "UNSPECIFIED")
226
+ competition_index = getattr(metrics, "competition_index", None)
227
+ low_bid_micros = getattr(metrics, "low_top_of_page_bid_micros", None)
228
+ high_bid_micros = getattr(metrics, "high_top_of_page_bid_micros", None)
229
+
230
+ ideas.append({
231
+ "keyword": idea.text,
232
+ "avg_monthly_searches": int(avg_monthly) if avg_monthly else None,
233
+ "competition": competition_label,
234
+ "competition_index": int(competition_index) if competition_index else None,
235
+ "low_top_of_page_bid": round(low_bid_micros / 1_000_000, 2) if low_bid_micros else None,
236
+ "high_top_of_page_bid": round(high_bid_micros / 1_000_000, 2) if high_bid_micros else None,
237
+ })
238
+
239
+ # Sort by avg monthly searches descending (None last)
240
+ ideas.sort(key=lambda x: x["avg_monthly_searches"] or 0, reverse=True)
241
+
242
+ insights = []
243
+ if ideas:
244
+ high_competition = [i for i in ideas if i["competition"] == "HIGH"]
245
+ low_competition = [i for i in ideas if i["competition"] == "LOW"]
246
+ if high_competition:
247
+ insights.append(
248
+ f"{len(high_competition)} high-competition keyword(s) — expect "
249
+ f"higher CPCs and harder positioning."
250
+ )
251
+ if low_competition:
252
+ insights.append(
253
+ f"{len(low_competition)} low-competition keyword(s) — good "
254
+ f"opportunities for early traction at lower cost."
255
+ )
256
+ with_volume = [i for i in ideas if i["avg_monthly_searches"]]
257
+ if with_volume:
258
+ top = with_volume[0]
259
+ insights.append(
260
+ f"Highest-volume idea: '{top['keyword']}' with ~{top['avg_monthly_searches']:,} "
261
+ f"avg monthly searches."
262
+ )
263
+
264
+ return {
265
+ "keyword_ideas": ideas,
266
+ "total_ideas": len(ideas),
267
+ "seed_keywords": seed_keywords,
268
+ "seed_url": url,
269
+ "insights": insights,
270
+ }
@@ -214,9 +214,108 @@ def get_negative_keywords(
214
214
  """
215
215
 
216
216
  rows = execute_query(config, customer_id, query)
217
+ for row in rows:
218
+ cid = row.get("campaign.id")
219
+ crit_id = row.get("campaign_criterion.criterion_id")
220
+ if cid and crit_id:
221
+ row["resource_id"] = f"{cid}~{crit_id}"
217
222
  return {"negative_keywords": rows, "total_negative_keywords": len(rows)}
218
223
 
219
224
 
225
+ def get_negative_keyword_lists(
226
+ config: AdLoopConfig,
227
+ *,
228
+ customer_id: str = "",
229
+ ) -> dict:
230
+ """List all shared negative keyword lists (SharedSets) in the account.
231
+
232
+ Returns each list's ID, name, status, and keyword count. Use this before
233
+ calling propose_negative_keyword_list to check whether a suitable list
234
+ already exists and only needs attaching to a new campaign.
235
+ """
236
+ from adloop.ads.gaql import execute_query
237
+
238
+ query = """
239
+ SELECT shared_set.id, shared_set.name, shared_set.status,
240
+ shared_set.member_count, shared_set.resource_name
241
+ FROM shared_set
242
+ WHERE shared_set.type = 'NEGATIVE_KEYWORDS'
243
+ AND shared_set.status != 'REMOVED'
244
+ ORDER BY shared_set.name
245
+ """
246
+
247
+ rows = execute_query(config, customer_id, query)
248
+ return {"negative_keyword_lists": rows, "total_lists": len(rows)}
249
+
250
+
251
+ def get_negative_keyword_list_keywords(
252
+ config: AdLoopConfig,
253
+ *,
254
+ customer_id: str = "",
255
+ shared_set_id: str = "",
256
+ ) -> dict:
257
+ """List the keywords inside a shared negative keyword list.
258
+
259
+ shared_set_id: the numeric ID from get_negative_keyword_lists
260
+ (shared_set.id field).
261
+ """
262
+ from adloop.ads.gaql import execute_query
263
+
264
+ if not shared_set_id:
265
+ return {"error": "shared_set_id is required"}
266
+ if not shared_set_id.isdigit():
267
+ return {"error": "shared_set_id must be a numeric ID"}
268
+
269
+ query = f"""
270
+ SELECT shared_criterion.keyword.text,
271
+ shared_criterion.keyword.match_type,
272
+ shared_criterion.type,
273
+ shared_set.id, shared_set.name
274
+ FROM shared_criterion
275
+ WHERE shared_set.id = {shared_set_id}
276
+ ORDER BY shared_criterion.keyword.text
277
+ """
278
+
279
+ rows = execute_query(config, customer_id, query)
280
+ return {
281
+ "keywords": rows,
282
+ "total_keywords": len(rows),
283
+ "shared_set_id": shared_set_id,
284
+ }
285
+
286
+
287
+ def get_negative_keyword_list_campaigns(
288
+ config: AdLoopConfig,
289
+ *,
290
+ customer_id: str = "",
291
+ shared_set_id: str = "",
292
+ ) -> dict:
293
+ """List which campaigns a shared negative keyword list is attached to.
294
+
295
+ shared_set_id: the numeric ID from get_negative_keyword_lists
296
+ (shared_set.id field). Omit to return all list-to-campaign attachments.
297
+ """
298
+ from adloop.ads.gaql import execute_query
299
+
300
+ shared_set_filter = ""
301
+ if shared_set_id:
302
+ if not shared_set_id.isdigit():
303
+ return {"error": "shared_set_id must be a numeric ID"}
304
+ shared_set_filter = f"AND shared_set.id = {shared_set_id}"
305
+
306
+ query = f"""
307
+ SELECT campaign.id, campaign.name, campaign.status,
308
+ shared_set.id, shared_set.name
309
+ FROM campaign_shared_set
310
+ WHERE campaign_shared_set.status != 'REMOVED'
311
+ {shared_set_filter}
312
+ ORDER BY shared_set.name, campaign.name
313
+ """
314
+
315
+ rows = execute_query(config, customer_id, query)
316
+ return {"attachments": rows, "total_attachments": len(rows)}
317
+
318
+
220
319
  def get_recommendations(
221
320
  config: AdLoopConfig,
222
321
  *,
@@ -341,6 +341,61 @@ def add_negative_keywords(
341
341
  return plan.to_preview()
342
342
 
343
343
 
344
+ def propose_negative_keyword_list(
345
+ config: AdLoopConfig,
346
+ *,
347
+ customer_id: str = "",
348
+ campaign_id: str = "",
349
+ list_name: str = "",
350
+ keywords: list[str] | None = None,
351
+ match_type: str = "EXACT",
352
+ ) -> dict:
353
+ """Draft a shared negative keyword list and attach it to a campaign — returns PREVIEW.
354
+
355
+ Creates a reusable negative keyword list (SharedSet) with the given keywords
356
+ and links it to the campaign. Unlike add_negative_keywords, the list can later
357
+ be reused across multiple campaigns.
358
+ Call confirm_and_apply with the returned plan_id to execute.
359
+ """
360
+ from adloop.safety.guards import SafetyViolation, check_blocked_operation
361
+ from adloop.safety.preview import ChangePlan, store_plan
362
+
363
+ try:
364
+ check_blocked_operation("create_negative_keyword_list", config.safety)
365
+ except SafetyViolation as e:
366
+ return {"error": str(e)}
367
+
368
+ keywords = keywords or []
369
+ match_type = match_type.upper()
370
+
371
+ errors = []
372
+ if not campaign_id:
373
+ errors.append("campaign_id is required")
374
+ if not list_name:
375
+ errors.append("list_name is required")
376
+ if not keywords:
377
+ errors.append("At least one keyword is required")
378
+ if match_type not in _VALID_MATCH_TYPES:
379
+ errors.append(f"Invalid match_type '{match_type}' — use EXACT, PHRASE, or BROAD")
380
+ if errors:
381
+ return {"error": "Validation failed", "details": errors}
382
+
383
+ plan = ChangePlan(
384
+ operation="create_negative_keyword_list",
385
+ entity_type="negative_keyword_list",
386
+ entity_id=campaign_id,
387
+ customer_id=customer_id,
388
+ changes={
389
+ "campaign_id": campaign_id,
390
+ "list_name": list_name,
391
+ "keywords": keywords,
392
+ "match_type": match_type,
393
+ },
394
+ )
395
+ store_plan(plan)
396
+ return plan.to_preview()
397
+
398
+
344
399
  def update_ad_group(
345
400
  config: AdLoopConfig,
346
401
  *,
@@ -1626,6 +1681,35 @@ def _draft_status_change(
1626
1681
  # ---------------------------------------------------------------------------
1627
1682
 
1628
1683
 
1684
+ _MUTATE_RESPONSE_RESULT_FIELDS = [
1685
+ "campaign_budget_result",
1686
+ "campaign_result",
1687
+ "ad_group_result",
1688
+ "ad_group_ad_result",
1689
+ "ad_group_criterion_result",
1690
+ "campaign_criterion_result",
1691
+ "asset_result",
1692
+ "campaign_asset_result",
1693
+ "customer_asset_result",
1694
+ ]
1695
+
1696
+
1697
+ def _extract_resource_name(resp: object) -> str:
1698
+ """Extract the resource_name from a MutateOperationResponse.
1699
+
1700
+ Uses direct field access instead of WhichOneof, which doesn't work on
1701
+ proto-plus wrapped messages returned by the google-ads library.
1702
+ """
1703
+ for field in _MUTATE_RESPONSE_RESULT_FIELDS:
1704
+ try:
1705
+ result = getattr(resp, field, None)
1706
+ if result and result.resource_name:
1707
+ return result.resource_name
1708
+ except Exception:
1709
+ continue
1710
+ return ""
1711
+
1712
+
1629
1713
  def _execute_plan(config: AdLoopConfig, plan: object) -> dict:
1630
1714
  """Dispatch to the right Google Ads mutate call based on plan.operation."""
1631
1715
  from adloop.ads.client import get_ads_client, normalize_customer_id
@@ -1641,6 +1725,7 @@ def _execute_plan(config: AdLoopConfig, plan: object) -> dict:
1641
1725
  "create_responsive_search_ad": _apply_create_rsa,
1642
1726
  "add_keywords": _apply_add_keywords,
1643
1727
  "add_negative_keywords": _apply_add_negative_keywords,
1728
+ "create_negative_keyword_list": _apply_create_negative_keyword_list,
1644
1729
  "pause_entity": _apply_status_change,
1645
1730
  "enable_entity": _apply_status_change,
1646
1731
  "remove_entity": _apply_remove,
@@ -1818,22 +1903,20 @@ def _apply_create_campaign(client: object, cid: str, changes: dict) -> dict:
1818
1903
  num_geo = len(changes.get("geo_target_ids") or [])
1819
1904
  num_lang = len(changes.get("language_ids") or [])
1820
1905
  for i, resp in enumerate(response.mutate_operation_responses):
1821
- resp_type = resp.WhichOneof("response")
1822
- if resp_type:
1823
- inner = getattr(resp, resp_type)
1824
- resource = getattr(inner, "resource_name", str(inner))
1906
+ rn = _extract_resource_name(resp)
1907
+ if rn:
1825
1908
  if i == 0:
1826
- results["campaign_budget"] = resource
1909
+ results["campaign_budget"] = rn
1827
1910
  elif i == 1:
1828
- results["campaign"] = resource
1911
+ results["campaign"] = rn
1829
1912
  elif i == 2:
1830
- results["ad_group"] = resource
1913
+ results["ad_group"] = rn
1831
1914
  elif i < 3 + num_keywords:
1832
- results.setdefault("keywords", []).append(resource)
1915
+ results.setdefault("keywords", []).append(rn)
1833
1916
  elif i < 3 + num_keywords + num_geo:
1834
- results.setdefault("geo_targets", []).append(resource)
1917
+ results.setdefault("geo_targets", []).append(rn)
1835
1918
  else:
1836
- results.setdefault("language_targets", []).append(resource)
1919
+ results.setdefault("language_targets", []).append(rn)
1837
1920
 
1838
1921
  return results
1839
1922
 
@@ -1874,14 +1957,12 @@ def _apply_create_ad_group(client: object, cid: str, changes: dict) -> dict:
1874
1957
 
1875
1958
  results: dict = {}
1876
1959
  for i, resp in enumerate(response.mutate_operation_responses):
1877
- resp_type = resp.WhichOneof("response")
1878
- if resp_type:
1879
- inner = getattr(resp, resp_type)
1880
- resource = getattr(inner, "resource_name", str(inner))
1960
+ rn = _extract_resource_name(resp)
1961
+ if rn:
1881
1962
  if i == 0:
1882
- results["ad_group"] = resource
1963
+ results["ad_group"] = rn
1883
1964
  else:
1884
- results.setdefault("keywords", []).append(resource)
1965
+ results.setdefault("keywords", []).append(rn)
1885
1966
 
1886
1967
  return results
1887
1968
 
@@ -2039,11 +2120,7 @@ def _apply_update_campaign(client: object, cid: str, changes: dict) -> dict:
2039
2120
 
2040
2121
  results = {"updated": []}
2041
2122
  for resp in response.mutate_operation_responses:
2042
- rn = (
2043
- resp.campaign_result.resource_name
2044
- or resp.campaign_budget_result.resource_name
2045
- or resp.campaign_criterion_result.resource_name
2046
- )
2123
+ rn = _extract_resource_name(resp)
2047
2124
  if rn:
2048
2125
  results["updated"].append(rn)
2049
2126
  return results
@@ -2441,3 +2518,77 @@ def _apply_create_sitelinks(client: object, cid: str, changes: dict) -> dict:
2441
2518
  client.enums.AssetFieldTypeEnum.SITELINK,
2442
2519
  populate,
2443
2520
  )
2521
+
2522
+
2523
+ def _apply_create_negative_keyword_list(
2524
+ client: object, cid: str, changes: dict
2525
+ ) -> dict:
2526
+ """Create a shared negative keyword list and attach it to a campaign.
2527
+
2528
+ Executes three sequential API calls. If step 2 or 3 fails, the result
2529
+ includes partial_failure info with the SharedSet resource name so the
2530
+ caller can clean up or retry the remaining steps.
2531
+ """
2532
+ shared_set_resource = None
2533
+
2534
+ # 1. Create the SharedSet
2535
+ shared_set_service = client.get_service("SharedSetService")
2536
+ ss_op = client.get_type("SharedSetOperation")
2537
+ shared_set = ss_op.create
2538
+ shared_set.name = changes["list_name"]
2539
+ shared_set.type_ = client.enums.SharedSetTypeEnum.NEGATIVE_KEYWORDS
2540
+ ss_response = shared_set_service.mutate_shared_sets(
2541
+ customer_id=cid, operations=[ss_op]
2542
+ )
2543
+ shared_set_resource = ss_response.results[0].resource_name
2544
+
2545
+ # 2. Add keywords to the list
2546
+ try:
2547
+ sc_service = client.get_service("SharedCriterionService")
2548
+ sc_ops = []
2549
+ for kw_text in changes["keywords"]:
2550
+ sc_op = client.get_type("SharedCriterionOperation")
2551
+ criterion = sc_op.create
2552
+ criterion.shared_set = shared_set_resource
2553
+ criterion.keyword.text = kw_text
2554
+ criterion.keyword.match_type = getattr(
2555
+ client.enums.KeywordMatchTypeEnum, changes["match_type"]
2556
+ )
2557
+ sc_ops.append(sc_op)
2558
+ sc_service.mutate_shared_criteria(customer_id=cid, operations=sc_ops)
2559
+ except Exception as exc:
2560
+ return {
2561
+ "partial_failure": True,
2562
+ "shared_set_resource": shared_set_resource,
2563
+ "completed_steps": ["create_shared_set"],
2564
+ "failed_step": "add_keywords",
2565
+ "error": _extract_error_message(exc),
2566
+ }
2567
+
2568
+ # 3. Attach the list to the campaign
2569
+ try:
2570
+ css_service = client.get_service("CampaignSharedSetService")
2571
+ css_op = client.get_type("CampaignSharedSetOperation")
2572
+ campaign_shared_set = css_op.create
2573
+ campaign_shared_set.campaign = client.get_service(
2574
+ "CampaignService"
2575
+ ).campaign_path(cid, changes["campaign_id"])
2576
+ campaign_shared_set.shared_set = shared_set_resource
2577
+ css_response = css_service.mutate_campaign_shared_sets(
2578
+ customer_id=cid, operations=[css_op]
2579
+ )
2580
+ except Exception as exc:
2581
+ return {
2582
+ "partial_failure": True,
2583
+ "shared_set_resource": shared_set_resource,
2584
+ "keyword_count": len(changes["keywords"]),
2585
+ "completed_steps": ["create_shared_set", "add_keywords"],
2586
+ "failed_step": "attach_to_campaign",
2587
+ "error": _extract_error_message(exc),
2588
+ }
2589
+
2590
+ return {
2591
+ "shared_set_resource": shared_set_resource,
2592
+ "campaign_shared_set_resource": css_response.results[0].resource_name,
2593
+ "keyword_count": len(changes["keywords"]),
2594
+ }
@@ -294,16 +294,17 @@ def run_init_wizard() -> None:
294
294
  _print()
295
295
 
296
296
  existing_config = None
297
+ _original_config_backup: str | None = None
297
298
  if _CONFIG_PATH.exists():
298
299
  _print(f" Found existing config at {_CONFIG_PATH}")
299
300
  if not _prompt_bool("Overwrite existing configuration?", default=False):
300
301
  _print(" Keeping existing config. Exiting.")
301
302
  return
303
+ _original_config_backup = _CONFIG_PATH.read_text()
302
304
  try:
303
305
  import yaml
304
306
 
305
- with open(_CONFIG_PATH) as f:
306
- existing_config = yaml.safe_load(f) or {}
307
+ existing_config = yaml.safe_load(_original_config_backup) or {}
307
308
  except Exception:
308
309
  existing_config = {}
309
310
 
@@ -364,7 +365,8 @@ def run_init_wizard() -> None:
364
365
  step_num += 1
365
366
  _step_header(step_num, "Authorization & Account Discovery")
366
367
 
367
- # Write a minimal temporary config for OAuth + discovery
368
+ # Write a temporary config for OAuth + discovery. If the wizard is
369
+ # interrupted after this point, _cleanup_on_cancel restores the original.
368
370
  _ADLOOP_DIR.mkdir(parents=True, exist_ok=True)
369
371
  temp_config_yaml = _generate_config_yaml(
370
372
  project_id=project_id,
@@ -378,6 +380,40 @@ def run_init_wizard() -> None:
378
380
  )
379
381
  _CONFIG_PATH.write_text(temp_config_yaml)
380
382
 
383
+ # Everything below uses the temp config for OAuth and discovery.
384
+ # If the wizard is interrupted, restore the original config (or remove
385
+ # the temp) so we never leave a half-baked config on disk.
386
+ try:
387
+ _run_wizard_post_config(
388
+ use_bundled=use_bundled,
389
+ credentials_path=credentials_path,
390
+ project_id=project_id,
391
+ developer_token=developer_token,
392
+ login_customer_id=login_customer_id,
393
+ step_num=step_num,
394
+ _existing=_existing,
395
+ )
396
+ except KeyboardInterrupt:
397
+ if _original_config_backup is not None:
398
+ _CONFIG_PATH.write_text(_original_config_backup)
399
+ elif _CONFIG_PATH.exists():
400
+ _CONFIG_PATH.unlink()
401
+ raise
402
+
403
+
404
+ def _run_wizard_post_config(
405
+ *,
406
+ use_bundled: bool,
407
+ credentials_path: str,
408
+ project_id: str,
409
+ developer_token: str,
410
+ login_customer_id: str,
411
+ step_num: int,
412
+ _existing: object,
413
+ ) -> None:
414
+ """Run the wizard steps after the temp config has been written."""
415
+ from adloop.config import load_config
416
+
381
417
  # Optional: copy custom credentials to ~/.adloop/
382
418
  if not use_bundled and credentials_path:
383
419
  creds_expanded = Path(credentials_path).expanduser()
@@ -395,13 +431,14 @@ def run_init_wizard() -> None:
395
431
  _print()
396
432
  oauth_ok = False
397
433
  try:
398
- from adloop.config import load_config
399
434
  from adloop.auth import _oauth_flow
400
435
 
401
436
  cfg = load_config(str(_CONFIG_PATH))
402
437
  _oauth_flow(cfg)
403
438
  _print(" ✓ OAuth token saved")
404
439
  oauth_ok = True
440
+ except KeyboardInterrupt:
441
+ raise
405
442
  except Exception as exc:
406
443
  _print(f" ✗ OAuth failed: {exc}")
407
444
  _print(" You can retry later — any AdLoop tool call will trigger auth.")
@@ -427,10 +464,12 @@ def run_init_wizard() -> None:
427
464
  else:
428
465
  _print(" No GA4 properties found. Enter manually:")
429
466
  property_id = _prompt_property_id()
467
+ except KeyboardInterrupt:
468
+ raise
430
469
  except Exception as exc:
431
470
  _print(f" Could not auto-discover GA4 properties: {exc}")
432
471
  property_id = _prompt_property_id(
433
- default=_existing("ga4", "property_id"),
472
+ default=_existing("ga4", "property_id"), # type: ignore[operator]
434
473
  )
435
474
  else:
436
475
  step_num += 1
@@ -439,7 +478,7 @@ def run_init_wizard() -> None:
439
478
  _print(" → https://analytics.google.com → Admin → Property Settings")
440
479
  _print()
441
480
  property_id = _prompt_property_id(
442
- default=_existing("ga4", "property_id"),
481
+ default=_existing("ga4", "property_id"), # type: ignore[operator]
443
482
  )
444
483
 
445
484
  # Auto-discover Ads accounts
@@ -463,18 +502,20 @@ def run_init_wizard() -> None:
463
502
  else:
464
503
  _print(" No Ads accounts found. Enter manually:")
465
504
  customer_id = _prompt_customer_id("Ads Customer ID (XXX-XXX-XXXX)")
505
+ except KeyboardInterrupt:
506
+ raise
466
507
  except Exception as exc:
467
508
  _print(f" Could not auto-discover Ads accounts: {exc}")
468
509
  customer_id = _prompt_customer_id(
469
510
  "Ads Customer ID (XXX-XXX-XXXX)",
470
- default=_existing("ads", "customer_id"),
511
+ default=_existing("ads", "customer_id"), # type: ignore[operator]
471
512
  )
472
513
  else:
473
514
  step_num += 1
474
515
  _step_header(step_num, "Google Ads Account")
475
516
  customer_id = _prompt_customer_id(
476
517
  "Ads Customer ID (XXX-XXX-XXXX)",
477
- default=_existing("ads", "customer_id"),
518
+ default=_existing("ads", "customer_id"), # type: ignore[operator]
478
519
  )
479
520
 
480
521
  # Safety defaults
@@ -482,7 +523,7 @@ def run_init_wizard() -> None:
482
523
  _step_header(step_num, "Safety Defaults")
483
524
  budget_str = _prompt(
484
525
  "Max daily budget cap (safety limit)",
485
- default=str(_existing("safety", "max_daily_budget", "50")),
526
+ default=str(_existing("safety", "max_daily_budget", "50")), # type: ignore[operator]
486
527
  required=False,
487
528
  )
488
529
  try:
@@ -393,6 +393,61 @@ def get_negative_keywords(
393
393
  )
394
394
 
395
395
 
396
+ @mcp.tool(annotations=_READONLY)
397
+ @_safe
398
+ def get_negative_keyword_lists(
399
+ customer_id: str = "",
400
+ ) -> dict:
401
+ """List all shared negative keyword lists (SharedSets) in the account.
402
+
403
+ Returns each list's ID, name, status, and keyword count. Always call
404
+ this before propose_negative_keyword_list to avoid creating duplicates —
405
+ a suitable list may already exist and just need attaching to a campaign.
406
+ """
407
+ from adloop.ads.read import get_negative_keyword_lists as _impl
408
+
409
+ return _impl(_config, customer_id=customer_id or _config.ads.customer_id)
410
+
411
+
412
+ @mcp.tool(annotations=_READONLY)
413
+ @_safe
414
+ def get_negative_keyword_list_keywords(
415
+ shared_set_id: str,
416
+ customer_id: str = "",
417
+ ) -> dict:
418
+ """List the keywords inside a shared negative keyword list.
419
+
420
+ shared_set_id: numeric ID from get_negative_keyword_lists (shared_set.id).
421
+ """
422
+ from adloop.ads.read import get_negative_keyword_list_keywords as _impl
423
+
424
+ return _impl(
425
+ _config,
426
+ customer_id=customer_id or _config.ads.customer_id,
427
+ shared_set_id=shared_set_id,
428
+ )
429
+
430
+
431
+ @mcp.tool(annotations=_READONLY)
432
+ @_safe
433
+ def get_negative_keyword_list_campaigns(
434
+ shared_set_id: str = "",
435
+ customer_id: str = "",
436
+ ) -> dict:
437
+ """List which campaigns a shared negative keyword list is attached to.
438
+
439
+ shared_set_id: numeric ID from get_negative_keyword_lists. Omit to see
440
+ all list-to-campaign attachments across the account.
441
+ """
442
+ from adloop.ads.read import get_negative_keyword_list_campaigns as _impl
443
+
444
+ return _impl(
445
+ _config,
446
+ customer_id=customer_id or _config.ads.customer_id,
447
+ shared_set_id=shared_set_id,
448
+ )
449
+
450
+
396
451
  # ---------------------------------------------------------------------------
397
452
  # Google Ads — Recommendations, Performance Max & Audience Tools
398
453
  # ---------------------------------------------------------------------------
@@ -896,6 +951,34 @@ def add_negative_keywords(
896
951
  )
897
952
 
898
953
 
954
+ @mcp.tool(annotations=_WRITE)
955
+ @_safe
956
+ def propose_negative_keyword_list(
957
+ campaign_id: str,
958
+ list_name: str,
959
+ keywords: list[str],
960
+ customer_id: str = "",
961
+ match_type: str = "EXACT",
962
+ ) -> dict:
963
+ """Draft a shared negative keyword list and attach it to a campaign — returns a PREVIEW.
964
+
965
+ Creates a reusable negative keyword list that can later be applied to multiple
966
+ campaigns, unlike add_negative_keywords which adds directly to one campaign.
967
+ match_type: "EXACT", "PHRASE", or "BROAD"
968
+ Call confirm_and_apply with the returned plan_id to execute.
969
+ """
970
+ from adloop.ads.write import propose_negative_keyword_list as _impl
971
+
972
+ return _impl(
973
+ _config,
974
+ customer_id=customer_id or _config.ads.customer_id,
975
+ campaign_id=campaign_id,
976
+ list_name=list_name,
977
+ keywords=keywords,
978
+ match_type=match_type,
979
+ )
980
+
981
+
899
982
  @mcp.tool(annotations=_WRITE)
900
983
  @_safe
901
984
  def update_ad_group(
@@ -1039,7 +1122,8 @@ def remove_entity(
1039
1122
  "campaign_asset", "asset", or "customer_asset"
1040
1123
  entity_id: The resource ID.
1041
1124
  For keywords: "adGroupId~criterionId"
1042
- For negative_keywords: the campaign criterion ID
1125
+ For negative_keywords: "campaignId~criterionId"
1126
+ (use the resource_id field from get_negative_keywords)
1043
1127
  For campaign_asset: "campaignId~assetId~fieldType"
1044
1128
  For asset: simple asset ID
1045
1129
  For customer_asset: "assetId~fieldType"
@@ -1211,3 +1295,40 @@ def estimate_budget(
1211
1295
  forecast_days=forecast_days,
1212
1296
  customer_id=customer_id or _config.ads.customer_id,
1213
1297
  )
1298
+
1299
+
1300
+ @mcp.tool(annotations=_READONLY)
1301
+ @_safe
1302
+ def discover_keywords(
1303
+ seed_keywords: list[str] = [], # noqa: B006 — mutable default required for MCP JSON schema
1304
+ url: str = "",
1305
+ geo_target_id: str = "2276",
1306
+ language_id: str = "1000",
1307
+ page_size: int = 50,
1308
+ customer_id: str = "",
1309
+ ) -> dict:
1310
+ """Discover new keyword ideas using Google Ads Keyword Planner.
1311
+
1312
+ Mirrors the "Discover new keywords" UI in Keyword Planner:
1313
+ - Start with keywords: pass seed_keywords (e.g. ["running shoes"])
1314
+ - Start with a website: pass url (e.g. "https://example.com/products")
1315
+ - Both together: keywords + url for more targeted ideas
1316
+
1317
+ Returns keyword ideas sorted by avg monthly search volume, with
1318
+ competition level (LOW/MEDIUM/HIGH) and top-of-page bid range.
1319
+
1320
+ geo_target_id: geo target constant (2276=Germany, 2840=USA, 2826=UK)
1321
+ language_id: language constant (1000=English, 1001=German, 1002=French)
1322
+ page_size: max keyword ideas to return (default 50, max 1000)
1323
+ """
1324
+ from adloop.ads.forecast import discover_keywords as _impl
1325
+
1326
+ return _impl(
1327
+ _config,
1328
+ seed_keywords=seed_keywords,
1329
+ url=url,
1330
+ geo_target_id=geo_target_id,
1331
+ language_id=language_id,
1332
+ page_size=page_size,
1333
+ customer_id=customer_id or _config.ads.customer_id,
1334
+ )
@@ -1,41 +0,0 @@
1
- """Google Ads API client wrapper — thin layer over the google-ads library."""
2
-
3
- from __future__ import annotations
4
-
5
- from typing import TYPE_CHECKING
6
-
7
- if TYPE_CHECKING:
8
- from google.ads.googleads.client import GoogleAdsClient
9
-
10
- from adloop.config import AdLoopConfig
11
-
12
- # Pin the API version so library upgrades don't silently break field names,
13
- # enum values, or mutate operation structures. Bump this deliberately when
14
- # migrating to a new API version — never let it float to the library default.
15
- GOOGLE_ADS_API_VERSION = "v23"
16
-
17
-
18
- def get_ads_client(config: AdLoopConfig) -> GoogleAdsClient:
19
- """Return an authenticated Google Ads API client pinned to a specific API version."""
20
- from google.ads.googleads.client import GoogleAdsClient
21
-
22
- from adloop.auth import get_ads_credentials
23
-
24
- credentials = get_ads_credentials(config)
25
-
26
- client_config = {
27
- "developer_token": config.ads.developer_token,
28
- "use_proto_plus": True,
29
- "version": GOOGLE_ADS_API_VERSION,
30
- }
31
-
32
- if config.ads.login_customer_id:
33
- client_config["login_customer_id"] = config.ads.login_customer_id.replace("-", "")
34
-
35
- client = GoogleAdsClient(credentials=credentials, **client_config)
36
- return client
37
-
38
-
39
- def normalize_customer_id(customer_id: str) -> str:
40
- """Strip dashes from customer ID for API calls (123-456-7890 -> 1234567890)."""
41
- return customer_id.replace("-", "")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes