Defensive Toolkit Install¶
Problem¶
Tools that install into shared user-config directories (~/.claude/, ~/.config/, ~/.vscode/, dotfiles managers) face a hard distinction problem: which files in the target directory are ours (safe to overwrite) and which belong to the user (must never be touched)? Solving this carelessly costs people real data.
Observed regression (2026-05-08, SPEC-124): A colleague's first onboarding ran scripts/setup.sh --full. The script did a plain cp configs/user-global/CLAUDE.md ~/.claude/CLAUDE.md and rm -rf on skill-symlink targets that happened to share names with user-created skills. Years of personal ~/.claude/CLAUDE.md preferences were silently overwritten; user-created skills were deleted. Adoption blocker.
Symptoms when this is violated:
- User files clobbered with no backup → unrecoverable data loss.
- Reinstall / git pull followed by setup.sh re-clobbers (no idempotency).
- Users learn to fear the installer and skip updates entirely → adoption drops.
- Conflicts go unnoticed because the script never compared before writing.
Pattern: Four Pillars of Defensive Install¶
A toolkit that writes into shared user-config space must build on all four. Skipping any one re-introduces the regression class.
1. Ownership classifier (is_toolkit_owned)¶
A pure function that, given any path under the shared directory, returns true iff the toolkit owns that file. Make ownership multi-signal, not a single heuristic:
is_toolkit_owned() {
local path="$1"
case "$path" in
# Namespace prefix on the basename
*/vt-c-*) return 0 ;;
# Path under toolkit-only subdirectories
"$HOME/.claude/plugins/company-claude-toolkit/"*) return 0 ;;
"$HOME/.claude/toolkit/"*) return 0 ;;
esac
# Symlink manifest (durable record of every symlink the toolkit creates)
grep -qxF "$path" "$MANIFEST_FILE" 2>/dev/null && return 0
return 1
}
Three signals = three independent escape hatches. The manifest is the most important: it survives renames and lets --update distinguish a vt-c-skill the toolkit installed last week from a user skill with a vt-c- prefix that the user happened to write (rare, but possible).
2. @-import for configuration files Claude Code already reads¶
Never overwrite a user-authored ~/.claude/CLAUDE.md. Instead, ship the toolkit defaults as a separate file under toolkit-owned space and have the user file include it via Claude Code's official @-import directive (recursive, max depth 5, documented in memory.md).
~/.claude/CLAUDE.md ← user-owned, one line added
└─ @~/.claude/toolkit/CLAUDE.defaults.md
~/.claude/toolkit/CLAUDE.defaults.md ← toolkit-owned, freely updatable
When the user file doesn't exist yet, the installer creates a minimal stub containing only the import line plus a comment marking the line as toolkit-managed. When the user file already exists, the installer appends the import line idempotently (grep first, then append) and leaves everything else alone.
This pattern generalizes anywhere the consuming tool supports an include/import directive: VS Code settings.json ("editor.settings": "..." references), shell rc files (source ~/.toolkit/bashrc), git config ([include] path=...).
3. Backup-before-write (defense in depth)¶
Even when the classifier says a file is toolkit-owned, take a timestamped backup before any destructive op. Cost is negligible; the upside is recovery from classifier bugs and from edge cases nobody anticipated.
do_backup_now() {
local path="$1"
[ -e "$path" ] || return 0
local backup="${path}.bak.$(date +%Y%m%d-%H%M%S)"
cp -a "$path" "$backup"
echo "Backed up: $path → $backup"
}
Run before every cp over an existing file, every rm, every ln -sf that would replace something.
4. Three-mode dispatch with explicit pre-conditions¶
One installer script handles three distinct life-cycle moments:
| Mode | Trigger | Pre-condition | Effect |
|---|---|---|---|
--verify --pre-install |
"What would change if I ran this?" | None | Dry-run: compute the diff, list conflicts, write nothing |
--update |
"Pull the latest toolkit defaults" | Toolkit already installed | Idempotent refresh of toolkit-owned files only |
--migrate-v2 |
"Move from old layout to new layout" | Detected old layout signature | One-time conversion + breadcrumb file so it doesn't re-run |
Three modes, three pre-conditions, three exit codes. The script refuses to proceed if its mode's pre-condition is unmet — this is what makes it safe to put in a session-start hook.
Verification: The mtime acceptance test¶
The whole point of the pattern is "user file is never touched." That claim is testable, and the sharpest test is one line:
mtime_before=$(stat -f %m ~/.claude/CLAUDE.md)
bash scripts/setup.sh --update
mtime_after=$(stat -f %m ~/.claude/CLAUDE.md)
[ "$mtime_before" = "$mtime_after" ] || { echo "FAIL: user CLAUDE.md was modified"; exit 1; }
Add this to the test suite. It runs in milliseconds, has zero false positives, and would have caught the SPEC-124 regression the first time anyone ran the test.
Apply the same idea to any other user-owned files the installer should leave alone. The mtime test is sharper than a content-diff test because it also catches no-op rewrites (open-and-close-without-change) which can break editor file watchers and trigger reprocessing in tools downstream.
When to use this pattern¶
- Any installer that writes into a directory the user also writes into.
- Any auto-update mechanism (session-start hook, daily pull,
--updateflag). - Any migration script that runs once per host and must not run twice destructively.
When NOT to use this pattern¶
- Containerized installs where the home directory is disposable.
- Project-local installs (
./node_modules/,.venv/) — those are 100% tool-owned and the user has no claim on individual files inside them. - One-shot scripts that write under
/tmpor a fresh directory the user didn't choose.
Reference¶
- Spec: SPEC-124 (
specs/124-toolkit-install-defensive/) — first implementation in V025-claude-toolkit - Skill:
vt-c-toolkit-update— wraps--updatemode with session-start awareness - Tests:
scripts/tests/setup/— 132 tests covering all four pillars, including the mtime test (scripts/tests/setup/06-backup-created-before-write.sh,09-backup-before-write.sh,10-update-skill-hook.sh)