| name | tidy-settings |
| description | Sort, dedupe, and audit Claude settings.json / settings.local.json files at user and project level. Flags risky permissions, broken references, glob-subsumed entries, syntax errors, and cross-file duplicates that could be promoted up the hierarchy. Mechanical fixes auto-apply, judgment calls are presented as a triage list. |
| allowed-tools | Read, Edit, Write, Bash(~/.agents/skills/tidy-settings/scripts/resolve-files.sh:*), Bash(python3:*), Bash(test:*), Bash(ls:*), Bash(git:*), Bash(readlink:*), Bash(realpath:*), AskUserQuestion |
| model-tier | standard |
| model | sonnet |
| effort | high |
| version | 1.3.0 |
| author | flurdy |
Tidy Settings
Occasional housekeeping on your Claude permissions. Settings files accumulate cruft: the same permission added narrowly five times, references to skills/scripts that no longer exist, redundant entries that one user-level rule already covers, and the occasional risky Bash(curl:*) you wish you hadn't accepted.
This skill cleans the mechanical noise itself and surfaces the judgment-heavy items for your review.
When to use
- Periodically (every few weeks), when settings files feel cluttered.
- After a long stretch where you've been clicking "Yes" to permission prompts and want to see what stuck.
- Before sharing or committing a project-level
settings.json — to make sure it's tidy and free of personal/local lines.
Complementary to fewer-permission-prompts (which adds permissions); this cleans them.
What it does
Auto-applied (mechanical, safe):
- Canonical sort: arrays grouped by tool prefix (
Bash, Edit, Read, Write, WebFetch, WebSearch, Skill, Task, mcp__*), alphabetical within group.
- Exact dedupe.
- Backup files (
.bak) written alongside each modified file.
Reported for your judgment (never auto-removed):
- Risk flags — entries matching known-risky patterns (
Bash(rm:*), Bash(curl:*), WebFetch(*), Read(//...), unrestricted Edit/Write, etc.).
- Broken refs —
Bash(~/path/...) or Bash(/abs/path/...) pointing at missing files; Skill(name) for skills not installed.
- Subsumed entries — narrow entry covered by a broader
:* entry in the same section of the same file (allow vs allow, deny vs deny, ask vs ask). The narrow one is technically redundant, but see "Subsumption trade-off" below before recommending which to remove.
- Syntax errors — entries that don't parse as a known permission shape.
- Cross-section conflicts — an entry in
allow shadowed by a wider :* entry in deny (deny wins, so the allow is dead code).
- Cross-file duplicates — same entry in multiple files. Often means an item should be promoted to user level (and removed from project locals), or vice versa if it references a project-specific path.
Permission precedence
Claude Code evaluates in this order. Always state this when explaining a subsumption or conflict finding:
- deny — hard block, no prompt.
- allow — auto-permit.
- ask — prompt the user.
- default — falls through to
--permission-mode behavior (default = ask, acceptEdits = auto-edit, auto/bypassPermissions = auto-allow).
Subsumption trade-off (important)
When a narrow entry is subsumed by a wider :* entry in the same section, removing one vs the other is not equivalent:
- Remove the narrow → behavior unchanged. The wide rule continues to govern that command family.
- Remove the wide → only exact-listed narrow variants remain governed. Anything else in the family falls through to the next tier (or default).
That second case is what bit the user in early testing — removing Bash(gh api:*) from ask left a handful of narrow gh api … entries, but every other gh api invocation now hits the default rather than always-prompting.
Section by section:
- In
allow: the broad pattern is usually intentional (you trust the command family). Suggest removing the narrows — the broad already covers them and the narrows just clutter.
- In
deny: the broad pattern is a safety wall (you never want this command family). Suggest removing the narrows — the broad blocks everything, narrows are redundant.
- In
ask: the broad pattern is a catch-all to ensure prompting. Removing it is the riskier choice. Default to suggesting remove narrows, and only suggest removing the broad if every plausible variant is explicitly listed.
When presenting subsumption findings, name the section, name the trade-off, and give a default recommendation rather than asking the user to guess.
Usage
/tidy-settings # Audit + auto-apply mechanical fixes + interactive triage
/tidy-settings --report # Report only, no writes (even for sort/dedupe)
Instructions
1. Resolve which files to operate on
Settings come in three roles. Resolve all of them with the helper script — never inline ad-hoc
shell for this (variable assignments and while read loops bypass the permission allowlist and
trigger noisy prompts):
~/.agents/skills/tidy-settings/scripts/resolve-files.sh
Output is ---<ROLE>--- sections, one existing file path per line:
---USER--- — ~/.claude/settings.json / settings.local.json (always checked).
---CANONICAL--- — the settings the main worktree uses. The script avoids
git rev-parse --show-toplevel (inside a worktree it points at the worktree's own checkout) and
instead resolves the first git worktree list entry's .claude through any symlink.
---WORKTREE--- — every other worktree's own real .claude/settings.local.json (and tracked
settings.json). These accumulate permissions that are lost when the worktree is pruned.
Worktree topology (why canonical ≠ pwd)
In the multi-repo setup, the main worktree's .claude is a symlink to a sibling state dir
(e.g. myrepo/.claude → claude-myrepo/), and worktrees live inside that state dir at
claude-myrepo/worktrees/<name>/ — each a real checkout with its own real .claude/. So:
- The canonical
settings.local.json (the one that survives) is reached through the main
worktree's symlinked .claude — the script's ---CANONICAL--- section.
- A worktree's
settings.local.json is a separate, gitignored file that vanishes on prune.
Permissions you accepted while working there are promotion candidates (step 4).
settings.json is git-tracked, so worktree copies converge on commit — drift there is reported
but lower-stakes than the local file.
If there are no sibling worktrees, this degrades to exactly the old user + project behavior.
2. Run the script
python3 ~/.agents/skills/tidy-settings/scripts/tidy_settings.py --apply \
--canonical "<canon>/settings.json" --canonical "<canon>/settings.local.json" \
--worktree "<wt>/.claude/settings.local.json" --worktree "<wt>/.claude/settings.json" \
<all files including the canonical and worktree ones...>
Substitute the literal paths from step 1's output (don't reference shell variables — state
doesn't persist between Bash calls). Every path is still passed positionally (so it's
sorted/deduped/audited once); --canonical and --worktree just tag which positional paths
participate in the promotion diff. Pass each ---CANONICAL--- line under --canonical and each
---WORKTREE--- line under --worktree. Omit both flags (and --apply) for plain --report mode;
with no sibling worktrees, omit them entirely.
Parse the JSON output. The shape is:
{
"files": [
{
"path": "...",
"exists": true,
"error": "read error: ... | JSON parse error: ...", // present only on failure
"applied": {
"changed": bool, "deduped": {...}, "backup": "..." | null,
"realpath": "...", // where the bytes actually went (symlink resolved)
"verified": bool, // re-read confirmed the write landed
"write_error": "..." // present only if the write itself failed
},
"report": {
"current": {"allow": [...], "deny": [...], "ask": [...]},
"allow_audit": {
"subsumed_candidates": [{"narrow": "...", "broad": "..."}],
"broken_refs": [{"entry": "...", "kind": "missing_file|missing_skill", "resolved": "..."}],
"risk_flags": [{"entry": "...", "severity": "high|medium", "reason": "..."}],
"syntax_errors": [{"entry": "...", "reason": "..."}]
},
"deny_audit": {...},
"ask_audit": {...}
}
}
],
"cross_file": {"duplicates": [{"entry": "...", "in": ["...", "..."]}]},
"worktree_promotions": [
{"from": "<wt>/.claude/settings.local.json", "into": "<canon>/settings.local.json",
"section": "allow", "entries": ["Bash(gh run watch *)"]}
]
}
worktree_promotions lists entries present in a worktree file but absent from the canonical file of
the same basename — these are exactly the permissions that disappear when the worktree is pruned.
3. Summarize the mechanical pass
Print a single short block per modified file, e.g.:
✓ ~/.claude/settings.json — sorted, dedupe removed 0, backup at settings.json.bak
✓ <canon>/settings.local.json — sorted, dedupe removed 2, verified, backup at settings.local.json.bak
✗ <canon>/settings.json — write FAILED (permission denied) — see remediation in step 5
- ~/.claude/settings.local.json — absent, skipped
When applied.changed is true, append verified (or a write FAILED callout) from
applied.verified / applied.write_error — never report a write as done without it.
4. Triage the findings
Order findings by severity:
- High-severity risk_flags first (
Bash(rm:*), Bash(*), WebFetch(*), root-level Read/Edit/Write).
- Cross-section conflicts (
allow_shadowed_by_deny) — these are dead code; the allow entry will never fire.
- Syntax errors next (these silently don't work).
- Broken refs — likely cleanup wins.
- Subsumed candidates — apply the "Subsumption trade-off" guidance above. For
Skill(name:*) vs Skill(name) in particular, the :* variant is typically meaningless (Skills don't take args) — recommend removing the :* form regardless of section.
- Cross-file duplicates — these need judgment. Apply this heuristic:
- Entry references a path inside
~/.claude/... or ~/Code/<not-current-repo>/... → suggest promoting to user-level (~/.claude/settings.json) and removing from project files.
- Entry references a path inside the current repo → suggest keeping at project level only.
- Entry already exists at user level AND project level → suggest removing from project (redundant).
- Entry has no path scope (e.g.
Skill(landscape), mcp__playwright__*) → suggest user level.
- Worktree-only entries (promotion candidates) — each
worktree_promotions entry is a
permission living only in a worktree's settings; it's lost when that worktree is pruned. Default
recommendation: promote into the canonical into file so it survives. Apply judgment though —
a one-off Bash(...) you accepted for a throwaway experiment may be better left to die with the
worktree. Batch with AskUserQuestion when several share an obvious intent (e.g. "Promote these 3
gh-related permissions from worktree zazzy into canonical settings?"). For tracked
settings.json drift, note that committing the worktree's change is usually the cleaner fix than
promoting — call it out rather than copying bytes.
- Medium-severity risk_flags last — most are intentional (
Bash(curl:*) if you do a lot of web work).
For each finding, present:
- The entry, the file(s) it's in, and why it's flagged.
- A specific suggested action.
- Whether to apply.
Group similar findings and use AskUserQuestion to batch decisions where it helps (e.g., "All 6 narrow Skill(<name>) entries are subsumed by Skill(<name>:*) variants — remove all narrow ones?").
5. Apply chosen changes
For removals: use Edit on the target JSON file to delete the line, including the trailing comma when not last, or the preceding comma when last in the array.
For cross-file promotion (move entry from file A to file B):
- Remove from file A using
Edit.
- Append to file B's
allow array (insert before the closing ]).
- Re-run the script with
--apply --no-backup on file B to re-sort.
For worktree → canonical promotion (a worktree_promotions entry the user approved): the entry
stays in the worktree (it may still be in use there) and is added to the canonical file. Append it
to the canonical file's matching section array, then re-run the script with --apply --no-backup on
the canonical file to re-sort.
Verify persistence (required when any canonical/worktree file was written)
The canonical file is reached through a .claude symlink and is often mode 600, so a write can
silently fail or land on the wrong inode. After all mutations, re-run the script with --report
on every file you touched and confirm:
- No file grew an
error (read error / JSON parse error).
- For each written file,
applied.write_error is absent and applied.verified is true. Use
applied.realpath to confirm the bytes went where you intended (through the symlink, not beside it).
- Every promoted entry now appears in the canonical file's
report.current.<section>.
If a write failed (write_error set or verified false), emit a loud ✗ FAILED line and do
not claim success. Remediate:
readlink -f <main_wt>/.claude — confirm the symlink resolves to a real directory.
ls -l <realpath> — check the file mode; a 600 file the agent can't write is the usual cause.
- Fallback: write directly to the resolved
applied.realpath rather than the symlinked path, then
re-verify.
6. Final summary
End with a short summary:
Applied:
- sorted/deduped 2 files
- removed 6 subsumed Skill(<name>) entries from ~/.claude/settings.json
- removed Bash(~/.agents/skills/landscape/scripts/pr-status.sh) (missing file)
- promoted Skill(landscape) and Skill(next) to user level
- promoted Bash(gh run watch *) from worktree zazzy → canonical (verified persisted)
Still on your radar:
- Bash(curl:*) in <project> — flagged medium-risk, you said keep
- WebSearch in user file — syntax check passes, but verify intent
Backups: 2 .bak files written; delete when you've verified.
Notes
- The script never auto-removes from the
deny or ask arrays beyond exact dedupe. Promote/remove from those only on explicit user request.
mcp__server__tool entries can't be verified for existence without a live MCP probe — leave them alone unless the user flags one.
WebSearch and other bare tool names (no parens) are valid permissions — they sort with their tool prefix.
- If a project's
.claude/ is a symlink to another git repo (e.g. claude-myrepo/), the underlying real path is what gets edited.
- Worktree promotion always targets the canonical main-worktree file (the
into path), never the reverse — the worktree copy is the ephemeral one. Promoting adds to canonical and leaves the worktree entry in place (it may still be in active use).
- Only
settings.local.json drift is high-stakes: it's gitignored and per-worktree, so it's gone on prune. settings.json is git-tracked — worktree drift there is reported, but committing the worktree's change is usually the right fix, not byte-copying it into canonical.
- Never report a canonical write as done on the strength of having issued the write. The symlink hop and
600 mode mean writes can silently fail; only applied.verified == true (a successful re-read) counts as persisted.