cli
How to write .mise.toml files with correct tool versions, settings, and platform stubs. Use when setting up project toolchains, fixing missing tools, or configuring sayt setup/doctor.
SKILL.mdplugins/sayt/skills/cli
GitHubsetup / doctor — Tool Management with mise
sayt setup installs the project toolchain: mise trust -y -a -q && mise install. A .sayt.setup.nu, or a .sayt.nu defining main setup, replaces that entirely (see below).
sayt doctor checks which environment tiers are ready:
| Tier | Tools checked |
|---|---|
| pkg | mise (or scoop on Windows) |
| cli | cue, gomplate |
| ide | cue |
| cnt | docker |
| k8s | kind, skaffold |
| cld | gcloud |
| xpl | crossplane |
After the tier table, doctor also prints:
- Release Checks (contextual) — shows
goreleaserif the current directory has.goreleaser.yamlor.goreleaser.yml. Skipped when no release config exists. - Health Checks — DNS resolution for
google.comandgithub.com. If either fails,doctorexits non-zero so the common "offline / flaky DNS / corp proxy blocking" case is caught before it turns into a mysteriousmise installordocker pullfailure downstream.
What setup Does and Does Not Do
sayt setup runs mise install. That is the entire job: install the tools declared in .mise.toml, pinned by mise.lock.
setup does not:
- Run
pnpm install,bundle install,pip install,go mod download,cargo fetch, or any other project dependency manager - Warm compiler caches
- Run migrations
- Pull base images
Those steps belong in:
- The Dockerfile — for container builds, do dependency installs as
RUNsteps so they cache properly Taskfile.ymldeps:entries — for explicit local orchestration where one step needs another first
This keeps setup fast and idempotent: you run it when .mise.toml changes or on a clean checkout, and you never need it in the inner loop.
.mise.toml Basics
[settings]
locked = true # fail if mise.lock is out of sync
lockfile = true # maintain mise.lock
experimental = true # enable some plugin features
paranoid = false # skip aggressive checksum verification
# Security features usually disabled during development for speed:
github.slsa = false
github.github_attestations = false
aqua.cosign = false
aqua.slsa = false
aqua.github_attestations = false
aqua.minisign = false
[tools]
node = "22.23.3"
go = "1.22"
"github:pnpm/pnpm" = "9.15.2"
"github:bufbuild/buf" = "1.32.1"
Pin exact versions ("22.23.3" not "22") so the lockfile can match. Use registry names (node, go) where available; github: for tools not in the default registry.
Per-Language Starter Tools
| Language | Typical tools |
|---|---|
| Node / pnpm | node, "github:pnpm/pnpm" |
| Node / Bun | node (Bun and Deno are picked up from .tool-versions if present) |
| Go | go, "github:sqlc-dev/sqlc", "github:gotestyourself/gotestsum" |
| JVM (Maven) | java, maven |
| JVM (Gradle) | java (gradlew ships in the repo) |
| Python | python, "pipx:uv" |
| Ruby | ruby |
| Elixir | erlang, elixir (version must match OTP: 1.18.3-otp-27) |
| .NET | dotnet |
| Scala (sbt) | java, sbt (sbt fetches Scala itself) |
| Rust | "cargo:cargo-audit" etc. — toolchain via rustup, not mise |
| C / autotools | none — system build tools |
locked = true compatibility
locked = true only works for backends whose lockfile entries carry download URLs. Omit locked = true (but keep lockfile = true) for backends that don't:
| Backend | URLs in lockfile? | Supports locked = true? |
|---|---|---|
core:node, core:go, core:bun, core:deno, core:ruby |
Yes | Yes |
core:java, core:python, core:erlang, core:elixir |
No | No |
asdf:dotnet, asdf:sbt |
No | No |
aqua: (maven, etc.) |
Yes | Yes |
github: |
Usually | Usually |
http: |
Yes, one per platform (see below) | Yes |
cargo: |
No | No |
pipx: |
Varies | Varies |
When in doubt, set lockfile = true, leave locked = true off, and rely on exact version pins for reproducibility.
http: entries are written by hand
mise lock records only the platform it runs on, whatever the backend and
whatever --platform says. An http: tool therefore gets one entry per run,
and every lockfile here that covers linux and windows was written by hand.
Write the checksum beside the URL: mise verifies it on install, and without one
the download is guarded by nothing. Compute it with blake3 over the exact
artifact the URL serves, and expect mise lock to leave the other platforms'
entries alone rather than refresh them.
Platform stubs
sayt uses mise "tool stubs" for CUE, Docker, Compose, and uvx. These have platform-specific TOML configs: cue.toml, docker.toml, compose.toml, uvx.toml, nu.toml. sayt selects the right one automatically.
Compose gets its own stub rather than riding docker compose, which would resolve whatever plugin version the host installed.
sayt runs its own pinned mise and puts it first on the PATH of every process it starts, so mise exec -- in a do: works with no global mise. Project operations honor the project's locked setting; mise lock and the tool stubs run unlocked, since they precede a usable lockfile.
mise lock — Always Audit
mise lock produces mise.lock. Always audit the output, and audit it again whenever .mise.toml changes. A regression here ships broken builds on the platforms that don't get CI coverage.
Minimum audit after every mise lock
- Platform coverage. For each tool, verify the lockfile has entries for every platform you care about. Target
darwin-arm64,linux-x64,linux-arm64, andwindows-x64unless you know a platform doesn't apply. Core tools (core:java,core:python, etc.) will lack URLs — that's expected; it just meanslocked = truecan't apply to them. - Asset correctness. For each platform entry, verify the URL points at the right binary. A
linux-arm64entry pointing at an Android binary is a silent failure until an ARM Linux runner picks it up. - No regressions. Compare against the previous lockfile. If a tool lost platform coverage it had before,
mise locksilently dropped it — fix it before committing.
Known pitfalls
- GitHub API rate limiting. When rate-limited,
mise locksilently producesgithub:entries with no platform URLs. These fail with "No lockfile URL found" underlocked = true. Fix: setGITHUB_TOKENbefore runningmise lock, or patch the lockfile usinggh api repos/OWNER/REPO/releases/tags/TAG --jq '.assets[] | {name, id}'. - Wrong Windows asset.
mise lockmay pick non-Windows assets forwindows-x64(e.g.,.rpmfor sops). Everywindows-x64URL must end in.exeor.zip. - Wrong Linux ARM64 asset.
mise lockmay pick Android binaries (aarch64-linux-android) forlinux-arm64. Verifylinux-arm64URLs containunknown-linux-muslorunknown-linux-gnu. - Binary renaming on Windows (github: backend). Some tools (e.g., yq) ship as
tool_windows_amd64.exeinside zips. Thegithub:backend doesn't rename extracted binaries. Fix: switch tohttp:with a bare binary URL.
http: Backend
Use http: when a tool isn't on GitHub or when github: produces wrong binaries on Windows. Zero API calls, fully declarative.
[tools]
"github:bufbuild/buf" = "1.32.1"
[tools."http:skaffold"]
version = "2.17.2"
url = 'https://storage.googleapis.com/skaffold/releases/v{{ version }}/skaffold-{{ os(macos="darwin") }}-{{ arch(x64="amd64") }}'
[tools."http:skaffold".platforms]
windows-x64 = { url = 'https://storage.googleapis.com/skaffold/releases/v{{ version }}/skaffold-windows-amd64.exe' }
[tools."http:docker-cli"]
version = "28.5.1"
url = 'https://download.docker.com/{{ os(macos="mac") }}/static/stable/{{ arch(x64="x86_64", arm64="aarch64") }}/docker-{{ version }}.tgz'
bin_path = "docker/docker"
[tools."http:docker-cli".platforms]
windows-x64 = { url = 'https://download.docker.com/win/static/stable/x86_64/docker-{{ version }}.zip' }
Important rules:
- Flat
[tools]entries must come first, thenhttp:sub-tables. versionmust not include avprefix if the URL template already adds it (url = '.../v{{ version }}/...'→version = "4.44.2", not"v4.44.2").- Windows overrides are required when the Windows URL differs (e.g.,
.exesuffix,.ziparchive). bin_pathpicks the binary inside an extracted archive when it's not the archive name.- Templates use Tera syntax, not Go template syntax.
mise exec -- in Scripts, Compose, and CI
When you need to invoke a mise-managed tool from somewhere that isn't inside a mise shim — Taskfile.yml, compose.yaml command:, a CI step, a lint recipe, a .say.yaml do: block — prefix the call with mise exec --:
# .say.yaml
say:
lint:
do: |
mise exec -- pnpm lint
mise exec -- rpk connect lint services/transform/pipelines/*.yaml
mise exec -- caddy validate --config services/proxy/Caddyfile --adapter caddyfile
# Taskfile.yml
tasks:
check:
cmds:
- mise exec -- kubeconform -summary -strict k8s/base/*.yaml
This resolves the tool via mise regardless of whether the caller's shell has shims activated, and picks up the version pinned in .mise.toml rather than whatever's on $PATH. Prefer mise exec -- over a bare tool name anywhere the invocation leaves an interactive shell.
Custom Setup via .sayt.nu
For setup beyond what mise provides:
# .sayt.nu
use tools.nu [run-mise]
export def "main setup" [] {
run-mise install
# then: fetch non-mise assets, seed local state, etc.
}
A main setup here replaces the built-in verb, so it calls run-mise install itself to keep the toolchain step; sayt puts its own directory on NU_LIB_DIRS, which is what resolves use tools.nu. Declaring extra commands beside the builtin in say.setup.rulemap instead requires re-declaring the builtin's cmds, since the builtin rule carries stop: true.
Writing Good .mise.toml Files
- Pin exact versions.
"22.23.3"not"22". - Always generate and audit the lockfile.
mise lock+ manual platform-coverage check, every time.mise.tomlchanges. Never ship a lockfile regression. - Use
locked = trueonly when the backend supports URLs. See the compatibility table. - Prefer backends in order:
core:>http:>github:>aqua:.aqua:hits the GitHub API on every install and fails under CI rate limits. - Check
.tool-versions. If it exists, mise picks it up automatically — no need to duplicate.
Current flags
Run sayt help setup and sayt help doctor for current flags.