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 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 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_DIRfor each script to a separate directory.
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/.
git clone https://github.com/<you>/az-profiles.git ~/development/tools/az-profiles
~/development/tools/az-profiles/install.shThe 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"' >> ~/.bashrcWhy source instead of putting it on
$PATH? A command on$PATHruns in a child process and cannot change the environment of the shell that invoked it. Since switching profiles means settingAZURE_CONFIG_DIRin your current shell,azphas to be a shell function. That's the same reasonnvm,pyenvandconda activateare sourced rather than installed as executables.
Requires the Azure CLI (az).
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.
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.
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 -acopies the link, which would make every profile an alias of the same directory — they'd all change together.azp clonepasses-Lto dereference, so profiles stay independent. - Reads from
/mnt/care slow.azp clonecopies only the small identity and config files (see below), which keeps it well under a second.
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 inAdd 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
| 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.
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 worksFor 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.
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 userdoes — 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 loginoverwrites 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>
azp default work # new shells start here
azp default # show current default
azp default --clear # new shells use the global ~/.azure againThe default applies to newly opened shells; it doesn't retroactively change ones you already have open.
Pin a repository to a profile so you can't act on the wrong tenant:
cd ~/dev/client-a
azp init client-a # writes .azprofileEntering 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.
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 yourselfThe 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}\]\$ '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 / sixelPre-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.pySet 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.
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.
- Each profile holds its own tokens. More profiles means more separate
sign-ins to refresh.
azp cloneavoids 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~/.azureand don't sync them to shared storage. - Extensions and tool binaries are shared, not isolated —
cliextensions/andbin/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
python3on a cache miss. Turn it off withazp prompt offorAZP_PROMPT=0if that's unwelcome; every other command works without it.
- Azure CLI configuration — what lives in
$AZURE_CONFIG_DIR - Sign in with the Azure CLI — authentication options accepted by
azp login azdconfiguration —AZD_CONFIG_DIR
MIT — see LICENSE.
