Shoddy · Fettler

Installing It

The download, getting it on PATH, and the optional MCP front end for an assistant. Read this once per machine.

This is the long way round, not the fastest route. If you just want Fettler working, three commands do it: the quick start. Come back here for the why and the flags.

Everything on this page is done once on a machine and not again. First, get the binary onto PATH — the list of folders your terminal searches when you type a command. Then, optionally, register it with an assistant client, and decide — for this account, across every project — whether Fettler is an option or the route. Turning it on in a particular project comes after; that is Fettler in a project. What the tool actually is, and what its boundary means, is Fettler.

The download

One download, one file on disk, no .NET runtime on the target machine. Every release attaches a self-contained, single-file fettle per operating system.

PlatformArchive
Windowsfettle-X.Y.Z-win-x64.zip
macOS, Apple siliconfettle-X.Y.Z-osx-arm64.tar.gz
macOS, Intelfettle-X.Y.Z-osx-x64.tar.gz
Linuxfettle-X.Y.Z-linux-x64.tar.gz

The unix archives are tars because the execute bit — the marker that says a file may run as a program — only survives a tar. Each archive also carries NOTICE and LICENSE. The single file has PdfPig, a PDF-reading library, bundled in. Its Apache-2.0 license asks the notice to travel with the binary, and MIT asks this project's own copyright notice to be in every copy.

PdfPig is in fettle and in nothing else this project ships. burler is the only other program here, and it has no PDF component and no dependency on one, direct or indirect. Neither program references the other, so nothing can leak between them — the wire between them is the whole contract. The full statement is in THIRD-PARTY-NOTICES.md.

Putting it on PATH

This is the step everything else assumes, and the step most likely to be skipped. Every registration on this page launches a bare fettle. If nothing on PATH resolves that name, the server never starts. From the assistant's side, that looks like having no file tools rather than like an error.
Windows · PowerShell

Where to put it. A per-user directory — no administrator rights needed, no other account affected.

$dir = "$env:LOCALAPPDATA\Programs\fettle"
New-Item -ItemType Directory -Force $dir | Out-Null
Expand-Archive fettle-X.Y.Z-win-x64.zip -DestinationPath $dir -Force

Unblock it if the zip came from a browser. A download carries the Mark-of-the-Web — Windows' "this came from the internet" marker — and can be refused on first run.

Get-ChildItem $dir -Recurse | Unblock-File

Put it on PATH for this account, permanently. Set the user variable by reading it, adding the folder, and writing it back. Do not use setx: it cuts the value off at 1024 characters, and it flattens the machine half of PATH into the user half.

$user = [Environment]::GetEnvironmentVariable('Path', 'User')
if ($user -notlike "*$dir*") {
    [Environment]::SetEnvironmentVariable('Path', "$user;$dir", 'User')
}

Then open a new terminal. A running shell keeps the environment it started with. That is the commonest reason fettle is "not on PATH" a minute after being put there.

fettle --version
fettle 1.0.0
macOS and Linux

Where to put it. ~/.local/bin if it is already on PATH; otherwise /usr/local/bin, which needs sudo.

mkdir -p ~/.local/bin
tar -xzf fettle-X.Y.Z-osx-arm64.tar.gz -C ~/.local/bin
fettle --version

If the shell cannot find it, add the directory and start a new shell:

echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc

macOS quarantines anything downloaded and refuses to run it, citing an unidentified developer. Clear the attribute on the one file:

xattr -d com.apple.quarantine ~/.local/bin/fettle

If you would rather not put it on PATH, that works too. fettle setup then writes the absolute path of the binary you ran it with, so a registration works without an installation step — and it says which of the two it did. The PATH form is better; the fallback is honest.

From source

Nothing else in this repository has to be built first. Fettler references no other project here:

./scripts/fettler-build.ps1 publish X.Y.Z    # X.Y.Z names the archives

Archives land in artifacts/publish/. A unix archive cut on Windows carries no execute bit, so the ones a release attaches always come from a unix build machine.

The clients, and the files behind them

The install is finished once it is on PATH, and it did not involve MCP. fettle is a command-line program. Every verb runs from a terminal or a script, taking its boundary from a .fettler.json in the tree. Everything below this additionally wires it into an assistant — a separate decision — and the MCP front end is what does it.

Each client keeps its registration in its own files, at two levels:

ClientGlobalRepo
Claude Code (CLI, and the VS Code extension, which shares its registry)~/.claude.json, ~/.claude/settings.json, ~/.claude/CLAUDE.md.mcp.json, .claude/settings.json, .claude/settings.local.json, CLAUDE.md
Claude Desktop%APPDATA%\Claude\claude_desktop_config.json; on macOS ~/Library/Application Support/Claude/none — no project scope
VS Code Copilot Chat%APPDATA%\Code\User\mcp.json and settings.json.vscode/mcp.json, .vscode/settings.json
GitHub Copilot coding agent.github/copilot-instructions.md

Claude Code in VS Code is deliberately not a fifth client. It shares the CLI's registry exactly. Reporting it separately would double every verdict and let the two disagree.

Registering it for every project

The machine-level registration, written once, for every project this account opens:

fettle setup --all --global --dry-run   # what would change
fettle setup --all --global             # change it
FlagWhat it decides here
--allEvery client this machine has. Ones that are not installed are skipped rather than created.
--globalThe machine level. The registration is written; the deny list is only offered, and needs --deny as well.
--denyWrite the deny list at the global level too. It is a separate flag because changing how the assistant may work in every project is not a side effect of configuring one.
--dry-runPrint the identical report and write nothing. Run this first.
Registering it makes it available; only an exclusion makes it the route. An assistant with its own file tools reaches for them out of habit, and a registered server it never calls changes nothing. --deny is how you say it at this level, and the exclusion list is what it is and why.

A global run declares no tree. So it writes no .fettler.json, and it says as much in its own output. Each project still needs one, and setup <client> --local in that tree is what creates it. setup and doctor are the only two verbs that run before any tree is declared.

The remaining flags — --local, --hooks, --force, --command — belong to a particular project rather than to the machine. The full table is with the step that uses them: fettle setup in a project.

The exclusion list

Denying the built-in file tools routes an assistant rather than disarming it, because the Fettler tools have different namesmcp__fettler__edit and so on, per the MCP front end. Eleven entries, no arguments, no platform variants, and setup --local writes all eleven:

{
  "permissions": {
    "deny": ["Read", "Write", "Edit", "NotebookEdit", "Grep", "Glob",
             "Bash", "PowerShell", "Monitor",
             "BashOutput", "TaskOutput"]
  }
}

Three of those eleven are not obvious, and each was found open on a machine where every other route was shut.

Note A closed list of names can only close the names on it, and the client keeps growing new ones. So fettle doctor prints the date its inventory was drawn, and reports any tool your configuration names that the inventory does not know (2.18) — because silence about an unknown tool otherwise reads exactly like a clean bill of health. Other MCP servers are the same problem in a different shape and are reported separately (2.17).

Read is on the list, with no path-scoped exception. A blanket Read defeats three protections at once: containment, the read permission, and hidden scopes. A scope with no list is invisible to search — and one call away from being read in full. Denying Read is affordable because Fettler reads notebooks, images and PDF as well as source.

Before you deny Write, a project needs its scratch tree declared. An assistant stages work — a long replacement, a generated file, a diff it is about to apply — in a scratch directory the harness gives it, well outside your source tree. Deny Write and that route shuts. The scratch is outside every declared tree, so Fettler will not write there either. Both routes closed — and it surfaces at the first staging write, not while you are running setup. Declaring it is step 2 of setting up a project.

The shell is denied by name, never by command

The last two entries are the shell, and they are there because a shell is a complete route round the boundary. This is not a second-order concern. node x.js writes anything anywhere, and it is not a writing command at all.

What cannot be done is enumerate what to block. A blocklist would have to name sed -i, cp, rm, git checkout, Set-Content, Remove-Item, python -c, node -e, their aliases and every platform variant — and then echo x > file defeats the lot, because > turns any command into a writer.

So the deny is the tool name, and the allow list is the exception. "Bash", "PowerShell" and "Monitor" are as short, closed and platform-identical as the other six entries. It is the commands behind them that never close. Those are yours to name, because only you know which ones your project needs.

This stops your build on the first run. That is the point at which you decide what to let through. Two ways, and the first is better:

// .fettler.json - runs inside the boundary, needs "execute" on the tree
"tasks": { "test": { "run": "dotnet test", "cwd": "." } }

// .claude/settings.json - for what a task cannot cover
"allow": ["Bash(git status:*)", "Bash(git log:*)", "Bash(git diff:*)"]

Never allowlist a command that writes. That is doctor check 2.6, and > and >> are on the list it matches against. A bare "Bash" in allow grants the whole shell back and undoes the deny entirely.

There is more than one shell, so every name is always written. A Bash-only deny leaves PowerShell wide open — and the machine this was built on used PowerShell for nearly every command. It leaves Monitor open too, which is a shell wearing a different word. Denying a tool that is not installed, or not present in your client, costs nothing.

On deny versus allow

When one rule is in both lists, which wins is the client's decision. Fettler is a separate process and cannot watch another program resolve its own rules. So it reports the conflict, and it names the fix that is correct under either outcome: remove the allow. An answer that does not depend on the assumption beats a guess at it.

The session hook

fettle setup claude-code --local --hooks

This runs fettle doctor --hook at the start of each session — the moment a diagnostic will actually be read. It is silent when everything is healthy or not applicable. Otherwise it prints a short block naming only what is wrong. It always exits 0, because a diagnostic that can stop a session from starting would be removed within a week. It carries a timeout.

{
  "hooks": {
    "SessionStart": [
      { "hooks": [
          { "type": "command", "command": "\"C:\\tools\\fettle\\fettle.exe\" doctor --hook", "timeout": 10 }
      ] }
    ]
  }
}

To remove it, delete Fettler's entry from the SessionStart array in .claude/settings.json. There is a backup beside that file from the first time setup wrote it.

The MCP front end

Everything above registered an MCP server without requiring you to know what one is, because fettle setup writes those files. MCP — the Model Context Protocol — is the standard way an assistant launches a tool and talks to it. This section is what setup wrote, for registering by hand or for reading a registration someone else made. The command line uses none of it.

serve starts the front end on stdio — plain text over the program's own input and output streams. It is a subcommand rather than a second executable name, because choosing a front end by the program's own name relies on a symbolic link, and Windows makes those awkward. A client launches it; a person does not. Run by hand it waits on stdin, which is correct behaviour that looks like a hang.

The registration setup writes is this, and nothing more:

{
  "mcpServers": {
    "fettler": { "command": "fettle", "args": ["serve"] }
  }
}

No root or config flag appears there, and none can. The server learns its trees at launch, from the .fettler.json in the folder it starts in, before the model has said anything. No tool schema carries a way to name a different one.

fettle VERBfettle serve
Started bya person, or a scriptthe client, from the registration above
Boundary from.fettler.json in the tree, or --root / --config.fettler.json in the folder the client launched it in. Nothing can name another.
Answers withtext, or JSON under --jsonMCP tool results
Dispatcherthe same one — a verb behaves identically through either

The tools are named mcp__fettler__VERB: mcp__fettler__read, mcp__fettler__search, mcp__fettler__edit and so on. That is what makes the exclusion list work — denying Read does not deny mcp__fettler__read.

setup is the one verb an assistant is not offered, and a test asserts it stays that way. It writes the file naming the command that launches the server. A model holding it could point its own boundary at a different binary, or grant back the permissions the deny list removes. Scaffolding a machine is a person's act, performed once, in a terminal. doctor is offered — a model diagnosing its own wiring beats asking somebody to open a terminal for it.

To check the server is live, ask the assistant for roots. It answers with the trees, what may be done in each, and which one an unqualified path lands in. No such tool means no server started — which looks identical to fettle not being on PATH, and usually is exactly that.
The machine is ready. Everything from here is per-project and in a fixed order — declare your trees, declare the scratch, declare your tasks, run setup, run doctor, then prove a refusal actually refuses: Fettler in a project.