← Commit history
MIGRATION.mdadded+39
@@ -0,0 +1,39 @@+# Migrating `adom-gmail` → `adom-google`++`adom-google` is a superset of `adom-gmail`. The migration is designed to be zero-touch.++## What changed++| Old (`adom-gmail`)                 | New (`adom-google`)                          |+|------------------------------------|----------------------------------------------|+| `adom-gmail attachments <id>`      | `adom-google gmail attachments <id>`         |+| `adom-gmail read <id> <att> [-o]`  | `adom-google gmail read <id> <att> [-o]`     |+| `adom-gmail ics <id>`              | `adom-google gmail ics <id>`                 |+| `adom-gmail init <id> <secret>`    | `adom-google init <id> <secret>`             |+| `adom-gmail auth`                  | `adom-google auth` (Gateway) / `auth --manual` |+| `adom-gmail auth-code '<code>'`    | `adom-google auth-code '<code>'`             |+| —                                  | `adom-google contacts create/list/search`    |+| scope: `gmail.readonly` only       | `gmail.readonly` + `contacts` (extensible)   |+| auth: localhost loopback paste     | **Adom OAuth Gateway** (loopback as fallback) |++## Nothing breaks++- **`adom-gmail` still works.** It's now a thin shim (`bin/adom-gmail`) that prints a+  deprecation line and `exec`s the matching `adom-google` subcommand. The original script+  is preserved at `the source repo (git history)`.+- **Old config is auto-imported.** On first `adom-google` run, if+  `~/.config/adom-google/config.json` is absent but `~/.config/adom-gmail/config.json`+  exists, its `client_id` / `client_secret` / `refresh_token` are imported and tagged with+  `scopes: [gmail.readonly]`.++## One action to unlock contacts++The imported refresh token only carries `gmail.readonly`, so People API calls would `403`.+Run **`adom-google auth`** once to re-consent with the full scope set+(`gmail.readonly` + `contacts`). After that, `contacts create/list/search` work and Gmail+keeps working unchanged.++## Rollback++`cp the source repo (git history) ~/.local/bin/adom-gmail` restores the original standalone tool.+Its config at `~/.config/adom-gmail/config.json` is untouched by the migration.
README.md+43−1
@@ -1,3 +1,45 @@ # adom-google -Drive your whole Google Workspace from the terminal — Gmail, Drive, Sheets, Docs, Slides, Calendar, Tasks, Chat + any Google API.+![hero](docs/hero.png)++**Your whole Google Workspace, from the terminal — for the Adom team.** One CLI that reads+and writes **Gmail, Contacts, Drive, Sheets, Docs, Slides, Calendar, and Tasks**, plus a+generic `api` passthrough to hit *any* Google API. One-click OAuth through the Adom gateway —+no per-user Google Cloud project, no copied secrets. Employee-only.++## Install++```bash+adompkg install adom-google+adom-google setup        # asks you: safe mode (no delete) or full mode?+```++`setup` walks you through one consent click in your browser; then you're on the board.++## What it does++- **`adom-google api <url>`** — sign any Google REST call with your token. Create a sheet,+  fill a doc template, drop a calendar event, upload to Drive — no per-API code:+  ```bash+  adom-google api -X POST https://sheets.googleapis.com/v4/spreadsheets -d '{"properties":{"title":"BOM"}}'+  adom-google api https://www.googleapis.com/drive/v3/files -q q="name contains 'invoice'"+  ```+- **`adom-google contacts create|list|search`** — store service identities (API portals, etc.).+- **`adom-google gmail attachments|read|ics`** — pull attachment bytes + parse .ics invites.++## Safe vs full mode++On first run you choose:++| Safe (default) | Full |+|---|---|+| Gmail, Contacts, Calendar events, Tasks + only the Drive/Sheets/Docs/Slides files **this app creates**. **No "delete all"** on the consent screen. | Broad read/write/**delete** across **all** your existing Drive/Sheets/Docs/Slides/Calendars. |++## Security++- **No client secret on your container.** The Adom OAuth gateway is a confidential-client+  **token broker** — it does the code→token exchange and refresh; the secret lives only there.+- Your refresh token is stored `0600` in `~/.config/adom-google/`. Secrets are never echoed.+- Each person's reach equals their own Google permissions (admin APIs only for admins).++> [Read the full SKILL.md source](/blob/app/adom-google/SKILL.md)
SKILL.mdadded+142
@@ -0,0 +1,142 @@+---+name: adom-google+description: Programmatic read+write access to a user's WHOLE Google Workspace from an Adom container — Gmail, Contacts, Drive, Sheets, Docs, Slides, Calendar, Tasks — plus a generic `api` passthrough to hit ANY Google API. CLI + OAuth only (no browser automation); employee-only. On FIRST-RUN SETUP you MUST ask the user to pick safe mode (no delete) vs full mode (incl. delete) before authorizing. Use when setting up/installing adom-google, authorizing Google, reading Gmail attachments/.ics, creating/listing/searching Contacts, editing Sheets/Docs/Slides, Drive files, Calendar events, or any Google Workspace automation. Trigger words — adom-google, install adom-google, set up adom-google, configure adom-google, google workspace cli, edit google sheet/doc/slides, drive upload, calendar event, gmail attachment, google contacts, people api, service identity contact, safe mode vs full mode, google oauth gateway, google api passthrough.+---++# adom-google++One CLI for a user's Google Workspace from inside an Adom container. **REST-direct** —+it calls the Google APIs straight, owns auth via the **Adom OAuth Gateway**, and stores a+long-lived refresh token `0600`. No browser automation, no per-user Google Cloud project.++```+adom OAuth layer   one shared Adom OAuth client + Gateway redirect → refresh token (0600)+adom-google        modular wrapper: gmail · contacts · (drive/calendar = +1 scope line)+Google REST APIs   called directly (gmail.googleapis.com, people.googleapis.com, …)+```++## Why REST-direct (not a base-CLI shim)++We evaluated wrapping a base CLI — Google's `googleworkspace/cli` (`gws`, Rust,+Discovery-driven, JSON, agent skills), `gog`/`gogcli` (Go), GAM/GAMADV-XTD3, the+Gemini CLI, and `gcloud`. Conclusion: **call the REST APIs directly.**++- **Auth is the part Adom must own anyway.** One shared internal OAuth client + the+  Gateway's static redirect + a long-lived refresh token is Adom-specific; no base CLI+  speaks it. Adopting one means reverse-engineering its keyring/credential format to+  inject our token — more work than one `requests.post` to the token endpoint.+- **The data surface we need is tiny and stable.** gmail.readonly attachment reads + a+  handful of People API calls. Each is one HTTPS request. A base CLI adds a process+  boundary, an install/version dependency, and output coupling for no benefit at this size.+- **No per-user GCP project.** REST-direct with the shared Adom client satisfies the hard+  constraint. `gws`/`gog`/GAM all push users toward their own OAuth client or `gcloud`.+- **Gemini CLI is the wrong layer** (an AI coding agent, not a Workspace data client) and+  **gcloud can't touch Workspace user data** (GCP-only). `gws` is the closest "official"+  Google Workspace CLI and is a reasonable **future accelerator** if we ever need broad+  Sheets/Docs/Chat coverage — but for Gmail+Contacts it's overkill.++The code is structured so a `gws` backend *could* be slotted behind the same subcommands+later; today the win is zero new runtime dependencies and a clean migration from adom-gmail.++## Install++`install.mjs` symlinks the CLI onto PATH automatically. Manual:++```bash+ln -sf <gallia>/skills/adom-google/bin/adom-google ~/.local/bin/adom-google+ln -sf <gallia>/skills/adom-google/bin/adom-gmail   ~/.local/bin/adom-gmail   # deprecation shim+```++Runtime: Python 3 + `requests` (already in the container). The Gateway WebSocket handshake+is implemented in the stdlib (no `ws`/`websockets` package).++## First-run setup — YOU MUST ASK THE USER which mode (REQUIRED)++When setting up adom-google on a user's container for the first time (it's unconfigured —+`adom-google setup` or any command prints a `Hint:` block saying so), **do NOT pick a mode+for them. Ask.** Use `AskUserQuestion` with these two options:++- **Safe mode (recommended default)** — read/write Gmail, Contacts, Calendar events, Tasks,+  and only the Drive/Sheets/Docs/Slides files *this app creates*. The Google consent screen+  shows **no "see & delete ALL your files"** — nothing scary.+- **Full mode** — broad read/write/**delete** across **all** the user's existing+  Drive/Sheets/Docs/Slides/Calendars. Only if they explicitly want full control.++Then run exactly one:+```bash+adom-google auth          # safe mode  (user picked safe)+adom-google auth --full   # full mode  (user picked full — includes delete)+```+The CLI emits this same instruction as `Hint:` lines whenever it's unconfigured, so you'll+be reminded at the exact moment of need even if this skill has been compacted out of context.++## Auth — one consent, long-lived refresh token++```bash+adom-google auth            # SAFE mode (default): no "delete all" on the consent screen+adom-google auth --full     # FULL mode: broad access incl. delete (trusted users)+adom-google auth --manual   # fallback: desktop-client paste flow (combine with --full)+adom-google auth-code '<code>'   # finish --manual+adom-google status          # shows mode + what's authorised (never prints secrets)+```++### How Adom handles the OAuth (Gateway broker flow)++Google requires one fixed `redirect_uri`, but every container has a unique hostname. The+Adom OAuth Gateway gives one static callback for all containers **and** acts as a+confidential-client **token broker**, so the client secret never reaches user containers:++1. `adom-google auth` builds the Google consent URL with+   `redirect_uri = https://oauth-9mycwxij7tif.adom.cloud/callback` and a random `state`,+   and opens a WebSocket to the Gateway registering `{state, app:"adom-google", exchange:true}`.+2. The user approves in their browser (their own @adom.inc Workspace account).+3. Google redirects to the Gateway `/callback`; the Gateway **does the code→token exchange+   itself** (using the secret it alone holds) and pushes `{type:"tokens", …}` over the WS.+4. The container stores only the **refresh token** `0600` — no client secret. Later refreshes+   go through the gateway's `POST /refresh`, so the secret stays on the gateway forever.++`bin/credentials.json` ships **only the public client_id** (it appears in every consent URL+anyway) — **no secret in the package.** The secret lives in the gateway's `creds/adom-google.json`+(0600, gitignored). The backing GCP project (Internal/adom.inc consent) enables whichever APIs+the user needs (People, Gmail, Drive, Sheets, Docs, Slides, Calendar, Tasks) and registers the+gateway callback as an authorized redirect URI.++If the Gateway is down, `auth --manual` uses a desktop-client loopback paste flow (that one+exchanges locally and does need a client secret via `init`).++## Commands++```bash+# Gmail (read-only; behaviour-compatible with old adom-gmail)+adom-google gmail attachments <messageId>+adom-google gmail read <messageId> <attachmentId> [-o file]+adom-google gmail ics  <messageId>++# Contacts (People API; read + write)+adom-google contacts create --name "First Last" [--org O] [--title T] \+    [--email [email protected] ...] [--phone +1... ...] [--notes "..."] [--json]+adom-google contacts list [--limit N] [--json]+adom-google contacts search <query> [-v] [--json]+```++We use Google Contacts to store **service identities** (portal/account metadata for an API+vendor), e.g. the JPMorgan Payments developer portal. Keep secrets (client secrets, portal+passwords) in a password manager — **never** in a contact's notes.++## Adding Drive / Calendar++Add the scope to `SCOPES` at the top of `bin/adom-google`, re-run `adom-google auth`, and+add a few REST calls against `www.googleapis.com/drive/v3` or `/calendar/v3`. Token refresh+and the `_request()` helper are already generic.++## Security++- Config `~/.config/adom-google/config.json` is created `0600`; the client secret and+  refresh token are never echoed to stdout/stderr or logs (`status` prints presence only).+- The Gateway only ever sees the short-lived auth `code`, never tokens or the client secret.++## Migration from adom-gmail++See [MIGRATION.md](MIGRATION.md). Short version: `adom-gmail` is now a forwarding shim;+its old `~/.config/adom-gmail/config.json` is auto-imported on first `adom-google` run+(gmail.readonly only — run `adom-google auth` once to add the `contacts` scope).
WIKI-PUBLISH.mdadded+75
@@ -0,0 +1,75 @@+# Publishing adom-google to Wiki v2++This file is your **runbook** for syncing this package to the wiki. Any Claude+thread can read this and know exactly how to push updates without re-learning+the API. Auto-generated by `adom-wiki-publish create`.++## Quick sync: "update the wiki for adom-google"++The simplest path uses the CLI:++```bash+# Push specific files (commits are free — no version bump)+adom-wiki-publish push adom-google --files README.md SKILL.md -m "Update docs"++# When ready to cut a new installable release+adom-wiki-publish publish adom-google --bump patch+```++## Verifying it worked++```bash+# Show page status (commits, version, hero status)+adom-wiki-publish status adom-google++# Open the wiki page in pup and screenshot it for visual verification+adom-wiki-publish verify adom-google+```++## Bundle info++- **Type:** app+- **Wiki page:** https://git-wiki-ktqxite5iglh.adom.cloud/pages/adom-google+- **Install:** `adompkg install adom-google`+- **Source:** this repository (commit, push, then sync to wiki)++## Adding a new file++Use `adom-wiki-publish push`:++```bash+adom-wiki-publish push adom-google --files path/to/new-file.md -m "Add new-file"+```++The CLI auto-detects binary files (.png, .jpg, .pdf, etc.) and base64-encodes them.+Text files get scanned for secrets before pushing.++## Hero image++Hero images live in `docs/hero.png` (or `docs/hero.jpg`). The `publish` command+auto-uploads it and sets the wiki metadata.++**Hero quality rules** (enforced by the linter):+- BEST: Screenshot of the real tool running with real data+- GOOD: Screenshot of real CLI output in a terminal+- ACCEPTABLE: Screenshot of a real app the skill controls+- BAD: Screenshot of the wiki page itself (circular)+- NEVER: AI-generated cards, stock photos, ImageMagick text-on-background++If you can't capture a real screenshot right now, defer — do not fake one.++## Running the linter++```bash+adom-wiki-publish lint .+```++Before any `publish`, the linter runs automatically. Fix any FAIL checks before+the tarball will upload.++## Notes++- This file is committed to git so any future Claude session can read it.+- The CLI auto-updates the skill list (for bundles) when you `push` new sub-skills.+- For more details on the CLI: `adom-wiki-publish --help` or read the+  `adom-wiki-publish` skill at `~/.claude/skills/adom-wiki-publish/SKILL.md`.
bin/adom-gmailadded+41
@@ -0,0 +1,41 @@+#!/usr/bin/env python3+"""adom-gmail — DEPRECATED shim. Superseded by `adom-google` (which generalises Gmail ++Contacts + Drive/Calendar). This forwards old commands so nothing breaks; please switch to+`adom-google gmail …`. Original preserved at+gallia/skills/adom-google/adom-gmail.legacy.py.+"""+import sys, os, shutil++TARGET = shutil.which("adom-google") or os.path.expanduser("~/.local/bin/adom-google")++# Old adom-gmail subcommands → adom-google argv.+MAP = {+    "attachments": ["gmail", "attachments"],+    "read":        ["gmail", "read"],+    "ics":         ["gmail", "ics"],+    "init":        ["init"],+    "auth":        ["auth", "--manual"],   # adom-gmail used the desktop-client paste flow+    "auth-code":   ["auth-code"],+    "help":        ["help"],+}+++def main():+    if not os.path.exists(TARGET):+        sys.exit("adom-gmail is deprecated and adom-google is not installed.\n"+                 "Install it from the gallia 'adom-google' skill.")+    args = sys.argv[1:]+    if not args or args[0] in ("-h", "--help"):+        args = ["help"]+    cmd = args[0]+    if cmd not in MAP:+        sys.stderr.write(f"adom-gmail: unknown command '{cmd}'. Use `adom-google help`.\n")+        sys.exit(2)+    if cmd not in ("help",):+        sys.stderr.write("[adom-gmail] deprecated → forwarding to "+                         f"`adom-google {' '.join(MAP[cmd])}`. Switch when convenient.\n")+    os.execv(TARGET, [TARGET] + MAP[cmd] + args[1:])+++if __name__ == "__main__":+    main()
bin/adom-googleadded+773
@@ -0,0 +1,773 @@+#!/usr/bin/env python3+"""adom-google — programmatic access to a user's Google Workspace from an Adom container.++Layered, REST-direct CLI (no browser automation, no heavyweight base CLI):++  ┌─ adom OAuth layer ── one shared Adom OAuth client + OAuth Gateway redirect →+  │                      a long-lived refresh token stored 0600 per user+  ├─ adom-google ─────── thin, modular wrapper exposing: gmail / contacts /+  │                      (drive, calendar trivially addable — same token plumbing)+  └─ base ────────────── the Google REST APIs themselves, called directly++Generalises (and replaces) the old `adom-gmail`, which was gmail.readonly-only.+The gmail subcommands here are behaviour-compatible with adom-gmail, and the old+~/.config/adom-gmail/config.json is auto-imported on first run.++Auth (one consent, long-lived refresh token):+  adom-google auth            # Adom way — uses the OAuth Gateway (no localhost loopback)+  adom-google auth --manual   # fallback — desktop-client paste flow (like adom-gmail)+  adom-google auth-code '<code>'   # finish the --manual flow+  adom-google status          # show what's authorised (never prints secrets)++See `adom-google help`.+"""+import sys, os, json, base64, argparse, urllib.parse, ssl, socket, hashlib, secrets, struct, time+import requests++CFG_DIR = os.path.expanduser("~/.config/adom-google")+CFG = os.path.join(CFG_DIR, "config.json")+LEGACY_CFG = os.path.expanduser("~/.config/adom-gmail/config.json")+HERE = os.path.dirname(os.path.realpath(__file__))  # realpath: resolve the ~/.local/bin symlink++# Scopes adom-google requests. Adding drive/calendar later = add one line here and+# re-run `adom-google auth`; everything else (token refresh, API calls) is generic.+# Two scope PROFILES. The consent screen's wording is driven entirely by these.+#   SAFE (default): no "see/delete ALL your files/sheets/calendars" lines. Uses narrowed+#     scopes — drive.file (only files this app creates/opens), calendar.events, gmail.modify+#     (NO permanent delete). Can create+edit Sheets/Docs/Slides it makes (via drive.file)+#     without the broad spreadsheets/documents/presentations "delete all" scopes.+#   FULL (opt-in, `auth --full`): the broad set — can touch ALL of the user's existing+#     Drive/Sheets/Docs/Slides/Calendars. For trusted users who want the whole board.+SCOPES_SAFE = [+    "https://www.googleapis.com/auth/gmail.modify",        # Gmail read/compose/send/labels (NOT permanent-delete)+    "https://www.googleapis.com/auth/contacts",            # People API — contacts read+write+    "https://www.googleapis.com/auth/drive.file",          # only files THIS app creates/opens (not "all your files")+    "https://www.googleapis.com/auth/calendar.events",     # calendar EVENTS (not "delete all your calendars")+    "https://www.googleapis.com/auth/tasks",               # Tasks+]+SCOPES_FULL = [+    "https://www.googleapis.com/auth/gmail.modify",        # Gmail read/compose/send/labels+    "https://www.googleapis.com/auth/contacts",            # contacts read+write+    "https://www.googleapis.com/auth/drive",               # ALL Drive files, sharing, permissions+    "https://www.googleapis.com/auth/spreadsheets",        # ALL Sheets — read+edit+    "https://www.googleapis.com/auth/documents",           # ALL Docs — read+edit+    "https://www.googleapis.com/auth/presentations",       # ALL Slides — read+edit+    "https://www.googleapis.com/auth/calendar",            # full Calendar+    "https://www.googleapis.com/auth/tasks",               # Tasks+    "https://www.googleapis.com/auth/chat.messages",       # Google Chat — read + POST messages as you (user OAuth)+    "https://www.googleapis.com/auth/chat.spaces.readonly", # Google Chat — list the spaces you're in+    # (Chat works because the shared rock-verbena project has a configured Chat app.)+    # Admin/Directory scopes stay OUT — separate admin-only opt-in.+]+++def _scopes_for(mode):+    return SCOPES_FULL if mode == "full" else SCOPES_SAFE++TOKEN_URL = "https://oauth2.googleapis.com/token"+AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"+GMAIL_API = "https://gmail.googleapis.com/gmail/v1/users/me"+PEOPLE_API = "https://people.googleapis.com/v1"++# --- Adom OAuth Gateway (one static redirect for every container; see gallia oauth skill) ---+GATEWAY_URL = os.environ.get("OAUTH_GATEWAY_URL", "https://oauth-9mycwxij7tif.adom.cloud")+GATEWAY_WS = os.environ.get("OAUTH_GATEWAY_WS", "wss://oauth-9mycwxij7tif.adom.cloud")+GATEWAY_REDIRECT = GATEWAY_URL.rstrip("/") + "/callback"+# Manual (desktop-client) flow redirect — loopback, user copies code from the URL bar.+MANUAL_REDIRECT = "http://localhost"+APP = "adom-google"   # broker app id — the gateway holds this app's client secret++# AI hint emitted whenever adom-google is unconfigured. Per ai-hints-in-clis: this is how the+# CLI forces the installing AI to ASK THE USER the safe/full choice at the moment of setup,+# instead of silently picking. Read at the moment of need — survives context compaction.+SETUP_HINT = """ERROR: adom-google is not authorized yet (no Google token for this container).+Hint: BEFORE running auth, ASK THE USER which access mode they want — do NOT choose for them.+Hint: Present it as a clear question (use AskUserQuestion). The two modes:+Hint:   • safe  (recommended default) — read/write Gmail, Contacts, Calendar events, Tasks, and+Hint:           ONLY the Drive/Sheets/Docs/Slides files THIS app creates. The consent screen shows+Hint:           NO "see & delete ALL your files/spreadsheets/calendars" — nothing scary.+Hint:   • full  — broad read/write/DELETE across ALL the user's existing Drive/Sheets/Docs/Slides/+Hint:           Calendars. Only for users who explicitly want full control incl. delete.+Hint: After the user picks, run EXACTLY ONE of:+Hint:     adom-google auth          # safe mode+Hint:     adom-google auth --full   # full mode (includes delete)+Hint: The auth command prints a consent URL — open it in the user's browser; they click Allow once."""+++# ---------------------------------------------------------------- config / creds+def _load():+    cfg = _maybe_load_raw()+    if cfg is None:+        sys.exit(SETUP_HINT)+    if not cfg.get("refresh_token"):+        sys.exit(SETUP_HINT)+    return cfg+++def _maybe_load_raw():+    if os.path.exists(CFG):+        with open(CFG) as f:+            return json.load(f)+    # One-time migration from adom-gmail.+    if os.path.exists(LEGACY_CFG):+        with open(LEGACY_CFG) as f:+            legacy = json.load(f)+        legacy.setdefault("scopes", ["https://www.googleapis.com/auth/gmail.readonly"])+        legacy["_migrated_from"] = LEGACY_CFG+        _save(legacy)+        sys.stderr.write(f"[adom-google] imported credentials from {LEGACY_CFG} "+                         f"(gmail.readonly only — run `adom-google auth` to add contacts).\n")+        return legacy+    return None+++def _save(cfg):+    os.makedirs(CFG_DIR, exist_ok=True)+    fd = os.open(CFG, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)+    with os.fdopen(fd, "w") as f:+        json.dump(cfg, f, indent=2)+    os.chmod(CFG, 0o600)+++def _bundled_client():+    """The shared Adom OAuth client (ships with the skill). Used by the gateway flow so+    users don't each create a Google Cloud project. Returns (client_id, client_secret)."""+    path = os.path.join(HERE, "credentials.json")+    if not os.path.exists(path):+        return None, None+    with open(path) as f:+        c = json.load(f)+    return c.get("clientId"), c.get("clientSecret")+++def _client_creds(cfg):+    """client_id (public) + client_secret. In BROKER mode the secret is None — it lives+    only on the gateway; the manual/local flow still uses a real secret."""+    cid = cfg.get("client_id") or _bundled_client()[0]+    csec = cfg.get("client_secret") or _bundled_client()[1]+    if not cid:+        sys.exit("No OAuth client_id available (need bundled credentials.json or `init`).")+    return cid, csec+++# ---------------------------------------------------------------- token / API+def _access_token(cfg):+    # Reuse a cached access token while it's still valid.+    if cfg.get("access_token") and cfg.get("access_expires", 0) > time.time():+        return cfg["access_token"]+    if cfg.get("broker"):+        # Confidential-client broker: the gateway holds the secret and does the refresh.+        r = requests.post(GATEWAY_URL.rstrip("/") + "/refresh",+                          json={"app": cfg.get("app", APP), "refresh_token": cfg["refresh_token"]},+                          timeout=30)+        if r.status_code != 200:+            sys.exit(f"Gateway token refresh failed ({r.status_code}). Re-run `adom-google auth`.")+        j = r.json()+    else:+        cid, csec = _client_creds(cfg)+        if not csec:+            sys.exit("No client secret for local refresh. Re-run `adom-google auth` (uses the broker).")+        r = requests.post(TOKEN_URL, data={+            "client_id": cid, "client_secret": csec,+            "refresh_token": cfg["refresh_token"], "grant_type": "refresh_token",+        }, timeout=30)+        if r.status_code != 200:+            sys.exit(f"Token refresh failed ({r.status_code}). Re-run `adom-google auth`.")+        j = r.json()+    cfg["access_token"] = j["access_token"]+    cfg["access_expires"] = time.time() + int(j.get("expires_in", 3600)) - 60+    _save(cfg)+    return cfg["access_token"]+++def _request(cfg, method, url, **kw):+    tok = _access_token(cfg)+    headers = kw.pop("headers", {})+    headers["Authorization"] = f"Bearer {tok}"+    r = requests.request(method, url, headers=headers, timeout=30, **kw)+    if r.status_code == 403 and "people.googleapis.com" in url:+        sys.exit(f"403 from People API ({r.status_code}). The stored token likely lacks the "+                 f"contacts scope — run `adom-google auth` to re-consent with the full scope set.")+    if r.status_code >= 400:+        sys.exit(f"Google API error ({r.status_code}) {method} {url}:\n{r.text}")+    return r.json() if r.content else {}+++def _gmail(cfg, path):+    return _request(cfg, "GET", f"{GMAIL_API}/{path}")+++def _has_scope(cfg, scope):+    return scope in (cfg.get("scopes") or [])+++# ---------------------------------------------------------------- gmail (migrated)+def _walk_parts(part, out):+    body = part.get("body", {})+    if body.get("attachmentId"):+        out.append({+            "attachmentId": body["attachmentId"],+            "filename": part.get("filename", ""),+            "mimeType": part.get("mimeType", ""),+            "size": body.get("size", 0),+        })+    for p in part.get("parts", []):+        _walk_parts(p, out)+++def _list_attachments(cfg, msg_id):+    msg = _gmail(cfg, f"messages/{msg_id}")+    out = []+    _walk_parts(msg.get("payload", {}), out)+    return out+++def _fetch_attachment_bytes(cfg, msg_id, att_id):+    data = _gmail(cfg, f"messages/{msg_id}/attachments/{att_id}")["data"]+    return base64.urlsafe_b64decode(data + "===")+++def _unfold(text):+    lines, cur = [], ""+    for raw in text.replace("\r\n", "\n").split("\n"):+        if raw[:1] in (" ", "\t"):+            cur += raw[1:]+        else:+            if cur:+                lines.append(cur)+            cur = raw+    if cur:+        lines.append(cur)+    return lines+++def _fmt_dt(val):+    v = val.strip()+    try:+        if v.endswith("Z"):+            return f"{v[0:4]}-{v[4:6]}-{v[6:8]} {v[9:11]}:{v[11:13]} UTC"+        if "T" in v and len(v) >= 15:+            return f"{v[0:4]}-{v[4:6]}-{v[6:8]} {v[9:11]}:{v[11:13]}"+        if len(v) == 8:+            return f"{v[0:4]}-{v[4:6]}-{v[6:8]} (all day)"+    except Exception:+        pass+    return v+++def parse_ics(raw):+    text = raw.decode("utf-8", "replace")+    events, ev = [], None+    for line in _unfold(text):+        if line.startswith("BEGIN:VEVENT"):+            ev = {}+        elif line.startswith("END:VEVENT"):+            if ev is not None:+                events.append(ev)+            ev = None+        elif ev is not None and ":" in line:+            key, val = line.split(":", 1)+            name = key.split(";", 1)[0].upper()+            tzid = ""+            if "TZID=" in key:+                tzid = " " + key.split("TZID=", 1)[1].split(";")[0]+            if name in ("SUMMARY", "LOCATION", "DESCRIPTION", "ORGANIZER", "STATUS"):+                ev[name] = val.replace("\\,", ",").replace("\\n", " ").replace("\\;", ";")+            elif name in ("DTSTART", "DTEND"):+                ev[name] = _fmt_dt(val) + tzid+            elif name == "ATTENDEE":+                ev.setdefault("ATTENDEES", []).append(val.replace("mailto:", ""))+    return events+++def cmd_gmail_attachments(a):+    cfg = _load()+    atts = _list_attachments(cfg, a.message_id)+    if not atts:+        print("(no attachments on this message)")+        return+    for x in atts:+        print(f"{x['mimeType']:24} {x['size']:>8}B  {x['filename']}\n    id: {x['attachmentId']}")+++def cmd_gmail_read(a):+    cfg = _load()+    data = _fetch_attachment_bytes(cfg, a.message_id, a.attachment_id)+    if a.out:+        with open(a.out, "wb") as f:+            f.write(data)+        print(f"Wrote {len(data)} bytes to {a.out}")+    else:+        sys.stdout.buffer.write(data)+++def cmd_gmail_ics(a):+    cfg = _load()+    atts = [x for x in _list_attachments(cfg, a.message_id)+            if x["filename"].lower().endswith(".ics") or "calendar" in x["mimeType"]]+    if not atts:+        sys.exit("No .ics/calendar attachment found on this message.")+    for x in atts:+        raw = _fetch_attachment_bytes(cfg, a.message_id, x["attachmentId"])+        for ev in parse_ics(raw):+            print("── Calendar event ──")+            for label in ("SUMMARY", "DTSTART", "DTEND", "LOCATION", "STATUS", "ORGANIZER"):+                if ev.get(label):+                    print(f"  {label.title():10}: {ev[label]}")+            if ev.get("ATTENDEES"):+                print(f"  Attendees : {', '.join(ev['ATTENDEES'])}")+            if ev.get("DESCRIPTION"):+                print(f"  Notes     : {ev['DESCRIPTION']}")+            print()+++# ---------------------------------------------------------------- contacts (People API)+def cmd_contacts_create(a):+    cfg = None if a.dry_run else _load()+    person = {}+    if a.name:+        parts = a.name.split(" ", 1)+        person["names"] = [{"givenName": parts[0], "familyName": parts[1] if len(parts) > 1 else ""}]+    if a.email:+        person["emailAddresses"] = [{"value": e} for e in a.email]+    if a.org or a.title:+        org = {}+        if a.org:+            org["name"] = a.org+        if a.title:+            org["title"] = a.title+        person["organizations"] = [org]+    if a.phone:+        person["phoneNumbers"] = [{"value": p} for p in a.phone]+    if a.notes:+        # Google Contacts "Notes" == People API biographies (plain text).+        person["biographies"] = [{"value": a.notes, "contentType": "TEXT_PLAIN"}]+    if a.dry_run:+        print("DRY RUN — would POST to people:createContact:\n")+        print(json.dumps(person, indent=2))+        return+    res = _request(cfg, "POST", f"{PEOPLE_API}/people:createContact",+                   headers={"Content-Type": "application/json"}, json=person)+    rn = res.get("resourceName", "?")+    nm = (res.get("names") or [{}])[0].get("displayName", a.name or "(no name)")+    print(f"✅ Created contact: {nm}")+    print(f"   resourceName: {rn}")+    if res.get("organizations"):+        print(f"   org: {res['organizations'][0].get('name','')}")+    if a.json:+        print(json.dumps(res, indent=2))+++def cmd_contacts_list(a):+    cfg = _load()+    params = {+        "personFields": "names,emailAddresses,organizations,phoneNumbers",+        "pageSize": str(a.limit),+        "sortOrder": "LAST_MODIFIED_DESCENDING",+    }+    res = _request(cfg, "GET", f"{PEOPLE_API}/people/me/connections?{urllib.parse.urlencode(params)}")+    conns = res.get("connections", [])+    if a.json:+        print(json.dumps(conns, indent=2))+        return+    if not conns:+        print("(no contacts)")+        return+    for c in conns:+        name = (c.get("names") or [{}])[0].get("displayName", "(no name)")+        email = (c.get("emailAddresses") or [{}])[0].get("value", "")+        org = (c.get("organizations") or [{}])[0].get("name", "")+        line = f"{name:32} {email:30}"+        if org:+            line += f" [{org}]"+        print(line.rstrip())+    if res.get("totalpeople"):+        print(f"\n({len(conns)} shown of {res['totalpeople']} total)")+++def cmd_contacts_search(a):+    cfg = _load()+    # People API requires a "warmup" request before the first searchContacts call.+    base = f"{PEOPLE_API}/people:searchContacts"+    rm = "names,emailAddresses,organizations,phoneNumbers,biographies"+    requests.get(base, params={"query": "", "readMask": rm},+                 headers={"Authorization": f"Bearer {_access_token(cfg)}"}, timeout=30)+    time.sleep(1)+    res = _request(cfg, "GET", f"{base}?{urllib.parse.urlencode({'query': a.query, 'readMask': rm, 'pageSize': '20'})}")+    results = res.get("results", [])+    if a.json:+        print(json.dumps(results, indent=2))+        return+    if not results:+        print("(no matches)")+        return+    for r in results:+        p = r.get("person", {})+        name = (p.get("names") or [{}])[0].get("displayName", "(no name)")+        email = (p.get("emailAddresses") or [{}])[0].get("value", "")+        org = (p.get("organizations") or [{}])[0].get("name", "")+        print(f"{name:32} {email:30}" + (f" [{org}]" if org else ""))+        notes = (p.get("biographies") or [{}])[0].get("value", "")+        if a.verbose and notes:+            print(f"    notes: {notes}")+        if a.verbose:+            print(f"    resourceName: {p.get('resourceName','')}")+++# ---------------------------------------------------------------- generic API passthrough+def cmd_api(a):+    """Sign ANY Google REST/Discovery call with the stored token — the 'whole board'.+    Reach of this command == the scopes the stored token holds (see `status`); add scopes+    in SCOPES + re-`auth` to widen it, and enable the API in the GCP project.++    Examples:+      adom-google api https://sheets.googleapis.com/v4/spreadsheets/<id>/values/A1:D10+      adom-google api -X POST https://sheets.googleapis.com/v4/spreadsheets -d '{"properties":{"title":"BOM"}}'+      adom-google api -X POST https://docs.googleapis.com/v1/documents/<id>:batchUpdate -d @reqs.json+    """+    cfg = _load()+    url = a.url if a.url.startswith("http") else "https://" + a.url.lstrip("/")+    headers = {"Authorization": f"Bearer {_access_token(cfg)}"}+    body = None+    if a.data is not None:+        body = open(a.data[1:]).read() if a.data.startswith("@") else a.data+        headers["Content-Type"] = "application/json"+    params = dict(q.split("=", 1) for q in (a.query or []))+    r = requests.request(a.method.upper(), url, headers=headers,+                         params=params or None, data=body, timeout=120)+    sys.stderr.write(f"HTTP {r.status_code} {a.method.upper()} {url}\n")+    ct = r.headers.get("content-type", "")+    if a.raw or "application/json" not in ct:+        sys.stdout.buffer.write(r.content)+    else:+        print(json.dumps(r.json(), indent=2, ensure_ascii=False))+    sys.exit(0 if r.status_code < 400 else 1)+++# ---------------------------------------------------------------- auth: gateway flow+def _minimal_ws_roundtrip(ws_url, send_msgs, want_types, timeout=600):+    """Tiny RFC6455 client (stdlib only): connect, send JSON text frames, return the first+    received JSON message whose 'type' == want_type. Used for the OAuth Gateway handshake so+    adom-google stays a single dependency-light file (no `ws`/`websockets` package).++    NOTE: this path is exercised only when the gateway is live. The `--manual` flow is the+    fallback that works without the gateway."""+    u = urllib.parse.urlparse(ws_url)+    host = u.hostname+    port = u.port or (443 if u.scheme == "wss" else 80)+    path = u.path or "/"+    key = base64.b64encode(secrets.token_bytes(16)).decode()++    raw = socket.create_connection((host, port), timeout=30)+    if u.scheme == "wss":+        raw = ssl.create_default_context().wrap_socket(raw, server_hostname=host)+    req = (f"GET {path} HTTP/1.1\r\nHost: {host}\r\nUpgrade: websocket\r\n"+           f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n"+           f"Sec-WebSocket-Version: 13\r\n\r\n")+    raw.sendall(req.encode())++    # read handshake response headers+    buf = b""+    while b"\r\n\r\n" not in buf:+        chunk = raw.recv(4096)+        if not chunk:+            raise RuntimeError("gateway closed during handshake")+        buf += chunk+    if b" 101 " not in buf.split(b"\r\n", 1)[0]:+        raise RuntimeError("gateway did not accept websocket upgrade: " + buf[:80].decode("latin1"))+    leftover = buf.split(b"\r\n\r\n", 1)[1]++    def _send_text(s):+        payload = s.encode()+        mask = secrets.token_bytes(4)+        masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))+        header = b"\x81"  # FIN + text+        n = len(payload)+        if n < 126:+            header += struct.pack("!B", 0x80 | n)+        elif n < 65536:+            header += struct.pack("!BH", 0x80 | 126, n)+        else:+            header += struct.pack("!BQ", 0x80 | 127, n)+        raw.sendall(header + mask + masked)++    inbuf = bytearray(leftover)++    def _recv_frame():+        nonlocal inbuf+        while True:+            while len(inbuf) < 2:+                inbuf += raw.recv(4096)+            b0, b1 = inbuf[0], inbuf[1]+            opcode = b0 & 0x0F+            ln = b1 & 0x7F+            idx = 2+            if ln == 126:+                while len(inbuf) < 4:+                    inbuf += raw.recv(4096)+                ln = struct.unpack("!H", inbuf[2:4])[0]; idx = 4+            elif ln == 127:+                while len(inbuf) < 10:+                    inbuf += raw.recv(4096)+                ln = struct.unpack("!Q", inbuf[2:10])[0]; idx = 10+            while len(inbuf) < idx + ln:+                inbuf += raw.recv(4096)+            data = bytes(inbuf[idx:idx + ln])+            del inbuf[:idx + ln]+            if opcode == 0x8:  # close+                raise RuntimeError("gateway closed the connection")+            if opcode == 0x9:  # ping → pong+                raw.sendall(b"\x8a\x80" + secrets.token_bytes(4))+                continue+            if opcode in (0x1, 0x2):+                return data++    for m in send_msgs:+        _send_text(json.dumps(m))++    raw.settimeout(timeout)+    deadline = time.time() + timeout+    try:+        while time.time() < deadline:+            data = _recv_frame()+            try:+                msg = json.loads(data.decode())+            except Exception:+                continue+            if msg.get("type") == "error":+                raise RuntimeError("gateway error: " + str(msg.get("error") or msg.get("detail")))+            if msg.get("type") in want_types:+                return msg+    finally:+        try:+            raw.close()+        except Exception:+            pass+    raise RuntimeError("timed out waiting for the gateway callback")+++def _exchange_code(cfg, code, redirect_uri, scopes, mode):+    cid, csec = _client_creds(cfg)+    r = requests.post(TOKEN_URL, data={+        "client_id": cid, "client_secret": csec, "code": code,+        "redirect_uri": redirect_uri, "grant_type": "authorization_code",+    }, timeout=30)+    if r.status_code != 200:+        sys.exit(f"Code exchange failed ({r.status_code}): {r.text}")+    rt = r.json().get("refresh_token")+    if not rt:+        sys.exit("No refresh_token returned (re-run auth; needs access_type=offline & prompt=consent).")+    cfg["refresh_token"] = rt+    cfg["scopes"] = scopes+    cfg["mode"] = mode+    cfg["broker"] = False+    cfg.pop("_migrated_from", None)+    cfg.pop("_pending_mode", None)+    _save(cfg)+++def _store_broker_tokens(cfg, tokens, scopes, mode):+    """Gateway broker returned the tokens directly — no client secret on this container."""+    cfg["refresh_token"] = tokens["refresh_token"]+    if tokens.get("access_token"):+        cfg["access_token"] = tokens["access_token"]+        cfg["access_expires"] = time.time() + int(tokens.get("expires_in", 3600)) - 60+    cfg["scopes"] = scopes+    cfg["mode"] = mode+    cfg["broker"] = True+    cfg["app"] = APP+    cfg.pop("client_secret", None)   # broker mode: never keep a secret locally+    cfg.pop("_migrated_from", None)+    cfg.pop("_pending_mode", None)+    _save(cfg)+++def cmd_auth(a):+    cfg = _maybe_load_raw() or {}+    mode = "full" if a.full else "safe"+    scopes = _scopes_for(mode)+    if a.manual:+        return _auth_manual(cfg, scopes, mode)+    # Gateway flow (the Adom way) — one static redirect, code routed back over WebSocket.+    cid, _ = _client_creds(cfg)+    state = secrets.token_urlsafe(24)+    params = {+        "client_id": cid, "redirect_uri": GATEWAY_REDIRECT, "response_type": "code",+        "scope": " ".join(scopes), "access_type": "offline", "prompt": "consent",+        "state": state,+    }+    auth_link = AUTH_URL + "?" + urllib.parse.urlencode(params)+    print(f"Adom OAuth Gateway flow — {mode.upper()} mode"+          + ("  (full Drive/Sheets/Docs/Slides + delete)" if mode == "full"+             else "  (safe: only files this app makes; no 'delete all')") + ".\n")+    print("1) Open this URL and approve (use your @adom.inc Workspace account):\n")+    print("   " + auth_link + "\n")+    print("2) Waiting for the gateway… (up to 10 min)")+    try:+        # Broker mode: the gateway exchanges the code and returns tokens (the client+        # secret stays on the gateway). Falls back to relayed code if it can't broker.+        msg = _minimal_ws_roundtrip(+            GATEWAY_WS,+            send_msgs=[{"type": "register", "state": state, "provider": "google",+                        "app": APP, "exchange": True}],+            want_types=("tokens", "callback"),+        )+    except Exception as e:+        sys.exit(f"\nGateway flow failed: {e}\n"+                 f"Is the OAuth Gateway up? ({GATEWAY_URL})\n"+                 f"Fallback: run `adom-google auth --manual`.")+    if msg.get("state") != state:+        sys.exit("State mismatch from gateway — aborting.")+    if msg.get("type") == "tokens":+        _store_broker_tokens(cfg, msg["tokens"], scopes, mode)+        print(f"\n✅ Authorized via gateway broker ({mode} mode). Token stored (0600); "+              "client secret never touched this container.")+    else:+        _exchange_code(cfg, msg["code"], GATEWAY_REDIRECT, scopes, mode)+        print(f"\n✅ Authorized via gateway ({mode} mode). Refresh token stored (0600).")+    print("   Scopes: " + ", ".join(s.split("/")[-1] for s in scopes))+++def _auth_manual(cfg, scopes, mode):+    cid, _ = _client_creds(cfg)+    if not cfg.get("client_id"):+        print("Manual flow uses the bundled Adom OAuth client, but its redirect must allow")+        print(f"`{MANUAL_REDIRECT}`. If you hit redirect_uri_mismatch, run:")+        print("   adom-google init <your_desktop_client_id> <your_desktop_client_secret>\n")+    cfg["_pending_mode"] = mode       # so auth-code knows which scope profile was requested+    _save(cfg)+    params = {+        "client_id": cid, "redirect_uri": MANUAL_REDIRECT, "response_type": "code",+        "scope": " ".join(scopes), "access_type": "offline", "prompt": "consent",+    }+    print(f"Manual (desktop-client) flow — {mode.upper()} mode.\n")+    print("1) Open this URL and approve:\n")+    print("   " + AUTH_URL + "?" + urllib.parse.urlencode(params))+    print("\n2) The page will fail to load (expected). Copy the `code` value from the address")+    print("   bar, then run:\n\n   adom-google auth-code '<paste-code-here>'")+++def cmd_auth_code(a):+    cfg = _maybe_load_raw() or {}+    mode = cfg.get("_pending_mode", "safe")+    scopes = _scopes_for(mode)+    _exchange_code(cfg, a.code, MANUAL_REDIRECT, scopes, mode)+    print(f"✅ Authorized ({mode} mode). Refresh token stored (0600).")+    print("   Scopes: " + ", ".join(s.split("/")[-1] for s in scopes))+++def cmd_init(a):+    cfg = _maybe_load_raw() or {}+    cfg["client_id"] = a.client_id+    cfg["client_secret"] = a.client_secret+    _save(cfg)+    print(f"Saved client credentials to {CFG}. Next: `adom-google auth --manual`")+++def cmd_setup(a):+    # Canonical first-run entry point. Always prints the ask-the-user hint (even mid-config),+    # so the installing AI is reminded to ask the safe/full question before authorizing.+    print(SETUP_HINT)+++def cmd_status(a):+    cfg = _maybe_load_raw()+    if not cfg or not cfg.get("refresh_token"):+        print(SETUP_HINT)+        return+    cid = cfg.get("client_id") or _bundled_client()[0] or "(none)"+    print(f"Config:      {CFG}")+    print(f"OAuth client: {cid[:18]}… ({'manual/explicit' if cfg.get('client_id') else 'bundled Adom client'})")+    print(f"Refresh token: {'present' if cfg.get('refresh_token') else 'MISSING — run auth'}")+    print(f"Mode:         {cfg.get('mode', '(unset)')}  (safe = no 'delete all'; full = broad)")+    print(f"Auth:         {'broker (secret on gateway, none here)' if cfg.get('broker') else 'local (client secret in config)'}")+    print(f"Scopes:       {', '.join(s.split('/')[-1] for s in (cfg.get('scopes') or [])) or '(none)'}")+    if cfg.get("_migrated_from"):+        print(f"Migrated from: {cfg['_migrated_from']} (gmail.readonly only — run `auth` to add contacts)")+    # never print client_secret / refresh_token values+++HELP = """adom-google — Google Workspace from your Adom container (Gmail, Contacts, …)++AUTH (one time; one consent → long-lived refresh token, stored 0600):+  adom-google auth              SAFE mode (default): create/edit Gmail/Contacts/Calendar ++                                only the Drive/Sheets/Docs/Slides files THIS app makes.+                                No scary "see & delete ALL your files" on the consent screen.+  adom-google auth --full       FULL mode: broad access to ALL your existing Drive/Sheets/+                                Docs/Slides/Calendars (incl. delete). For trusted users.+  adom-google auth --manual     fallback: desktop-client paste flow (combine w/ --full)+  adom-google auth-code '<code>'   finish the --manual flow+  adom-google init <id> <secret>   set an explicit desktop OAuth client (for --manual)+  adom-google status            show mode + what's authorised (never prints secrets)++GMAIL (read-only; migrated from adom-gmail):+  adom-google gmail attachments <messageId>            list attachments + ids+  adom-google gmail read <messageId> <attId> [-o f]    dump attachment bytes+  adom-google gmail ics  <messageId>                   parse .ics invite(s)++CONTACTS (Google People API; read + write):+  adom-google contacts create --name "First Last" [--org O] [--title T] \\+      [--email [email protected] ...] [--phone +1... ...] [--notes "..."] [--json]+  adom-google contacts list [--limit N] [--json]+  adom-google contacts search <query> [-v] [--json]++API (generic passthrough — hit ANY Google API with your token; the "whole board"):+  adom-google api <url> [-X METHOD] [-d '<json>'|@file] [-q k=v ...] [--raw]+    e.g. adom-google api https://sheets.googleapis.com/v4/spreadsheets/<id>/values/A1:C5+         adom-google api -X POST https://docs.googleapis.com/v1/documents -d '{"title":"Report"}'+  Reach == the scopes the token holds (see `status`). Widen via SCOPES + re-`auth`+  + enabling that API in the GCP project.++DRIVE / CALENDAR / SHEETS / DOCS / SLIDES: either use `api` above, or add a typed+subcommand. Add the scope in SCOPES (top of this file) and re-run `auth`.++SECURITY: config is 0600; secrets are never echoed to stdout/stderr or logs.+"""+++def main():+    p = argparse.ArgumentParser(add_help=False)+    sub = p.add_subparsers(dest="cmd")++    s = sub.add_parser("auth"); s.add_argument("--manual", action="store_true"); s.add_argument("--full", action="store_true", help="request the FULL (broad, incl. delete-all) scope set; default is SAFE"); s.set_defaults(fn=cmd_auth)+    s = sub.add_parser("auth-code"); s.add_argument("code"); s.set_defaults(fn=cmd_auth_code)+    s = sub.add_parser("init"); s.add_argument("client_id"); s.add_argument("client_secret"); s.set_defaults(fn=cmd_init)+    s = sub.add_parser("status"); s.set_defaults(fn=cmd_status)+    s = sub.add_parser("setup"); s.set_defaults(fn=cmd_setup)++    g = sub.add_parser("gmail"); gs = g.add_subparsers(dest="sub")+    x = gs.add_parser("attachments"); x.add_argument("message_id"); x.set_defaults(fn=cmd_gmail_attachments)+    x = gs.add_parser("read"); x.add_argument("message_id"); x.add_argument("attachment_id"); x.add_argument("-o", "--out"); x.set_defaults(fn=cmd_gmail_read)+    x = gs.add_parser("ics"); x.add_argument("message_id"); x.set_defaults(fn=cmd_gmail_ics)++    c = sub.add_parser("contacts"); cs = c.add_subparsers(dest="sub")+    x = cs.add_parser("create")+    x.add_argument("--name"); x.add_argument("--org"); x.add_argument("--title")+    x.add_argument("--email", action="append"); x.add_argument("--phone", action="append")+    x.add_argument("--notes"); x.add_argument("--json", action="store_true")+    x.add_argument("--dry-run", dest="dry_run", action="store_true", help="print the People API payload without sending")+    x.set_defaults(fn=cmd_contacts_create)+    x = cs.add_parser("list"); x.add_argument("--limit", type=int, default=50); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_contacts_list)+    x = cs.add_parser("search"); x.add_argument("query"); x.add_argument("-v", "--verbose", action="store_true"); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_contacts_search)++    # Generic passthrough — hit ANY Google API with the stored token.+    x = sub.add_parser("api")+    x.add_argument("url", help="full URL or host/path (https:// assumed)")+    x.add_argument("-X", "--method", default="GET")+    x.add_argument("-d", "--data", help="JSON body string, or @file")+    x.add_argument("-q", "--query", action="append", help="key=value query param (repeatable)")+    x.add_argument("--raw", action="store_true", help="print raw bytes (don't pretty-print JSON)")+    x.set_defaults(fn=cmd_api)++    sub.add_parser("help")++    if len(sys.argv) == 1 or sys.argv[1] in ("help", "-h", "--help"):+        print(HELP); return+    args = p.parse_args()+    if not hasattr(args, "fn"):+        print(HELP); return+    args.fn(args)+++if __name__ == "__main__":+    main()
bin/credentials.jsonadded+5
@@ -0,0 +1,5 @@+{+  "_comment": "BROKER MODE: only the PUBLIC client_id ships here (it appears in every consent URL anyway). The client SECRET lives ONLY on the OAuth gateway service container (creds/adom-google.json, 0600) — never on user containers, never in this repo. The gateway does the code->token exchange and the refresh on our behalf. To point at a different shared client, change clientId here AND the gateway's creds file.",+  "clientId": "432727599812-g01vp9dso4c2lu942fb8qi305dbhdgrv.apps.googleusercontent.com",+  "app": "adom-google"+}
docs/hero.pngadded
⋯ 1 unchanged line ⋯
install.shadded+10
@@ -0,0 +1,10 @@+#!/bin/bash+# adompkg install hook: put adom-google on PATH + drop its skill in place.+set -e+DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"+mkdir -p "$HOME/.local/bin" "$HOME/.claude/skills/adom-google"+chmod +x "$DIR/bin/adom-google" "$DIR/bin/adom-gmail"+ln -sf "$DIR/bin/adom-google" "$HOME/.local/bin/adom-google"+ln -sf "$DIR/bin/adom-gmail"  "$HOME/.local/bin/adom-gmail"+cp -f "$DIR/SKILL.md" "$HOME/.claude/skills/adom-google/SKILL.md"+echo "✅ adom-google installed. Next: run 'adom-google setup' (it will ask safe vs full mode)."
package.jsonadded+14
@@ -0,0 +1,14 @@+{+  "slug": "adom-google",+  "version": "0.1.0",+  "type": "app",+  "description": "Whole-of-Google-Workspace CLI for Adom containers \u2014 Gmail, Contacts, Drive, Sheets, Docs, Slides, Calendar, Tasks, plus a generic `api` passthrough to hit ANY Google API. One-click OAuth via the Adom gateway. Employee-only. On first run it asks each user to choose safe mode (no delete) or full mode (incl. delete).",+  "install": {+    "binary_name": "adom-google"+  },+  "dependencies": {},+  "scripts": {+    "install": "./install.sh",+    "uninstall": "./uninstall.sh"+  }+}\ No newline at end of file
uninstall.shadded+4
@@ -0,0 +1,4 @@+#!/bin/bash+rm -f "$HOME/.local/bin/adom-google" "$HOME/.local/bin/adom-gmail"+rm -rf "$HOME/.claude/skills/adom-google"+echo "adom-google uninstalled (your ~/.config/adom-google token is left intact)."