From 58daf273719aec7e7c95e68d9ea5ba6b21de8505 Mon Sep 17 00:00:00 2001 From: Daniel Klose Date: Tue, 28 Apr 2026 15:32:21 +0200 Subject: [PATCH] feat(cli): install Claude orchestration rules globally (closes #25) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `adloop install-rules`, `update-rules`, and `uninstall-rules` subcommands that manage AdLoop's orchestration rules + slash commands at the user level (~/.claude/), so Claude Code sessions outside this repo inherit the same safety patterns, GAQL reference, and 43-tool guidance that Cursor users get for free via workspace rules. The init wizard now detects Claude installations and offers to install the rules at the end of setup. Two install modes: - inline (default): full rules embedded in ~/.claude/CLAUDE.md between sentinel comments. Reliable; ~10K tokens loaded every Claude Code session. - lazy (--lazy): small directive in CLAUDE.md pointing at ~/.claude/rules/adloop.md. Cheaper baseline cost; the LLM reads the full rules only when AdLoop tools are in scope. Idempotency is handled by sentinel comments ` ... ` that include the AdLoop version, so update-rules can detect drift across versions and replace stale blocks cleanly. uninstall-rules only touches the managed block and adloop-* prefixed slash commands — user-authored content is never modified. Claude Desktop has no programmatic rules location, so it returns manual paste-into-claude.ai instructions instead. Architecture: - src/adloop/rules/{adloop.md, commands/*.md} — bundled with the wheel via uv_build's default file inclusion (verified by building locally). - scripts/sync-rules.py now writes three targets: in-repo Cursor format source -> .claude/rules/adloop.md (in-repo Claude Code) + src/adloop/rules/adloop.md (bundled) + .claude/commands/ -> src/adloop/rules/commands/. Single source of truth maintained. - src/adloop/rules_install.py — pure module with detect_clients, install_rules, update_rules, uninstall_rules. Frontmatter is stripped for inline mode (CLAUDE.md is itself a rules file) but preserved for the lazy-mode sibling file. Tests: 26 new in tests/test_rules_install.py covering detection, inline/lazy install, idempotency, frontmatter handling, mode preservation on update, version-mismatched block replacement, namespaced-command isolation during uninstall, and CLI entry-point smoke tests. Full suite: 184 passed. --- README.md | 17 +- scripts/sync-rules.py | 72 ++- src/adloop/__init__.py | 32 +- src/adloop/cli.py | 115 +++- src/adloop/rules/__init__.py | 6 + src/adloop/rules/adloop.md | 580 ++++++++++++++++++ .../rules/commands/analyze-performance.md | 32 + src/adloop/rules/commands/budget-plan.md | 34 + src/adloop/rules/commands/create-ad.md | 42 ++ src/adloop/rules/commands/create-campaign.md | 46 ++ .../rules/commands/diagnose-tracking.md | 44 ++ .../rules/commands/optimize-campaign.md | 52 ++ src/adloop/rules_install.py | 470 ++++++++++++++ tests/test_rules_install.py | 446 ++++++++++++++ 14 files changed, 1970 insertions(+), 18 deletions(-) create mode 100644 src/adloop/rules/__init__.py create mode 100644 src/adloop/rules/adloop.md create mode 100644 src/adloop/rules/commands/analyze-performance.md create mode 100644 src/adloop/rules/commands/budget-plan.md create mode 100644 src/adloop/rules/commands/create-ad.md create mode 100644 src/adloop/rules/commands/create-campaign.md create mode 100644 src/adloop/rules/commands/diagnose-tracking.md create mode 100644 src/adloop/rules/commands/optimize-campaign.md create mode 100644 src/adloop/rules_install.py create mode 100644 tests/test_rules_install.py diff --git a/README.md b/README.md index 3674688..93a27ce 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,22 @@ Or add to your project's `.mcp.json`: } ``` -Then copy `.claude/rules/adloop.md` and `.claude/commands/` from this repo into your project for orchestration rules and slash commands. +Then install the orchestration rules + slash commands globally so every Claude Code session inherits them: + +```bash +adloop install-rules +``` + +This writes a managed block to `~/.claude/CLAUDE.md` and copies the slash commands (prefixed `adloop-*`) into `~/.claude/commands/`. The block is delimited by sentinel comments so it's safe to run multiple times — re-running just refreshes the content. Two install modes: + +- **inline** (default) — full rules embedded in `~/.claude/CLAUDE.md`. Reliable but adds ~10K tokens to every Claude Code session. +- **lazy** (`adloop install-rules --lazy`) — small directive in `CLAUDE.md` pointing at `~/.claude/rules/adloop.md`. Cheaper baseline cost; the LLM reads the rules file only when AdLoop tools are in scope. + +To refresh after upgrading AdLoop: `adloop update-rules`. To remove cleanly: `adloop uninstall-rules` — only the managed block and `adloop-*` commands are touched, never your own content. + +If you'd rather manage things by hand instead, copy `.claude/rules/adloop.md` and `.claude/commands/` from this repo into your project's `.claude/` directory. + +**Claude Desktop / claude.ai** has no programmatic rules location. Run `adloop install-rules` and it will print the rules content for you to paste into Project settings → Custom instructions on claude.ai. diff --git a/scripts/sync-rules.py b/scripts/sync-rules.py index 7f72654..4427959 100755 --- a/scripts/sync-rules.py +++ b/scripts/sync-rules.py @@ -1,18 +1,34 @@ #!/usr/bin/env python3 -"""Sync orchestration rules from Cursor format to Claude Code format. +"""Sync orchestration rules + slash commands into all canonical locations. -Reads .cursor/rules/adloop.mdc (canonical source), strips Cursor-specific -frontmatter, prepends Claude Code frontmatter, and writes to -.claude/rules/adloop.md. +Reads ``.cursor/rules/adloop.mdc`` (the canonical, Cursor-flavoured source), +strips Cursor frontmatter, prepends Claude Code frontmatter, and writes the +result to: -Run this after editing adloop.mdc to keep both files in sync. + - ``.claude/rules/adloop.md`` — for Claude Code in *this* repo + - ``src/adloop/rules/adloop.md`` — bundled with the wheel for + ``adloop install-rules`` to install + globally on user machines + +Also copies ``.claude/commands/*.md`` into ``src/adloop/rules/commands/`` so +slash commands ship with the package. + +Run this after editing ``adloop.mdc`` or any command file. """ +from __future__ import annotations + +import shutil from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent CURSOR_RULES = REPO_ROOT / ".cursor" / "rules" / "adloop.mdc" CLAUDE_RULES = REPO_ROOT / ".claude" / "rules" / "adloop.md" +CLAUDE_COMMANDS_DIR = REPO_ROOT / ".claude" / "commands" + +PACKAGE_RULES_DIR = REPO_ROOT / "src" / "adloop" / "rules" +PACKAGE_RULES = PACKAGE_RULES_DIR / "adloop.md" +PACKAGE_COMMANDS_DIR = PACKAGE_RULES_DIR / "commands" CLAUDE_FRONTMATTER = """\ --- @@ -29,17 +45,55 @@ def extract_body(content: str) -> str: return content[end + 3:].lstrip("\n") -def main() -> None: +def sync_rules() -> str: + """Sync the rules content. Returns the rendered Claude-format content.""" if not CURSOR_RULES.exists(): raise FileNotFoundError(f"Canonical rules not found: {CURSOR_RULES}") body = extract_body(CURSOR_RULES.read_text()) + rendered = CLAUDE_FRONTMATTER + "\n" + body CLAUDE_RULES.parent.mkdir(parents=True, exist_ok=True) - CLAUDE_RULES.write_text(CLAUDE_FRONTMATTER + "\n" + body) + CLAUDE_RULES.write_text(rendered) + + PACKAGE_RULES_DIR.mkdir(parents=True, exist_ok=True) + PACKAGE_RULES.write_text(rendered) + + return rendered + + +def sync_commands() -> int: + """Mirror .claude/commands/*.md into the package. Returns count copied.""" + if not CLAUDE_COMMANDS_DIR.is_dir(): + return 0 + + PACKAGE_COMMANDS_DIR.mkdir(parents=True, exist_ok=True) + + # Remove stale commands that no longer exist in the source + source_names = {p.name for p in CLAUDE_COMMANDS_DIR.glob("*.md")} + for stale in PACKAGE_COMMANDS_DIR.glob("*.md"): + if stale.name not in source_names: + stale.unlink() + + count = 0 + for cmd in CLAUDE_COMMANDS_DIR.glob("*.md"): + shutil.copy2(cmd, PACKAGE_COMMANDS_DIR / cmd.name) + count += 1 + return count + + +def main() -> None: + sync_rules() + cmd_count = sync_commands() - print(f"Synced: {CURSOR_RULES.relative_to(REPO_ROOT)}") - print(f" -> {CLAUDE_RULES.relative_to(REPO_ROOT)}") + print(f"Synced rules: {CURSOR_RULES.relative_to(REPO_ROOT)}") + print(f" -> {CLAUDE_RULES.relative_to(REPO_ROOT)}") + print(f" -> {PACKAGE_RULES.relative_to(REPO_ROOT)}") + print( + f"Synced {cmd_count} command(s):" + f" {CLAUDE_COMMANDS_DIR.relative_to(REPO_ROOT)}" + ) + print(f" -> {PACKAGE_COMMANDS_DIR.relative_to(REPO_ROOT)}") if __name__ == "__main__": diff --git a/src/adloop/__init__.py b/src/adloop/__init__.py index a3dca75..9d5a8a0 100644 --- a/src/adloop/__init__.py +++ b/src/adloop/__init__.py @@ -8,14 +8,21 @@ def main() -> None: """Entry point for `adloop` console script. - Routes to the setup wizard when called as ``adloop init``, - otherwise starts the MCP server. + Subcommands: + adloop Start the MCP server (default). + adloop init Run the interactive setup wizard. + adloop install-rules Install Claude orchestration rules globally. + adloop update-rules Refresh the installed rules block. + adloop uninstall-rules Remove the installed rules block + commands. + adloop --version, -V Print version and exit. """ - if len(sys.argv) > 1 and sys.argv[1] in ("--version", "-V"): + args = sys.argv[1:] + + if args and args[0] in ("--version", "-V"): print(f"adloop {__version__}") return - if len(sys.argv) > 1 and sys.argv[1] == "init": + if args and args[0] == "init": from adloop.cli import run_init_wizard try: @@ -23,7 +30,18 @@ def main() -> None: except KeyboardInterrupt: print("\n\n Setup cancelled.\n") sys.exit(130) - else: - from adloop.server import mcp + return + + if args and args[0] in ("install-rules", "update-rules", "uninstall-rules"): + from adloop.cli import run_rules_command + + try: + sys.exit(run_rules_command(args[0], args[1:])) + except KeyboardInterrupt: + print("\n\n Cancelled.\n") + sys.exit(130) + return + + from adloop.server import mcp - mcp.run() + mcp.run() diff --git a/src/adloop/cli.py b/src/adloop/cli.py index b7630e1..9d2e234 100644 --- a/src/adloop/cli.py +++ b/src/adloop/cli.py @@ -580,10 +580,123 @@ def _run_wizard_post_config( for line in claude_snippet.splitlines(): _print(f" {line}") _print() - _print(" Then copy .claude/rules/adloop.md and .claude/commands/ into your project.") + _print(" (Or run `adloop install-rules` after this wizard to install") + _print(" rules + slash commands automatically into ~/.claude/.)") + + # Offer to install Claude rules globally now if a Claude installation + # is detected. Cursor is intentionally skipped — Cursor handles workspace + # rules natively via .cursor/rules/. + _maybe_offer_global_rules_install() _print() _print(" Restart your editor to pick up the MCP server.") _print() _print(" ✓ Setup complete!") _print() + + +def _maybe_offer_global_rules_install() -> None: + """If Claude is detected, offer to install rules globally during init.""" + from adloop.rules_install import detect_clients, install_rules + + clients = detect_clients() + if not clients: + return + + _print() + _print(" ── Claude Orchestration Rules ──") + _print() + _print(" Detected Claude installations:") + for c in clients: + _print(f" • {c.display_name}") + _print() + _print(" AdLoop ships orchestration rules that teach Claude how to use") + _print(" these tools safely (43 tools, safety patterns, GAQL reference).") + _print(" Without them, you get raw tool access but no orchestration.") + _print() + + if not _prompt_bool("Install rules globally now?", default=True): + _print(" Skipping. Run `adloop install-rules` later if you change your mind.") + return + + _print() + _print(" Install mode:") + _print(" 1. inline (default) — full rules in ~/.claude/CLAUDE.md") + _print(" Reliable; loaded every Claude Code session (~10K tokens).") + _print(" 2. lazy — small directive in CLAUDE.md, full rules in") + _print(" ~/.claude/rules/adloop.md, loaded only when AdLoop is active.") + _print(" Cheaper baseline cost; slightly less reliable.") + _print() + raw = input(" Choose [1/2, default 1]: ").strip() + mode = "lazy" if raw == "2" else "inline" + + _print() + results = install_rules(mode=mode) + _print_install_results(results) + + +def _print_install_results(results: list) -> None: + """Pretty-print install/update/uninstall results.""" + if not results: + _print(" No Claude installations detected — nothing to do.") + return + for r in results: + if r.action == "manual": + _print(f" ⚠ {r.client}: manual step required") + _print() + for line in r.instructions.splitlines(): + _print(f" {line}") + _print() + continue + target = r.rules_target if r.rules_target else "(none)" + _print(f" ✓ {r.client}: {r.action} → {target}") + if r.commands_installed: + _print( + f" {len(r.commands_installed)} slash command(s) installed" + f" (prefixed adloop-*)" + ) + if r.commands_removed: + _print( + f" {len(r.commands_removed)} slash command(s) removed" + ) + + +def run_rules_command(subcommand: str, argv: list[str]) -> int: + """Entry point for `adloop install-rules / update-rules / uninstall-rules`. + + Returns the process exit code (0 = success). + """ + from adloop.rules_install import ( + install_rules, + uninstall_rules, + update_rules, + ) + + mode = "inline" + install_commands = True + if "--lazy" in argv: + mode = "lazy" + if "--no-commands" in argv: + install_commands = False + + _print() + if subcommand == "install-rules": + _print(" Installing AdLoop rules...") + results = install_rules(mode=mode, install_commands=install_commands) + elif subcommand == "update-rules": + _print(" Updating AdLoop rules...") + # update-rules preserves the existing mode when no flag is passed. + explicit_mode = mode if "--lazy" in argv or "--inline" in argv else None + results = update_rules( + mode=explicit_mode, install_commands=install_commands # type: ignore[arg-type] + ) + elif subcommand == "uninstall-rules": + _print(" Uninstalling AdLoop rules...") + results = uninstall_rules(remove_commands=install_commands) + else: + _print(f" Unknown subcommand: {subcommand}") + return 1 + + _print_install_results(results) + _print() + return 0 diff --git a/src/adloop/rules/__init__.py b/src/adloop/rules/__init__.py new file mode 100644 index 0000000..f0b30ce --- /dev/null +++ b/src/adloop/rules/__init__.py @@ -0,0 +1,6 @@ +"""Bundled orchestration rules + slash commands. + +Files in this package are written by ``scripts/sync-rules.py`` from the +canonical sources at ``.cursor/rules/adloop.mdc`` and ``.claude/commands/``. +Do not edit the files in this directory directly — they will be overwritten. +""" diff --git a/src/adloop/rules/adloop.md b/src/adloop/rules/adloop.md new file mode 100644 index 0000000..f2a0a6f --- /dev/null +++ b/src/adloop/rules/adloop.md @@ -0,0 +1,580 @@ +--- +description: AdLoop MCP orchestration — Google Ads + GA4 + codebase intelligence +--- + +# AdLoop — AI Orchestration Rules + +You have access to AdLoop MCP tools that connect Google Ads and Google Analytics (GA4) data. These rules teach you how to use them intelligently. + +## Tool Inventory + +### Diagnostics + +| Tool | When to Use | Key Parameters | +|------|-------------|----------------| +| `health_check` | First thing to run when tools are failing — tests OAuth token, GA4, and Ads connectivity | (none) | + +**If health_check reports auth errors:** Tell the user to delete `~/.adloop/token.json` and re-run any tool to trigger re-authorization. If tokens keep expiring weekly, the GCP consent screen needs to be published from "Testing" to "In production". + +### GA4 Read Tools + +| Tool | When to Use | Key Parameters | +|------|-------------|----------------| +| `get_account_summaries` | First-time discovery — find which GA4 properties exist | (none — uses config) | +| `run_ga4_report` | Any analytics question — sessions, users, conversions, page performance | `dimensions`, `metrics`, `date_range_start`, `date_range_end`, `limit` | +| `run_realtime_report` | After code deploys — verify tracking fires correctly | `dimensions`, `metrics` | +| `get_tracking_events` | Understanding what events are configured and their volume | `date_range_start`, `date_range_end` | + +### Google Ads Read Tools + +| Tool | When to Use | Key Parameters | +|------|-------------|----------------| +| `list_accounts` | First-time discovery — find which Ads accounts exist | (none — uses MCC from config) | +| `get_campaign_performance` | Campaign-level metrics — impressions, clicks, cost, conversions | `date_range_start`, `date_range_end` | +| `get_ad_performance` | Ad copy analysis — which headlines/descriptions work | `date_range_start`, `date_range_end` | +| `get_keyword_performance` | Keyword analysis — quality scores, competitive metrics | `date_range_start`, `date_range_end` | +| `get_search_terms` | Find negative keyword opportunities and understand user intent | `date_range_start`, `date_range_end` | +| `get_negative_keywords` | List direct campaign-level negative keywords (not inside SharedSets) | `campaign_id` (optional) | +| `get_negative_keyword_lists` | List all shared negative keyword lists — names, IDs, status, keyword count | (none) | +| `get_negative_keyword_list_keywords` | List the keywords inside a specific shared negative keyword list | `shared_set_id` (required) | +| `get_negative_keyword_list_campaigns` | List which campaigns a shared negative keyword list is attached to | `shared_set_id` (optional) | +| `get_recommendations` | Google's auto-generated recommendations with estimated impact — budget, keyword, bid strategy, ad copy suggestions | `recommendation_types` (optional filter), `campaign_id` (optional) | +| `get_pmax_performance` | Performance Max campaign metrics with network breakdown + asset group ad strength | `date_range_start`, `date_range_end` | +| `get_asset_performance` | Per-asset details for PMax — field type, serving status, content. Use with `get_detailed_asset_performance` for quality signals | `campaign_id` (optional) | +| `get_detailed_asset_performance` | Top-performing asset combinations — which headline+description+image combos Google selects most | `campaign_id` (optional) | +| `get_audience_performance` | Audience segment metrics — remarketing, in-market, affinity, demographics | `date_range_start`, `date_range_end`, `campaign_id` (optional) | +| `run_gaql` | Custom queries not covered by other tools | `query`, `format` (table/json/csv) | + +**Return format notes:** +- Ads read tools automatically compute `metrics.cost` and `metrics.cpa` from `metrics.cost_micros` — no manual division needed. `metrics.currency` contains the account's currency code (auto-detected). +- `metrics.average_cpc_amount` is also pre-computed where available. +- `get_ad_performance` returns full `headlines` and `descriptions` lists for RSAs. +- `get_recommendations` returns `estimated_improvement` per recommendation (potential minus base metrics) and `insights[]` that flag self-serving budget recommendations. +- PMax tools: `get_pmax_performance` returns `insights[]` flagging weak ad strength and zero-conversion asset groups. `segments.ad_network_type` includes MIXED — a Google catch-all for most PMax traffic. Full channel splits (Search vs YouTube vs Display vs Discover) are not available via the API. +- `get_asset_performance` returns `by_status` and `by_field_type` summaries. Note: per-asset performance labels (BEST/GOOD/LOW) are not available for PMax assets in API v23. Use `get_detailed_asset_performance` for quality signals via top combinations. +- `get_audience_performance` works for campaigns with explicit audience targeting. PMax audience targeting is automatic and may not appear in this report. + +### Cross-Reference Tools (GA4 + Ads combined) + +| Tool | When to Use | Key Parameters | +|------|-------------|----------------| +| `analyze_campaign_conversions` | "What's my real CPA?", paid vs organic comparison, GDPR gap analysis | `date_range_start`, `date_range_end`, `campaign_name` (optional filter) | +| `landing_page_analysis` | "Which landing pages convert?", identify pages with traffic but no conversions | `date_range_start`, `date_range_end` | +| `attribution_check` | "Are my conversions tracked correctly?", Ads vs GA4 conversion discrepancies | `date_range_start`, `date_range_end`, `conversion_events` (optional GA4 event names) | + +These tools call both APIs internally and return unified results with computed `insights[]`. They are read-only — no mutations. Each returns a `date_range` and auto-generates conditional warnings (GDPR gaps, zero conversions, attribution mismatches, orphaned URLs). + +### Tracking Tools + +| Tool | When to Use | Key Parameters | +|------|-------------|----------------| +| `validate_tracking` | Compare codebase event code against actual GA4 events — find missing/broken tracking | `expected_events` (list of event names found in code), `date_range_start`, `date_range_end` | +| `generate_tracking_code` | Generate ready-to-paste GA4 gtag JavaScript for an event | `event_name`, `event_params` (optional), `trigger` (form_submit/button_click/page_load) | + +`validate_tracking` requires the AI to first search the codebase for `gtag('event', ...)` or `dataLayer.push({event: ...})` calls, extract event names, then pass them to the tool. The tool queries GA4 and returns a structured comparison (matched, missing, unexpected, auto-collected). + +`generate_tracking_code` includes recommended parameters for well-known GA4 events (sign_up, purchase, etc.) and optionally checks if the event already fires in GA4. + +### Planning Tools + +| Tool | When to Use | Key Parameters | +|------|-------------|----------------| +| `discover_keywords` | Discover new keyword ideas from seed keywords and/or a URL — returns avg monthly searches, competition, and bid range | `seed_keywords` (list, optional), `url` (optional), `geo_target_id`, `language_id`, `page_size` | +| `estimate_budget` | Budget planning before launching a campaign — forecasts clicks, impressions, cost for a set of keywords | `keywords` (list of {text, match_type, max_cpc}), `daily_budget` (optional), `geo_target_id`, `language_id`, `forecast_days` | + +`estimate_budget` calls the Google Ads Keyword Planner API (read-only — creates nothing). Returns forecast metrics for the specified keywords and optional budget, including daily estimates and insights about budget sufficiency. Common geo targets: 2276=Germany, 2840=USA, 2826=UK. Common languages: 1000=English, 1001=German, 1002=French. + +### Google Ads Write Tools (ALL require safety confirmation) + +| Tool | What It Does | Validation | +|------|-------------|------------| +| `draft_campaign` | Create full campaign structure (budget + campaign + ad group + keywords + geo/language targeting) | `campaign_name`, `daily_budget`, `bidding_strategy`, `geo_target_ids` (REQUIRED), `language_ids` (REQUIRED), optional `search_partners_enabled`, `display_network_enabled`, `display_expansion_enabled`, optional `max_cpc` for MANUAL_CPC ad-group bids or TARGET_SPEND CPC caps | +| `draft_ad_group` | Create a new ad group within an existing campaign (does NOT publish) | `campaign_id` (REQUIRED), `ad_group_name` (REQUIRED), `keywords` (optional list of {text, match_type}), `cpc_bid_micros` (optional) | +| `update_campaign` | Modify existing campaign settings — bid strategy, budget, geo targets, language targets, Search partners, display expansion | `campaign_id` (REQUIRED), plus any of: `bidding_strategy`, `daily_budget`, `geo_target_ids`, `language_ids`, `search_partners_enabled`, `display_network_enabled`, TARGET_SPEND `max_cpc` | +| `update_ad_group` | Update ad group name and/or MANUAL_CPC `max_cpc` | `ad_group_id`, optional `ad_group_name`, optional `max_cpc` | +| `draft_responsive_search_ad` | Create RSA preview (does NOT publish) | 3-15 headlines (≤30 chars), 2-4 descriptions (≤90 chars), final_url required, path1/path2 (≤15 chars each). Each headline/description may be a plain string (unpinned) or `{"text": "...", "pinned_field": "HEADLINE_1"}` (pinned). Valid pin slots: `HEADLINE_1/2/3`, `DESCRIPTION_1/2`. Google permits ≤2 headlines per slot, ≤1 description per slot. | +| `draft_callouts` | Create callout assets for a campaign (does NOT publish) | `campaign_id`, `callouts` list with 1-25 chars each | +| `draft_structured_snippets` | Create structured snippet assets for a campaign (does NOT publish) | `campaign_id`, `snippets` list of `{header, values}` with official header values and 3-10 values | +| `draft_image_assets` | Create image assets for a campaign from local files (does NOT publish) | `campaign_id`, `image_paths` list of local PNG/JPEG/GIF files | +| `draft_sitelinks` | Create sitelink extensions for a campaign (does NOT publish) | `campaign_id`, `sitelinks` list of {link_text ≤25 chars, final_url, description1 ≤35 chars, description2 ≤35 chars} | +| `draft_keywords` | Propose keyword additions (does NOT add) | Each keyword needs `text` and `match_type` (EXACT/PHRASE/BROAD) | +| `add_negative_keywords` | Propose negative keywords directly on a campaign (does NOT add) | `campaign_id`, keyword list, `match_type` | +| `propose_negative_keyword_list` | Draft a shared negative keyword list and attach it to a campaign (does NOT create) | `campaign_id`, `list_name`, keyword list, `match_type` | +| `add_to_negative_keyword_list` | Append keywords to an EXISTING shared negative keyword list (does NOT add) | `shared_set_id` (from `get_negative_keyword_lists`), keyword list, `match_type` | +| `pause_entity` | Propose pausing campaign/ad group/ad/keyword | `entity_type`, `entity_id` | +| `enable_entity` | Propose enabling paused entity | `entity_type`, `entity_id` | +| `remove_entity` | Propose REMOVING an entity (irreversible) | `entity_type` (incl. "negative_keyword", "shared_criterion", "campaign_asset", "asset", "customer_asset"), `entity_id` | +| `confirm_and_apply` | Execute a previously previewed change | `plan_id` from a draft tool, `dry_run` (default true) | + +**Write tool workflow:** +1. Call a `draft_*` tool → returns a preview with a `plan_id` +2. Show the full preview to the user and wait for approval +3. Call `confirm_and_apply(plan_id=..., dry_run=true)` first to test +4. Only call with `dry_run=false` after explicit user confirmation + +**Safety behaviors:** +- New campaigns and RSAs are created as PAUSED — user must explicitly enable them after review. +- `draft_campaign` REQUIRES `geo_target_ids` and `language_ids` — campaigns without targeting waste budget. The tool rejects drafts with missing targeting. +- `draft_campaign` enforces the `max_daily_budget` safety cap, rejects BROAD match + non-Smart Bidding, warns if budget is below 5x target CPA, and interprets `max_cpc` by bidding strategy: MANUAL_CPC seeds the initial ad-group bid, TARGET_SPEND sets the Maximize Clicks CPC ceiling. +- `display_network_enabled` is the canonical Search display-expansion flag. `display_expansion_enabled` is only a compatibility alias and should be normalized away before presenting the plan to the user. +- `update_ad_group` is the right tool for later MANUAL_CPC bid changes. Use `update_campaign` for TARGET_SPEND (Maximize Clicks) `max_cpc` changes. +- Ad-group pause/enable is already handled by `pause_entity` / `enable_entity` with `entity_type="ad_group"`; do not invent a separate pause tool. +- `update_campaign` replaces geo/language targets entirely (not append). Pass the full desired list. +- `remove_entity` is IRREVERSIBLE — always prefer `pause_entity` unless the user explicitly wants permanent removal. Removal triggers double confirmation in the safety layer. +- `remove_entity` supports `entity_type` values: "campaign", "ad_group", "ad", "keyword", "negative_keyword", "shared_criterion", "campaign_asset", "asset", "customer_asset". Use "negative_keyword" to remove campaign-level negative keywords. Use "shared_criterion" to remove a keyword from a shared negative keyword list — the `entity_id` format is "sharedSetId~criterionId" (use the `resource_id` field from `get_negative_keyword_list_keywords`). Use "campaign_asset" to remove sitelinks and other asset links from a campaign. Use "asset" to remove a standalone asset. Use "customer_asset" to remove a customer-level asset link. +- `require_dry_run: true` in config overrides `dry_run=false` — the user must change the config to allow real mutations. +- All operations (including dry runs) are logged to `~/.adloop/audit.log`. + +## Safety Rules (CRITICAL — always follow) + +1. **NEVER call confirm_and_apply without showing the preview to the user first.** Always present the full preview from a draft_* tool and wait for explicit user approval. + +2. **Default to dry_run=true.** When calling confirm_and_apply, always use dry_run=true unless the user explicitly says to apply for real. Even then, `require_dry_run` in config may override this. + +3. **Respect budget caps.** The config has max_daily_budget set. Never propose a campaign budget above this. + +4. **Double-check destructive operations.** For any pause, enable, remove, or budget change, explicitly warn the user about the impact before proceeding. `remove_entity` is irreversible — prefer `pause_entity` and only use removal when the user explicitly requests it. + +5. **One change at a time.** Don't batch multiple write operations. Draft one change, get approval, apply it, then move to the next. + +6. **Never guess entity IDs.** Always retrieve IDs from a read tool first (`get_campaign_performance` for campaign IDs, `get_ad_performance` for ad IDs, etc.) before passing them to write tools. + +7. **NEVER add BROAD match keywords without verifying Smart Bidding.** Before calling `draft_keywords` with BROAD match, ALWAYS check the campaign's bidding strategy via `get_campaign_performance` or `run_gaql`. If the campaign uses MANUAL_CPC, MANUAL_CPM, or any non-Smart Bidding strategy, REFUSE to add BROAD match keywords. Use PHRASE or EXACT instead. The `draft_keywords` tool will also return a warning, but you must catch this BEFORE drafting. Broad Match + Manual CPC is the single most common cause of wasted ad spend. + +8. **NEVER create ads or sitelinks with URLs you haven't verified.** Every `final_url` in an RSA and every sitelink URL MUST point to a real, working page. The draft tools now validate URLs automatically and reject non-reachable ones. But before you even call a draft tool, verify the URLs exist — check the codebase for route definitions, or confirm the pages are live. Ads pointing to 404 pages waste budget, destroy quality score, and create a terrible user experience. This applies to display paths too — don't invent URL paths that don't exist on the site. + +9. **Pre-write validation: check before you change.** Before ANY write operation, verify the campaign/ad group context is sound: + - Check bidding strategy (rule 7) + - Check if conversion tracking is active (zero conversions + high spend = problem to fix first, not more ads to create) + - Check quality scores (if all keywords have QS < 5, the problem is landing page/relevance, not keyword count) + - If the account has systemic issues, WARN the user before making changes that won't help. Adding more keywords to a campaign with zero conversions and quality score 0 makes things worse, not better. + +## GDPR Consent & Data Discrepancies + +Most websites (especially in the EU) use a GDPR cookie consent banner. This has a critical impact on data interpretation: + +- **Google Ads counts all clicks** regardless of consent. A click is a click — no consent needed. +- **GA4 only records sessions for users who accept analytics cookies.** Users who reject or ignore the consent banner are invisible to GA4. +- **This means Ads clicks will almost always be higher than GA4 sessions.** A ratio of 2:1 to 5:1 (clicks:sessions) is normal with consent banners, not a tracking bug. +- **Conversion events in GA4 are also affected** — only consenting users trigger events. True conversion rates are likely higher than what GA4 reports. + +**Before diagnosing a tracking issue, always consider consent:** +1. If Ads shows 10 clicks but GA4 shows 3 sessions → likely consent rejection, not broken tracking. +2. If GA4 shows 0 sessions from paid traffic → consent could explain it, but also check UTM parameters and GA4 filters. +3. Only flag tracking as broken when the discrepancy cannot be explained by consent (e.g., GA4 shows zero sessions for ALL traffic sources, or organic traffic also shows anomalies). + +**Google Consent Mode v2:** Some sites implement Consent Mode, which sends cookieless pings to GA4 even without consent. This reduces (but doesn't eliminate) the gap. If you see GA4 data is closer to Ads data, Consent Mode may be active. Check for `gtag('consent', ...)` calls in the codebase. + +## Orchestration Patterns + +### When user asks about performance or "how are my ads doing" + +1. Call `get_campaign_performance` for the relevant date range +2. If results include Performance Max campaigns (`campaign.advertising_channel_type = PERFORMANCE_MAX`), also call `get_pmax_performance` for asset group ad strength and network breakdown — PMax campaigns need different analysis than Search campaigns +3. If they mention conversions, CPA, or "is it worth it", call `analyze_campaign_conversions` instead — it gives Ads + GA4 data in one call with GDPR-aware cost-per-conversion +4. If they mention specific keywords or search terms, also call `get_keyword_performance` or `get_search_terms` +5. Present a summary with the key metrics: spend (`metrics.cost`), clicks, conversions, CPA (`metrics.cpa`), CTR +6. Highlight anything concerning: zero conversions, high CPA, low quality scores, wasteful search terms +7. Compare against best practices (see Marketing Best Practices section) +8. If the account has active Google recommendations, mention that `get_recommendations` can surface Google's suggestions for improvement + +### When user asks about conversions or conversion drops + +1. Call `attribution_check` with relevant date range and `conversion_events` if the user mentions specific events (e.g. sign_up, purchase) — this does the Ads vs GA4 comparison in one call and auto-generates insights +2. If the discrepancy needs page-level drill-down, call `landing_page_analysis` to see which pages get paid traffic but don't convert +3. Call `get_search_terms` to see if search intent shifted +4. **Before concluding tracking is broken:** Check the `insights` from `attribution_check` — it already factors in GDPR consent gaps. Only diagnose a tracking issue if the tool's insights suggest it. +5. If the user's codebase is accessible (Cursor native), search for recent changes to the affected pages +6. Present a unified diagnosis combining the cross-reference tool insights with code analysis + +### When user wants to create an ad + +1. Call `get_campaign_performance` to understand existing campaign structure and find the right `campaign.id` +2. **Pre-write checks (CRITICAL):** + - Is the target campaign's bidding strategy appropriate? MANUAL_CPC campaigns should NOT get more ads before fixing bidding. + - Does the campaign have any conversions? If it has significant spend and zero conversions, WARN the user that adding ads won't help — conversion tracking and campaign setup need fixing first. + - What are the quality scores? If all keywords are below 5, improving ad relevance and landing pages matters more than new ads. +3. Use `run_gaql` to find ad group IDs: `SELECT ad_group.id, ad_group.name FROM ad_group WHERE campaign.id = {campaign_id}` +4. Call `get_tracking_events` to verify conversion tracking exists +5. If codebase is accessible, read the landing page code to extract value propositions and determine the language. **Verify the final_url page actually exists** — check route definitions or confirm the URL is live. NEVER use a URL you haven't verified. +6. Call `draft_responsive_search_ad` with at least 8-10 diverse headlines and 3-4 descriptions. Write copy in the correct language — if the landing page is multilingual or the language is unclear, ask the user before writing. Follow the "Ad Copy Character Limits" section — count characters for every headline before generating +7. **Pinning (use sparingly).** By default, pass headlines and descriptions as plain strings — Google rotates them for you and ad strength benefits from the diversity. Only switch to the dict shape `{"text": "...", "pinned_field": "HEADLINE_1"}` when the user has a concrete brand-safety, compliance, or messaging requirement (e.g. "the brand name must always appear", "the disclaimer must always show"). Trade-offs to flag before pinning: + - Each pin reduces Google's ability to optimize headline order, which usually drops ad strength one notch. + - Phone numbers should generally use a **call asset at the campaign level** (separate tool/manual setup) rather than a pinned headline — call assets are clickable on mobile and don't burn a headline slot. If the user asks to pin a phone number, suggest the call-asset path first. + - Valid pin slots: `HEADLINE_1`, `HEADLINE_2`, `HEADLINE_3` for headlines; `DESCRIPTION_1`, `DESCRIPTION_2` for descriptions. Google permits at most 2 headlines per slot and 1 description per slot — the draft tool enforces these caps. + - You can mix pinned dict entries with plain-string entries in a single call (e.g. brand pinned to `HEADLINE_1`, the rest unpinned). +8. **Always set display paths** (`path1`, `path2`, max 15 chars each). These appear in the display URL (e.g. `example.com/Products/Pricing`) and significantly improve ad relevance. Derive them from the landing page URL structure or the ad's value proposition. +9. Present the complete preview to the user — include any warnings from the pre-write checks, and explicitly call out which assets (if any) are pinned and the trade-off. +10. After the ad is created, **suggest sitelinks** if the campaign doesn't have any. Use `draft_sitelinks` with at least 4 relevant links (key pages like pricing, features, signup, etc.). Sitelinks increase ad real estate and CTR. +11. Wait for explicit user approval before calling `confirm_and_apply` + +### When user wants to add keywords + +1. Call `get_campaign_performance` to identify the target campaign and its **bidding strategy** +2. **Pre-write checks (CRITICAL):** + - If the campaign uses MANUAL_CPC/MANUAL_CPM: ONLY use EXACT or PHRASE match. NEVER propose BROAD match. Explain why. + - If the campaign has zero conversions: WARN that adding keywords won't help until conversion tracking is working. + - If all existing keywords have quality score < 5: WARN that the problem is ad relevance and landing pages, not keyword coverage. +3. Call `get_keyword_performance` to see what keywords already exist — avoid duplicates +4. Call `get_search_terms` to understand what's already triggering ads +5. Call `draft_keywords` with appropriate match types (the tool will also warn about BROAD + non-Smart Bidding) +6. Present the preview with any warnings +7. Wait for explicit user approval + +### When user wants to create a new campaign + +1. Call `get_campaign_performance` to understand the existing campaign structure — avoid duplicate campaign names +2. If the user hasn't specified a budget, call `estimate_budget` with proposed keywords to get a data-driven budget recommendation +3. **Pre-write checks (CRITICAL):** + - **Bidding strategy**: Default to MAXIMIZE_CONVERSIONS. It tells Google the goal is conversions (not just clicks), doesn't waste budget on non-converting clicks, and starts building the conversion model from day one — even with zero history, Google uses broad signals (search intent, device, time of day). Only use TARGET_SPEND/MANUAL_CPC if the user explicitly requests it and understands the trade-offs. + - **Geo targeting**: ALWAYS ask the user which countries/regions to target if not specified. Never create a campaign without geo targets — untargeted campaigns waste budget on irrelevant geographies. Common IDs: 2276=Germany, 2040=Austria, 2756=Switzerland, 2840=USA, 2826=UK. + - **Language targeting**: ALWAYS ask the user which languages to target if not specified. Language targeting restricts ads to users whose browser/Google language matches — without it, ads show to anyone in the geo region regardless of language. Common IDs: 1001=German, 1000=English, 1002=French. + - Does the account have conversion tracking working? Call `attribution_check` — if zero conversions across the board, WARN that new campaigns won't help until tracking is fixed. + - Is the proposed budget reasonable? Must be ≤ `max_daily_budget` in config, and ideally ≥ 5x target CPA. +4. Call `draft_campaign` with campaign name, daily budget, bidding strategy, `geo_target_ids`, `language_ids`, ad group name, and optional keywords +5. Review the preview and any `warnings` (budget sufficiency, MANUAL_CPC warning, BROAD match rejection) +6. Present the complete preview to the user — emphasize the campaign will be created as PAUSED, and confirm the geo/language targets are correct +7. After campaign creation, remind the user to: + - Add ads via `draft_responsive_search_ad` (with display paths set) + - Add sitelinks via `draft_sitelinks` (at least 4 recommended) + - If the user needs multiple ad groups (e.g., different keyword themes), use `draft_ad_group` to add additional ad groups after the initial campaign is created and confirmed + - Enable the campaign via `enable_entity` only after ads and sitelinks are in place +8. Wait for explicit user approval before calling `confirm_and_apply` + +### When user wants to add an ad group to an existing campaign + +1. Call `get_campaign_performance` to identify the target campaign and verify it exists +2. **Pre-write checks (CRITICAL):** + - Check the campaign's bidding strategy — if MANUAL_CPC, only use EXACT or PHRASE match keywords + - Check if conversion tracking is active (zero conversions + high spend = problem to fix first) + - Check existing ad groups via `run_gaql`: `SELECT ad_group.id, ad_group.name FROM ad_group WHERE campaign.id = {campaign_id}` — avoid duplicate ad group names +3. Call `draft_ad_group` with `campaign_id`, `ad_group_name`, and optional `keywords` +4. Present the complete preview to the user +5. Wait for explicit user approval before calling `confirm_and_apply` +6. After the ad group is created, remind the user to add RSAs via `draft_responsive_search_ad` using the new `ad_group_id` from the result — an ad group without ads won't serve + +### When user wants to change campaign settings (bid strategy, targeting, budget) + +1. Call `get_campaign_performance` to identify the campaign and its current settings +2. Use `run_gaql` to check current targeting: + - Geo: `SELECT campaign_criterion.location.geo_target_constant FROM campaign_criterion WHERE campaign.id = {id} AND campaign_criterion.type = 'LOCATION'` + - Language: `SELECT campaign_criterion.language.language_constant FROM campaign_criterion WHERE campaign.id = {id} AND campaign_criterion.type = 'LANGUAGE'` +3. **Pre-write checks:** + - If changing to MAXIMIZE_CONVERSIONS: good default choice — confirm with the user + - If changing to MANUAL_CPC: warn about the trade-offs (no automation, requires constant monitoring) + - If removing geo or language targets: warn that this broadens targeting and may waste budget + - If the campaign is in a learning phase: warn that changes will restart the learning phase +4. Call `update_campaign` with only the parameters that need to change +5. Present the preview — clearly show what's changing (old → new) +6. Wait for explicit user approval before calling `confirm_and_apply` + +### When user asks "how much should I spend" or "what budget do I need" + +1. Ask the user for their target keywords (or suggest some based on the business context) +2. Ask for the target geography and language (or infer from the existing account) +3. Call `estimate_budget` with the keywords, match types, and optional daily budget +4. Present the forecast: estimated clicks, impressions, cost, and avg CPC +5. If the user provided a daily budget, highlight whether it's sufficient to capture most available traffic +6. Use the forecast to inform `draft_campaign` decisions — the estimated daily cost guides the budget parameter + +### When user asks about tracking or event issues + +1. **First, consider GDPR consent** — if Ads clicks > GA4 sessions, this is likely consent rejection, not broken tracking. State this before investigating further. +2. If the codebase is accessible, search for `gtag('event'` and `dataLayer.push` calls to extract event names. Also look for consent mode implementation (`gtag('consent', ...)`) +3. Call `validate_tracking` with the extracted event names — it compares codebase events against actual GA4 data and returns matched, missing, and unexpected events +4. Review the `insights[]` from `validate_tracking` — missing events indicate code not deployed or behind untriggered conditions; unexpected events may come from tag managers +5. If the user needs to add new tracking, use `generate_tracking_code` to produce the gtag snippet with recommended parameters + +### When user asks to add negative keywords + +1. Call `get_search_terms` to see current search term data +2. Call `get_negative_keywords` to see what's already blocked — avoid duplicates +3. Identify irrelevant terms that waste budget — group them by theme +4. Call `get_campaign_performance` to get the right `campaign.id` +5. Choose the right write tool: + - **Direct campaign negatives** (`add_negative_keywords`): faster, campaign-specific, no reuse across campaigns + - **Append to an existing shared list** (`add_to_negative_keyword_list`): pass `shared_set_id` from `get_negative_keyword_lists`. Use this whenever a suitable list already exists — never recreate a list just to add a few more terms. + - **Create a new shared list** (`propose_negative_keyword_list`): creates a named, reusable list and attaches it to a campaign. Only use this when no suitable list exists yet. +6. **Before calling `propose_negative_keyword_list`**, always call `get_negative_keyword_lists` first to check whether a suitable list already exists. If a matching list is found, inspect its keywords via `get_negative_keyword_list_keywords` and check which campaigns it is already on via `get_negative_keyword_list_campaigns`. If the list exists and just needs more keywords, call `add_to_negative_keyword_list` instead of creating a duplicate. Creating duplicate lists wastes account resources and makes management harder. +7. **Before calling `add_to_negative_keyword_list`**, call `get_negative_keyword_list_keywords` to see what's already in the list — avoid re-adding existing terms. Duplicate keyword additions will fail at apply time with a Google Ads error. +8. **To remove a keyword from a shared list**, use `remove_entity` with `entity_type="shared_criterion"` and pass the `resource_id` returned by `get_negative_keyword_list_keywords` as `entity_id`. This is irreversible — always confirm with the user first. +9. Present preview and wait for confirmation + +### When user wants to discover new keywords + +1. Ask whether to start with seed keywords, a URL, or both — these map directly to the two modes in Google Ads Keyword Planner UI +2. Call `discover_keywords` with `seed_keywords`, `url`, or both, plus the target `geo_target_id` and `language_id` +3. Review `insights[]` — highlights the highest-volume idea, high-competition terms to budget carefully for, and low-competition opportunities +4. Present results grouped by competition level (LOW → MEDIUM → HIGH) so the user can quickly spot easy wins vs expensive battles +5. For any ideas the user wants to act on, suggest the next step: + - Use `estimate_budget` with the selected keywords to forecast traffic and cost before committing + - Use `draft_keywords` to add them to an existing ad group + - Use `draft_campaign` to build a new campaign around them + +### When user asks to pause or enable something + +1. Call the appropriate read tool to confirm the entity exists and get its current status +2. Call `pause_entity` or `enable_entity` with the entity type and ID +3. Present the preview with a clear warning about impact (e.g. "This will stop all ads in this campaign") +4. Wait for confirmation + +### When user asks about landing page performance + +1. Call `landing_page_analysis` — it combines ad final URLs with GA4 page data in one call +2. Review the `insights[]` for pages with traffic but zero conversions, high bounce rates, or orphaned URLs +3. If the codebase is accessible, read the flagged landing pages to identify UX or content issues +4. Present the results sorted by paid sessions, highlighting problem pages + +### When user asks "is my tracking working" or "are conversions set up correctly" + +1. Call `attribution_check` with `conversion_events` set to the expected events (e.g. `["sign_up", "purchase"]`) +2. The tool checks: do these events exist in GA4? Do they fire from paid traffic? Does Ads agree? +3. If the `insights[]` mention missing events or zero counts, search the codebase for tracking code and then call `validate_tracking` with the extracted event names for a structured comparison +4. If the `insights[]` mention GDPR consent gaps, explain that this is normal EU behavior, not broken tracking +5. If tracking code needs to be added, use `generate_tracking_code` to produce ready-to-paste gtag snippets with the right parameters + +### When user asks "paid vs organic" or "which channel converts better" + +1. Call `analyze_campaign_conversions` — it returns both paid campaign metrics and non-paid channel conversion rates +2. Compare `campaigns[].ga4_conversion_rate` (paid) vs `non_paid_channels[].conversion_rate` (organic/direct/referral) +3. If paid conversion rate is significantly lower, investigate landing page relevance and ad targeting before increasing spend + +### When user asks about Performance Max or PMax campaigns + +1. Call `get_pmax_performance` for campaign-level metrics with network breakdown and asset group ad strength +2. Review `insights[]` — weak ad strength and zero-conversion asset groups are the most actionable findings +3. If ad strength is POOR or AVERAGE, call `get_asset_performance` to see which specific assets are underperforming (LOW label) and which asset types are missing +4. Call `get_detailed_asset_performance` to see which headline+description+image combinations Google selects most — this reveals what's actually working +5. If the user wants to improve PMax performance: + - Replace LOW-performing assets with new ones + - Ensure minimum asset diversity: at least 5 headlines, 5 descriptions, 5 marketing images, 1 landscape image, 1 logo, 1 YouTube video (recommended) + - Check that final URLs point to relevant, working landing pages +6. Note: PMax campaigns are partially opaque. `segments.ad_network_type` gives SEARCH, CONTENT, YOUTUBE_SEARCH, YOUTUBE_WATCH, and MIXED — but MIXED is a catch-all for most PMax traffic. Full per-channel transparency is not available via the API. + +### When user asks about Google's recommendations or "what does Google suggest" + +1. Call `get_recommendations` to retrieve all active (non-dismissed) recommendations +2. Review the `by_type` summary and `insights[]` — these flag self-serving budget recommendations +3. **NEVER blindly endorse Google's recommendations.** Cross-reference each recommendation against actual account data: + - If Google says "raise budget" but the campaign has zero conversions → bad advice. Fix tracking/landing pages first. + - If Google says "add keywords" but existing keywords have quality score < 5 → bad advice. Fix relevance first. + - If Google says "switch to Maximize Conversions" and the campaign uses Manual CPC → likely good advice, but verify conversion tracking works. + - If Google says "use Broad Match" and the campaign isn't on Smart Bidding → reject per safety rule 7. +4. Use `estimated_improvement` to prioritize: recommendations with >1 estimated additional conversion are worth investigating +5. For keyword recommendations, call `get_keyword_performance` to check existing keyword overlap before acting +6. Present recommendations grouped by type with your assessment of each — the user should understand which ones are genuinely helpful vs which ones just increase Google's revenue + +### When user asks about audience performance or targeting + +1. Call `get_audience_performance` for the relevant date range +2. If filtered to a specific campaign, also call `get_campaign_performance` for context on the campaign's overall performance +3. Compare audience segment metrics: which segments convert best? Which have high spend but zero conversions? +4. For PMax campaigns, note that audience targeting is automatic — audience performance data may not appear in the `ad_group_audience_view` report. Audience signals in PMax are configured at the asset group level and serve as hints, not hard targeting. +5. For Display/Search campaigns with explicit audience overlays, use the data to recommend bid adjustments or audience exclusions + +## Default Parameters + +When the user doesn't specify: +- **Date range**: Default to last 30 days for Ads, last 7 days for GA4 +- **Customer ID**: Use the default from config (no need to ask) +- **Property ID**: Use the default from config (no need to ask) +- **Format**: Use "table" for run_gaql results + +## GAQL Quick Reference + +GAQL (Google Ads Query Language) is SQL-like but with specific resource names and field paths. + +### Basic Syntax + +```sql +SELECT field1, field2, ... +FROM resource +WHERE condition +ORDER BY field [ASC|DESC] +LIMIT n +``` + +### Common Resources + +| Resource | Use For | +|----------|---------| +| `campaign` | Campaign-level data | +| `ad_group` | Ad group-level data | +| `ad_group_ad` | Ad-level data (includes ad copy) | +| `keyword_view` | Keyword performance | +| `search_term_view` | Search terms report | +| `ad_group_criterion` | Keywords and targeting criteria | +| `campaign_budget` | Budget information | +| `bidding_strategy` | Bidding strategy details | +| `customer_client` | List accounts under an MCC (uses login_customer_id) | +| `asset_group` | PMax asset group data (ad strength, status) | +| `asset_group_asset` | PMax per-asset performance labels | +| `asset_group_top_combination_view` | PMax top asset combinations | +| `recommendation` | Google's auto-generated recommendations | +| `ad_group_audience_view` | Audience segment performance | + +### Common Fields + +**Campaign fields:** +- `campaign.id`, `campaign.name`, `campaign.status` +- `campaign.advertising_channel_type` (SEARCH, DISPLAY, SHOPPING, VIDEO) +- `campaign.bidding_strategy_type` + +**Ad Group fields:** +- `ad_group.id`, `ad_group.name`, `ad_group.status` +- `ad_group.cpc_bid_micros` (bid in micros — divide by 1,000,000 for actual value) + +**Ad fields:** +- `ad_group_ad.ad.responsive_search_ad.headlines` +- `ad_group_ad.ad.responsive_search_ad.descriptions` +- `ad_group_ad.ad.final_urls` +- `ad_group_ad.status` + +**Keyword fields:** +- `ad_group_criterion.keyword.text` +- `ad_group_criterion.keyword.match_type` (EXACT, PHRASE, BROAD) +- `ad_group_criterion.quality_info.quality_score` + +**Metrics (available on most resources):** +- `metrics.impressions`, `metrics.clicks`, `metrics.cost_micros` +- `metrics.conversions`, `metrics.conversions_value` +- `metrics.ctr`, `metrics.average_cpc` +- `metrics.search_impression_share`, `metrics.search_rank_lost_impression_share` + +**Segments (for time-based breakdowns):** +- `segments.date` — daily breakdown +- `segments.device` — MOBILE, DESKTOP, TABLET +- `segments.ad_network_type` — SEARCH, CONTENT, YOUTUBE + +### Date Ranges + +```sql +WHERE segments.date DURING LAST_7_DAYS +WHERE segments.date DURING LAST_30_DAYS +WHERE segments.date DURING THIS_MONTH +WHERE segments.date DURING LAST_MONTH +WHERE segments.date BETWEEN '2026-01-01' AND '2026-01-31' +``` + +### Important GAQL Rules + +- You CANNOT use `SELECT *` — every field must be named explicitly +- **Fields used in ORDER BY must appear in SELECT.** `ORDER BY metrics.cost_micros` will fail unless `metrics.cost_micros` is in your SELECT clause. This is the most common GAQL error. +- Metrics can be used in WHERE clauses (e.g., `WHERE metrics.clicks > 5`). The real constraint is field compatibility — certain resource attribute fields cannot be selected alongside specific metrics or segments in the same query. When a query fails, check field compatibility for the resource. +- `cost_micros` values are in micros — divide by 1,000,000 for the actual currency amount. The dedicated read tools (get_campaign_performance, etc.) already compute `metrics.cost` and `metrics.cpa` for you. Only `run_gaql` returns raw micros. +- When selecting `segments.date`, results are broken down by day +- Status values are strings: `'ENABLED'`, `'PAUSED'`, `'REMOVED'` +- `search_term_view` always requires a date segment in WHERE + +### Example Queries + +**Top campaigns by spend:** +```sql +SELECT campaign.name, campaign.status, metrics.cost_micros, metrics.clicks, metrics.conversions +FROM campaign +WHERE segments.date DURING LAST_30_DAYS AND campaign.status = 'ENABLED' +ORDER BY metrics.cost_micros DESC +LIMIT 10 +``` + +**Keywords with low quality score:** +```sql +SELECT ad_group_criterion.keyword.text, ad_group_criterion.quality_info.quality_score, + metrics.impressions, metrics.clicks, metrics.cost_micros +FROM keyword_view +WHERE segments.date DURING LAST_30_DAYS + AND ad_group_criterion.quality_info.quality_score < 5 +ORDER BY metrics.cost_micros DESC +``` + +**Search terms that cost money but don't convert:** +```sql +SELECT search_term_view.search_term, metrics.clicks, metrics.cost_micros, metrics.conversions +FROM search_term_view +WHERE segments.date DURING LAST_30_DAYS AND metrics.clicks > 5 AND metrics.conversions = 0 +ORDER BY metrics.cost_micros DESC +LIMIT 20 +``` + +**Ad groups in a specific campaign:** +```sql +SELECT ad_group.id, ad_group.name, ad_group.status +FROM ad_group +WHERE campaign.id = 12345678 +``` + +## Ad Copy Character Limits + +Google Ads enforces hard character limits. The `draft_responsive_search_ad` tool will reject copy that exceeds them, but you must write copy that fits on the FIRST attempt — do not generate copy and hope it fits. + +**Hard limits:** +- Headlines: **30 characters** max (including spaces) +- Descriptions: **90 characters** max (including spaces) +- Display path fields: **15 characters** each (path1, path2) +- Sitelink link text: **25 characters** max +- Sitelink descriptions: **35 characters** max (description1, description2) + +**30 characters is very short.** This is the most common source of rejected ad drafts. Many languages produce words and phrases that easily exceed 30 characters — German compound words, French/Spanish phrases with articles, etc. English is among the most compact languages for ad copy. + +**Rules for writing ad copy that fits:** +1. **Count characters for every headline BEFORE calling the draft tool.** Every space, hyphen, accent, and punctuation mark counts as 1 character. If a headline is over 30, rewrite it shorter — don't submit it and hope. +2. **Abbreviate long words.** If a domain term exceeds ~15 characters, abbreviate it or use a shorter synonym. Save long terminology for descriptions (90 char limit). +3. **Use short action verbs and numbers.** Numbers, symbols, and abbreviations save space. +4. **Put long phrases in descriptions, not headlines.** Headlines are for punchy hooks. Descriptions have 3x the space for detail. +5. **Write in the correct language.** Check the landing page to determine the language. If the landing page is multilingual or the language is ambiguous, ASK the user which language the ad should be in before writing any copy. +6. **Test each headline mentally:** if it's close to 30, it's probably over. Aim for 25 or fewer to leave margin. + +## Geo & Language Targeting Reference + +### Common Geo Target IDs (geoTargetConstants) + +| ID | Country | +|----|---------| +| 2276 | Germany | +| 2040 | Austria | +| 2756 | Switzerland | +| 2840 | United States | +| 2826 | United Kingdom | +| 2250 | France | +| 2380 | Italy | +| 2724 | Spain | +| 2528 | Netherlands | +| 2056 | Belgium | + +For cities/regions, use `run_gaql` with `geo_target_constant` resource or look up IDs in the Google Ads API Geo Target documentation. + +### Common Language IDs (languageConstants) + +| ID | Language | +|----|----------| +| 1000 | English | +| 1001 | German | +| 1002 | French | +| 1003 | Italian | +| 1004 | Spanish | +| 1005 | Dutch | +| 1009 | Portuguese | +| 1014 | Polish | + +## Marketing Best Practices + +When advising on Google Ads: + +- **Bid strategy default**: Prefer MAXIMIZE_CONVERSIONS for new campaigns. It leverages Google's signals from day one, even without conversion history. TARGET_SPEND (Maximize Clicks) only makes sense as a deliberate data-collection phase — and even then, MAXIMIZE_CONVERSIONS is usually better because it starts optimizing for conversions immediately instead of just accumulating clicks. +- **Geo targeting is mandatory**: Every campaign must target specific countries/regions. Untargeted campaigns serve globally and waste budget. ALWAYS set geo_target_ids when creating campaigns. +- **Language targeting is mandatory**: Every campaign must target specific languages. Without it, ads show to all users in the geo region regardless of browser language. A German-language ad shown to an English speaker in Germany wastes budget. ALWAYS set language_ids when creating campaigns. +- **Match types**: Never recommend Broad Match without Smart Bidding (tCPA or tROAS) active on the campaign. Broad Match without Smart Bidding leads to budget waste. +- **CPA monitoring**: Flag any campaign where CPA exceeds 3x the target for review. +- **Budget sufficiency**: A campaign's daily budget should be at least 5x its target CPA to generate enough data for the algorithm. +- **Learning phase**: Don't edit campaigns that are in an active learning phase (Google Ads shows "Learning" or "Learning (limited)" status). Wait until the learning phase completes before making changes. +- **Negative keyword hygiene**: After reviewing search terms, always suggest adding irrelevant terms as negatives. Group them by theme. +- **RSA best practices**: Provide at least 8-10 unique headlines (out of max 15) and 3-4 descriptions (out of max 4). Make headlines diverse — don't repeat the same message. **Pin only when necessary** — pinning reduces Google's ability to rotate assets and typically lowers ad strength. Reach for the dict shape `{"text": "...", "pinned_field": "HEADLINE_1"}` only for concrete brand-safety/compliance reasons. For phone numbers, prefer a campaign-level call asset over a pinned headline. See "Ad Copy Character Limits" section below for language-specific guidance. +- **Quality Score**: If keywords have quality score < 5, prioritize improving ad relevance and landing page experience over bid increases. +- **Zero conversions**: When a campaign has spent significant budget with zero conversions, investigate (1) is GDPR consent reducing visible conversions? (2) is conversion tracking set up correctly in GA4? (3) is the landing page converting organic traffic? (4) are search terms relevant? Don't just increase budget. +- **Manual CPC + Broad Match**: This combination is the #1 cause of wasted budget. Broad Match without Smart Bidding matches any vaguely related query — a niche industry keyword on BROAD will match generic, irrelevant, and competitor terms. NEVER create this combination. If it already exists, recommend switching to PHRASE/EXACT match or moving the campaign to Smart Bidding BEFORE any other changes. +- **Display paths**: Always set `path1` and `path2` on RSAs. They cost nothing, improve ad relevance, and make the display URL informative (e.g. `example.com/Features/Pricing` instead of bare `example.com`). Derive them from the landing page path or the ad's core message. Max 15 chars each. +- **Sitelinks**: Every campaign should have at least 4 sitelinks. They increase ad real estate (more screen space = higher CTR), direct users to key pages, and are free. Good candidates: pricing, features, signup/trial, about, key product pages. Use `draft_sitelinks` to create them. Link text max 25 chars, descriptions max 35 chars each. +- **Clicks vs sessions gap**: Never report a clicks > sessions discrepancy as a tracking bug without first accounting for GDPR consent. In the EU, 30-70% of users may reject analytics cookies. This is normal, not broken. +- **Performance Max asset diversity**: PMax campaigns need diverse assets to perform well. Minimum recommended: 5+ headlines (max 30 chars), 5+ long headlines (max 90 chars), 5+ descriptions (max 90 chars), 5+ marketing images (1200x628), 5+ square images (1200x1200), 1+ logo (1200x1200), 1+ landscape logo (1200x300). A YouTube video is strongly recommended. Ad strength below GOOD usually means missing asset types. +- **PMax transparency limitations**: The Google Ads API does not provide full channel-level breakdowns for PMax. `segments.ad_network_type` returns SEARCH, CONTENT, YOUTUBE_SEARCH, YOUTUBE_WATCH, and MIXED — but MIXED is a catch-all for most traffic. Do not promise users exact Search vs Display vs YouTube splits. Be honest about what the data shows and what it doesn't. +- **Google recommendations are not neutral**: Google's auto-generated recommendations optimize for Google's revenue, not necessarily the advertiser's ROI. Budget increase and Broad Match recommendations should always be cross-referenced against actual conversion data. Bid strategy recommendations (switch to Smart Bidding) are usually sound. Keyword recommendations may be too broad. Never blindly apply recommendations — evaluate each one against the account's actual performance. diff --git a/src/adloop/rules/commands/analyze-performance.md b/src/adloop/rules/commands/analyze-performance.md new file mode 100644 index 0000000..32bea82 --- /dev/null +++ b/src/adloop/rules/commands/analyze-performance.md @@ -0,0 +1,32 @@ +--- +description: Analyze Google Ads + GA4 performance with cross-channel insights +allowed-tools: ["mcp"] +--- + +Analyze Google Ads and GA4 performance: $ARGUMENTS + +## 1. Pull data (AdLoop MCP) + +- `get_campaign_performance` — relevant date range (default: last 30 days) +- `analyze_campaign_conversions` — cross-referenced Ads + GA4 data with GDPR gap detection +- If specific campaigns mentioned, filter by name +- If keywords are relevant, also pull `get_keyword_performance` and `get_search_terms` + +## 2. Analyze + +- Spend, Clicks, Conversions, CPA, CTR per campaign +- Paid vs organic comparison (from non_paid_channels) +- GDPR gap (clicks vs sessions ratio — 2:1 to 5:1 is normal in EU) +- Flag: zero conversions with significant spend, CPA > 3x target, QS < 5, wasteful search terms + +If conversion issues found: run `attribution_check` +If landing page problems suspected: run `landing_page_analysis` + +## 3. Present results + +- Summary table of all campaigns with key metrics +- Highlight what's working and what's not +- Ranked list of recommended actions with priority and estimated impact +- If search terms show waste, quantify the amount and suggest negatives + +Keep the GDPR consent gap in mind — never diagnose clicks > sessions as broken tracking without considering consent rejection first. diff --git a/src/adloop/rules/commands/budget-plan.md b/src/adloop/rules/commands/budget-plan.md new file mode 100644 index 0000000..fcb9e84 --- /dev/null +++ b/src/adloop/rules/commands/budget-plan.md @@ -0,0 +1,34 @@ +--- +description: Estimate budget for keywords using Google Ads Keyword Planner +allowed-tools: ["mcp"] +--- + +Plan budget for Google Ads keywords: $ARGUMENTS + +## 1. Gather inputs + +- Target keywords: from user input or suggest based on business context +- Match types: EXACT or PHRASE preferred (BROAD only with Smart Bidding) +- Max CPC bids: optional, helps refine forecasts +- Geography: ask user or infer from existing account (common: 2276=Germany, 2840=USA, 2826=UK) +- Language: ask user or infer (common: 1000=English, 1001=German, 1002=French) +- Daily budget: optional — if provided, shows whether it's sufficient + +## 2. Run forecast + +- Call `estimate_budget` with keywords, match types, optional daily budget, geo target, language +- The tool uses Google Ads Keyword Planner API (read-only, creates nothing) + +## 3. Present results + +- Estimated daily clicks, impressions, cost, and average CPC +- If daily budget was provided: is it sufficient to capture most available traffic? +- Compare estimated CPC across keywords — some may be too expensive +- Highlight keywords with low forecast volume (may not be worth targeting) + +## 4. Recommendations + +- Suggest a daily budget based on the forecast data +- If budget is tight, recommend focusing on highest-intent keywords with EXACT match +- If budget is ample, PHRASE match captures more volume +- Link to campaign creation: use the forecast to set the budget in `draft_campaign` diff --git a/src/adloop/rules/commands/create-ad.md b/src/adloop/rules/commands/create-ad.md new file mode 100644 index 0000000..1818021 --- /dev/null +++ b/src/adloop/rules/commands/create-ad.md @@ -0,0 +1,42 @@ +--- +description: Create a responsive search ad with pre-write validation and safety checks +allowed-tools: ["mcp"] +--- + +Create a Google Ads responsive search ad for: $ARGUMENTS + +## 1. Research + +- `get_campaign_performance` — find campaign structure and campaign.id +- `run_gaql` — get ad group IDs: `SELECT ad_group.id, ad_group.name FROM ad_group WHERE campaign.id = {id}` +- `get_tracking_events` — verify conversion tracking exists + +## 2. Pre-write validation (CRITICAL) + +Before drafting anything, check: +- Is the bidding strategy appropriate? MANUAL_CPC = warn user, adding ads won't help before fixing bidding +- Does the campaign have conversions? High spend + zero conversions = warn, adding ads won't help +- Quality scores? All below 5 = relevance problem, not ad problem +- If systemic issues found, warn user before proceeding + +## 3. Landing page analysis + +- Read the landing page code to extract value propositions +- Determine the correct language — if unclear, ASK the user before writing any copy + +## 4. Write ad copy + +- 8-10 diverse headlines (MAX 30 characters each — count every one!) +- 3-4 descriptions (MAX 90 characters each) +- Count characters BEFORE calling draft_responsive_search_ad +- Aim for 25 chars on headlines to leave margin +- Write in the landing page's language +- Make headlines diverse — don't repeat the same message + +## 5. Draft and confirm + +- Call `draft_responsive_search_ad` with the copy +- Show full preview to user including any warnings +- Wait for explicit approval +- `confirm_and_apply(plan_id=..., dry_run=true)` first +- Only `dry_run=false` after user explicitly confirms diff --git a/src/adloop/rules/commands/create-campaign.md b/src/adloop/rules/commands/create-campaign.md new file mode 100644 index 0000000..4b8dc06 --- /dev/null +++ b/src/adloop/rules/commands/create-campaign.md @@ -0,0 +1,46 @@ +--- +description: Create a new Google Ads search campaign with safety checks +allowed-tools: ["mcp"] +--- + +Create a new Google Ads campaign for: $ARGUMENTS + +## 1. Research existing structure + +- `get_campaign_performance` — understand existing campaigns, avoid duplicate names +- Check what's already running and what bidding strategies are in use + +## 2. Budget estimation + +- If user hasn't specified a budget, call `estimate_budget` with proposed keywords +- Ask for target geography and language if not clear (common: 2276=Germany, 2840=USA, 2826=UK) +- Present the forecast: estimated clicks, impressions, cost, avg CPC + +## 3. Pre-write checks (CRITICAL) + +- Bidding strategy: recommend MAXIMIZE_CONVERSIONS or TARGET_CPA over MANUAL_CPC +- Conversion tracking: run `attribution_check` — if zero conversions across the board, WARN that a new campaign won't help until tracking is fixed +- Budget: must be <= max_daily_budget in config, ideally >= 5x target CPA +- Keywords: if using BROAD match, campaign MUST use Smart Bidding — otherwise use PHRASE or EXACT + +## 4. Draft campaign + +- Call `draft_campaign` with: + - campaign_name, daily_budget, bidding_strategy + - ad_group_name + - Optional keywords with appropriate match types +- Review the preview and any warnings (budget sufficiency, bidding, match type safety) + +## 5. Present and confirm + +- Show the complete preview to the user +- Emphasize: campaign will be created as PAUSED +- Wait for explicit approval +- `confirm_and_apply(plan_id=..., dry_run=true)` first +- Only `dry_run=false` after user confirms + +## 6. Next steps + +After campaign creation, remind the user to: +1. Add ads via `draft_responsive_search_ad` (use /create-ad) +2. Enable the campaign via `enable_entity` when ready diff --git a/src/adloop/rules/commands/diagnose-tracking.md b/src/adloop/rules/commands/diagnose-tracking.md new file mode 100644 index 0000000..f9dd938 --- /dev/null +++ b/src/adloop/rules/commands/diagnose-tracking.md @@ -0,0 +1,44 @@ +--- +description: Diagnose tracking and conversion issues across Google Ads and GA4 +allowed-tools: ["mcp"] +--- + +Diagnose tracking and conversion issues for: $ARGUMENTS + +## 1. GDPR check first + +Before investigating anything technical, consider: +- Are Ads clicks > GA4 sessions? This is likely GDPR consent rejection (normal in EU, 2:1 to 5:1 ratio) +- State this upfront before deeper investigation + +## 2. Attribution check + +- Run `attribution_check` with relevant `conversion_events` (e.g., sign_up, purchase, form_submit) +- Review the `insights[]` — the tool already factors in GDPR consent gaps +- Only proceed to deeper investigation if insights suggest a real tracking problem + +## 3. Codebase analysis + +- Search for `gtag('event'` and `dataLayer.push({event:` to find tracking code +- Search for `gtag('consent'` to check Consent Mode v2 implementation +- Extract all event names from code + +## 4. Validate tracking + +- Run `validate_tracking` with the extracted event names +- Compare results: matched (working), missing (in code but not firing), unexpected (in GA4 but not in code) +- Missing events = code not deployed or behind untriggered conditions +- Unexpected events = likely from tag managers + +## 5. Landing page check + +- Run `landing_page_analysis` to check pages with traffic but zero conversions +- If codebase is accessible, read flagged pages for UX issues + +## 6. Diagnosis + +- Only diagnose tracking as BROKEN when discrepancy can't be explained by consent +- Signs of real issues: zero sessions for ALL sources, organic also anomalous, events in code but never fire +- If tracking code needs to be added, use `generate_tracking_code` to produce the snippet + +Present unified diagnosis: GDPR impact + tracking status + specific recommendations. diff --git a/src/adloop/rules/commands/optimize-campaign.md b/src/adloop/rules/commands/optimize-campaign.md new file mode 100644 index 0000000..d96fe79 --- /dev/null +++ b/src/adloop/rules/commands/optimize-campaign.md @@ -0,0 +1,52 @@ +--- +description: Full optimization checklist for a Google Ads campaign +allowed-tools: ["mcp"] +--- + +Optimize Google Ads campaign: $ARGUMENTS + +Follow this checklist in order — earlier items have higher impact. + +## 1. Diagnose + +- `get_campaign_performance` — current metrics +- `attribution_check` — is tracking working? +- If zero conversions + significant spend: STOP and resolve tracking first + +## 2. Search term cleanup + +- `get_search_terms` — identify irrelevant terms wasting budget +- `get_negative_keywords` — what's already blocked (avoid duplicates) +- Propose negatives with `add_negative_keywords` (show preview, wait for approval) +- Group negatives by theme for clarity + +## 3. Quality Score + +- `get_keyword_performance` — check QS for each keyword +- QS < 5 = relevance problem (landing page + ad copy mismatch) +- Suggest specific improvements to ad copy or landing page + +## 4. Ad copy + +- `get_ad_performance` — which ads perform best +- If CTR < 2% across all ads: headlines need rewriting +- If fewer than 3 active ads: create new ones with `draft_responsive_search_ad` +- Count characters before drafting (30 char headline limit) + +## 5. Bidding strategy + +- Check if campaign uses Smart Bidding or Manual CPC +- Manual CPC + Broad Match: MIGRATE to Phrase/Exact OR switch to Smart Bidding +- Recommend Maximize Conversions if sufficient conversion data exists + +## 6. Budget + +- Is daily budget >= 5x target CPA? +- If not: either increase budget or focus on reducing CPA first +- Use `estimate_budget` if considering budget changes + +## Rules + +- One change at a time — show preview, wait for approval +- Never edit during Learning Phase +- Priority order: tracking > negatives > QS > ad copy > bidding > budget diff --git a/src/adloop/rules_install.py b/src/adloop/rules_install.py new file mode 100644 index 0000000..f4bc2cb --- /dev/null +++ b/src/adloop/rules_install.py @@ -0,0 +1,470 @@ +"""Install AdLoop orchestration rules + slash commands into Claude clients. + +Public API: + +- :func:`detect_clients` — return the Claude installations we can target. +- :func:`install_rules` — write the managed rules block (idempotent). +- :func:`update_rules` — refresh an existing managed block. +- :func:`uninstall_rules` — remove the managed block, leaving the rest of + the user's CLAUDE.md untouched. + +Idempotency is handled by a sentinel-comment block: + + + ... managed content ... + + +The sentinel includes the AdLoop version that wrote it so we can detect drift +on upgrade. Anything outside the sentinel pair is preserved verbatim. + +Two install modes: + +- **inline** (default): the full rules text is pasted between the sentinels + in ``~/.claude/CLAUDE.md``. Reliable but ~10K tokens loaded every Claude + Code session. +- **lazy**: a small directive is written between the sentinels pointing at + ``~/.claude/rules/adloop.md`` (which contains the full rules). The LLM is + instructed to read the rules file only when AdLoop tools are in scope. + Cheaper baseline cost, less reliable. + +Slash commands are installed namespaced (``adloop-*``) so they don't collide +with user-defined commands of the same name. + +Claude Desktop has no programmatic rules concept — for that client we return +``InstructionResult`` describing what the user must paste into claude.ai +project instructions manually. +""" + +from __future__ import annotations + +import importlib.resources +import re +import shutil +from dataclasses import dataclass, field +from pathlib import Path +from typing import Literal + +from adloop import __version__ + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +SENTINEL_START_PREFIX = "" + +# Match any version, so we can find/replace blocks written by older versions. +_SENTINEL_BLOCK_RE = re.compile( + r".*?\n?", + re.DOTALL, +) + +InstallMode = Literal["inline", "lazy"] + +# The directive used in lazy mode. Kept short to minimise baseline token cost. +_LAZY_DIRECTIVE = """\ +**AdLoop orchestration**: if MCP tools like `get_campaign_performance`, +`run_gaql`, `draft_responsive_search_ad`, or other AdLoop tools are available +in this session, read `~/.claude/rules/adloop.md` for the full safety rules, +orchestration patterns, and GAQL reference before using them. Skip this read +if AdLoop is not active in the current session. +""" + +# A short prelude printed inline above the rules content. +_INLINE_PRELUDE = """\ +The block below contains AdLoop's MCP orchestration guide. It is loaded +automatically because `adloop install-rules` placed it here. To remove or +update, run `adloop uninstall-rules` or `adloop update-rules`. +""" + + +# --------------------------------------------------------------------------- +# Dataclasses +# --------------------------------------------------------------------------- + + +@dataclass +class ClaudeClient: + """A detected Claude installation we can target. + + Attributes: + name: Stable identifier ("claude_code", "claude_desktop"). + display_name: Human-friendly name for prompts. + rules_target: Path to the markdown file that should contain the + managed sentinel block. May be ``None`` for clients (like + Claude Desktop) that have no programmatic rules concept. + commands_dir: Where to install slash commands. ``None`` if the + client doesn't support them. + notes: Any caveats to surface during install (e.g. manual steps). + """ + + name: str + display_name: str + rules_target: Path | None + commands_dir: Path | None = None + notes: str = "" + + +@dataclass +class InstallResult: + """Outcome of an install/update/uninstall operation.""" + + client: str + action: Literal["installed", "updated", "uninstalled", "skipped", "manual"] + rules_target: Path | None = None + commands_installed: list[str] = field(default_factory=list) + commands_removed: list[str] = field(default_factory=list) + instructions: str = "" # human-readable next-steps message + + +# --------------------------------------------------------------------------- +# Detection +# --------------------------------------------------------------------------- + + +def detect_clients(home: Path | None = None) -> list[ClaudeClient]: + """Return the Claude installations we can detect on this machine. + + The Cursor client is intentionally never returned — Cursor handles + workspace rules natively via ``.cursor/rules/`` and needs no global install. + """ + home = home or Path.home() + detected: list[ClaudeClient] = [] + + # Claude Code (CLI) — presence of ~/.claude/ or ~/.claude/CLAUDE.md. + claude_code_dir = home / ".claude" + if claude_code_dir.exists() or _claude_code_binary_present(): + detected.append( + ClaudeClient( + name="claude_code", + display_name="Claude Code (CLI)", + rules_target=claude_code_dir / "CLAUDE.md", + commands_dir=claude_code_dir / "commands", + ) + ) + + # Claude Desktop — best-effort detection across platforms. We only surface + # this client to print manual instructions, since claude.ai has no + # programmatic rules location we can write to. + desktop_dirs = [ + home / "Library" / "Application Support" / "Claude", # macOS + home / ".config" / "Claude", # Linux + home / "AppData" / "Roaming" / "Claude", # Windows + ] + if any(p.exists() for p in desktop_dirs): + detected.append( + ClaudeClient( + name="claude_desktop", + display_name="Claude Desktop", + rules_target=None, + commands_dir=None, + notes=( + "Claude Desktop has no programmatic rules location. " + "Manual paste required — see install output." + ), + ) + ) + + return detected + + +def _claude_code_binary_present() -> bool: + """Return True if the `claude` CLI is on PATH (covers fresh installs).""" + return shutil.which("claude") is not None + + +# --------------------------------------------------------------------------- +# Bundled-content access +# --------------------------------------------------------------------------- + + +def _read_bundled_rules() -> str: + """Load the bundled rules markdown shipped with the package. + + Returns the file as-is (with its YAML frontmatter intact) since lazy mode + writes it to ``~/.claude/rules/adloop.md`` where the frontmatter is + meaningful. For inline mode, use :func:`_read_bundled_rules_body`. + """ + ref = importlib.resources.files("adloop.rules").joinpath("adloop.md") + with importlib.resources.as_file(ref) as p: + return Path(p).read_text() + + +def _read_bundled_rules_body() -> str: + """Like :func:`_read_bundled_rules` but with YAML frontmatter stripped. + + Used when the rules content is embedded inline in ``~/.claude/CLAUDE.md`` + (which is itself a rules file and shouldn't contain nested frontmatter). + """ + text = _read_bundled_rules() + if not text.startswith("---"): + return text + end = text.index("---", 3) + return text[end + 3:].lstrip("\n") + + +def _list_bundled_commands() -> list[tuple[str, str]]: + """Return ``[(filename, content), ...]`` for every bundled slash command.""" + commands: list[tuple[str, str]] = [] + try: + cmd_pkg = importlib.resources.files("adloop.rules").joinpath("commands") + except (FileNotFoundError, ModuleNotFoundError): + return commands + + with importlib.resources.as_file(cmd_pkg) as p: + cmd_dir = Path(p) + if not cmd_dir.is_dir(): + return commands + for md in sorted(cmd_dir.glob("*.md")): + commands.append((md.name, md.read_text())) + return commands + + +# --------------------------------------------------------------------------- +# Sentinel-block helpers +# --------------------------------------------------------------------------- + + +def _sentinel_start(version: str = __version__) -> str: + return f"" + + +def _build_managed_block(mode: InstallMode, rules_target_path: Path) -> str: + """Return the full sentinel-bracketed block to write.""" + start = _sentinel_start() + if mode == "lazy": + body = _LAZY_DIRECTIVE + else: + body = _INLINE_PRELUDE + "\n" + _read_bundled_rules_body() + return f"{start}\n\n{body}\n\n{SENTINEL_END}\n" + + +def _replace_or_append_block(existing: str, new_block: str) -> str: + """Idempotent merge: replace any existing managed block, else append.""" + if _SENTINEL_BLOCK_RE.search(existing): + return _SENTINEL_BLOCK_RE.sub(new_block, existing, count=1) + if existing and not existing.endswith("\n"): + existing += "\n" + if existing and not existing.endswith("\n\n"): + existing += "\n" + return existing + new_block + + +def _strip_block(existing: str) -> str: + """Return ``existing`` with the managed block removed (idempotent).""" + cleaned = _SENTINEL_BLOCK_RE.sub("", existing, count=1) + # Collapse any triple+ blank lines left behind by the strip. + cleaned = re.sub(r"\n{3,}", "\n\n", cleaned) + return cleaned + + +# --------------------------------------------------------------------------- +# Public entry points +# --------------------------------------------------------------------------- + + +def install_rules( + *, + mode: InstallMode = "inline", + install_commands: bool = True, + home: Path | None = None, +) -> list[InstallResult]: + """Install (or refresh) the rules block on every detected client.""" + return _apply_to_clients( + clients=detect_clients(home=home), + mode=mode, + install_commands=install_commands, + action="install", + ) + + +def update_rules( + *, + mode: InstallMode | None = None, + install_commands: bool = True, + home: Path | None = None, +) -> list[InstallResult]: + """Refresh the managed block for an already-installed client. + + If ``mode`` is None, preserve whichever mode the existing block uses + (inline vs lazy detected by the directive content). New installs default + to inline. + """ + return _apply_to_clients( + clients=detect_clients(home=home), + mode=mode, + install_commands=install_commands, + action="update", + ) + + +def uninstall_rules( + *, + remove_commands: bool = True, + home: Path | None = None, +) -> list[InstallResult]: + """Remove the managed block + namespaced slash commands from each client. + + Anything outside the sentinel block in CLAUDE.md is preserved verbatim. + Only ``adloop-*.md`` commands are removed — user-authored commands are + never touched. + """ + results: list[InstallResult] = [] + for client in detect_clients(home=home): + if client.rules_target is None: + results.append( + InstallResult( + client=client.name, + action="manual", + instructions=_manual_uninstall_instructions(client), + ) + ) + continue + + result = InstallResult(client=client.name, action="skipped") + result.rules_target = client.rules_target + + # Remove sentinel block from CLAUDE.md + if client.rules_target.exists(): + existing = client.rules_target.read_text() + cleaned = _strip_block(existing) + if cleaned != existing: + if cleaned.strip() == "": + client.rules_target.unlink() + else: + client.rules_target.write_text(cleaned) + result.action = "uninstalled" + + # Remove the lazy-mode rules file if present + lazy_rules_file = ( + client.rules_target.parent / "rules" / "adloop.md" + ) + if lazy_rules_file.exists(): + lazy_rules_file.unlink() + try: + lazy_rules_file.parent.rmdir() # only if empty + except OSError: + pass + + # Remove namespaced slash commands + if remove_commands and client.commands_dir and client.commands_dir.exists(): + for stale in client.commands_dir.glob("adloop-*.md"): + stale.unlink() + result.commands_removed.append(stale.name) + if result.commands_removed: + result.action = "uninstalled" + + results.append(result) + return results + + +# --------------------------------------------------------------------------- +# Worker +# --------------------------------------------------------------------------- + + +def _apply_to_clients( + *, + clients: list[ClaudeClient], + mode: InstallMode | None, + install_commands: bool, + action: Literal["install", "update"], +) -> list[InstallResult]: + results: list[InstallResult] = [] + for client in clients: + if client.rules_target is None: + results.append( + InstallResult( + client=client.name, + action="manual", + instructions=_manual_install_instructions(client, mode or "inline"), + ) + ) + continue + + rules_target = client.rules_target + rules_target.parent.mkdir(parents=True, exist_ok=True) + + existing = rules_target.read_text() if rules_target.exists() else "" + had_block = bool(_SENTINEL_BLOCK_RE.search(existing)) + + # Resolve mode: explicit > existing-mode-detection > inline default. + resolved_mode: InstallMode + if mode is not None: + resolved_mode = mode + elif had_block: + resolved_mode = "lazy" if _existing_block_is_lazy(existing) else "inline" + else: + resolved_mode = "inline" + + # In lazy mode, the full rules go to a sibling file. + if resolved_mode == "lazy": + lazy_rules_dir = rules_target.parent / "rules" + lazy_rules_dir.mkdir(parents=True, exist_ok=True) + (lazy_rules_dir / "adloop.md").write_text(_read_bundled_rules()) + + new_block = _build_managed_block(resolved_mode, rules_target) + merged = _replace_or_append_block(existing, new_block) + rules_target.write_text(merged) + + result = InstallResult( + client=client.name, + action="updated" if had_block else "installed", + rules_target=rules_target, + ) + + # Slash commands. + if install_commands and client.commands_dir is not None: + client.commands_dir.mkdir(parents=True, exist_ok=True) + for filename, content in _list_bundled_commands(): + target = client.commands_dir / f"adloop-{filename}" + target.write_text(content) + result.commands_installed.append(target.name) + + results.append(result) + return results + + +def _existing_block_is_lazy(existing: str) -> bool: + """Heuristic: lazy blocks contain the directive sentence; inline don't.""" + match = _SENTINEL_BLOCK_RE.search(existing) + if not match: + return False + block = match.group(0) + # Lazy blocks are short and reference the rules file path. + return "~/.claude/rules/adloop.md" in block and len(block) < 2000 + + +# --------------------------------------------------------------------------- +# Manual-instruction builders (Claude Desktop) +# --------------------------------------------------------------------------- + + +def _manual_install_instructions(client: ClaudeClient, mode: InstallMode) -> str: + if mode == "lazy": + body = ( + "Lazy mode is not meaningful for Claude Desktop (claude.ai). " + "Falling back to inline.\n\n" + ) + else: + body = "" + + rules = _read_bundled_rules_body() + return ( + f"{body}" + f"To use AdLoop's orchestration rules in {client.display_name}, " + "open your project on https://claude.ai, go to Project settings → " + "Custom instructions, and paste the contents below.\n\n" + "Re-paste after every `adloop update-rules` to stay in sync.\n\n" + "--- BEGIN ADLOOP RULES ---\n" + f"{rules}\n" + "--- END ADLOOP RULES ---\n" + ) + + +def _manual_uninstall_instructions(client: ClaudeClient) -> str: + return ( + f"To uninstall AdLoop's rules from {client.display_name}, open the " + "project on https://claude.ai, go to Project settings → Custom " + "instructions, and remove the AdLoop section." + ) diff --git a/tests/test_rules_install.py b/tests/test_rules_install.py new file mode 100644 index 0000000..25c4ed7 --- /dev/null +++ b/tests/test_rules_install.py @@ -0,0 +1,446 @@ +"""Tests for adloop.rules_install — global Claude rules installer.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from adloop import __version__ +from adloop import rules_install + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _setup_claude_code_home(tmp_path: Path) -> Path: + """Create a fake ~/.claude/ tree to make detect_clients() find Claude Code.""" + (tmp_path / ".claude").mkdir(parents=True, exist_ok=True) + return tmp_path + + +def _setup_claude_desktop_home(tmp_path: Path) -> Path: + """Create a fake macOS Claude Desktop dir.""" + (tmp_path / "Library" / "Application Support" / "Claude").mkdir( + parents=True, exist_ok=True + ) + return tmp_path + + +# --------------------------------------------------------------------------- +# detect_clients +# --------------------------------------------------------------------------- + + +class TestDetectClients: + def test_returns_empty_when_nothing_detected(self, tmp_path): + # Bare home with no Claude dirs. + result = rules_install.detect_clients(home=tmp_path) + # Note: claude binary on PATH could still cause detection; allow that. + assert all(c.name != "claude_desktop" for c in result) + + def test_detects_claude_code_via_directory(self, tmp_path): + _setup_claude_code_home(tmp_path) + result = rules_install.detect_clients(home=tmp_path) + names = [c.name for c in result] + assert "claude_code" in names + cc = next(c for c in result if c.name == "claude_code") + assert cc.rules_target == tmp_path / ".claude" / "CLAUDE.md" + assert cc.commands_dir == tmp_path / ".claude" / "commands" + + def test_detects_claude_desktop_macos_path(self, tmp_path): + _setup_claude_desktop_home(tmp_path) + result = rules_install.detect_clients(home=tmp_path) + names = [c.name for c in result] + assert "claude_desktop" in names + cd = next(c for c in result if c.name == "claude_desktop") + assert cd.rules_target is None # manual-only client + + +# --------------------------------------------------------------------------- +# install_rules — inline mode +# --------------------------------------------------------------------------- + + +class TestInstallInline: + def test_creates_claude_md_with_sentinel_block(self, tmp_path): + _setup_claude_code_home(tmp_path) + results = rules_install.install_rules( + mode="inline", install_commands=False, home=tmp_path + ) + + cc_result = next(r for r in results if r.client == "claude_code") + assert cc_result.action == "installed" + + claude_md = tmp_path / ".claude" / "CLAUDE.md" + assert claude_md.exists() + body = claude_md.read_text() + assert f"" in body + assert "" in body + # Inline mode should embed the actual rules content. + assert "AdLoop" in body + + def test_preserves_existing_user_content_outside_block(self, tmp_path): + _setup_claude_code_home(tmp_path) + claude_md = tmp_path / ".claude" / "CLAUDE.md" + claude_md.write_text( + "# My personal Claude instructions\n\nDo not use tabs.\n" + ) + + rules_install.install_rules( + mode="inline", install_commands=False, home=tmp_path + ) + + body = claude_md.read_text() + assert "# My personal Claude instructions" in body + assert "Do not use tabs." in body + assert "", + body, + re.DOTALL, + ) + assert block is not None + assert "description: AdLoop MCP orchestration" not in block.group(0) + + def test_install_commands_creates_namespaced_files(self, tmp_path): + _setup_claude_code_home(tmp_path) + rules_install.install_rules( + mode="inline", install_commands=True, home=tmp_path + ) + + cmd_dir = tmp_path / ".claude" / "commands" + installed = sorted(p.name for p in cmd_dir.glob("*.md")) + assert installed, "expected at least one command installed" + assert all(name.startswith("adloop-") for name in installed) + + +# --------------------------------------------------------------------------- +# install_rules — lazy mode +# --------------------------------------------------------------------------- + + +class TestInstallLazy: + def test_writes_short_directive_in_claude_md(self, tmp_path): + _setup_claude_code_home(tmp_path) + rules_install.install_rules( + mode="lazy", install_commands=False, home=tmp_path + ) + + claude_md = tmp_path / ".claude" / "CLAUDE.md" + body = claude_md.read_text() + # Lazy directive should be small (sentinels + ~5 lines of prose). + assert "", + body, + re.DOTALL, + ) + assert block_match is not None + assert len(block_match.group(0)) < 2000 + + def test_writes_full_rules_to_sibling_file(self, tmp_path): + _setup_claude_code_home(tmp_path) + rules_install.install_rules( + mode="lazy", install_commands=False, home=tmp_path + ) + + rules_file = tmp_path / ".claude" / "rules" / "adloop.md" + assert rules_file.exists() + # Should match the bundled rules content (frontmatter intact — + # this is a Claude rules file, not a CLAUDE.md). + bundled = rules_install._read_bundled_rules() + assert rules_file.read_text() == bundled + assert rules_file.read_text().startswith("---") + + +# --------------------------------------------------------------------------- +# update_rules +# --------------------------------------------------------------------------- + + +class TestUpdateRules: + def test_update_preserves_existing_mode(self, tmp_path): + _setup_claude_code_home(tmp_path) + # Install lazy first. + rules_install.install_rules( + mode="lazy", install_commands=False, home=tmp_path + ) + + # Update without specifying mode — should stay lazy. + rules_install.update_rules(install_commands=False, home=tmp_path) + + body = (tmp_path / ".claude" / "CLAUDE.md").read_text() + assert "~/.claude/rules/adloop.md" in body # lazy directive marker + block = re.search( + r"", + body, + re.DOTALL, + ) + assert block is not None + assert len(block.group(0)) < 2000 # still lazy + + def test_update_can_switch_modes_explicitly(self, tmp_path): + _setup_claude_code_home(tmp_path) + rules_install.install_rules( + mode="lazy", install_commands=False, home=tmp_path + ) + + # Switch to inline. + rules_install.update_rules( + mode="inline", install_commands=False, home=tmp_path + ) + + body = (tmp_path / ".claude" / "CLAUDE.md").read_text() + block = re.search( + r"", + body, + re.DOTALL, + ) + assert block is not None + assert len(block.group(0)) > 2000 # inline is large + + +# --------------------------------------------------------------------------- +# uninstall_rules +# --------------------------------------------------------------------------- + + +class TestUninstall: + def test_removes_block_but_keeps_other_content(self, tmp_path): + _setup_claude_code_home(tmp_path) + claude_md = tmp_path / ".claude" / "CLAUDE.md" + claude_md.write_text("# Mine\n\nKeep me.\n") + rules_install.install_rules( + mode="inline", install_commands=False, home=tmp_path + ) + + rules_install.uninstall_rules(remove_commands=False, home=tmp_path) + + body = claude_md.read_text() + assert "Keep me." in body + assert "\nold body\n\n" + ) + + rules_install.install_rules( + mode="inline", install_commands=False, home=tmp_path + ) + + body = claude_md.read_text() + assert "user content" in body + assert "old body" not in body + assert f"" in body + assert body.count("