For scripts that became tools.
weir is a typed shell-scripting language.
Commands, pipes and files, like a shell — the whole file checked before anything runs. One native binary — LSP, REPL, and formatter built in.
type Pkg = { name: string; version: string }
let pkg = File.read "package.json" |> from json Pkg
let tag = $"v{pkg.version}"
git tag $tag
print $"tagged {pkg.name} v{pkg.version}"
tagged acme-api v2.4.0
Declare the shape; from json parses a file or a
tool's output into it — and the typed value crosses back into
a real command's argv as exactly one word.
Quick install
curl -fsSL https://weir.sh/install.sh | sh
irm https://weir.sh/install.ps1 | iex
The check reaches past your code
This script has three mistakes, and none of them are weir: a
tool's flag, checked against a generated signature
(weir add sig bicep); a manifest's key, checked
against a vendored schema (weir add schema); and
a program name, checked against your PATH. One report, every
finding located — and nothing ran.
Nothing on this page is a mock-up — every quoted output comes from running the shipped binary, verified in CI.
#sig bicep
bicep build --outfil main.json
let config = yaml schema=k8s-configmap
apiVerison: v1
kind: ConfigMap
metadata:
name: site-config
data:
template: main.json
config |> to yaml |> File.write "config.yaml"
rsnyc -av main.json config.yaml backup:/srv/site
deploy.weir:3:13: error [sig]: unknown flag '--outfil' for bicep. Did you mean '--outfile'? (#sig bicep, line 1; exhaustive signature) deploy.weir:6:5: error [schema]: schema k8s-configmap: unknown field 'apiVerison' — did you mean 'apiVersion'? deploy.weir:14:1: warning [cmd-not-found]: command not found on PATH: rsnyc — weir resolves commands at check time; the script runs once it is installed
A real CLI, free
Declare the shape; the interface is derived. The
/// docs become the help text, so you cannot
forget to update --help — and unknown flags,
missing requireds and bad values arrive together, in one
error, before any effect.
type Cli = {
/// print what would happen, deploy nothing
dryRun: bool
/// the environment to deploy to
target: string
/// wait this long for the health check
timeout: Option<Duration>
}
let cli = Args.load Cli
usage: ship.weir [flags]
-d, --dry-run print what would happen, deploy nothing
--target <string> required — the environment to deploy to
--timeout <duration> optional — wait this long for the health check
Two ingredients, removed
Two bash mechanisms wreck machines. One is an unquoted variable
splitting into words — still quoted away by hand in
production, as pgpool did to an rm -rf in a
node-recovery script in September
2025, a maintainer catching it before it fired. The other is a
path glued from pieces that came apart; Valve shipped that
in 2015, and it wiped real drives:
steam.sh, shipped:
# figure out the absolute path to the script being run
STEAMROOT="$(cd "${0%/*}" && echo $PWD)"
# Scary!
rm -rf "$STEAMROOT/"*
When the folder moved, $(…) came back
empty — "$STEAMROOT/"* became
/*, and the last line deleted everything users
owned, mounted
backup drives included.
The same shape, in weir:
let steamroot = Self.scriptPath |> Path.dir
rm -rf $steamroot/*
steam.weir:2:18: error [parse]: argv words do not concatenate — adjacent pieces would each become their OWN
argument (`$root/*` would pass `/*` separately; `--flag="v"` would pass
`--flag=` separately). Build one word with an interpolated arg (`$"{root}/*"`,
`$"--flag={v}"`), quote the whole word, or use `Path.under dir name` for a
filesystem path (it refuses a result that escapes dir, where `Path.combine`
would follow it)
Weir removes both ingredients: a spliced value is always exactly one argv word, and gluing is unrepresentable — neither failure is a mistake weir detects, both are sentences weir cannot say.
See what a script can do before you run it
Command names are literal and nothing expands inside argv, so
a script's reach is statically knowable —
weir check --can reports it, with a source line
for every claim.
tools/fuzz.weir can (capability, not behaviour — an untaken branch still counts):
ambient reads (inform, change nothing):
reads:
File.read (path not statically known) tools/fuzz.weir:59:20
environment:
reads WEIR_FUZZ_STRICT_SPANS (Env.get) tools/fuzz.weir:43:14
mutations:
runs:
git tools/fuzz.weir:23:12
ci/deep-lock.sh × 2 tools/fuzz.weir:31:1, 66:5
date tools/fuzz.weir:39:17
dotnet tools/fuzz.weir:53:9
writes:
within tmp (a temporary directory) tools/fuzz.weir:47:1
environment:
sets WEIR_FUZZ_SEED, WEIR_FUZZ_COUNT, WEIR_FUZZ_REPORT for children (within env) tools/fuzz.weir:52:5
terminates:
fail tools/fuzz.weir:62:9
What falls out
-
within proc— a background process gets a scope, never a&; the tree is killed and reaped on every exit. -
Secret— a token that cannot reach a log line, a shown record, or the wire;Secret.revealis the only exit, so the audit is the call sites. -
retryandpoll— bounded loops; an unbounded loop is unrepresentable, andwatch=fails the wait the moment the child dies, with its last output. -
exit codes as data —
| completeand| succeedsmake a command's outcome a value, andSeq.exactlyOnesays one line whereheadwould silently accept two. -
within lock— an advisory file lock the kernel releases on any death,kill -9included. -
the
yamlblock — splices are nodes, never text, so YAML injection is unrepresentable — the argv law, one format over.
Why not the alternatives
In the author's words: bash has no types — a misspelled command, an unset variable, a value that splits into two words all wait until the shell reaches that line, mid-run. PowerShell compounds its quirks at tool scale and is not typed where it counts. F# scripts had the types — weir is F#-shaped because they were closest — but not the start-up time, the caching, or identical behaviour across Linux CI and macOS/Windows dev machines. Nushell is nice, but its type system is still being worked out. Weir selects from F#, which works, and adds the shell-specific parts.
Provenance
weir was written almost entirely by an AI agent, directed by one
person through plans, reviews and rulings — about two months,
part time. That is a statement about economics, not capability.
What makes it checkable rather than a claim: every fenced example
in the docs executes in CI (error demos must fail); an
F# fidelity oracle referees every divergence claim; an external
referee pushes 103 hostile payloads through PyYAML and
Python's json; and five adversarial reviews sit,
findings and all, in the
public ledger.
Don't trust it; check it. git clone
and ./ci/local.sh run the whole battery. The longer
story is The method.
Learn more
- The guide — start here; every example runs in CI.
- The reference — the language and every module.
- The showcase — one script touring the whole language, CI-run.
- Coming from bash, PowerShell, Python…
- Changelog