I handed Claude Code 2.1.237 a settings.json with a single letter wrong in a key name, ran claude doctor, and diffed the output against the correct file.
There was no difference. Not a warning, not a note. The file loads, the parser is happy, and the misspelled line quietly evaporates.
As an indie developer with several sites handed off to unattended jobs, I edit these files more often than I expected to, and this is the failure mode I fear most. Broken configuration announces itself. Silently discarded configuration lets you believe a setting is in force for days.
Everything below was measured on Claude Code 2.1.237 (linux-x64).
The correct file and the misspelled file produce byte-identical output
I set up two directories. One of them collects four typos I have genuinely made before.
// A: .claude/settings.json (with typos)
{
"outputStile": "Concise",
"cleanupPeriodDay": 3,
"permissionmode": "plan",
"permissions": { "denny": ["Bash(rm -rf *)"] }
}// B: .claude/settings.json (correct)
{
"outputStyle": "Concise",
"cleanupPeriodDays": 3,
"permissions": { "deny": ["Bash(rm -rf *)"] }
}outputStile swaps y for i. cleanupPeriodDay drops the plural s. permissionmode loses the interior capital. permissions.denny doubles the n. Every one of them is valid JSON, and every one of them reads as fine at a glance.
I ran claude doctor in each directory and compared. That subcommand reads the settings files in the current directory without a trust prompt, which makes it convenient for exactly this kind of check.
cd A && claude doctor < /dev/null > ../doctorA.txt 2>&1
cd ../B && claude doctor < /dev/null > ../doctorB.txt 2>&1
diff ../doctorA.txt ../doctorB.txt && echo "identical"The output was identical. The deny rule I wrote as denny never appears anywhere, and never gets mentioned as missing. A guardrail I intended to put in front of a destructive command simply is not there.
Only value types are actually validated
If key names slip through, what does get caught? I varied the kind of mistake and measured five cases.
| Kind of mistake | Example | What doctor says | Exit code |
|---|---|---|---|
| JSON syntax error | trailing comma | Invalid settings / Invalid or malformed JSON | 0 |
| Wrong type on a known key | "cleanupPeriodDays": "three" | Expected number, but received string | 0 |
| Wrong type, nested | "permissions": { "deny": "Bash(rm -rf *)" } | Expected array, but received string | 0 |
| Value that does not exist | "permissionMode": "planz" | silence | 0 |
| Misspelled key name | outputStile and three others | silence (output identical to the correct file) | 0 |
Type checking is real and it is good. Put a string where a number belongs and you get Expected number, but received string with the exact path. The nested case behaves the same way. You can rely on this part.
What you cannot rely on is value validity. permissionMode: "planz" passes without comment, presumably because it is a string and the schema asks for a string. Do not assume enum values are checked.
And key names are invisible no matter how you mangle them. All five cases exited 0, which matters more than it looks. I will come back to it.
The MCP config does warn about the same mistake
This is not a blanket personality trait of the CLI. Make the equivalent typo in an MCP server definition and you get told.
// .mcp.json — "command" misspelled as "commnad"
{ "mcpServers": { "demo": { "commnad": "node", "args": ["server.js"] } } }$ claude mcp list
No MCP servers configured. Use `claude mcp add` to add a server.
MCP config diagnostics ⚠
[Contains warnings] Project config (shared via .mcp.json)
└ [Warning] [demo] mcpServers.demo: Skipped — invalid MCP server config for "demo":
command: expected string, received undefined
It never names commnad as an unknown key, but it notices that the required command is missing, skips the server, and tells you which one. When a schema has required fields, a misspelled key surfaces naturally as an absence.
settings.json has no required fields. Every key is optional, so any file is a valid file, including one where half your intent has been thrown away. The design makes sense. It just means the net has to be yours.
Catching misspelled keys yourself
I could not find a way to pull the official key list programmatically, so I keep a small allowlist of the keys I actually use and compare against that. Trying to enumerate everything would rot within a month; a deliberately narrow list stays accurate.
# known_keys.txt — only the keys you actually use. Add new ones on purpose.
outputStyle
permissionMode
cleanupPeriodDays
permissions
permissions.deny
permissions.allow
permissions.ask
env
model
The checker is short enough to read in one sitting.
#!/usr/bin/env python3
"""Detect misspelled key names in settings.json.
claude doctor never warns about unknown keys, so compare against a known-key list yourself.
Usage: python3 check_settings_keys.py <settings.json> <known_keys.txt>
"""
import difflib, json, sys, re
def flatten(obj, prefix=""):
"""Collapse nested keys into dotted paths like permissions.deny.
Only key names matter here, so we do not descend into arrays or hook bodies."""
out = []
if isinstance(obj, dict):
for k, v in obj.items():
path = f"{prefix}{k}"
out.append(path)
if isinstance(v, dict):
out.extend(flatten(v, path + "."))
return out
def normalize(k):
"""Ignore case and separators so permissionmode matches permissionMode."""
return re.sub(r"[^a-z0-9]", "", k.lower())
def main(settings_path, known_path):
with open(known_path, encoding="utf-8") as f:
known = [l.strip() for l in f if l.strip() and not l.startswith("#")]
known_norm = {normalize(k): k for k in known}
try:
with open(settings_path, encoding="utf-8") as f:
data = json.load(f)
except json.JSONDecodeError as e:
print(f"FAIL {settings_path}: not readable as JSON ({e})")
return 1
typos, unknowns = [], []
for key in flatten(data):
if key in known:
continue
hit = known_norm.get(normalize(key))
if hit is None:
# Keys that differ by one or two characters are typos too
near = difflib.get_close_matches(normalize(key), known_norm.keys(), n=1, cutoff=0.8)
hit = known_norm[near[0]] if near else None
if hit:
typos.append((key, hit)) # spelled almost right = almost certainly a typo
else:
unknowns.append(key) # genuinely unknown = possibly a new setting
for wrong, right in typos:
print(f"FAIL {settings_path}: '{wrong}' looks like a typo for '{right}'")
for key in unknowns:
print(f"?? {settings_path}: '{key}' is not in the list (add it if the setting is real)")
if not typos and not unknowns:
print(f"OK {settings_path}")
return 1 if typos else 0 # fail on typos only; let unknown keys through
if __name__ == "__main__":
sys.exit(main(sys.argv[1], sys.argv[2]))My first version did not have the three difflib lines. Normalizing case and separators alone caught only permissionmode out of the four, because outputStile and cleanupPeriodDay and permissions.denny are genuinely different strings once normalized.
Catching a one-character difference requires similarity, not equality. With difflib.get_close_matches at cutoff=0.8, all four get named:
$ python3 check_settings_keys.py A/.claude/settings.json known_keys.txt
FAIL A/.claude/settings.json: 'outputStile' looks like a typo for 'outputStyle'
FAIL A/.claude/settings.json: 'cleanupPeriodDay' looks like a typo for 'cleanupPeriodDays'
FAIL A/.claude/settings.json: 'permissionmode' looks like a typo for 'permissionMode'
FAIL A/.claude/settings.json: 'permissions.denny' looks like a typo for 'permissions.deny'
$ echo $?
1
False positives were the thing I actually worried about. A check that fails the moment you adopt a legitimate new setting gets disabled within a week. I added statusLine and enableAllProjectMcpServers to test that:
$ python3 check_settings_keys.py H/.claude/settings.json known_keys.txt
?? H/.claude/settings.json: 'statusLine' is not in the list (add it if the setting is real)
?? H/.claude/settings.json: 'statusLine.type' is not in the list (add it if the setting is real)
?? H/.claude/settings.json: 'enableAllProjectMcpServers' is not in the list (add it if the setting is real)
$ echo $?
0
They are visible, but the exit code stays 0. Fail on typos, report unknowns. That split is what makes the check survivable in daily use.
doctor exits 0 even when your settings never loaded
There is one more trap if you plan to automate any of this. As the table shows, claude doctor returns 0 regardless — including when a trailing comma means the entire file was discarded.
#!/usr/bin/env bash
# claude doctor exits 0 even for broken settings. Read the output and fail yourself.
set -uo pipefail
out="$(claude doctor < /dev/null 2>&1)"
echo "$out"
if grep -q 'Invalid settings' <<< "$out"; then
echo "FAIL: settings were not loaded (see Invalid settings above)" >&2
exit 1
fi
exit 0Across the correct file, the trailing-comma file, and the wrong-type file, this returned 0, 1, and 1. The diagnosis was already there; the only missing piece was treating it as a failure.
Put the two together and the shape of the check is settled. Let doctor own syntax and types, let your own list own key names, and let both speak through exit codes so they fit in a pre-commit hook or the preflight step of an unattended job.
If the file is broken badly enough that Claude Code will not start at all, the triage path is different, and I wrote that one up separately in When a Broken settings.json Stops Claude Code From Starting. This piece is about the stage before that, where startup is fine and only your intent is missing.
Confirm the behavior, not just the file
To be straight about the limits: this check protects the keys you already know about. Anything outside the list passes as ??, and value validity is never examined. The permissionMode: "planz" file sails past both my checker and doctor.
So I pair it with a habit. Whenever I add a setting, I confirm it once by behavior. Wrote a deny rule? Try the command and watch it get refused. Changed the output style? Look at the shape of the next response. It costs under a minute, and it is the only real evidence that what you wrote arrived.
Finding things that fail quietly always comes down to the same move: decide the expected count or the expected reaction in advance, then compare it against what actually happened. I applied the same idea to a batch job that was skipping every file in One Space in a Folder Name Turned 80 Checks Into Zero.
Start by opening one settings.json and reading the key names out loud. Plural s present? Interior capitals intact? That alone may surface a setting you thought was in force.
This script started life the day I found exactly one such typo, and it has grown from there. Thank you for reading.