adloop 0.1.0__py3-none-any.whl
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/__init__.py +21 -0
- adloop/__main__.py +5 -0
- adloop/ads/__init__.py +0 -0
- adloop/ads/client.py +41 -0
- adloop/ads/forecast.py +156 -0
- adloop/ads/gaql.py +178 -0
- adloop/ads/read.py +238 -0
- adloop/ads/write.py +950 -0
- adloop/auth.py +132 -0
- adloop/cli.py +375 -0
- adloop/config.py +102 -0
- adloop/crossref.py +509 -0
- adloop/ga4/__init__.py +0 -0
- adloop/ga4/client.py +31 -0
- adloop/ga4/reports.py +141 -0
- adloop/ga4/tracking.py +36 -0
- adloop/safety/__init__.py +0 -0
- adloop/safety/audit.py +40 -0
- adloop/safety/guards.py +56 -0
- adloop/safety/preview.py +58 -0
- adloop/server.py +778 -0
- adloop/tracking.py +244 -0
- adloop-0.1.0.dist-info/METADATA +382 -0
- adloop-0.1.0.dist-info/RECORD +26 -0
- adloop-0.1.0.dist-info/WHEEL +4 -0
- adloop-0.1.0.dist-info/entry_points.txt +3 -0
adloop/auth.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Google API authentication — OAuth 2.0 and service account support."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from google.auth.credentials import Credentials
|
|
10
|
+
|
|
11
|
+
from adloop.config import AdLoopConfig
|
|
12
|
+
|
|
13
|
+
# Request all scopes in a single OAuth flow so one token works for both
|
|
14
|
+
# GA4 and Google Ads. Without this, separate tokens would constantly
|
|
15
|
+
# overwrite each other at the same token_path.
|
|
16
|
+
_ALL_SCOPES = [
|
|
17
|
+
"https://www.googleapis.com/auth/analytics.readonly",
|
|
18
|
+
"https://www.googleapis.com/auth/analytics.edit",
|
|
19
|
+
"https://www.googleapis.com/auth/adwords",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
_GA4_SCOPES = [
|
|
23
|
+
"https://www.googleapis.com/auth/analytics.readonly",
|
|
24
|
+
"https://www.googleapis.com/auth/analytics.edit",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
_ADS_SCOPES = [
|
|
28
|
+
"https://www.googleapis.com/auth/adwords",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_ga4_credentials(config: AdLoopConfig) -> Credentials:
|
|
33
|
+
"""Return authenticated credentials for GA4 APIs."""
|
|
34
|
+
creds_path = Path(config.google.credentials_path).expanduser()
|
|
35
|
+
|
|
36
|
+
if creds_path.exists():
|
|
37
|
+
import json
|
|
38
|
+
|
|
39
|
+
with open(creds_path) as f:
|
|
40
|
+
creds_info = json.load(f)
|
|
41
|
+
|
|
42
|
+
if creds_info.get("type") == "service_account":
|
|
43
|
+
from google.oauth2 import service_account
|
|
44
|
+
|
|
45
|
+
return service_account.Credentials.from_service_account_file(
|
|
46
|
+
str(creds_path),
|
|
47
|
+
scopes=_GA4_SCOPES,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return _oauth_flow(config)
|
|
51
|
+
|
|
52
|
+
import google.auth
|
|
53
|
+
|
|
54
|
+
credentials, _ = google.auth.default(scopes=_GA4_SCOPES)
|
|
55
|
+
return credentials
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_ads_credentials(config: AdLoopConfig) -> Credentials:
|
|
59
|
+
"""Return authenticated credentials for Google Ads API."""
|
|
60
|
+
creds_path = Path(config.google.credentials_path).expanduser()
|
|
61
|
+
|
|
62
|
+
if creds_path.exists():
|
|
63
|
+
import json
|
|
64
|
+
|
|
65
|
+
with open(creds_path) as f:
|
|
66
|
+
creds_info = json.load(f)
|
|
67
|
+
|
|
68
|
+
if creds_info.get("type") == "service_account":
|
|
69
|
+
from google.oauth2 import service_account
|
|
70
|
+
|
|
71
|
+
return service_account.Credentials.from_service_account_file(
|
|
72
|
+
str(creds_path),
|
|
73
|
+
scopes=_ADS_SCOPES,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
return _oauth_flow(config)
|
|
77
|
+
|
|
78
|
+
import google.auth
|
|
79
|
+
|
|
80
|
+
credentials, _ = google.auth.default(scopes=_ADS_SCOPES)
|
|
81
|
+
return credentials
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _oauth_flow(config: AdLoopConfig) -> Credentials:
|
|
85
|
+
"""Run OAuth Desktop flow requesting all scopes (GA4 + Ads).
|
|
86
|
+
|
|
87
|
+
Uses a single token file for all scopes to avoid conflicts between
|
|
88
|
+
GA4 and Ads auth sharing the same token_path.
|
|
89
|
+
"""
|
|
90
|
+
from google.auth.transport.requests import Request
|
|
91
|
+
from google.oauth2.credentials import Credentials as OAuthCredentials
|
|
92
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
93
|
+
|
|
94
|
+
token_path = Path(config.google.token_path).expanduser()
|
|
95
|
+
creds_path = Path(config.google.credentials_path).expanduser()
|
|
96
|
+
|
|
97
|
+
creds = None
|
|
98
|
+
if token_path.exists():
|
|
99
|
+
creds = OAuthCredentials.from_authorized_user_file(
|
|
100
|
+
str(token_path), _ALL_SCOPES
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if creds and creds.valid:
|
|
104
|
+
return creds
|
|
105
|
+
|
|
106
|
+
if creds and creds.expired and creds.refresh_token:
|
|
107
|
+
try:
|
|
108
|
+
creds.refresh(Request())
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
err_str = str(exc).lower()
|
|
111
|
+
if "revoked" in err_str or "invalid_grant" in err_str:
|
|
112
|
+
token_path.unlink(missing_ok=True)
|
|
113
|
+
raise RuntimeError(
|
|
114
|
+
"OAuth token has been revoked or expired. "
|
|
115
|
+
"This typically happens when the Google Cloud consent screen "
|
|
116
|
+
"is in 'Testing' mode (tokens expire after 7 days). "
|
|
117
|
+
"Fix: (1) re-run any AdLoop tool to trigger re-authorization, "
|
|
118
|
+
"(2) publish the consent screen to 'In production' in Google "
|
|
119
|
+
"Cloud Console to prevent future expiry."
|
|
120
|
+
) from exc
|
|
121
|
+
raise
|
|
122
|
+
else:
|
|
123
|
+
flow = InstalledAppFlow.from_client_secrets_file(
|
|
124
|
+
str(creds_path), _ALL_SCOPES
|
|
125
|
+
)
|
|
126
|
+
creds = flow.run_local_server(port=0)
|
|
127
|
+
|
|
128
|
+
token_path.parent.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
with open(token_path, "w") as f:
|
|
130
|
+
f.write(creds.to_json())
|
|
131
|
+
|
|
132
|
+
return creds
|
adloop/cli.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""Interactive setup wizard for first-time AdLoop configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
import textwrap
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
_ADLOOP_DIR = Path.home() / ".adloop"
|
|
11
|
+
_CONFIG_PATH = _ADLOOP_DIR / "config.yaml"
|
|
12
|
+
|
|
13
|
+
_GOOGLE_CLOUD_INSTRUCTIONS = """\
|
|
14
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
15
|
+
│ Google Cloud Setup Checklist │
|
|
16
|
+
│ │
|
|
17
|
+
│ Complete these steps in your browser before continuing: │
|
|
18
|
+
│ │
|
|
19
|
+
│ 1. Create (or select) a Google Cloud project │
|
|
20
|
+
│ → https://console.cloud.google.com/projectcreate │
|
|
21
|
+
│ │
|
|
22
|
+
│ 2. Enable these APIs: │
|
|
23
|
+
│ • Google Analytics Data API │
|
|
24
|
+
│ • Google Analytics Admin API │
|
|
25
|
+
│ • Google Ads API │
|
|
26
|
+
│ → https://console.cloud.google.com/apis/library │
|
|
27
|
+
│ │
|
|
28
|
+
│ 3. Create an OAuth consent screen (External, Testing mode OK) │
|
|
29
|
+
│ → https://console.cloud.google.com/apis/credentials/consent │
|
|
30
|
+
│ Add your email as a test user. │
|
|
31
|
+
│ │
|
|
32
|
+
│ 4. Create OAuth 2.0 Client ID (Desktop application) │
|
|
33
|
+
│ → https://console.cloud.google.com/apis/credentials │
|
|
34
|
+
│ Download the JSON file. │
|
|
35
|
+
│ │
|
|
36
|
+
│ 5. Get your Google Ads Developer Token from your MCC account │
|
|
37
|
+
│ → https://ads.google.com/aw/apicenter │
|
|
38
|
+
│ (You need a Manager Account / MCC) │
|
|
39
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _print(msg: str = "") -> None:
|
|
44
|
+
print(msg)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _prompt(label: str, default: str = "", required: bool = True) -> str:
|
|
48
|
+
suffix = f" [{default}]" if default else ""
|
|
49
|
+
while True:
|
|
50
|
+
raw = input(f" {label}{suffix}: ").strip()
|
|
51
|
+
value = raw or default
|
|
52
|
+
if value or not required:
|
|
53
|
+
return value
|
|
54
|
+
_print(" ⚠ This field is required.")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _prompt_bool(label: str, default: bool = True) -> bool:
|
|
58
|
+
hint = "Y/n" if default else "y/N"
|
|
59
|
+
raw = input(f" {label} [{hint}]: ").strip().lower()
|
|
60
|
+
if not raw:
|
|
61
|
+
return default
|
|
62
|
+
return raw in ("y", "yes", "true", "1")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _format_customer_id(raw: str) -> str:
|
|
66
|
+
"""Normalize a customer ID to XXX-XXX-XXXX format."""
|
|
67
|
+
digits = re.sub(r"[^0-9]", "", raw)
|
|
68
|
+
if len(digits) == 10:
|
|
69
|
+
return f"{digits[:3]}-{digits[3:6]}-{digits[6:]}"
|
|
70
|
+
return raw
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _validate_customer_id(raw: str) -> str | None:
|
|
74
|
+
digits = re.sub(r"[^0-9]", "", raw)
|
|
75
|
+
if len(digits) != 10:
|
|
76
|
+
return "Customer ID must be 10 digits (e.g. 123-456-7890)"
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _prompt_customer_id(label: str, default: str = "") -> str:
|
|
81
|
+
while True:
|
|
82
|
+
value = _prompt(label, default=default)
|
|
83
|
+
formatted = _format_customer_id(value)
|
|
84
|
+
err = _validate_customer_id(formatted)
|
|
85
|
+
if err:
|
|
86
|
+
_print(f" ⚠ {err}")
|
|
87
|
+
continue
|
|
88
|
+
return formatted
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _validate_credentials_path(path_str: str) -> str | None:
|
|
92
|
+
p = Path(path_str).expanduser()
|
|
93
|
+
if not p.exists():
|
|
94
|
+
return f"File not found: {p}"
|
|
95
|
+
if not p.suffix == ".json":
|
|
96
|
+
return "Expected a .json file"
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _prompt_credentials_path(default: str = "~/.adloop/credentials.json") -> str:
|
|
101
|
+
while True:
|
|
102
|
+
value = _prompt("Path to OAuth credentials JSON", default=default)
|
|
103
|
+
err = _validate_credentials_path(value)
|
|
104
|
+
if err:
|
|
105
|
+
_print(f" ⚠ {err}")
|
|
106
|
+
retry = _prompt_bool("Try again?", default=True)
|
|
107
|
+
if not retry:
|
|
108
|
+
return value
|
|
109
|
+
continue
|
|
110
|
+
return value
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _prompt_property_id(default: str = "") -> str:
|
|
114
|
+
while True:
|
|
115
|
+
value = _prompt("GA4 Property ID (numeric)", default=default)
|
|
116
|
+
if value and not value.isdigit():
|
|
117
|
+
_print(" ⚠ Property ID should be numeric (e.g. 519379787)")
|
|
118
|
+
continue
|
|
119
|
+
return value
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _generate_config_yaml(
|
|
123
|
+
*,
|
|
124
|
+
project_id: str,
|
|
125
|
+
credentials_path: str,
|
|
126
|
+
property_id: str,
|
|
127
|
+
developer_token: str,
|
|
128
|
+
customer_id: str,
|
|
129
|
+
login_customer_id: str,
|
|
130
|
+
max_daily_budget: float,
|
|
131
|
+
require_dry_run: bool,
|
|
132
|
+
) -> str:
|
|
133
|
+
dry_run_str = "true" if require_dry_run else "false"
|
|
134
|
+
return textwrap.dedent(f"""\
|
|
135
|
+
# AdLoop configuration
|
|
136
|
+
# 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"
|
|
143
|
+
|
|
144
|
+
ga4:
|
|
145
|
+
property_id: "{property_id}"
|
|
146
|
+
|
|
147
|
+
ads:
|
|
148
|
+
developer_token: "{developer_token}"
|
|
149
|
+
customer_id: "{customer_id}"
|
|
150
|
+
# MCC / Manager Account ID (required if using a manager account)
|
|
151
|
+
login_customer_id: "{login_customer_id}"
|
|
152
|
+
|
|
153
|
+
safety:
|
|
154
|
+
# Maximum daily budget AdLoop can set (safety cap)
|
|
155
|
+
max_daily_budget: {max_daily_budget}
|
|
156
|
+
max_bid_increase_pct: 100
|
|
157
|
+
# When true, confirm_and_apply always runs as dry_run regardless of the parameter
|
|
158
|
+
require_dry_run: {dry_run_str}
|
|
159
|
+
log_file: "~/.adloop/audit.log"
|
|
160
|
+
blocked_operations: []
|
|
161
|
+
""")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _generate_cursor_snippet() -> str:
|
|
165
|
+
python_path = sys.executable
|
|
166
|
+
return textwrap.dedent(f"""\
|
|
167
|
+
{{
|
|
168
|
+
"mcpServers": {{
|
|
169
|
+
"adloop": {{
|
|
170
|
+
"command": "{python_path}",
|
|
171
|
+
"args": ["-m", "adloop"]
|
|
172
|
+
}}
|
|
173
|
+
}}
|
|
174
|
+
}}
|
|
175
|
+
""").strip()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _generate_claude_code_snippet() -> str:
|
|
179
|
+
"""Generate the Claude Code CLI command to add AdLoop as an MCP server."""
|
|
180
|
+
python_path = sys.executable
|
|
181
|
+
quoted = f'"{python_path}"' if " " in python_path else python_path
|
|
182
|
+
return f"claude mcp add --transport stdio adloop -- {quoted} -m adloop"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _generate_claude_json_snippet() -> str:
|
|
186
|
+
"""Generate .mcp.json configuration for Claude Code."""
|
|
187
|
+
python_path = sys.executable
|
|
188
|
+
return textwrap.dedent(f"""\
|
|
189
|
+
{{
|
|
190
|
+
"mcpServers": {{
|
|
191
|
+
"adloop": {{
|
|
192
|
+
"command": "{python_path}",
|
|
193
|
+
"args": ["-m", "adloop"]
|
|
194
|
+
}}
|
|
195
|
+
}}
|
|
196
|
+
}}
|
|
197
|
+
""").strip()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _step_header(num: int, title: str) -> None:
|
|
201
|
+
_print()
|
|
202
|
+
_print(f" ── Step {num}: {title} ──")
|
|
203
|
+
_print()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def run_init_wizard() -> None:
|
|
207
|
+
"""Interactive setup wizard for AdLoop."""
|
|
208
|
+
_print()
|
|
209
|
+
_print(" ╔═══════════════════════════════════╗")
|
|
210
|
+
_print(" ║ AdLoop Setup Wizard ║")
|
|
211
|
+
_print(" ╚═══════════════════════════════════╝")
|
|
212
|
+
_print()
|
|
213
|
+
|
|
214
|
+
existing_config = None
|
|
215
|
+
if _CONFIG_PATH.exists():
|
|
216
|
+
_print(f" Found existing config at {_CONFIG_PATH}")
|
|
217
|
+
if not _prompt_bool("Overwrite existing configuration?", default=False):
|
|
218
|
+
_print(" Keeping existing config. Exiting.")
|
|
219
|
+
return
|
|
220
|
+
try:
|
|
221
|
+
import yaml
|
|
222
|
+
|
|
223
|
+
with open(_CONFIG_PATH) as f:
|
|
224
|
+
existing_config = yaml.safe_load(f) or {}
|
|
225
|
+
except Exception:
|
|
226
|
+
existing_config = {}
|
|
227
|
+
|
|
228
|
+
def _existing(section: str, key: str, fallback: str = "") -> str:
|
|
229
|
+
if existing_config and section in existing_config:
|
|
230
|
+
return str(existing_config[section].get(key, fallback))
|
|
231
|
+
return fallback
|
|
232
|
+
|
|
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")
|
|
255
|
+
_print()
|
|
256
|
+
property_id = _prompt_property_id(
|
|
257
|
+
default=_existing("ga4", "property_id"),
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
# Step 5: Developer Token
|
|
261
|
+
_step_header(5, "Google Ads Developer Token")
|
|
262
|
+
developer_token = _prompt(
|
|
263
|
+
"Developer Token",
|
|
264
|
+
default=_existing("ads", "developer_token"),
|
|
265
|
+
)
|
|
266
|
+
|
|
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
|
+
)
|
|
273
|
+
login_customer_id = _prompt_customer_id(
|
|
274
|
+
"MCC / Manager Account ID (XXX-XXX-XXXX)",
|
|
275
|
+
default=_existing("ads", "login_customer_id"),
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Step 7: Safety defaults
|
|
279
|
+
_step_header(7, "Safety Defaults")
|
|
280
|
+
budget_str = _prompt(
|
|
281
|
+
"Max daily budget cap (safety limit)",
|
|
282
|
+
default=str(_existing("safety", "max_daily_budget", "50")),
|
|
283
|
+
required=False,
|
|
284
|
+
)
|
|
285
|
+
try:
|
|
286
|
+
max_daily_budget = float(budget_str) if budget_str else 50.0
|
|
287
|
+
except ValueError:
|
|
288
|
+
max_daily_budget = 50.0
|
|
289
|
+
_print(" ⚠ Invalid number, using default 50.0")
|
|
290
|
+
|
|
291
|
+
require_dry_run = _prompt_bool(
|
|
292
|
+
"Require dry_run for all mutations? (recommended for setup)",
|
|
293
|
+
default=True,
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Write config
|
|
297
|
+
_print()
|
|
298
|
+
_print(" ── Writing Configuration ──")
|
|
299
|
+
_ADLOOP_DIR.mkdir(parents=True, exist_ok=True)
|
|
300
|
+
|
|
301
|
+
config_yaml = _generate_config_yaml(
|
|
302
|
+
project_id=project_id,
|
|
303
|
+
credentials_path=credentials_path,
|
|
304
|
+
property_id=property_id,
|
|
305
|
+
developer_token=developer_token,
|
|
306
|
+
customer_id=customer_id,
|
|
307
|
+
login_customer_id=login_customer_id,
|
|
308
|
+
max_daily_budget=max_daily_budget,
|
|
309
|
+
require_dry_run=require_dry_run,
|
|
310
|
+
)
|
|
311
|
+
_CONFIG_PATH.write_text(config_yaml)
|
|
312
|
+
_print(f" ✓ Config written to {_CONFIG_PATH}")
|
|
313
|
+
|
|
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
|
+
# MCP configuration snippets
|
|
344
|
+
_print()
|
|
345
|
+
_print(" ── MCP Configuration ──")
|
|
346
|
+
|
|
347
|
+
# Cursor
|
|
348
|
+
_print()
|
|
349
|
+
_print(" For Cursor, add to .cursor/mcp.json:")
|
|
350
|
+
_print()
|
|
351
|
+
cursor_snippet = _generate_cursor_snippet()
|
|
352
|
+
for line in cursor_snippet.splitlines():
|
|
353
|
+
_print(f" {line}")
|
|
354
|
+
_print()
|
|
355
|
+
_print(" Then copy .cursor/rules/adloop.mdc into your project.")
|
|
356
|
+
|
|
357
|
+
# Claude Code
|
|
358
|
+
_print()
|
|
359
|
+
_print(" For Claude Code, run:")
|
|
360
|
+
_print()
|
|
361
|
+
_print(f" {_generate_claude_code_snippet()}")
|
|
362
|
+
_print()
|
|
363
|
+
_print(" Or add to your project's .mcp.json:")
|
|
364
|
+
_print()
|
|
365
|
+
claude_snippet = _generate_claude_json_snippet()
|
|
366
|
+
for line in claude_snippet.splitlines():
|
|
367
|
+
_print(f" {line}")
|
|
368
|
+
_print()
|
|
369
|
+
_print(" Then copy .claude/rules/adloop.md and .claude/commands/ into your project.")
|
|
370
|
+
|
|
371
|
+
_print()
|
|
372
|
+
_print(" Restart your editor to pick up the MCP server.")
|
|
373
|
+
_print()
|
|
374
|
+
_print(" ✓ Setup complete!")
|
|
375
|
+
_print()
|
adloop/config.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Load and validate AdLoop configuration from ~/.adloop/config.yaml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class GoogleConfig:
|
|
14
|
+
project_id: str = ""
|
|
15
|
+
credentials_path: str = "~/.adloop/credentials.json"
|
|
16
|
+
token_path: str = "~/.adloop/token.json"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class GA4Config:
|
|
21
|
+
property_id: str = ""
|
|
22
|
+
|
|
23
|
+
def __post_init__(self) -> None:
|
|
24
|
+
if self.property_id and not self.property_id.startswith("properties/"):
|
|
25
|
+
self.property_id = f"properties/{self.property_id}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class AdsConfig:
|
|
30
|
+
developer_token: str = ""
|
|
31
|
+
customer_id: str = ""
|
|
32
|
+
login_customer_id: str = ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class SafetyConfig:
|
|
37
|
+
max_daily_budget: float = 50.0
|
|
38
|
+
max_bid_increase_pct: int = 100
|
|
39
|
+
require_dry_run: bool = True
|
|
40
|
+
log_file: str = "~/.adloop/audit.log"
|
|
41
|
+
blocked_operations: list[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class AdLoopConfig:
|
|
46
|
+
google: GoogleConfig = field(default_factory=GoogleConfig)
|
|
47
|
+
ga4: GA4Config = field(default_factory=GA4Config)
|
|
48
|
+
ads: AdsConfig = field(default_factory=AdsConfig)
|
|
49
|
+
safety: SafetyConfig = field(default_factory=SafetyConfig)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _resolve_path(path_str: str) -> Path:
|
|
53
|
+
"""Expand ~ and env vars in a path string."""
|
|
54
|
+
return Path(os.path.expandvars(os.path.expanduser(path_str)))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def load_config(config_path: str | None = None) -> AdLoopConfig:
|
|
58
|
+
"""Load configuration from YAML file.
|
|
59
|
+
|
|
60
|
+
Resolution order:
|
|
61
|
+
1. Explicit ``config_path`` argument
|
|
62
|
+
2. ``ADLOOP_CONFIG`` environment variable
|
|
63
|
+
3. ``~/.adloop/config.yaml`` default
|
|
64
|
+
"""
|
|
65
|
+
if config_path is None:
|
|
66
|
+
config_path = os.environ.get("ADLOOP_CONFIG", "~/.adloop/config.yaml")
|
|
67
|
+
|
|
68
|
+
path = _resolve_path(config_path)
|
|
69
|
+
|
|
70
|
+
if not path.exists():
|
|
71
|
+
return AdLoopConfig()
|
|
72
|
+
|
|
73
|
+
with open(path) as f:
|
|
74
|
+
raw = yaml.safe_load(f) or {}
|
|
75
|
+
|
|
76
|
+
google_raw = raw.get("google", {})
|
|
77
|
+
ga4_raw = raw.get("ga4", {})
|
|
78
|
+
ads_raw = raw.get("ads", {})
|
|
79
|
+
safety_raw = raw.get("safety", {})
|
|
80
|
+
|
|
81
|
+
return AdLoopConfig(
|
|
82
|
+
google=GoogleConfig(
|
|
83
|
+
project_id=google_raw.get("project_id", ""),
|
|
84
|
+
credentials_path=google_raw.get("credentials_path", "~/.adloop/credentials.json"),
|
|
85
|
+
token_path=google_raw.get("token_path", "~/.adloop/token.json"),
|
|
86
|
+
),
|
|
87
|
+
ga4=GA4Config(
|
|
88
|
+
property_id=ga4_raw.get("property_id", ""),
|
|
89
|
+
),
|
|
90
|
+
ads=AdsConfig(
|
|
91
|
+
developer_token=ads_raw.get("developer_token", ""),
|
|
92
|
+
customer_id=ads_raw.get("customer_id", ""),
|
|
93
|
+
login_customer_id=ads_raw.get("login_customer_id", ""),
|
|
94
|
+
),
|
|
95
|
+
safety=SafetyConfig(
|
|
96
|
+
max_daily_budget=safety_raw.get("max_daily_budget", 50.0),
|
|
97
|
+
max_bid_increase_pct=safety_raw.get("max_bid_increase_pct", 100),
|
|
98
|
+
require_dry_run=safety_raw.get("require_dry_run", True),
|
|
99
|
+
log_file=safety_raw.get("log_file", "~/.adloop/audit.log"),
|
|
100
|
+
blocked_operations=safety_raw.get("blocked_operations", []),
|
|
101
|
+
),
|
|
102
|
+
)
|