# -*- coding: utf-8 -*- """Install the Felhom Claude Code skills from /skills/ into ~/.claude/skills/. Usage: python3 scripts/install_skills.py (run from the felhom.eu repo root or anywhere) Personal skills (~/.claude/skills/) apply across all projects — the right scope for the 4-repo felhom workspace. Preferred install is a LINK so edits in the repo are live immediately: a POSIX symlink on Linux/macOS (the DooPlex environment), a junction (mklink /J) on Windows. If the link cannot be created or isn't followed, falls back to a full COPY — in copy mode you must RE-RUN this script after editing skills/. Idempotent: safe to re-run any time. """ import os, shutil, subprocess, sys REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SRC = os.path.join(REPO, "skills") DST = os.path.join(os.path.expanduser("~"), ".claude", "skills") fails = 0 copy_mode_used = False IS_WINDOWS = os.name == "nt" LINK_KIND = "junction" if IS_WINDOWS else "symlink" def is_working_link(target, src): """True if target resolves into src (junction/symlink already correct).""" try: return os.path.isfile(os.path.join(target, "SKILL.md")) and \ os.path.samefile(os.path.join(target, "SKILL.md"), os.path.join(src, "SKILL.md")) except OSError: return False def remove_existing(target): # a link must be removed WITHOUT recursing INTO it (that would delete the repo's skills); # a real copied directory needs rmtree if os.path.islink(target): os.unlink(target) # POSIX symlink (also a Windows symlink-to-dir) return try: os.rmdir(target) # works for Windows junctions and empty dirs except OSError: shutil.rmtree(target, ignore_errors=True) def make_link(target, src): """Create the platform's live link. Returns (ok, detail-for-humans).""" if IS_WINDOWS: r = subprocess.run(["cmd", "/c", "mklink", "/J", target, src], capture_output=True, text=True) if r.returncode != 0: return False, (r.stderr or r.stdout).strip() or "mklink failed" return True, "" try: os.symlink(src, target, target_is_directory=True) return True, "" except OSError as e: return False, str(e) def install(name): global fails, copy_mode_used src = os.path.join(SRC, name) target = os.path.join(DST, name) if os.path.lexists(target): if is_working_link(target, src): print("OK %-22s %s (already installed, live-linked to repo)" % (name, LINK_KIND)) return remove_existing(target) ok, detail = make_link(target, src) if ok and is_working_link(target, src): print("OK %-22s %s -> %s" % (name, LINK_KIND, src)) return if not ok: reason = detail else: reason = "not followed" # fall back to copy if os.path.lexists(target): remove_existing(target) try: shutil.copytree(src, target) copy_mode_used = True print("OK %-22s COPY (%s failed: %s)" % (name, LINK_KIND, reason)) except OSError as e: fails += 1 print("FAIL %-22s %s" % (name, e)) if not os.path.isdir(SRC): print("FAIL: no skills/ dir at %s" % SRC) sys.exit(2) os.makedirs(DST, exist_ok=True) names = sorted(d for d in os.listdir(SRC) if os.path.isfile(os.path.join(SRC, d, "SKILL.md"))) if not names: print("FAIL: no skills found under %s" % SRC) sys.exit(2) for n in names: install(n) if copy_mode_used: print("\nNOTE: copy mode active for at least one skill — RE-RUN this script after editing skills/.") sys.exit(1 if fails else 0)