install.sh: per-repo wiring scatters absolute symlinks that rot when the checkout moves #23

Closed
opened 2026-08-23 05:51:16 +00:00 by mathias · 2 comments
Owner

What happened

A sweep of ~/dev on flamingo found 243 dangling skill symlinks across 12 repos, plus a stale duplicate checkout serving 20-skills-out-of-date content. All cleaned up manually; this issue is about the mechanism that produced them.

Root cause

wire_claude_repo() in install.sh (and install:claude:repo in Taskfile.yml) creates <repo>/.claude/skills/<name> symlinks by absolute path to $CHECKOUT_DIR. Three properties combine badly:

  1. Absolute path, no indirection. When the checkout location changes, every previously-wired repo breaks. There has been no migration step across the moves.
  2. Only rewires the repo you happen to be standing in. Re-running the installer fixes global + cwd. The other N repos silently keep pointing at a path that no longer exists.
  3. Invisible to git. ~/.config/git/ignore has **/.claude/skills/, so git status never surfaces the rot in any repo.

Observed generations on this host:

Gen Checkout path Evidence
1 /Users/mathias/dev/.skills (macOS) .skills-shared links in cobalt-dingo, hyperguild — dead the moment the repo was cloned on Linux
2 /home/mathias/dev/.skills 243 links, May 29 – Jun 12; path no longer exists
3 ~/.local/share/skills (installer default) live but pinned at e553dae, missing 20 skills; tapir was silently loading these
4 ~/dev/skills (current, per CLAUDE.md) healthy

Gen 3 is the nastier failure mode: a working link to a stale checkout serves outdated skill bodies with no error, and project-level skills shadow global ones.

Secondary defect: CHECKOUT_DIR defaults to ~/.local/share/skills in both install.sh and Taskfile.yml, but the documented location in CLAUDE.md is ~/dev/skills. task update against the default therefore pulls a checkout nothing is linked to — or clones a second one.

Proposed fix

Per-repo wiring is redundant on Claude Code: ~/.claude/skills/ is already visible in every project on the host, and the per-repo dir is gitignored, so it is never shared with anyone. It buys nothing and rots by default.

  1. Drop wire_claude_repo / install:claude:repo, or gate it behind an explicit SKILLS_WIRE_REPO=1.
  2. Make CHECKOUT_DIR default match the documented ~/dev/skills, or resolve it from the script's own location when run from a checkout.
  3. Add a task doctor that reports dangling links and links resolving outside the current CHECKOUT_DIR (catches the gen-3 stale-but-live case), across all four harness dirs.
  4. Have task check fail on a stray non-directory entry at the repo root — a loose secret-hygiene.md sat unloadable in ~/.claude/skills/ for months for exactly this reason (fixed in 21be3ea).

Related: #7 (task install broken → docs recommend install.sh).

## What happened A sweep of `~/dev` on flamingo found **243 dangling skill symlinks across 12 repos**, plus a stale duplicate checkout serving 20-skills-out-of-date content. All cleaned up manually; this issue is about the mechanism that produced them. ## Root cause `wire_claude_repo()` in `install.sh` (and `install:claude:repo` in `Taskfile.yml`) creates `<repo>/.claude/skills/<name>` symlinks by **absolute path to `$CHECKOUT_DIR`**. Three properties combine badly: 1. **Absolute path, no indirection.** When the checkout location changes, every previously-wired repo breaks. There has been no migration step across the moves. 2. **Only rewires the repo you happen to be standing in.** Re-running the installer fixes global + cwd. The other N repos silently keep pointing at a path that no longer exists. 3. **Invisible to git.** `~/.config/git/ignore` has `**/.claude/skills/`, so `git status` never surfaces the rot in any repo. Observed generations on this host: | Gen | Checkout path | Evidence | |-----|---------------|----------| | 1 | `/Users/mathias/dev/.skills` (macOS) | `.skills-shared` links in cobalt-dingo, hyperguild — dead the moment the repo was cloned on Linux | | 2 | `/home/mathias/dev/.skills` | 243 links, May 29 – Jun 12; path no longer exists | | 3 | `~/.local/share/skills` (installer default) | live but pinned at `e553dae`, missing 20 skills; tapir was silently loading these | | 4 | `~/dev/skills` (current, per CLAUDE.md) | healthy | Gen 3 is the nastier failure mode: a *working* link to a stale checkout serves outdated skill bodies with no error, and project-level skills shadow global ones. Secondary defect: `CHECKOUT_DIR` defaults to `~/.local/share/skills` in both `install.sh` and `Taskfile.yml`, but the documented location in CLAUDE.md is `~/dev/skills`. `task update` against the default therefore pulls a checkout nothing is linked to — or clones a second one. ## Proposed fix Per-repo wiring is redundant on Claude Code: `~/.claude/skills/` is already visible in every project on the host, and the per-repo dir is gitignored, so it is never shared with anyone. It buys nothing and rots by default. 1. Drop `wire_claude_repo` / `install:claude:repo`, or gate it behind an explicit `SKILLS_WIRE_REPO=1`. 2. Make `CHECKOUT_DIR` default match the documented `~/dev/skills`, or resolve it from the script's own location when run from a checkout. 3. Add a `task doctor` that reports dangling links **and** links resolving outside the current `CHECKOUT_DIR` (catches the gen-3 stale-but-live case), across all four harness dirs. 4. Have `task check` fail on a stray non-directory entry at the repo root — a loose `secret-hygiene.md` sat unloadable in `~/.claude/skills/` for months for exactly this reason (fixed in 21be3ea). Related: #7 (`task install` broken → docs recommend `install.sh`).
Author
Owner

Correction: the CHECKOUT_DIR "mismatch" is not a bug, and the fix is stronger than I wrote

New fact from the host owner: flamingo and iguana share a single iCloud-synced ~/dev. koala has its own independent clones at the same relative path. That makes the fleet:

Host ~/dev Path root
flamingo iCloud folder (shared) /Users/mathias
iguana same iCloud folder /Users/mathias
koala independent git clones /home/mathias

Two consequences.

1. Absolute wiring inside ~/dev is unfixable, not merely fragile

~/dev spans two path roots. Any absolute symlink stored in it resolves on exactly one side and dangles on the other, permanently, by construction. No installer re-run repairs it — the same file has to be correct at /Users/mathias/... and /home/mathias/... simultaneously.

Live proof: cobalt-dingo and hyperguild track .skills-shared -> /Users/mathias/dev/.skills in git. It resolves on both Macs and has always dangled on koala.

So proposal 1 in the issue body (drop wire_claude_repo) is not a tidiness argument. Per-repo wiring cannot be made correct on this fleet. It should be removed outright rather than gated behind an opt-in flag.

2. ~/.local/share/skills is the right default — the docs are what's wrong

I filed this saying the default CHECKOUT_DIR should change to match CLAUDE.md's ~/dev/skills. That is backwards. On the Macs, ~/dev/skills would put the checkout inside iCloud, i.e. one git working tree with two concurrent writers — the failure mode described in brain knowledge/icloud-git-workspace-appearance-vs-corruption. The host-local default is correct precisely because it is outside the synced folder.

Revised: keep CHECKOUT_DIR=~/.local/share/skills. Fix the documentation (~/dev/CLAUDE.md, SKILLS_INDEX.md) which currently instructs cloning into ~/dev. koala's ~/dev/skills works only because koala's ~/dev is not synced — an accident, not a pattern to document.

Still open, unchanged

  • task doctor reporting both dangling and stale-but-resolving links (the silent-wrong case).
  • task check rejecting stray non-directory root entries.

Separate finding for the Macs

The shared ~/dev carries local-dev pinned pre-extraction (e6d3c2d), so ~/dev/.skills still exists there. Both Macs have zero engineering skills wired into any harness — ~/.claude/skills on iguana holds gsd-* only, and no crush / antigravity / vibe skills dirs exist. Pulling 1966d5a on either Mac deletes .skills with no replacement in place. Needs a host-local install on each Mac before that pull.

## Correction: the `CHECKOUT_DIR` "mismatch" is not a bug, and the fix is stronger than I wrote New fact from the host owner: **flamingo and iguana share a single iCloud-synced `~/dev`.** koala has its own independent clones at the same relative path. That makes the fleet: | Host | `~/dev` | Path root | |---|---|---| | flamingo | iCloud folder (shared) | `/Users/mathias` | | iguana | **same** iCloud folder | `/Users/mathias` | | koala | independent git clones | `/home/mathias` | Two consequences. ### 1. Absolute wiring inside `~/dev` is unfixable, not merely fragile `~/dev` spans two path roots. Any absolute symlink stored in it resolves on exactly one side and dangles on the other, permanently, by construction. No installer re-run repairs it — the same file has to be correct at `/Users/mathias/...` and `/home/mathias/...` simultaneously. Live proof: `cobalt-dingo` and `hyperguild` **track** `.skills-shared -> /Users/mathias/dev/.skills` in git. It resolves on both Macs and has always dangled on koala. So proposal 1 in the issue body (drop `wire_claude_repo`) is not a tidiness argument. Per-repo wiring cannot be made correct on this fleet. It should be removed outright rather than gated behind an opt-in flag. ### 2. `~/.local/share/skills` is the *right* default — the docs are what's wrong I filed this saying the default `CHECKOUT_DIR` should change to match CLAUDE.md's `~/dev/skills`. That is backwards. On the Macs, `~/dev/skills` would put the checkout **inside iCloud**, i.e. one git working tree with two concurrent writers — the failure mode described in brain `knowledge/icloud-git-workspace-appearance-vs-corruption`. The host-local default is correct precisely because it is outside the synced folder. Revised: keep `CHECKOUT_DIR=~/.local/share/skills`. Fix the **documentation** (`~/dev/CLAUDE.md`, `SKILLS_INDEX.md`) which currently instructs cloning into `~/dev`. koala's `~/dev/skills` works only because koala's `~/dev` is not synced — an accident, not a pattern to document. ### Still open, unchanged - `task doctor` reporting both dangling **and** stale-but-resolving links (the silent-wrong case). - `task check` rejecting stray non-directory root entries. ### Separate finding for the Macs The shared `~/dev` carries `local-dev` pinned pre-extraction (`e6d3c2d`), so `~/dev/.skills` still exists there. Both Macs have **zero** engineering skills wired into any harness — `~/.claude/skills` on iguana holds `gsd-*` only, and no crush / antigravity / vibe skills dirs exist. Pulling `1966d5a` on either Mac deletes `.skills` with no replacement in place. Needs a host-local install on each Mac before that pull.
Author
Owner

Done — the mechanism is gone, not patched

Shipped in v0.4.1. koala is fully migrated and verified.

What changed

Claude Code no longer uses symlinks at all. This repo is now a marketplace hosting one plugin — itself, source: "." — installed straight from gitea:

/plugin marketplace add https://git.d-ma.be/mathias/skills.git
/plugin install skills@mathias

Skills moved to skills/<name>/SKILL.md. The plugin ships 42 skills + 3 hooks + 3 MCP servers as one versioned unit, so the secret-guard and pre-task-ritual hooks and the brain/gitea/infra server definitions travel with it instead of existing only in koala's ~/.claude/.

The other harnesses went from 168 symlinks to 3. wire_claude_repo and wire_claude_global are deleted outright rather than flag-gated — on a mixed-root fleet per-repo wiring cannot be made correct. Antigravity and Vibe targets removed (unused); Pi and OpenCode added, reached through the convergent ~/.agents/skills convention both read:

~/.agents/skills          -> $CHECKOUT/skills
~/.config/crush/skills    -> ~/.agents/skills
~/.config/opencode/skills -> ~/.agents/skills

Adding a skill now wires it everywhere with no re-run.

Corrections to what I filed above

Two of my original claims were wrong and are worth recording:

  1. CHECKOUT_DIR was never the bug. I filed this saying the default should change to match CLAUDE.md's ~/dev/skills. Backwards — on the Macs that puts a git working tree inside iCloud with two concurrent writers. The host-local default is correct because it is outside the shared folder. The docs were wrong, and they now say so with the reason attached (~/dev/.context/AGENT.md updated and synced).

  2. Per-repo wiring is not fragile, it is impossible. ~/dev spans /Users/mathias and /home/mathias; an absolute link stored there is broken on one side by construction. So it had to be removed, not gated.

Detection, since removal only covers Claude Code

bin/doctor (POSIX sh, 27 ms, no network) reports with a non-zero exit: dangling links; links that resolve but land outside the active checkout — the silent-stale case that is worse than dangling because nothing complains; count mismatches; stray entries; leftover per-skill Claude symlinks; and divergence between the plugin copy and the copy the other harnesses link to.

Wired as a SessionStart hook in the plugin, so it reaches every host that installs it with no launchd/systemd wiring. A healthy host prints nothing.

task check gained frontmatter validation, a stray-root-entry gate, and manifest/tag version parity.

Original proposals, resolved

  • drop wire_claude_repo — deleted outright ✅
  • fix CHECKOUT_DIR default — rejected, default was right; docs fixed instead ✅
  • task doctor for dangling + stale-but-live — bin/doctor, 7 fixture tests ✅
  • task check rejects stray root entries ✅

Verification

claude plugin validate . clean · 27/27 shell tests green · task check OK at 42 skills · doctor exit 0 · plugin update path proven live (0.3.3 → 0.4.1 through the version field) · secret-guard confirmed blocking the sops decrypt-and-print shape from inside the plugin in a fresh session.

Follow-ups: #24 (Mac rollout — both still have no skills and no secret-guard), #25 (env -u false positive).

Closing.

## Done — the mechanism is gone, not patched Shipped in v0.4.1. koala is fully migrated and verified. ### What changed **Claude Code no longer uses symlinks at all.** This repo is now a marketplace hosting one plugin — itself, `source: "."` — installed straight from gitea: ``` /plugin marketplace add https://git.d-ma.be/mathias/skills.git /plugin install skills@mathias ``` Skills moved to `skills/<name>/SKILL.md`. The plugin ships **42 skills + 3 hooks + 3 MCP servers** as one versioned unit, so the `secret-guard` and `pre-task-ritual` hooks and the brain/gitea/infra server definitions travel with it instead of existing only in koala's `~/.claude/`. **The other harnesses went from 168 symlinks to 3.** `wire_claude_repo` and `wire_claude_global` are deleted outright rather than flag-gated — on a mixed-root fleet per-repo wiring cannot be made correct. Antigravity and Vibe targets removed (unused); Pi and OpenCode added, reached through the convergent `~/.agents/skills` convention both read: ``` ~/.agents/skills -> $CHECKOUT/skills ~/.config/crush/skills -> ~/.agents/skills ~/.config/opencode/skills -> ~/.agents/skills ``` Adding a skill now wires it everywhere with no re-run. ### Corrections to what I filed above Two of my original claims were wrong and are worth recording: 1. **`CHECKOUT_DIR` was never the bug.** I filed this saying the default should change to match CLAUDE.md's `~/dev/skills`. Backwards — on the Macs that puts a git working tree inside iCloud with two concurrent writers. The host-local default is correct *because* it is outside the shared folder. The **docs** were wrong, and they now say so with the reason attached (`~/dev/.context/AGENT.md` updated and synced). 2. **Per-repo wiring is not fragile, it is impossible.** `~/dev` spans `/Users/mathias` and `/home/mathias`; an absolute link stored there is broken on one side by construction. So it had to be removed, not gated. ### Detection, since removal only covers Claude Code `bin/doctor` (POSIX sh, 27 ms, no network) reports with a non-zero exit: dangling links; links that **resolve but land outside the active checkout** — the silent-stale case that is worse than dangling because nothing complains; count mismatches; stray entries; leftover per-skill Claude symlinks; and divergence between the plugin copy and the copy the other harnesses link to. Wired as a `SessionStart` hook in the plugin, so it reaches every host that installs it with no launchd/systemd wiring. A healthy host prints nothing. `task check` gained frontmatter validation, a stray-root-entry gate, and manifest/tag version parity. ### Original proposals, resolved - ~~drop `wire_claude_repo`~~ — deleted outright ✅ - ~~fix `CHECKOUT_DIR` default~~ — **rejected**, default was right; docs fixed instead ✅ - ~~`task doctor` for dangling + stale-but-live~~ — `bin/doctor`, 7 fixture tests ✅ - ~~`task check` rejects stray root entries~~ ✅ ### Verification `claude plugin validate .` clean · 27/27 shell tests green · `task check` OK at 42 skills · `doctor` exit 0 · plugin update path proven live (0.3.3 → 0.4.1 through the `version` field) · `secret-guard` confirmed blocking the sops decrypt-and-print shape from inside the plugin in a fresh session. Follow-ups: #24 (Mac rollout — both still have no skills and no secret-guard), #25 (`env -u` false positive). Closing.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: mathias/skills#23