← Commit history

v0.8.19: typed subcommands (calendar/drive/sheets/docs/slides/tasks)

John Lauer ·6350c1dc60 ·2mo ago ·parent ac3a44c
2 files changed +629−14
bin/adom-google+624−10
@@ -22,6 +22,12 @@ Auth (one consent, long-lived refresh token): See `adom-google help`. """ import sys, os, json, base64, argparse, urllib.parse, ssl, socket, hashlib, secrets, struct, time++ADOM_GOOGLE_VERSION = "0.8.19"+if sys.argv[1:2] == ["--version"]:+    print(f"adom-google {ADOM_GOOGLE_VERSION}")+    sys.exit(0)+ import requests  # --- ACCOUNTS ----------------------------------------------------------------------------------@@ -73,6 +79,7 @@ SCOPES_SAFE = [     "https://www.googleapis.com/auth/tasks",               # Tasks     "https://www.googleapis.com/auth/chat.messages",       # Google Chat — read + post (not a "delete all" risk)     "https://www.googleapis.com/auth/chat.spaces.readonly", # Google Chat — list your spaces+    "https://www.googleapis.com/auth/chat.spaces.create",  # Google Chat — CREATE spaces (create-only, fits SAFE) ] SCOPES_FULL = [     "https://www.googleapis.com/auth/gmail.modify",        # Gmail read/compose/send/labels@@ -84,9 +91,22 @@ SCOPES_FULL = [     "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+    "https://www.googleapis.com/auth/chat.spaces",         # Google Chat — create spaces + read/update metadata+    #   (supersedes chat.spaces.readonly, which capped FULL users at read-only space listing —+    #    "full" could never create a space no matter what Google granted. wiki-notify-gchat 2026-07-21.)+    "https://www.googleapis.com/auth/chat.memberships",         # add/remove space members + managers+    "https://www.googleapis.com/auth/chat.delete",              # delete conversations/spaces — FULL only, NEVER safe+    "https://www.googleapis.com/auth/chat.customemojis",        # custom emoji+    "https://www.googleapis.com/auth/chat.users.readstate",     # per-user last-read time+    "https://www.googleapis.com/auth/chat.users.spacesettings", # per-user space settings+    "https://www.googleapis.com/auth/chat.users.availability",  # presence+    "https://www.googleapis.com/auth/chat.users.sections",      # sections     # (Chat works because the shared rock-verbena project has a configured Chat app.)-    # Admin/Directory scopes stay OUT — separate admin-only opt-in.+    # Every string above was validated against Google's authorize endpoint before shipping (issue+    # #233): an invalid scope makes Google reject the WHOLE request with invalid_scope, which+    # breaks sign-in entirely rather than just the new capability. Re-validate before adding more.+    # Admin/Directory scopes stay OUT — separate admin-only opt-in. chat.admin.* stays OUT+    # PERMANENTLY: it is an org-wide admin surface, not a "drive my own Chat" capability. ]  @@ -106,6 +126,20 @@ SCOPES_PERSONAL = [ ]  +# ADMIN / Directory bundle — the "separate admin-only opt-in" the policy always anticipated.+# NEVER in safe/full/personal: only a Workspace ADMIN can grant these, and they are org-wide+# (every user, every group), not "drive my own Workspace". Opt in explicitly at auth time with+# `auth --admin` so it costs ONE consent, not a second browser trip later (John, 2026-07-21).+SCOPES_ADMIN = [+    "https://www.googleapis.com/auth/admin.directory.group",         # create/manage groups (distribution lists)+    "https://www.googleapis.com/auth/admin.directory.group.member",  # add/remove group members+    "https://www.googleapis.com/auth/admin.directory.user.readonly", # read the user directory (to populate groups)+    "https://www.googleapis.com/auth/apps.groups.settings",          # Groups Settings API — who may post,+    #   moderation, Collaborative Inbox. WITHOUT it a group can be created but silently rejects+    #   external mail, so a contact@ address would bounce every message from a web form.+]++ def _scopes_for(mode):     if mode == "personal":         return SCOPES_PERSONAL@@ -201,6 +235,13 @@ 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"+CAL_API = "https://www.googleapis.com/calendar/v3"+DRIVE_API = "https://www.googleapis.com/drive/v3"+DRIVE_UPLOAD = "https://www.googleapis.com/upload/drive/v3"+SHEETS_API = "https://sheets.googleapis.com/v4"+DOCS_API = "https://docs.googleapis.com/v1"+SLIDES_API = "https://slides.googleapis.com/v1"+TASKS_API = "https://tasks.googleapis.com/tasks/v1"  # --- OAuth PROVIDER (gateway URL + client) — deliberately NOT hardcoded ----------------------- # This open-source CLI ships with ZERO provider baked in. The provider (which OAuth gateway to@@ -653,6 +694,49 @@ def _msg_text(text):     return text  +def _chat_upload(cfg, space, path):+    """Upload a local file as a Chat attachment → the attachmentDataRef to attach to a message.++    Two-step, per the Chat API: POST the bytes to the /upload endpoint (multipart: a small JSON+    metadata part naming the file, then the raw bytes), then reference the returned+    attachmentDataRef on messages.create. Covers images, video, PDFs, anything Chat accepts.+    Uses the SAME chat.messages scope as a text post (verified 2026-07-21), so no re-consent."""+    if not os.path.isfile(path):+        sys.exit(f"attachment not found: {path}")+    import mimetypes+    filename = os.path.basename(path)+    ctype = mimetypes.guess_type(filename)[0] or "application/octet-stream"+    with open(path, "rb") as f:+        blob = f.read()++    boundary = "adomgoogle" + secrets.token_hex(12)+    meta = json.dumps({"filename": filename}).encode()+    body = b"".join([+        f"--{boundary}\r\n".encode(),+        b"Content-Type: application/json; charset=UTF-8\r\n\r\n", meta, b"\r\n",+        f"--{boundary}\r\n".encode(),+        f"Content-Type: {ctype}\r\n\r\n".encode(), blob, b"\r\n",+        f"--{boundary}--\r\n".encode(),+    ])+    # NOTE: the endpoint is attachments:upload. messages:upload returns a bare 503 from the+    # upload frontend (no JSON), which reads like an outage rather than a bad path.+    r = requests.post(+        f"https://chat.googleapis.com/upload/v1/{space}/attachments:upload",+        headers={"Authorization": f"Bearer {_access_token(cfg)}",+                 "Content-Type": f"multipart/related; boundary={boundary}"},+        params={"uploadType": "multipart"}, data=body, timeout=300)+    if r.status_code >= 400:+        hint = ""+        if r.status_code == 403 and "ACCESS_TOKEN_SCOPE_INSUFFICIENT" in r.text:+            hint = "\nMissing scope — run: adom-google scopes --require chat.messages --auto-widen"+        sys.exit(f"Chat upload failed ({r.status_code}) for {filename}:\n{r.text[:400]}{hint}")+    ref = (r.json() or {}).get("attachmentDataRef")+    if not ref:+        sys.exit(f"Chat upload returned no attachmentDataRef for {filename}: {r.text[:300]}")+    sys.stderr.write(f"(uploaded {filename}, {len(blob)/1024:.0f} KB, {ctype})\n")+    return ref++ def cmd_chat_spaces(a):     cfg = _load()     spaces, page = [], None@@ -687,6 +771,8 @@ def cmd_chat_dm(a):  def cmd_chat_send(a):     cfg = _load()+    if not a.text and not getattr(a, "attach", None):+        sys.exit("nothing to send — pass message text and/or --attach <path>")     if "@" in a.to:         sp = _find_dm(cfg, a.to)         space = sp.get("name")@@ -699,6 +785,10 @@ def cmd_chat_send(a):         sp = _chat(cfg, space)                       # validate + fetch spaceUri / displayName         label = sp.get("displayName") or space     body = {"text": _msg_text(a.text)}+    # --attach is repeatable: upload each file first, then reference them on the message.+    attachments = [_chat_upload(cfg, space, p) for p in (getattr(a, "attach", None) or [])]+    if attachments:+        body["attachment"] = [{"attachmentDataRef": ref} for ref in attachments]     params = None     if a.thread:         body["thread"] = {"threadKey": a.thread}@@ -730,6 +820,91 @@ def cmd_chat_read(a):   # ---------------------------------------------------------------- generic API passthrough+# Hosts whose scopes adom-google deliberately does NOT request, at any tier. Hitting these is a+# policy decision, not a missing feature, so say so instead of suggesting a re-auth that won't help.+_EXCLUDED_HOSTS = {+    "admin.googleapis.com": "Admin SDK (Directory: users, groups/distribution lists, org units)",+    "cloudidentity.googleapis.com": "Cloud Identity (groups/memberships)",+}+# Best-effort host → the scope tail most likely missing, for the actionable hint.+_HOST_SCOPE = {+    "gmail.googleapis.com": "gmail.modify", "chat.googleapis.com": "chat.messages",+    "sheets.googleapis.com": "spreadsheets", "docs.googleapis.com": "documents",+    "slides.googleapis.com": "presentations", "www.googleapis.com": "drive",+    "drive.googleapis.com": "drive", "calendar-json.googleapis.com": "calendar",+    "tasks.googleapis.com": "tasks", "people.googleapis.com": "contacts",+}+++def _scope_wall_hint(url, resp):+    """On a 403 scope failure, tell the caller what to DO next.++    Google's 403 body names the reason but never the fix, so an agent hitting a scope wall through+    the generic `api` passthrough just saw raw JSON and stalled (wiki-notify-gchat tried to create+    a [email protected] distribution list, 2026-07-21). Print the exact next command, or say plainly+    that the scope is excluded by policy so nobody burns a re-auth chasing it."""+    if resp.status_code != 403:+        return+    text = resp.text or ""+    host = urllib.parse.urlparse(url).netloc+    e = sys.stderr++    # THREE different 403s that look identical in raw JSON but need opposite actions:+    #   SERVICE_DISABLED               -> the API is off in the GCP project (re-auth won't help)+    #   ACCESS_TOKEN_SCOPE_INSUFFICIENT-> missing scope (re-auth WILL help)+    #   not-an-admin                   -> the human lacks Workspace admin rights (nothing helps)+    # Keep them apart: one the user can fix in a console, one in a consent screen, one not at all.+    if "SERVICE_DISABLED" in text or "accessNotConfigured" in text:+        proj = ""+        try:+            for d in resp.json().get("error", {}).get("details", []):+                c = (d.get("metadata") or {}).get("consumer") or ""+                if c.startswith("projects/"):+                    proj = c.split("/", 1)[1]+        except Exception:+            pass+        e.write("\n── API not enabled (NOT a scope problem) ───────────────────\n")+        e.write(f"{host} is switched OFF in the OAuth client's Google Cloud project"+                + (f" ({proj})" if proj else "") + ".\n")+        e.write("Your token is fine — re-running `auth` will NOT fix this. Someone with access to\n"+                "that project must enable the API once:\n")+        if proj:+            e.write(f"  https://console.developers.google.com/apis/api/{host}/overview?project={proj}\n")+        e.write("Then wait a minute for it to propagate and retry.\n")+        e.write("────────────────────────────────────────────────────────────\n")+        return+    if "Not Authorized to access this resource" in text or "authError" in text:+        e.write("\n── not a Workspace admin ───────────────────────────────────\n")+        e.write("The API is reachable and the token has the scope, but this Google account is not a\n"+                "Workspace ADMIN, so it cannot manage the domain. Nothing you can change here — a\n"+                "Workspace super-admin must either grant admin rights or run this themselves.\n")+        e.write("────────────────────────────────────────────────────────────\n")+        return+    if "ACCESS_TOKEN_SCOPE_INSUFFICIENT" not in text and "insufficientPermissions" not in text:+        return+    e.write("\n── scope wall ──────────────────────────────────────────────\n")+    if host in _EXCLUDED_HOSTS:+        e.write(f"{host} needs {_EXCLUDED_HOSTS[host]} scopes. These are NOT in safe/full/personal\n"+                "by design (org-wide admin surface), so a bare `auth` will never unlock them.\n"+                "Opt in explicitly — one consent, and only a Workspace ADMIN can grant it:\n"+                "  adom-google auth --admin\n"+                "That covers Groups/distribution lists end to end, including apps.groups.settings,\n"+                "without which a new group silently rejects external mail.\n")+    else:+        guess = _HOST_SCOPE.get(host)+        e.write("The token is missing a scope for this API. Check what you actually hold:\n"+                "  adom-google scopes\n")+        if guess:+            e.write(f"and widen it (one Allow click, existing access is preserved):\n"+                    f"  adom-google scopes --require {guess} --auto-widen\n")+        else:+            e.write("and widen it with the scope this method needs:\n"+                    "  adom-google scopes --require <scope> --auto-widen\n")+        e.write("NOTE: a granted scope is not enough on its own — the API must also be ENABLED in\n"+                "the OAuth client's GCP project.\n")+    e.write("────────────────────────────────────────────────────────────\n")++ 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@@ -750,7 +925,13 @@ def cmd_api(a):     elif a.data is not None:         body = open(a.data[1:], "rb").read() if a.data.startswith("@") else a.data         headers["Content-Type"] = a.content_type or "application/json"-    params = dict(q.split("=", 1) for q in (a.query or []))+    # A LIST of pairs, not a dict: repeated keys are how several Google APIs take lists+    # (metadataHeaders, fields, orderBy...). dict() kept only the LAST value, so+    #   -q metadataHeaders=From -q metadataHeaders=To+    # silently requested only To — a truncated REQUEST returning a valid 200, which reads as+    # complete-but-empty data. That cost a sibling thread an entire email-harvesting pass across+    # 19 people before they worked out the tool was wrong, not the mailbox (2026-07-21).+    params = [tuple(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")@@ -759,10 +940,50 @@ def cmd_api(a):         sys.stdout.buffer.write(r.content)     else:         print(json.dumps(r.json(), indent=2, ensure_ascii=False))+    _scope_wall_hint(url, r)     sys.exit(0 if r.status_code < 400 else 1)   # ---------------------------------------------------------------- auth: gateway flow+def _ws_wait_with_reconnect(ws_url, send_msgs, want_types, timeout=3600, state=None,+                            gateway_url=None):+    """Wait for the gateway's callback, surviving dropped sockets.++    The ingress kills idle WebSockets (~120s), laptops sleep, wifi flaps. A single drop used to+    abort the whole sign-in ("gateway closed the connection") and, worse, the gateway deleted the+    pending session on disconnect so clicking Allow later showed "Session Expired". The gateway+    now keeps sessions alive and PARKS the result; this loop reconnects, re-registers the same+    state (which replays a parked result), and polls GET /result as a last resort. The user gets+    the full window to read Google's consent screen and click Allow."""+    deadline = time.time() + timeout+    attempt = 0+    while time.time() < deadline:+        remaining = max(30, int(deadline - time.time()))+        try:+            return _minimal_ws_roundtrip(ws_url, send_msgs, want_types, timeout=remaining)+        except Exception as e:+            # Before retrying, ask the gateway whether the result already landed while we were+            # disconnected — the common case when the drop happened mid-consent.+            if state and gateway_url:+                try:+                    r = requests.get(gateway_url.rstrip("/") + "/result",+                                     params={"state": state}, timeout=20)+                    j = r.json()+                    if j.get("ok") and not j.get("pending") and j.get("result"):+                        res = j["result"]+                        if res.get("type") == "error":+                            raise RuntimeError("gateway error: " + str(res.get("error") or res.get("detail")))+                        if res.get("type") in want_types:+                            return res+                except requests.RequestException:+                    pass+            if time.time() >= deadline:+                break+            attempt += 1+            time.sleep(min(5, attempt))          # brief backoff, then reconnect + re-register+    raise RuntimeError("timed out waiting for the gateway callback")++ def _minimal_ws_roundtrip(ws_url, send_msgs, want_types, timeout=3600):     """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@@ -917,6 +1138,20 @@ def _print_open_url(auth_link, step="1"):     print("     # logged-out window instead: browser_open_window (add \"profile\":\"default\").\n")     print("   Or paste the URL into the browser by hand (if via a shell, SINGLE-QUOTE the whole URL):\n")     print("   " + auth_link + "\n")+    # Foreground guidance. abe/nbrowser rightly defaults to BACKGROUND windows and the+    # foreground-etiquette skill says never steal focus — but OAuth consent is the one flow whose+    # entire purpose is a human physically clicking Allow. A silent background window plus a+    # taskbar flash is easy to miss, and a missed consent wastes the user's grant. So this is a+    # sanctioned exception: TELL THEM FIRST, then raise. Never raise a window silently.+    print("   TELLING THE USER (foreground exception — read before raising any window):")+    print("     Consent is the one flow where taking the foreground is justified: nothing")+    print("     progresses until a human clicks Allow. Announce it FIRST, never raise silently:\n")+    print("       adom-desktop notify_user '{\"title\":\"Google sign-in needed\",\"body\":\"Opening")+    print("         the consent page in your browser now - click Allow. You have ~60 min.\"}'")+    print("       # then raise that window (e.g. desktop_bring_to_front on its title)\n")+    print("     GENTLER, prefer it when the user may be mid-task: leave the window in the")+    print("     background and flash the taskbar (browser_alert_window) so they come to it when")+    print("     ready. The session survives a dropped socket now, so there is no rush either way.\n")   def cmd_auth(a):@@ -933,7 +1168,39 @@ def cmd_auth(a):         # complete set: it succeeds for whitelisted testers now, and becomes usable for everyone         # once CASA clears (at which point this default flips to full).         mode = "full" if getattr(a, "full", False) else "personal"-    scopes = _scopes_for(mode)+    # `preserve_scopes` (set by `scopes --auto-widen`) is the AUTHORITATIVE base: the scopes the+    # token actually holds. Otherwise fall back to the tier default.+    preserve = list(getattr(a, "preserve_scopes", None) or [])+    scopes = preserve or _scopes_for(mode)+    # `--admin` folds in the Directory/Groups bundle so ONE consent covers admin work too.+    if getattr(a, "admin", False):+        scopes = scopes + SCOPES_ADMIN+    # `--add-scope` (repeatable) widens the computed set — also how `scopes --auto-widen` re-auths.+    extra = [_normalize_scope(s) for s in (getattr(a, "add_scopes", None) or [])]+    scopes = list(dict.fromkeys(scopes + extra))++    # SAFETY NET for every path, not just --auto-widen: a Google consent REPLACES the grant, so+    # any currently-held scope missing from this request is silently revoked on Allow. Warn loudly+    # and offer the exact command that keeps everything. Best-effort: never block auth on it.+    if not preserve and cfg.get("refresh_token"):+        try:+            tok = _access_token(cfg)+            r = requests.get("https://oauth2.googleapis.com/tokeninfo",+                             params={"access_token": tok}, timeout=20)+            if r.status_code == 200:+                held = set(r.json().get("scope", "").split())+                dropping = sorted(held - set(scopes))+                if dropping:+                    tails = ", ".join(s.split("/")[-1] for s in dropping)+                    sys.stderr.write(+                        "\n⚠ THIS CONSENT WOULD REVOKE SCOPES YOU CURRENTLY HOLD:\n"+                        f"    {tails}\n"+                        "  Approving replaces your grant with only what is requested here.\n"+                        "  To ADD a capability while keeping everything, cancel and run:\n"+                        f"    adom-google scopes --require <scope> --auto-widen\n"+                        "  (or re-run this command with the flag that includes them, e.g. --admin)\n\n")+        except requests.RequestException:+            pass     if a.manual:         return _auth_manual(cfg, scopes, mode)     # Gateway flow — one static redirect, code routed back over WebSocket. Needs a provider.@@ -941,7 +1208,12 @@ def cmd_auth(a):     if not pr["gateway_url"]:         sys.exit(NO_PROVIDER_HINT)     cid, _ = _client_creds(cfg)+    # Before minting a new sign-in, see whether an EARLIER one already completed while no CLI was+    # listening. If so the user has already clicked Allow and must not be asked to do it twice.+    if _pending_claim(cfg, scopes, mode, pr["gateway_url"]):+        return     state = secrets.token_urlsafe(24)+    _pending_save(state, scopes, mode, pr["gateway_url"])     params = {         "client_id": cid, "redirect_uri": pr["gateway_redirect"], "response_type": "code",         "scope": " ".join(scopes), "access_type": "offline", "prompt": "consent",@@ -961,18 +1233,21 @@ def cmd_auth(a):           + ("  (full Workspace: Gmail/Contacts/Drive/Sheets/Docs/Slides/Calendar/Tasks/Chat + delete)"              if mode == "full" else "  (safe: only files this app makes; no 'delete all')") + ".\n")     _print_open_url(auth_link)-    # The gateway keeps the sign-in session alive for the full 10 min via a WS heartbeat-    # (gateway fix for issue #1), so the client waits the same 10 min.+    # The gateway keeps the sign-in session alive for the full hour and parks the result if our+    # socket drops; _ws_wait_with_reconnect reconnects and claims it. A network blip no longer+    # costs the user their sign-in.     print("2) Waiting for your approval — you have ~60 minutes, no rush.")     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(+        msg = _ws_wait_with_reconnect(             pr["gateway_ws"],             send_msgs=[{"type": "register", "state": state, "provider": "google",                         "app": pr["app"], "exchange": want_exchange}],             want_types=("tokens", "callback"),             timeout=3600,+            state=state,+            gateway_url=pr["gateway_url"],         )     except Exception as e:         sys.exit(f"\nGateway flow failed: {e}\n"@@ -987,14 +1262,97 @@ def cmd_auth(a):                  f"likely expired — re-run `adom-google auth` (you have ~60 minutes to click Allow).")     if msg.get("type") == "tokens":         _store_broker_tokens(cfg, msg["tokens"], scopes, mode)+        _pending_clear()         print(f"\n✅ Authorized via gateway broker ({mode} mode). Token stored (0600); "               "client secret never touched this container.")     else:         _exchange_code(cfg, msg["code"], pr["gateway_redirect"], scopes, mode)+        _pending_clear()         print(f"\n✅ Authorized via gateway ({mode} mode). Refresh token stored (0600).")     print("   Scopes: " + ", ".join(s.split("/")[-1] for s in scopes))  +def _pending_path():+    return os.path.join(CFG_DIR, "pending-auth.json")+++def _pending_save(state, scopes, mode, gateway_url):+    """Remember the in-flight sign-in so a LATER run can claim it.++    Each auth run mints a fresh random state, so without this a click that lands after the CLI+    exits is unrecoverable: the gateway parks the tokens under a state nobody remembers, they+    expire unclaimed, and the user consented for nothing while seeing "Authorization Complete".+    Observed in the wild — a consent link whose CLI had exited was swept at exactly 60m."""+    try:+        os.makedirs(CFG_DIR, exist_ok=True)+        fd = os.open(_pending_path(), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)+        with os.fdopen(fd, "w") as f:+            json.dump({"state": state, "scopes": scopes, "mode": mode,+                       "gateway_url": gateway_url, "created_at": time.time()}, f)+    except OSError:+        pass                                        # never block auth on bookkeeping+++def _pending_clear():+    try:+        os.remove(_pending_path())+    except OSError:+        pass+++def _pending_claim(cfg, scopes, mode, gateway_url):+    """Try to finish an EARLIER sign-in whose CLI exited before the user clicked Allow.++    Returns True if it recovered a completed authorization (nothing left to do), else False.+    Only auto-claims when the parked grant matches the scopes being asked for now — claiming a+    narrower earlier grant would silently under-authorize."""+    try:+        p = json.load(open(_pending_path()))+    except Exception:+        return False+    state = p.get("state")+    if not state:+        return False+    age = time.time() - (p.get("created_at") or 0)+    if age > 3700:                                  # past the gateway's 60m sweep — nothing to get+        _pending_clear(); return False+    try:+        r = requests.get((p.get("gateway_url") or gateway_url).rstrip("/") + "/result",+                         params={"state": state}, timeout=20)+        j = r.json()+    except Exception:+        return False+    if not j.get("ok"):+        _pending_clear(); return False              # unknown/expired state+    if j.get("pending"):+        mins = int(age // 60)+        sys.stderr.write(+            f"\nNote: an earlier consent link from {mins}m ago is still open and unclicked.\n"+            "  Approving THAT tab still works — this run mints a new link, and clicking either\n"+            "  finishes the job. (Nothing is lost either way.)\n")+        return False+    result = j.get("result") or {}+    if result.get("type") == "error":+        _pending_clear(); return False+    if sorted(p.get("scopes") or []) != sorted(scopes):+        sys.stderr.write(+            "\nNote: a COMPLETED earlier approval is waiting, but it granted a different scope\n"+            "  set than this run is requesting, so it was not claimed automatically.\n")+        return False+    if result.get("type") == "tokens":+        _store_broker_tokens(cfg, result["tokens"], p.get("scopes"), p.get("mode") or mode)+    elif result.get("code"):+        _exchange_code(cfg, result["code"], _provider()["gateway_redirect"],+                       p.get("scopes"), p.get("mode") or mode)+    else:+        return False+    _pending_clear()+    print("✅ Recovered your earlier approval — you already clicked Allow, so no need to do it "+          "again.\n   Token stored (0600).")+    print("   Scopes: " + ", ".join(s.split("/")[-1] for s in (p.get("scopes") or [])))+    return True++ def _auth_manual(cfg, scopes, mode):     cid, _ = _client_creds(cfg)     if not cfg.get("client_id"):@@ -1214,6 +1572,81 @@ def cmd_setup(a):     print(SETUP_HINT)  +def _normalize_scope(s):+    """Accept either a full scope URL or the short tail ('chat.spaces')."""+    return s if s.startswith("http") else "https://www.googleapis.com/auth/" + s+++def cmd_scopes(a):+    """GRANTED scopes (authoritative, from Google's tokeninfo) vs REQUESTED (what auth asked for).++    `status` prints cfg['scopes'], which is only a record of what was REQUESTED at auth+    time — it says nothing about what Google actually granted, and agents have made wrong+    architectural calls trusting it (wiki-notify-gchat, 2026-07-21). This command refreshes+    the access token and asks tokeninfo for the truth.++    NOTE: tokeninfo reports granted SCOPES but not whether an API is enabled in the GCP+    project. To probe real capability without creating anything, send a deliberately+    invalid body to the target method: missing scope → 403 ACCESS_TOKEN_SCOPE_INSUFFICIENT+    (fails before body validation); scope present → 400 INVALID_ARGUMENT.+    """+    cfg = _maybe_load_raw()+    if not cfg or not cfg.get("refresh_token"):+        sys.exit(SETUP_HINT)+    tok = _access_token(cfg)+    r = requests.get("https://oauth2.googleapis.com/tokeninfo",+                     params={"access_token": tok}, timeout=30)+    if r.status_code != 200:+        sys.exit(f"tokeninfo failed ({r.status_code}): {r.text[:200]}")+    granted = sorted(r.json().get("scope", "").split())+    requested = sorted(cfg.get("scopes") or [])+    missing = [s for s in requested if s not in granted]      # asked for, NOT granted+    extra = [s for s in granted if s not in requested]        # granted, never recorded+    required = [_normalize_scope(s) for s in (getattr(a, "require", None) or [])]+    short = [s for s in required if s not in granted]++    if getattr(a, "json", False):+        print(json.dumps({"granted": granted, "requested": requested,+                          "drift": {"requested_not_granted": missing, "granted_not_requested": extra},+                          "require": {"required": required, "missing": short} if required else None,+                          "account": ACCOUNT, "mode": cfg.get("mode")}, indent=2))+    else:+        tail = lambda s: s.split("/")[-1]+        print(f"GRANTED   ({len(granted)}, authoritative via tokeninfo):")+        for s in granted: print(f"  ✓ {tail(s)}")+        if missing:+            print(f"DRIFT — requested at auth but NOT granted:")+            for s in missing: print(f"  ✗ {tail(s)}")+        if extra:+            print(f"DRIFT — granted but not in the recorded request:")+            for s in extra: print(f"  + {tail(s)}")+        if not missing and not extra:+            print("No drift: granted matches the recorded request.")+        if required:+            print(f"REQUIRE check: {'ALL PRESENT' if not short else 'MISSING → ' + ', '.join(tail(s) for s in short)}")++    if short:+        widen_flags = " ".join(f"--add-scope {s.split('/')[-1]}" for s in short)+        acct = "" if ACCOUNT == "default" else f" --account {ACCOUNT}"+        fix = f"adom-google{acct} auth{' --safe' if cfg.get('mode') == 'safe' else ''} {widen_flags}"+        if getattr(a, "auto_widen", False):+            print(f"\n--auto-widen: re-running auth with the union of GRANTED + required…")+            import types+            # Union against what is actually GRANTED, never the tier default. Unioning against+            # the tier silently dropped everything held beyond it — after `auth --admin` this+            # built an 18-scope URL (17 tier + 1 new) instead of 22, so approving it would have+            # REVOKED domain group admin while appearing to add a capability. The help text+            # promises --auto-widen "preserves what you already hold", so it must actually do+            # that. (Reported 2026-07-22.)+            na = types.SimpleNamespace(manual=False, safe=cfg.get("mode") == "safe",+                                       full=cfg.get("mode") == "full", admin=False,+                                       url_file=getattr(a, "url_file", None),+                                       add_scopes=short, preserve_scopes=granted)+            return cmd_auth(na)+        print(f"\nFix: {fix}", file=sys.stderr)+        sys.exit(2)++ def cmd_status(a):     cfg = _maybe_load_raw()     if not cfg or not cfg.get("refresh_token"):@@ -1247,7 +1680,11 @@ AUTH (one time; one consent → long-lived refresh token, stored 0600):   adom-google auth --manual     fallback: desktop-client paste flow   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 access + what's authorised (never prints secrets)+  adom-google status            show access + what was REQUESTED at auth (never prints secrets)+  adom-google scopes [--json]   authoritative GRANTED scopes (live tokeninfo) + drift vs requested+  adom-google scopes --require chat.spaces [--auto-widen]+                                gate for other tools: exit 2 + exact fix when a scope is missing;+                                --auto-widen re-runs auth with the union  ONBOARD A NEW ORG (give your team its OWN Google OAuth client — the AI hand-holds you):   adom-google onboard [--org <slug>]   print the step-by-step plan + exact values to use@@ -1301,11 +1738,164 @@ SECURITY: config is 0600; secrets are never echoed to stdout/stderr or logs. """  +# ---------------------------------------------------------------- typed subcommands+# Thin, guided wrappers over the Google REST APIs (issue #662): Calendar, Drive, Sheets,+# Docs, Slides, and Tasks were named in the brief and discovery triggers but only reachable+# through the raw `api` passthrough. These give first-class, discoverable commands for the+# common flows; anything deeper still drops to `adom-google api <url>`.++def cmd_calendar_list(a):+    cfg = _load()+    params = {"maxResults": str(a.max), "singleEvents": "true", "orderBy": "startTime",+              "timeMin": __import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat()}+    res = _request(cfg, "GET", f"{CAL_API}/calendars/{urllib.parse.quote(a.cal)}/events?{urllib.parse.urlencode(params)}")+    events = res.get("items", [])+    if a.json:+        print(json.dumps(events, indent=2, ensure_ascii=False)); return+    if not events:+        print("(no upcoming events)"); return+    for e in events:+        start = (e.get("start") or {}).get("dateTime") or (e.get("start") or {}).get("date") or "?"+        print(f"{start}  {e.get('summary','(no title)')}  [{e.get('id','')}]")+++def cmd_calendar_create(a):+    cfg = _load()+    if not a.start or not a.end:+        sys.exit("calendar create needs --start and --end (RFC3339, e.g. 2026-08-20T14:00:00-05:00)")+    body = {"summary": a.summary,+            "start": {"dateTime": a.start}, "end": {"dateTime": a.end}}+    if a.location: body["location"] = a.location+    if a.description: body["description"] = a.description+    if a.attendees:+        body["attendees"] = [{"email": e.strip()} for e in a.attendees.split(",") if e.strip()]+    params = {"sendUpdates": "all" if a.send else "none"}+    res = _request(cfg, "POST", f"{CAL_API}/calendars/{urllib.parse.quote(a.cal)}/events?{urllib.parse.urlencode(params)}",+                   json=body)+    if a.json:+        print(json.dumps(res, indent=2, ensure_ascii=False)); return+    print(res.get("htmlLink") or res.get("id", ""))+    sys.stderr.write(f"→ created '{a.summary}'" + (" (invites sent)" if a.send else "") + "\n")+++def cmd_calendar_calendars(a):+    cfg = _load()+    res = _request(cfg, "GET", f"{CAL_API}/users/me/calendarList")+    cals = res.get("items", [])+    if a.json:+        print(json.dumps(cals, indent=2, ensure_ascii=False)); return+    for c in cals:+        print(f"{c.get('id')}  ·  {c.get('summary','')}" + ("  (primary)" if c.get("primary") else ""))+++def cmd_drive_list(a):+    cfg = _load()+    params = {"pageSize": str(a.limit), "fields": "files(id,name,mimeType,modifiedTime,size)",+              "orderBy": "modifiedTime desc"}+    if a.query: params["q"] = a.query+    res = _request(cfg, "GET", f"{DRIVE_API}/files?{urllib.parse.urlencode(params)}")+    files = res.get("files", [])+    if a.json:+        print(json.dumps(files, indent=2, ensure_ascii=False)); return+    if not files:+        print("(no files)"); return+    for f in files:+        print(f"{f.get('id')}  {f.get('name','')}  ({f.get('mimeType','')})")+++def cmd_drive_upload(a):+    cfg = _load()+    if not os.path.isfile(a.path):+        sys.exit(f"file not found: {a.path}")+    import mimetypes+    name = a.name or os.path.basename(a.path)+    mime = a.mime or mimetypes.guess_type(name)[0] or "application/octet-stream"+    with open(a.path, "rb") as f:+        blob = f.read()+    up = _request(cfg, "POST", f"{DRIVE_UPLOAD}/files?uploadType=media",+                  headers={"Content-Type": mime}, data=blob)+    fid = up.get("id")+    if fid and name:+        up = _request(cfg, "PATCH", f"{DRIVE_API}/files/{fid}?fields=id,name,webViewLink",+                      json={"name": name})+    if a.json:+        print(json.dumps(up, indent=2, ensure_ascii=False)); return+    print(up.get("webViewLink") or up.get("id", ""))+    sys.stderr.write(f"→ uploaded {name} ({len(blob)//1024} KB, {mime})\n")+++def _created_link(res, kind):+    fid = res.get("spreadsheetId") or res.get("documentId") or res.get("presentationId") or res.get("id", "")+    urls = {"sheet": f"https://docs.google.com/spreadsheets/d/{fid}/edit",+            "doc": f"https://docs.google.com/document/d/{fid}/edit",+            "slides": f"https://docs.google.com/presentation/d/{fid}/edit"}+    return urls.get(kind, fid), fid+++def cmd_sheets_create(a):+    cfg = _load()+    res = _request(cfg, "POST", f"{SHEETS_API}/spreadsheets", json={"properties": {"title": a.title}})+    if a.json:+        print(json.dumps(res, indent=2, ensure_ascii=False)); return+    link, _ = _created_link(res, "sheet"); print(link)+    sys.stderr.write(f"→ created spreadsheet '{a.title}'\n")+++def cmd_docs_create(a):+    cfg = _load()+    res = _request(cfg, "POST", f"{DOCS_API}/documents", json={"title": a.title})+    if a.json:+        print(json.dumps(res, indent=2, ensure_ascii=False)); return+    link, _ = _created_link(res, "doc"); print(link)+    sys.stderr.write(f"→ created doc '{a.title}'\n")+++def cmd_slides_create(a):+    cfg = _load()+    res = _request(cfg, "POST", f"{SLIDES_API}/presentations", json={"title": a.title})+    if a.json:+        print(json.dumps(res, indent=2, ensure_ascii=False)); return+    link, _ = _created_link(res, "slides"); print(link)+    sys.stderr.write(f"→ created presentation '{a.title}'\n")+++def cmd_tasks_list(a):+    cfg = _load()+    res = _request(cfg, "GET", f"{TASKS_API}/lists/{a.list}/tasks?showCompleted=false&maxResults=100")+    items = res.get("items", [])+    if a.json:+        print(json.dumps(items, indent=2, ensure_ascii=False)); return+    if not items:+        print("(no tasks)"); return+    for t in items:+        print(f"[{'x' if t.get('status')=='completed' else ' '}] {t.get('title','')}  ({t.get('id','')})")+++def cmd_tasks_add(a):+    cfg = _load()+    body = {"title": a.title}+    if a.notes: body["notes"] = a.notes+    res = _request(cfg, "POST", f"{TASKS_API}/lists/{a.list}/tasks", json=body)+    if a.json:+        print(json.dumps(res, indent=2, ensure_ascii=False)); return+    print(res.get("id", ""))+    sys.stderr.write(f"→ added task '{a.title}'\n")++ 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("--safe", action="store_true", help="advanced: request the NARROW create-only scope set (no delete-all); default is FULL"); s.add_argument("--full", action="store_true", help="Connector: request the FULL set incl. Gmail-read + all Drive (works for whitelisted testers now, everyone once CASA clears). No-op for the work client (already full)."); s.add_argument("--url-file", dest="url_file", help="also write the consent URL to this file (one clean line, for tooling)"); s.set_defaults(fn=cmd_auth)+    s = sub.add_parser("auth", description=(+        "Authorize this container against Google. Default = FULL Workspace access (Gmail, Drive, "+        "Sheets, Docs, Slides, Calendar, Tasks, Chat incl. spaces/memberships/delete). "+        "Admin/Directory scopes are NOT in any tier by default; add them with --admin (one "+        "consent, no second browser trip) to manage Google Groups / distribution lists. "+        "Already authorized and just missing one capability? Do NOT re-run a bare `auth` -- use "+        "`adom-google scopes --require <scope> --auto-widen`, which preserves what you already "+        "hold. See granted-vs-requested with `adom-google scopes`."))+    s.add_argument("--manual", action="store_true", help="fallback when the gateway is unreachable: desktop-client copy/paste flow (finish with `auth-code '<code>'`)"); s.add_argument("--safe", action="store_true", help="advanced: request the NARROW create-only scope set (no delete-all); default is FULL"); s.add_argument("--full", action="store_true", help="Connector: request the FULL set incl. Gmail-read + all Drive (works for whitelisted testers now, everyone once CASA clears). No-op for the work client (already full)."); s.add_argument("--url-file", dest="url_file", help="also write the consent URL to this file (one clean line, for tooling)"); s.add_argument("--admin", action="store_true", help="ALSO request the Workspace ADMIN bundle (admin.directory.group + .group.member + .user.readonly + apps.groups.settings) so ONE consent covers Google Groups / distribution lists. Only a Workspace admin can grant it."); s.add_argument("--add-scope", dest="add_scopes", action="append", help="widen the scope set (repeatable; full URL or short tail like chat.spaces)"); s.set_defaults(fn=cmd_auth)+    s = sub.add_parser("scopes"); s.add_argument("--json", action="store_true"); s.add_argument("--require", nargs="+", help="scopes that MUST be granted (full URL or short tail); exit 2 + print the fix when short"); s.add_argument("--auto-widen", dest="auto_widen", action="store_true", help="with --require: re-run auth with the union instead of exiting"); s.add_argument("--url-file", dest="url_file", help="with --auto-widen: also write the consent URL here"); s.set_defaults(fn=cmd_scopes)     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)     pv = sub.add_parser("provider"); pvs = pv.add_subparsers(dest="sub")@@ -1347,9 +1937,33 @@ def main():     ch = sub.add_parser("chat"); chx = ch.add_subparsers(dest="sub")     x = chx.add_parser("spaces"); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_chat_spaces)     x = chx.add_parser("dm"); x.add_argument("email"); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_chat_dm)-    x = chx.add_parser("send"); x.add_argument("--to", required=True, help="spaces/XXX id OR an email (auto-resolves the DM)"); x.add_argument("text", help="message text, or '-' for stdin, or '@file'"); x.add_argument("--thread", help="threadKey for a threaded reply"); x.set_defaults(fn=cmd_chat_send)+    x = chx.add_parser("send"); x.add_argument("--to", required=True, help="spaces/XXX id OR an email (auto-resolves the DM)"); x.add_argument("text", nargs="?", default="", help="message text, or '-' for stdin, or '@file' (optional when --attach is used)"); x.add_argument("--thread", help="threadKey for a threaded reply"); x.add_argument("--attach", action="append", metavar="PATH", help="attach a local file (image/video/PDF/any); repeatable"); x.set_defaults(fn=cmd_chat_send)     x = chx.add_parser("read"); x.add_argument("--from", dest="frm", required=True, help="spaces/XXX id OR an email"); x.add_argument("--limit", type=int, default=20); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_chat_read) +    # Calendar (issue #662)+    cal = sub.add_parser("calendar", help="Google Calendar: list/create events, list calendars"); cax = cal.add_subparsers(dest="sub")+    x = cax.add_parser("list", help="upcoming events"); x.add_argument("--cal", default="primary"); x.add_argument("--max", type=int, default=10); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_calendar_list)+    x = cax.add_parser("create", help="create an event"); x.add_argument("--summary", required=True); x.add_argument("--start", help="RFC3339, e.g. 2026-08-20T14:00:00-05:00"); x.add_argument("--end", help="RFC3339"); x.add_argument("--location"); x.add_argument("--description"); x.add_argument("--attendees", help="comma-separated emails"); x.add_argument("--cal", default="primary"); x.add_argument("--send", action="store_true", help="email invites to attendees"); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_calendar_create)+    x = cax.add_parser("calendars", help="list your calendars"); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_calendar_calendars)++    # Drive+    dr = sub.add_parser("drive", help="Google Drive: list/search + upload files"); drx = dr.add_subparsers(dest="sub")+    x = drx.add_parser("list", help="list/search files"); x.add_argument("--query", help="Drive query, e.g. \"name contains 'invoice'\""); x.add_argument("--limit", type=int, default=20); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_drive_list)+    x = drx.add_parser("upload", help="upload a local file"); x.add_argument("path"); x.add_argument("--name"); x.add_argument("--mime"); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_drive_upload)++    # Sheets / Docs / Slides (create the file, then edit via `api` batchUpdate)+    sh = sub.add_parser("sheets", help="Google Sheets"); shx = sh.add_subparsers(dest="sub")+    x = shx.add_parser("create", help="create a spreadsheet"); x.add_argument("--title", required=True); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_sheets_create)+    dc = sub.add_parser("docs", help="Google Docs"); dcx = dc.add_subparsers(dest="sub")+    x = dcx.add_parser("create", help="create a document"); x.add_argument("--title", required=True); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_docs_create)+    sl = sub.add_parser("slides", help="Google Slides"); slx = sl.add_subparsers(dest="sub")+    x = slx.add_parser("create", help="create a presentation"); x.add_argument("--title", required=True); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_slides_create)++    # Tasks+    tk = sub.add_parser("tasks", help="Google Tasks: list/add"); tkx = tk.add_subparsers(dest="sub")+    x = tkx.add_parser("list", help="list tasks"); x.add_argument("--list", default="@default"); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_tasks_list)+    x = tkx.add_parser("add", help="add a task"); x.add_argument("--title", required=True); x.add_argument("--notes"); x.add_argument("--list", default="@default"); x.add_argument("--json", action="store_true"); x.set_defaults(fn=cmd_tasks_add)+     # 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)")
page.json+5−4
@@ -4,7 +4,7 @@   "slug": "adom-google",   "title": "Adom Google",   "brief": "Drive your whole Google Workspace from the terminal, run by your AI. Read Gmail, write Google Docs, build Google Sheets and Google Slides, search Google Drive, manage Google Calendar and Google Tasks, and post to Google Chat, plus any Google API and a zero-setup personal Gmail.",-  "version": "0.8.19",+  "version": "0.8.3",   "visibility": "public",   "install": {     "binary_name": "adom-google"@@ -94,7 +94,7 @@   "skills": [],   "hero_path": "screenshots/hero-google-v5.png",   "hero_type": "image",-  "description": "Drive your whole Google Workspace from the terminal: Gmail, Contacts, Drive, Sheets, Docs, Slides, Calendar, Tasks, Google Chat, plus a generic `api` passthrough to hit ANY Google API. Connect a personal @gmail (or another org's Google account) with ZERO setup via Adom's hosted Connector (`adom-google connect-personal`), or bring your own token-broker gateway (`provider set`): Adom staff use the private adom-google-adom provider. One-click OAuth, no client secret on your machine.",+  "description": "Drive your whole Google Workspace from the terminal: Gmail, Contacts, Drive, Sheets, Docs, Slides, Calendar, Tasks, Google Chat, plus a generic `api` passthrough to hit ANY Google API. Open source, ships with NO OAuth provider baked in: point it at your own token-broker gateway (`provider set`), buy a managed one, or: Adom staff: install the private adom-google-adom provider. One-click OAuth, no client secret on your machine. First run asks safe mode (no delete) vs full mode (incl. delete).",   "keywords": [     "adom-google",     "google-workspace",@@ -118,6 +118,8 @@     "uninstall": "./uninstall.sh"   },   "files": [+    "bin/adom-google",+    "bin/adom-gmail",     "SKILL.md",     "skills/adom-google-onboarding/SKILL.md",     "README.md",@@ -126,8 +128,7 @@     "provider.example.json",     "install.sh",     "uninstall.sh",-    "package.json",-    "VERSION"+    "package.json"   ],   "org": "adom" }\ No newline at end of file