Which account am I? Per-shell context isolation for cloud CLIs
You have production open in one terminal and staging in another. You switch the staging window to a different cluster, run your command, and later fire a kubectl delete in the production window, except it is not on production anymore. The moment you switched staging, the production window switched too, silently, because both read the same file.
This is not a mistake you make once and learn from. It is designed in. Every mainstream cloud CLI keeps "which account am I?" in a single mutable file, and every shell on the machine reads it. The model assumes one human running one session at a time, which stopped being true the day you opened a second terminal, and stopped being true twice over once coding agents started running shells of their own.
The answer is one rule: export, never write. Each of these CLIs honors environment variables that override the shared file (CLOUDSDK_CORE_PROJECT for gcloud, a per-cluster KUBECONFIG for kubectl, DIGITALOCEAN_CONTEXT for doctl), so make every context switch an export in the shell that wants it and leave the global file untouched. Each terminal becomes its own isolated session. The rest of this post shows the failure in detail, the variables per tool, and the shell functions that make the switch one command.
The shared mutable file
Look at where each tool stores the current selection:
- gcloud keeps the active configuration in
~/.config/gcloud/active_config, a global pointer to the account, project, and region. - kubeconfig keeps
current-contextand each context'snamespaceinside one~/.kube/config. - doctl keeps the active
contextkey in~/.config/doctl/config.yaml.
Each is a single value shared by every process that reads the file. gcloud config set, kubectl config use-context, doctl auth switch: all of them write that one global value, and every other shell inherits it on the next command. There is no per-terminal notion of "the account I am on". There is only the account, and whoever wrote it last wins.
Here is the failure, explicitly, with two terminals open at once:
# terminal 1: working against staging all afternoon
$ kubectl config current-context
gke-staging
# terminal 2: you switch over to check something on production
$ kubectl config use-context gke-prod
Switched to context "gke-prod".
# back in terminal 1, which you never touched
$ kubectl config current-context
gke-prod
$ kubectl delete pod payments-0 # you think staging; this deletes in productionThe same shape repeats with every tool that stores its selection this way:
# terminal 1
$ gcloud config get-value project
acme-staging
# terminal 2
$ gcloud config set project acme-prod
# terminal 1 again, untouched, now aimed at production
$ gcloud config get-value project
acme-prodAnd doctl auth switch --context prod moves every doctl shell on the machine in exactly the same way. Terminal 1 did nothing, yet its next command runs against production, because the context it reads was never its own.
The fix, by hand: export, never write
Each of these CLIs already honors environment variables that take precedence over its config file. That is the whole fix: move the selection out of the shared file and into variables scoped to the shell that made it. A context switch stops being "write the global file" and becomes "export a variable in this shell". Nothing is written back for another terminal to inherit.
The rule for every switch is the same: export, never write. Set the variables this shell will read, and leave the on-disk config untouched so a stray write cannot move any other window.
For gcloud, three variables override the active configuration entirely:
$ export CLOUDSDK_CORE_ACCOUNT=ada@acme
$ export CLOUDSDK_CORE_PROJECT=acme-staging
$ export CLOUDSDK_CORE_REGION=europe-west1For kubectl, the override is KUBECONFIG, which points at a file. So instead of one merged ~/.kube/config with a mutable current-context, give each cluster its own file and point the shell at the one it should use:
$ kubectl config view --minify --flatten --context gke-staging > ~/.kube/configs/staging.yaml
$ export KUBECONFIG=~/.kube/configs/staging.yamlNamespace is the awkward one: kubectl has no namespace environment variable, so pass --namespace explicitly on every command. helm does have one:
$ export HELM_NAMESPACE=payments
$ kubectl --namespace payments get podsFor doctl, a single variable selects the context:
$ export DIGITALOCEAN_CONTEXT=acme-stagingBecause the selection now lives in the environment, two terminals hold two different accounts at the same time, and neither can move the other:
# terminal 1: production
$ export CLOUDSDK_CORE_PROJECT=acme-prod
$ export KUBECONFIG=~/.kube/configs/prod.yaml
# terminal 2, at the same time: staging
$ export CLOUDSDK_CORE_PROJECT=acme-staging
$ export KUBECONFIG=~/.kube/configs/staging.yaml
# terminal 1 is still on production; nothing it did leaked here, and nothing here reached itThe prompt closes the loop, but only if it reads the right thing
Isolation is only half the win if you still cannot tell at a glance which account a terminal is on. The obvious reach is a "context-aware" prompt that asks the tool: run kubectl config current-context, or read active_config, or shell out to doctl auth list. That does not work, and it fails in exactly the way the shared file already fails.
A prompt that reads the centralized config reads the global value, so every terminal shows the same thing even when they are meant to be on different contexts, which is the original bug wearing a prompt. Take the two terminals from before, each with a prompt that runs kubectl config current-context:
# terminal 1, meant to be staging
[gke-prod] $ # shows prod, because terminal 2 wrote the global context
# terminal 2, on production
[gke-prod] $ # shows prod too, so the prompt "agrees" with terminal 1 and both are misleadingIt can also be stale: shelling out to the CLI on every prompt is slow enough that people cache it, and a cache of a global mutable file drifts the moment another shell writes. And once you have moved selection into per-shell variables, a prompt tied to the on-disk config is not just unhelpful, it is wrong: that file no longer changes, so a prompt reading it reports the same stale default in every window regardless of what the shell will actually do:
# per-shell isolation in effect; this shell acts on production
$ echo "$CLOUDSDK_CORE_ACCOUNT $CLOUDSDK_CORE_PROJECT"
ada@acme acme-prod
# the shared config that selection replaced never moves, so a prompt reading the file sees:
$ cat ~/.config/gcloud/active_config
defaultThe env variable holds the real target, acme-prod; the file holds the frozen default. A prompt that trusts the file confidently shows the wrong answer. The source of truth has to be the same one the command will use: the prompt must read the very same environment variables the shell exports, nothing else. Then what you see is what you will act on, and two terminals on two contexts visibly show two different prompts.
Why this matters more now: coding agents
A coding agent like Claude Code runs its own shells alongside yours, often several at once. Under the shared-file model that is a hazard: an agent that switched project to run a check would move your terminals too, and an agent is exactly the kind of actor you do not want silently repointing your production context. With selection scoped to each shell, every agent session is its own island. It can hold whatever account it needs, and it cannot touch yours or another agent's, for the same reason your two terminals cannot touch each other.
This is what makes a machine safe for genuinely parallel work: several windows on several environments, plus agents running their own, with no shared switch for any of them to flip.
How Dotfiles Pro ships it
The raw exports work, but nobody types four export lines per switch all day, and the --namespace flag on every kubectl command is exactly the kind of discipline that erodes by Friday. The googlecloud, kubernetes, and digitalocean roles wrap the pattern in switch functions that do the exporting for you:
| Tool | Switch | Exports |
|---|---|---|
| gcloud | gcp-account <account> [project] [region] | CLOUDSDK_CORE_ACCOUNT, CLOUDSDK_CORE_PROJECT, CLOUDSDK_CORE_REGION |
| kubectl / helm | kube-config <cluster> [namespace], kube-namespace <ns> | KUBECONFIG (a per-cluster file under ~/.kube/configs/), KUBECTL_NAMESPACE, HELM_NAMESPACE |
| doctl | do-account <context> | DIGITALOCEAN_CONTEXT |
Every function follows the export-never-write rule from above, validates its argument against the accounts or clusters that actually exist, and comes with tab completion. Run one with no argument and it prints the shell's current selection and lists the options, so "which account am I?" always has an answer you can see:
$ gcp-account ada@acme acme-prod europe-west1
$ kube-config prod paymentsThe namespace gap gets a proper fix too: a thin kubectl wrapper reads KUBECTL_NAMESPACE and injects --namespace for you, while helm reads its native HELM_NAMESPACE, and the two stay in agreement. No flag to remember, no way for them to drift apart.
On top of the functions sit a few simple but handy aliases, so the memorable name always works:
googlecloudrunsgcp-accountkubernetesandkubeconfigrunkube-configdigitaloceanrunsdo-accountkuberuns the namespace-awarekubectlwrapperkube-namespaces,kube-clusters,kube-contexts, anddo-projectslist what you can switch to
Finally, the starship role renders a prompt that reads the same variables the switch functions export. The account, project, region, cluster, and namespace on the prompt are not a cache and not a second query that can disagree; they are the exact values the next command resolves:
ada on ☁️ ada@acme (acme-prod) (europe-west1) on ⛵ gke-prod (payments) ~/work/apiThe full rationale lives in the architecture guide under per-shell context isolation.
The pattern is not specific to these three tools. Any CLI that honors an environment override for its host, account, or config directory can be isolated the same way: give the shell the variable, leave the shared file alone, and let the prompt read it back. One account per window, never one account for the machine.