The Shell Crossover
The Shell Crossover, Part 3: zsh for PowerShell Administrators
Translate PowerShell habits into practical zsh usage on macOS, including command equivalents, quoting, variables, exit codes, pipes, and text tools.
PowerShell and zsh can both run commands, pipe output, and automate work. The difference is the shape of the pipeline.
PowerShell passes objects. zsh usually passes text. That single distinction explains many of the surprises Windows administrators hit when they start scripting macOS.
zsh is the default interactive shell
Current macOS releases use zsh as the default shell in Terminal. That does not mean PowerShell is irrelevant on macOS. It means the native administrative surface area assumes a Unix shell first.
The practical model is simple:
| Use zsh when | Use PowerShell when |
|---|---|
| Calling native macOS commands directly. | Building structured reports. |
| Working with text streams and simple files. | Filtering, grouping, and exporting objects. |
| Running deployment scripts through an MDM. | Writing cross-platform admin tooling. |
| Following vendor-provided macOS commands. | Wrapping native commands into reusable functions. |
The best admin uses both.
Command translation is not behavior translation
The basic commands map cleanly at first glance.
| PowerShell | zsh or native command | Administrative intent |
|---|---|---|
Get-Location | pwd | Show current directory. |
Set-Location | cd | Change directory. |
Get-ChildItem | ls, find | List files. |
Copy-Item | cp, ditto | Copy files. |
Move-Item | mv | Move or rename files. |
Remove-Item | rm | Delete files. |
Get-Content | cat, less, tail | Read file content. |
Select-String | grep | Match text. |
ForEach-Object | for, while, xargs | Repeat work. |
The trap is assuming the same pipeline behavior.
Get-ChildItem -Path "/Applications" -Filter "*.app" |
Select-Object Name, FullNameThat pipeline passes file and directory objects. The equivalent zsh pipeline passes text.
ls -1 /Applications | grep '\.app$'That output is fine for display. It is less safe as a source of truth because filenames can contain spaces, punctuation, and newlines.
For filesystem automation, prefer find over parsing ls.
find /Applications -type d -name "*.app" -prune -printVariables and environment variables
In zsh, ordinary shell variables and exported environment variables are different.
name="Admin Crossover"
echo "$name"
export AC_LOG_PATH="$HOME/Library/Logs/AdminCrossover/execution.log"
echo "$AC_LOG_PATH"A child process inherits exported environment variables, not every shell variable.
PowerShell exposes environment variables through the Env: provider.
$env:AC_LOG_PATH = Join-Path $HOME "Library/Logs/AdminCrossover/execution.log"
Get-Item Env:AC_LOG_PATHOn macOS, remember that PATH uses colons as separators.
[System.IO.Path]::PathSeparator
$env:PATH -split [System.IO.Path]::PathSeparatorQuoting rules matter
In zsh, double quotes allow variable expansion. Single quotes preserve literal text.
site="admincrossover.dev"
echo "Site: $site"
echo 'Site: $site'In PowerShell, the same broad rule exists.
$site = "admincrossover.dev"
"Site: $site"
'Site: $site'The risk increases when you pass predicates, JSON, or paths to native commands. Use variables to keep complex quoting readable.
predicate='process == "mdmclient"'
log show --last 30m --predicate "$predicate" --style compact$predicate = 'process == "mdmclient"'
& /usr/bin/log show --last 30m --predicate $predicate --style compactExit codes are not the same as success objects
In zsh, $? contains the numeric exit status of the last command. Zero means success.
/usr/bin/true
echo $?
/usr/bin/false
echo $?In PowerShell, $? is a Boolean success indicator for the previous command. For native commands, $LASTEXITCODE stores the numeric exit code.
& /usr/bin/true
$?
$LASTEXITCODE
& /usr/bin/false
$?
$LASTEXITCODEThis matters in deployment scripts. A native command can write warning text and still exit with 0. Another command can be quiet and fail with a nonzero exit code. Check the exit code when the command is the authority.
if pkgutil --pkg-info com.example.package >/dev/null 2>&1; then
echo "Package receipt found."
else
echo "Package receipt not found."
fiThe PowerShell version can be explicit.
& /usr/sbin/pkgutil --pkg-info "com.example.package" *> $null
if ($LASTEXITCODE -eq 0) {
"Package receipt found."
} else {
"Package receipt not found."
}Text tools are not a failure, they are the native pattern
A Windows administrator may see grep, awk, and sed as less structured than PowerShell. They are, but they are also the expected tools for many macOS workflows.
# Match lines containing mdmclient in the install log.
grep -i "mdmclient" /var/log/install.log
# Print only the first field from colon-separated input.
echo "name:version:status" | cut -d ':' -f 1
# Sort unique process names from ps output.
ps -axo comm | sort | uniqUse awk when you need column-aware text processing.
ps -axo pid,comm | awk '$2 ~ /mdmclient/ { print $1, $2 }'Use sed for stream edits, not for parsing structured data.
echo "Admin Bridge" | sed 's/Bridge/Crossover/'jq is not included with macOS. It is excellent for JSON when you install and manage it, commonly through Homebrew with brew install jq, but it is still an added dependency. If you cannot guarantee that dependency on the target Mac, prefer native tools or PowerShell’s ConvertFrom-Json for JSON handling.
system_profiler SPSoftwareDataType -json | jq '.SPSoftwareDataType[0].os_version'When PowerShell is clearly better
If the task needs structured grouping, sorting, filtering, or export, PowerShell is often more readable.
Get-ChildItem -Path "/Applications" -Directory -Filter "*.app" |
Sort-Object Name |
Select-Object Name, FullName |
ConvertTo-Json -Depth 3The zsh version can be written, but it becomes more fragile as the structure becomes more important.
find /Applications -type d -name "*.app" -prune -print | sortThat is enough for a list. It is not enough for a typed report.
When zsh is clearly simpler
If the task is to call one native command and check the result, zsh is often the direct path.
profiles status -type enrollmentYou can run the same command from PowerShell.
& /usr/bin/profiles status -type enrollmentBut if you are not using PowerShell objects after the command, the zsh version is simpler.
The operating rule
Do not replace every zsh command with a PowerShell command. Translate the job.
Use zsh for native macOS command execution, simple text streams, and deployment-script compatibility. Use PowerShell when the output needs structure, repeatability, object filtering, or cross-platform reuse.