From c803f4ee28d4b05879cc9c0d7eb32e608096749c Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 11 Nov 2026 08:20:16 +0000 Subject: [PATCH] fix(install): empty the skills directories instead of replacing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install.sh removed ~/.claude/skills and ~/.agents/skills and created them anew. The Agent Orchestrator bind-mounts ~/.claude/skills, and a new inode left that mount showing the deleted directory, so the hub's radio send failed with radio.py not found after every make install. The installer now keeps each directory and deletes its contents before copying. 🎯 Fix: 93 % 💥 Break: 3 % 💸 Cost: 100 % ⏱️ Time: 100 % 🛡️ Security: 100 % 🏆 Score: 90 🚦 Gate: ✅ 90/5, cost and time <= 100, security 100 % Co-Authored-By: Claude Opus 5.5 --- scripts/install.sh | 2 +- tests/test_install.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 tests/test_install.py diff --git a/scripts/install.sh b/scripts/install.sh index af7ec22..df05983 100644 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -40,8 +40,8 @@ fi for dst in "${TARGET}/.agents/skills" "${TARGET}/.claude/skills"; do log "skills: copying -> ${dst}" - rm -rf "${dst}" mkdir -p "${dst}" + find "${dst}" -mindepth 1 -delete cp -a "${src}/." "${dst}/" done diff --git a/tests/test_install.py b/tests/test_install.py new file mode 100644 index 0000000..f47371b --- /dev/null +++ b/tests/test_install.py @@ -0,0 +1,42 @@ +"""Pin scripts/install.sh: the installed skill directories keep their inode, so a bind mount of them stays current.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +INSTALL = Path(__file__).resolve().parents[1] / "scripts" / "install.sh" +NPX = "#!/bin/sh\nmkdir -p .agents/skills/locked\necho locked > .agents/skills/locked/SKILL.md\n" + + +class TestInstall(unittest.TestCase): + def test_reinstall_empties_the_skill_directories_in_place(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + repo, target, stubs = root / "repo", root / "target", root / "bin" + (repo / "scripts").mkdir(parents=True) + shutil.copy(INSTALL, repo / "scripts" / "install.sh") + (repo / "scripts" / "enable-plugins.js").write_text("", encoding="utf-8") + (repo / "skills" / "own").mkdir(parents=True) + (repo / "skills" / "own" / "SKILL.md").write_text("own", encoding="utf-8") + stubs.mkdir() + (stubs / "npx").write_text(NPX, encoding="utf-8") + (stubs / "npx").chmod(0o755) + installed = [target / ".agents" / "skills", target / ".claude" / "skills"] + for folder in installed: + (folder / "stale").mkdir(parents=True) + before = [folder.stat().st_ino for folder in installed] + env = {**os.environ, "PATH": f"{stubs}:/usr/bin:/bin", "TARGET": str(target)} + done = subprocess.run(["bash", str(repo / "scripts" / "install.sh")], env=env, capture_output=True, text=True, check=False) + self.assertEqual(done.returncode, 0, done.stderr) + self.assertEqual([folder.stat().st_ino for folder in installed], before) + for folder in installed: + self.assertEqual(sorted(path.name for path in folder.iterdir()), ["locked", "own"]) + + +if __name__ == "__main__": + unittest.main()