turf
An AI-powered infrastructure management CLI built on the turf MCP
server — the Terraform-compatible infrastructure engine built for AI
agents: full support for Terraform HCL and the module registry, driven by
an AI agent and governed by the plan. turf drives the server through
cagent to plan and apply changes against
any OpenTofu provider.
It is intended as a showcase: a small, readable example of how to wrap the turf MCP server in a polished UX. The server is the product; this CLI is one way to consume it. Here's the CLI in action:
Install
Homebrew (recommended) — macOS and Linux
Installs the turf CLI, turf-mcp-server, turf-engine and turf-builtin-provider
from the latest release, with restate-server from Restate's tap. Homebrew loads a formula
from a third-party tap only when you name it, so the command names both:
brew install restatedev/tap/restate-server turfbuild/tap/turf
brew services start turf # the engine, now and at every login
turf-mcp-server engine status # the health check
turf --version
The same command upgrades a 0.x install, where brew upgrade turf alone stops at
restate-server. Later upgrades are brew upgrade turf.
Turf is free for noncommercial use. Commercial use, including a company's internal use, needs a license: see License and https://turf.build/licensing.
From source
Build just the CLI from this repository (the source is MPL-2.0):
go install github.com/turfbuild/turf@latest # → a `turf` binary
# or, from a clone:
make build # → bin/turf
The CLI launches turf-mcp-server as a subprocess and must be able to find
it. Install the server separately and ensure it is on PATH:
# Verify the server is reachable
which turf-mcp-server
# Or point the CLI at a specific binary
export TURF_MCP_SERVER=/path/to/turf-mcp-server
Connect your agent
turf-mcp-server is a stdio MCP server, and this CLI is one of its clients. Register it
with the agent you already use — Claude Code, Claude Desktop, Cursor, Codex, Gemini CLI,
VS Code — in one command: it detects the agents on this machine, lets you pick, and writes
each config with the server's absolute path (Node 18+;
add-mcp is Neon's open-source installer).
npx add-mcp@2 "$(command -v turf-mcp-server)" --name turf -g
Without Node, the agent's own command does the same:
claude mcp add turf --scope user -- turf-mcp-server # Claude Code
codex mcp add turf -- turf-mcp-server # Codex CLI
gemini mcp add -s user turf turf-mcp-server # Gemini CLI
code --add-mcp '{"name":"turf","command":"turf-mcp-server"}' # VS Code
In Claude Code and Claude Desktop, Turf runs on your Claude subscription — no model API key.
Turf's prompts are slash commands there: /turf:guide walks through a use case on a
real example, /turf:up path/to/config deploys or reconciles a configuration, and
/turf:destroy path/to/config tears it down (arguments are one word each). Every
other client takes the same requests in words. Per-client setup, scopes and env vars:
github.com/turfbuild.
Usage
The CLI is Turf's standalone agent: the same server and engine, driven by a model you bring (see Model providers).
# Interactive infrastructure management (TUI)
turf chat
# Get started with a use-case walkthrough (type inside the session)
turf chat # then: /guide
# Deploy from HCL configuration in the current directory
turf up
# ...or target another directory (like `tofu -chdir=DIR`)
turf -C ./examples/azure-storage-container up
# Destroy everything in a configuration
turf -C ./examples/azure-storage-container destroy
# Use a specific model
turf --model anthropic/claude-sonnet-5 chat
# Run a one-shot request without the TUI (see Scripting with exec)
turf exec "what workspaces exist?"
# Authenticate to a private registry or TFE-compatible host (see Credentials)
turf login app.terraform.io
turf logout app.terraform.io
Type /guide inside turf chat to get started with what Turf is for, on real examples from
turf-examples: deploy the NVIDIA AI Cluster Runtime
(AICR) on a kind cluster, take a node through the day-2 maintenance runbook as Terraform actions,
a product that installs itself, promotion across environments, your TACO as the control plane, and
deploying with guardrails (/guide day 2, /guide guardrails, …).
Flags
Per command, up/destroy/exec also take --yes (alias --auto-approve), and turf/chat/exec take --session <id|-N> and --continue/-c.
The three --log-* flags are pass-throughs to the turf-mcp-server subprocess. When unset, the TF_LOG_* env vars (matching OpenTofu's convention) reach the server through environment inheritance. Provider plugins also inherit these vars, so TF_LOG_PROVIDER=DEBUG enables plugin-side debug logging into the same sink as the server.
Credentials
turf login <hostname> obtains an API token for a Terraform-compatible host — a
private module registry, or a TFE-compatible backend such as HCP Terraform or
Scalr — and turf logout <hostname> removes it again. Like tofu login, both
require an explicit hostname; neither guesses a default.
The token is written to credentials.tfrc.json in your OpenTofu/Terraform CLI
configuration directory, in the same format those tools write, so a
co-installed tofu or terraform reads exactly what turf stores (and vice
versa):
The file is written with mode 0600, and any unrelated settings already in it
(plugin_cache_dir, provider_installation, other hosts) are preserved.
turf uses these credentials for its own host-facing requests too — remote-protocol
state backends (HCP Terraform, Scalr, TFE) and the provider registry — so one
turf login covers turf and a co-installed tofu/terraform alike.
Note: that consumption lives in
turf-mcp-serverand landed after v0.11.1. Against v0.11.1 or earlier the file is still written correctly and still servestofu/terraform, but the server itself authenticates anonymously. Check withturf-mcp-server --version.
Most hosts, including HCP Terraform and Scalr, do not implement the OAuth login
protocol, so turf login opens their user-token page and reads back the token
you paste. Hosts that do advertise a login.v1 service get a full OAuth 2.0
authorization-code flow with PKCE instead. Either way turf shows you where the
token will be stored and asks for confirmation before writing it.
For CI, pipe the token instead of typing it:
echo "$TFE_TOKEN" | turf login --token-stdin tfe.example.com
Prefer a pipe over a herestring (<<<), which materializes the token in a
temporary file in some shells. Without --token-stdin, turf login refuses to
run with stdin redirected rather than hanging on a prompt nobody can answer.
Precedence when a host is configured more than once follows OpenTofu's: a
TF_TOKEN_<hostname> environment variable wins over the credentials file, which
wins over a credentials helper. turf login warns when the token it is about to
write would be shadowed by such a variable, and turf logout warns when one will
keep authenticating you after the stored token is gone.
Two limitations relative to tofu login: turf does not detect a credentials
block hand-written in .terraformrc/.tofurc (such a block silently keeps
winning over the file turf writes), and it does not support credentials helpers.
Note also that turf logout only forgets the token locally — it does not revoke
it on the host, so revoke it there if it has leaked.
Scripting with exec
turf exec runs a single natural-language request against the model (set
with --model) without launching the TUI, streaming the run to stdout. It is
the entry point for driving turf from a script, a CI job, or another agent — the
same real model and tools as chat, just headless.
# A message argument — quoting is optional; words are joined into one request
turf exec create a random_pet named demo
turf exec "create a random_pet named demo"
# Use -- to pass a message that begins with a dash
turf exec -- --dry-run first, then apply
# Read the request from stdin
echo "what workspaces exist?" | turf exec
turf exec - < request.txt
The process exits non-zero if the run fails, so exec composes with normal
shell error handling (set -e, &&, CI steps).
--json — machine-readable event stream. With --json, turf emits one JSON
object per line for every runtime event (agent text, tool calls, tool results,
warnings, errors) instead of formatted text. This is the reliable way to assert
on a run from a script even though model prose is non-deterministic — you match
on structure (which tools ran, whether an error occurred), not wording:
# Which turf tools did the run invoke?
turf exec --json "create a random_pet" | jq -r 'select(.type=="tool_call") | .tool_call.function.name'
# A failed run exits non-zero; its error goes to stderr, not into the event stream
turf exec --json "reconcile the stack" > events.jsonl || echo "run failed" >&2
--yes / --auto-approve — unattended runs. exec is non-interactive, so
nobody can answer the agent's approval questions. Without --yes they are
declined: anything that needs your yes — a destroy, a replace, an import, an
invoke — is not applied and stays waiting at its approval point, where a later
interactive turf can pick it up. (The agent still approves a low-risk plan —
only creates and in-place updates — itself, as it does at a terminal.) --yes
answers those questions yes, and approves any other tool call that would ask on
stdin, so the run proceeds with no human at the keyboard. turf's own tools never
ask for permission. The same rule holds for turf up and turf destroy run
without a terminal (a pipe, CI). Use --hide-tool-calls to print only the
agent's prose.
execstill launchesturf-mcp-server, so it must be onPATH(see Install). For repeatable runs with no cloud credentials or cost, point it at HCL using OpenTofu's credential-free providers (null,local,random,tls).
Model providers
turf runs on any model provider that Docker's docker-agent runtime supports —
select one with --model provider/model (env TURF_MODEL). The default is
auto: turf picks the first provider whose credentials are set, falling back to
a keyless local model, so it runs out of the box — pin a specific default in
turf.yaml. Docker's providers overview lists
the full matrix and each provider's exact configuration.
turf needs a tool-calling model. Every turf action runs through tools, so the model must support tool (function) calling. The cloud models below all qualify; for local models, choose a tool-capable one (see below) — a model without it fails with
does not support tools.
Local models — no API key, no cost
Run the model entirely on your own machine — no API key, no cost, and your infrastructure prompts stay off third-party APIs. Local models need two things configured that cloud models do not: a tool-capable model, and a context window large enough for turf's prompt.
turf's prompt is ~27k tokens before you type anything. turf exposes two dozen infrastructure tools plus their workflow instructions, and that goes in every request. Docker Model Runner's default window is far smaller, so an unconfigured local model rejects turf's very first message with
exceeds the available context size. Setcontext_sizeand it works.
Docker Model Runner (bundled with Docker Desktop) — pull the model, then
declare it in a turf.yaml:
docker model pull ai/qwen3 # once — fetch the model
# ~/.turf/turf.yaml (global) or <project>/.turf/turf.yaml
model: local
models:
local:
provider: dmr
model: ai/qwen3
provider_opts:
# turf's own prompt is ~27k tokens, so Model Runner's default window
# rejects the first request. 64k leaves working room for plan and state
# tool results. Lower it if the model won't fit in memory — the engine
# reserves cache for the whole window up front, so memory use scales
# with this number.
context_size: 65536
Then just turf chat. The config file is required for this: context_size is a
per-model setting, so --model dmr/ai/qwen3 on its own cannot carry it and
will fall back to the runner's default window. turf discovers the local Model
Runner automatically; set MODEL_RUNNER_HOST to point at a remote runner. See
Docker's Model Runner guide.
Ollama (if you already run it):
turf --model ollama/llama3.2 # defaults to http://localhost:11434
Ollama has the same context problem but a different knob — provider_opts does
not reach it. Size its window with OLLAMA_CONTEXT_LENGTH (or num_ctx in a
Modelfile) before starting the server.
Pick a tool-capable local model — DMR's ai/qwen3, or on Ollama one of
llama3.1/llama3.2, qwen2.5, or the mistral family. Older or smaller
builds (plain llama3, tiny 1B GGUFs) often lack tool support and fail with
does not support tools.
Cloud providers
Export the matching API key, then select the model. A representative set:
turf inherits docker-agent's full provider list — including Azure OpenAI and GitHub Copilot. See the providers overview for every option.
Custom OpenAI-compatible endpoints
For any OpenAI-compatible server (vLLM, LM Studio, an internal gateway), use the
openai provider and override the endpoint with --base-url (env
TURF_MODEL_BASE_URL):
turf --model openai/my-model --base-url http://localhost:8000/v1
Configuration file (turf.yaml)
Beyond the flags, a project can pin its model configuration in a turf.yaml,
read from two locations in increasing precedence (project overrides global):
~/.turf/turf.yaml— per-user global (override the home dir withTURF_HOME)<project>/.turf/turf.yaml— versioned alongside your infra config
Overall model precedence is --model / TURF_MODEL > turf.yaml model: >
auto. A minimal file:
# Default selector: a named model below, an inline provider/model, or "auto".
model: smart
models:
# Try each candidate in order; pick the first whose credentials are set. A
# candidate is an inline provider/model OR the name of another entry here —
# which is how the local fallback below keeps its provider_opts.
smart:
first_available:
- anthropic/claude-sonnet-5
- openai/gpt-4o
- local # keyless local fallback (defined below)
# Docker Model Runner. context_size is required for local models: turf's own
# prompt is ~27k tokens and the runner's default window is far smaller. See
# Local models above.
local:
provider: dmr
model: ai/qwen3
provider_opts:
context_size: 65536
# A named model that targets a custom provider (defined below).
house:
provider: myhouse
model: my-model
# Custom providers, referenced by models by name — e.g. an internal
# OpenAI-compatible gateway. Credentials are read from the named env var.
providers:
myhouse:
provider: openai
base_url: https://gateway.internal/v1
token_key: MY_HOUSE_KEY
The models: and providers: values follow docker-agent's model/provider
schema, so per-model tuning fields (temperature, max_tokens, …) and a
top-level models_gateway: work here too. The same file also carries the
branding: overlay. For a complete project, see the
CLI configuration example.
Troubleshooting
turf won't start
could not start the model … API key … is required — turf could not reach an
LLM. Either set the key for your provider, or switch to a keyless local model —
which needs a turf.yaml, not just a --model flag (see
Local models).
unknown provider … / invalid model format … — --model must be a named
model from turf.yaml or a provider/model ref (e.g.
anthropic/claude-sonnet-5, google/gemini-pro-latest). Check the prefix
against the providers overview.
turf-mcp-server not found on PATH — the CLI launches the server as a
subprocess and must find it. Install it and put it on PATH, or point at it
explicitly (see Install):
export TURF_MCP_SERVER=/path/to/turf-mcp-server
model failed: … does not support tools
turf drives everything through tools, so the model must support tool (function)
calling — many local models don't. Switch to a tool-capable model: DMR's
ai/qwen3, or on Ollama one of llama3.1/llama3.2, qwen2.5, or mistral
rather than plain llama3. See Model providers.
model failed: … exceeds the available context size
A local model is running with a context window smaller than turf's prompt. turf sends ~27k tokens before you type anything (two dozen infrastructure tools and their workflow instructions), and Docker Model Runner's default window is far smaller — so the very first message is rejected.
Declare the model in a turf.yaml with a context_size that fits, then run
turf chat with no --model flag — the setting is per-model, so
--model dmr/ai/qwen3 alone cannot carry it:
model: local
models:
local:
provider: dmr
model: ai/qwen3
provider_opts:
context_size: 65536
See Local models. On Ollama the equivalent
knob is OLLAMA_CONTEXT_LENGTH.
The machine bogs down while a local model runs
The inference engine reserves cache for the whole context window up front, so
memory use scales with context_size — a large window on a large model can
exhaust RAM and push the machine into swap. Lower context_size (32768 is the
practical floor given turf's ~27k prompt) or pick a smaller model.
Theming
turf ships five built-in terrain themes — a play on turf's own name, the astroturf pun: the same astroturf imagined in a different terrain, each palette drawn from that place's natural objects. The terrain tints the HCL syntax colors and the create / destroy / change status colors, but those keep their meaning (create stays green, destroy red, change amber) so a plan stays legible:
The default is stadium. cagent's built-ins (and the legacy calm-roots)
remain selectable in the theme picker: /settings → Appearance → Theme in
turf chat (there is no /theme command). Themes are loaded and saved from turf's own
config home — ~/.turf (override with TURF_HOME) — so turf never reads or
writes ~/.cagent.
To use a custom theme, drop a YAML file at ~/.turf/themes/<name>.yaml and pick
it in the theme picker (/settings → Appearance → Theme). A partial file is merged onto the
built-in default, so you only specify the colors you want to change. Edits
hot-reload while the picker is open, and your selection persists across
restarts.
To preview a theme without changing your saved choice, launch with --theme
(or set TURF_THEME):
turf --theme tokyo-night chat
# ~/.turf/themes/my-brand.yaml
version: 1
name: "My Brand"
colors:
text_primary: "#C0C0C0"
markdown:
heading: "#7AA2F7"
link: "#7AA2F7"
Co-branding
A distributor can give turf their own look and voice without forking it, using a
branding: section in turf.yaml. It merges
from the same two locations, so a brand can be set once per machine
(~/.turf/turf.yaml) or per project (<project>/.turf/turf.yaml), and a project
file can override a single key while inheriting the rest.
branding:
# Default theme — a theme *ref*, not a path: one of turf's terrain themes, or
# your own dropped at ~/.turf/themes/<name>.yaml (see Theming).
theme: surf
# Welcome banner for the lean TUI, as a path relative to this turf.yaml.
# Plain text is fine — each line is drawn in the theme's accent color.
banner: taco-banner.txt
# Replaces turf's built-in chat welcome text.
welcome_message: |
Welcome to Taco — infrastructure management with governance built in.
# Appended to turf's persona as extra standing guidance.
additional_instructions: |
Treat platform policy as a gate: evaluate the plan against the workspace's
policy set before asking the user to approve it.
Branding is look and voice only — it does not rename turf. There is no name
key: the binary, the TUI status bar, and the agent badge all still say turf, so
a co-branded turf is still recognizably turf. The tool namespace is likewise
fixed at turf_* (the timeline renderers, the /tools grouping, and the
pre-approval allow-list all depend on it), and no gate is relaxed — the
pre-approved tool list, the mutation confirmations, and the filesystem sandbox
are all in code.
Two things worth knowing. theme: sets the default — a theme saved in the picker
and --theme both still win, so a user keeps control of their own look. And
additional_instructions is prompt content from a config file: it can steer the
agent's behavior, so treat a turf.yaml from an untrusted source with the same
care as any script you would run. A misconfigured brand degrades rather than
fails — an unreadable banner or theme falls back to turf's own with a warning.
For worked examples, see the integrations in the examples repo.
Skills
You can teach turf your own procedures by dropping SKILL.md files into
turf-owned locations. Each skill is a directory:
~/.turf/skills/<name>/SKILL.md # global: org best practices, migration playbooks
<project>/.turf/skills/<name>/SKILL.md # project: versioned alongside your infra config
turf scans only these two locations (override the global one with
TURF_HOME) — it never reads ~/.claude, ~/.codex, or ~/.agents. A project
skill overrides a global skill of the same name. The working-dir location is
exactly <cwd>/.turf/skills — there is no walk up the tree, so what loads is
predictable from where you launch turf.
A SKILL.md needs YAML frontmatter with at least name and description (the
description is what turf matches a request against). Keep the body lean and push
detail into supporting files, loaded on demand:
---
name: azure-migration
description: Adopt hand-built Azure resources into managed state. Use when the user says "adopt" or "import our existing".
# context: fork # optional — run this skill as an isolated sub-agent
---
# Azure migration playbook
Adopt-then-reconcile; never create before importing.
1. Import each existing resource (ID formats: see references/import-ids.md).
2. Plan — an `adopt id=…` row with no change means clean adoption; `~` or `-/+` means drift to reconcile.
~/.turf/skills/azure-migration/
SKILL.md
references/
import-ids.md # loaded on demand when the skill needs it
These are separate from turf's built-in infrastructure workflows (which the agent already knows) — your skills add to them, they don't replace them.
For a working project skill you can copy, see the CLI configuration example.
Architecture
turf (this binary) your agent (Claude Code, Cursor, Codex, …)
│ │
│ cagent runtime + LLM │ the agent's own model
│ (local via Docker Model Runner / │
│ Ollama, or cloud: Anthropic / │
│ OpenAI / Google / …) │
▼ ▼
turf-mcp-server (separate binary, on PATH; MCP over stdio)
│
▼
the Turf engine (turf-engine + restate-server; `brew services start turf`)
│
▼
OpenTofu providers (downloaded on demand)
The CLI never imports turf's server code. The only contract is the MCP protocol spoken over stdio.
Examples
Runnable examples live in turfbuild/turf-examples.
- Configuring the CLI
— a per-project turf setup: a
.turf/turf.yamlwith credential-basedfirst_availablemodel fallback and named models, plus a project.turf/skills/skill. Copy it as a starting point for your own.turf/directory. - Infrastructure configurations — ordinary HCL you drive with
turf -C <dir>: a local Kubernetes kind cluster (CRD + custom resource, or a Helm release; credential-free, Docker only), Azure and GCP modules, and language/feature tours (Terraform Actions, Turf-native actions).
License
The turf CLI — the source in this repository — is the Covered Software,
licensed under the Mozilla Public License 2.0 (MPL-2.0). See
LICENSE. Copyright © The Turf Authors.
Turf as a whole — this CLI combined with turf-mcp-server, turf-engine and
turf-builtin-provider, distributed together in every release archive and by the
Homebrew formula — is the MPL-2.0 Larger Work, licensed under the
PolyForm Noncommercial License 1.0.0 (SPDX:
PolyForm-Noncommercial-1.0.0). MPL-2.0 expressly permits combining the Covered
Software into such a Larger Work under other terms.
- Free for personal use, research, study, and for educational, public research, charitable and government institutions.
- Commercial use requires a license, including use inside a company. Free 60-day evaluations and production subscriptions are available; see https://turf.build/licensing or email [email protected].
turf license prints the license and the third-party notices
(NOTICE); turf --version states the license in one line.
Telemetry
Turf sends anonymous usage telemetry to Turf LLC by default: the version, the platform, counts of resources, changes and runs, and an anonymous installation id. It never includes resource contents, credentials, state, variable values or names. The Turf engine sends it; the CLI and the components Turf is built on send nothing. The field list and the endpoint are in docs/telemetry.md and at https://turf.build/telemetry.
turf telemetry offturns it off, then restart the engine:brew services restart turf.turf telemetry statusshows the setting and the installation id.TURF_TELEMETRY=offorDO_NOT_TRACK=1in the engine's environment also turns it off. For the Homebrew service that environment isturf.env(~/.homebrew/services/turf.env), not your shell.- With Homebrew's analytics off (
brew analytics offorHOMEBREW_NO_ANALYTICS), the Homebrew service starts with it off.