Generated by Claude (Anthropic) based on a real debugging session with @ankitg12. This post exists because it took ~102,000 tokens of back-and-forth to get Unix tools working in PowerShell — every pitfall documented here was actually hit. The goal: save you those tokens. All code was tested and verified working on PowerShell Core 7.5.4 / Windows 11.
Tags: #claude #llm #aiassisted
You'd think getting ls -lt or grep to work in PowerShell would be a five-minute job. It wasn't. This post documents every pitfall hit along the way so you don't have to burn the same time.
The Goal
Get common Unix commands — ls -lt, grep, sed, awk, head, tail — working with real Unix-style flags in PowerShell Core 7.x on Windows 11.
Don't Reinvent the Wheel — Existing Tools First
Before writing any code, the right tools already exist:
| Tool | What It Does | Stars | Install |
|---|---|---|---|
| uutils/coreutils | Rust rewrite of GNU coreutils, native Windows .exe
|
~22.7k | scoop install uutils-coreutils |
| PowerShell-WSL-Interop | Wraps real Linux binaries from WSL into PS functions | ~447 | Install-Module WslInterop |
| unix-pwsh | Drop-in profile with 30+ Unix aliases | ~87 | Copy profile snippet |
| Nushell | Cross-platform shell replacement with Unix ergonomics | ~34k | scoop install nu |
We went with Scoop to install individual GNU tools — no WSL required, small installs, real binaries:
scoop install uutils-coreutils # ls, cat, head, tail, wc, sort, uniq, touch, du, df
scoop install grep # GNU grep 3.11
scoop install sed # GNU sed 4.9
scoop install gawk # GNU awk 5.3.2 (aliased as awk)
This should have been it. It wasn't.
Pitfall 1: uutils-coreutils Doesn't Include grep
The scoop package for uutils-coreutils installs a single multicall binary (coreutils.exe) — similar to BusyBox. Check what's actually bundled:
coreutils --list
grep, sed, awk, and find are not in the list. They need separate scoop packages (above). If you add function grep { coreutils grep @args } thinking it'll work, you'll get:
grep: function/utility not found
That's coreutils itself telling you the subcommand doesn't exist. The fix: install them separately and don't write any function wrappers at all.
Pitfall 2: Function Wrappers Shadow Real Binaries
PowerShell's command resolution order is:
Alias > Function > Cmdlet > External binary (.exe)
So if you write function grep { ... } in your profile, it beats grep.exe in PATH regardless of PATH ordering. Same for function sort, function uniq — PowerShell has built-in aliases sort → Sort-Object and uniq → Get-Unique.
Rule: If a scoop shim exists for the binary, don't add a function wrapper. Just fix PATH if needed. For tools only available via coreutils multicall (head, tail, wc, du, df), use functions — but remove any conflicting aliases first:
# Remove PS aliases that beat functions in resolution order
'sort','uniq' | ForEach-Object { Remove-Item "Alias:$_" -ErrorAction SilentlyContinue }
Remove-Item Alias:ls -ErrorAction SilentlyContinue
function head { coreutils head @args }
function tail { coreutils tail @args }
function wc { coreutils wc @args }
function sort { coreutils sort @args }
function uniq { coreutils uniq @args }
function touch { coreutils touch @args }
function du { coreutils du @args }
function df { coreutils df @args }
Pitfall 3: Git's Bundled grep Shadows Scoop's grep
Git for Windows ships its own grep.exe (v3.0, ancient) in C:\Program Files\Git\usr\bin\. If Git's path appears before scoop's shims in PATH, Git's grep wins. Check with:
Get-Command grep -All
Fix — add scoop shims to the front of PATH in your profile, but make it idempotent so reloading profile doesn't keep prepending:
if ($env:PATH -notlike "*\scoop\shims*") {
$env:PATH = "$env:USERPROFILE\scoop\shims;" + $env:PATH
}
Pitfall 4: CRLF vs LF in Profile Files
PowerShell profile files on Windows use CRLF (\r\n) line endings. Many editors, tools, and (notably) Claude's Edit tool write LF (\n) only. Mixing them in the same file causes silent parse failures — often reported far from the actual problem line.
After editing a .ps1 file programmatically, normalize:
$f = $PROFILE
$c = [System.IO.File]::ReadAllText($f, [System.Text.Encoding]::UTF8)
$c = $c -replace "`r`n","`n" -replace "`n","`r`n"
[System.IO.File]::WriteAllText($f, $c, [System.Text.Encoding]::UTF8)
Verify the profile parses cleanly before testing anything:
$errors = $null
$null = [System.Management.Automation.Language.Parser]::ParseFile($PROFILE, [ref]$null, [ref]$errors)
$errors # empty = clean
Pitfall 5: Emoji in Profile Files Are a Trap
This one cost significant debugging time. A profile line like:
Write-Host "📁 Session saved: $path"
The emoji 📁 is UTF-8 bytes F0 9F 93 81. If the file is ever read/written as Windows-1252, byte 0x93 becomes " (LEFT DOUBLE QUOTATION MARK). PowerShell's parser sees this as closing the string, breaking everything that follows:
Write-Host "ðŸ" Session saved: $path"
# ^ parser sees this as end of string
# everything after is broken
The parse error is reported ~80 lines later with a completely unrelated message. Use only ASCII in profile files.
Pitfall 6: Diagnose in the Actual Shell, Not a Subprocess
The biggest time-waster: running diagnostics via a subprocess:
# DON'T — this spawns Windows PowerShell 5.x, not your pwsh 7.x session
powershell.exe -File diagnose.ps1
powershell.exe = Windows PowerShell 5.x. pwsh = PowerShell Core 7.x. They have:
- Different
$PROFILEpaths (WindowsPowerShell\vsPowerShell\) - Different command resolution
- Different module paths
So the subprocess shows completely different results. Always diagnose inline in the actual session:
# DO — run directly in your pwsh session
$PSVersionTable.PSVersion # confirm which PS you're running
Get-Command grep -All # see ALL resolution candidates in priority order
(Get-Command grep).Source # which binary actually wins
Pitfall 7: ls Needs Special Handling
ls is a built-in PowerShell alias for Get-ChildItem. Since aliases beat functions, you must remove it before defining a custom ls. Also, Format-Table inside a function conflicts with PowerShell's outer formatter when the function output is piped — use Select-Object to emit plain objects instead:
Remove-Item Alias:ls -ErrorAction SilentlyContinue
function ls {
$flags = ""; $paths = @()
foreach ($a in $args) {
if ($a -match '^-') { $flags += $a.TrimStart('-') }
else { $paths += $a }
}
if ($paths.Count -eq 0) { $paths = @('.') }
$showHidden = $flags -match 'a'
$longFormat = $flags -match 'l'
$sortByTime = $flags -match 't'
$reverse = $flags -match 'r'
foreach ($p in $paths) {
$items = Get-ChildItem -Path $p -Force:$showHidden
if ($sortByTime) { $items = $items | Sort-Object LastWriteTime -Descending:(!$reverse) }
else { $items = $items | Sort-Object Name -Descending:$reverse }
if ($longFormat) {
$items | Select-Object Mode,
@{N='Modified'; E={$_.LastWriteTime.ToString('yyyy-MM-dd HH:mm')}},
@{N='Size'; E={if($_.PSIsContainer){'<DIR>'}else{$_.Length}}},
Name
} else { $items.Name }
}
}
Supports: ls -lt, ls -la, ls -ltr, ls -a, ls /some/path.
The Final Working Setup
# Install (run once)
scoop install uutils-coreutils grep sed gawk
# Profile additions
# 1. Idempotent PATH fix
if ($env:PATH -notlike "*\scoop\shims*") {
$env:PATH = "$env:USERPROFILE\scoop\shims;" + $env:PATH
}
# 2. coreutils multicall wrappers (remove conflicting aliases first)
'sort','uniq' | ForEach-Object { Remove-Item "Alias:$_" -ErrorAction SilentlyContinue }
Remove-Item Alias:ls -ErrorAction SilentlyContinue
function head { coreutils head @args }
function tail { coreutils tail @args }
function wc { coreutils wc @args }
function sort { coreutils sort @args }
function uniq { coreutils uniq @args }
function touch { coreutils touch @args }
function du { coreutils du @args }
function df { coreutils df @args }
# 3. ls with Unix flag support
function ls {
$flags = ""; $paths = @()
foreach ($a in $args) {
if ($a -match '^-') { $flags += $a.TrimStart('-') } else { $paths += $a }
}
if ($paths.Count -eq 0) { $paths = @('.') }
$showHidden = $flags -match 'a'; $longFormat = $flags -match 'l'
$sortByTime = $flags -match 't'; $reverse = $flags -match 'r'
foreach ($p in $paths) {
$items = Get-ChildItem -Path $p -Force:$showHidden
if ($sortByTime) { $items = $items | Sort-Object LastWriteTime -Descending:(!$reverse) }
else { $items = $items | Sort-Object Name -Descending:$reverse }
if ($longFormat) {
$items | Select-Object Mode,
@{N='Modified';E={$_.LastWriteTime.ToString('yyyy-MM-dd HH:mm')}},
@{N='Size';E={if($_.PSIsContainer){'<DIR>'}else{$_.Length}}},Name
} else { $items.Name }
}
}
Result:
grep --version → grep.exe (GNU grep) 3.11
sed --version → GNU sed 4.9
awk --version → GNU Awk 5.3.2
ls -lt → files sorted by time, long format
ls -la → all files including hidden
head/tail/wc/sort/uniq/touch/du/df → via uutils-coreutils
Summary of Pitfalls
| # | Pitfall | Fix |
|---|---|---|
| 1 | uutils-coreutils doesn't bundle grep/sed/awk | Install them as separate scoop packages |
| 2 | Function wrappers shadow .exe binaries | Don't wrap what scoop already installs; remove aliases before functions |
| 3 | Git's grep shadows scoop's grep | Prepend scoop shims to PATH (idempotently) |
| 4 | Mixed CRLF/LF breaks PS parser | Normalize file to CRLF after any programmatic edit |
| 5 | Emoji corrupts as Windows-1252 quote | Use only ASCII in profile files |
| 6 |
powershell.exe -File is a different shell than pwsh
|
Diagnose inline in the actual session |
| 7 |
Format-Table inside functions breaks pipes |
Use Select-Object to emit plain objects |
Further Reading
- uutils/coreutils — the Rust GNU coreutils rewrite
- PowerShell-WSL-Interop — if you have WSL, this gives you the real thing
- unix-pwsh — a ready-made profile alternative
- Scoop — the package manager that makes all of this possible
- HN discussion on uutils: https://news.ycombinator.com/item?id=11337399
Top comments (0)