A Developer Terminal with PowerShell 7: the Object Pipeline, $PROFILE, PSReadLine and Equivalents for Bash Users
In PowerShell, .NET objects flow through the pipe instead of text, so the Bookstore API's JSON can be filtered and grouped directly with Invoke-RestMethod (prices arrive as doubles; convert to decimal for money). With real output we cover $PROFILE and modules, what the execution policy really protects, winget, a bash equivalents table, curl.exe, what 7.6 LTS brought and 7.4's support ending on 10 November 2026, plus encoding and CRLF pitfalls.
Version note: The commands in this post were run with PowerShell 7.6.10 (PSReadLine 2.4.5, PSResourceGet 1.2.1) on Ubuntu 24.04, in a clean environment with the .NET 10 SDK, posh-git 1.1.0 and Starship 1.27.0 installed. Cmdlets and their output are the same on Windows; only paths start with C:\. Execution policy, winget and Windows-specific alias behaviour come from the Microsoft Learn documentation; those parts were not run on Windows.
Guess First
The API of Bookstore, the online bookstore used as the shared example across this site's series, returns five books as JSON from /api/books. Prices are decimal on the C# side. In PowerShell you run:
PS> $books = Invoke-RestMethod http://localhost:5080/api/books
PS> $books.Count
PS> $books[0].price.GetType().FullName
What does the first line return: a JSON string or a collection of five items? Does the second line say System.Decimal, System.Double or System.String? The answer to the second question is a detail you need to know when doing money calculations in PowerShell.
Text Pipeline and Object Pipeline
In bash, what flows through a pipe is text. When you write ps aux | grep dotnet | awk '{print $2}', awk assumes it "knows" the second column of ps's output is the PID; if the output format changes, the command silently gives the wrong result. How the text pipeline is put together is covered in the Linux and Shell series.
In PowerShell, what flows through a pipe is .NET objects. You use property names instead of column numbers:
PS> Get-Process | Where-Object CPU -gt 1 | Sort-Object CPU -Descending | Select-Object -First 3 Name, Id, CPU, @{ n = "MemMB"; e = { [int]($_.WorkingSet64 / 1MB) } }
Name Id CPU MemMB
---- -- --- -----
VBCSCompiler 1663 3.29 215
VBCSCompiler 4615 2.79 226
pwsh 8295 2.21 156
Where-Object CPU -gt 1 is a numeric comparison, not text matching. @{ n = ...; e = ... } defines a calculated property: here we turn bytes into MB. The table format is produced only at the very end, when printing to the screen; before that you hold real objects at every step. If you don't know which properties an object has, Get-Member tells you. It is the most important discovery tool in PowerShell.
Handling JSON from the Bookstore API as Objects
The answer to the guess is here. Invoke-RestMethod looks at the response's Content-Type and turns the JSON into objects itself:
PS> $books = Invoke-RestMethod http://localhost:5080/api/books
PS> $books.GetType().Name; $books.Count
Object[]
5
PS> $books[0] | Get-Member -MemberType NoteProperty
TypeName: System.Management.Automation.PSCustomObject
Name MemberType Definition
---- ---------- ----------
author NoteProperty string author=Frank Herbert
category NoteProperty string category=science-fiction
currency NoteProperty string currency=TRY
isbn NoteProperty string isbn=9780441172719
price NoteProperty double price=420
stock NoteProperty long stock=12
title NoteProperty string title=Dune
An array of five items, each a PSCustomObject. price, however, is a double: JSON has a single number type with no decimal/double distinction, and PowerShell turns fractional numbers into doubles. That's fine for viewing, but for totals and discount calculations convert explicitly with [decimal]$_.price. The rest are one-line queries:
PS> $books | Where-Object Stock -eq 0 | Select-Object Title, Author
title author
----- ------
Emma Jane Austen
PS> $books | Sort-Object Price -Descending | Select-Object -First 3 Title, Price
title price
----- -----
Dune 420.00
The Hobbit 310.00
Middlemarch 265.00
PS> $books | Group-Object Category | Select-Object Name, Count
Name Count
---- -----
classics 3
fantasy 1
science-fiction 1
PS> $books | Where-Object Stock -lt 5 | Select-Object Isbn, Title, Stock | Export-Csv low-stock.csv; Get-Content low-stock.csv
"isbn","title","stock"
"9780141439587","Emma","0"
"9780141439549","Middlemarch","3"
Property names are case-insensitive: even though the JSON has stock, you can write Stock. To take one book and turn it back into JSON there is ConvertTo-Json; to read JSON from a file, Get-Content x.json | ConvertFrom-Json. For an ISBN that isn't found, Invoke-RestMethod turns the 404 into an exception: Response status code does not indicate success: 404 (Not Found). In the next post we see how to do the same jobs in bash with curl and jq; knowing both means you can answer the same question whichever shell you are in.
If you'll need one of these queries again and again, turn it into a script. I added scripts/low-stock.ps1 to the repo:
#Requires -Version 7.6
param(
[string]$BaseUrl = 'http://localhost:5080',
[int]$Threshold = 5,
[string]$OutFile
)
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
$books = Invoke-RestMethod "$BaseUrl/api/books"
$low = $books |
Where-Object stock -lt $Threshold |
Sort-Object stock |
Select-Object isbn, title, stock, @{ Name = 'price'; Expression = { [decimal]$_.price } }
if ($OutFile) {
$low | Export-Csv $OutFile
Write-Output "$($low.Count) books written to $OutFile"
}
else {
$low
}
PS> ./scripts/low-stock.ps1
isbn title stock price
---- ----- ----- -----
9780141439587 Emma 0 185.50
9780141439549 Middlemarch 3 265.00
PS> ./scripts/low-stock.ps1 -Threshold 10 -OutFile low.csv
3 books written to low.csv
The param block gives the script typed parameters; typing -Threshold also gets tab completion. #Requires -Version 7.6 stops the script from accidentally running in Windows PowerShell 5.1.
$PROFILE and Modules: PSReadLine, posh-git, Terminal-Icons
PowerShell's startup file is $PROFILE. There are four profiles; the one you care about is "Current User, Current Host". Its values on Linux:
PS> $PROFILE | Select-Object *
AllUsersAllHosts : /usr/share/powershell/.store/powershell.linux.arm64/7.6.10/powershell.linu
x.arm64/7.6.10/tools/net10.0/any/profile.ps1
AllUsersCurrentHost : /usr/share/powershell/.store/powershell.linux.arm64/7.6.10/powershell.linu
x.arm64/7.6.10/tools/net10.0/any/Microsoft.PowerShell_profile.ps1
CurrentUserAllHosts : /home/ada/.config/powershell/profile.ps1
CurrentUserCurrentHost : /home/ada/.config/powershell/Microsoft.PowerShell_profile.ps1
Length : 61
On Windows, according to Microsoft's documentation, the same file is $HOME\Documents\PowerShell\Microsoft.PowerShell_profile.ps1. The documentation also warns: if the Documents folder is redirected to OneDrive or a network share, modules may fail to load and the profile may throw errors. If the file doesn't exist, create it with New-Item -ItemType File -Path $PROFILE -Force and open it with code $PROFILE. The VS Code PowerShell extension uses its own host and reads Microsoft.VSCode_profile.ps1; if you want the same settings in both places, put the shared part in CurrentUserAllHosts (profile.ps1).
The part of my profile added in this post:
# Frequent commands (an alias can't take arguments, a function can)
function dt { dotnet test @args }
function db { dotnet build @args }
function gs { git status -sb @args }
function dtf {
param(
[Parameter(Mandatory)][string]$Name,
[Parameter(ValueFromRemainingArguments)][string[]]$Rest
)
dotnet test --filter "FullyQualifiedName~$Name" @Rest
}
# Modules and prompt
if (Get-Module posh-git -ListAvailable) {
Import-Module posh-git
}
if (Get-Command starship -ErrorAction Ignore) {
Invoke-Expression (&starship init powershell)
}
In PowerShell, Set-Alias only maps one command name to another and can't carry arguments; two-word shortcuts like dotnet test have to be functions. @args passes all the arguments the function received as they are. In dtf, though, using [Parameter(Mandatory)] makes it an "advanced function" and @args stops working, so we collect the remaining arguments with ValueFromRemainingArguments. When I first wrote it without knowing this, dtf IsbnTests --no-build failed with: A positional parameter cannot be found that accepts argument '--no-build'.
I install modules from the PowerShell Gallery with PSResourceGet; version 1.2.1 ships with PowerShell 7.6:
PS> Install-PSResource posh-git -Scope CurrentUser -TrustRepository
PS> Import-Module posh-git; (TabExpansion2 "git sw" 6).CompletionMatches.CompletionText; (TabExpansion2 "git switch ma" 13).CompletionMatches.CompletionText
switch
main
In the previous post PowerShell had no git completion; posh-git completes both subcommands and branch names and adds branch information to the prompt (~/bookstore [main]>). Because I use Starship for the prompt, I load posh-git only for completion; the same starship.toml from zsh gives the same prompt in PowerShell. Terminal-Icons adds file-type icons to Get-ChildItem output and needs a Nerd Font. posh-git's latest release is 1.1.0, Terminal-Icons' is 0.11.0, and both repos have barely changed since 2024; they work, but I don't expect active development.
What Execution Policy Protects and What It Doesn't
The first obstacle someone running a profile or script on Windows for the first time hits is the execution policy. Microsoft's documentation says it plainly: the execution policy is not a security boundary but a safety setting that makes it harder for a user to run scripts unintentionally. Someone who copies a script's content and pastes it at the command line bypasses it anyway. According to the documentation, PowerShell 7's default on Windows is RemoteSigned: local scripts run, unsigned ones downloaded from the internet don't. If no scope has a value set, the effective policy on a Windows client is Restricted and not even the profile loads. To check on your own machine:
PS> Get-ExecutionPolicy -List
PS> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
The "came from the internet" information comes from the Mark of the Web that the browser adds to the file; Unblock-File removes it. According to the documentation, files downloaded with curl.exe, Invoke-WebRequest and Invoke-RestMethod may not get this mark, so RemoteSigned treats them as local files. On Linux and macOS the execution policy is not enforced; the same command returned Unrestricted for every scope there. In short, know the execution policy so you can fix the "no script runs" problem, but don't leave security decisions to it.
Installing Tools with winget
On Windows, use winget instead of downloading installers and clicking next-next. The IDs of the tools in this series, from the winget-pkgs repository:
PS> winget install --id Microsoft.PowerShell --source winget
PS> winget install --id Microsoft.WindowsTerminal --source winget
PS> winget install --id Git.Git --source winget
PS> winget install --id Microsoft.DotNet.SDK.10 --source winget
PS> winget install --id OpenJS.NodeJS.LTS --source winget
PS> winget install --id Starship.Starship --source winget
PS> winget upgrade --all
Giving the exact ID with --id prevents another package with a similar name from being picked. After installation, PATH is updated in newly opened terminals; if dotnet isn't found in the same window, open a new tab. Turning the setup of a fresh machine into a script is the subject of the Productivity and Automation series.
Bash ↔ PowerShell Equivalents and curl.exe
The equivalents someone coming from bash needs most often:
| bash | PowerShell 7 | Note |
|---|---|---|
ls -la | Get-ChildItem -Force (gci) | Returns objects: Length, LastWriteTime |
cat file | Get-Content file (gc) | -Tail 20 -Wait = tail -f |
grep -rn Austen src | Get-ChildItem src -Recurse -Filter *.cs | Select-String Austen | Results are objects with .LineNumber |
export X=1, echo $X | $env:X = '1', $env:X | Only for that process and its children |
which dotnet | Get-Command dotnet | -All for every copy |
cmd > /dev/null | cmd > $null | 2>&1 is the same |
a && b, a || b | same | Since PowerShell 7.0 |
source ./env.sh | . ./env.ps1 | Dot-sourcing, runs in the same scope |
curl -s url | jq | Invoke-RestMethod url (irm) | JSON becomes objects by itself |
Aliases differ by platform. On Windows, ls, cat, cp and rm are aliases of PowerShell cmdlets, which is why ls -la fails on Windows (Get-ChildItem has no -la parameter). On Linux and macOS these aliases aren't defined, and the real ls runs:
PS> Get-Alias curl, wget, ls, cat, cd, cp
Get-Alias: This command cannot find a matching alias because an alias with the name 'curl' does not exist.
Get-Alias: This command cannot find a matching alias because an alias with the name 'wget' does not exist.
Get-Alias: This command cannot find a matching alias because an alias with the name 'ls' does not exist.
Get-Alias: This command cannot find a matching alias because an alias with the name 'cat' does not exist.
CommandType Name Version Source
----------- ---- ------- ------
Alias cd -> Set-Location
Get-Alias: This command cannot find a matching alias because an alias with the name 'cp' does not exist.
curl and wget are a special case: in Windows PowerShell 5.1 both were aliases of Invoke-WebRequest, and real curl commands like curl -H "..." url gave confusing errors. These two aliases aren't defined in PowerShell 7; on Windows too, typing curl runs the real curl. Windows 10 and 11 ship the real curl as curl.exe; in scripts, write curl.exe with the extension to be sure which one runs. The curl -w examples in the Networking and HTTP series work unchanged in PowerShell 7 with curl.exe.
PowerShell 7.6 LTS: What It Brought, When 7.4 Ended
PowerShell 7.6 shipped as an LTS release on .NET 10 on 18 March 2026 and, according to Microsoft's support page, is supported until 14 November 2028. The same page says support for 7.4 LTS and 7.5 ended on 10 November 2026; as of the day this post was written, neither gets security updates. It's time to move machines still on 7.4 to 7.6. The changes from the announcement you'll notice in daily use:
- Tab completion improvements: better path completion across providers, value completion for some cmdlet parameters, completing a module by its short name.
- Formerly experimental features becoming permanent: among them the feedback provider that offers suggestions when a command isn't found, and redirecting output to a variable.
Get-Clipboard -Delimiter,Get-Command -ExcludeModule, and stderr output respecting theNO_COLORenvironment variable.- A few small breaking changes, such as
Join-Path -ChildPathnow accepting multiple values.
Redirecting to a variable now works in 7.6 without any extra setting:
PS> dotnet --version > variable:sdk; "SDK: $sdk"
SDK: 10.0.404
Get-ExperimentalFeature lists which features are still experimental; in this version three remain and none is enabled.
In Production
We saw it in the first post: pwsh -c and pwsh -File load the profile unless -NoProfile is given. I ran into a real side effect of this with my own profile. The line that set the prediction view to ListView printed this warning every time in a pwsh -c call not attached to a terminal:
WARNING: The prediction 'ListView' is temporarily disabled because the current window size of the console is too small. To use the 'ListView', please make sure the 'WindowWidth' is not less than '50' and the 'WindowHeight' is not less than '5'.
Imagine that landing in a build script's log. I apply two fixes together: always call scripts and build steps with pwsh -NoProfile -File, and guard settings in the profile that only make sense interactively:
Set-PSReadLineOption -PredictionSource History
if (-not [Console]::IsOutputRedirected) {
Set-PSReadLineOption -PredictionViewStyle ListView
}
The second topic is error handling. $ErrorActionPreference = 'Stop' stops the script on cmdlet errors, but by default it doesn't see non-zero exit codes from external programs like git, dotnet and npm. $PSNativeCommandUseErrorActionPreference, permanent since PowerShell 7.4, changes that:
$ cat check.ps1
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
git -C /home/ada/bookstore switch no-such-branch
Write-Output "this line never runs"
$ pwsh -NoProfile -File check.ps1; echo "exit code: $?"
fatal: invalid reference: no-such-branch
NativeCommandExitException: /tmp/check.ps1:4
Line |
4 | git -C /home/ada/bookstore switch no-such-branch
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
| Program "git" ended with non-zero exit code: 128 (0x80).
exit code: 1
The script stopped at git's error and the next line didn't run. Without the second variable, the same script printed the error and carried on with the next line. Make it a habit to put these two lines at the top of every PowerShell script that will run in CI.
Common Pitfalls
The first is mixing up Windows PowerShell 5.1 and PowerShell 7. powershell.exe is 5.1, pwsh.exe is 7. The profile folders are separate (Documents\WindowsPowerShell and Documents\PowerShell), the module folders are separate, and the behaviour differs. Most "my profile doesn't work" complaints come from being in the wrong PowerShell; check with $PSVersionTable.PSEdition.
The second is encoding. PowerShell 7 writes files as UTF-8 without a BOM by default:
PS> "Les Misérables" > title.txt; Format-Hex title.txt
Label: /home/ada/bookstore/title.txt
Offset Bytes Ascii
00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
------ ----------------------------------------------- -----
0000000000000000 4C 65 73 20 4D 69 73 C3 A9 72 61 62 6C 65 73 0A Les Misérables�
PS> "Les Misérables" | Out-File title-bom.txt -Encoding utf8BOM; (Format-Hex title-bom.txt).Bytes[0..3] -join " "
239 187 191 76
é was written as two bytes (C3 A9) and the file doesn't start with a BOM; if you ask for utf8BOM, the first three bytes are EF BB BF. According to Microsoft's documentation, Windows PowerShell 5.1 is inconsistent: > and Out-File write UTF-16LE, while Set-Content uses the system's ANSI code page. 5.1 reading a BOM-less UTF-8 script as ANSI and garbling non-ASCII characters is also a known problem; save scripts that must also run in 5.1 and contain non-ASCII characters as UTF-8 with BOM.
The third is line endings. A bash script saved on Windows with CRLF endings blows up on Linux, in WSL or in a container like this:
$ ./restore.sh
/usr/bin/env: ‘bash\r’: No such file or directory
/usr/bin/env: use -[v]S to pass options in shebang lines
$ od -c restore.sh | head -2
0000000 # ! / u s r / b i n / e n v b
0000020 a s h \r \n e c h o " r e s t o
The fix belongs in the repo, not in the editor: put *.sh text eol=lf and *.ps1 text eol=crlf in .gitattributes, so everyone's checkout gets the same line endings. Line ending settings are covered in detail in the Git & GitHub series post on git config.
The fourth is adding up money amounts from JSON as doubles; we saw it above, convert to [decimal] before calculating.
A Small Experiment
★☆☆ Run Get-ChildItem -Recurse -File src | Sort-Object Length -Descending | Select-Object -First 5 FullName, Length in the Bookstore repo. Answer the same question in bash with find, sort and head, and compare the two approaches.
★★☆ While the API is running, write a one-line pipeline that calculates the total stock value per category (price times stock) using [decimal]. Hint: Group-Object, then Measure-Object -Sum and a calculated property.
★★★ Add a -Category parameter to scripts/low-stock.ps1, restrict the allowed values with [ValidateSet()] and see tab completion suggest those values. Run the script with pwsh -NoProfile -File and confirm that the exit code is non-zero when the API is down.
Both shells are configured. In the next post we add the modern CLI tools that speed up daily work on top of them: ripgrep, fd, bat, eza, zoxide, fzf, and jq for handling the Bookstore API's JSON in bash.