README
¶
g9s
A k9s-style terminal UI for Google Cloud. Pick a project, browse what's running in it, act on it — without leaving the terminal or juggling gcloud config set project.
Built for the case where you have several projects, each reached through a different account, and those accounts expire daily.
⬇️ Download
No Go toolchain, no dependencies compiled on your machine. These links always resolve to the newest release:
| Platform | Download |
|---|---|
| macOS — Apple Silicon (M1–M4) | g9s_darwin_arm64.tar.gz |
| macOS — Intel | g9s_darwin_amd64.tar.gz |
| Linux — x86-64 | g9s_linux_amd64.tar.gz |
| Linux — ARM64 | g9s_linux_arm64.tar.gz |
| checksums.txt |
📋 All releases and version history → — every version, with auto-generated notes of what changed.
One-liner for Apple Silicon:
curl -L https://github.com/TTMathCS/g9s/releases/latest/download/g9s_darwin_arm64.tar.gz | tar xz --strip-components=1 && sudo mv g9s /usr/local/bin/ && g9s -version
Full instructions — checksum and signed-provenance verification, other platforms, the macOS Gatekeeper step, and building from source instead — are under Install. gcloud is still required separately; see Requirements.
Three screens, in the order you move through them.
Projects. You open here, and the thing you actually need to know is already on screen: which of your ten projects you can use right now. Green is good for another 38 minutes, amber expired overnight, hollow means this machine has never logged in. Press l on any row and gcloud takes the terminal to fix it.

Dashboard. Selecting a project fans out across every resource kind at once and lands you here — what exists, how much of it, and what state it is in, before you drill into anything. A category whose listing came back partial says so on its own row, so a truncated list never reads as an empty one. enter opens the category under the cursor, or jump straight in with 1/2/3.

Resources. The table for one category, colour-coded by status. a swaps to All Resources, which merges every kind into one table keyed by kind, name, location and status — the flat "what is in this project" list. esc goes back up to the dashboard, p all the way out to the project list.

All three screenshots are generated from the real rendering code — see docs/. The projects, IDs and accounts in them are invented.
Roadmap
Legend: ✅ shipped · 🔜 next up · 💡 candidate · ⛔ not planned (by design, not an oversight)
Resource kinds
| Kind | Status | Scope | Notes |
|---|---|---|---|
| Compute Engine instances | ✅ | zonal, aggregated | one aggregatedList call covers every zone |
| GKE clusters | ✅ | zonal + regional, aggregated | parent: projects/*/locations/- covers everything in one call |
| Cloud Storage buckets | ✅ | global | simplest lister — one call, no fan-out |
| Dataproc clusters | ✅ | regional | a client per region; global always swept |
| Cloud Composer environments | ✅ | location-scoped | one client, location in the request parent |
| BigQuery datasets & recent jobs | 🔜 | global | jobs answer "what is running", not just "what exists" |
| Cloud SQL instances | 🔜 | global | version, tier, HA state, maintenance window |
| Pub/Sub topics & subscriptions | 🔜 | global | subscription backlog is the number people actually want |
| Secret Manager secrets | 🔜 | global | names and versions only — never values |
| Cloud Run services & jobs | 🔜 | regional | |
| Dataproc jobs | 🔜 | regional | clusters without jobs is half the story |
| Dataflow jobs | 🔜 | regional | |
| Service accounts & keys | 🔜 | global | key age is a standing audit question |
| GKE node pools | 🔜 | per-cluster | drill-down from a GKE row, not a new top-level tab |
| Compute/serverless (Functions, Batch, instance groups, disks, GPU/TPU) | 💡 | mixed | |
| Data (Bigtable, Spanner, Memorystore, Firestore, Datastream, Artifact Registry) | 💡 | mixed | |
| Networking (VPC, firewall, LB, Cloud DNS, VPN, Interconnect, PSC) | 💡 | mixed | |
| Security/identity (IAM bindings, KMS, Certificate Manager, VPC-SC, Org Policy) | 💡 | mixed | |
| Operations (Logging, Monitoring alerts, Error Reporting, Scheduler, Cloud Build) | 💡 | mixed | |
| Cost & quota (usage vs. limits, monthly spend) | 💡 | mixed | needs billing export reachable |
Platform features
| Feature | Status | Notes |
|---|---|---|
| Per-project dashboard with status rollups | ✅ | |
| Merged All Resources view across kinds | ✅ | |
| Filter, describe-as-YAML, Console/Airflow links, OSC 52 yank, SSH | ✅ | |
| Mutating actions behind a confirmation | 🔜 | VM / Dataproc power state first — no Terraform drift |
| Terraform state overlay (managed / drifted / unmanaged) | 🔜 | the single most useful thing on this list, and the most work |
| Cloud Asset Inventory fast path | 💡 | optional — plenty of orgs don't enable the API |
| Cross-project view (one kind, every project at once) | 💡 | the other axis from the dashboard's per-kind rollup |
| Saved filters / bookmarks | 💡 | |
| Export current table to CSV/JSON | 💡 | |
| Prebuilt release binaries (no Go toolchain needed) | ✅ | macOS + Linux, arm64 + amd64, with checksums and signed SLSA provenance — see Install |
| Writing infrastructure | ⛔ | not a Terraform replacement |
| Storing credentials | ⛔ | gcloud owns that; g9s never touches a credential |
| Displaying secret values | ⛔ | names/versions only — use gcloud secrets versions access, which is logged |
ROADMAP.md has the full picture with reasoning per item — why each is scoped the way it is, and why global/regional/zonal is what decides the cost of adding it.
Why this exists
Cloud Asset Inventory makes "list everything in a project" a single API call. Without it — and plenty of orgs don't enable it — you fan out across a dozen service APIs, several of which are region-scoped, and you do it again for every project. g9s does that fan-out and puts the result in one keyboard-driven table.
Status
MVP. Five resource kinds — Compute Engine, GKE, Cloud Storage, Dataproc, Cloud Composer — read-only plus SSH. The resource layer is behind a one-method interface, so adding a kind is one new file — see Adding a resource kind.
Navigation is three levels deep: projects → dashboard → a category's table, with esc walking back up. A new kind appears on the dashboard, in the tab bar and in All Resources automatically; there is nothing to register in the UI.
The number keys reach kinds 1–9, so there is room for four more before the digits run out; past that, tab/shift+tab, 0/a and the : commands still reach everything.
Requirements
gcloud CLI — required, not optional. g9s checks for it at startup and exits with gcloud not found rather than letting you discover the problem mid-session. It's needed because login and SSH are the two places a human is involved:
lrunsgcloud auth application-default loginto mint credentialssrunsgcloud compute ssh
Everything else — the resource listing — talks to the GCP APIs directly and never shells out. See Design notes for why.
Go 1.25+ — only if you build from source. Not needed if you download a release binary, which is self-contained. Go is never a runtime dependency.
Install
Two options. The first needs no Go toolchain and pulls no dependencies onto your machine; the second builds from source.
Option 1: download a release binary (no Go toolchain)
Every tagged release attaches archives for macOS and Linux on both Apple Silicon/ARM and Intel/AMD64, plus a checksums.txt. Archive names carry no version, so releases/latest/download/… is a permanent URL — the version lives in the release tag, the directory inside the archive, and g9s -version.
# Pick your platform: darwin_arm64, darwin_amd64, linux_amd64, linux_arm64
PLATFORM=darwin_arm64
BASE=https://github.com/TTMathCS/g9s/releases/latest/download
curl -LO "${BASE}/g9s_${PLATFORM}.tar.gz"
curl -LO "${BASE}/checksums.txt"
# Verify before extracting, not after
shasum -a 256 -c checksums.txt --ignore-missing
tar -xzf "g9s_${PLATFORM}.tar.gz" --strip-components=1
sudo mv g9s /usr/local/bin/
g9s -version
To pin a specific version instead of tracking the latest, swap latest/download for download/v0.1.0 — see all releases.
Verifying provenance, not just integrity. The checksum only proves your download wasn't corrupted in transit — it says nothing about where the file came from. Each archive also carries a signed SLSA build provenance attestation tying it to this repository, the exact commit and the workflow run that produced it. If you have the gh CLI:
gh attestation verify "g9s_${PLATFORM}.tar.gz" --repo TTMathCS/g9s
That is the check worth running. It fails if the archive was built anywhere other than this repo's CI.
On macOS, Gatekeeper will complain the first time — the binaries are not Apple-notarised (that needs a paid Developer ID). Clear it with xattr -d com.apple.quarantine /usr/local/bin/g9s, or right-click → Open once. If you would rather not, build from source instead.
Option 2: build from source
Needs Go 1.25+ and fetches roughly a hundred module dependencies. See Setup on a new Mac below for the full toolchain walkthrough, or if you already have Go:
go install github.com/TTMathCS/g9s/cmd/g9s@latest
Setup on a new Mac
For building from source. Both paths work on Apple Silicon and Intel. Pick one.
With Homebrew
brew install go
brew install --cask gcloud-cli # the cask was renamed from google-cloud-sdk
The cask symlinks gcloud into your Homebrew prefix (/opt/homebrew/bin on Apple Silicon, /usr/local/bin on Intel), so it lands on your PATH with no further setup.
If Homebrew itself isn't installed yet and you want it:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
On Apple Silicon its installer prints two eval lines to add /opt/homebrew/bin to your PATH — run them, or brew won't be found in new shells.
Without Homebrew
Go — download the macOS .pkg from go.dev/dl (ARM64 for Apple Silicon, x86-64 for Intel) and run it. It installs to /usr/local/go and adds /usr/local/go/bin to your PATH via /etc/paths.d/go, which takes effect in new shells.
gcloud — download and run Google's installer:
# Apple Silicon; for Intel swap darwin-arm for darwin-x86_64
curl -O https://dl.google.com/dl/cloudsdk/channels/rapid/downloads/google-cloud-cli-darwin-arm.tar.gz
tar -xf google-cloud-cli-darwin-arm.tar.gz
./google-cloud-sdk/install.sh
install.sh offers to edit your shell profile to add gcloud to PATH — say yes, or you'll have to invoke it by full path. Extract it somewhere permanent (~/google-cloud-sdk is conventional); the install location is the installation, and moving it later breaks the profile entry.
Skip gcloud init. It configures a default project in your global gcloud state, which g9s deliberately doesn't use — it sets CLOUDSDK_CONFIG per project instead. Running it is harmless, just pointless here.
gcloud needs Python 3. macOS provides it with the Xcode Command Line Tools (xcode-select --install); if gcloud picks the wrong interpreter, point it at one with CLOUDSDK_PYTHON=/path/to/python3.
Then install g9s
Two ways. Use the second if your network doesn't reach proxy.golang.org.
A — go install:
go install github.com/TTMathCS/g9s/cmd/g9s@latest
This drops the binary in $(go env GOPATH)/bin, normally ~/go/bin — not on your PATH by default on a fresh Mac. Add it:
echo 'export PATH="$HOME/go/bin:$PATH"' >> ~/.zshrc
exec zsh
(macOS has used zsh as the default shell since Catalina. On bash, use ~/.bash_profile.)
B — clone and build:
git clone https://github.com/TTMathCS/g9s.git
cd g9s
go build -o g9s ./cmd/g9s # binary lands in the current directory
Copy the binary onto your PATH yourself (sudo mv g9s /usr/local/bin/), or run it as ./g9s.
Behind a corporate proxy
Point GOPROXY at your internal Go registry. For Artifactory the api/go path segment is required:
go env -w GOPROXY="https://artifactory.example.com/artifactory/api/go/<go-repo>"
go env -w GOSUMDB=off # unless your registry proxies sum.golang.org
go env -w persists these to ~/.config/go/env, so they survive new shells and don't need to live in your profile.
Authenticate with ~/.netrc — Go reads it natively, which keeps the token out of GOPROXY and out of your shell history:
machine artifactory.example.com
login <username>
password <api-key-or-access-token>
chmod 600 ~/.netrc
With that in place both install methods work normally. If you use the JFrog CLI, jf go-config followed by jf go build ./cmd/g9s sets GOPROXY for you.
Which method to use. Cloning does not remove the need for a module registry — go build still resolves ~30 dependencies, so a clone alone doesn't get you an offline build. Method B helps in the specific case where your registry serves common dependencies but won't resolve github.com/TTMathCS/g9s itself — a brand-new repo that isn't cached, or a registry with an approval allowlist. The clone sidesteps that one lookup; the dependencies still come from Artifactory.
Toolchain gotcha. go.mod requires Go 1.25.0, and the default GOTOOLCHAIN=auto will try to download a matching toolchain through GOPROXY if your local Go is older. On a restricted network that fails with a confusing error. Install Go 1.25+ directly and it never comes up; go env -w GOTOOLCHAIN=local makes the attempt fail fast and loudly instead.
Verify
gcloud --version # any recent version
go version # must be 1.25 or newer
g9s -version # prints "g9s dev" — the version is only stamped in tagged release builds
If g9s isn't found, the PATH line above is what's missing. If g9s starts and immediately prints gcloud not found at "gcloud", gcloud isn't on the PATH of the shell you launched it from — open a new terminal, since profile edits don't apply retroactively.
Quick start
g9s -init # writes ~/.config/g9s/config.yaml
$EDITOR ~/.config/g9s/config.yaml
g9s
Start with a single project and add the rest once it works.
On first launch every project shows ○ not logged in. Select one, press l, and gcloud takes over the terminal to run the login. Once it's done you're dropped back into the table.
Nothing needs to be logged in ahead of time, and you don't need to have run gcloud on this machine before — g9s keeps its credentials in its own directory and never reads or writes your global ~/.config/gcloud. Moving to a new Mac therefore means logging in again; there's no credential state worth copying across, and copying it would defeat the isolation.
Staying current
go install github.com/TTMathCS/g9s/cmd/g9s@latest # upgrade g9s
brew upgrade --cask gcloud-cli # or: gcloud components update
Use gcloud components update for the non-Homebrew install. It doesn't work on the Homebrew cask — brew owns those files and gcloud will tell you so.
How authentication works
This is the part worth understanding, because it's usually mis-modelled.
g9s never sees your password. When you press l, it suspends itself and runs gcloud auth application-default login with the terminal handed over. gcloud opens your browser; your identity provider's login page — including the password from your PAM checkout and the MFA challenge — is handled entirely by the browser. g9s resumes once gcloud has written the credentials to disk.
If your identity is federated (Entra ID, Okta, or similar), that browser redirect is what carries you to your IdP. There is no way to type an SSO password into a terminal and have Google accept it, and you should be suspicious of any tool that offers to.
Each project gets its own credential directory. g9s sets CLOUDSDK_CONFIG to a per-project path under credential_dir, so:
- logging into one project never disturbs another,
- ten projects with ten different support accounts coexist without
gcloud config configurationsjuggling, - nothing g9s does mutates your normal
~/.config/gcloudstate.
Expiry is detected by using the credentials, not by reading a timestamp. A refresh token your IdP has invalidated looks perfectly healthy on disk. g9s mints a real access token to check, so an expired session shows up as ● expired — press l to re-login rather than as a confusing API error ten seconds later. With a typical federated session policy you should expect to re-login roughly once a day.
When the terminal isn't on the machine with the browser
Press L instead of l. That adds --no-browser, which prints a bootstrap command to run on a trusted machine that has both a browser and gcloud; you paste the resulting URL back. It's fiddlier than the local flow, so prefer running g9s on your workstation if you can.
Configuration
~/.config/g9s/config.yaml, or $G9S_CONFIG, or -config <path>.
defaults:
# Swept for region-scoped resources unless a project overrides them.
# Keep this tight: every region is another API call on every refresh.
regions:
- northamerica-northeast1
- us-central1
credential_dir: ~/.local/share/g9s/credentials
gcloud_path: gcloud
list_timeout: 90s
projects:
- name: sandbox # label in the picker; names the credential dir
project_id: my-sandbox-project
description: personal access, read-only
- name: prod-data
project_id: my-prod-data-project
account: svc-prod-support@example.com # passed to gcloud --account
regions:
- northamerica-northeast1 # overrides defaults.regions
composer_locations:
- us-central1 # overrides both, for Composer only
Region resolution runs most-specific-first: projects[].composer_locations → projects[].regions → defaults.composer_locations → defaults.regions. Dataproc works the same way via dataproc_regions, and always includes the global region, which is easy to forget and does hold clusters.
Unknown keys are an error rather than a silent default, so a typo'd regionz: tells you instead of quietly scanning nothing.
Keys
The bindings follow k9s muscle memory where the two tools overlap: : jumps by name, d describes, s opens a shell (here: SSH), / filters, and q/esc back out one level rather than quitting.
| Key | Action |
|---|---|
↑/k, ↓/j |
move cursor |
g / G |
top / bottom |
enter |
dashboard: open the category · table: describe (YAML, as gcloud describe shows it) |
d |
describe the selected resource |
: |
command — :vm :gke :gcs :dataproc :composer :all :projects :q (prefixes work: :data) |
1–9 |
jump straight to a resource kind |
0 / a |
all resources — every kind in one table |
tab / shift+tab |
cycle resource kinds |
q / esc |
back up one level — table to dashboard, dashboard to projects |
p |
back to the project list |
/ |
filter rows; esc clears |
r |
refresh current kind — every kind when on the dashboard |
o |
open — Airflow UI for Composer, Cloud Console otherwise |
c |
open in Cloud Console |
y |
copy name to clipboard (OSC 52, works over SSH) |
s |
SSH to the selected running VM |
l / L |
log in / log in without a local browser |
? |
help |
ctrl+c / :q |
quit — or q from the project list |
Partial results are shown as partial
With a least-privilege account, some regions and some APIs will refuse you. A tool that discards the whole refresh because one of ten regions returned 403 is useless, and one that silently drops it is worse — an empty table reads as "nothing is running here."
So listers return whatever succeeded plus a warning per failed scope, and the footer says so:
⚠ 2 scope(s) unavailable: europe-west1: permission denied; us-east4: permission denied
Errors that are expected rather than informative — the API simply isn't enabled in that region, or the region doesn't exist — are suppressed, or the footer would be permanently full of noise.
Adding a resource kind
Implement gcp.Lister in a new file under internal/gcp and add it to Listers():
type Lister interface {
Kind() Kind
List(ctx context.Context, cfg *config.Config, p config.Project, opts []option.ClientOption) (Result, error)
}
Use fanOut for anything region-scoped; it handles the concurrency, the partial-failure collection and the stable ordering. internal/gcp/dataproc.go is the shortest example.
Two things the table relies on: your Resource.Row must have exactly as many cells as Kind.Columns, and the number keys only reach the first nine listers. Both are covered by tests.
Design notes
Why not shell out to gcloud ... --format=json? It's the fast way to build this and it handles auth for free, but each invocation is a ~1–2s Python cold start. Across a fan-out of a dozen regions the UI would feel dead. g9s uses gcloud only where a human is involved — login and SSH — and talks to the APIs directly everywhere else.
Why is Dataproc the awkward one? Its endpoint is regional. A request for us-central1 sent to the default endpoint returns nothing rather than an error, so each region needs its own client pointed at <region>-dataproc.googleapis.com. Composer is location-scoped through the request parent instead, so one client covers every location. Compute needs no fan-out at all — aggregatedList returns every zone in one call.
Why a quota project? Application default credentials minted from a user account have no project of their own, and most APIs reject the call outright without one attached. g9s sets it on every client.
Security
g9s never sees your password, never writes a credential, and issues no mutating API call — every request is a List or a Get. Credentials are isolated per project under a 0700 directory, and the config file is refused if anyone else can write it, since gcloud_path decides which binary gets executed.
SECURITY.md covers the threat model, what the tool can reach and run, the findings from a July 2026 code review (two issues found and fixed, plus the paths examined and cleared), and the dependency posture. CI runs govulncheck ./... on every push.
License
MIT
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
g9s
command
Command g9s is a terminal UI for browsing Google Cloud resources.
|
Command g9s is a terminal UI for browsing Google Cloud resources. |
|
internal
|
|
|
auth
Package auth manages per-project GCP credentials.
|
Package auth manages per-project GCP credentials. |
|
config
Package config loads and validates the g9s configuration file.
|
Package config loads and validates the g9s configuration file. |
|
gcp
Package gcp lists GCP resources for a project.
|
Package gcp lists GCP resources for a project. |