# -*- coding: utf-8 -*- """Lay down the workspace files that live outside every repo, from their versioned copies. Usage: python3 scripts/install_workspace.py [--dry-run] [--workspace DIR] [--settings FILE] WHAT THIS EXISTS FOR. Claude Code runs from /mnt/5_hdd/felhom.eu/git, which is not a git repository. The workspace-root CLAUDE.md and the InstructionsLoaded hook shape every session and existed on ONE machine — the one host that cannot be rebuilt from anything else. This restores them. WHAT IT DOES NOT TOUCH. The auto-memory store. That is backed up, not versioned — see workspace/README.md for why, and /opt/backup/scripts/backup-config.sh for where. THE ONE WAY THIS DIFFERS FROM install_skills.py, AND IT IS THE IMPORTANT ONE. That script's targets are disposable: a skill directory can be deleted and re-linked with nothing lost. These targets are LIVE CONFIGURATION. ~/.claude/settings.json holds permissions, plugins and effort level that this repo knows nothing about, and the workspace CLAUDE.md may have been edited on the machine since the last sync. So: - every write is preceded by a TIMESTAMPED BACKUP of the file being replaced; - settings.json is MERGED, never rewritten from a template — this script owns exactly one key, hooks.InstructionsLoaded, and every other key is carried through untouched; - a diverging CLAUDE.md is reported, not silently resolved in either direction. IDEMPOTENT. A second run writes nothing and says so. That is asserted by the caller in the session report, because "it ran twice without error" and "it changed nothing the second time" are different claims and only the second one means idempotent. """ import argparse import datetime import json import os import shutil import sys REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # Canonical source of the workspace instructions. Deliberately NOT workspace/ — see workspace/README.md. CLAUDE_SRC = os.path.join(REPO, "documentation", "runbooks", "workspace-CLAUDE.md") HOOK_SRC = os.path.join(REPO, "workspace", "hooks", "instructions-loaded.json") changed = [] unchanged = [] problems = [] def stamp(): return datetime.datetime.now().strftime("%Y%m%d-%H%M%S") def backup(path): dst = "%s.bak-%s" % (path, stamp()) shutil.copy2(path, dst) return dst def read(path): with open(path, "r", encoding="utf-8") as fh: return fh.read() def install_claude_md(workspace, dry_run): target = os.path.join(workspace, "CLAUDE.md") if not os.path.exists(CLAUDE_SRC): problems.append("versioned source missing: %s" % CLAUDE_SRC) return src_text = read(CLAUDE_SRC) if os.path.exists(target): if read(target) == src_text: unchanged.append("CLAUDE.md (already byte-identical to the versioned copy)") return # Diverged. Back up FIRST, install, and report the direction so a human can judge which # side was right — this script must not silently pick one. if dry_run: changed.append("CLAUDE.md WOULD back up and overwrite (live file differs from source)") return b = backup(target) with open(target, "w", encoding="utf-8") as fh: fh.write(src_text) changed.append( "CLAUDE.md DIVERGED -> backed up to %s, installed from %s.\n" " If the LIVE file was the newer one, restore it and re-sync the versioned " "copy instead; instructions_gate check 5 enforces they match." % (os.path.basename(b), CLAUDE_SRC) ) return if dry_run: changed.append("CLAUDE.md WOULD create (absent)") return with open(target, "w", encoding="utf-8") as fh: fh.write(src_text) changed.append("CLAUDE.md created from %s" % CLAUDE_SRC) def install_hook(settings_path, dry_run): if not os.path.exists(HOOK_SRC): problems.append("versioned source missing: %s" % HOOK_SRC) return fragment = json.loads(read(HOOK_SRC)) want = fragment.get("hooks", {}).get("InstructionsLoaded") if not want: problems.append("%s declares no hooks.InstructionsLoaded" % HOOK_SRC) return if os.path.exists(settings_path): try: settings = json.loads(read(settings_path)) except ValueError as e: # A broken settings.json silently disables EVERY setting in it. Never overwrite one we # could not parse — that would destroy whatever the user was mid-way through fixing. problems.append( "%s is not valid JSON (%s). Refusing to write: a malformed settings file disables " "every setting in it, and overwriting would destroy the original." % (settings_path, e) ) return else: settings = {} have = settings.get("hooks", {}).get("InstructionsLoaded") if have == want: unchanged.append("settings.json (hooks.InstructionsLoaded already current)") return if dry_run: changed.append("settings.json WOULD merge hooks.InstructionsLoaded (%s)" % ("replacing an existing one" if have else "adding")) return before_keys = sorted(settings.keys()) if os.path.exists(settings_path): b = backup(settings_path) else: b = None os.makedirs(os.path.dirname(settings_path), exist_ok=True) settings.setdefault("hooks", {})["InstructionsLoaded"] = want with open(settings_path, "w", encoding="utf-8") as fh: json.dump(settings, fh, indent=2, ensure_ascii=False) fh.write("\n") after_keys = sorted(json.loads(read(settings_path)).keys()) lost = set(before_keys) - set(after_keys) if lost: problems.append("MERGE LOST KEYS from settings.json: %s" % ", ".join(sorted(lost))) changed.append( "settings.json %s hooks.InstructionsLoaded%s; %d top-level key(s) carried through unchanged" % ("replaced" if have else "added", (" (backup %s)" % os.path.basename(b)) if b else "", len(before_keys)) ) def main(argv): ap = argparse.ArgumentParser() ap.add_argument("--dry-run", action="store_true", help="report what would change; write nothing") ap.add_argument("--workspace", default=os.path.dirname(REPO), help="workspace root (default: the parent of this repo)") ap.add_argument("--settings", default=os.path.join(os.path.expanduser("~"), ".claude", "settings.json")) args = ap.parse_args(argv[1:]) print("install_workspace%s" % (" [--dry-run]" if args.dry_run else "")) print(" workspace : %s" % args.workspace) print(" settings : %s" % args.settings) print("") if not os.path.isdir(args.workspace): print("FAIL: workspace root does not exist: %s" % args.workspace) return 2 install_claude_md(args.workspace, args.dry_run) install_hook(args.settings, args.dry_run) for c in changed: print("CHANGED %s" % c) for u in unchanged: print("unchanged %s" % u) for p in problems: print("PROBLEM %s" % p) print("") if problems: print("install_workspace: %d PROBLEM(S)" % len(problems)) return 1 if not changed: print("install_workspace: nothing to do — already installed (idempotent)") else: print("install_workspace: %d change(s)" % len(changed)) return 0 if __name__ == "__main__": sys.exit(main(sys.argv))