Scripts the agent
writes right
the first time.
Husk is a typed, plan-before-execute scripting language built for AI agents. One static binary. Every effect declared. Every step resumable. Open source.
# Typed paths. Planned writes. Resumable. let repos = fs.walk(path("~/src")) | where .name == ".git" | map .parent for repo in repos { checkpoint(repo) { git.pull(repo, rebase: true)? } }
Strict. Structured. Planned.
Husk's defaults are hostile to every kind of silent failure bash hands an agent. You opt into looseness. You never opt into rigor.
Structured data is the primitive.
Values have shapes. path is a type. json parses to typed records. Pipelines carry structure, not bytes. Text is one specific type, not the universal substrate.
Strict by default, lenient by annotation.
Undefined variable → error. Failed command → error. Failed pipeline stage → error with which stage. You opt into looseness with try, ?, and || default.
Everything is a plan before an execution.
Reads execute. Writes describe. husk plan rehearses the whole script against live data, showing exactly what would happen — no separate plan/apply codepaths.
Effects are explicit.
@effect("write") declares mutations. --allow fs.write enforces them at runtime. A net-only module cannot write files, even if it tries.
Idempotency is first-class.
fs.ensure converges state: no-op if already in place, atomic write otherwise. The ensure_* convention carries through every stdlib module.
Resumability via checkpoints.
Group related work in a checkpoint block. Step 14 of 18 failed? husk resume picks up at 14. Completed checkpoints cache their return value.
Structured tracing by default.
Every step emits a JSONL event with inputs, outputs, duration, effects. No echo "got here". Export to OTLP with --trace otlp.
No null. No exceptions.
Missing values are Option[T]. Fallible operations return Result[T, error]. The ? operator propagates both. Agents reason about explicit data flow.
Rehearse the script against real data.
Read functions execute. Write functions describe what would happen. The plan reflects reality because reads run against the live filesystem, network, and git state.
Agents show the plan to the operator. Operator says yes. Agent appends --apply. No separate plan/apply code paths to drift.
Signed releases. No registry server.
Every module release is signed via sigstore — keyless, OIDC-bound, logged to Rekor. The registry is a public git repo, not a server. The install flow verifies content hashes against each module's husk.lock before any code executes.
sigstore + Rekor
Keyless signing per release. Short-lived Fulcio certs bound to GitHub / email identity. Every signature in the public Rekor transparency log.
Content-addressed install
Modules live at ~/.local/share/husk/modules/<content-hash>/. The install-site husk.lock pins the exact hash and signer identity.
Registry is git
Name resolution is a file in github.com/husk-sh/registry. Mirror it, fork it, point Husk at your own for air-gapped environments.
One static binary. Every platform.
No runtime dependencies. Verify the SHA-256 against the release manifest and you're done.
Also available via direct download.
Ten bash commands. One Husk script.
Rename docker-compose.yml to compose.yml across 18 repos. Update references. Commit per repo. Here is what the agent actually produced — and what it should have produced.
# 1 — search .yml find ~/src -name "docker-compose.yml" \ -not -path "*/node_modules/*" \ -not -path "*/.git/*" \ -not -path "*/.terraform/*" # 2 — search .yaml (duplicate the exclusion list, again) find ~/src -name "docker-compose.yaml" \ -not -path "*/node_modules/*" \ -not -path "*/.git/*" \ -not -path "*/.terraform/*" # 3 — plan rename (grep -v, different dialect) for f in $(find ~/src | grep -v node_modules); do echo "RENAME: $f -> compose.yml" done # 4 — real rename ($(find) word-splits on spaces) for f in $(find ~/src); do mv "$f" "$(dirname \"$f\")/compose.yml" done # 5 — discover repos (cd state is lost in subshell) repos=$(for f in $(find ~/src); do cd "$(dirname $f)" && \ git rev-parse --show-toplevel done | sort -u) # 6 — commit loop (git add -A sweeps unrelated changes) for repo in $repos; do cd "$repo" \ && git add -A \ && git commit -m "chore: rename" done # 7 — discovery broke. Fallback: hand-enumerate 18 repos. for repo in \ /Users/s/src/husk \ /Users/s/src/authpipe \ /Users/s/src/sandbar \ # ...15 more hardcoded repo paths... ; do cd "$repo" \ && git add -A \ && git commit -m "chore: rename" done # 8 & 9 — poll a background task output file cat /tmp/claude/tasks/bz61ttr1o.output sleep 10 && cat /tmp/claude/tasks/bz61ttr1o.output
# Rename docker-compose.{yml,yaml} to compose.yml # across a portfolio. Plan once. Commit per repo. let root = param("root", default: path("~/src")) let files = fs.walk(root, exclude: list(".git", "node_modules")) | where .name matches /docker-compose\.ya?ml/ let by_repo = files | group_by { f => git.repo_root(f.path) } by_repo | parallel(max: 4) { repo, files => checkpoint("repo:${repo}") { for f in files { let dest = f.path.parent / "compose.yml" fs.ensure(dest, contents: fs.read(f.path)?) fs.ensure(f.path, absent: true) } git.commit(repo, paths: files | map .path, message: "chore: rename compose") } }
exclude: parameter on fs.walk, declared once.path is a type. Iteration is over records, not bytes.husk plan stubs write-effects; --apply runs them.git.commit(paths: […]) takes explicit path lists. Give your agents
a language they'll love.
Install Husk. Port one script. Watch the plan. Apply it.