GitHub - clarenceb/az-profiles: Per-terminal Azure CLI profile isolation for Bash and Zsh — work with multiple Azure identities, subscriptions and tenants at the same time. · GitHub
Skip to content

About

Per-terminal Azure CLI profile isolation for Bash and Zsh — work with multiple Azure identities, subscriptions and tenants at the same time.

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

6 Commits

Folders and files

Repository files navigation

azp — per-terminal Azure CLI profiles

az-profiles (azp)

Per-terminal Azure CLI logins. Work in several Azure tenants and subscriptions at once, with each terminal — and each background agent session — pinned to its own identity.

fredb@host:~/dev/client-a (infra:main*) [az:clientA→Contoso Prod]$

The problem

The Azure CLI keeps its token cache and the active subscription in a single directory, ~/.azure. So az login and az account set are effectively global: switching subscription in one terminal silently changes it in every other terminal, in your editor, in CI helper scripts, and in any long-running agent session. If you routinely move between tenants, it's easy to run a command against the wrong subscription without noticing.

The fix

The Azure CLI honours the AZURE_CONFIG_DIR environment variable. Point it at a different directory per shell and each shell gets a completely independent login. This is Microsoft's own recommendation for isolating concurrent CLI usage:

To avoid potential failures, isolate the Azure CLI configuration folder for each script by setting environment variable AZURE_CONFIG_DIR for each script to a separate directory.

— Tips for using the Azure CLI successfully

azp is a small shell function that manages those directories: creating them, switching between them, showing which one is active, and optionally selecting one automatically per project directory. It also sets AZD_CONFIG_DIR so the Azure Developer CLI (azd) follows along.

There is no daemon, no config format and no dependency beyond az itself — profiles are ordinary directories under ~/.azure-profiles/.

Install

git clone https://github.com/<you>/az-profiles.git ~/development/tools/az-profiles
~/development/tools/az-profiles/install.sh

The installer symlinks the script to ~/.az-profiles.sh and adds a source line to ~/.bashrc and ~/.zshrc. Open a new terminal, or . ~/.az-profiles.sh.

Manual install — symlink it wherever you like and source it:

ln -s ~/development/tools/az-profiles/az-profiles.sh ~/.az-profiles.sh
echo '[ -f "$HOME/.az-profiles.sh" ] && . "$HOME/.az-profiles.sh"' >> ~/.bashrc

Why source instead of putting it on $PATH? A command on $PATH runs in a child process and cannot change the environment of the shell that invoked it. Since switching profiles means setting AZURE_CONFIG_DIR in your current shell, azp has to be a shell function. That's the same reason nvm, pyenv and conda activate are sourced rather than installed as executables.

Requires the Azure CLI (az).

Platform support

azp is a Bash / Zsh function, so it targets Unix-like shells:

Platform Status
Linux Supported
WSL (WSL1 / WSL2) Supported — see the note below
macOS Supported (Bash 3.2 stock, or Homebrew Bash/Zsh)
Windows — PowerShell or cmd Not supported
Windows — Git Bash / MSYS2 Untested; likely works

Requirements: az, plus Bash 4+ or Zsh (macOS's stock Bash 3.2 is fine — no associative arrays or ${var^^} are used). python3 is used only by the prompt segment; without it the segment shows the profile name but no subscription, and every other command is unaffected. GNU-vs-BSD differences in stat and cp are handled explicitly, so no coreutils install is needed on macOS.

Windows without WSL

There's no PowerShell port, but the underlying mechanism is identical — set the same environment variable per session:

$env:AZURE_CONFIG_DIR = "$HOME\.azure-profiles\client-a"
$env:AZD_CONFIG_DIR   = "$env:AZURE_CONFIG_DIR\azd"
az login --tenant <tenant-id>

Wrap that in a PowerShell function if you want the same ergonomics.

WSL note

If you installed the Azure CLI on Windows and use it from WSL, ~/.azure may be a symlink to your Windows profile (/mnt/c/Users/<you>/.azure). Two consequences:

  • Copying a symlink with cp -a copies the link, which would make every profile an alias of the same directory — they'd all change together. azp clone passes -L to dereference, so profiles stay independent.
  • Reads from /mnt/c are slow. azp clone copies only the small identity and config files (see below), which keeps it well under a second.

Getting started

Import your existing login as a profile, so you don't have to re-authenticate:

azp clone work        # copy current ~/.azure into a profile named 'work'
azp default work      # make it the profile new terminals start in

Add a second identity:

azp login client-a --tenant <tenant-id>

Then check what you've got:

azp ls
     PROFILE                SUBSCRIPTION                           ACCOUNT
*d   work                   Contoso Production                     you@contoso.com
     client-a               Client A Sandbox                       you@clienta.com

  * = this shell    d = default for new shells

Commands

Command Description
azp ls List profiles with subscription and signed-in account
azp current Show this shell's profile, subscription, tenant and user
azp use <profile> Switch this shell to a profile (creates it if new)
azp login <profile> [args] Switch, then az login; extra args pass through
azp clone <profile> Seed a new profile from the current ~/.azure, no re-login
azp rm <profile> Delete a profile
azp reset Revert this shell to the global ~/.azure
azp users List users signed in to the current profile
azp user <name> Switch active user within the current profile
azp adduser [args] az login an additional user into the current profile
azp default [profile] Profile that new shells start in (--clear to unset)
azp init [profile] Pin the current directory tree via a .azprofile file
azp auto on|off Toggle directory auto-switching
azp prompt [on|off|init] Control the prompt segment
azp prompt format [fmt] Choose what the segment shows
azp prompt icon [mode] Logo image, Unicode dots, Nerd Font glyph, or plain text

Run azp with no arguments for the same summary. Bash tab-completion is included for subcommands and profile names.

  ╭───────────╮
  │ ● ● ●     │   azp
  │  ❯ ▂      │   per-terminal Azure CLI profiles
  ╰───────────╯

The banner drops to ASCII (+--+, >) on non-UTF-8 locales, and colour is suppressed when output isn't a terminal, when TERM=dumb, or when NO_COLOR is set.

Adding profiles

For a new account or tenant, log in fresh:

azp login client-a --tenant <tenant-id>
azp login client-a --use-device-code      # any az login flag works

For a second context under an account you're already signed in to, clone instead — it copies the token cache, so there's no second sign-in:

azp clone prod
az account set -s <subscription-id>

That's the usual way to keep one terminal on prod and another on dev without either one disturbing the other.

clone copies only identity and configuration — azureProfile.json, the MSAL token caches, config, clouds.config and friends. Installed extensions and downloaded tool binaries (cliextensions/, bin/) are symlinked back to the global install rather than duplicated, and logs and caches are skipped. A profile is a few hundred KB rather than a few hundred MB, and cloning takes under a second.

Multiple users in one profile

A profile can hold several signed-in identities at once. This is the right tool when the same terminal context needs different users — for example an admin account alongside a day-to-day one.

azp users                        # who is signed in to this profile
azp adduser --tenant <id>        # az login another identity, keeping the rest
azp user admin@contoso.com       # make that identity active
     USER                                        SUBSCRIPTIONS
*    you@contoso.com                             90
     admin@contoso.com                           2

azp user accepts any unambiguous substring, so azp user admin is usually enough; if it matches more than one user, azp lists the candidates instead of guessing. Switching is instant when the cached token is still valid — no browser round-trip.

How this works, and its one limitation. The Azure CLI keys its profile by subscription ID, and each subscription entry records exactly one owning user. Selecting a user therefore means selecting a subscription that user owns, which is what azp user does — preferring your current subscription if that user can also see it.

The consequence: a single profile cannot hold two users on the same subscription. The second az login overwrites the first for that subscription. If you need two identities against one subscription — say to compare RBAC — give each its own profile:

azp login sub1-admin --tenant <id>
azp login sub1-readonly --tenant <id>

Default profile

azp default work        # new shells start here
azp default             # show current default
azp default --clear     # new shells use the global ~/.azure again

The default applies to newly opened shells; it doesn't retroactively change ones you already have open.

Per-directory profiles

Pin a repository to a profile so you can't act on the wrong tenant:

cd ~/dev/client-a
azp init client-a       # writes .azprofile

Entering that directory — or any subdirectory — switches automatically, and leaving it restores whatever you were using before:

~$ cd dev/client-a
azp: auto -> client-a
~/dev/client-a$ cd ~
azp: auto -> work

.azprofile is a one-line text file containing a profile name (# comments allowed). It's safe to commit if your team shares profile naming, or add it to .git/info/exclude to keep it local. A manual azp use inside a pinned tree takes precedence until you leave and return. Disable with azp auto off.

If a .azprofile names a profile that doesn't exist, azp warns rather than switching silently.

Prompt indicator

The active profile and subscription are appended to your existing prompt:

fredb@host:~/dev (repo:main*) [az:client-a→Client A Sandbox]$

The segment shows profile→subscription — the subscription, not the tenant, since the tenant is only a GUID. Change what it shows with a format string:

azp prompt format                 # current format, tokens and presets
azp prompt format user            # switch to a preset
azp prompt format '%p→%U@%s'      # or supply your own template
Preset Template Renders
profile %p [az:mcap]
sub (default) %p→%s [az:mcap→Contoso-Prod-Sandbox…]
user %p→%U [az:mcap→fredb]
user-sub %p→%U@%s [az:mcap→fredb@Contoso-Prod-Sand…]

Tokens: %p profile, %s subscription, %u full user, %U user before the @, %d user's domain.

If you upgrade the script and re-source it in an already-open shell, the prompt cache is discarded automatically. Should a segment ever look stale or half-empty, opening a new terminal always resolves it.

Which is right depends on how you name profiles. If a profile maps to one identity, the name already implies the user and sub is the more informative choice — the subscription is what changes and what commands act on. If you use azp user to switch identities within a profile, prefer user-sub, or a user switch won't be visible in the prompt.

Persist your choice in your rc file:

AZP_PROMPT_FORMAT='%p→%s'

Nothing is shown when you're on the global ~/.azure, so unmanaged shells look untouched. !login marks a profile with no active credentials.

The segment never shells out to az — that would add roughly a second to every prompt. It parses azureProfile.json directly and caches on the file's mtime, so a re-parse only happens after the profile actually changes.

Control it at runtime:

azp prompt          # is the segment on, and what does it render right now
azp prompt off      # hide it in this shell (PS1 left untouched)
azp prompt on       # show it again, attaching to PS1 if needed
azp prompt init     # print shell commands to attach it yourself

The segment attaches automatically when the script is sourced. If your PS1 is set after that — common when a framework or a later line in .bashrc owns the prompt — the segment gets overwritten. Attach it explicitly at the end of your rc file instead:

eval "$(azp prompt init)"

To place it by hand, set AZP_PROMPT_ATTACH=0 before sourcing the script, then put ${_AZP_PROMPT_STR} wherever you want it and call __azp_precmd from PROMPT_COMMAND (Bash) or a precmd hook (Zsh). If you also want the logo image, emit ${_AZP_ICON_RAW} immediately after it, inside \[ \] (Bash) or %{ %} (Zsh) so the shell counts it as zero-width:

PS1='...\[\033[01;36m\]${_AZP_PROMPT_STR}\[\033[00m${_AZP_ICON_RAW}\]\$ '

Icon

The az: prefix can be replaced by the actual azp logo, drawn as a real image in the prompt:

fredb@host:~/dev (repo:main*) [▣ client-a→Client A Sandbox]$

This uses Sixel graphics, which Windows Terminal 1.22+ supports (so Windows Terminal + WSL2 works), as do WezTerm, foot, contour, mlterm, xterm built with --enable-sixel-graphics, and VS Code's terminal 1.94+. macOS Terminal.app and iTerm2 do not.

azp prompt icon             # what mode is active, and what this terminal supports
azp prompt icon test        # preview it with column markers
azp prompt icon dots        # force a mode
azp prompt icon probe       # re-detect (after changing font size, say)
Mode Renders Requires
auto (default) best available —
sixel the logo as an image a Sixel-capable terminal
dots [●●● mcap→Contoso…] UTF-8
glyph [󰠅 mcap→Contoso…] a Nerd Font
text [az:mcap→Contoso…] nothing (plain ASCII)
none [mcap→Contoso…] nothing

auto never sends a graphics sequence blindly. On the first prompt in a new kind of terminal it sends a Device Attributes query (ESC [ c) and only uses Sixel if the terminal reports attribute 4; it also asks for the cell size (ESC [ 16 t) and picks the largest bundled image that fits inside the two reserved columns. It errs small on purpose: an image even one pixel taller than a text row makes the terminal scroll, which pushes the rest of the prompt onto the next line. The answer is cached under ~/.cache/az-profiles/, so later shells start without probing. Terminals that don't answer fall back to dots, then to text on non-UTF-8 locales. The probe is skipped entirely if you have already started typing, so it can't swallow type-ahead.

To skip detection altogether, pin a mode in your rc file:

AZP_PROMPT_ICON=dots        # or text / none / sixel

Pre-rendered images live in assets/sixel/, at heights that are exact multiples of 6 — Sixel data is written in six-pixel bands, and a partial band would push the image past the bottom of the text row. To regenerate them after editing assets/icon.svg:

pip install cairosvg pillow
python3 tools/build-sixel.py
Variable Default Purpose
AZ_PROFILE_ROOT ~/.azure-profiles Where profile directories live
AZ_PROFILE_MARKER .azprofile Per-directory marker filename
AZP_AUTO 1 Auto-switch on directory change
AZP_PROMPT 1 Render the prompt segment
AZP_PROMPT_ATTACH 1 Patch PS1/PROMPT automatically when sourced
AZP_PROMPT_FORMAT %p→%s What the segment shows (see above)
AZP_PROMPT_SHOW_GLOBAL 0 Show the segment on the global profile too
AZP_PROMPT_MAXLEN 28 Truncate long subscription names
AZP_PROMPT_ICON auto auto/sixel/dots/glyph/text/none
AZP_PROMPT_ICON_DOTS ●●● Characters used by dots mode
AZP_PROMPT_ICON_GLYPH 󰠅 Character used by glyph mode
AZP_PROMPT_ICON_CELLS 2 Columns reserved for the Sixel image
AZP_PROMPT_ICON_HEIGHT (auto) Image height in px (multiple of 6); overrides detection
AZP_ASSET_DIR (next to script) Where sixel/ images are looked up

Set these before sourcing the script, e.g. in ~/.bashrc.

If you use a prompt framework such as Starship or Powerlevel10k, set AZP_PROMPT_ATTACH=0 and read $AZURE_CONFIG_DIR from your own prompt config — most already ship an Azure module.

How it works

azp use <name> exports two variables and nothing else:

export AZURE_CONFIG_DIR="$AZ_PROFILE_ROOT/<name>"
export AZD_CONFIG_DIR="$AZURE_CONFIG_DIR/azd"

Because they're exported, every process launched from that shell inherits the profile — az, azd, Terraform's azurerm provider, scripts, and editors started from that terminal. Because they're per-process, no other shell is affected.

Uninstalling is correspondingly simple: remove the source line from your shell rc file. Your profiles remain as plain directories under ~/.azure-profiles/, and ~/.azure is never modified.

Trade-offs

  • Each profile holds its own tokens. More profiles means more separate sign-ins to refresh. azp clone avoids this when the account is the same.
  • Tokens are on disk, exactly as the Azure CLI already stores them. Profile directories are created 700; treat them as you would ~/.azure and don't sync them to shared storage.
  • Extensions and tool binaries are shared, not isolated — cliextensions/ and bin/ symlink back to the global install. Installing an extension in one profile makes it available in all of them. Only credentials and configuration are separated.
  • Two users on the same subscription need two profiles — see Multiple users in one profile.
  • Already-open terminals keep their old profile until you restart them or re-source the script.
  • The prompt segment shells out to python3 on a cache miss. Turn it off with azp prompt off or AZP_PROMPT=0 if that's unwelcome; every other command works without it.

Related

License

MIT — see LICENSE.

About

Per-terminal Azure CLI profile isolation for Bash and Zsh — work with multiple Azure identities, subscriptions and tenants at the same time.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages