DEV Community

Ankit Gaur
Ankit Gaur

Posted on

Getting Unix Tools to Work in PowerShell: A Debugging War Story

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 sortSort-Object and uniqGet-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 }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Pitfall 5: Emoji in Profile Files Are a Trap

This one cost significant debugging time. A profile line like:

Write-Host "📁 Session saved: $path"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

powershell.exe = Windows PowerShell 5.x. pwsh = PowerShell Core 7.x. They have:

  • Different $PROFILE paths (WindowsPowerShell\ vs PowerShell\)
  • 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
Enter fullscreen mode Exit fullscreen mode

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 }
    }
}
Enter fullscreen mode Exit fullscreen mode

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 }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)