Skip to content

fix(install): adopt Cellar + symlink-farm layout for orphan-free re-deploys #560

@bakeb7j0

Description

@bakeb7j0

Problem

The current install script deploys files directly into ~/.local/bin/ (and merges hook entries into ~/.claude/settings.json referencing those paths). This conflates two concerns:

  1. Kit-managed files (deployed from this repo) live in the same namespace as user-installed binaries and other tools' binaries in ~/.local/bin/.
  2. Orphan rot is structural — when a script is removed from the repo (e.g. discord-bot in commit b079782), ./install does not delete the previously-installed copy. The --prune flag exists but is opt-in, not the default. Users carry around dead binaries indefinitely.

Concrete failure modes recently surfaced:

Both are symptoms of the same root cause: the install layer can't reliably reconcile the on-disk state with the source tree because it doesn't own a discrete namespace.

Proposal: Cellar + symlink farm (Homebrew/Nix pattern)

Adopt a layout where kit-managed files live in a dedicated, fully-owned directory ("Cellar"), and ~/.local/bin/ contains only symlinks pointing into it. Each install wipes and recreates the Cellar, eliminating orphan rot at the structural level.

Layout

~/.claude/scripts/                          ← Cellar (fully kit-owned)
├── precheck-asking-detector.sh
├── hooks/nerf/pre-compact.sh
├── hooks/nerf/session-start-compact.sh
├── vox
├── slackbot-send
└── ...                                     (mirrors scripts/<sub>/<file> from repo)

~/.local/bin/                               ← symlink farm (PATH discoverability)
├── precheck-asking-detector.sh → ~/.claude/scripts/precheck-asking-detector.sh
├── vox                          → ~/.claude/scripts/vox
├── slackbot-send                → ~/.claude/scripts/slackbot-send
└── ...                                     (one symlink per Cellar entry that needs PATH)

Install procedure

  1. Wipe the Cellar: rm -rf ~/.claude/scripts/ — orphans die here.
  2. Deploy fresh from scripts/ (recursive) → ~/.claude/scripts/. Preserves directory structure, executable bits, and subtree organization.
  3. Reap stale symlinks in ~/.local/bin/: walk and remove any symlink whose target lies under ~/.claude/scripts/ but no longer points at an existing file. Catches both removed-from-repo cases (e.g. discord-bot) and rename cases.
  4. Recreate symlinks in ~/.local/bin/ for each Cellar entry that should be on PATH.

Settings.json hook paths

Hook commands in config/settings.template.json change from:

~/.local/bin/hooks/nerf/pre-compact.sh

to:

~/.claude/scripts/hooks/nerf/pre-compact.sh

This is a one-time path migration. merge_settings() needs to detect old paths in user settings and rewrite them — not just add missing matchers (subsumes #556).

Granularity question (worth nailing down before implementation)

Two reasonable defaults for the symlink walk:

  • (A) Symlink everything in scripts/ recursively — preserves current behavior; subdirs like hooks/nerf/ get mirrored at ~/.local/bin/hooks/nerf/.
  • (B) Symlink only top-level scripts/ entries — subtrees (hooks, vox-providers, internal helpers) stay in the Cellar only. Hooks are called by absolute path from settings.json anyway; having them in ~/.local/bin/hooks/nerf/ is purely vestigial.

Recommendation: (B). Anything that doesn't need to be invoked from a user shell shouldn't be in ~/.local/bin/. Hooks fall in this category.

Changes

  • install — rewrite the scripts-and-config phase:
    • Wipe + redeploy ~/.claude/scripts/
    • Walk + reap stale symlinks under ~/.local/bin/
    • Recreate symlinks (granularity per recommendation above)
    • merge_settings(): rewrite hook command paths from old → new format; also union-merge new matcher entries into existing event arrays (closes fix(install): union-merge hook matcher arrays in merge_settings() #556).
  • config/settings.template.json — update all ~/.local/bin/hooks/... paths to ~/.claude/scripts/hooks/....
  • ./install --check — surface dangling symlinks and old-path hook commands as drift.
  • ./install --prune — semantics change: no longer needed for orphan rot (Cellar wipe handles that); could be retired or repurposed.

Tests

  • First run after upgrade: simulated user with old layout — plain ~/.local/bin/<script> files (not symlinks), old hook paths in settings.json. After install: Cellar populated, symlinks replace plain files, hook paths rewritten, .bak backups preserved.
  • Steady state: subsequent install on already-Cellar-deployed system — Cellar wiped + redeployed, symlinks intact, no drift reported.
  • Removed-from-repo case: delete a script from scripts/, run install, confirm both Cellar entry AND symlink in ~/.local/bin/ are gone (no --prune needed).
  • Settings hook-path migration: ~/.claude/settings.json with old ~/.local/bin/hooks/... path gets rewritten to ~/.claude/scripts/hooks/.... User-customized hook entries are preserved in .bak.
  • User-customized script: if ~/.local/bin/<script> is a plain file (not a symlink) with hand edits, back up to .bak before symlink replacement.
  • Symlinks pointing outside Cellar: unrelated user symlinks in ~/.local/bin/ are NOT touched.

Acceptance Criteria

  • ~/.claude/scripts/ is the canonical install location for all kit-managed scripts; the entire tree is wiped and recreated on every ./install.
  • ~/.local/bin/ contains only symlinks for kit scripts that need PATH discoverability (granularity per recommendation B).
  • After running ./install against a tree that has removed a script, both the Cellar entry AND the corresponding ~/.local/bin/ symlink are gone — no --prune flag required.
  • ~/.claude/settings.json hook paths get rewritten from ~/.local/bin/hooks/... to ~/.claude/scripts/hooks/... during merge_settings().
  • merge_settings() union-merges new matcher entries into existing event arrays (closes fix(install): union-merge hook matcher arrays in merge_settings() #556).
  • User customizations in ~/.local/bin/<script> (plain files, not symlinks) are backed up to .bak before being replaced with a symlink.
  • ./install --check reports both stale symlinks and old-path hook entries as drift.
  • Existing tests still pass (./scripts/ci/validate.sh 122/0).

Non-goals

  • Not migrating skills (~/.claude/skills/) to the Cellar pattern — they're already in a dedicated namespace.
  • Not migrating context-crystallizer (~/.claude/context-crystallizer/) — already in a dedicated namespace.
  • Not changing how MCP servers are installed — they have their own install-remote.sh per repo and don't deploy through this script.
  • Not retiring --prune in the same PR (decoupled cleanup; can come later if it's truly redundant).

Dependencies

Related

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions