← 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

John Lauer ·96d3bf5027 ·3mo ago ·parent c5e132d
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"   ]-}+}\ No newline at end of file
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: