omo-gws-multi-account0.5.0
Multi-account management for the Google Workspace CLI (`gws`). omo package: a `tool_call` hook and a bundled skill.
Untested on omp
This is a pi package. No one has reported whether it works on omp yet. If you try it, add your result below.
Reports 0
No reports yet.
Readme
gws-multi-account
Multi-account management for gws, the Google Workspace CLI. A monorepo shipping plugins for four coding agents out of one shared core:
- Claude Code — a PreToolUse hook that blocks bare
gwscalls, installed via the bundled marketplace (git clone). - opencode — a
tool.execute.beforehook and auto-registered skill, published to npm asopencode-gws-multi-account. - TypeClaw — a
tool.beforehook and a contributed skill, published to npm astypeclaw-gws-multi-account. - omo (omo native) — a
tool_callextension hook and a bundled skill, published to npm asomo-gws-multi-account.
All four plugins share the same enforcement logic (packages/core) and the same SKILL.md. One source tree, four targets.
Why
The gws CLI reads GOOGLE_WORKSPACE_CLI_CONFIG_DIR to pick which account's credentials to use. The simplest way to juggle multiple accounts is to put each account's credentials in its own subdirectory and point the env var at the right one:
GOOGLE_WORKSPACE_CLI_CONFIG_DIR=~/.config/gws/personal@gmail.com gws auth login
GOOGLE_WORKSPACE_CLI_CONFIG_DIR=~/.config/gws/work@company.com gws auth login
That gives you a per-account layout:
~/.config/gws/
├── personal@gmail.com/
│ ├── client_secret.json
│ ├── credentials.enc
│ └── token_cache.json
└── work@company.com/
├── client_secret.json
├── credentials.enc
└── token_cache.json
And from then on, every call names its account explicitly:
GOOGLE_WORKSPACE_CLI_CONFIG_DIR=~/.config/gws/personal@gmail.com gws gmail ...
GOOGLE_WORKSPACE_CLI_CONFIG_DIR=~/.config/gws/work@company.com gws gmail ...
This works — but it's easy for an agent to forget the prefix, and when it does, gws silently falls back to the default account and writes to the wrong inbox / calendar / drive. This package is the guardrail: a hook that inspects every gws invocation and blocks any call missing the env var with an explanatory message the agent can act on. The layout above becomes a contract; the agent picks the account from ~/.config/gws/accounts.json and the hook makes sure it actually did.
It also blocks a second footgun: foreground gws auth login. That command starts an interactive OAuth callback server and blocks until a browser redirect completes; the agent shell's ~60s command timeout kills it mid-flow and leaves the user with a dead URL. The hook catches this and points the agent at the background-spawn flow in references/auth-login.md. Legitimate background spawns (... gws auth login ... & or nohup gws auth login ... &) pass through unchanged.
See skills/gws-multi-account/SKILL.md for the full layout contract.
Install
Claude Code
/plugin marketplace add indentcorp/gws-multi-account
/plugin install gws-multi-account@gws-multi-account
opencode
Add to your opencode.json:
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-gws-multi-account"]
}
The plugin registers its bundled skill at runtime through opencode's config hook — your config files are never modified.
TypeClaw
Add the package to plugins[] in your typeclaw.json:
{
"plugins": ["typeclaw-gws-multi-account"]
}
The plugin's plugin factory contributes a tool.before hook (same enforcement as the other hosts) and the bundled gws-multi-account skill via skillsDirs. Restart the agent to load it.
omo
omo install npm:omo-gws-multi-account
The package is a standard senpi/pi package: its package.json pi manifest loads dist/extension.js (a tool_call hook that blocks offending bash calls) and the bundled skills/ directory. Add -l to install it for the current project only.
Platform support
- macOS, Linux — both plugins work out of the box. The Claude hook runs under Node (bundled with Claude Code), the opencode plugin runs under Bun (bundled with opencode). No shell dependencies.
- Windows — the opencode plugin works. The Claude Code plugin is currently blocked by an upstream Claude Code bug (anthropics/claude-code#32486):
${CLAUDE_PLUGIN_ROOT}inhooks.jsonis not expanded on Windows, so the hook path never resolves. The plugin logic (parser, hook entry, skill registration) is cross-platform — the blocker is variable interpolation inside Claude Code itself, not in this package. The opencode plugin uses a different hook mechanism and is unaffected.
CI runs the full pipeline on Ubuntu, macOS, and Windows.
How it works
All four plugins funnel every Bash-style command through the same enforcement function (packages/core) before the agent is allowed to run it.
- Intercept the command. On Claude Code,
hooks/hooks.jsonregistershook.jsas aPreToolUsehook matching theBashtool; the hook receives the tool payload on stdin. On opencode,plugin.tsregisters atool.execute.beforecallback that fires for thebashtool with the resolved command args. On TypeClaw, the plugin'spluginfactory contributes atool.beforehook that gates the built-inbashtool and returns{ block: true, reason }to block. On omo, the extension registers atool_callhandler that gates the built-inbashtool and likewise returns{ block: true, reason }. Non-Bash tools and empty commands short-circuit immediately. - Split into segments.
parser.tssplits the command on shell control operators (;,&&,||,|,&) and evaluates each segment independently. This is whycd foo && gws …requires the env var on thegwssegment — thecdsegment is irrelevant. - Find the real command word. Within a segment, the parser walks past transparent prefixes:
NAME=VALUEassignments and theenvbuiltin. The first bare word is the actual command. If it isn'tgws(word-boundary match, somy_gws_wrapperandgwsfoodon't trigger), the segment passes. - Check for foreground
gws auth login. If the next two non-flag positional args areauththenlogin, and the segment was not backgrounded (trailing&at the original split point), it's a violation regardless of whether the env var is set — the env var doesn't help when the real problem is the interactive callback server getting killed by the agent's command timeout.gws auth status,gws auth logout,gws auth setup, and background-spawnedgws auth login ... &all pass. - Check for the env var. If any prefix assignment sets
GOOGLE_WORKSPACE_CLI_CONFIG_DIR, the segment passes. Otherwise it's a violation. - Block with an actionable message. Claude's hook writes a
PreToolUsedeny JSON to stdout (permissionDecision: "deny") with the exact offending segment and a fix hint. opencode's hook throws with the same message, which opencode surfaces to the agent. Both messages point at~/.config/gws/accounts.jsonso the agent can pick the right account and retry; the auth-login message additionally points at the skill's background-spawn reference. - Fail open on crash. If the parser itself throws, the Claude hook logs to stderr and exits 0 rather than bricking the user's Bash. The opencode hook inherits opencode's error surface but never swallows the user's command silently.
On opencode startup there's a second, independent flow: the plugin's config hook resolves its bundled skills/ directory (../skills relative to dist/plugin.js) and adds it to the in-memory skills.paths. Nothing is written to disk, and because the path is re-resolved from import.meta.url on every launch it stays correct after package-cache reinstalls.
Layout
.
├── .claude-plugin/ Claude Code plugin + marketplace manifest (git-clone target)
│ ├── marketplace.json
│ └── plugin.json
├── hooks/ Pre-built Claude hook (committed; marketplace clones from GitHub)
│ ├── hook.js Built output of packages/claude/src/hook.ts
│ └── hooks.json References ${CLAUDE_PLUGIN_ROOT}/hooks/hook.js — stays at root
├── skills/ Canonical skill — COMMITTED at root (Claude clones it; build
│ └── gws-multi-account/ copies it into each npm package)
│ ├── SKILL.md
│ └── references/
│ └── auth-login.md OAuth background-spawn flow
├── packages/
│ ├── core/ @gws-multi-account/core — shared, private (not published)
│ │ └── src/parser.ts Enforcement logic (findViolation, buildDenyMessage)
│ ├── claude/ @gws-multi-account/claude — private (built to root hooks/)
│ │ └── src/hook.ts Claude Code PreToolUse entry (stdin/stdout deny JSON)
│ ├── opencode/ opencode-gws-multi-account (npm)
│ │ └── src/
│ │ ├── plugin.ts opencode plugin entry (tool.execute.before hook)
│ │ └── skill-registration.ts
│ ├── typeclaw/ typeclaw-gws-multi-account (npm)
│ │ └── src/plugin.ts TypeClaw plugin entry (definePlugin + tool.before hook)
│ └── omo/ omo-gws-multi-account (npm, pi package)
│ └── src/extension.ts omo extension entry (tool_call hook)
├── tests/
│ └── hook.test.ts bun test — parser, entries, registration, manifests
├── build.ts One script, all targets (hooks/ + each npm package's dist/)
├── package.json Private workspace root (workspaces: packages/*)
├── tsconfig.json / tsconfig.base.json
├── .oxlintrc.json
└── .oxfmtrc.json
Develop
All commands run from the repo root and operate across the whole workspace:
bun install
bun run build # hooks/hook.js + packages/{opencode,typeclaw,omo}/dist + skills copies
bun run test
bun run typecheck
bun run lint
bun run format # writes
bun run format:check
Build outputs
hooks/hook.jsis committed to git. Claude Code's marketplace installer clones from GitHub and cannot runbun build, so the pre-built hook must be present in the tree.skills/at the repo root is committed — it's the canonical skill source and Claude's marketplace clones it directly.packages/*/dist/, the per-packageskills/copies, and per-packageREADME.md/LICENSEare gitignored build artifacts. The three npm packages (opencode-gws-multi-account,typeclaw-gws-multi-account,omo-gws-multi-account) install from npm;@gws-multi-account/coreis private and bundled inline at build time, so it is never published.
After editing any packages/*/src/, run bun run build before committing so hooks/hook.js stays in sync. CI runs the full pipeline on every push/PR (bun run lint, format:check, typecheck, test) and then verifies no drift via git diff --exit-code hooks/ — so if you forget to rebuild, the check fails with a clear message.
Design notes
- Per-segment parsing — commands split on
;,&&,||,|,&socd foo && gws …is evaluated as two segments; the env-var must live on thegwssegment. - Background-aware split — the split preserves which separator produced each segment, so a segment terminated by a bare
&is marked backgrounded. Thegws auth logincheck skips backgrounded segments; the env-var check does not (forgetting the env var is wrong in any mode). - Transparent prefixes —
NAME=VALUEassignments and theenvbuiltin are walked over to find the real command word. Wrappers likenohup,setsid, andtimeoutare not walked — they become the command word, so thegws auth logincheck naturally skips them (wrapping innohupis exactly how you background-spawn). - Word boundaries —
gwsmust be a standalone word (regex(^|\s|=)gws(\s|$));my_gws_wrapperandgwsfoodon't trigger. - Positional-arg matching — the
gws auth logincheck walks positional args (skipping flags) instead of substring-matching, so a file argument likesome-auth-login.pdfor an unrelated subcommand likegws auth something-else-loginnever trips it. - Fail open on crash — a parser exception logs to stderr and exits 0 rather than blocking the user's Bash.
- No config writes — the opencode plugin injects its skill path through the
confighook at runtime instead of editingopencode.json/opencode.jsonc, so JSONC comments are never at risk and nothing is printed on startup.
License
MIT