adloop 0.3.0__tar.gz → 0.4.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 (28) hide show
  1. {adloop-0.3.0 → adloop-0.4.0}/PKG-INFO +26 -43
  2. {adloop-0.3.0 → adloop-0.4.0}/README.md +25 -42
  3. {adloop-0.3.0 → adloop-0.4.0}/pyproject.toml +1 -1
  4. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/__init__.py +1 -1
  5. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ads/write.py +41 -11
  6. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/auth.py +73 -9
  7. adloop-0.4.0/src/adloop/bundled_credentials.json +12 -0
  8. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/cli.py +244 -74
  9. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/config.py +2 -2
  10. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/__main__.py +0 -0
  11. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ads/__init__.py +0 -0
  12. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ads/client.py +0 -0
  13. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ads/currency.py +0 -0
  14. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ads/forecast.py +0 -0
  15. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ads/gaql.py +0 -0
  16. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ads/pmax.py +0 -0
  17. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ads/read.py +0 -0
  18. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/crossref.py +0 -0
  19. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ga4/__init__.py +0 -0
  20. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ga4/client.py +0 -0
  21. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ga4/reports.py +0 -0
  22. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ga4/tracking.py +0 -0
  23. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/safety/__init__.py +0 -0
  24. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/safety/audit.py +0 -0
  25. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/safety/guards.py +0 -0
  26. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/safety/preview.py +0 -0
  27. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/server.py +0 -0
  28. {adloop-0.3.0 → adloop-0.4.0}/src/adloop/tracking.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: adloop
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -209,28 +209,32 @@ uv sync
209
209
  uv run adloop init
210
210
  ```
211
211
 
212
- The `adloop init` wizard walks you through everything:
212
+ The `adloop init` wizard walks you through everything. AdLoop ships with built-in Google OAuth credentials, so you don't need to create a Google Cloud project.
213
213
 
214
- 1. **Google Cloud checklist** with clickable links to each setup page
215
- 2. **Credentials** — prompts for your OAuth JSON path, validates the file exists
216
- 3. **GA4 Property ID** — validates numeric format
217
- 4. **Developer Token** — from your Google Ads MCC API Center
218
- 5. **Customer IDs** — auto-formats `1234567890` `123-456-7890`
219
- 6. **Safety defaults** — budget cap and dry-run preference
220
- 7. **OAuth authorization** — optionally opens your browser to complete auth immediately
221
- 8. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code
214
+ The wizard:
215
+
216
+ 1. **Developer token** — from your Google Ads MCC ([API Center](https://ads.google.com/aw/apicenter))
217
+ 2. **MCC Account ID** — your Manager Account ID (top bar in the MCC UI)
218
+ 3. **OAuth sign-in** — opens a browser to sign in with Google (or prints a URL for headless servers)
219
+ 4. **Auto-discovers your accounts** — finds your GA4 properties and Ads accounts automatically
220
+ 5. **Safety defaults** — budget cap and dry-run preference
221
+ 6. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code
222
222
 
223
223
  ### Requirements
224
224
 
225
225
  - Python 3.11+
226
- - [uv](https://docs.astral.sh/uv/) for package management
227
- - A Google Cloud project (free tier works)
228
226
  - A Google Ads account with an MCC (Manager Account)
229
227
 
230
- ### Manual Setup (If Not Using the Wizard)
228
+ ### Headless Servers
229
+
230
+ Running on a server without a browser (VMs, Docker, SSH)? The wizard automatically detects this and falls back to a manual flow: it prints an authorization URL you can open on any device, then you paste the redirect URL back into the terminal.
231
+
232
+ ### Advanced Setup (Custom Google Cloud Project)
231
233
 
232
234
  <details>
233
- <summary>Click to expand manual setup steps</summary>
235
+ <summary>Click to expand only needed if you want to use your own GCP project instead of AdLoop's built-in credentials</summary>
236
+
237
+ When you run `adloop init`, choose "No" when asked about built-in credentials. The wizard will guide you through:
234
238
 
235
239
  #### Step 1 — Google Cloud Project
236
240
 
@@ -247,8 +251,6 @@ The `adloop init` wizard walks you through everything:
247
251
  3. Select **Desktop app** as the application type, give it any name
248
252
  4. Download the JSON file and save it as `~/.adloop/credentials.json`
249
253
 
250
- On first run, AdLoop opens a browser window where you sign in with your Google account and grant access. The resulting token is saved to `~/.adloop/token.json` and refreshed automatically.
251
-
252
254
  > Service accounts are also supported — just place the service account key JSON at the same `credentials_path`. AdLoop detects the file type automatically.
253
255
 
254
256
  #### Step 3 — Google Ads Developer Token
@@ -261,28 +263,7 @@ Access levels:
261
263
  - **Explorer** (automatic) — 2,880 operations/day on production accounts. Enough to get started.
262
264
  - **Basic** (requires application) — 15,000 operations/day. Apply through the same API Center page if you need more.
263
265
 
264
- #### Step 4 — Find Your IDs
265
-
266
- | ID | Where to Find It |
267
- |----|-------------------|
268
- | **GA4 Property ID** | GA4 → Admin → Property Settings (numeric, e.g. `123456789`) |
269
- | **Google Ads Customer ID** | Google Ads UI → top bar (e.g. `123-456-7890`) |
270
- | **MCC Account ID** | MCC UI → top bar (e.g. `123-456-7890`) |
271
-
272
- #### Step 5 — Install and Configure
273
-
274
- ```bash
275
- git clone https://github.com/kLOsk/adloop.git
276
- cd adloop
277
- uv sync
278
-
279
- mkdir -p ~/.adloop
280
- cp config.yaml.example ~/.adloop/config.yaml
281
- ```
282
-
283
- Edit `~/.adloop/config.yaml` and fill in the values from the previous steps. See [`config.yaml.example`](config.yaml.example) for a fully documented template.
284
-
285
- #### Step 6 — Connect to Your Editor
266
+ #### Step 4 — Connect to Your Editor
286
267
 
287
268
  **Cursor** — Add to your project's `.cursor/mcp.json`:
288
269
 
@@ -341,12 +322,12 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
341
322
 
342
323
  | Section | Key | Default | Description |
343
324
  |---------|-----|---------|-------------|
344
- | `google` | `project_id` | | Your Google Cloud project ID |
345
- | `google` | `credentials_path` | `~/.adloop/credentials.json` | Path to OAuth client JSON or service account key |
325
+ | `google` | `project_id` | *(empty)* | Google Cloud project ID (only needed with custom credentials) |
326
+ | `google` | `credentials_path` | *(empty — uses built-in)* | Path to OAuth client JSON or service account key. Leave empty to use AdLoop's built-in credentials. |
346
327
  | `google` | `token_path` | `~/.adloop/token.json` | Where to store the OAuth token (auto-created) |
347
- | `ga4` | `property_id` | — | Your GA4 property ID (found in GA4 Admin → Property Settings) |
328
+ | `ga4` | `property_id` | — | Your GA4 property ID (auto-discovered by `adloop init`) |
348
329
  | `ads` | `developer_token` | — | Your Google Ads API developer token |
349
- | `ads` | `customer_id` | — | Default Google Ads customer ID |
330
+ | `ads` | `customer_id` | — | Default Google Ads customer ID (auto-discovered by `adloop init`) |
350
331
  | `ads` | `login_customer_id` | — | Your MCC account ID |
351
332
  | `safety` | `max_daily_budget` | `50.00` | Maximum allowed daily budget per campaign |
352
333
  | `safety` | `require_dry_run` | `true` | Force all writes to dry-run mode |
@@ -359,7 +340,7 @@ src/adloop/
359
340
  ├── __init__.py # Entry point — routes 'adloop init' to wizard, otherwise starts MCP server
360
341
  ├── server.py # FastMCP server — 38 tool registrations with safety annotations
361
342
  ├── config.py # Config loader (~/.adloop/config.yaml)
362
- ├── auth.py # OAuth 2.0 Desktop flow + service account support + token refresh handling
343
+ ├── auth.py # OAuth 2.0 flow (bundled + custom credentials, headless fallback) + service accounts
363
344
  ├── cli.py # Interactive 'adloop init' setup wizard
364
345
  ├── crossref.py # Cross-reference tools (GA4 + Ads combined analysis)
365
346
  ├── tracking.py # Tracking validation + code generation tools
@@ -392,6 +373,8 @@ What's been shipped and what's next:
392
373
  - ~~Setup wizard (`adloop init`)~~ ✓
393
374
  - ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
394
375
  - ~~PyPI package~~ ✓ — `pip install adloop`
376
+ - ~~Bundled OAuth credentials~~ ✓ — no Google Cloud project required, auto-discovery of GA4/Ads accounts
377
+ - ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
395
378
  - **Community launch** — HN, Indie Hackers, r/cursor, Twitter
396
379
  - **Video walkthrough**
397
380
 
@@ -185,28 +185,32 @@ uv sync
185
185
  uv run adloop init
186
186
  ```
187
187
 
188
- The `adloop init` wizard walks you through everything:
188
+ The `adloop init` wizard walks you through everything. AdLoop ships with built-in Google OAuth credentials, so you don't need to create a Google Cloud project.
189
189
 
190
- 1. **Google Cloud checklist** with clickable links to each setup page
191
- 2. **Credentials** — prompts for your OAuth JSON path, validates the file exists
192
- 3. **GA4 Property ID** — validates numeric format
193
- 4. **Developer Token** — from your Google Ads MCC API Center
194
- 5. **Customer IDs** — auto-formats `1234567890` `123-456-7890`
195
- 6. **Safety defaults** — budget cap and dry-run preference
196
- 7. **OAuth authorization** — optionally opens your browser to complete auth immediately
197
- 8. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code
190
+ The wizard:
191
+
192
+ 1. **Developer token** — from your Google Ads MCC ([API Center](https://ads.google.com/aw/apicenter))
193
+ 2. **MCC Account ID** — your Manager Account ID (top bar in the MCC UI)
194
+ 3. **OAuth sign-in** — opens a browser to sign in with Google (or prints a URL for headless servers)
195
+ 4. **Auto-discovers your accounts** — finds your GA4 properties and Ads accounts automatically
196
+ 5. **Safety defaults** — budget cap and dry-run preference
197
+ 6. **Editor config snippets** — prints MCP configuration for both Cursor and Claude Code
198
198
 
199
199
  ### Requirements
200
200
 
201
201
  - Python 3.11+
202
- - [uv](https://docs.astral.sh/uv/) for package management
203
- - A Google Cloud project (free tier works)
204
202
  - A Google Ads account with an MCC (Manager Account)
205
203
 
206
- ### Manual Setup (If Not Using the Wizard)
204
+ ### Headless Servers
205
+
206
+ Running on a server without a browser (VMs, Docker, SSH)? The wizard automatically detects this and falls back to a manual flow: it prints an authorization URL you can open on any device, then you paste the redirect URL back into the terminal.
207
+
208
+ ### Advanced Setup (Custom Google Cloud Project)
207
209
 
208
210
  <details>
209
- <summary>Click to expand manual setup steps</summary>
211
+ <summary>Click to expand only needed if you want to use your own GCP project instead of AdLoop's built-in credentials</summary>
212
+
213
+ When you run `adloop init`, choose "No" when asked about built-in credentials. The wizard will guide you through:
210
214
 
211
215
  #### Step 1 — Google Cloud Project
212
216
 
@@ -223,8 +227,6 @@ The `adloop init` wizard walks you through everything:
223
227
  3. Select **Desktop app** as the application type, give it any name
224
228
  4. Download the JSON file and save it as `~/.adloop/credentials.json`
225
229
 
226
- On first run, AdLoop opens a browser window where you sign in with your Google account and grant access. The resulting token is saved to `~/.adloop/token.json` and refreshed automatically.
227
-
228
230
  > Service accounts are also supported — just place the service account key JSON at the same `credentials_path`. AdLoop detects the file type automatically.
229
231
 
230
232
  #### Step 3 — Google Ads Developer Token
@@ -237,28 +239,7 @@ Access levels:
237
239
  - **Explorer** (automatic) — 2,880 operations/day on production accounts. Enough to get started.
238
240
  - **Basic** (requires application) — 15,000 operations/day. Apply through the same API Center page if you need more.
239
241
 
240
- #### Step 4 — Find Your IDs
241
-
242
- | ID | Where to Find It |
243
- |----|-------------------|
244
- | **GA4 Property ID** | GA4 → Admin → Property Settings (numeric, e.g. `123456789`) |
245
- | **Google Ads Customer ID** | Google Ads UI → top bar (e.g. `123-456-7890`) |
246
- | **MCC Account ID** | MCC UI → top bar (e.g. `123-456-7890`) |
247
-
248
- #### Step 5 — Install and Configure
249
-
250
- ```bash
251
- git clone https://github.com/kLOsk/adloop.git
252
- cd adloop
253
- uv sync
254
-
255
- mkdir -p ~/.adloop
256
- cp config.yaml.example ~/.adloop/config.yaml
257
- ```
258
-
259
- Edit `~/.adloop/config.yaml` and fill in the values from the previous steps. See [`config.yaml.example`](config.yaml.example) for a fully documented template.
260
-
261
- #### Step 6 — Connect to Your Editor
242
+ #### Step 4 — Connect to Your Editor
262
243
 
263
244
  **Cursor** — Add to your project's `.cursor/mcp.json`:
264
245
 
@@ -317,12 +298,12 @@ All configuration lives in `~/.adloop/config.yaml`. See [`config.yaml.example`](
317
298
 
318
299
  | Section | Key | Default | Description |
319
300
  |---------|-----|---------|-------------|
320
- | `google` | `project_id` | | Your Google Cloud project ID |
321
- | `google` | `credentials_path` | `~/.adloop/credentials.json` | Path to OAuth client JSON or service account key |
301
+ | `google` | `project_id` | *(empty)* | Google Cloud project ID (only needed with custom credentials) |
302
+ | `google` | `credentials_path` | *(empty — uses built-in)* | Path to OAuth client JSON or service account key. Leave empty to use AdLoop's built-in credentials. |
322
303
  | `google` | `token_path` | `~/.adloop/token.json` | Where to store the OAuth token (auto-created) |
323
- | `ga4` | `property_id` | — | Your GA4 property ID (found in GA4 Admin → Property Settings) |
304
+ | `ga4` | `property_id` | — | Your GA4 property ID (auto-discovered by `adloop init`) |
324
305
  | `ads` | `developer_token` | — | Your Google Ads API developer token |
325
- | `ads` | `customer_id` | — | Default Google Ads customer ID |
306
+ | `ads` | `customer_id` | — | Default Google Ads customer ID (auto-discovered by `adloop init`) |
326
307
  | `ads` | `login_customer_id` | — | Your MCC account ID |
327
308
  | `safety` | `max_daily_budget` | `50.00` | Maximum allowed daily budget per campaign |
328
309
  | `safety` | `require_dry_run` | `true` | Force all writes to dry-run mode |
@@ -335,7 +316,7 @@ src/adloop/
335
316
  ├── __init__.py # Entry point — routes 'adloop init' to wizard, otherwise starts MCP server
336
317
  ├── server.py # FastMCP server — 38 tool registrations with safety annotations
337
318
  ├── config.py # Config loader (~/.adloop/config.yaml)
338
- ├── auth.py # OAuth 2.0 Desktop flow + service account support + token refresh handling
319
+ ├── auth.py # OAuth 2.0 flow (bundled + custom credentials, headless fallback) + service accounts
339
320
  ├── cli.py # Interactive 'adloop init' setup wizard
340
321
  ├── crossref.py # Cross-reference tools (GA4 + Ads combined analysis)
341
322
  ├── tracking.py # Tracking validation + code generation tools
@@ -368,6 +349,8 @@ What's been shipped and what's next:
368
349
  - ~~Setup wizard (`adloop init`)~~ ✓
369
350
  - ~~Claude Code support~~ ✓ — `CLAUDE.md`, `.mcp.json`, `.claude/rules/`, `.claude/commands/`, CLI wizard snippets
370
351
  - ~~PyPI package~~ ✓ — `pip install adloop`
352
+ - ~~Bundled OAuth credentials~~ ✓ — no Google Cloud project required, auto-discovery of GA4/Ads accounts
353
+ - ~~Headless server support~~ ✓ — manual URL copy-paste flow for servers without a browser
371
354
  - **Community launch** — HN, Indie Hackers, r/cursor, Twitter
372
355
  - **Video walkthrough**
373
356
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "adloop"
3
- version = "0.3.0"
3
+ version = "0.4.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.3.0"
5
+ __version__ = "0.4.0"
6
6
 
7
7
 
8
8
  def main() -> None:
@@ -987,6 +987,40 @@ def draft_sitelinks(
987
987
  # ---------------------------------------------------------------------------
988
988
 
989
989
 
990
+ def _extract_error_message(exc: Exception) -> str:
991
+ """Extract a meaningful error message from Google Ads API exceptions.
992
+
993
+ GoogleAdsException.__init__ doesn't call super().__init__(), so str(e)
994
+ returns ''. This function digs into the failure proto to surface the
995
+ actual error code, message, and trigger values.
996
+ """
997
+ try:
998
+ from google.ads.googleads.errors import GoogleAdsException
999
+
1000
+ if isinstance(exc, GoogleAdsException) and exc.failure:
1001
+ parts = []
1002
+ for error in exc.failure.errors:
1003
+ error_code = error.error_code
1004
+ code_field = error_code.WhichOneof("error_code")
1005
+ code_value = getattr(error_code, code_field) if code_field else "UNKNOWN"
1006
+ line = f"[{code_field}={code_value.name if hasattr(code_value, 'name') else code_value}]"
1007
+ if error.message:
1008
+ line += f" {error.message}"
1009
+ if error.trigger and error.trigger.string_value:
1010
+ line += f" (trigger: {error.trigger.string_value})"
1011
+ parts.append(line)
1012
+ if parts:
1013
+ msg = "; ".join(parts)
1014
+ if exc.request_id:
1015
+ msg += f" [request_id={exc.request_id}]"
1016
+ return msg
1017
+ except Exception:
1018
+ pass
1019
+
1020
+ fallback = str(exc)
1021
+ return fallback if fallback else repr(exc)
1022
+
1023
+
990
1024
  def confirm_and_apply(
991
1025
  config: AdLoopConfig,
992
1026
  *,
@@ -1036,6 +1070,7 @@ def confirm_and_apply(
1036
1070
  try:
1037
1071
  result = _execute_plan(config, plan)
1038
1072
  except Exception as e:
1073
+ error_message = _extract_error_message(e)
1039
1074
  log_mutation(
1040
1075
  config.safety.log_file,
1041
1076
  operation=plan.operation,
@@ -1045,9 +1080,9 @@ def confirm_and_apply(
1045
1080
  changes=plan.changes,
1046
1081
  dry_run=False,
1047
1082
  result="error",
1048
- error=str(e),
1083
+ error=error_message,
1049
1084
  )
1050
- return {"error": str(e), "plan_id": plan.plan_id}
1085
+ return {"error": error_message, "plan_id": plan.plan_id}
1051
1086
 
1052
1087
  log_mutation(
1053
1088
  config.safety.log_file,
@@ -2171,17 +2206,13 @@ def _apply_remove(
2171
2206
  )
2172
2207
 
2173
2208
  elif entity_type == "campaign_asset":
2174
- # Campaign asset composite ID: {campaign_id}~{asset_id}~{field_type}
2175
- parts = entity_id.replace(",", "~").split("~")
2209
+ parts = entity_id.split("~")
2176
2210
  if len(parts) != 3:
2177
2211
  raise ValueError(
2178
2212
  f"campaign_asset entity_id must be "
2179
2213
  f"'campaignId~assetId~fieldType', got '{entity_id}'"
2180
2214
  )
2181
- ca_service = client.get_service("CampaignAssetService")
2182
- resource_name = ca_service.campaign_asset_path(
2183
- cid, parts[0], parts[1], parts[2]
2184
- )
2215
+ resource_name = f"customers/{cid}/campaignAssets/{entity_id}"
2185
2216
  ga_service = client.get_service("GoogleAdsService")
2186
2217
  op = client.get_type("MutateOperation")
2187
2218
  op.campaign_asset_operation.remove = resource_name
@@ -2202,14 +2233,13 @@ def _apply_remove(
2202
2233
  )
2203
2234
 
2204
2235
  elif entity_type == "customer_asset":
2205
- parts = entity_id.replace(",", "~").split("~")
2236
+ parts = entity_id.split("~")
2206
2237
  if len(parts) != 2:
2207
2238
  raise ValueError(
2208
2239
  f"customer_asset entity_id must be "
2209
2240
  f"'assetId~fieldType', got '{entity_id}'"
2210
2241
  )
2211
- ca_service = client.get_service("CustomerAssetService")
2212
- resource_name = ca_service.customer_asset_path(cid, parts[0], parts[1])
2242
+ resource_name = f"customers/{cid}/customerAssets/{entity_id}"
2213
2243
  ga_service = client.get_service("GoogleAdsService")
2214
2244
  op = client.get_type("MutateOperation")
2215
2245
  op.customer_asset_operation.remove = resource_name
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import importlib.resources
5
6
  from pathlib import Path
6
7
  from typing import TYPE_CHECKING
7
8
 
@@ -29,11 +30,39 @@ _ADS_SCOPES = [
29
30
  ]
30
31
 
31
32
 
33
+ def _get_credentials_path(config: AdLoopConfig) -> Path | None:
34
+ """Resolve OAuth client credentials using a priority chain.
35
+
36
+ 1. User-provided credentials_path in config (if non-empty and file exists)
37
+ 2. ~/.adloop/credentials.json (if file exists — legacy or manually placed)
38
+ 3. Bundled credentials shipped with the package
39
+ 4. None (caller falls back to Application Default Credentials)
40
+ """
41
+ if config.google.credentials_path:
42
+ user_path = Path(config.google.credentials_path).expanduser()
43
+ if user_path.exists():
44
+ return user_path
45
+
46
+ local_path = Path("~/.adloop/credentials.json").expanduser()
47
+ if local_path.exists():
48
+ return local_path
49
+
50
+ try:
51
+ ref = importlib.resources.files("adloop").joinpath("bundled_credentials.json")
52
+ with importlib.resources.as_file(ref) as bundled:
53
+ if bundled.exists():
54
+ return Path(bundled)
55
+ except (FileNotFoundError, TypeError):
56
+ pass
57
+
58
+ return None
59
+
60
+
32
61
  def get_ga4_credentials(config: AdLoopConfig) -> Credentials:
33
62
  """Return authenticated credentials for GA4 APIs."""
34
- creds_path = Path(config.google.credentials_path).expanduser()
63
+ creds_path = _get_credentials_path(config)
35
64
 
36
- if creds_path.exists():
65
+ if creds_path is not None:
37
66
  import json
38
67
 
39
68
  with open(creds_path) as f:
@@ -47,7 +76,7 @@ def get_ga4_credentials(config: AdLoopConfig) -> Credentials:
47
76
  scopes=_GA4_SCOPES,
48
77
  )
49
78
 
50
- return _oauth_flow(config)
79
+ return _oauth_flow(config, creds_path)
51
80
 
52
81
  import google.auth
53
82
 
@@ -57,9 +86,9 @@ def get_ga4_credentials(config: AdLoopConfig) -> Credentials:
57
86
 
58
87
  def get_ads_credentials(config: AdLoopConfig) -> Credentials:
59
88
  """Return authenticated credentials for Google Ads API."""
60
- creds_path = Path(config.google.credentials_path).expanduser()
89
+ creds_path = _get_credentials_path(config)
61
90
 
62
- if creds_path.exists():
91
+ if creds_path is not None:
63
92
  import json
64
93
 
65
94
  with open(creds_path) as f:
@@ -73,7 +102,7 @@ def get_ads_credentials(config: AdLoopConfig) -> Credentials:
73
102
  scopes=_ADS_SCOPES,
74
103
  )
75
104
 
76
- return _oauth_flow(config)
105
+ return _oauth_flow(config, creds_path)
77
106
 
78
107
  import google.auth
79
108
 
@@ -81,18 +110,29 @@ def get_ads_credentials(config: AdLoopConfig) -> Credentials:
81
110
  return credentials
82
111
 
83
112
 
84
- def _oauth_flow(config: AdLoopConfig) -> Credentials:
113
+ def _oauth_flow(
114
+ config: AdLoopConfig, creds_path: Path | None = None
115
+ ) -> Credentials:
85
116
  """Run OAuth Desktop flow requesting all scopes (GA4 + Ads).
86
117
 
87
118
  Uses a single token file for all scopes to avoid conflicts between
88
119
  GA4 and Ads auth sharing the same token_path.
120
+
121
+ Falls back to a manual copy-paste flow when no browser is available
122
+ (headless servers, Docker containers, SSH sessions).
89
123
  """
90
124
  from google.auth.transport.requests import Request
91
125
  from google.oauth2.credentials import Credentials as OAuthCredentials
92
126
  from google_auth_oauthlib.flow import InstalledAppFlow
93
127
 
94
128
  token_path = Path(config.google.token_path).expanduser()
95
- creds_path = Path(config.google.credentials_path).expanduser()
129
+ if creds_path is None:
130
+ creds_path = _get_credentials_path(config)
131
+ if creds_path is None:
132
+ raise RuntimeError(
133
+ "No OAuth credentials found. Run 'adloop init' or place "
134
+ "credentials.json at ~/.adloop/credentials.json"
135
+ )
96
136
 
97
137
  creds = None
98
138
  if token_path.exists():
@@ -123,10 +163,34 @@ def _oauth_flow(config: AdLoopConfig) -> Credentials:
123
163
  flow = InstalledAppFlow.from_client_secrets_file(
124
164
  str(creds_path), _ALL_SCOPES
125
165
  )
126
- creds = flow.run_local_server(port=0)
166
+ creds = _run_oauth_with_fallback(flow)
127
167
 
128
168
  token_path.parent.mkdir(parents=True, exist_ok=True)
129
169
  with open(token_path, "w") as f:
130
170
  f.write(creds.to_json())
131
171
 
132
172
  return creds
173
+
174
+
175
+ def _run_oauth_with_fallback(flow: object) -> Credentials:
176
+ """Try browser-based OAuth; fall back to manual URL copy-paste for headless."""
177
+ try:
178
+ return flow.run_local_server(port=0) # type: ignore[union-attr]
179
+ except Exception:
180
+ pass
181
+
182
+ auth_url, _ = flow.authorization_url(prompt="consent") # type: ignore[union-attr]
183
+ print()
184
+ print(" No browser detected — using manual authorization.")
185
+ print()
186
+ print(" Open this URL in a browser on any device:")
187
+ print()
188
+ print(f" {auth_url}")
189
+ print()
190
+ print(" Sign in and grant access. Your browser will redirect to a")
191
+ print(" localhost URL that won't load — that's expected.")
192
+ print(" Copy the FULL URL from your browser's address bar.")
193
+ print()
194
+ redirect_url = input(" Paste the redirect URL here: ").strip()
195
+ flow.fetch_token(authorization_response=redirect_url) # type: ignore[union-attr]
196
+ return flow.credentials # type: ignore[union-attr]
@@ -0,0 +1,12 @@
1
+ {
2
+ "_comment": "PLACEHOLDER — Replace this file with the real OAuth client JSON downloaded from your GCP project. See the plan in .cursor/plans/ for instructions.",
3
+ "installed": {
4
+ "client_id": "REPLACE_ME.apps.googleusercontent.com",
5
+ "project_id": "REPLACE_ME",
6
+ "auth_uri": "https://accounts.google.com/o/oauth2/auth",
7
+ "token_uri": "https://oauth2.googleapis.com/token",
8
+ "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
9
+ "client_secret": "REPLACE_ME",
10
+ "redirect_uris": ["http://localhost"]
11
+ }
12
+ }
@@ -119,10 +119,28 @@ def _prompt_property_id(default: str = "") -> str:
119
119
  return value
120
120
 
121
121
 
122
+ def _prompt_choice(label: str, choices: list[tuple[str, str]]) -> str:
123
+ """Present a numbered list of choices and return the selected value."""
124
+ _print(f" {label}")
125
+ _print()
126
+ for i, (value, display) in enumerate(choices, 1):
127
+ _print(f" {i}. {display}")
128
+ _print()
129
+ while True:
130
+ raw = input(f" Enter number [1-{len(choices)}]: ").strip()
131
+ try:
132
+ idx = int(raw) - 1
133
+ if 0 <= idx < len(choices):
134
+ return choices[idx][0]
135
+ except ValueError:
136
+ pass
137
+ _print(f" ⚠ Please enter a number between 1 and {len(choices)}")
138
+
139
+
122
140
  def _generate_config_yaml(
123
141
  *,
124
- project_id: str,
125
- credentials_path: str,
142
+ project_id: str = "",
143
+ credentials_path: str = "",
126
144
  property_id: str,
127
145
  developer_token: str,
128
146
  customer_id: str,
@@ -131,16 +149,22 @@ def _generate_config_yaml(
131
149
  require_dry_run: bool,
132
150
  ) -> str:
133
151
  dry_run_str = "true" if require_dry_run else "false"
152
+
153
+ google_section = "google:\n"
154
+ if project_id:
155
+ google_section += f' project_id: "{project_id}"\n'
156
+ if credentials_path:
157
+ google_section += f' credentials_path: "{credentials_path}"\n'
158
+ else:
159
+ google_section += " # Using built-in credentials (no credentials_path needed)\n"
160
+ google_section += ' token_path: "~/.adloop/token.json"\n'
161
+
134
162
  return textwrap.dedent(f"""\
135
163
  # AdLoop configuration
136
164
  # Generated by: adloop init
137
- # Docs: https://github.com/your-org/adloop
138
-
139
- google:
140
- project_id: "{project_id}"
141
- credentials_path: "{credentials_path}"
142
- token_path: "~/.adloop/token.json"
165
+ # Docs: https://github.com/kLOsk/adloop
143
166
 
167
+ {google_section}
144
168
  ga4:
145
169
  property_id: "{property_id}"
146
170
 
@@ -203,6 +227,61 @@ def _step_header(num: int, title: str) -> None:
203
227
  _print()
204
228
 
205
229
 
230
+ def _discover_ga4_properties(config: object) -> list[tuple[str, str]]:
231
+ """Call GA4 Admin API to discover accessible properties.
232
+
233
+ Returns list of (property_id, display_string) tuples.
234
+ """
235
+ from adloop.ga4.reports import get_account_summaries
236
+
237
+ result = get_account_summaries(config) # type: ignore[arg-type]
238
+ properties: list[tuple[str, str]] = []
239
+ for account in result.get("accounts", []):
240
+ acct_name = account.get("display_name", "Unknown")
241
+ for prop in account.get("properties", []):
242
+ prop_path = prop.get("property", "")
243
+ prop_name = prop.get("display_name", "Unknown")
244
+ prop_id = prop_path.replace("properties/", "")
245
+ properties.append((prop_id, f"{prop_name} ({prop_id}) — {acct_name}"))
246
+ return properties
247
+
248
+
249
+ def _discover_ads_accounts(config: object) -> list[tuple[str, str]]:
250
+ """Call Google Ads API to discover accessible accounts.
251
+
252
+ Returns list of (customer_id_formatted, display_string) tuples.
253
+ Non-manager accounts only.
254
+ """
255
+ from adloop.ads.read import list_accounts
256
+
257
+ result = list_accounts(config) # type: ignore[arg-type]
258
+ accounts: list[tuple[str, str]] = []
259
+ for acct in result.get("accounts", []):
260
+ is_manager = False
261
+ for key in ("customer_client.manager", "customer.manager"):
262
+ if acct.get(key) is True:
263
+ is_manager = True
264
+ break
265
+ if is_manager:
266
+ continue
267
+
268
+ raw_id = ""
269
+ name = "Unknown"
270
+ for id_key in ("customer_client.id", "customer.id"):
271
+ if id_key in acct:
272
+ raw_id = str(acct[id_key])
273
+ break
274
+ for name_key in ("customer_client.descriptive_name", "customer.descriptive_name"):
275
+ if name_key in acct:
276
+ name = str(acct[name_key])
277
+ break
278
+
279
+ if raw_id:
280
+ formatted = _format_customer_id(raw_id)
281
+ accounts.append((formatted, f"{name} ({formatted})"))
282
+ return accounts
283
+
284
+
206
285
  def run_init_wizard() -> None:
207
286
  """Interactive setup wizard for AdLoop."""
208
287
  _print()
@@ -230,53 +309,174 @@ def run_init_wizard() -> None:
230
309
  return str(existing_config[section].get(key, fallback))
231
310
  return fallback
232
311
 
233
- # Step 1: Google Cloud instructions
234
- _step_header(1, "Google Cloud Setup")
235
- _print(_GOOGLE_CLOUD_INSTRUCTIONS)
236
- input(" Press Enter when you've completed the steps above...")
237
-
238
- # Step 2: Credentials path
239
- _step_header(2, "OAuth Credentials")
240
- credentials_path = _prompt_credentials_path(
241
- default=_existing("google", "credentials_path", "~/.adloop/credentials.json")
242
- )
243
-
244
- # Step 3: Google Cloud Project ID
245
- _step_header(3, "Google Cloud Project")
246
- project_id = _prompt(
247
- "Google Cloud Project ID",
248
- default=_existing("google", "project_id"),
249
- )
250
-
251
- # Step 4: GA4 Property ID
252
- _step_header(4, "Google Analytics (GA4)")
253
- _print(" Find your GA4 Property ID at:")
254
- _print(" → https://analytics.google.com → Admin → Property Settings")
312
+ # Step 1: Credentials mode
313
+ _step_header(1, "Google Credentials")
314
+ _print(" AdLoop includes built-in Google OAuth credentials so you")
315
+ _print(" don't need to create your own Google Cloud project.")
255
316
  _print()
256
- property_id = _prompt_property_id(
257
- default=_existing("ga4", "property_id"),
317
+ use_bundled = _prompt_bool(
318
+ "Use built-in credentials? (recommended)", default=True
258
319
  )
259
320
 
260
- # Step 5: Developer Token
261
- _step_header(5, "Google Ads Developer Token")
321
+ credentials_path = ""
322
+ project_id = ""
323
+ if not use_bundled:
324
+ _print()
325
+ _print(_GOOGLE_CLOUD_INSTRUCTIONS)
326
+ input(" Press Enter when you've completed the steps above...")
327
+
328
+ _step_header(2, "OAuth Credentials")
329
+ credentials_path = _prompt_credentials_path(
330
+ default=_existing("google", "credentials_path", "~/.adloop/credentials.json")
331
+ )
332
+
333
+ _step_header(3, "Google Cloud Project")
334
+ project_id = _prompt(
335
+ "Google Cloud Project ID",
336
+ default=_existing("google", "project_id"),
337
+ )
338
+
339
+ # Developer token (both paths need this)
340
+ step_num = 2 if use_bundled else 4
341
+ _step_header(step_num, "Google Ads Developer Token")
342
+ _print(" Find your developer token in your MCC account:")
343
+ _print(" → https://ads.google.com/aw/apicenter")
344
+ _print()
262
345
  developer_token = _prompt(
263
346
  "Developer Token",
264
347
  default=_existing("ads", "developer_token"),
265
348
  )
266
349
 
267
- # Step 6: Customer ID
268
- _step_header(6, "Google Ads Account IDs")
269
- customer_id = _prompt_customer_id(
270
- "Ads Customer ID (XXX-XXX-XXXX)",
271
- default=_existing("ads", "customer_id"),
272
- )
350
+ # MCC Account ID (needed before auto-discovery for Ads API calls)
351
+ step_num += 1
352
+ _step_header(step_num, "MCC / Manager Account")
353
+ _print(" Your MCC (Manager) account ID is in the top bar of your MCC.")
354
+ _print()
273
355
  login_customer_id = _prompt_customer_id(
274
- "MCC / Manager Account ID (XXX-XXX-XXXX)",
356
+ "MCC Account ID (XXX-XXX-XXXX)",
275
357
  default=_existing("ads", "login_customer_id"),
276
358
  )
277
359
 
278
- # Step 7: Safety defaults
279
- _step_header(7, "Safety Defaults")
360
+ # OAuth + auto-discovery
361
+ step_num += 1
362
+ _step_header(step_num, "Authorization & Account Discovery")
363
+
364
+ # Write a minimal temporary config for OAuth + discovery
365
+ _ADLOOP_DIR.mkdir(parents=True, exist_ok=True)
366
+ temp_config_yaml = _generate_config_yaml(
367
+ project_id=project_id,
368
+ credentials_path=credentials_path,
369
+ property_id="",
370
+ developer_token=developer_token,
371
+ customer_id="",
372
+ login_customer_id=login_customer_id,
373
+ max_daily_budget=50.0,
374
+ require_dry_run=True,
375
+ )
376
+ _CONFIG_PATH.write_text(temp_config_yaml)
377
+
378
+ # Optional: copy custom credentials to ~/.adloop/
379
+ if not use_bundled and credentials_path:
380
+ creds_expanded = Path(credentials_path).expanduser()
381
+ adloop_creds = _ADLOOP_DIR / "credentials.json"
382
+ if creds_expanded != adloop_creds and creds_expanded.exists():
383
+ if _prompt_bool(
384
+ f"Copy {creds_expanded.name} to {_ADLOOP_DIR}?", default=True
385
+ ):
386
+ import shutil
387
+
388
+ shutil.copy2(creds_expanded, adloop_creds)
389
+ _print(f" ✓ Credentials copied to {adloop_creds}")
390
+
391
+ _print(" Signing in with Google (this may open a browser)...")
392
+ _print()
393
+ oauth_ok = False
394
+ try:
395
+ from adloop.config import load_config
396
+ from adloop.auth import _oauth_flow
397
+
398
+ cfg = load_config(str(_CONFIG_PATH))
399
+ _oauth_flow(cfg)
400
+ _print(" ✓ OAuth token saved")
401
+ oauth_ok = True
402
+ except Exception as exc:
403
+ _print(f" ✗ OAuth failed: {exc}")
404
+ _print(" You can retry later — any AdLoop tool call will trigger auth.")
405
+
406
+ # Auto-discover GA4 properties
407
+ property_id = ""
408
+ if oauth_ok:
409
+ _print()
410
+ _print(" Discovering GA4 properties...")
411
+ try:
412
+ cfg = load_config(str(_CONFIG_PATH))
413
+ ga4_props = _discover_ga4_properties(cfg)
414
+ if len(ga4_props) == 1:
415
+ property_id = ga4_props[0][0]
416
+ _print(f" ✓ Found GA4 property: {ga4_props[0][1]}")
417
+ if not _prompt_bool("Use this property?", default=True):
418
+ property_id = _prompt_property_id()
419
+ elif len(ga4_props) > 1:
420
+ _print(f" Found {len(ga4_props)} GA4 properties:")
421
+ property_id = _prompt_choice(
422
+ "Select your GA4 property:", ga4_props
423
+ )
424
+ else:
425
+ _print(" No GA4 properties found. Enter manually:")
426
+ property_id = _prompt_property_id()
427
+ except Exception as exc:
428
+ _print(f" Could not auto-discover GA4 properties: {exc}")
429
+ property_id = _prompt_property_id(
430
+ default=_existing("ga4", "property_id"),
431
+ )
432
+ else:
433
+ step_num += 1
434
+ _step_header(step_num, "Google Analytics (GA4)")
435
+ _print(" Find your GA4 Property ID at:")
436
+ _print(" → https://analytics.google.com → Admin → Property Settings")
437
+ _print()
438
+ property_id = _prompt_property_id(
439
+ default=_existing("ga4", "property_id"),
440
+ )
441
+
442
+ # Auto-discover Ads accounts
443
+ customer_id = ""
444
+ if oauth_ok:
445
+ _print()
446
+ _print(" Discovering Google Ads accounts...")
447
+ try:
448
+ cfg = load_config(str(_CONFIG_PATH))
449
+ ads_accounts = _discover_ads_accounts(cfg)
450
+ if len(ads_accounts) == 1:
451
+ customer_id = ads_accounts[0][0]
452
+ _print(f" ✓ Found Ads account: {ads_accounts[0][1]}")
453
+ if not _prompt_bool("Use this account?", default=True):
454
+ customer_id = _prompt_customer_id("Ads Customer ID (XXX-XXX-XXXX)")
455
+ elif len(ads_accounts) > 1:
456
+ _print(f" Found {len(ads_accounts)} Ads accounts:")
457
+ customer_id = _prompt_choice(
458
+ "Select your default Ads account:", ads_accounts
459
+ )
460
+ else:
461
+ _print(" No Ads accounts found. Enter manually:")
462
+ customer_id = _prompt_customer_id("Ads Customer ID (XXX-XXX-XXXX)")
463
+ except Exception as exc:
464
+ _print(f" Could not auto-discover Ads accounts: {exc}")
465
+ customer_id = _prompt_customer_id(
466
+ "Ads Customer ID (XXX-XXX-XXXX)",
467
+ default=_existing("ads", "customer_id"),
468
+ )
469
+ else:
470
+ step_num += 1
471
+ _step_header(step_num, "Google Ads Account")
472
+ customer_id = _prompt_customer_id(
473
+ "Ads Customer ID (XXX-XXX-XXXX)",
474
+ default=_existing("ads", "customer_id"),
475
+ )
476
+
477
+ # Safety defaults
478
+ step_num += 1
479
+ _step_header(step_num, "Safety Defaults")
280
480
  budget_str = _prompt(
281
481
  "Max daily budget cap (safety limit)",
282
482
  default=str(_existing("safety", "max_daily_budget", "50")),
@@ -293,10 +493,9 @@ def run_init_wizard() -> None:
293
493
  default=True,
294
494
  )
295
495
 
296
- # Write config
496
+ # Write final config
297
497
  _print()
298
498
  _print(" ── Writing Configuration ──")
299
- _ADLOOP_DIR.mkdir(parents=True, exist_ok=True)
300
499
 
301
500
  config_yaml = _generate_config_yaml(
302
501
  project_id=project_id,
@@ -311,35 +510,6 @@ def run_init_wizard() -> None:
311
510
  _CONFIG_PATH.write_text(config_yaml)
312
511
  _print(f" ✓ Config written to {_CONFIG_PATH}")
313
512
 
314
- # Optional: Copy credentials.json to ~/.adloop/ if it's elsewhere
315
- creds_expanded = Path(credentials_path).expanduser()
316
- adloop_creds = _ADLOOP_DIR / "credentials.json"
317
- if creds_expanded != adloop_creds and creds_expanded.exists():
318
- if _prompt_bool(
319
- f"Copy {creds_expanded.name} to {_ADLOOP_DIR}?", default=True
320
- ):
321
- import shutil
322
-
323
- shutil.copy2(creds_expanded, adloop_creds)
324
- _print(f" ✓ Credentials copied to {adloop_creds}")
325
-
326
- # Optional OAuth
327
- _print()
328
- if _prompt_bool("Run OAuth authorization now? (opens browser)", default=True):
329
- _print(" Starting OAuth flow...")
330
- try:
331
- from adloop.config import load_config
332
- from adloop.auth import _oauth_flow
333
-
334
- cfg = load_config(str(_CONFIG_PATH))
335
- _oauth_flow(cfg)
336
- _print(" ✓ OAuth token saved — AdLoop is ready to connect")
337
- except Exception as exc:
338
- _print(f" ✗ OAuth failed: {exc}")
339
- _print(" You can retry later — any AdLoop tool call will trigger auth.")
340
- else:
341
- _print(" Skipped — OAuth will run automatically on first tool call.")
342
-
343
513
  # MCP configuration snippets
344
514
  _print()
345
515
  _print(" ── MCP Configuration ──")
@@ -12,7 +12,7 @@ import yaml
12
12
  @dataclass
13
13
  class GoogleConfig:
14
14
  project_id: str = ""
15
- credentials_path: str = "~/.adloop/credentials.json"
15
+ credentials_path: str = "" # empty = use bundled credentials shipped with the package
16
16
  token_path: str = "~/.adloop/token.json"
17
17
 
18
18
 
@@ -81,7 +81,7 @@ def load_config(config_path: str | None = None) -> AdLoopConfig:
81
81
  return AdLoopConfig(
82
82
  google=GoogleConfig(
83
83
  project_id=google_raw.get("project_id", ""),
84
- credentials_path=google_raw.get("credentials_path", "~/.adloop/credentials.json"),
84
+ credentials_path=google_raw.get("credentials_path", ""),
85
85
  token_path=google_raw.get("token_path", "~/.adloop/token.json"),
86
86
  ),
87
87
  ga4=GA4Config(
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
File without changes