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
v0.3.0 — provider model: open-source CLI ships NO OAuth provider (no Adom gateway/client in the package); add provider set/show; Adom infra moves to private adom-google-adom package
6 files changed
+176−57
README.md+13−6@@ -59,15 +59,18 @@ source for the gateway: [`john/service-oauth`](https://wiki.adom.inc/pages/servi ## Getting your own auth — three ways -The **code** is open source. The thing that's account-specific is the OAuth app + gateway.-Pick your path:+The **code** is open source and ships with **no OAuth provider baked in** — it has no idea+which gateway or Google client to use until you give it one. That "provider" (gateway URL ++client_id) is the only account-specific piece. Pick your path: ### 1. You're at Adom → just use it ```bash-adompkg install adom-google-adom-google setup # asks: safe mode (no delete) or full mode?-adom-google auth # one Allow click — done+adompkg install adom-google-adom # pulls the CLI + drops the Adom provider config+adom-google setup # asks: safe mode (no delete) or full mode?+adom-google auth # one Allow click — done ```+(`adom-google-adom` is a tiny private, Adom-only package that writes the Adom provider; the+public `adom-google` package contains none of Adom's infrastructure.) ### 2. Any other company / team → let Adom host your OAuth (easiest) 💵 Email **[email protected]**. We spin up a dedicated OAuth gateway container for your org@@ -82,7 +85,11 @@ Fork this repo and stand up your own: with the Gmail/Drive/Sheets/Docs/Slides/Calendar/Tasks/People/Chat APIs enabled. - The open-source OAuth gateway ([`john/service-oauth`](https://wiki.adom.inc/pages/service-oauth)) on a small container, with your client's secret in `creds/<app>.json`.-- Point `OAUTH_GATEWAY_URL` + `bin/credentials.json` (client_id) at yours.+- Point the CLI at **your** gateway + client (writes `~/.config/adom-google/provider.json`, 0600):+ ```bash+ adom-google provider set --gateway https://your-gateway.example --client-id <your-client-id>+ adom-google provider show # verify what's resolved (env > provider.json > bundled)+ ``` **Enabling the APIs** — in the Cloud Console go to **APIs & Services → Library**, search each one, and click **Enable**. You want all nine:
SKILL.md+15−11@@ -94,19 +94,22 @@ Adom OAuth Gateway gives one static callback for all containers **and** acts as 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`,+ `redirect_uri = <your gateway>/callback` (from the resolved provider) 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).+2. The user approves in their browser (their own Google 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.+**The package ships NO provider** — no gateway URL, no client_id, nothing Adom-specific. The+provider (`{gateway_url, client_id, app}`) is resolved at runtime, later wins: bundled+`provider.json` → legacy `credentials.json` → `~/.config/adom-google/provider.json` → env+(`OAUTH_GATEWAY_URL`, `ADOM_GOOGLE_CLIENT_ID/SECRET/APP`). With none configured, every command+prints the NO_PROVIDER hint. Adom employees install the private **`adom-google-adom`** package,+which writes the Adom provider (gateway + Internal client_id) to `~/.config/adom-google/provider.json`;+self-hosters run `adom-google provider set --gateway <url> --client-id <id>`. The client secret is+never in any package — in broker mode it lives only on the gateway's `creds/<app>.json` (0600). 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`).@@ -122,10 +125,11 @@ non-adom.inc accounts at consent. Two options to surface to them: they then get the full adom-google capability set, same as the Adom team. 2. **Self-host (DIY):** fork the repo; create their own Google Cloud project + OAuth client (Internal to their Workspace), enable the APIs they need, run the open-source OAuth gateway- (`john/service-oauth`) with their client secret in `creds/<app>.json`, and point- `OAUTH_GATEWAY_URL` + `bin/credentials.json` (client_id) at theirs. Google **Chat** also- needs a one-time Chat-app configuration (name/avatar/description) in the Cloud console's- Chat API → Configuration tab, or `spaces.*` calls 404 with "Chat app not found".+ (`john/service-oauth`) with their client secret in `creds/<app>.json`, then point the CLI at+ theirs: `adom-google provider set --gateway https://their-gateway --client-id <their-id>`+ (writes `~/.config/adom-google/provider.json`). Google **Chat** also needs a one-time+ Chat-app configuration (name/avatar/description) in the Cloud console's Chat API →+ Configuration tab, or `spaces.*` calls 404 with "Chat app not found". ## Commands
WIKI-PUBLISH.md+1−1@@ -29,7 +29,7 @@ adom-wiki-publish verify adom-google ## Bundle info - **Type:** app-- **Wiki page:** https://git-wiki-ktqxite5iglh.adom.cloud/pages/adom-google+- **Wiki page:** https://wiki.adom.inc/pages/adom-google - **Install:** `adompkg install adom-google` - **Source:** this repository (commit, push, then sync to wiki)
bin/adom-google+139−37@@ -71,13 +71,69 @@ 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+# --- OAuth PROVIDER (gateway URL + client) — deliberately NOT hardcoded -----------------------+# This open-source CLI ships with ZERO provider baked in. The provider (which OAuth gateway to+# talk to + which Google client_id) is resolved at runtime so the public code carries no+# Adom-specific infrastructure. Resolution, later wins:+# 1. bundled provider.json next to the script (a private *provider* package may ship this)+# 2. legacy credentials.json next to the script (old {clientId,...}; back-compat only)+# 3. ~/.config/adom-google/provider.json (what `provider set` / the employee pkg writes)+# 4. env: OAUTH_GATEWAY_URL, OAUTH_GATEWAY_WS, ADOM_GOOGLE_CLIENT_ID/SECRET/APP+# Adom employees get the Adom provider via the private `adom-google-adom` package; everyone else+# self-hosts a gateway or buys a managed one ([email protected]). See README.+DEFAULT_APP = "adom-google"+PROVIDER_PATH = os.path.join(CFG_DIR, "provider.json")+BUNDLED_PROVIDER = os.path.join(HERE, "provider.json")+LEGACY_CREDS = os.path.join(HERE, "credentials.json")+MANUAL_REDIRECT = "http://localhost" # manual desktop-client flow: loopback, user copies the code++_PROVIDER = None+def _provider():+ """Resolve {app, gateway_url, gateway_ws, gateway_redirect, client_id, client_secret}."""+ global _PROVIDER+ if _PROVIDER is not None:+ return _PROVIDER+ p = {"app": DEFAULT_APP, "gateway_url": None, "gateway_ws": None,+ "client_id": None, "client_secret": None}+ def merge(path, keymap):+ if not os.path.exists(path):+ return+ try:+ with open(path) as f:+ d = json.load(f)+ except Exception:+ return+ for src, dst in keymap.items():+ if d.get(src):+ p[dst] = d[src]+ full = {"gateway_url": "gateway_url", "gateway_ws": "gateway_ws", "client_id": "client_id",+ "client_secret": "client_secret", "app": "app"}+ merge(BUNDLED_PROVIDER, full)+ merge(LEGACY_CREDS, {"clientId": "client_id", "clientSecret": "client_secret", "app": "app"})+ merge(PROVIDER_PATH, full)+ e = os.environ+ p["gateway_url"] = e.get("OAUTH_GATEWAY_URL", p["gateway_url"])+ p["gateway_ws"] = e.get("OAUTH_GATEWAY_WS", p["gateway_ws"])+ p["client_id"] = e.get("ADOM_GOOGLE_CLIENT_ID", p["client_id"])+ p["client_secret"] = e.get("ADOM_GOOGLE_CLIENT_SECRET", p["client_secret"])+ p["app"] = e.get("ADOM_GOOGLE_APP", p["app"])+ if p["gateway_url"]:+ p["gateway_url"] = p["gateway_url"].rstrip("/")+ p["gateway_ws"] = (p["gateway_ws"] or "wss://" + p["gateway_url"].split("://", 1)[-1]).rstrip("/")+ p["gateway_redirect"] = p["gateway_url"] + "/callback"+ else:+ p["gateway_redirect"] = None+ _PROVIDER = p+ return p+++NO_PROVIDER_HINT = """ERROR: adom-google has no OAuth provider configured (gateway URL + client_id).+The open-source CLI ships without one on purpose — the provider is account-specific. Three ways:+Hint: • Adom employee → install the private `adom-google-adom` package (drops the Adom provider).+Hint: • Managed (any org, ~$5/mo) → email [email protected] for a hosted OAuth gateway container.+Hint: • Self-host (DIY) → run your own gateway (john/service-oauth) + Google OAuth client, then:+Hint: adom-google provider set --gateway https://your-gateway.example --client-id <id>+Hint: Check the current provider any time with: adom-google provider show""" # 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,@@ -131,24 +187,15 @@ def _save(cfg): 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]+ """client_id (public) + client_secret, from the per-user config or the resolved provider.+ In BROKER mode the secret is None — it lives only on the gateway; the manual/local flow+ still uses a real secret (via `init` or a self-host provider)."""+ pr = _provider()+ cid = cfg.get("client_id") or pr["client_id"]+ csec = cfg.get("client_secret") or pr.get("client_secret") if not cid:- sys.exit("No OAuth client_id available (need bundled credentials.json or `init`).")+ sys.exit(NO_PROVIDER_HINT) return cid, csec @@ -159,8 +206,11 @@ def _access_token(cfg): 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"]},+ pr = _provider()+ if not pr["gateway_url"]:+ sys.exit(NO_PROVIDER_HINT)+ r = requests.post(pr["gateway_url"] + "/refresh",+ json={"app": cfg.get("app", pr["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`.")@@ -580,7 +630,7 @@ def _store_broker_tokens(cfg, tokens, scopes, mode): cfg["scopes"] = scopes cfg["mode"] = mode cfg["broker"] = True- cfg["app"] = APP+ cfg["app"] = _provider()["app"] cfg.pop("client_secret", None) # broker mode: never keep a secret locally cfg.pop("_migrated_from", None) cfg.pop("_pending_mode", None)@@ -593,33 +643,36 @@ def cmd_auth(a): 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.+ # Gateway flow — one static redirect, code routed back over WebSocket. Needs a provider.+ pr = _provider()+ if not pr["gateway_url"]:+ sys.exit(NO_PROVIDER_HINT) cid, _ = _client_creds(cfg) state = secrets.token_urlsafe(24) params = {- "client_id": cid, "redirect_uri": GATEWAY_REDIRECT, "response_type": "code",+ "client_id": cid, "redirect_uri": pr["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"+ print(f"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("1) Open this URL and approve with your Google 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,+ pr["gateway_ws"], send_msgs=[{"type": "register", "state": state, "provider": "google",- "app": APP, "exchange": True}],+ "app": pr["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"Is the OAuth Gateway up? ({pr['gateway_url']})\n" f"Fallback: run `adom-google auth --manual`.") if msg.get("state") != state: sys.exit("State mismatch from gateway — aborting.")@@ -628,7 +681,7 @@ def cmd_auth(a): 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)+ _exchange_code(cfg, msg["code"], pr["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)) @@ -636,7 +689,7 @@ def cmd_auth(a): 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("Manual flow uses the configured 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@@ -669,6 +722,46 @@ def cmd_init(a): print(f"Saved client credentials to {CFG}. Next: `adom-google auth --manual`") +def cmd_provider_set(a):+ """Write the OAuth provider (gateway + client) to ~/.config/adom-google/provider.json.+ This is what the private `adom-google-adom` package drops for Adom employees; self-hosters+ point it at their own gateway + client."""+ prov = {}+ if os.path.exists(PROVIDER_PATH):+ try:+ with open(PROVIDER_PATH) as f:+ prov = json.load(f)+ except Exception:+ prov = {}+ if a.gateway: prov["gateway_url"] = a.gateway.rstrip("/")+ if a.ws: prov["gateway_ws"] = a.ws+ if a.client_id: prov["client_id"] = a.client_id+ if a.client_secret: prov["client_secret"] = a.client_secret+ if a.app: prov["app"] = a.app+ os.makedirs(CFG_DIR, exist_ok=True)+ fd = os.open(PROVIDER_PATH, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)+ with os.fdopen(fd, "w") as f:+ json.dump(prov, f, indent=2)+ os.chmod(PROVIDER_PATH, 0o600)+ print(f"Saved provider → {PROVIDER_PATH} (0600).")+ print("Next: ask the user safe vs full, then `adom-google auth`.")+++def cmd_provider_show(a):+ pr = _provider()+ if os.path.exists(PROVIDER_PATH): src = PROVIDER_PATH+ elif os.path.exists(BUNDLED_PROVIDER): src = BUNDLED_PROVIDER + " (bundled)"+ elif os.path.exists(LEGACY_CREDS): src = LEGACY_CREDS + " (legacy)"+ elif os.environ.get("OAUTH_GATEWAY_URL"): src = "environment"+ else: src = "(none — run `provider set`)"+ print(f"app: {pr['app']}")+ print(f"gateway_url: {pr['gateway_url'] or '(UNSET)'}")+ print(f"gateway_ws: {pr['gateway_ws'] or '(derived from gateway_url)'}")+ print(f"client_id: {(pr['client_id'][:22] + '…') if pr['client_id'] else '(UNSET)'}")+ print(f"client_secret: {'present' if pr.get('client_secret') else '(none — broker mode)'}")+ print(f"source: {src}")++ 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.@@ -680,9 +773,12 @@ def cmd_status(a): if not cfg or not cfg.get("refresh_token"): print(SETUP_HINT) return- cid = cfg.get("client_id") or _bundled_client()[0] or "(none)"+ pr = _provider()+ cid = cfg.get("client_id") or pr["client_id"] or "(none)"+ src = "config" if cfg.get("client_id") else ("provider" if pr["client_id"] else "UNSET") print(f"Config: {CFG}")- print(f"OAuth client: {cid[:18]}… ({'manual/explicit' if cfg.get('client_id') else 'bundled Adom client'})")+ print(f"Provider: gateway={pr['gateway_url'] or '(UNSET — run `provider set`)'} app={pr['app']}")+ print(f"OAuth client: {(cid[:18] + '…') if cid != '(none)' else cid} (from {src})") 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)'}")@@ -737,6 +833,12 @@ def main(): 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)+ pv = sub.add_parser("provider"); pvs = pv.add_subparsers(dest="sub")+ x = pvs.add_parser("set")+ x.add_argument("--gateway"); x.add_argument("--ws")+ x.add_argument("--client-id", dest="client_id"); x.add_argument("--client-secret", dest="client_secret")+ x.add_argument("--app"); x.set_defaults(fn=cmd_provider_set)+ x = pvs.add_parser("show"); x.set_defaults(fn=cmd_provider_show) s = sub.add_parser("status"); s.set_defaults(fn=cmd_status) s = sub.add_parser("setup"); s.set_defaults(fn=cmd_setup)
package.json+2−2@@ -1,8 +1,8 @@ { "slug": "adom-google",- "version": "0.2.1",+ "version": "0.3.0", "type": "app",- "description": "Drive your whole Google Workspace from the terminal \u2014 Gmail, Contacts, Drive, Sheets, Docs, Slides, Calendar, Tasks, Google Chat, plus a generic `api` passthrough to hit ANY Google API. One-click OAuth via the Adom token-broker gateway (no client secret on your machine). On first run it asks each user to choose safe mode (no delete) or full mode (incl. delete).",+ "description": "Drive your whole Google Workspace from the terminal \u2014 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 \u2014 Adom staff \u2014 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).", "install": { "binary_name": "adom-google" },
provider.example.jsonadded+6@@ -0,0 +1,6 @@+{+ "_comment": "adom-google ships with NO provider. To self-host, copy this to ~/.config/adom-google/provider.json (or run `adom-google provider set`). It points the CLI at YOUR OAuth gateway + Google client. No client secret goes here — in broker mode the secret lives only on the gateway.",+ "gateway_url": "https://your-oauth-gateway.example.com",+ "client_id": "YOUR_GOOGLE_OAUTH_CLIENT_ID.apps.googleusercontent.com",+ "app": "adom-google"+}