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.8.0: multi-account support (--account/ADOM_GOOGLE_ACCOUNT, isolated config+provider per account) + onboarding skill: mandatory profiles-first preflight, never the user's tabs
3 files changed
+107−16
bin/adom-google+86−6@@ -24,7 +24,34 @@ 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")+# --- ACCOUNTS ----------------------------------------------------------------------------------+# adom-google supports multiple Google accounts on one container, fully isolated from each+# other. The unnamed "default" account keeps the original ~/.config/adom-google/ paths; a+# named account (e.g. `--account personal`) lives under ~/.config/adom-google/accounts/<name>/+# with its OWN config.json (refresh token, mode) and its OWN provider.json (which may point at+# a different OAuth client/app — a Workspace-Internal client can't sign in a gmail.com user).+# Select with `--account <name>` (any position) or ADOM_GOOGLE_ACCOUNT=<name>.+def _resolve_account():+ acct = os.environ.get("ADOM_GOOGLE_ACCOUNT", "").strip()+ argv, kept, i = sys.argv, [sys.argv[0]], 1+ while i < len(argv):+ a = argv[i]+ if a == "--account":+ if i + 1 >= len(argv):+ sys.exit("ERROR: --account requires a name, e.g. --account personal")+ acct = argv[i + 1]; i += 2; continue+ if a.startswith("--account="):+ acct = a.split("=", 1)[1]; i += 1; continue+ kept.append(a); i += 1+ sys.argv = kept+ acct = (acct or "default").strip().lower()+ if not acct or not all(c.isalnum() or c in "-_" for c in acct):+ sys.exit(f"ERROR: bad account name {acct!r} — letters/digits/dashes/underscores only")+ return acct++ACCOUNT = _resolve_account()+BASE_CFG_DIR = os.path.expanduser("~/.config/adom-google")+CFG_DIR = BASE_CFG_DIR if ACCOUNT == "default" else os.path.join(BASE_CFG_DIR, "accounts", ACCOUNT) 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@@ -158,7 +185,8 @@ PEOPLE_API = "https://people.googleapis.com/v1" # 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")+PROVIDER_PATH = os.path.join(CFG_DIR, "provider.json") # per-account when --account is set+BASE_PROVIDER_PATH = os.path.join(BASE_CFG_DIR, "provider.json") # named accounts inherit from this 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@@ -186,7 +214,9 @@ def _provider(): "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)+ merge(BASE_PROVIDER_PATH, full)+ if PROVIDER_PATH != BASE_PROVIDER_PATH:+ merge(PROVIDER_PATH, full) # named account's own provider wins over the inherited base 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"])@@ -194,7 +224,7 @@ def _provider(): p["client_secret"] = e.get("ADOM_GOOGLE_CLIENT_SECRET", p["client_secret"]) p["app"] = e.get("ADOM_GOOGLE_APP", p["app"]) # broker is a bool — merge() skips falsy values, so read it explicitly (later wins).- for path in (BUNDLED_PROVIDER, PROVIDER_PATH):+ for path in (BUNDLED_PROVIDER, BASE_PROVIDER_PATH, PROVIDER_PATH): if os.path.exists(path): try: d = json.load(open(path))@@ -253,8 +283,8 @@ 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):+ # One-time migration from adom-gmail (default account only — named accounts start clean).+ if ACCOUNT == "default" and 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"])@@ -956,6 +986,7 @@ def cmd_provider_set(a): def cmd_provider_show(a): pr = _provider() if os.path.exists(PROVIDER_PATH): src = PROVIDER_PATH+ elif os.path.exists(BASE_PROVIDER_PATH): src = BASE_PROVIDER_PATH + " (inherited from default account)" 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"@@ -1035,6 +1066,43 @@ def cmd_onboard_finish(a): print("\nNext: ask the user safe vs full, then `adom-google auth`.") +def cmd_accounts(a):+ """List every configured account: the default plus each dir under accounts/."""+ rows = []+ def probe(name, cfg_dir):+ cfg_path = os.path.join(cfg_dir, "config.json")+ cfg = {}+ if os.path.exists(cfg_path):+ try:+ cfg = json.load(open(cfg_path))+ except Exception:+ pass+ prov_path = os.path.join(cfg_dir, "provider.json")+ app = None+ if os.path.exists(prov_path):+ try:+ app = json.load(open(prov_path)).get("app")+ except Exception:+ pass+ rows.append({"account": name, "authorized": bool(cfg.get("refresh_token")),+ "mode": cfg.get("mode"), "app": app, "config": cfg_path})+ probe("default", BASE_CFG_DIR)+ acct_root = os.path.join(BASE_CFG_DIR, "accounts")+ if os.path.isdir(acct_root):+ for name in sorted(os.listdir(acct_root)):+ if os.path.isdir(os.path.join(acct_root, name)):+ probe(name, os.path.join(acct_root, name))+ if getattr(a, "json", False):+ print(json.dumps(rows, indent=2))+ return+ for r in rows:+ star = "*" if r["account"] == ACCOUNT else " "+ auth = f"authorized ({r['mode'] or 'mode unset'})" if r["authorized"] else "NOT authorized"+ app = f" app={r['app']}" if r["app"] else ""+ print(f"{star} {r['account']:<12} {auth}{app}")+ print("\nSelect one per-command with `--account <name>` or export ADOM_GOOGLE_ACCOUNT=<name>.")++ 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.@@ -1049,6 +1117,7 @@ def cmd_status(a): 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"Account: {ACCOUNT}" + ("" if ACCOUNT == "default" else " (select with --account or ADOM_GOOGLE_ACCOUNT)")) print(f"Config: {CFG}") 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})")@@ -1113,6 +1182,16 @@ API (generic passthrough — hit ANY Google API with your token; the "whole boar 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`. +ACCOUNTS (multiple Google accounts, fully isolated — e.g. work + personal):+ adom-google accounts [--json] list configured accounts (token presence, mode, app)+ adom-google --account <name> <cmd> run ANY command against a named account+ ADOM_GOOGLE_ACCOUNT=<name> same, via env (flag wins)+ The default (unnamed) account keeps the original paths; a named account lives at+ ~/.config/adom-google/accounts/<name>/ with its OWN config.json + provider.json.+ Its provider inherits from the default account's provider.json unless overridden —+ set a different OAuth client/app with: adom-google --account <name> provider set …+ First-time auth for a new account: adom-google --account <name> auth [--full]+ SECURITY: config is 0600; secrets are never echoed to stdout/stderr or logs. """ @@ -1131,6 +1210,7 @@ def main(): 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("accounts"); s.add_argument("--json", action="store_true"); s.set_defaults(fn=cmd_accounts) s = sub.add_parser("setup"); s.set_defaults(fn=cmd_setup) ob = sub.add_parser("onboard"); ob.add_argument("--org"); ob.add_argument("--json", action="store_true")
package.json+4−4@@ -1,9 +1,9 @@ { "slug": "adom-google",- "version": "0.7.0",+ "version": "0.8.0", "type": "app", "license": "MIT",- "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).",+ "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).", "keywords": [ "adom-google", "google-workspace",@@ -32,7 +32,7 @@ "hero": { "path": "screenshots/hero-grid-v4.png" },- "discovery_pitch": "Drive your whole Google Workspace from the terminal through your AI — read Gmail (and its attachments), search Drive, build Sheets/Docs/Slides, manage Calendar & Tasks, and post to Google Chat as yourself. One OAuth, any Google API.",+ "discovery_pitch": "Drive your whole Google Workspace from the terminal through your AI \u2014 read Gmail (and its attachments), search Drive, build Sheets/Docs/Slides, manage Calendar & Tasks, and post to Google Chat as yourself. One OAuth, any Google API.", "discovery_triggers": [ "read my gmail", "read my email",@@ -96,4 +96,4 @@ "build a Google Slides deck for the board review", "post to Google Chat that the boards shipped" ]-}+}
skills/adom-google-onboarding/SKILL.md+17−6@@ -46,14 +46,23 @@ If "not connected": the target profile has no open Chrome window — ask the use window) in the profile that's signed into their Workspace, then `nbrowser_rescan`. Fallback to pup: `adom-desktop browser_open_window '{"sessionId":"gcp","nativeBrowser":"chrome","nativeProfile":"Default","url":"https://console.cloud.google.com"}'`. -> ⚠ **Confirm the signed-in account first.** Drive to `https://console.cloud.google.com`, screenshot,-> and verify the top-right account is the user's **Workspace admin** (not a personal @gmail). Creating-> the project under the wrong account is the #1 way this goes sideways. If wrong, have them switch-> accounts in Chrome before continuing.+> ⛔ **MANDATORY PREFLIGHT — profiles first, then YOUR OWN window. Never the user's tabs.**+> 1. `nbrowser_profiles` (read-only) → every connected `browser:email` profile across Chrome AND Edge.+> 2. Pick the profile whose **email matches the Google account this setup is FOR** (org onboarding →+> the Workspace admin's profile; a personal-account setup → that personal profile, e.g.+> `chrome:[email protected]`). The user almost certainly has a browser profile tied to that account+> — find it, never guess, and never use "whatever profile is active."+> 3. `nbrowser_open_window {profile:"<key>", url:"https://console.cloud.google.com", sessionId:"gcp"}`+> → a BACKGROUND window pinned to that profile. **ALL console driving happens in this window via+> its sessionId.** A bare `nbrowser_navigate` (no sessionId) targets the user's ACTIVE tab — the+> extension (v0.0.21+) refuses it (`no_target`/`not_owned`), and it once destroyed a user's other+> AI threads. There is no override.+> 4. Screenshot the new window and verify the top-right account matches. Wrong account = wrong+> project owner, the #1 way this goes sideways. ## Drive loop (repeat for every step) -1. `nbrowser_navigate` to the deep link from the config.+1. `nbrowser_navigate {sessionId:"gcp", url:"<deep link from the config>"}` — always in YOUR window. 2. `nbrowser_screenshot` → **read it** (don't assume the DOM; Google Console mutates constantly). 3. Act with **trusted** input: `nbrowser_type {selector,text}` to fill, `nbrowser_click {selector}` to click. Selectors are modal-aware; prefer visible text / `aria-label` (`[aria-label="Create"]`,@@ -115,7 +124,9 @@ consent screen, the recommended default). Then: adom-google auth # safe (or: adom-google auth --full) ``` -Print the consent URL; drive their browser to it (`nbrowser_navigate`), screenshot the consent, pause+Print the consent URL; open it in a NEW background window in the matching profile+(`nbrowser_open_window {profile:"<key>", url:"<consent url>", sessionId:"consent"}` — never a bare+`nbrowser_navigate` at their tabs), screenshot the consent, pause for **Allow**. The gateway relays the code; the CLI exchanges it locally. Then prove it works — small, real, delightful: