tidy-settings · v1.3.0 · 2026-07-18 · sha256 adfb2682b517e2fe
tidy-settings v1.3.0A
Immutable. This exact content never changes and is served at /api/v1/blob/adfb2682b517e2fe.
---
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(~/.claude/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:**
1. **deny** — hard block, no prompt.
2. **allow** — auto-permit.
3. **ask** — prompt the user.
4. **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):
```bash
~/.claude/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
```bash
python3 ~/.claude/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:
1. **High-severity risk_flags** first (`Bash(rm:*)`, `Bash(*)`, `WebFetch(*)`, root-level Read/Edit/Write).
2. **Cross-section conflicts** (`allow_shadowed_by_deny`) — these are dead code; the allow entry will never fire.
3. **Syntax errors** next (these silently don't work).
4. **Broken refs** — likely cleanup wins.
5. **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.
6. **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.
7. **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.
8. **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):
1. Remove from file A using `Edit`.
2. Append to file B's `allow` array (insert before the closing `]`).
3. 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(~/.claude/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.