← back to Terminal Status
install.py
167 lines
#!/usr/bin/env python3
"""Prepare, inspect, apply, or roll back a fixed list of local script adapters."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import stat
import subprocess
import sys
from terminal_status import atomic_write
PROJECT = Path(__file__).resolve().parent
USER_ROOT = Path.home()
PLAN = PROJECT / "verification/install-plan.json"
BUNDLE = PROJECT / "verification/install"
def digest(data):
return hashlib.sha256(data).hexdigest()
def replacements():
files = {}
for color in ("green", "yellow", "purple", "orange", "pink"):
path = f".claude/skills/{color}dot/{color}dot.sh"
files[path] = (
'#!/usr/bin/env bash\n'
'# Shared semantic status; see Projects/terminal-status/README.md.\n'
f'exec bash "$HOME/Projects/terminal-status/integrations/dot.sh" claude {color} "$@"\n')
mapping = {
".claude/skills/colordots/paint-lib.sh": "paint-lib.sh",
".claude/hooks/dot-floor.sh": "dot-floor.sh",
".claude/skills/color/scripts/working-state.sh": "working-state.sh",
".claude/skills/color/scripts/paint-tab.sh": "paint-tab.sh",
".claude/skills/color/scripts/tab-state.sh": "tab-state.sh",
".agents/skills/color/scripts/semantic-dot.sh": "semantic-dot.sh",
".agents/skills/color/scripts/repaint-dot.sh": "repaint-dot.sh",
".claude/skills/tab-ticket/scripts/tab-ticket-set.sh": "tab-ticket-set.sh",
".claude/skills/tab-ticket/scripts/tab-ticket-title.sh": "tab-ticket-title.sh",
".claude/skills/dot/current.sh": "current-dot.sh",
".claude/skills/lightgreendot/lightgreendot.sh": "lightgreendot.sh",
}
for target, source in mapping.items():
files[target] = (PROJECT / "integrations" / source).read_text()
scanner = ".claude/skills/allcolordots/allcolordots.sh"
baseline = BUNDLE / "before" / scanner
text = (baseline if baseline.exists() else USER_ROOT / scanner).read_text()
start = text.index("# Codex rewrites its title")
end = text.index("# ---- JUMP:")
text = text[:start] + text[end:]
start = text.index("# ---- collect normalized")
end = text.index("# ---- roster of ONE color")
text = text[:start] + (
'# ---- Shared state scanner: presentation never overrides canonical state ----\n'
'collect_rows(){\n'
' python3 "$HOME/Projects/terminal-status/terminal_status.py" scan --tsv\n'
'}\n\n') + text[end:]
start = text.index("# Prefer semantic LIVE")
end = text.index("# Usage:")
text = text[:start] + (
'# Status comes from the shared session-bound terminal-status engine.\n'
'# Labels and dot files are presentation; exact main agent ownership is required.\n'
'#\n') + text[end:]
text = text.replace('CODEX_DOTS="$HOME/.codex/tab-dots"\n', '')
text = text.replace('set -u\n', 'set -u\nset -o pipefail\n')
text = "\n".join(line + ' || return $?' if 'local want=' in line and 'collect_rows' in line else line for line in text.split('\n'))
text = text.replace('print_roster yellow; exit 0', 'print_roster yellow; exit $?')
text = text.replace('print_roster "${2:-yellow}"; exit 0', 'print_roster "${2:-yellow}"; exit $?')
text = text.replace(" exit 0 ;;", " exit $? ;;")
text = text.replace('rows="$(collect_rows)"', 'rows="$(collect_rows)" || exit $?')
files[scanner] = text
# Keep the existing SessionStart hook and initialize semantic status afterward.
for target in (".claude/skills/color/scripts/color.sh",
".agents/skills/color/scripts/color.sh"):
text = (USER_ROOT / target).read_text()
marker = "# Shared terminal status follows the cosmetic session color."
if marker not in text:
text += (
'\n' + marker + '\n'
'python3 "$HOME/Projects/terminal-status/terminal_status.py" start --quiet || true\n')
files[target] = text
return files
def prepare(extend=False):
if PLAN.exists() and not extend:
raise SystemExit("Plan already exists; inspect it instead of replacing the baseline")
old = {item["relative"]: item for item in json.loads(PLAN.read_text())} if extend else {}
entries = []
for relative, content in replacements().items():
target = USER_ROOT / relative
if target.is_symlink():
raise SystemExit("Refusing to replace symlink: " + str(target))
original = target.read_bytes()
before = BUNDLE / "before" / relative
after = BUNDLE / "after" / relative
if relative in old:
if digest(original) not in (old[relative]["after_sha256"], old[relative]["before_sha256"], old[relative].get("upgrade_sha256")):
raise SystemExit("Concurrent adapter edit: " + relative)
else:
atomic_write(before, original.decode())
atomic_write(after, content)
subprocess.run(["bash", "-n", str(after)], check=True)
entries.append({
"relative": relative, "target": str(target),
"before": str(before), "after": str(after),
"before_sha256": digest(before.read_bytes()),
"after_sha256": digest(content.encode()),
"upgrade_sha256": digest(original),
"mode": stat.S_IMODE(target.stat().st_mode),
})
atomic_write(PLAN, json.dumps(entries, indent=2) + "\n")
print(f"Prepared {len(entries)} script changes: {PLAN}")
def change(rollback=False):
entries = json.loads(PLAN.read_text())
expected = "after_sha256" if rollback else "before_sha256"
desired = "before_sha256" if rollback else "after_sha256"
source_key = "before" if rollback else "after"
# Check every preimage before the first mutation; preserve concurrent edits.
for item in entries:
target = Path(item["target"])
if target.is_symlink():
raise SystemExit("Refusing to replace symlink: " + str(target))
current = digest(target.read_bytes())
allowed = (item[expected], item[desired], item.get("upgrade_sha256")) if not rollback else (item[expected], item[desired])
if current not in allowed:
raise SystemExit("Concurrent edit; no changes applied: " + str(target))
if digest(Path(item[source_key]).read_bytes()) != item[desired]:
raise SystemExit("Changed install bundle; no changes applied")
for item in entries:
target = Path(item["target"])
if digest(target.read_bytes()) == item[desired]:
continue
atomic_write(target, Path(item[source_key]).read_text())
os.chmod(target, item["mode"])
print(("Restored" if rollback else "Installed") + f" {len(entries)} script adapters")
def check():
failed = []
for item in json.loads(PLAN.read_text()):
if digest(Path(item["target"]).read_bytes()) != item["after_sha256"]:
failed.append(item["target"])
if failed:
print(json.dumps({"ok": False, "drift": failed}, indent=2))
return 1
print(json.dumps({"ok": True, "adapters": len(json.loads(PLAN.read_text()))}))
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("action", choices=["prepare", "extend", "apply", "rollback", "check"])
args = parser.parse_args()
if args.action in ("prepare", "extend"):
prepare(extend=args.action == "extend")
elif args.action == "check":
sys.exit(check())
else:
change(rollback=args.action == "rollback")