Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

25 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

gwx

Multiple Google accounts, one CLI, optimal for Agents.

A tiny bash wrapper around gws (Google's officially unofficial Workspace CLI). Every call must name an account — so an agent can never send a personal email from your work address by accident.

Install

npm install -g @pa1nd/gwx

That pulls in gws (the Google Workspace CLI) as a dependency, so there's nothing else to set up — and it puts the gwx command on your PATH. Update with npm update -g @pa1nd/gwx, remove with npm uninstall -g @pa1nd/gwx. Requires Node 18+ (macOS / Linux).

jq is required in practice. Without it the scope picker cannot run and multi-account fan-out degrades to a stub envelope with a different shape, so the documented jq pipelines stop working. brew install jq / apt install jq. git is needed only for gwx skills install and gwx learn, and timeout (GNU coreutils) only if you set GWX_TIMEOUT — gwx warns when you ask for a timeout it cannot enforce.

Or via the curl installer (no npm)
curl -fsSL https://raw.githubusercontent.com/pA1nD/gwx/main/install.sh | bash

Sets everything up: installs gwx, adds gws if you don't have it, and prints the next step. Re-run anytime to update.

How it works

See everything at once. Fan out across every account in parallel:

gwx all calendar +agenda           # today's events, every account
gwx work,personal gmail +triage    # unread inbox, work + personal

Output is one JSON line per account. To pretty-print grouped per account:

gwx all calendar +agenda | jq -r '"=== \(.account) ===\n\(.stdout)"'

Reads on multiple accounts fan out — requests run in parallel. Writes (e.g. send, create, delete) refuse to run without a single named account, so you can't accidentally mass-mail from every inbox at once.

Hand it to an agent. Ask Claude things like:

> Anything urgent across my inboxes right now? Flag, don't reply.

● I'll scan every inbox in parallel and flag anything that looks urgent.

● Bash(gwx all gmail +triage)
  ⎿  work     → 312 unread, 3 marked urgent
     personal → 87 unread, 1 marked urgent
     side     → 19 unread, all spam

● Bash(gwx work gmail users messages modify ...)        ×3
● Bash(gwx personal gmail users messages modify ...)    ×1
  ⎿  4 threads flagged

● Flagged 4 threads. No replies sent.
  • work: invite — "30 min sync" tomorrow 9am, no agenda (HR + your manager)
  • work: legal — subject "see attached", 9 PDFs, empty body
  • work: AWS billing — monthly spend up 340% (MTD $18,400)
  • personal: mom — "haven't heard from you in a while" (sent Sunday, it's Friday)

Or, multi-step:

> What should I know about for my next meeting?

● I'll find your next meeting and check recent threads with the attendees.

● Bash(gwx all calendar +agenda)
  ⎿  work     → 14:00 "Q3 review w/ Acme" (alice@acme.com, bob@acme.com)
     personal → 18:30 "Dinner w/ Sam"
     side     → (nothing today)

● Bash(gwx all gmail users messages list --params '{"q":"from:(alice@acme.com OR bob@acme.com)"}')
  ⎿  work     → 12 threads, latest 2 days ago
     personal → 0 threads
     side     → 0 threads

● Q3 review with Acme at 14:00 — TL;DR:
  • Alice pushed back on enterprise pricing yesterday — wants 15% or annual.
  • Bob asked for the churn deck (still owed).
  • Open from last time: SSO rollout date, contract redlines.

And there's a deeper bench: ~95 ready-made workflow skills — +triage, +meeting-prep, drive audits, role-based personas — drop into the agent's project with gwx skills install. More on that below.

Getting started

1. Get an OAuth client from Google Cloud Console

You need ONE OAuth client (it can be shared across all your accounts, or you can create one per account — gwx init will ask).

Briefly:

  1. Sign in to console.cloud.google.com with the account that should own the project. (For Workspace-only setups, pick the account where you're admin.)
  2. Create a project (e.g., gwx-oauth-client).
  3. Enable APIs (☰ → APIs & Services → Library): Gmail API, Google Drive API, Google Calendar API, Google Sheets API, Google Docs API, Google People API.
  4. Configure OAuth consent screen (APIs & Services → OAuth consent screen):
    • User Type: External
    • Add your email(s) as Test users
  5. Create OAuth client (APIs & Services → Credentials → + CREATE CREDENTIALS):
    • Application type: Desktop app
    • Copy the Client ID and Client secret
  6. (Workspace admins) Trust the app (admin.google.com → Security → Access and data control → API controls → Manage App Access →
    • Configure new app → OAuth App Name or Client ID → paste your Client ID → Trusted → Continue → Finish). Skip if your org isn't admin-managed by you.

2. Add each account

Run gwx init <name> once per account. The first time you'll be asked for your OAuth client; subsequent calls offer to reuse the existing client (or provide a new one).

gwx init work
# Prompts for client_id and client_secret (paste from step 5 above)

gwx init personal
# Existing OAuth clients found:
#   1) work
#   n) provide a new client
# → Hit enter (default 1) to reuse work's client

gwx init side

3. Authenticate each account

gwx login work
# Opens browser → sign in as work account → click through unverified-app warning
gwx login personal
gwx login side

Each login opens a scope picker first — an arrow-key multi-select seeded from a baked catalog and cross-checked against Google's live discovery API. Scopes Google has added since the catalog was written appear tagged NEW; catalog scopes that no longer exist render greyed as retired. Eight core scopes (Drive, Gmail, Calendar, Sheets, Docs, Slides, Tasks, Pub/Sub) are pre-checked; the ninth, Cloud Platform, is listed but left off, since full GCP access is rarely what you want from a Workspace CLI.

Scopes for work  (↑↓ · space · enter · q)
 ▸ [x] Google Drive
   [x] Gmail
   [ ] Cloud Platform (full GCP access — leave off unless you need it)
   ── Gmail (extended) ──
   [ ] NEW Read-only

Skip it when you already know what you want, or when there's no terminal to draw on (CI, ssh without -t, an agent subprocess — where gwx detects the missing TTY and lets gws choose rather than failing):

gwx login work --scopes https://www.googleapis.com/auth/gmail.readonly
gwx login work --no-picker

4. Verify

gwx whoami
Accounts:
  work        ✓  alice@workdomain.com    (14 scopes)
  personal    ✓  alice@personal.com      (14 scopes)
  side        ✓  alice@side.com          (14 scopes)

Why

gws itself is brilliant — agent-first, JSON in/out, dynamically generated from Google's API Discovery Service. But it's single-account by design (multi-account support was removed in v0.7.0).

gwx adds the multi-account contract on top:

  • Explicit per-call account selection (no defaults, no inference).
  • Parallel reads with a write-refusal allowlist — agents can't accidentally fan out a send to every inbox.
  • A separate gwx skills install command that fetches the ~95 upstream gws skills, rewrites them for the multi-account contract, and installs them per-project — so heavyweight workflow skills land only in the agents that need them, not every Claude Code session.

Using gwx with agents

⚠️ gwx gives an agent live read+write access to every account you've added — Gmail, Drive, Calendar, Sheets, Docs, People. Sends, shares, deletes, and calendar invites are real-world side effects that can't be undone. Treat each account in gwx whoami like a logged-in browser tab and review what the agent is about to do, especially for writes.

Once installed, gwx is on your PATH — any Claude Code agent can call it from Bash without further setup (gwx work gmail users messages list ...). The remaining setup below makes agents fluent in the wrapper and prevents them from sidestepping it.

Teach agents the gwx contract

Having gwx on PATH isn't quite enough. Agents don't reach for commands they don't already know about, and even when they do they tend to use them naively. The repo ships a small gwx:cli skill that teaches the wrapper's contract — "always specify an account; only allowlisted verbs fan out" — so agents discover the binary, understand the rules, and use it safely. With the skill loaded, Claude also picks gwx up automatically when you mention your emails, calendar, drive, or other Workspace data — no need to spell out the command.

There are three ways to load it, depending on the granularity you want:

1. In-session (gwx learn). Best for ad-hoc empowerment of a session that's already running. Tell the agent:

> run `gwx learn`, then check my latest emails

gwx learn writes the full gwx:cli skill plus an index of all ~95 rewritten gws workflow skills to stdout. The agent reads it into its context window, learns the contract, and gets a discovery map for every workflow helper. gwx learn <skill-name> pulls one specific skill inline without writing anything to disk.

2. Per session (--plugin-dir). Pre-load the skill at session start so the agent has it from the first turn:

claude --plugin-dir ~/.local/share/gwx      # curl-installer layout

That path is where install.sh puts things. An npm install lives elsewhere — --plugin-dir "$(dirname "$(dirname "$(readlink -f "$(command -v gwx)")")")" finds it, or just use option 3 below, which doesn't care how you installed.

3. Globally (plugin marketplace). Install once and every Claude Code CLI session loads the skill automatically (the gwx repo is its own marketplace):

/plugin marketplace add pA1nD/gwx
/plugin install gwx@gwx-marketplace

After that, the gwx:cli skill is available everywhere — no --plugin-dir flag needed.

Keeping agents on the wrapper

gwx's account isolation only holds if the agent goes through the wrapper. If an agent shells out to gws gmail users messages send ... directly, account selection drops out and you're back to whatever account gws defaulted to.

The skill files installed by gwx skills install carry the rule inline: the gwx-cli skill has a "Never call gws directly" section, and every rewritten gws-* skill is prefixed with a banner that repeats it. That's the enforcement — instruction-level, not policy-level.

A Bash(gws *) deny rule in ~/.claude/settings.json looks like extra defense, but in practice Claude Code's permission classifier treats gws as a substring of gwx and blocks legitimate wrapper calls. The skill-based rule is more reliable. If you still want the deny as belt-and-braces, add it manually — the installer no longer prompts for it.

Powerful Agent Skills

Upstream gws ships a deep library of ~95 skills that turn an agent into a capable Workspace operator: per-API skills (gws-gmail, gws-drive, gws-calendar, gws-sheets, gws-docs, gws-people, …), end-to-end recipes for concrete workflows (+triage an inbox, +meeting-prep from a calendar event, draft-and-send threads, sheet roll-ups, drive audits), and personas that give an agent a working role (executive assistant, researcher, ops). Together they're the difference between an agent that knows the API exists and one that can actually run your Monday morning.

For agents that need that context, install the rewritten skills into the agent's directory:

cd ~/path/to/your/agent
gwx skills install                # rewrites + installs gws-* into ./.claude/skills/

Subset and variants:

gwx skills install --skill gws-gmail gws-drive    # subset
gwx skills install --recipes                       # also recipe-* (concrete workflows)
gwx skills install --personas                      # also persona-* (role definitions)
gwx skills install --all                           # everything (~95 skills)

gwx skills uninstall                               # remove all + manifest
gwx skills uninstall --skill <name>...

Upstream skills are written for single-account gws — every example reads gws gmail users messages list .... Therefore we rewrite each skill during installation: gws Xgwx <account> X , so the agents can follow these skills correctly.

Trust note: gwx skills install does a shallow clone of googleworkspace/cli and rewrites the markdown into your project. The rewriter doesn't sandbox the content beyond the gwsgwx <account> substitution — anything the upstream skill says to your agent gets passed through. Installing skills means trusting googleworkspace/cli's main HEAD at clone time.

Command reference

Account management

gwx init    <name>      add an account (prompts for OAuth, offers reuse)
gwx login   <name>      OAuth flow (shows the scope picker)
  --scopes <csv>        skip the picker; request exactly these scopes
  --no-picker           skip the picker; let gws choose scopes
gwx logout  <name>      clear credentials (keep alias)
gwx remove  <name>      delete account entirely (alias + creds + client)
gwx whoami              list accounts + auth status
gwx --version           print the version

The wrapper

gwx <account>   <gws args...>     run against one account
gwx <a,b>       <gws args...>     fan out across listed accounts (read-only)
gwx all         <gws args...>     fan out across every account (read-only)
gwx <account>   --help            pass-through to gws (full CLI surface)

Teach a running session (in-session)

gwx learn                          print cli skill + index of all ~95 skills
gwx learn <skill-name>             print one cached skill in full
gwx learn --refresh                rebuild the cache, then print

Skills (per-project workflow context)

gwx skills install [--skill <names>...] [--recipes] [--personas] [--all]
gwx skills uninstall [--skill <names>...]
gwx skills refresh                 rebuild the rewritten cache from upstream

The rewritten cache lives at ~/.cache/gwx/skills-rewritten/ and is pre-populated by install.sh so gwx learn and gwx skills install are both fast and offline. Re-run gwx skills refresh to pick up upstream changes between gwx releases.

Read fan-out vs writes

Multi-account fan-out is gated by an allowlist:

list, get, search, schema, +triage, +agenda, +read

Anything else — writes, unknown verbs — must target a single account or gwx exits with code 3 and points you at the single-account form. This prevents "send this email to all 3 accounts" type accidents.

Every account always gets a line in the NDJSON, including ones that failed — a logged-out or expired account comes back as {"account":"side","exit":1, "stdout":"","stderr":"..."} rather than being omitted. Check .exit before trusting .stdout; an absent account means it wasn't targeted, not that it had nothing to report.

Fan-out calls are capped by GWX_TIMEOUT (default 30 seconds, per account). Raise it for wide queries:

GWX_TIMEOUT=120 gwx all gmail users threads list --params '{"maxResults":200}'

A timed-out account reports "exit":124. Single-account calls are not capped — they run as long as gws runs, so an interactive command is never killed mid-flight.

Discovering services and verbs

gwx --help does not duplicate gws's help. However, pass --help through with any account and you get the full, auto-updating gws CLI surface:

gwx work --help                 # all services
gwx work gmail --help           # one service
gwx work gmail +send --help     # a helper

This is the canonical way to learn what's available — there's nothing to hardcode or memorize.

How account isolation works

In local mode (the default), every gwx invocation sets GOOGLE_WORKSPACE_CLI_CONFIG_DIR to a per-account directory under ~/.config/gwx/, so each account writes to its own scope. It also clears any inherited GOOGLE_WORKSPACE_CLI_* credentials first…_TOKEN in particular outranks the config directory inside gws, so a stray one in your environment would otherwise act as a different account than the one you named. (In remote mode no config directory is set at all — see Remote mode.)

~/.config/gwx/                          (mode 0700)
├── accounts.list                       one alias per line
├── gws_bin                  (0600)     pinned absolute path to gws
└── accounts/
    ├── work/                           (0700)
    │   ├── client.env       (0600)     OAuth client_id + secret (plaintext)
    │   ├── credentials.enc  (0600)     refresh token, AES-256-GCM
    │   └── token_cache.json (0600)     access token, encrypted
    ├── personal/                       (0700)
    └── side/                           (0700)

Refresh and access tokens are AES-256-GCM encrypted at rest. The encryption key lives in your OS secret store — macOS Keychain or Windows Credential Manager — under one shared entry, gws-cli/<your-os-user>. All gwx accounts share that single key; per-account separation comes from the filesystem (0700 dirs, 0600 files), not the cipher. On Linux the keyring path isn't compiled into gws, so the key falls back to a .encryption_key file alongside each account's ciphertext.

The OAuth client_id / client_secret in client.env are stored as plaintext shell-quoted env at mode 0600 — same shape as ~/.aws/credentials, ~/.npmrc, or ~/.kube/config.

Run gwx login calls one at a time — parallel first-time logins race on the keychain write.

Remote mode

By default gwx holds the credentials: it reads client.env and the encrypted tokens under ~/.config/gwx/accounts/<name>/ and hands them to gws. Some deployments invert that — an agent may use an account without ever being able to read its credential. GWX_MODE=remote is for those:

export GWX_MODE=remote
export GWX_GWS_BIN=/path/to/backend      # a drop-in that behaves like gws
gwx alice@example.com gmail +triage

In remote mode gwx never opens a credential file, never sources an OAuth client secret, and never sets GOOGLE_WORKSPACE_CLI_CONFIG_DIR — naming the credential directory is most of the way to handing over the credential. The selected account crosses to the backend in one variable:

GWX_ACCOUNT=alice@example.com

and nothing else does. Whatever owns the credential gives the backend its own access; that is not gwx's business.

Because gwx no longer owns sign-in, init, login, logout and remove are refused (exit 2) with a message pointing elsewhere, and every other message stops suggesting them. The account list is still a plain text file at ~/.config/gwx/accounts.list, one name per line — whoever owns the credentials writes it; gwx only reads it. Everything else is identical: fan-out, the read-verb classifier, the per-account envelope, whoami, exit codes.

The mode is explicit and never inferred — not from whether GWX_GWS_BIN is set, not from whether an account directory exists. An unrecognised value is an error rather than a fallback, because guessing "local" would source exactly the credentials remote mode exists to keep out of the process.

Checking a backend

A backend must be a drop-in for gws: argv verbatim, stdout and stderr returned, exit code preserved, and auth status answering in gws's own schema (auth_method, user, scope_count, token_validwhoami parses all four). To verify one:

./tests/conformance.sh /path/to/backend alice@example.com

Exit codes

0   success
2   bad usage (missing/unknown account, bad flags, verb refused in remote mode)
3   fan-out refused (write/unknown verb)
4   account not authenticated, missing OAuth client, gws/git not installed
*   passed through from gws

Repo layout

.
├── .claude-plugin/
│   ├── plugin.json                 plugin manifest (name="gwx" → namespace gwx:*)
│   └── marketplace.json            marketplace manifest (repo IS its own marketplace)
├── bin/gwx                         the wrapper + inline rewriter (single bash file)
├── bin/gwx-picker                  multi-select scope picker (pure bash + ANSI)
├── bin/gwx-scopes-build            baked catalog + live discovery → picker JSON
├── scopes.json                     baked OAuth scope catalog
├── package.json                    npm package (@pa1nd/gwx)
├── skills/cli/SKILL.md             the gwx:cli skill
├── install.sh                      unified install/update/uninstall
├── tests/test.sh                   bash test suite (no external deps)
├── tests/conformance.sh            check a backend stand-in against the contract
├── README.md
├── LICENSE
└── .gitignore

Develop

./tests/test.sh                              # full suite, runs in seconds
./install.sh install --from-folder .         # install from this clone
./install.sh uninstall                       # preserves ~/.config/gwx
./install.sh uninstall --purge               # also wipes accounts + cache

Status

v0.3.8 — picker layout. The top block is now exactly the eight granted scopes, so it reads as one statement: this is the grant, everything below is opt-in. Pub/Sub and Cloud Platform moved out of it into a Google Cloud group — once switched off they were unchecked rows in the block that otherwise means "you are about to grant this". The NEW badge and its New · … group prefix are gone; scopes found via live discovery now merge into the ordinary groups instead of forming a parallel set, and Gmail (extended) and friends collapse into plain Gmail. Two tests pin the layout: the top block must equal the default set in both directions, and nothing below it may be missing a group header.

v0.3.7 — the default scope set is now asserted, and --help names the version. cloud-platform (full GCP access) was pre-checked in the picker from 27 May to 4 Aug because nothing tested the default set; pubsub rode along granting access to a service gws does not even expose. Both are off. contacts is on, so People API lookups work out of the box. Two tests now pin the set: one on its exact contents, one requiring every pre-checked scope to render in the picker's visible top block — a checked box scrolled off-screen is how cloud-platform survived ten weeks. --version and --help read one constant, so an install lagging behind announces itself. Tests 151 → 153.

v0.3.6 — tests/conformance.sh was measuring its own argv. Its phase-3 probe sent gmail users labels list with no --params; that is a generated Discovery command, so gws rejects it locally with a 400 validationError before building a request — and the script reported it as a backend fault. Reported by a backend author who lost a debugging cycle to it. Fixed, and a validationError is now named as this script's bug rather than the backend's. Tests 150 → 151: the new one runs every probe against the real gws, so it covers any probe added later.

v0.3.5 — gwx login could run the login you just cancelled. The scope picker ran as scopes-build | picker under set -o pipefail, so a picker that exited without draining stdin left the writer to die of SIGPIPE; when the picker's own exit was 0 — confirmed, nothing selected — that 141 was the only non-zero left, gwx reported "picker failed", and fell through to the gws default picker instead of aborting. It is staged through a file now, so the picker's exit code is always its own. Whether it bit you was a race against the pipe buffer, which is why it only ever surfaced on a loaded CI machine. Tests 148 → 150.

v0.3.4 — tests/conformance.sh gave the wrong verdict in both directions. It demanded four auth status fields that real gws omits whenever the credential arrives in the environment, so a faithful backend failed while one that volunteered an identity on a failed mint passed. It now checks that the document tells exactly one credential story, rejects reads that come back as {"error":{"code":401}}, and skips (rather than fails) phase 3 when run standalone with no gwx to drive. Nothing in bin/ changed; the npm package is unaffected. Tests 143 → 148.

v0.3.3 — whoami no longer reports ✓-worthy accounts as ✗ when the credential arrives in the environment rather than a local store. gws reports auth_method: "none" for an empty credential store, which is exactly what token-in-environment deployments arrange on purpose; gwx now reads the token_env_var field alongside it.

v0.3.2 — clears the remaining findings from the same review. gwx all now emits the per-account envelope even when only one account is configured (the output shape no longer depends on how many you have), JSON false/null payloads stay parsed, skills uninstall no longer strands gwx-cli, and install.sh update can no longer copy the live install onto itself. Tests 122 → 137.

v0.3.1 — security and correctness release from a multi-model review (codex, grok, and a fleet of Claude agents with adversarial verification). Closes two ways a write could reach the wrong account, an OAuth-secret exposure during login, a case where a fan-out that failed entirely still exited 0, and a way init could delete existing credentials. Tests 104 → 122. Recommended for everyone.

v0.3.0 — adds GWX_MODE=remote (see Remote mode) for setups where something other than gwx holds the credentials. Local behaviour is unchanged: the mode is opt-in, explicit, and never inferred.

v0.2.0 — fixes four failure-reporting and backend-resolution bugs; most consequential is that a fan-out account whose call produced no stdout (logged out, expired, timed out, crashed) was silently dropped from the NDJSON instead of reporting a row. Anything that consumes fan-out output to decide whether an account is healthy wants this version or later. Pre-1.0; gws itself is also pre-1.0, so expect occasional churn. PRs welcome.

License

MIT © 2026 Björn Schmidtke. See LICENSE.

About

Multi-account wrapper around Google's gws CLI. Forces explicit per-account selection across Gmail, Drive, Calendar, Sheets, Docs, and People — so an agent can never send a personal email from your work address by accident.

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages