# Shell Portability

> Read-only syntax scanner for cross-shell traps, reporting file:line + why + the PS-5.1-safe fix: && and || (PS 5.1 parser error), ternary / ?. / ?? (PS7-only), Read-Host/pause/Out-GridView (block non-interactive runs), Set-Content/Add-Content/Out-File without -Encoding, bash-isms in .ps1 and PowerShell-isms in .sh. Use when: "will this run on PowerShell 5.1", "lint my shell scripts", "shell-portability", before scheduling a script — and `check "<command>"` for ONE command string before handing it to someone to run, the surface a file scan cannot reach. Syntax only — encoding/filename quirks are path-quirk-audit. Zero deps.

- Skill: `evan-daruwalla/shell-portability` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add evan-daruwalla/shell-portability`
- Raw SKILL.md: https://api.skillmd.com/api/skills/evan-daruwalla/shell-portability/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Security
- Author: Evan-Daruwalla (https://skillmd.com/u/evan-daruwalla)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/evan-daruwalla/shell-portability

---


# shell-portability — cross-shell syntax trap scanner

The engine is `shell-portability.js` (portable Node, zero deps). It is the
proactive counterpart to a reactive shell-troubleshooting runbook (that
runbook diagnoses a script that already broke; this scans the source and flags
the same documented traps *before* they reach the shell) — with `file:line`,
why it breaks on a Windows machine, and the PowerShell-5.1-safe alternative.
Read-only; it never writes outside its own canary temp dir.

Scope is **syntax and semantics only**. Encoding and filename quirks (non-ASCII
bytes that corrupt a `.bat` parse, UTF-16 BOMs, builtin-shadowing stray files)
are `path-quirk-audit`'s job — this scanner deliberately does not duplicate
them, and it skips `.bat` files entirely.

## What it flags

**`.ps1` / `.psm1`** — the target here is PowerShell **5.1** (the common
Windows default, not PS7):

| Trap | Why it breaks | PS5.1-safe fix |
|---|---|---|
| `&&` / `\|\|` | no pipeline-chain operators — parser error | `A; if ($?) { B }` / `A; if (-not $?) { B }` |
| `?:` ternary | PS7-only | `if/else` |
| `?.` / `?[]` | null-conditional, PS7-only | guard with `if ($null -ne $x)` |
| `??` / `??=` | null-coalescing, PS7-only | `if ($null -eq $x) { ... }` |
| `Read-Host` / `pause` / `Out-GridView` | block a non-interactive / scheduled run | take input as a param / env / file |
| `Set-Content`/`Add-Content`/`Out-File` w/o `-Encoding` | ANSI/UTF-16 default corrupts UTF-8 for the next reader | add `-Encoding utf8` |
| bash-style `NAME=value` / `export NAME=value` | PS can't assign to a bareword; `export` isn't a cmdlet | `$name = value` / `$env:NAME = 'value'` |

**`.sh` / `.bash`** — PowerShell-isms leaking into a POSIX script:

| Trap | Why it breaks | POSIX fix |
|---|---|---|
| `$env:NAME` | bash has no `$env:` namespace | `$NAME` / `${NAME}`, set with `export` |
| Verb-Noun cmdlet (`Get-ChildItem`, `Set-Content`, ...) | PowerShell cmdlet, not a bash command | POSIX equivalent (`ls`/`find`, `printf > file`) |

## Commands

```
node shell-portability.js scan <path> [<path>...]
node shell-portability.js check "<command>" [--sh]
node shell-portability.js --canary
node shell-portability.js --help
```

- **scan** recurses any files or directories given (skips `.git`,
  `node_modules`, `.golden`, `graphify-out`), reads every `.ps1/.psm1/.sh/.bash`
  file, and prints one block per finding:

  ```
  path\to\deploy.ps1:12: [chain-and-or] PS 5.1 has no && / || pipeline-chain operators (parser error)
      fix: A; if ($?) { B }  (and)   /   A; if (-not $?) { B }  (or)
      > git pull && npm run build
  ```

  Exit 1 if anything is flagged, 0 if clean.

- **check** runs the same rule table over ONE command string instead of a
  file. Use it before handing a command to someone to run. Defaults to the
  PowerShell 5.1 dialect; `--sh` checks it as POSIX instead. Same exit codes.

  ```
  $ node shell-portability.js check 'cd "D:/repo" && npm run build'
  <command>:1: [chain-and-or] PS 5.1 has no && / || pipeline-chain operators (parser error)
      fix: A; if ($?) { B }  (and)   /   A; if (-not $?) { B }  (or)
      > cd "D:/repo" && npm run build
  ```

  **ALWAYS QUOTE the command.** Unquoted, your own shell eats the `&&` before
  Node sees it, so only the first half is scanned and it reports clean — a
  false negative, the worst outcome for a gate. The clean line echoes exactly
  what was scanned (`clean: no PowerShell 5.1 traps in: cd D:/repo`) so that
  truncation is visible rather than silent.

### Why `check` exists

`scan` only reads **files**. A command written into a chat reply, a README, or
a runbook never reaches the scanner's input surface — which is how a
`cd X && git push` gets handed to a PowerShell 5.1 user with the rule that
would catch it sitting right there, having nothing to run against. `check`
closes that gap by making a bare string checkable.

### Suppression

A trailing `# portability-ok` comment silences every finding on that line — use
it for a deliberate exception (e.g. a string that merely *contains* `&&`, or a
script that is PS7-only on purpose):

```powershell
$result = git pull && git push   # portability-ok  (PS7 CI runner only)
```

### Examples

- **Guard a scheduled job's script before it ships** (a monthly rebalance job
  runs as a Windows scheduled task — a `Read-Host` or a `&&` would hang or
  parser-error unattended): `node shell-portability.js scan /path/to/scripts`
  — catches any interactive prompt or PS7-ism before the scheduled run hits it.
- **Check a helper `.ps1` writes UTF-8 for the next tool** (`Set-Content`'s
  ANSI default is a well-known trap): the scanner flags every
  `Set-Content`/`Out-File` missing `-Encoding utf8`.
- **Scan a `.sh` you cross-wrote in a PowerShell headspace**: catches a stray
  `$env:PATH` or `Get-ChildItem` before it silently does nothing under bash.

## Windows notes

- PowerShell 5.1 lacks `&&`, `||`, ternary, `?.`, and `??` — this scanner exists
  because those keep leaking in from PS7 / bash habits.
- Backtick line-continuations are joined into one logical line, so an
  `-Encoding` flag on the following line still counts. `<# ... #>` block comments
  and whole-line `#` comments are not scanned.
- `.bat` files are out of scope — their traps are byte/parse-level, which is
  `path-quirk-audit`'s domain.

## Storage / exit codes

Read-only — writes nothing except inside a throwaway temp dir during `--canary`.

`0` clean · `1` findings or canary failure · `2` usage error.

## Verification (the done-check)

```
node shell-portability.js --canary
```

Self-tests both directions in a throwaway temp dir: the documented traps are
CAUGHT (a bad `.ps1` with `&&` + a ternary + an unencoded `Set-Content` yields
exactly 3 findings; `??`, `?.`, `Read-Host`, bash-assign, and a bad `.sh` each
caught) AND clean PS5.1-safe / POSIX code stays quiet (0 findings); the check
mode catches a chained handed-over command and passes its chain-free form, in
both dialects, and rejects an empty invocation; plus the
`# portability-ok` suppression and an end-to-end directory walk. MUST print
`CANARY PASS 22/22` before you trust a result.

