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¶
Implemented in
scripts/tests/setup/24-coexistence-decoy-home.sh(SPEC-169 Wave 1). Until 2026-08-06 this section described a test that did not exist — see the correction under "Tests" below.
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-t-toolkit-update— wraps--updatemode with session-start awareness - Tests:
scripts/tests/setup/— numbered scenarios covering all four pillars (derive the count, see below), including24-coexistence-decoy-home.sh, which seeds a decoy$HOMEand asserts that nothing the user owns is destroyed by an install, and26-dir-ownership-and-failed-writes.sh, which asserts that the ownership test can tell a user's directory from the toolkit's and that a failed write is reported rather than swallowed.
⚠ Corrected 2026-08-06 (BUG-033). This line previously read "132 tests … including the
mtime test (06-backup-created-before-write.sh, 09-backup-before-write.sh,
10-update-skill-hook.sh)". Both halves were false: the directory held 19 scenarios, not 132,
and none of the three named files contained an mtime assertion — measured, all three exist
and all three have zero. The files were real, which is what made the claim survive inspection.
This mattered beyond tidiness. SPEC-169 inherited the false claim: its SC-1 was written as "The mtime acceptance test passes for …" — definite article, presupposing a test that had never been written. A durable pattern doc asserting a verifiable fact that nobody re-verified propagated confidence rather than preventing recurrence, which is the opposite of what a pattern doc is for.
The mtime check described below now genuinely exists, built during SPEC-169 Wave 1 Task 3 as
the idempotency assertion in 24-coexistence-decoy-home.sh. Derive this count rather than
quoting it — ls scripts/tests/setup/[0-9]*.sh | wc -l.
⚠ The corrected count went stale again within the same branch. It was rewritten to "22" and was wrong by the next commit, because adding a scenario is the most common change this directory ever sees — the one edit guaranteed to invalidate a quoted total. A count sitting four lines above the instruction not to quote counts is the instruction failing to bind its own paragraph. The number has now been removed rather than corrected a third time: the only durable fix for a mirrored count is to delete the mirror.