# Haskell Toolchain Mise

> Set up a native (non-container) Haskell toolchain on the host with mise + GHCup. Covers the mise.toml pin (ghcup, cabal, ormolu), GHC and HLS via GHCup, the HLS/GHC compatibility rule, cabal-installed dev tools, PATH and shim wiring for direnv and editors, macOS prerequisites, and the CI mirror of the same pins. Use when bootstrapping a Haskell project without Docker, when the devcontainer is too slow on macOS, or when asked to install or pin GHC, cabal, HLS, ormolu or hlint locally.

- Skill: `ivelten/haskell-toolchain-mise` (Agent Skill)
- Install (CLI): `npx skillmds@latest add ivelten/haskell-toolchain-mise`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ivelten/haskell-toolchain-mise/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: ivelten (https://skillmd.com/u/ivelten)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/ivelten/haskell-toolchain-mise

---


# Haskell Toolchain with mise + GHCup

The native alternative to the devcontainer: the toolchain runs directly on the host, with no Docker, no VM and no bind-mount I/O penalty. `mise` owns the pinnable binaries and puts them on `PATH` per project; `ghcup` owns GHC and HLS, which mise cannot install.

## Detection — check before asking

```bash
ls mise.toml .mise.toml 2>/dev/null            # project pin present?
mise ls 2>/dev/null | grep -E '^(ghcup|cabal|ormolu)'
ghcup list -c installed -t ghc 2>/dev/null
```

If `mise.toml` pins `ghcup`/`cabal` → the native path is already set up. Acknowledge it and proceed.

## Division of labor

| Tool | Provisioned by | Why |
|---|---|---|
| `ghcup` | mise (`aqua:haskell/ghcup-hs`) | pinnable per project, bootstraps the rest |
| `cabal` | mise (`aqua:haskell/cabal/cabal-install`) | exact versions available, switches per project |
| `ormolu` | mise (`github:tweag/ormolu`) | ships an `aarch64-darwin` binary |
| **GHC** | **ghcup** | not installable via mise: the `ghc` registry entry is `conda:ghc` (needs conda) and the `mise-ghcup` asdf plugin was archived in Nov 2025. `mise ls-remote ghc` returns nothing |
| **HLS** | **ghcup** | bindists are compiled against specific GHC *patch* versions; ghcup is the only thing that matches them correctly |
| `hlint` | Homebrew or cabal | **not** via mise — see the Apple Silicon note below |
| `cabal-gild`, `hoogle`, `fast-tags`, `doctest`, DAP adapters | `cabal install` | not in the mise registry; they are GHC-compiled anyway |

## `mise.toml`

Commit at the repo root:

```toml
[tools]
ghcup = "latest"
cabal = "3.12.1.0"
# Keep the CLI in the series HLS bundles (HLS 2.14 accepts ormolu ^>=0.8),
# otherwise editor formatOnSave and the PostToolUse hook reformat each other.
ormolu = "0.8.1.1"

[env]
# GHC and HLS come from ghcup; cabal.project's with-compiler selects the exact
# compiler per project. 9.10.3 is the only 9.10.x with an HLS 2.14 bindist —
# do not "downgrade" to 9.10.1 or 9.10.2.
GHC_VERSION = "9.10.3"

[tasks.setup]
description = "Provision the GHC/HLS half of the toolchain via ghcup"
run = [
  "ghcup install ghc 9.10.3",
  "ghcup install hls recommended",
]
```

Onboarding is then `mise install && mise run setup` — the native equivalent of the devcontainer's `post-create.sh`. Both commands are idempotent.

## Version compatibility — the HLS bindist decides the GHC

This is the one place where picking versions independently breaks the setup. HLS ships prebuilt binaries for an explicit list of GHC **patch** versions; a GHC outside that list leaves the project with no working language server.

**Choose the GHC from the HLS support list, never the reverse.** Verify before pinning:

```bash
ghcup list -t hls                            # which HLS is tagged recommended
ghcup install hls recommended
ls ~/.ghcup/bin/haskell-language-server-*    # one binary per supported GHC
haskell-language-server-wrapper --probe-tools
```

The GHC being pinned must appear in that `ls` output. Snapshot verified 2026-09 — treat it as dated and re-run the commands above rather than trusting the table:

| | |
|---|---|
| HLS tagged `Recommended`/`Latest` in ghcup | 2.14.0.0 |
| GHCs with a bindist in HLS 2.14.0.0 | 9.14.1, 9.12.4, 9.12.2, **9.10.3**, 9.8.4, 9.6.7 |
| ormolu accepted by HLS 2.14.0.0 | `^>=0.5.3 \|\| ^>=0.6 \|\| ^>=0.7 \|\| ^>=0.8` |
| doctest against the GHC API | 0.22.x: `ghc >=8.0 && <9.12` — fine on 9.10.3 |

If the wanted GHC has no bindist, the options are to move the pin to one that does, or `ghcup compile hls --version <v> --ghc <ghcver>` (expensive, builds from source).

## GHC and HLS via GHCup

```bash
ghcup install ghc 9.10.3       # provides ~/.ghcup/bin/ghc-9.10.3
ghcup install hls recommended
```

**`ghcup set` is required, even though cabal does not need it.** `with-compiler: ghc-9.10.3` in
`cabal.project` selects the versioned binary directly, so cabal builds fine without it and
projects pinned to different GHCs coexist. HLS is the reason to run it anyway: it looks for a
bare `ghc` on `PATH`, and a fresh `ghcup install` leaves only `ghc-9.10.3`, `ghc-pkg-9.10.3` and
friends. Skipping it produces

```
Failed to find executable "ghc" in $PATH for this Default project
```

as soon as a `.hs` file is opened. Re-run it whenever the pinned GHC changes:

```bash
ghcup set ghc 9.10.3   # creates the unversioned ghc, ghc-pkg, ghci, haddock, runghc symlinks
```

The global switch only affects the `Default` cradle (a loose `.hs` file outside a project). A
cabal project still takes its compiler from `with-compiler`, so this does not interfere with
per-project pins.

## PATH and shims

Three directories must be on `PATH`, in this order:

```bash
# ~/.zshrc
eval "$(mise activate zsh)"          # mise shims: ghcup, cabal, ormolu
export PATH="$HOME/.ghcup/bin:$PATH" # ghc-<ver>, haskell-language-server-*
export PATH="$(cabal path --installdir):$PATH"
```

Do not hardcode cabal's install directory. Cabal 3.10+ uses XDG paths (`~/.local/bin`) on a clean install but keeps `~/.cabal/bin` when `~/.cabal` already exists — `cabal path --installdir` reports the truth.

This wiring is load-bearing, not cosmetic: the global `PostToolUse` formatting hook runs `command -v ormolu` / `command -v cabal-gild`, and the `haskell-lsp` plugin launches a bare `haskell-language-server-wrapper`. Both resolve from the non-interactive shell's `PATH`; if the binaries are missing there, formatting and LSP fail silently. Verify with `zsh -lc 'command -v ormolu cabal-gild haskell-language-server-wrapper'`.

## `.envrc` for the native path

```bash
# .envrc — committed to the repo
use mise
export DATABASE_URL="postgres://localhost/myapp_dev"
```

`use mise` activates the project's pinned tools on `cd`. Run `direnv allow` once per project — natively this is manual, whereas the devcontainer automates it in `post-start.sh`. `.direnv/` is gitignored.

## `.tool-versions` vs `mise.toml`

mise reads `.tool-versions` natively, which is a trap here: a `ghc 9.10.3` line makes `mise install` reach for the conda backend and fail. On the native path use **`mise.toml` only**. If the repo needs a `.tool-versions` anyway (CI, asdf, or a contributor on plain ghcup), tell mise to stay out of it:

```toml
[settings]
disable_tools = ["ghc"]
```

## hlint on Apple Silicon

hlint publishes no `aarch64-darwin` release asset. `mise install hlint` still reports success — it silently unpacks the **source** tarball, leaving no executable behind. Use Homebrew instead (arm64 bottle, same version), or build it:

```bash
brew install hlint          # preferred
cabal install hlint         # fallback, compiles from source
```

Keep hlint out of `mise.toml` so the failure cannot reappear, and note it in the project README next to the pinned versions.

## macOS prerequisites

`xcode-select --install` is required before the first `ghcup install ghc`. If a package with C bindings fails to build, install the library it wants — `brew install pkg-config libffi gmp openssl zlib` covers the usual suspects. Do this on first failure, not preemptively.

## Editor integration

**Do not use `manageHLS: "GHCup"` on this path.** That mode makes the extension manage the
ghcup installation — it runs `ghcup upgrade` on startup — but here ghcup belongs to mise, and
mise shims resolve their version from the **current directory**. The extension host runs outside
the workspace, so the call fails:

```
mise ERROR No version is set for shim: ghcup      → exit 1
```

surfaced as `'.../shims/ghcup --no-verbose upgrade' exited with exit code 1`. Pointing
`haskell.ghcupExecutablePath` at `~/.ghcup/bin/ghcup` does not help either: with a
mise-provisioned ghcup that file does not exist — the binary lives under
`~/.local/share/mise/installs/ghcup/`.

`ghcup` has already installed HLS via `mise run setup`, so the extension only has to consume it:

```json
{
  "haskell.manageHLS": "PATH",
  "haskell.serverExecutablePath": "/absolute/path/to/.ghcup/bin/haskell-language-server-wrapper",
  "haskell.formattingProvider": "ormolu",
  "[haskell]": {
    "editor.formatOnSave": true
  },
  "haskell.serverEnvironment": {
    "PATH": "<home>/.ghcup/bin:<home>/.local/share/mise/shims:<cabal installdir>:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
  }
}
```

`haskell.serverEnvironment` is not optional on macOS: GUI apps do not inherit the login shell
`PATH`, and VS Code caches the resolved environment at startup, so a `~/.zshrc` edit does not
reach an already-running window. Without it the server cannot find `ghc` or `cabal`.

**Put this in VS Code's User settings, not in the repo.** The paths describe the machine, not the
project, and none of them carry a version number, so they survive GHC/HLS/cabal upgrades. Keeping
them out of `.vscode/settings.json` also avoids committing absolute `/Users/<name>/…` paths, and
every new Haskell project then works with no editor setup at all.

Scope `editor.formatOnSave` inside the `[haskell]` block. At the top level of User settings it
would enable format-on-save for every language on the machine.

The catch to know about: since the shims directory is on that `PATH` and mise resolves by
directory, **every Haskell project needs its own `mise.toml` pinning `cabal` and `ormolu`**. A
project without those pins fails the same way, just naming `cabal` instead of `ghcup`.

Verify the whole chain with a clean environment, which is what the extension effectively has:

```bash
env -i HOME="$HOME" PATH="<the PATH above>" haskell-language-server-wrapper --probe-tools
```

`cabal` and `ghc` must both be reported, and the cradle must be the project's, not `Default`.

Commit `.vscode/extensions.json` with the same extension list the devcontainer declares, so a host VS Code gets the same recommendations.

## CI

CI has no mise and no container. Mirror the pins explicitly with `haskell-actions/setup` (`ghc-version`, `cabal-version` matching `mise.toml`) — see `haskell-documentation` for the workflow. The pins must be updated in both places together.

## Native or devcontainer?

- **Native (this skill)** — no Docker, no VM overhead, full speed on Apple Silicon, host editor and tooling work directly. Costs a one-time toolchain install per machine.
- **Devcontainer** (`haskell-devcontainer`) — parity with a Linux CI, disposable environment, and Docker-based services (Postgres) alongside the app. Costs virtualization overhead on macOS.

## Related

- `haskell-project-setup` — project layout, `.cabal`/`cabal.project` templates, language extensions.
- `haskell-devcontainer` — the containerized alternative.
- `haskell-quality-gates` — the formatter/linter these pins feed, and the pre-commit hooks that need them on `PATH`.

