@topy-ai/maggie 0.7.47 → 0.7.49

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 (91) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +76 -15
  3. package/bin/maggie.js +23 -6
  4. package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
  5. package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
  6. package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
  7. package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
  8. package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
  9. package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
  10. package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
  11. package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
  12. package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
  13. package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
  14. package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
  15. package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
  16. package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
  17. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
  18. package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
  19. package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
  20. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
  21. package/bundled-marketplace/README.md +17 -0
  22. package/bundled-marketplace/catalog.json +56 -7
  23. package/bundled-references/browser-inspection.md +25 -0
  24. package/bundled-references/google-integrations-runbook.md +54 -0
  25. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
  26. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
  27. package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
  28. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
  29. package/bundled-references/maggiedash-booking/PRD.md +9 -1
  30. package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
  31. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
  32. package/bundled-references/maggiedash-booking/README.md +23 -6
  33. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
  34. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
  35. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
  36. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
  37. package/bundled-references/maggiedash-booking/TASKS.md +44 -3
  38. package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
  39. package/bundled-references/provider-adapter-contract.md +33 -0
  40. package/bundled-skills/maggie-booking/SKILL.md +8 -0
  41. package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
  42. package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
  43. package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
  44. package/bundled-skills/maggie-deployment/SKILL.md +141 -7
  45. package/bundled-skills/maggie-deployment/references/vps.md +74 -1
  46. package/bundled-skills/maggie-design/SKILL.md +154 -0
  47. package/bundled-skills/maggie-feedback/SKILL.md +67 -1
  48. package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
  49. package/bundled-skills/maggie-ops/SKILL.md +37 -0
  50. package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
  51. package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
  52. package/bundled-skills/maggie-service-booking/SKILL.md +82 -0
  53. package/bundled-skills/maggie-social-share/SKILL.md +56 -7
  54. package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
  55. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +152 -0
  56. package/bundled-skills/maggie-template/SKILL.md +57 -3
  57. package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
  58. package/bundled-tools/clis/maggie_booking.py +199 -26
  59. package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
  60. package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
  61. package/bundled-tools/clis/maggie_deployment.py +307 -18
  62. package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
  63. package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
  64. package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
  65. package/bundled-tools/clis/maggie_design.py +17 -0
  66. package/bundled-tools/clis/maggie_design_lint.py +277 -0
  67. package/bundled-tools/clis/maggie_design_verify.py +359 -0
  68. package/bundled-tools/clis/maggie_docs.py +82 -4
  69. package/bundled-tools/clis/maggie_email_localization.py +382 -0
  70. package/bundled-tools/clis/maggie_feedback.py +115 -5
  71. package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
  72. package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
  73. package/bundled-tools/clis/maggie_localization.py +5 -0
  74. package/bundled-tools/clis/maggie_marketing_run.py +272 -0
  75. package/bundled-tools/clis/maggie_marketplace.py +199 -5
  76. package/bundled-tools/clis/maggie_notifications.py +528 -0
  77. package/bundled-tools/clis/maggie_ops.py +87 -0
  78. package/bundled-tools/clis/maggie_palette.py +280 -0
  79. package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
  80. package/bundled-tools/clis/maggie_schedule.py +12 -2
  81. package/bundled-tools/clis/maggie_service_import.py +520 -0
  82. package/bundled-tools/clis/maggie_social_queue.py +442 -0
  83. package/bundled-tools/clis/maggie_social_share.py +46 -9
  84. package/bundled-tools/clis/maggie_template_package.py +1141 -0
  85. package/bundled-tools/runtime/google_ads.py +306 -0
  86. package/bundled-tools/runtime/google_capabilities.py +18 -2
  87. package/bundled-tools/runtime/google_remediation.py +152 -0
  88. package/package.json +1 -1
  89. package/references/browser-inspection.md +25 -0
  90. package/references/google-integrations-runbook.md +54 -0
  91. package/references/provider-adapter-contract.md +33 -0
@@ -0,0 +1,306 @@
1
+ """Read-only Google Ads preflight and Keyword Planner adapter.
2
+
3
+ Every request goes through an injectable transport so tests run offline. The
4
+ default transport uses urllib and is only exercised when an operator runs the
5
+ CLI with credentials. The adapter refuses any request that is not one of the
6
+ three read-only calls below, so it cannot mutate Ads state.
7
+
8
+ Credential values are read from environment variables named by the caller and
9
+ are never returned, logged, or written to a report.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import re
17
+ import urllib.error
18
+ import urllib.parse
19
+ import urllib.request
20
+ from typing import Any, Callable
21
+
22
+ from google_remediation import classify
23
+
24
+
25
+ ADS_SCOPE = "https://www.googleapis.com/auth/adwords"
26
+ ADS_HOST = "googleads.googleapis.com"
27
+ TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo"
28
+ DEFAULT_API_VERSION = os.environ.get("MAGGIE_GOOGLE_ADS_API_VERSION", "v21")
29
+ COMPETITION = {"UNSPECIFIED": "unknown", "UNKNOWN": "unknown", "LOW": "low", "MEDIUM": "medium", "HIGH": "high"}
30
+
31
+ # (method, path regex relative to https://googleads.googleapis.com/<version>/)
32
+ READ_ONLY_ROUTES = (
33
+ ("GET", r"customers:listAccessibleCustomers"),
34
+ ("POST", r"customers/\d{10}/googleAds:search"),
35
+ ("POST", r"customers/\d{10}:generateKeywordIdeas"),
36
+ )
37
+
38
+ Transport = Callable[[str, str, dict[str, str], Any], tuple[int, Any]]
39
+
40
+
41
+ class AdsRequestRefused(ValueError):
42
+ """Raised when a request is not on the read-only allowlist."""
43
+
44
+
45
+ def normalize_customer_id(value: Any) -> str | None:
46
+ """Return a 10-digit customer ID without hyphens, or None when invalid."""
47
+ if not isinstance(value, str):
48
+ return None
49
+ digits = value.replace("-", "").strip()
50
+ return digits if re.fullmatch(r"\d{10}", digits) else None
51
+
52
+
53
+ def urllib_transport(method: str, url: str, headers: dict[str, str], body: Any) -> tuple[int, Any]:
54
+ data = None
55
+ if body is not None:
56
+ if headers.get("content-type") == "application/x-www-form-urlencoded":
57
+ data = urllib.parse.urlencode(body).encode()
58
+ else:
59
+ data = json.dumps(body).encode()
60
+ headers = {**headers, "content-type": "application/json"}
61
+ request = urllib.request.Request(url, data=data, headers=headers, method=method)
62
+ try:
63
+ with urllib.request.urlopen(request, timeout=30) as response:
64
+ return response.status, json.loads(response.read() or b"{}")
65
+ except urllib.error.HTTPError as error:
66
+ try:
67
+ payload = json.loads(error.read() or b"{}")
68
+ except json.JSONDecodeError:
69
+ payload = {}
70
+ return error.code, payload
71
+
72
+
73
+ def _error_signal(payload: Any) -> dict[str, Any]:
74
+ """Extract only error codes/reasons from a provider payload for classification."""
75
+ signal: list[str] = []
76
+ if isinstance(payload, dict):
77
+ error = payload.get("error")
78
+ if isinstance(error, str):
79
+ signal.append(error)
80
+ if isinstance(error, dict):
81
+ signal.append(str(error.get("status") or ""))
82
+ signal.append(str(error.get("message") or "")[:300])
83
+ for detail in error.get("details") or []:
84
+ if not isinstance(detail, dict):
85
+ continue
86
+ signal.append(str(detail.get("reason") or ""))
87
+ for item in detail.get("errors") or []:
88
+ if isinstance(item, dict) and isinstance(item.get("errorCode"), dict):
89
+ signal.extend(f"{value}" for value in item["errorCode"].values())
90
+ if isinstance(payload.get("error_description"), str):
91
+ signal.append(payload["error_description"][:300])
92
+ return {"reason": " ".join(part for part in signal if part)[:300]}
93
+
94
+
95
+ class AdsClient:
96
+ def __init__(self, *, access_token: str, developer_token: str | None, login_customer_id: str | None = None,
97
+ api_version: str = DEFAULT_API_VERSION, transport: Transport | None = None) -> None:
98
+ self._access_token = access_token
99
+ self._developer_token = developer_token
100
+ self._login_customer_id = login_customer_id
101
+ self.api_version = api_version
102
+ self._transport = transport or urllib_transport
103
+
104
+ def _headers(self) -> dict[str, str]:
105
+ headers = {"authorization": f"Bearer {self._access_token}"}
106
+ if self._developer_token:
107
+ headers["developer-token"] = self._developer_token
108
+ if self._login_customer_id:
109
+ headers["login-customer-id"] = self._login_customer_id
110
+ return headers
111
+
112
+ def request(self, method: str, path: str, body: Any = None) -> tuple[int, Any]:
113
+ if not any(method == allowed and re.fullmatch(pattern, path) for allowed, pattern in READ_ONLY_ROUTES):
114
+ raise AdsRequestRefused(f"refused non-allowlisted Google Ads request: {method} {path.split('/')[-1].split(':')[-1]}")
115
+ return self._transport(method, f"https://{ADS_HOST}/{self.api_version}/{path}", self._headers(), body)
116
+
117
+ def token_scopes(self) -> tuple[int, list[str]]:
118
+ # POST body keeps the token out of URLs and access logs.
119
+ status, payload = self._transport("POST", TOKENINFO_URL, {"content-type": "application/x-www-form-urlencoded"}, {"access_token": self._access_token})
120
+ scopes = str(payload.get("scope") or "").split() if isinstance(payload, dict) else []
121
+ return status, scopes
122
+
123
+
124
+ def _check(checks: list[dict[str, Any]], name: str, status: str, detail: str, remediation: dict[str, Any] | None = None) -> None:
125
+ item = {"check": name, "status": status, "detail": detail}
126
+ if remediation:
127
+ item["remediation"] = {key: remediation[key] for key in ("code", "prerequisite", "nextAction", "httpStatus")}
128
+ checks.append(item)
129
+
130
+
131
+ def _failure(status: int, payload: Any) -> dict[str, Any]:
132
+ return classify(_error_signal(payload), status=status, provider="google-ads")
133
+
134
+
135
+ def preflight(*, customer_id: str | None, login_customer_id: str | None, access_token: str | None,
136
+ developer_token: str | None, api_version: str = DEFAULT_API_VERSION,
137
+ transport: Transport | None = None, probe_keyword_planner: bool = False) -> dict[str, Any]:
138
+ """Run ordered, independent read-only checks and return a redacted report."""
139
+ checks: list[dict[str, Any]] = []
140
+ customer = normalize_customer_id(customer_id)
141
+ login = normalize_customer_id(login_customer_id) if login_customer_id else None
142
+ if customer:
143
+ _check(checks, "customer_id_format", "passed", "customer ID is 10 digits")
144
+ else:
145
+ _check(checks, "customer_id_format", "failed", "customer ID must be 10 digits (hyphens allowed in input)",
146
+ {"code": "customer_id_format", "prerequisite": "ads-customer-id", "nextAction": "Pass the target Ads customer ID as 10 digits, e.g. 123-456-7890.", "httpStatus": None})
147
+ if login_customer_id is None:
148
+ _check(checks, "login_customer_id_format", "skipped", "no manager login-customer-id supplied")
149
+ elif login:
150
+ _check(checks, "login_customer_id_format", "passed", "login-customer-id is 10 digits")
151
+ else:
152
+ _check(checks, "login_customer_id_format", "failed", "login-customer-id must be 10 digits",
153
+ {"code": "login_customer_mismatch", "prerequisite": "ads-login-customer-id", "nextAction": "Pass the manager account ID as 10 digits, or omit it for direct access.", "httpStatus": None})
154
+ if developer_token:
155
+ _check(checks, "developer_token_present", "passed", "developer token is present (value not recorded)")
156
+ else:
157
+ _check(checks, "developer_token_present", "failed", "developer token environment variable is empty", classify("DEVELOPER_TOKEN_PARAMETER_MISSING", provider="google-ads"))
158
+ if not access_token:
159
+ _check(checks, "oauth_scope", "failed", "access token environment variable is empty", classify("invalid_grant", provider="google-ads"))
160
+ return _report(checks, customer, login, api_version)
161
+
162
+ client = AdsClient(access_token=access_token, developer_token=developer_token, login_customer_id=login, api_version=api_version, transport=transport)
163
+ status, scopes = client.token_scopes()
164
+ if status != 200:
165
+ _check(checks, "oauth_scope", "failed", "token introspection failed", classify("invalid_grant", status=status, provider="google-ads"))
166
+ return _report(checks, customer, login, api_version)
167
+ if ADS_SCOPE in scopes:
168
+ _check(checks, "oauth_scope", "passed", "token carries the adwords scope")
169
+ else:
170
+ _check(checks, "oauth_scope", "failed", "token lacks the adwords scope", classify("ACCESS_TOKEN_SCOPE_INSUFFICIENT", provider="google-ads"))
171
+ return _report(checks, customer, login, api_version)
172
+ if not developer_token or not customer:
173
+ return _report(checks, customer, login, api_version)
174
+
175
+ status, payload = client.request("GET", "customers:listAccessibleCustomers")
176
+ accessible = sorted(name.split("/")[-1] for name in (payload.get("resourceNames") or [])) if status == 200 and isinstance(payload, dict) else []
177
+ if status != 200:
178
+ _check(checks, "accessible_customers", "failed", "listAccessibleCustomers was rejected", _failure(status, payload))
179
+ return _report(checks, customer, login, api_version)
180
+ direct = customer in accessible
181
+ via_manager = bool(login and login in accessible)
182
+ if direct or via_manager:
183
+ _check(checks, "accessible_customers", "passed", f"{len(accessible)} accessible customer(s); target reachable {'directly' if direct else 'via manager'}")
184
+ else:
185
+ _check(checks, "accessible_customers", "failed", f"{len(accessible)} accessible customer(s); target not among them",
186
+ classify("USER_PERMISSION_DENIED" if not login else "USER_PERMISSION_DENIED manager", provider="google-ads"))
187
+ return _report(checks, customer, login, api_version)
188
+
189
+ currency = None
190
+ if via_manager and not direct:
191
+ query = f"SELECT customer_client.id, customer_client.manager, customer_client.status FROM customer_client WHERE customer_client.id = {customer}"
192
+ status, payload = client.request("POST", f"customers/{login}/googleAds:search", {"query": query})
193
+ rows = (payload.get("results") or []) if status == 200 and isinstance(payload, dict) else []
194
+ if status == 200 and rows:
195
+ _check(checks, "account_hierarchy", "passed", "target is linked under the manager account")
196
+ else:
197
+ _check(checks, "account_hierarchy", "failed", "target is not linked under the manager account",
198
+ _failure(status, payload) if status != 200 else classify("login-customer-id", provider="google-ads"))
199
+ return _report(checks, customer, login, api_version)
200
+ else:
201
+ _check(checks, "account_hierarchy", "skipped", "direct access; no manager hierarchy needed")
202
+ status, payload = client.request("POST", f"customers/{customer}/googleAds:search",
203
+ {"query": "SELECT customer.id, customer.currency_code, customer.manager, customer.test_account FROM customer LIMIT 1"})
204
+ if status == 200 and isinstance(payload, dict) and payload.get("results"):
205
+ info = payload["results"][0].get("customer") or {}
206
+ currency = info.get("currencyCode")
207
+ if info.get("manager"):
208
+ _check(checks, "customer_readable", "failed", "target is a manager account; Keyword Planner needs a client account",
209
+ {"code": "manager_account_target", "prerequisite": "ads-client-account", "nextAction": "Select a client (non-manager) customer ID as the Keyword Planner target.", "httpStatus": None})
210
+ return _report(checks, customer, login, api_version, currency)
211
+ _check(checks, "customer_readable", "passed", "customer row readable" + (" (test account)" if info.get("testAccount") else ""))
212
+ else:
213
+ _check(checks, "customer_readable", "failed", "customer query was rejected", _failure(status, payload))
214
+ return _report(checks, customer, login, api_version)
215
+
216
+ if probe_keyword_planner:
217
+ status, payload = client.request("POST", f"customers/{customer}:generateKeywordIdeas",
218
+ {"keywordSeed": {"keywords": ["test"]}, "pageSize": 1, "includeAdultKeywords": False})
219
+ if status == 200:
220
+ _check(checks, "keyword_planner", "passed", "one-result KeywordPlanIdeaService probe succeeded")
221
+ else:
222
+ _check(checks, "keyword_planner", "failed", "KeywordPlanIdeaService probe was rejected", _failure(status, payload))
223
+ else:
224
+ _check(checks, "keyword_planner", "ready", "all prerequisites passed; pass --probe-keyword-planner to spend one read-only request")
225
+ return _report(checks, customer, login, api_version, currency)
226
+
227
+
228
+ def _report(checks: list[dict[str, Any]], customer: str | None, login: str | None, api_version: str, currency: str | None = None) -> dict[str, Any]:
229
+ failed = [item for item in checks if item["status"] == "failed"]
230
+ return {
231
+ "schemaVersion": "maggie-google-ads-preflight.v1",
232
+ "passed": not failed,
233
+ "apiVersion": api_version,
234
+ "customerId": customer,
235
+ "loginCustomerId": login,
236
+ "currency": currency,
237
+ "checks": checks,
238
+ "firstFailure": failed[0]["check"] if failed else None,
239
+ "nextAction": failed[0]["remediation"]["nextAction"] if failed and failed[0].get("remediation") else None,
240
+ "mutationsAllowed": False,
241
+ }
242
+
243
+
244
+ def _micros(value: Any) -> float | None:
245
+ try:
246
+ return round(int(value) / 1_000_000, 2)
247
+ except (TypeError, ValueError):
248
+ return None
249
+
250
+
251
+ def normalize_ideas(results: list[Any], currency: str | None) -> list[dict[str, Any]]:
252
+ ideas = []
253
+ for item in results:
254
+ if not isinstance(item, dict) or not isinstance(item.get("text"), str):
255
+ continue
256
+ metrics = item.get("keywordIdeaMetrics") or {}
257
+ searches = metrics.get("avgMonthlySearches")
258
+ ideas.append({
259
+ "text": item["text"],
260
+ "avgMonthlySearches": int(searches) if str(searches or "").isdigit() else None,
261
+ "competition": COMPETITION.get(str(metrics.get("competition") or "UNKNOWN"), "unknown"),
262
+ "competitionIndex": int(metrics["competitionIndex"]) if str(metrics.get("competitionIndex") or "").isdigit() else None,
263
+ "lowTopOfPageBid": _micros(metrics.get("lowTopOfPageBidMicros")),
264
+ "highTopOfPageBid": _micros(metrics.get("highTopOfPageBidMicros")),
265
+ "currency": currency,
266
+ })
267
+ return ideas
268
+
269
+
270
+ def keyword_ideas(*, customer_id: str, login_customer_id: str | None, access_token: str, developer_token: str,
271
+ keywords: list[str], url: str | None = None, language: str | None = None, geo_targets: list[str] | None = None,
272
+ currency: str | None = None, max_pages: int = 1, page_size: int = 100,
273
+ api_version: str = DEFAULT_API_VERSION, transport: Transport | None = None) -> dict[str, Any]:
274
+ customer = normalize_customer_id(customer_id)
275
+ if not customer:
276
+ raise ValueError("customer ID must be 10 digits")
277
+ if not keywords and not url:
278
+ raise ValueError("provide at least one seed keyword or a seed URL")
279
+ login = normalize_customer_id(login_customer_id) if login_customer_id else None
280
+ client = AdsClient(access_token=access_token, developer_token=developer_token, login_customer_id=login, api_version=api_version, transport=transport)
281
+ body: dict[str, Any] = {"pageSize": max(1, min(page_size, 1000)), "includeAdultKeywords": False, "keywordPlanNetwork": "GOOGLE_SEARCH"}
282
+ if keywords and url:
283
+ body["keywordAndUrlSeed"] = {"keywords": keywords, "url": url}
284
+ elif keywords:
285
+ body["keywordSeed"] = {"keywords": keywords}
286
+ else:
287
+ body["urlSeed"] = {"url": url}
288
+ if language:
289
+ body["language"] = language if language.startswith("languageConstants/") else f"languageConstants/{language}"
290
+ if geo_targets:
291
+ body["geoTargetConstants"] = [item if item.startswith("geoTargetConstants/") else f"geoTargetConstants/{item}" for item in geo_targets]
292
+ results: list[Any] = []
293
+ pages = 0
294
+ while pages < max(1, max_pages):
295
+ status, payload = client.request("POST", f"customers/{customer}:generateKeywordIdeas", body)
296
+ pages += 1
297
+ if status != 200:
298
+ return {"schemaVersion": "maggie-google-ads-keyword-ideas.v1", "passed": False, "customerId": customer, "ideas": normalize_ideas(results, currency),
299
+ "pages": pages, "remediation": {key: value for key, value in _failure(status, payload).items() if key != "provider"}, "mutationsAllowed": False}
300
+ results.extend(payload.get("results") or [])
301
+ token = payload.get("nextPageToken")
302
+ if not token:
303
+ break
304
+ body = {**body, "pageToken": token}
305
+ return {"schemaVersion": "maggie-google-ads-keyword-ideas.v1", "passed": True, "customerId": customer, "pages": pages,
306
+ "ideas": normalize_ideas(results, currency), "mutationsAllowed": False}
@@ -6,6 +6,8 @@ import re
6
6
  from typing import Any
7
7
  from urllib.parse import urlsplit
8
8
 
9
+ from google_remediation import classify as classify_failure
10
+
9
11
 
10
12
  SCHEMA_VERSION = "maggie-google-capability-report.v2"
11
13
  CAPABILITY_NAMES = ("read", "report", "edit", "publish")
@@ -172,7 +174,7 @@ def validate_report(report: Any) -> dict[str, Any]:
172
174
  errors.append(f"{prefix} must be an object")
173
175
  continue
174
176
  required = {"provider", "resource", "activeAccount", "target", "authMode", "scopes", "productRole", "evidence", "capabilities", "nextAction"}
175
- unknown = sorted(set(row) - required)
177
+ unknown = sorted(set(row) - required - {"failure"})
176
178
  if unknown:
177
179
  errors.append(f"{prefix} contains unsupported fields")
178
180
  provider = row.get("provider")
@@ -253,6 +255,15 @@ def validate_report(report: Any) -> dict[str, Any]:
253
255
  errors.append(f"{prefix}.evidence.adsPrerequisites requires developer token and customer ID presence")
254
256
  elif prerequisites is not None:
255
257
  errors.append(f"{prefix}.evidence.adsPrerequisites is only valid for google-ads")
258
+ remediation = None
259
+ failure = row.get("failure")
260
+ if failure is not None:
261
+ if not isinstance(failure, dict) or set(failure) - {"code", "reason", "httpStatus"} or not any(failure.get(key) for key in ("code", "reason", "httpStatus")):
262
+ errors.append(f"{prefix}.failure must contain only code, reason, and httpStatus")
263
+ elif any(not isinstance(failure.get(key), str) or len(failure[key]) > 300 for key in ("code", "reason") if key in failure):
264
+ errors.append(f"{prefix}.failure.code and reason must be bounded strings")
265
+ else:
266
+ remediation = classify_failure(failure, provider=provider)
256
267
  normalized.append({
257
268
  "provider": provider,
258
269
  "resource": resource,
@@ -269,7 +280,8 @@ def validate_report(report: Any) -> dict[str, Any]:
269
280
  "writeTested": isinstance(evidence.get("writeTest"), dict),
270
281
  },
271
282
  "capabilities": safe_capabilities,
272
- "nextAction": row.get("nextAction"),
283
+ "nextAction": remediation["nextAction"] if remediation else row.get("nextAction"),
284
+ **({"remediation": remediation} if remediation else {}),
273
285
  })
274
286
 
275
287
  return {
@@ -277,5 +289,9 @@ def validate_report(report: Any) -> dict[str, Any]:
277
289
  "passed": not errors,
278
290
  "errors": sorted(set(errors)),
279
291
  "providerResults": normalized,
292
+ "remediation": [
293
+ {"provider": item["provider"], "resource": item["resource"], **{key: item["remediation"][key] for key in ("code", "prerequisite", "nextAction", "httpStatus")}}
294
+ for item in normalized if item.get("remediation")
295
+ ],
280
296
  "mutationsAllowed": False,
281
297
  }
@@ -0,0 +1,152 @@
1
+ """Map redacted Google provider failures to one prerequisite and one safe action.
2
+
3
+ The classifier is deterministic and offline. It reads an error code, HTTP
4
+ status, and short message only to choose a rule; it never returns the input
5
+ message, so tokens or raw provider payloads cannot leak into a report.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Any
12
+
13
+
14
+ # Ordered: the first matching rule wins, so specific product errors precede
15
+ # generic HTTP-status fallbacks.
16
+ RULES: tuple[dict[str, Any], ...] = (
17
+ {
18
+ "code": "adc_invalid_scope",
19
+ "patterns": (r"invalid_scope",),
20
+ "prerequisite": "oauth-scope-request",
21
+ "nextAction": "Re-run the ADC login or impersonation command requesting only the exact allowlisted scope for this provider (see the scope allowlist); do not reuse a token minted without that scope.",
22
+ },
23
+ {
24
+ "code": "token_scope_insufficient",
25
+ "patterns": (r"ACCESS_TOKEN_SCOPE_INSUFFICIENT", r"insufficient(?:_| )(?:authentication )?scopes?", r"Request had insufficient authentication scopes"),
26
+ "prerequisite": "oauth-granted-scope",
27
+ "nextAction": "Mint a new short-lived token that includes the provider's required scope, then repeat the same read-only request.",
28
+ },
29
+ {
30
+ "code": "redirect_uri_mismatch",
31
+ "patterns": (r"redirect_uri_mismatch",),
32
+ "prerequisite": "oauth-client-redirect-uri",
33
+ "nextAction": "Add the exact callback URL used by this connect path to the OAuth client's authorized redirect URIs, or switch to the client that already lists it.",
34
+ },
35
+ {
36
+ "code": "oauth_consent_not_configured",
37
+ "patterns": (r"consent screen", r"OAuth consent", r"access_denied.*(?:not completed|verification)", r"app (?:is )?(?:not verified|blocked)", r"org_internal"),
38
+ "prerequisite": "oauth-consent-configuration",
39
+ "nextAction": "Configure the OAuth consent screen (user type, test users or verification status) in the Cloud project that owns the client, then reconnect.",
40
+ },
41
+ {
42
+ "code": "developer_token_prohibited",
43
+ "patterns": (r"DEVELOPER_TOKEN_PROHIBITED", r"developer token is not allowed with the project", r"DEVELOPER_TOKEN_(?:MISMATCH|INVALID_FOR_PROJECT)"),
44
+ "prerequisite": "ads-developer-token-project-pairing",
45
+ "nextAction": "Use the OAuth client from the Cloud project already paired with this developer token, or request access for the new project; one developer token is bound to one project.",
46
+ },
47
+ {
48
+ "code": "developer_token_not_approved",
49
+ "patterns": (r"DEVELOPER_TOKEN_NOT_APPROVED", r"only approved for use with test accounts", r"DEVELOPER_TOKEN_NOT_ON_ALLOWLIST"),
50
+ "prerequisite": "ads-developer-token-access-level",
51
+ "nextAction": "Apply for Basic or Standard access for the developer token, or run against a Google Ads test account until it is approved.",
52
+ },
53
+ {
54
+ "code": "developer_token_missing",
55
+ "patterns": (r"DEVELOPER_TOKEN_PARAMETER_MISSING", r"developer[- ]token (?:header )?(?:is )?(?:missing|required)", r"DEVELOPER_TOKEN_(?:INVALID|MISSING)"),
56
+ "prerequisite": "ads-developer-token",
57
+ "nextAction": "Install the developer token in the host secret store and send it as the developer-token header; record only its presence.",
58
+ },
59
+ {
60
+ "code": "login_customer_mismatch",
61
+ "patterns": (r"login[-_ ]customer[-_ ]id", r"LOGIN_CUSTOMER_ID", r"USER_PERMISSION_DENIED.*manager"),
62
+ "prerequisite": "ads-login-customer-id",
63
+ "nextAction": "Set login-customer-id to the manager account that links to the target customer (digits only), or omit it when the user has direct access.",
64
+ },
65
+ {
66
+ "code": "ads_user_permission_denied",
67
+ "patterns": (r"USER_PERMISSION_DENIED", r"CUSTOMER_NOT_FOUND", r"NOT_ADS_USER"),
68
+ "prerequisite": "ads-account-access",
69
+ "nextAction": "Grant the authenticated Google user access to the target Ads customer (or its manager), then repeat listAccessibleCustomers before any report.",
70
+ },
71
+ {
72
+ "code": "ga4_property_membership",
73
+ "patterns": (r"does not have sufficient permissions for this property", r"User does not have (?:sufficient )?permission.*propert", r"properties/\d+.*PERMISSION_DENIED"),
74
+ "prerequisite": "ga4-property-membership",
75
+ "nextAction": "Add the principal (user or service account email) to the GA4 property with Viewer or higher in Property Access Management.",
76
+ },
77
+ {
78
+ "code": "gsc_property_membership",
79
+ "patterns": (r"User does not have sufficient permission for site", r"sites/.*PERMISSION_DENIED"),
80
+ "prerequisite": "gsc-property-membership",
81
+ "nextAction": "Add the principal as a user of the exact Search Console property (URL-prefix or domain) being queried.",
82
+ },
83
+ {
84
+ "code": "api_disabled",
85
+ "patterns": (r"SERVICE_DISABLED", r"has not been used in project .* or it is disabled", r"API .*is disabled", r"accessNotConfigured"),
86
+ "prerequisite": "cloud-api-enablement",
87
+ "nextAction": "Enable this provider's API in the Cloud project that owns the OAuth client or service account, wait a few minutes, then retry.",
88
+ },
89
+ {
90
+ "code": "quota_exhausted",
91
+ "patterns": (r"RESOURCE_EXHAUSTED", r"RATE_LIMIT_EXCEEDED", r"quota (?:exceeded|exhausted)", r"rateLimitExceeded", r"RESOURCE_TEMPORARILY_EXHAUSTED"),
92
+ "prerequisite": "provider-quota",
93
+ "nextAction": "Stop retrying, wait for the quota window or request more quota; record the request as blocked rather than repeating it.",
94
+ },
95
+ {
96
+ "code": "iam_token_creator_missing",
97
+ "patterns": (r"iam\.serviceAccounts\.getAccessToken", r"serviceAccountTokenCreator"),
98
+ "prerequisite": "service-account-impersonation",
99
+ "nextAction": "Grant the caller roles/iam.serviceAccountTokenCreator on the target service account, then re-run the impersonation command.",
100
+ },
101
+ {
102
+ "code": "product_role_denied",
103
+ "patterns": (r"PERMISSION_DENIED", r"The caller does not have permission", r"forbidden"),
104
+ "prerequisite": "product-role",
105
+ "nextAction": "Grant the principal the needed product-level role on the selected resource; Cloud IAM alone does not grant product access.",
106
+ },
107
+ {
108
+ "code": "invalid_grant",
109
+ "patterns": (r"invalid_grant", r"Token has been expired or revoked", r"UNAUTHENTICATED", r"invalid_token"),
110
+ "prerequisite": "oauth-credential-validity",
111
+ "nextAction": "Reconnect the account to obtain a fresh credential; the stored token is expired or revoked.",
112
+ },
113
+ )
114
+
115
+ STATUS_FALLBACK = {
116
+ 401: RULES[-1],
117
+ 403: next(rule for rule in RULES if rule["code"] == "product_role_denied"),
118
+ 429: next(rule for rule in RULES if rule["code"] == "quota_exhausted"),
119
+ }
120
+
121
+ UNKNOWN = {
122
+ "code": "unclassified",
123
+ "prerequisite": "unknown",
124
+ "nextAction": "Record the provider error code and HTTP status (no payload) and check the prerequisites in order: scope, consent, API enablement, product access, quota.",
125
+ }
126
+
127
+
128
+ def classify(error: Any = None, *, status: int | None = None, provider: str | None = None) -> dict[str, Any]:
129
+ """Return {code, prerequisite, nextAction, provider, httpStatus}; never echoes input text."""
130
+ if isinstance(error, dict):
131
+ status = status if status is not None else error.get("httpStatus", error.get("status"))
132
+ parts = [error.get(key) for key in ("code", "reason", "status", "message")]
133
+ text = " ".join(str(item) for item in parts if isinstance(item, (str, int)))
134
+ provider = provider or error.get("provider")
135
+ else:
136
+ text = str(error or "")
137
+ text = text[:2000]
138
+ rule = None
139
+ for candidate in RULES:
140
+ if any(re.search(pattern, text, re.IGNORECASE) for pattern in candidate["patterns"]):
141
+ rule = candidate
142
+ break
143
+ if rule is None and isinstance(status, int):
144
+ rule = STATUS_FALLBACK.get(status)
145
+ rule = rule or UNKNOWN
146
+ return {
147
+ "code": rule["code"],
148
+ "prerequisite": rule["prerequisite"],
149
+ "nextAction": rule["nextAction"],
150
+ "provider": provider if isinstance(provider, str) else None,
151
+ "httpStatus": status if isinstance(status, int) else None,
152
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.7.47",
3
+ "version": "0.7.49",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -75,3 +75,28 @@ appearance. For every stateful component capture state A and state B and
75
75
  record the trigger and transition. Keep screenshots and extracted data under
76
76
  the target's namespaced research root so multiple URLs cannot overwrite one
77
77
  another.
78
+
79
+ ## Network interception and write safety
80
+
81
+ Any skill that drives a browser against a running app (QA, clone, design
82
+ review) must assume the app may talk to a real API. A local development build
83
+ is often configured with the production API base URL, so a click in local QA
84
+ can change live accounts.
85
+
86
+ - Do not rely on page-level stubs such as overriding `window.fetch` from the
87
+ console or an injected script. Bundled API clients usually capture `fetch`
88
+ at module load, use `XMLHttpRequest`, or run in a service worker, so the
89
+ override never sees their requests.
90
+ - Intercept at the network layer instead: Playwright `page.route()` /
91
+ `context.route()`, Puppeteer request interception, or CDP `Fetch.enable`.
92
+ Deny by default every non-GET request to a production origin and let only
93
+ explicitly listed reads through.
94
+ - Verify the interception before any write flow: trigger one harmless request
95
+ and confirm in the browser's network log that it was intercepted, not sent.
96
+ - Without network-layer interception, run write flows only against a
97
+ non-production API, or after the user gives explicit consent naming the
98
+ account and the data that will change. Record a backup or restore path first.
99
+
100
+ `maggie qa start --api-base URL --production-api URL` records the API target.
101
+ Scenarios marked `"mutates": true` are blocked against a production API unless
102
+ `--allow-production-writes` is given. See the `maggie-qa-workflow` skill.
@@ -119,6 +119,60 @@ selected `resource`. A report that only proves account discovery, or comes from
119
119
  the wrong browser account/property, is rejected before a write workflow can use
120
120
  it.
121
121
 
122
+ ## Failure remediation
123
+
124
+ A failed check must name the one prerequisite that failed, not a generic
125
+ "access denied". Put only the provider error code/reason and HTTP status in the
126
+ row's optional `failure` field; the validator returns `remediation` with a
127
+ stable `code`, the failed `prerequisite`, and one safe `nextAction`. It never
128
+ echoes the input text. `maggie ops google-diagnose` applies the same mapping to
129
+ one error.
130
+
131
+ | Signal | Failed prerequisite | Safe next action |
132
+ |---|---|---|
133
+ | `invalid_scope` (ADC) | OAuth scope request | Re-run the ADC/impersonation command with the exact allowlisted scope |
134
+ | `ACCESS_TOKEN_SCOPE_INSUFFICIENT` | Granted token scope | Mint a new token that includes the provider scope |
135
+ | `redirect_uri_mismatch` | OAuth client redirect URI | Add the exact callback URL to the client, or use the client that lists it |
136
+ | Consent screen not configured / app not verified | OAuth consent configuration | Configure user type, test users, or verification in the client's project |
137
+ | `SERVICE_DISABLED` / `accessNotConfigured` | Cloud API enablement | Enable the API in the project that owns the client or service account |
138
+ | `RESOURCE_EXHAUSTED` / HTTP 429 | Provider quota | Stop retrying; wait for the window or request quota; record `blocked` |
139
+ | `DEVELOPER_TOKEN_PARAMETER_MISSING` | Ads developer token | Install the token in the host secret store; send the `developer-token` header |
140
+ | `DEVELOPER_TOKEN_NOT_APPROVED` | Ads token access level | Apply for Basic/Standard access, or use a test account |
141
+ | `DEVELOPER_TOKEN_PROHIBITED` | Developer token/project pairing | Use the OAuth client from the paired Cloud project, or request access for the new project |
142
+ | `USER_PERMISSION_DENIED` with a manager | `login-customer-id` | Set it to the manager that links the target, or omit it for direct access |
143
+ | `USER_PERMISSION_DENIED` / `CUSTOMER_NOT_FOUND` | Ads account access | Grant the user access to the customer or its manager |
144
+ | GA4 "insufficient permissions for this property" | GA4 property membership | Add the principal to the property with Viewer or higher |
145
+ | `iam.serviceAccounts.getAccessToken` denied | Service-account impersonation | Grant `roles/iam.serviceAccountTokenCreator` on the target service account |
146
+ | Other `PERMISSION_DENIED` / HTTP 403 | Product role | Grant the product-level role; Cloud IAM alone is insufficient |
147
+ | `invalid_grant` / HTTP 401 | Credential validity | Reconnect; the stored token is expired or revoked |
148
+
149
+ ## Google Ads read-only preflight and Keyword Planner
150
+
151
+ `maggie ops google-ads-preflight` checks, in order and independently: customer
152
+ and `login-customer-id` format (10 digits; hyphens stripped), developer-token
153
+ presence (environment variable name only), the `adwords` scope via token
154
+ introspection (token sent in a POST body, never a URL),
155
+ `customers:listAccessibleCustomers`, the manager hierarchy through
156
+ `customer_client` when a manager is used, a `customer` read (currency, manager,
157
+ test-account flags), and Keyword Planner readiness. The Keyword Planner check
158
+ reports `ready` without spending a request unless `--probe-keyword-planner` is
159
+ passed, which sends one one-result `generateKeywordIdeas` call. Every failure
160
+ carries the same `remediation` mapping as above.
161
+
162
+ `maggie ops google-ads-keyword-ideas` sends read-only `generateKeywordIdeas`
163
+ requests (keyword, URL, or keyword+URL seed; language and geo-target constants;
164
+ bounded pagination) and normalizes each idea to `text`, `avgMonthlySearches`,
165
+ `competition`, `competitionIndex`, and `lowTopOfPageBid`/`highTopOfPageBid`
166
+ converted from micros into the customer currency.
167
+
168
+ The adapter refuses any request outside `listAccessibleCustomers`,
169
+ `googleAds:search`, and `generateKeywordIdeas`, so it cannot mutate campaigns,
170
+ budgets, or keywords. The REST API version defaults to `v21` and can be set with
171
+ `--api-version` or `MAGGIE_GOOGLE_ADS_API_VERSION`; check Google's sunset
172
+ schedule and use a currently supported version. Keyword Planner volumes are
173
+ estimates for planning and require an approved developer token on a real
174
+ account; test accounts return no metrics.
175
+
122
176
  ## Capability matrix
123
177
 
124
178
  Every provider/resource row must expose this shape:
@@ -150,6 +150,39 @@ image-processing contract: optimisation may use Cloudflare Images
150
150
  transformations, Astro build tooling or a provider-specific implementation
151
151
  behind the same interface.
152
152
 
153
+ ## OAuth connections
154
+
155
+ A provider connection is only as reliable as its weakest connect path:
156
+
157
+ - Each adapter declares one canonical OAuth scope set. Every client that
158
+ starts the flow (dashboard, mobile app, CLI) requests exactly that set.
159
+ LinkedIn invalidates a member's earlier tokens when a later sign-in asks for
160
+ a different scope set, so a mobile app that omits `email` silently revokes
161
+ the dashboard's token while the database still says `active`.
162
+ - Prefer one OAuth client per product. When a client ID is shared with other
163
+ products or callbacks, record it as `client_shared_with`; their sign-ins can
164
+ revoke this product's tokens unless they use the identical scope set.
165
+ - Treat stored connection status as a cache. A token that the provider has
166
+ revoked, or that the serving host cannot decrypt, is `needs_reconnect`
167
+ before anything is scheduled on it — not when a post fails.
168
+ - Provider media URLs (for example `media.licdn.com` with an `e=` expiry) are
169
+ signed and expire. Cache the bytes in owned storage or refresh on readback.
170
+
171
+ `maggie tool maggie_social_share.py adapters` and `connections` check these
172
+ rules from a redacted JSON manifest; see the Social Share skill for shapes.
173
+
174
+ ## Structured model output
175
+
176
+ Keep structured-output schemas small. Gemini rejected a JSON schema whose enum
177
+ had 125 values with `400 INVALID_ARGUMENT`, and the caller silently fell back
178
+ for every request. For a large vocabulary (styles, categories, catalogue IDs):
179
+
180
+ 1. ask for a free string (optionally with a short list of examples in the
181
+ prompt) instead of a large `enum`;
182
+ 2. validate the returned value server-side against the authoritative list;
183
+ 3. treat a schema rejection or an unknown value as a visible, counted failure —
184
+ never a silent fallback to a default.
185
+
153
186
  ## Adding a provider
154
187
 
155
188
  1. Implement the relevant interface in `src/lib/providers/`.