adops
v0.1.0
Published
The Google Ads CLI Google never shipped: browse and bulk-edit your account from the terminal, plus cross-platform reporting for Meta.
Maintainers
Readme
adops
The Google Ads CLI Google never shipped — with a cross-platform reporting layer.
Google gives you Google Ads Editor, a desktop GUI you can't script, cron, or pipe. adops is the missing command-line equivalent: browse your account, mine waste, and bulk-edit campaigns, keywords, budgets, and ads — every change staged as a reviewable plan and dry-run against Google's own API before it touches anything. On top of Google it adds one thing no single-platform tool can: Meta spend in the same table, so pacing and reporting are genuinely cross-platform.
$ adops spend
PLATFORM SPEND BUDGET PACE PROJECTED
google $31,204 $50,000 103% $51,633
meta $44,890 $80,000 93% $74,504
blended $76,094 / $130,000 · 1,966 conv · $38.70 CAC · day 19/31Why Google-primary?
In April 2026 Meta shipped its own official Ads CLI + MCP connectors — a free, first-party way to create and edit Meta campaigns from a terminal. Competing with a platform's own tool on its own turf is a losing game, so adops doesn't: it goes deep where there's still no first-party CLI (Google), and keeps Meta for the one thing Meta's Meta-only tool can't do — showing both platforms side by side. For deep Meta writes, use Meta's CLI; for a unified view and a real Google editor, use this.
Status: v0.1 — 18 commands built, 58 tests green (mocked at the HTTP seam). Implemented:
auth·accounts·report·spend·audit·watch·export·plan/apply/log·gads query·gads campaigns·gads keywords·gads ads·gads terms·gads negatives·gads budgets·gads urls check·meta insights·meta fatigue. Everything else in this README is spec (🔜) and errors honestly if you try it. Not yet validated against the live APIs — the field-name and mutate-payload assumptions are unverified until a Google developer token and a Meta system-user token go in (see the annoying parts).
Principles
- Text in, text out. Every command prints a table for humans and
--json/--csvfor machines. Everything composes with pipes. - Read-only by default. Anything that writes prints a plan and stops. Nothing mutates without
--apply, and every apply runs Google's nativevalidateOnlydry-run first — the API itself checks the change before it executes. - Built for cron. Quiet by default, meaningful exit codes:
0fine,1error,2alert condition met. Schedulers and CI understand it. - Your account belongs in git.
adops exportserializes account structure to files. Diff it, PR it, blame it. - No magic. No AI bidding, no "optimization score," no telemetry. The tool does exactly what the command says.
Install
# not on npm yet — from a clone:
npm install && npm run build && npm i -g . # Node ≥ 20
# later: npm i -g adops
# later still: curl -fsSL https://adops.sh/install | shYes, the domain will be the install script.
Quickstart
adops auth google # browser OAuth once; refresh token → OS keychain
adops auth meta # system-user token recommended (doesn't expire in 60 days)
adops accounts # list every account you can touch, both platforms
# first pull — raw GAQL, no boilerplate:
adops gads query "SELECT campaign.name, metrics.cost_micros, metrics.conversions
FROM campaign WHERE segments.date DURING LAST_7_DAYS
ORDER BY metrics.cost_micros DESC" --csv > last7.csv
# first cross-platform report:
adops report last-7d --by campaignConfiguration
~/.config/adops/config.toml, overridable per-invocation with --profile <name> (agencies: one profile per client).
[profiles.acme]
google_customer_id = "123-456-7890"
google_login_customer_id = "111-222-3333" # MCC, if any
meta_ad_account = "act_1234567890"
currency = "USD"
[profiles.acme.budgets] # used by `adops spend`
google = 50_000
meta = 80_000Every credential also reads from env (CI-friendly): GOOGLE_ADS_DEVELOPER_TOKEN, GOOGLE_ADS_OAUTH_CLIENT_ID, GOOGLE_ADS_OAUTH_CLIENT_SECRET, GOOGLE_ADS_REFRESH_TOKEN, GOOGLE_ADS_LOGIN_CUSTOMER_ID, META_ACCESS_TOKEN, META_AD_ACCOUNT_ID, ADOPS_PROFILE.
Commands
Conventions: reads are plain commands; writes are verbs that produce a plan unless --apply is passed. - reads from stdin. --json, --csv, --table everywhere. NO_COLOR respected.
Cross-platform
| Command | What it does |
|---|---|
| adops auth <google\|meta> | OAuth / token setup; secrets stored in the OS keychain |
| adops accounts | List accessible accounts and their IDs across both platforms |
| adops report [preset] | Unified performance report (last-7d, mtd, custom --from --to), --by campaign\|adgroup\|ad\|day |
| adops spend | Pacing vs. configured budgets: burn rate, month-end projection, blended CAC/ROAS |
| adops audit | Run the QA checklist (below). --fix stages a plan for the fixable ones |
| adops watch | Evaluate alert rules from watch.toml (spend spikes, conv drops to zero, paused-by-accident, disapprovals). Exit 2 + --notify <webhook> on trigger |
| adops audiences push list.csv | Normalize + SHA-256 hash emails/phones, upload to Google Customer Match and Meta Custom Audiences in one shot |
| adops export [dir] | Serialize full account structure (campaigns → ads, settings, targeting) to YAML/JSON files. --diff compares files against live state |
| adops plan show <id> / adops apply <id> / adops rollback <id> | The mutation lifecycle — see Safety model |
| adops log | Receipts of every applied mutation (what, when, by which command) |
adops audit checks: disapproved ads · final URLs returning non-200 · redirect chains · missing UTM/tracking templates · budget-capped campaigns · zero-impression entities (30d) · empty ad groups / ad sets · conflicting negatives blocking your own keywords · unlinked/silent pixels · naming-convention lint (regex per profile) · ads in learning limbo · orphaned assets.
Google Ads — adops gads … (the primary surface)
✅ built · 🔜 planned. The editor loop is list → stage → review → apply; every write goes through the safety model.
| Command | What it does |
|---|---|
| ✅ gads query "<GAQL>" | Raw GAQL passthrough via searchStream. The escape hatch — any report the API can produce, zero boilerplate |
| ✅ gads campaigns list\|pause\|enable | Browse campaigns with MTD spend; bulk pause/enable by id (--id "111,222"), staged as a plan |
| ✅ gads keywords list\|add\|pause\|enable\|bid | The keyword editor: list with bids/cost/conv, add positives to an ad group, bulk pause/enable, change max-CPC — all staged |
| ✅ gads ads list\|pause\|enable | Ad-level status + cost; bulk pause/enable by id |
| ✅ gads terms | Search terms report. Filters: --min-cost, --conv 0, --last 30d. The waste finder that feeds negatives |
| ✅ gads negatives plan\|add | Campaign-level negative keywords. plan - reads terms JSON from stdin (pipe straight from terms --json) |
| ✅ gads budgets list\|set | Per-campaign budgets and pacing; stage a daily-amount change with --budget <id> --daily <usd> |
| ✅ gads urls check | Hit every enabled final URL: status codes, redirects, missing UTM. Broken-destination finder |
| 🔜 gads negatives at ad-group / shared-set level; rm | Campaign-level ships today; the other scopes need more resource types |
| 🔜 gads ideas "<seed>" | Keyword Planner ideas with volume/competition |
| 🔜 gads clone <campaign> | Duplicate a campaign with --find/--replace overrides — the geo-expansion workhorse |
| 🔜 gads rsa new\|update | Responsive search ads from a YAML spec (15 headlines + 4 descriptions in a file, not 19 UI boxes) |
| 🔜 gads pmax, gads assets, gads perf, gads invoices | PMax asset groups, extensions, segmented/auction performance, invoice PDFs |
Meta Ads — adops meta … (reporting layer only)
Meta ships its own official Ads CLI for creating and editing Meta campaigns — adops doesn't duplicate it. What adops keeps is the read side, so Meta shows up in the same report/spend tables as Google. Writes are out of scope here by design; reach for Meta's CLI.
| Command | What it does |
|---|---|
| ✅ meta insights | Insights at any level (--level campaign\|adset\|ad) with breakdowns (--by age,gender); cursor paging handled |
| ✅ meta fatigue | Frequency + CTR-decay report (7d vs. prior 7d) — surfaces burned-out ads to pause in Meta's own tools |
| 🔜 meta leads pull | Pull lead-gen form submissions to CSV/JSON on a cron — a read Meta's CLI doesn't emphasize |
Safety model
$ adops gads terms --last 30d --min-cost 50 --conv 0 --json \
| adops gads negatives plan - --campaign 456
plan-8f3a: add 14 exact negatives → campaign 456
nothing applied · review: adops plan show 8f3a · execute: adops apply 8f3a- Plan → apply. Write commands emit a plan file (
~/.adops/plans/), print the operations, and exit.adops apply <id>executes it. Same idea asterraform plan. Ops are grouped by resource and batched into one mutate call each. - Native validation, always. Every apply runs the operations with Google's
validateOnly: trueflag first — the real API checks the real change without executing it — and only then runs them for real.adops apply --validatestops after the dry-run. - Receipts. Every real apply appends to an append-only JSONL log (
~/.adops/log.jsonl): plan id, op count, operation descriptions, timestamp. Validate-only runs leave no receipt — nothing changed. - Rollback, honestly scoped. 🔜
adops rollback <receipt>will restore what's restorable (statuses, budgets, bids, added negatives). Deletions and served impressions can't be undone — it'll say so instead of pretending.
Automation recipes
Nightly guardrail — page yourself before the client does:
0 7 * * * adops watch --profile acme --notify $SLACK_WEBHOOKWeekly negatives, as a pull request (account structure lives in git via adops export):
# .github/workflows/negatives.yml
on: { schedule: [{ cron: "0 7 * * 1" }] }
jobs:
mine:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm i -g adops
- run: |
adops gads terms --last 7d --min-cost 50 --conv 0 --json \
| adops gads negatives plan - --out plans/
env:
GOOGLE_ADS_DEVELOPER_TOKEN: ${{ secrets.GADS_DEV_TOKEN }}
GOOGLE_ADS_REFRESH_TOKEN: ${{ secrets.GADS_REFRESH_TOKEN }}
- uses: peter-evans/create-pull-request@v6
with: { title: "negatives: weekly mine", branch: bot/negatives }Review the plan in the PR, merge, and a second job runs adops apply. Negative keywords with code review.
The annoying parts (read before building anything on this)
Platform reality, not tool limitations:
- Google Ads developer token. The API requires one per Google Cloud project, and Basic access involves an application Google reviews (days to weeks). Apply on day one; until approval you get test-account access only. This is the long pole for any Google Ads tooling.
- Meta access tiers. Development tier works immediately on ad accounts you own, with lower rate limits; Standard access requires app review. Use a system user token from Business Manager — user tokens expire every 60 days and will break your cron jobs.
- Rate limits. Google meters mutate operations; Meta throttles per ad account and reports usage in
X-Business-Use-Case-Usageheaders.adopsreads those headers and backs off automatically — but a giant bulk job may deliberately take a while. It tells you. - API version churn. Google Ads ships ~3 versions/year and each lives about a year; Meta's Graph API versions sunset on a similar clock.
adopspins versions and runs a daily CI canary against both APIs, so upgrades are a version bump here, not a fire drill in your scripts.
Non-goals
- Meta writes. Meta shipped its own official Ads CLI in April 2026 — creating and editing Meta campaigns from the terminal is a solved, first-party problem. adops keeps Meta reads only, for the cross-platform view. Don't rebuild what the platform now gives you free.
- Dashboards. You have a BI tool;
adopsfeeds it (--json→ BigQuery/Sheets/whatever). - Bid algorithms. The platforms' auto-bidding beats your spreadsheet. We move data and structure, not bids-by-vibes.
- Attribution modeling. Out of scope, permanently.
- Other networks. No TikTok/LinkedIn/Amazon until Google is deep and solid here.
Roadmap
Deepen the Google editor: ad-group & shared-set negatives, clone, rsa, pmax, perf, ideas. Then: Sheets/BigQuery sinks for report · rollback · standalone binaries (no Node) · a daily CI canary against the pinned Google API version · adops mcp (expose every command as an MCP server, so agents drive it with the same plan/apply guardrails you get).
Stack (for contributors)
TypeScript, Node ≥ 20, ESM. No SDKs on either platform — plain REST: Google Ads via googleAds:searchStream + :mutate endpoints (API version pinned in one constant in src/platforms/google.ts), Meta via the Graph API (pinned in src/platforms/meta.ts). Deps: commander, zod, picocolors, smol-toml — that's all. Retry/backoff and Meta usage-header handling live in one seam (src/core/http.ts), which is also where tests mock. Every command returns {columns, rows} internally and only the output layer prints — that's what makes --json/--csv free everywhere. No framework, no telemetry.
Run the tests: npm test (vitest, fixture-based, no network).
License
MIT.
