Shoddy · Fettler
Six steps, in the order they depend on each other. Declare the boundary, wire the client, prove the refusals.
This page assumes fettle is already on the machine and
registered. That is installing it,
done once. What follows is per project, and the numbering is not
decoration: each step needs the one before it.
.fettler.json first, before
you run anything. It is the file that declares your trees, and
until it exists this project has no boundary. The server learns its
trees at launch, from the .fettler.json in the folder it
starts in — and declaring nothing
is a refusal, not a fallback to the current directory.
setup --local will create the file if none is
there, and it is the only thing that does. But what it writes is the
bare minimum, {"trees":{"work":{"path":"."}}}, and it never
revises one afterwards. A scaffolded file nobody read is still the
boundary you are running under. Write it yourself, and you know what it
says. setup and doctor are the two verbs
that run without one; everything else refuses until it exists.# 1. write .fettler.json in an editor <- your boundary; nothing below writes it
# 2. add .fettler.local.json for the scratch <- gitignored, this machine's own
# 3. add "tasks" if anything needs running
fettle doctor # what is wired, and what goes round it
fettle setup claude-code --local --dry-run # what would change
fettle setup claude-code --local --hooks # change it
fettle doctor # confirm, and see what is left by handThe last step matters. setup clears the findings whose
fix is an addition. Every finding whose fix is a
removal — 2.6, 2.7, 2.11, 2.14, B.17 — is still
there afterwards, and still yours to do.
A tree is a path and a set of permissions. More than one may be declared. A folder of code, a sibling folder, and a scratch directory is the ordinary case, not the exotic one. A file at the top of the project says it once, so nobody types a boundary onto every command.
Put it at the top of the project, named
.fettler.json, and check it in. The smallest useful
one is a single tree:
{
"trees": {
"work": {
"path": ".",
"can": ["list", "read", "create", "update", "rename", "delete"]
}
}
}A fuller one, with a second tree kept read-only and a folder inside it hidden altogether:
{
"trees": {
"work": {
"path": ".",
"can": ["list", "read", "create", "update", "rename", "delete"]
},
"requirements": {
"path": "../shoddy-requirements",
"can": ["list", "read"],
"scopes": {
"backlog": { "can": ["list", "read", "create", "update", "rename"] },
"backlog/cancelled": { "can": [] }
}
}
}
}execute, so "can" may be left off it
entirely. Every other tree defaults to list read,
and only a configuration file can widen that.create and nothing else grants
create and nothing else — not even
read. The rules are on
the Fettler page.Check what you wrote by asking for it back:
$ fettle roots
work /work/shoddy (default)
can: list read create update rename delete
requirements /work/shoddy-requirements
can: list read
backlog can: list read create update rename
backlog/cancelled can: nothing
declared by /work/shoddy/.fettler.jsonA gitignored .fettler.local.json beside it is merged
over the top. Your machine's own trees need not be typed on every
command, and need not be inflicted on everybody else's checkout.
A name in both files means the overlay's version wins entirely — not field by field. Nobody reading two files side by side could say what a field-level merge produced without running it.
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.It goes in the gitignored overlay, because the path is this machine's and nobody else's:
// .fettler.local.json - beside .fettler.json, gitignored
{
"trees": {
"scratch": {
"path": "C:/Users/you/AppData/Local/Temp/claude/<project-folder>",
"can": ["list", "read", "create", "update", "rename", "delete"]
}
}
}execute — which is never a default
anyway. A scratch tree is for staging text, not for running what is
staged.fettle doctor reports its absence as
2.15, and stops once a tree inside it is
declared.setup cannot do steps 1 and 2 for you.fettler.json and .fettler.local.json are
the files that say what Fettler may do, and Fettler refuses to write
those at every permission level. setup is bound by the same
rule. If it could declare a tree, it could grant itself any tree, and
the refusal that makes every other rule
hold would be decoration. It may create a starter
.fettler.json where none exists; it never changes one. So
it names the fix, and a person applies it — the same shape as
findings 2.6, 2.7, 2.11 and B.17, which setup also reports
and does not clear.
Only if something in this project needs running. run
executes only what the configuration declares, and never composes a
shell command. It also needs the tree to grant
execute, which is never a default:
{
"trees": {
"work": { "path": ".", "can": ["list", "read", "create", "update", "rename", "delete", "execute"] }
},
"replacements": {
"feature-branch": "hebdenbridge"
},
"tasks": {
"build": { "run": "pwsh -File shoddy-build.ps1" },
"gate": { "run": "node scripts/gate/driver.mjs gate --resume", "cwd": "." },
"sign": { "run": "signtool sign /f \"C:\\keys\\my cert.pfx\" bin/app.exe" },
"feature": { "run": "pwsh -File scripts/shoddy-feature.ps1 new {feature-branch}" }
}
}pwsh on a machine that only has Windows PowerShell fails at
launch, not at declaration time.replacements. The branch to cut, or the note to commit
under, cannot be a second task — it is a fresh string each time.
Declare it once, name it in the command line, and keep the value in the
personal overlay, which is gitignored, so today's
branch name never becomes a diff.
What a replacement means is on
the reference page. Only an identifier between braces is a name, so a
brace already on a command line keeps meaning what it meant.cwd — the folder the task runs in — is
optional. It defaults to the top of the default tree, and is
checked for containment like any other path.shoddy-build.ps1 rather than build.ps1, a
checkin task rather than commit. An assistant's
own permission layer reads these names, and a task named with a bare git
word can be refused as if it were the raw command it resembles. A prefixed
name says whose procedure it is, and stops the pattern-match before it
starts.Skip this unless a tree holds health, consumer-report or education records. It is off everywhere until a tree asks for it, and asking is one line in the file you have just written:
{
"trees": {
"records": {
"path": "../records",
"can": ["list", "read"],
"screen": ["identifiers"]
}
}
}identifiers is the tier that works with nothing
installed — identifiers with a fixed shape or an explicit label.
The other three categories, clinical, legal
and scientific, screen nothing at all until you install
a model, and fettle roots says so in as many words
rather than letting the name imply otherwise.
fettle setupfettle setup <client> | --all [--global | --local] [--dry-run] [--force] [--hooks] [--deny] [--command PATH]
clients: claude-code · claude-desktop · vscode-copilot · github-copilotIt is a command rather than a .ps1 and a
.sh, so there is one implementation instead of two that
must be kept in step by hand.
| Flag | What it does |
|---|---|
<client> | One of claude-code, claude-desktop, vscode-copilot, github-copilot. |
--all | Every client this machine has. Ones that are not installed are skipped rather than created. |
--local | The repository level: .mcp.json, .vscode/mcp.json, .claude/settings.json. Reviewable in a diff, affects this tree only. The deny list is written here by default — asking for setup in a project is asking for Fettler to be the route in it. |
--global | The machine level, covered on installing it. 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. |
--hooks | Add the SessionStart hook that runs doctor. Claude Code only, opt-in, and added once however often you re-run. It is independent of whether the deny list needed changing, so asking for it on a project that is already denied still installs it. What it writes. |
--command PATH | Register an absolute path instead of a bare fettle. This is the answer when the binary is deliberately not on PATH. |
--dry-run | Print the identical report and write nothing. Run this first. |
--force | Overwrite an existing fettler entry rather than leaving it alone. Needed when a registration is broken and has to be replaced. |
It merges into JSON and never clobbers. These files hold
other people's servers, other people's permission grants, and a hook
array that is already doing something. Setup reads the file, adds or
updates Fettler's own key, and preserves everything else. Before its
first write it makes a backup at <file>.fettler-backup
and says where that went. --dry-run prints what it would do
and touches nothing.
.fettler.json, a missing
instruction block, a missing hook.allow entry is your own configuration, and removing it
changes how you work. Setup names the conflicting entries and
leaves them.PATH is not a configuration edit. Setup says so, rather
than writing a registration it knows will fail to launch..fettler.json at --local,
and never changes one. Creating where none exists is scaffolding.
Rewriting one is the escape
the rule-file refusal closes, and
belongs to a person with an editor. --global declares no
tree and so writes no such file — it says so in its own output.
What it creates declares the folder as work and nothing
more, which is why step 1 still comes
first.Re-running changes nothing the second time — for the JSON and
for the marker-delimited instruction block alike. A registration that
already says something different needs --force,
so setup never quietly reshapes somebody else's choice.
fettle doctorTwo jobs in one verb: is Fettler wired into each client, and is anything on this machine letting the assistant go round it.
fettle doctor [--json] [--quiet] [--hook] [--client NAME]--client narrows the report, never the scan. So
"it looked fine" can never mean "it only looked at one".setup.~/.claude/.credentials.json sits in the same directory as
files it reads. It names files and keys, never values.--hook always exits 0.$ fettle doctor
fettle 1.0.0 C:\tools\fettle\fettle.exe
on PATH: no - every registration naming a bare 'fettle' will fail to launch
tool inventory drawn 2026-08-20; check 2.8 says nothing about a tool added to the client since
claude-code global absent no server called 'fettler' is registered here
C:\Users\you\.claude.json
claude-code repo broken the registration launches 'fettle', which resolves to
nothing on this machine - and fettle is not on PATH at all
C:\work\tree\.mcp.json
claude-desktop repo n/a this client has no project scope, so there is nothing
to configure here
what could let the assistant go round the boundary:
! 2.7 the built-in tools Fettler replaces are explicitly allowed: Read(//c/work/**).
Read in particular defeats containment, the read permission and hidden scopes at once
C:\Users\you\.claude\settings.json
- 2.8 nothing denies the built-in Read, Write, Edit, NotebookEdit, Grep, Glob, Bash,
PowerShell, Monitor, BashOutput, TaskOutput, so Fettler is an option rather than
the route; `fettle setup claude-code --local` writes the denies
C:\work\tree\.claude\settings.local.json
- 2.17 1 other MCP server is registered here and the deny list cannot narrow it: its
tools are not called Read, so nothing setup writes says anything about what they
may reach. It is: sparky. Deny mcp__<name>__* for any that should not be a route
C:\Users\you\.claude.jsonThe report is two halves. The wiring is one row per client
per level, ending in a verdict. The findings are numbered, and each one
is a way round the boundary — ! is serious,
- is worth knowing.
Everything doctor can report, and how
to clear it gives a row per check: what it found, and the edit that
clears it. setup adds and never deletes, so any finding
whose fix is "remove" is yours to make in an editor.
| Verdict | Means | Exit |
|---|---|---|
n/a | The client is not installed, or has nothing at this level. Claude Desktop has no project scope at all, so its repo level is always this. | never a failure |
absent | The client is here; Fettler is not registered with it. | warning |
broken | Registered and wrong: the command resolves to nothing, the configuration will not parse, a tree does not exist. | failure |
healthy | Registered, and the command it names actually resolves. | — |
The wiring being right and the boundary holding are two different claims, and only the second one matters. Ask the assistant to do each of these, and watch what comes back.
| Ask the assistant to… | What a correct setup does |
|---|---|
| list the tools it has | fettler appears, and doctor is among the verbs |
| ask for the roots | the trees come back with what may be done in each, not only where they are — and they are the trees you declared in step 1 |
| read a file | it uses the Fettler tool, and the answer carries an encoding, a line ending and a hash |
read /etc/passwd or C:\Windows\win.ini | refused as outside every declared tree, exit 4 — and refused identically whether or not the file is there |
write .fettler.json | refused as governed, exit 11, whatever the tree grants |
write .claude/settings.json | refused as governed too — a model does not edit the file naming the tools it may call |
| write a file containing an AWS key or a PEM private-key header | refused as credential, exit 12, naming the line and the detector but never the secret |
run a task in a tree with no execute | refused, exit 8, naming the grant that is missing |
| edit a file with the built-in editor | refused by the deny list, and it reaches for Fettler instead |
| stage something in its scratch directory | it writes — because step 2 declared that tree. If this one fails, that is the step that was skipped |
.fettler.json needs the
server restarted. The MCP server learns its trees at launch and
does not re-read them, so a tree you added mid-session is not there
yet. This is the same property that stops a model widening its own
boundary, met from the other side.