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/...)orBash(/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
allowshadowed by a wider:*entry indeny(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-modebehavior (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 avoidsgit rev-parse --show-toplevel(inside a worktree it points at the worktree's own checkout) and instead resolves the firstgit worktree listentry's.claudethrough any symlink.---WORKTREE---— every other worktree's own real.claude/settings.local.json(and trackedsettings.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.jsonis a separate, gitignored file that vanishes on prune. Permissions you accepted while working there are promotion candidates (step 4). settings.jsonis 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:*)vsSkill(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.
- Entry references a path inside
- Worktree-only entries (promotion candidates) — each
worktree_promotionsentry is a permission living only in a worktree's settings; it's lost when that worktree is pruned. Default recommendation: promote into the canonicalintofile so it survives. Apply judgment though — a one-offBash(...)you accepted for a throwaway experiment may be better left to die with the worktree. Batch withAskUserQuestionwhen several share an obvious intent (e.g. "Promote these 3gh-related permissions from worktreezazzyinto canonical settings?"). For trackedsettings.jsondrift, 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
allowarray (insert before the closing]). - Re-run the script with
--apply --no-backupon 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_erroris absent andapplied.verifiedistrue. Useapplied.realpathto 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; a600file the agent can't write is the usual cause.- Fallback: write directly to the resolved
applied.realpathrather 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
denyoraskarrays beyond exact dedupe. Promote/remove from those only on explicit user request. mcp__server__toolentries can't be verified for existence without a live MCP probe — leave them alone unless the user flags one.WebSearchand 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
intopath), 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.jsondrift is high-stakes: it's gitignored and per-worktree, so it's gone on prune.settings.jsonis 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
600mode mean writes can silently fail; onlyapplied.verified == true(a successful re-read) counts as persisted.