weir

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}"
$ weir tag.weir
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

More install options

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
$ weir check deploy.weir
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
$ weir ship.weir --help
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/*
$ weir check steam.weir
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.

$ weir check --can tools/fuzz.weir
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

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