Skip to content

Latest commit

 

History

History
571 lines (410 loc) · 22.2 KB

File metadata and controls

571 lines (410 loc) · 22.2 KB

Using ps-bash

Ps-bash has two modes: the PsBash module and the ps-bash shell. They share the same 77 bash commands and typed object pipeline, but serve different use cases.

PsBash Module

The PowerShell module (Install-Module PsBash) adds bash commands directly into your PowerShell session. Use this when you want bash commands available alongside Get-ChildItem, ForEach-Object, and everything else PowerShell offers.

Install-Module PsBash
ls -la                          # LsEntry objects
ps aux | sort -k3 -rn | head 5  # PsEntry objects, sorted by CPU
cat README.md | grep 'bash'     # text search, typed objects

How it works

Every command returns a PSCustomObject with typed .NET properties and a .BashText string property. The console renders BashText (looks like real bash). Your code accesses typed properties.

ls -la
  → Invoke-BashLs
  → [LsEntry] objects
      .Name         = "README.md"        [string]
      .SizeBytes    = 4521               [int]
      .Permissions  = "-rw-r--r--"       [string]
      .Owner        = "beagle"           [string]
      .LastModified = 2024-04-02...      [DateTime]
      .BashText     = "-rw-r--r-- 1..."  [string]

Pipeline commands (grep, sort, head, tail, tee) match against BashText but pass through the original typed objects. This is the pipeline bridge.

Mixing with PowerShell

Since PsBash commands are just PowerShell functions, they work seamlessly with native cmdlets:

# Bash to PowerShell
$files = ls -la | grep '.ps1' | sort -k5 -h
$files | Where-Object { $_.SizeBytes -gt 50KB }
$files | ForEach-Object { Write-Host $_.Name }

# Bash command, PowerShell comparison
$top = ps aux | sort -k3 -rn | head 5
$top | Where-Object { $_.CPU -gt 5.0 } | ForEach-Object { Stop-Process $_.PID -WhatIf }

# PowerShell in, bash out
Get-Content errors.log | grep 'FATAL' | sort | uniq -c

Setup

# Install from PSGallery
Install-Module PsBash

# Add to your PowerShell profile for permanent access
Add-Content $PROFILE "`nImport-Module PsBash -DisableNameChecking"

ps-bash Shell

The standalone ps-bash binary is a full interactive shell. Use it as your terminal shell on Windows — it gives you bash syntax, aliases, a colored prompt, and runs external tools like claude, copilot, and git directly.

How it works

The shell runs a persistent PowerShell worker process behind the scenes. When you type a command:

Input
  │
  ├─ Is it an alias? ───→ Expand, re-evaluate
  │
  ├─ Is it a bash builtin or mapped command?
  │   (cd, ls, grep, echo, sort, find, ps, ...)
  │   ───→ Transpile to PowerShell, execute in worker
  │
  ├─ Is it a pipeline or has redirects?
  │   ───→ Transpile the whole pipeline, execute in worker
  │
  └─ Otherwise (external tool)
      (claude, git, npm, dotnet, python, code, ...)
      ───→ Process.Start() directly on the console

Transpiled commands go through BashParser -> PsEmitter -> IpcWorker -> ps-bash-host. They run inside the persistent PowerShell runspace, so variables and working directory persist between commands.

External tools bypass the worker entirely. They get the real console — stdin, stdout, Ctrl+C, ANSI colors, raw mode, alternate screen buffer. This is why claude, copilot, and git work interactively.

Compact Output Mode

Compact output mode is opt-in for agent contexts that need lower token volume. Enable it per invocation with --compact-output, or ambiently with PSBASH_COMPACT_OUTPUT=1. The launcher resolves precedence as:

  1. --compact-output / --no-compact-output
  2. PSBASH_COMPACT_OUTPUT
  3. default off

The resolved value is propagated to the host as PSBASH_COMPACT_OUTPUT=1 or 0, so later output-rendering stages only need to read one setting. Defaults are unchanged when neither flag nor environment variable is present.

With compact mode on, command output is summarized with the command identity, exit code, timeout status, stdout/stderr line counts, stream labels, stderr and file/line diagnostics, and tail context. Repeated lines and low-value progress or listing noise are collapsed to reduce token volume.

Compact mode also shortens routine status surfaces such as ps-bash host status. The verbose status form normally prints endpoint, metadata, process, protocol, build, owner, start time, health, and final state as separate labeled lines; compact mode keeps those identifiers on one summary line. In the formatter snapshot this reduces the representative status payload from 222 characters and about 22 whitespace tokens across 10 lines to 201 characters and about 15 whitespace tokens on 1 line while retaining endpoint, pid, protocol, build, owner, health, and state.

The representative noisy-failure fixture in OutputCompactorTests reduces a repeated build log to less than half of the raw character count and less than half of the estimated whitespace-token count while preserving exit code, stderr, file/line diagnostics, warnings, failure text, and repetition counts.

Use compact mode for agent and CI log capture where token volume matters. Leave it off when downstream tooling needs exact stdout/stderr bytes, when full progress history is the artifact being inspected, or for human-facing interactive sessions.

Command Assist Providers

Ctrl+~ opens command assist in the interactive editor. By default ps-bash uses claude -p "{{prompt}}" as the provider. The prompt includes the current line, cursor position, working directory, shell name, and OS. Provider output is shown for review and is never executed directly from the provider process.

Provider configuration is loaded from PSBASH_AI_CONFIG when set, otherwise from $PSBASH_HOME/.psbash/ai-providers.json or ~/.psbash/ai-providers.json. Multiple providers can be declared and selected with defaultProvider:

{
  "defaultProvider": "claude",
  "providers": [
    {
      "name": "claude",
      "executable": "claude",
      "args": ["-p", "{{prompt}}"],
      "timeoutMs": 30000,
      "outputLimit": 8192,
      "promptTemplate": "Context:\n- input: {{buffer}}\n- cursor: {{cursor}}\n- cwd: {{cwd}}\n- shell: {{shell}}\n- OS: {{os}}\n\nReturn compact JSON only: {\"command\":\"single bash command or empty\",\"explanation\":\"why\",\"refusal\":\"optional\",\"clarification\":\"optional\",\"plan\":[\"optional steps\"]}"
    }
  ]
}

Provider processes inherit the shell environment by default. Entries in environment override variables; a null value removes a variable. Startup failures, missing executables, nonzero exits, and timeouts are reported at the prompt and the original line is restored.

The default contract prefers compact JSON with command, explanation, refusal, clarification, and plan fields. A structured response with a non-empty command is treated as an executable suggestion. Refusals, clarifications, plans, malformed JSON, and multi-line explanatory plain text are shown as review-only text: they can be inserted for editing but cannot be run with the execute action. Single-line plain text is accepted for compatibility with simple providers.

ps-bash shows the provider name, current directory, generated text, and any explanation before asking for an action: execute, insert/edit, retry, switch provider, or cancel. Commands that match destructive patterns such as recursive delete, forced git operations, forced overwrite redirects, privilege escalation, package installation, or network-to-shell pipelines require an additional EXECUTE confirmation.

To disable command assist, set PSBASH_AI_DISABLE=1; the hotkey will report that the feature is disabled and restore the original line. Ctrl+~ and Ctrl+^ are both accepted because terminals commonly encode Ctrl+~ as the same control character. To change providers, set defaultProvider in the config or choose switch provider from the review prompt.

Troubleshooting:

  • Missing provider executable: install the CLI or set executable to an absolute path.
  • Slow provider: reduce timeoutMs or use a faster local provider.
  • Truncated answer: increase outputLimit.
  • Unexpected non-executable review text: return structured JSON with a non-empty command field.
ps-bash --compact-output -c "dotnet test"
$env:PSBASH_COMPACT_OUTPUT = '1'
ps-bash -c "git status"
ps-bash --no-compact-output -c "git status"

Interactive pager

less is handled as shell-adjacent pager functionality. In a real ps-bash interactive session, less file and producer | less delegate to native less when it is available on PATH; pipeline input is spooled to a temporary file and removed after the pager exits. Native less provides the MVP pager behavior: arrow keys, PgUp/PgDn, / search, resize handling, and q to quit.

In non-interactive contexts such as ps-bash -c "printf 'x\n' | less" or IPC-captured execution, less does not wait for terminal input. Piped input is passed through, and file operands are printed to stdout. If an interactive terminal is attached but native less is missing, ps-bash reports a clear error instead of attempting partial emulation.

Setup

  1. Install the binary:
iwr https://raw.githubusercontent.com/standardbeagle/ps-bash/main/install.ps1 | iex
  1. Add to Windows Terminal — Settings → Add new profile:
Setting Value
Name ps-bash
Command line C:\Users\you\.local\bin\ps-bash.exe
Starting directory C:\Users\you
  1. Create ~/.psbashrc:
# ~/.psbashrc — sourced on interactive shell launch
alias ll='ls -al'
alias la='ls -a'
alias grep='grep --color=auto'
alias cdsp='claude --dangerously-skip-permissions'

export EDITOR="code"
if [ -f ~/.env ]; then set -a; source ~/.env; set +a; fi
  1. Launch — Open the ps-bash Windows Terminal tab.

The prompt

andyb@my-pc:~/projects/my-app (main) $
  • andyb@my-pc — user@host (green, bold)
  • ~/projects/my-app — working directory with ~ substitution (cyan, bold)
  • (main) — git branch (green = clean, red = dirty)
  • $$ for users, # for admin (magenta, bold)

Multi-line input

The shell detects incomplete constructs and shows a continuation prompt:

andyb@pc:~/app $ if [ -f package.json ]; then
> echo "found it"
> fi
found it

andyb@pc:~ $ greet() {
> echo "Hello, $1"
> }

andyb@pc:~ $ greet World
Hello, World

Works for if/fi, for/do/done, while/do/done, case/esac, functions, braces, parens, and unclosed quotes.

Aliases

alias ll='ls -al'          # define
alias                       # list all
alias ll                    # show one
unalias ll                  # remove
unalias -a                  # remove all

Aliases expand the first word of input before the transpiler sees it. So ll becomes ls -al, then ls is mapped to Invoke-BashLs.

Autosuggestions

As you type, the shell suggests commands from your history in gray (dimmed) text after your cursor:

andyb@pc:~/app $ git comm
                    it -m "fix parser bug"

Press Right or End to accept the full suggestion, or keep typing to ignore it.

Key behaviors:

  • Suggestions match the beginning of history entries (prefix match)
  • Commands from your current directory are suggested first
  • Most recent commands are preferred when multiple matches exist
  • Suggestions are disabled when the tab completion menu is active
  • Configure via ~/.psbash/config.toml:
[completion]
autosuggestions = true   # Enable (default) or disable

This is fish shell's most praised feature — it dramatically reduces typing for repeated commands. See docs/specs/autosuggestions.md for full details.

Completion Details

Tab completion can show a compact flag/parameter detail panel under the prompt. Use these controls:

  • F1: open the detail browser for the current command or selected flag.
  • Down: focus the visible detail panel.
  • Up / Down: move within a focused panel.
  • Right: drill into the selected flag or parameter details.
  • Enter: insert the selected flag or parameter.
  • Esc: leave the focused panel and return to editing.

The prompt legend stays compact: unfocused panels show F1 details · ↓ focus; focused panels show → details next to the scroll/insert controls.

Environment variables

export EDITOR="code"        # set in the worker
export PATH="$HOME/.local/bin:$PATH"

Ctrl+C handling

  • Transpiled commands: Cancels execution, prints ^C, restarts the worker
  • External tools: Passes through to the child process — claude and git handle it themselves
  • Shell mode: Pressing Ctrl+C never exits the shell; use exit or Ctrl+D to quit

External command resolution

The shell resolves external commands using the merged PATH from your PowerShell profile, so tools installed via package managers (like fnm, nvm-windows, or scoop) are available even though the worker starts with -NoProfile.

opencode                      # resolves opencode.ps1 from fnm PATH
copilot                       # resolves copilot.exe from WinGet
claude                        # resolves claude.exe from ~/.local/bin

On Windows, .cmd, .bat, .ps1, and .exe extensions are all resolved automatically.

--noprofile / --norc

Start the shell without loading your PowerShell profile PATH:

ps-bash --noprofile
ps-bash --norc

This is useful for CI/CD or when you want a clean environment, matching the behavior of bash --noprofile.

install command

Copy files with automatic Windows binary swap support:

install -m 755 ./build/myapp /usr/local/bin/myapp
install -t ~/.local/bin ./build/myapp

On Windows, if the destination file is in use, install renames the old file (.old suffix), copies the new one, and schedules deferred deletion of the old file.

Exit

exit, exit N, logout, or Ctrl+D.

Choosing Between the Module and the Shell

Use this guide to decide which mode fits your workflow.

Use the module when:

  • You already live in PowerShell and want bash commands added to it
  • You're writing scripts that mix bash and PowerShell idioms
  • You want typed .NET objects from bash commands to pipe into cmdlets
  • You're in VS Code's integrated PowerShell terminal
  • You're in a CI/CD pipeline running PowerShell
# Your PowerShell profile
Import-Module PsBash -DisableNameChecking

# Now bash commands are just PowerShell functions
ls -la | Where-Object { $_.SizeBytes -gt 1MB }

Use the shell when:

  • You want a dedicated terminal that speaks bash natively
  • You're running AI coding agents (Claude Code, OpenCode, Copilot)
  • You want aliases, .psbashrc, and a bash-like prompt
  • You're switching from WSL/Git Bash and want a Windows-native equivalent
  • You want to run claude, copilot, or gemini interactively
# Launch ps-bash as your shell
ps-bash

# Inside: bash commands work
ll                           # alias: ls -al
cd ~/projects && git status  # cd + git

# External tools work too
claude                       # full interactive session

Use both when:

  • Module for scripts and VS Code, shell for terminal
  • They share the same command implementations — what you learn in one transfers to the other

Comparison

Module Shell
Install Install-Module PsBash Download binary
Access Inside PowerShell sessions Standalone terminal
Prompts Your PowerShell prompt user@host:cwd (branch) $
Aliases PowerShell aliases alias builtin + .psbashrc
Startup file $PROFILE ~/.psbashrc
External tools Via PowerShell (& claude) Direct console execution (PATH + PATHEXT)
History PSReadLine (rich) SQLite with Ctrl+R fuzzy search
Tab completion Full (flags, files) Commands, flags, files, sequences
AI agent use No Yes (SHELL=ps-bash)
Multi-line PSReadLine handles it Built-in > continuation
Variable sharing Native PS variables Variables in the worker session
.psbashrc Not sourced Sourced on startup
Ctrl+C PS handles it Cancels command; never exits shell
--noprofile N/A ps-bash --noprofile

Mixing Bash and PowerShell in the Shell

Since the shell's worker is a persistent PowerShell session, you can freely mix syntaxes:

Bash commands

ls -la | grep '.cs' | sort -k5 -h
cat README.md | wc -l
find . -name '*.ps1' -type f

PowerShell commands

Anything the bash parser doesn't recognize passes through to PowerShell:

Get-Process | Where-Object { $_.CPU -gt 5 }
$PSVersionTable
Write-Host "hello"
Get-Date -Format 'yyyy-MM-dd'

Bash to PowerShell bridge

# bash produces typed objects, PowerShell filters them
ls -la | Where-Object { $_.SizeBytes -gt 100KB }

# bash processes, PowerShell stops them
$top = ps aux | sort -k3 -rn | head 5
$top | ForEach-Object { Stop-Process $_.PID -WhatIf }

Pipeline matrix

From To Example
Bash → Bash ls | grep foo Fully transpiled, typed objects flow
PS → PS Get-Process | Sort-Object CPU Passed through verbatim
Bash → PS ls -la | Where-Object { $_.SizeBytes -gt 1MB } Bash objects, PS filter
PS → Bash Get-Content log | grep ERROR PS output, bash filter

Variables

# bash variable assignment
files=$(find . -name '*.cs' -type f)

# access in PowerShell context
$files | Measure-Object -Line

# PowerShell variable (set in the worker)
$procs = Get-Process
# use in next bash command's $() substitution

Variables persist across commands because the worker session is long-lived.

Limitations

These are the known gaps in priority order. Each includes an estimated effort and what would be involved in fixing it.

1. No command history — Small effort

The shell uses .NET's Console.ReadLine() which has no history buffer. Pressing Up does nothing.

Fix: Replace Console.ReadLine() with a readline library that supports history, reverse-search (Ctrl+R), and line editing. Options:

  • Use System.ReadLine NuGet package
  • Port a minimal GNU readline wrapper
  • Use PowerShell's PSReadLine via the worker

Impact: This is the single biggest UX gap. Every shell needs Up-arrow history.

2. No tab completion — Medium effort

The module has full tab completion for flags (ls -[Tab]), but the shell has none. No command completion, no file completion, no flag completion.

Fix: The worker already has the PsBash module loaded with TabExpansion definitions. Wire Console.TreatControlCAsInput + Tab key detection to request completions from the worker via a protocol message, then render inline.

3. Glob expansion inconsistent across commands — Small effort

ls *.txt works because Invoke-BashLs uses -Filter. cat *.txt works because PowerShell resolves it. But rm *.bak or mv *.tmp /tmp/ may not expand as expected because glob resolution depends on the individual command implementation.

Fix: Add a shell-level glob expansion step before sending to the worker — expand *.txt to the matching files using Directory.GetFiles(), then pass the expanded list to the command.

4. First-word-only alias expansion — Small effort

Only the first word of input is checked for alias expansion. sudo ll won't expand ll. git commit -m "fix" && ll won't expand ll after &&.

Fix: After splitting on &&, ||, ;, and |, expand aliases on the first word of each segment.

5. FOO=bar command falls back to worker — Small effort

EDITOR=nano crontab -e has env pairs, so TryRunDirect rejects it and sends it through the worker. The worker handles env vars then runs the command — but it runs inside the pipe protocol, not on the console. Interactive tools launched this way won't get a terminal.

Fix: In TryRunDirect, extract env pairs, set them as environment variables on the ProcessStartInfo, and still run directly.

6. Variable/glob arguments fall back to worker — Small effort

git log $BRANCH or rm *.bak contain non-literal word parts, so TryRunDirect rejects them. The worker handles them fine, but the command loses direct console access.

Fix: For globs, expand before checking. For variables, resolve them by sending a quick $var query to the worker first. Or accept the fallback for variable cases (the worker handles them correctly).

7. No customizable prompt — Small effort

The prompt format is hardcoded in C#. Users can't configure colors, layout, or add custom segments.

Fix: Support PROMPT_COMMAND or a .psbashrc prompt function. The shell evaluates the function each cycle and uses the result instead of the built-in prompt.

8. No PROMPT_COMMAND / preexec hook — Small effort

No mechanism to run code before each prompt display (for custom window titles, tmux integration, etc.).

Fix: After each command completes and before displaying the prompt, evaluate a PROMPT_COMMAND variable from the worker.

9. Worker state not synced to shell — Medium effort

The PowerShell worker has its own $PWD, $LASTEXITCODE, variables. The shell tracks _lastDir separately by parsing cd commands. If the worker's CWD drifts (e.g., a PowerShell function changes it), the prompt shows the wrong directory.

Fix: After each command, query the worker's $PWD and sync _lastDir. This also fixes pushd/popd prompt tracking.


Priority Summary

# Limitation Effort Impact
1 No command history Small Critical UX — every shell needs this
2 No tab completion Medium Major UX — expected in any modern shell
3 Glob expansion inconsistent Small High — rm *.bak should just work
4 First-word-only alias expansion Small Medium — sudo ll is common
5 FOO=bar cmd falls back to pipe Small Medium — needed for env-prefixed tools
6 Variable/glob args fall back Small Low — worker handles it correctly
7 No customizable prompt Small Medium — personalization matters
8 No preexec hook Small Low — niche feature
9 Worker state not synced Medium Medium — causes subtle prompt bugs

Items 1, 3, 4 are the highest-impact, smallest-effort wins. They would make the shell feel substantially more complete.