Shoddy · Fettler
The download, getting it on PATH, and the optional MCP front end for an assistant. Read this once per machine.
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.
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.
| Platform | Archive |
|---|---|
| Windows | fettle-X.Y.Z-win-x64.zip |
| macOS, Apple silicon | fettle-X.Y.Z-osx-arm64.tar.gz |
| macOS, Intel | fettle-X.Y.Z-osx-x64.tar.gz |
| Linux | fettle-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.
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.PATHfettle. 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.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 -ForceUnblock 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-FilePut 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.0Where 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 --versionIf the shell cannot find it, add the directory and start a new shell:
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrcmacOS 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/fettleIf 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.
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 archivesArchives 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.
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:
| Client | Global | Repo |
|---|---|---|
| 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.
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| Flag | What it decides here |
|---|---|
--all | Every client this machine has. Ones that are not installed are skipped rather than created. |
--global | The machine level. The registration is written; the deny list is only offered, and needs --deny as well. |
--deny | Write 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-run | Print the identical report and write nothing. Run this first. |
--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.
Denying the built-in file tools routes an assistant rather
than disarming it, because the Fettler tools have different names
— mcp__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.
Monitor is a shell. Its own description says it
runs the command it is handed in the same shell environment
Bash does. Denying Bash and leaving it open
denies the word and not the thing.BashOutput and TaskOutput are
readers. They start nothing and write nothing, which is exactly why
they were missed — neither an editor nor a shell. But they hand
back the output of work already done, and those bytes reached the model
without passing the tree boundary, the secret scan or the
disclosure screen.
TaskOutput settles the question itself: it recommends
Read on the task's output file as the alternative, which is
a tool already on this list.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.
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 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.
"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.
deny versus allowWhen 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.
fettle setup claude-code --local --hooksThis 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 }
] }
]
}
}setup with — rather than a bare fettle.
A hook that cannot launch is worse than no hook, because it fails where
nobody is reading.--hooks is opt-in. A hook runs a program at the
start of every session, and that is not a side effect of asking for a
registration.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.
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 VERB | fettle serve | |
|---|---|---|
| Started by | a person, or a script | the 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 with | text, or JSON under --json | MCP tool results |
| Dispatcher | the 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.
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.setup, run
doctor, then prove a refusal actually refuses:
Fettler in a project.