Linter for a Claude Code settings.json / settings.local.json.
Claude Code reads its behavior from settings files — the project
.claude/settings.json and .claude/settings.local.json, and the
user-level ~/.claude/settings.json. The highest-stakes thing those
files do is wire hooks: a PreToolUse or PostToolUse entry whose
command string is run before or after a tool call. A PreToolUse
hook is often a guardrail — it can deny a dangerous command, or
require approval before a publish or a push.
The failure mode that matters: if a hook's command does not resolve
to a file that exists and is executable, the hook silently never
runs. No error, no warning — the tool call just proceeds as if the
hook were never configured. A guardrail you believe is protecting you
is not. This happens for ordinary reasons: the hook script gets moved,
a refactor renames .claude/hooks/, a fresh clone drops the executable
bit, an absolute path is copied between machines.
hookprobe runs a hook with
synthetic input to test its decision logic. setlint is the other
half: it checks that a settings file actually connects a hook in the
first place.
Bash + jq.
setlint was conceived and written by Claude in claude-expo — a private
workshop where Claude picks and builds small utilities under operator
oversight. Correctness- and security-sensitive changes get a
cross-vendor second read from Codex (OpenAI's GPT) before they land. It
is the settings-file half of a pair with
hookprobe; more on the
workshop in
claude-journal.
setlint [PATH] lint settings at PATH (default: .)
setlint --project-dir DIR resolve $CLAUDE_PROJECT_DIR to DIR
setlint --json emit findings as a single JSON document
setlint --ci emit GitHub Actions workflow-command annotations
setlint --strict treat warnings as errors (affects exit code)
setlint --quiet suppress the "clean" line; print findings only
setlint --version print version
setlint -h | --help show help
PATH may be a settings JSON file directly, or a directory. For a
directory, setlint discovers .claude/settings.json,
.claude/settings.local.json, settings.json, and
settings.local.json beneath it and lints each that exists.
A hook command frequently references the project root as
$CLAUDE_PROJECT_DIR — the variable Claude Code injects when it runs
the hook. setlint resolves it the same way: to the directory
containing the .claude/ that holds the settings file (or to
--project-dir if you pass one). So a command like
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/guardrails.sh"is checked against the real file on disk, not skipped. $HOME and a
leading ~/ are resolved too.
Errors fail the lint (exit 2). Warnings are advisory (exit 1) unless
--strict promotes them.
| level | check | meaning |
|---|---|---|
| error | invalid-json |
file is not parseable JSON (Claude Code silently ignores a malformed settings file) |
| error | not-object |
top-level JSON is not an object |
| error | hooks-not-object |
hooks is present but not an object |
| error | event-not-array |
a hooks.<Event> value is not an array |
| error | group-not-object |
a matcher group is not an object |
| error | group-hooks-bad |
a matcher group's hooks is missing or not an array |
| error | hook-not-object |
a hook entry is not an object |
| error | hook-no-type |
a hook entry has no type |
| error | hook-command-empty |
a command hook has an empty command |
| error | hook-command-missing |
resolved command path does not exist |
| error | hook-command-not-exec |
resolved command path exists but is not executable |
| warn | hook-command-unresolved |
command has an unresolved $VAR; cannot verify |
| warn | hook-command-complex |
command is a pipeline/compound; not statically checked |
| warn | hook-command-not-on-path |
bare command not found on PATH at lint time |
| warn | misplaced-permission-key |
top-level allow/deny/ask belongs under permissions |
| warn | key-typo |
top-level key is a known misspelling (permission, hook) |
A settings file has requirements (the command field must be a
string; hooks must be an object) and it has permissions (a command
may be a shell pipeline; a path may be resolved from an environment
variable known only at runtime). A linter's job is to enforce the
requirements and to deliberately not police the permissions —
otherwise it fires on correct configuration and you learn to ignore it.
So setlint asserts only what it can actually resolve:
- A command with an unresolved variable (anything other than
$CLAUDE_PROJECT_DIR/$HOME) is a warning, not an error. The path may be perfectly valid at runtime; setlint just can't see it. - A command that is a shell pipeline or compound (
|,&&,;,$(…), …) is a warning — the program isn't a single token to stat. - A bare command name resolved off
PATHis checked against lint-timePATH; a miss is a warning, since the runtimePATHmay differ.
The errors are reserved for wiring that is unambiguously broken: a path that resolves and points at nothing, or at a file without the execute bit.
Default output groups findings by file. --json emits one document
({version, errors, warnings, findings[]}) for downstream tools.
--ci emits GitHub Actions ::error/::warning workflow commands so
findings annotate a pull request.
$ setlint .
.claude/settings.local.json
✗ hook-command-missing hooks.PreToolUse[0].hooks[0]: command path does not exist: …/.claude/hooks/guardrails.sh
1 errors, 0 warnings
0— clean.1— warnings only.2— at least one error,--strictwith warnings, or a usage error.
- setlint lints each settings file independently; it does not merge the user / project / local precedence chain into the effective config.
- For a command with arguments (
python hook.py), setlint checks the first token (python) — it verifies the interpreter, not the script argument. Direct script paths are the common case and are fully checked. - A quoted command path (single or double quotes) is taken whole, so a quoted path with spaces resolves correctly. An unquoted path with embedded spaces is not split-aware (first whitespace token wins) — rare, and the fix is to quote it.
tests/run.sh
Fixture-driven: each tests/fixtures/<name>/ is a small project tree
plus an expected.txt (exit code + the check names that fixture should
emit). The not-exec fixture commits a mode-644 hook script, so the
executable bit is part of the fixture — preserve it under git.
MIT — see LICENSE. "setlint" as a name and the KeMeK
Network identity are not covered by the code license.
KeMeK Network © 2026