app
Adom Google
Public Made by Adomby adom
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.
← Commit history
adom-google v0.1.0 source
11 files changed
+1146−1
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.+++**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"+ }+}
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)."