Stop reading diffs and running tests to find out what your shell script does.
Watch it run, line by line, expansion by expansion — without letting it touch your machine.
A bash script's bugs are almost never logic bugs. They're expansion bugs: an empty variable, an
unquoted word that split into three, a glob that matched nothing. shellcheck catches some of them
statically. A test tells you that something failed. Neither shows you the thing you actually need:
cp "$f" "$dest/$(date +%F)/"What did bash run? With bashle, the answer is on the line, the moment you save:
dest= dest=''
for f in $files; do ×3 f='my report.txt'
cp "$f" "$dest/" cp 'my report.txt' '/' ✗1
done$dest was empty, and you can see it in the command that ran. No test run, no guessing.
| bash ≥ 4.1 | For BASH_XTRACEFD. macOS ships bash 3.2, which will not work — brew install bash and bashle finds it automatically. |
| macOS | Containment uses sandbox-exec and APFS clones. Linux support is not built yet. |
| VS Code ≥ 1.85 |
Not on the Marketplace yet, so build the .vsix and install it:
git clone https://github.com/srj31/bashle && cd bashle
npm install
npm run package
code --install-extension bashle-0.1.0.vsix # or: cursor --install-extension ...Reload the window. Bashle activates on any file VS Code recognises as shellscript.
- Open a
.shfile. - Add a probe comment saying how to run it.
- Save.
# @probe staging --dry-run
set -euo pipefail
target="$1"
mkdir -p "releases/$target"
echo "deploying to $target" > "releases/$target/log.txt"On save you get expansions and exit codes inline, and a Files panel showing that the run created
releases/staging/ and releases/staging/log.txt — in a throwaway clone, not in your repo.
A probe is a comment. One rule: # @probe <shell words>, interpreted against whatever it is attached to.
# @probe staging --dry-run ← attached to the file: the script's "$@"
# @probe prod => exit 1 ← with an expected exit code
# @probe "a//b" => "a/b" ← attached to a function: the function's args
normalize_path() {
echo "${1//\/\//\/}"
}Modifiers apply to every probe in the same comment block:
# @env DEPLOY_ENV=staging
# @stdin "yes"
# @probe --interactiveExpectations are optional. => exit N checks the exit code; => "text" checks stdout with the
trailing newline ignored. Without one you just get the observed values, no verdict.
Several probes can share a target — each runs in its own clean clone:
# @probe 2 3 => "5"
# @probe -1 1 => "0"
add() { echo $(( $1 + $2 )); }Function probes source your script. If it has no
[[ "${BASH_SOURCE[0]}" == "$0" ]]guard around its main body, that body runs first. Bashle detects this and says so rather than letting you wonder where the extra output came from.
Inline — the expanded command, an ✗N badge on a nonzero exit, ×N when a line ran more than
once, and the values of variables that line mentions.
On hover — every execution of that line, numbered, with its own expansion, exit code and variables.
In the panel — three tabs:
- Files (default) — what the run created, modified and deleted, with diffs. For a file-manipulation script this is the real output.
- Trace — every executed line in order: line number, iteration, the command as bash ran it, exit code, variables.
- Output — the script's own stdout and stderr, kept separate from the trace.
Probe runs execute real commands. Bashle contains them in three layers, and tells you which ones are active.
- Scratch clone. Your workspace is cloned with
cp -c(APFS clonefile — instant, and consumes no disk until something writes), and the script runs with its cwd there. Relative paths hit the clone, and they hit files that genuinely exist, so the script behaves realistically. - Redirected environment.
HOMEandTMPDIRpoint inside the clone, so~/.config/...andmktempare contained too. - Kernel enforcement. A
sandbox-execprofile denies all writes outside the clone, denies the network outright, and denies executingsudo. A blocked write fails visibly instead of silently succeeding.
Afterwards the clone is diffed against your pristine workspace to produce the Files tab, then deleted.
What this does not protect you from. A command that does its work in another process — docker,
launchctl, anything talking to a system daemon — is not stopped by a file sandbox. Treat probes on
scripts like those with the same care you'd treat running them.
The status bar shows ⛨ when kernel enforcement is on and ⚠ when only layers 1–2 are, so you are
never guessing about which you have.
Probes live in your scripts. They're comments, so they commit with the code and travel with the repo. A teammate without bashle installed just sees a comment saying how the script is meant to be run — which is documentation you probably wanted anyway.
The working directory is the workspace root, not the script's directory. A script at
scripts/build.sh that reads data.txt gets the data.txt at your repo root. If your script expects
to run from its own directory, anchor it the usual way:
cd "$(dirname "$0")"Paths in the Files tab are relative to the workspace root too, so they read the same as git status.
Per-repo settings go in .vscode/settings.json and commit with the repo:
{
"bashle.runOnSave": false,
"bashle.timeoutMs": 15000,
"bashle.maxCloneBytes": 2147483648
}runOnSave: false is worth considering for a repo whose scripts are slow or heavy — you then drive it
with ⌘⌥R when you actually want a run.
On a large repo, the clone itself is instant (APFS clonefile, no disk used until something writes),
but bashle measures the workspace first and refuses above maxCloneBytes (512 MB by default). .git,
node_modules and .bashle are cloned but excluded from the diff, so they never show up as changes.
Before you probe a script with real side effects, know exactly what the sandbox covers. Writes
outside the clone, network access and sudo are blocked by the kernel. Commands that do their work in
another process — docker, launchctl, anything driving a system daemon — are not, because the file
sandbox only constrains the process it wrapped. For scripts like those, keep runOnSave off and read
the trace before you trust it.
Bashle is being built out iteratively, starting with the constructs that carry most scripts:
Working now — assignments, simple commands, for / while / until, if / case,
functions, variable expansion and word splitting, redirections, exit codes, file manipulation.
Not yet — per-stage attribution inside pipelines (a pipeline is reported as one line), values
inside subshells and process substitution, trap handlers you install yourself (bashle's own
DEBUG and EXIT traps will be replaced by them), and scripts that re-enter bash as a child process.
The trace model already records subshell level and nesting depth, so widening coverage is additive.
| Setting | Default | |
|---|---|---|
bashle.bashPath |
(auto) | Path to a bash ≥ 4.1. Auto-discovers Homebrew, then PATH. |
bashle.runOnSave |
true |
Run probes on save. Turn off to drive it with ⌘⌥R only. |
bashle.timeoutMs |
5000 |
Kill a probe run after this long. The partial trace is kept. |
bashle.enforceSandbox |
true |
Kernel enforcement. Turning it off leaves only the clone containing the run. |
bashle.maxRecords |
50000 |
Stop tracing after this many events and mark the trace truncated. |
bashle.maxCloneBytes |
512 MB |
Refuse to clone a workspace larger than this. |
bashle.watchAllVariables |
false |
Snapshot every variable instead of only those the script names. Slower. |
⌘⌥R |
Run probes in this file |
⌘⌥I |
Inspect this line (opens the Trace tab) |
| — | Bashle: Show panel · Bashle: Clear annotations |
No source rewriting — your file is never modified. The tracer is injected through BASH_ENV, which
bash sources before a non-interactive script:
PS4is set to emit a structured record withBASH_SOURCE,LINENOandBASH_SUBSHELL, andset -xwrites it to file descriptor 9 viaBASH_XTRACEFD— so the trace never collides with your script's own stdout or stderr.- A
DEBUGtrap withset -Trecords the unexpanded command, the previous command's exit status, and adeclare -psnapshot of the variables your script actually names. - Records are
\x1f-delimited fields in\x1e-delimited records, which bash emits with a singleprintfand no quoting hazards.
Pairing those two streams is less obvious than it sounds — for loops emit their xtrace line
before the DEBUG trap fires, so the parser matches in either order and attributes each exit code
to the command that actually preceded it.
npm installThe fastest loop. npm run probe runs every probe in a file through the real engine — same tracer,
same sandbox — and prints the annotations inline.
npm run probe -- examples/deploy.sh 15 mkdir -p "$dest" mkdir -p releases/staging dest=releases/staging
17 for artifact in app.js styles.css readme md; do ×4 ✗1 artifact=readme
18 cp "artifacts/$artifact" "$dest/" ×4 cp artifacts/md releases/staging/ ✗1 artifact=md
files changed in the sandbox
+ releases/staging/app.js
+ releases/staging/status.txt
+ releases/staging/styles.css
exit 0 · ⛨ sandboxed
examples/deploy.sh has a deliberate bug: readme md is unquoted, so it splits into two words and
the loop runs four times instead of three. You can see it in the iteration count, in the failing
cp artifacts/md, and in the Files list where readme md was never copied — and your real
examples/ directory is untouched.
- Open this folder in VS Code.
- Press F5 (Run Bashle in a new VS Code window). It builds first, then opens a second window
with the extension loaded and
examples/as its workspace. - Open
examples/deploy.shin that window and hit ⌘S.
Annotations appear at end of line, hover a line for every execution of it, and ⌘⌥I opens the panel.
Edit and save again to watch it update. Extension logs go to the Debug Console of the first window;
reload the second window with ⌘R after changing extension code.
npm test # 131 tests, including end-to-end runs against real bash and a real sandbox
npm run build # bundle to dist/extension.js
npm run typecheckThe containment tests are adversarial on purpose: scripts that try to write to /tmp, to an absolute
path back inside the real workspace, and to delete real files must each be blocked and reported.
MIT
