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.
- {adloop-0.3.0 → adloop-0.4.0}/PKG-INFO +26 -43
- {adloop-0.3.0 → adloop-0.4.0}/README.md +25 -42
- {adloop-0.3.0 → adloop-0.4.0}/pyproject.toml +1 -1
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/__init__.py +1 -1
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ads/write.py +41 -11
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/auth.py +73 -9
- adloop-0.4.0/src/adloop/bundled_credentials.json +12 -0
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/cli.py +244 -74
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/config.py +2 -2
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/__main__.py +0 -0
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ads/__init__.py +0 -0
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ads/client.py +0 -0
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ads/currency.py +0 -0
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ads/forecast.py +0 -0
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ads/gaql.py +0 -0
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ads/pmax.py +0 -0
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ads/read.py +0 -0
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/crossref.py +0 -0
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ga4/__init__.py +0 -0
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ga4/client.py +0 -0
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ga4/reports.py +0 -0
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/ga4/tracking.py +0 -0
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/safety/__init__.py +0 -0
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/safety/audit.py +0 -0
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/safety/guards.py +0 -0
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/safety/preview.py +0 -0
- {adloop-0.3.0 → adloop-0.4.0}/src/adloop/server.py +0 -0
- {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
|
+
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
###
|
|
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
|
|
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 —
|
|
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` |
|
|
345
|
-
| `google` | `credentials_path` |
|
|
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 (
|
|
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
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
###
|
|
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
|
|
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 —
|
|
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` |
|
|
321
|
-
| `google` | `credentials_path` |
|
|
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 (
|
|
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
|
|
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
|
|
|
@@ -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=
|
|
1083
|
+
error=error_message,
|
|
1049
1084
|
)
|
|
1050
|
-
return {"error":
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
63
|
+
creds_path = _get_credentials_path(config)
|
|
35
64
|
|
|
36
|
-
if creds_path
|
|
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 =
|
|
89
|
+
creds_path = _get_credentials_path(config)
|
|
61
90
|
|
|
62
|
-
if creds_path
|
|
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(
|
|
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
|
|
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
|
|
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/
|
|
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:
|
|
234
|
-
_step_header(1, "Google
|
|
235
|
-
_print(
|
|
236
|
-
|
|
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
|
-
|
|
257
|
-
|
|
317
|
+
use_bundled = _prompt_bool(
|
|
318
|
+
"Use built-in credentials? (recommended)", default=True
|
|
258
319
|
)
|
|
259
320
|
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
#
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
356
|
+
"MCC Account ID (XXX-XXX-XXXX)",
|
|
275
357
|
default=_existing("ads", "login_customer_id"),
|
|
276
358
|
)
|
|
277
359
|
|
|
278
|
-
#
|
|
279
|
-
|
|
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 = "
|
|
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", "
|
|
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
|
|
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
|