Skip to content

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.

Runtime Rust, single binary
Install macOS · Linux · Windows
License Apache 2.0
Signed sigstore / Rekor
install · sh ~/
$ curl -fsSL https://install.husk.sh | sh
installed husk 0.1.0 → ~/.local/bin/husk
$ husk --version
husk 0.1.0 (darwin/arm64)
rename.husk husk plan
# 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.

§ 01

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.

§ 02

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.

§ 03

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.

§ 04

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.

§ 05

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.

§ 06

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.

§ 07

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.

§ 08

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.

The plan reflects reality because reads execute. Writes are intercepted by the runtime and replaced with plan() calls that describe their effect.
husk plan rename.husk --root ~/src 16 steps · 0 applied
READ fs.walk root=~/src → 487 files
READ where .name in (docker-compose.yml, .yaml) → 18 files
READ git.repo_root → 18 repos

PLAN fs.ensure file=husk/compose.yml write 4.2KB
PLAN fs.ensure file=husk/docker-compose.yml delete
PLAN git.commit repo=husk paths=2 "chore: rename to compose.yml"
…× 17 — same pattern across remaining repos
Effects: fs.write × 18 · fs.delete × 18 · git.commit × 18

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.

§ Signed

sigstore + Rekor

Keyless signing per release. Short-lived Fulcio certs bound to GitHub / email identity. Every signature in the public Rekor transparency log.

§ Deterministic

Content-addressed install

Modules live at ~/.local/share/husk/modules/<content-hash>/. The install-site husk.lock pins the exact hash and signer identity.

§ Portable

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.

§ a real task

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.

◈ bash · what happened 10 commands · 6 retries
 # 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 
§ husk · what it should be 1 script · 0 retries
 # 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")
  }
} 
§ 01
Exclusion list duplicated 5× in 2 dialects
exclude: parameter on fs.walk, declared once.
§ 02
for f in $(find …) — word-splits on spaces
path is a type. Iteration is over records, not bytes.
§ 03
Plan and real-run are different code
One script. husk plan stubs write-effects; --apply runs them.
§ 04
git add -A sweeps unrelated changes
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.