diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 6bed1b4..ca581f1 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -49,6 +49,9 @@ jobs: - name: install.py transactional self-test (no host/state dir touched) run: python3 installers/install.py --self-test + - name: Opt-in SessionStart update-notify hook test (settings merge + hook logic, offline) + run: python3 installers/tests/test_session_hook.py + - name: Release-ZIP provenance + updater-consumability round-trip (build -> safe-extract) run: bash installers/tests/test_release_zip.sh @@ -246,5 +249,7 @@ jobs: run: python installers/tests/test_txn.py - name: Updater verify / safe-extract / check-update test (offline) run: python installers/tests/test_update.py + - name: Opt-in SessionStart update-notify hook test (settings merge + hook logic) + run: python installers/tests/test_session_hook.py - name: install.py transactional self-test run: python installers/install.py --self-test diff --git a/CHANGELOG.md b/CHANGELOG.md index dda0ab4..6fa12e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,17 @@ ### Added +- **Opt-in update notice for Claude Code (off by default).** `install.py --enable-update-notify` + merges a SessionStart hook (`installers/session_update_check.py`) into `~/.claude/settings.json` + that prints a one-line "update available" `systemMessage` at session start; `--disable-update-notify` + removes only that hook. The hook **does not read the SessionStart stdin** (no cwd/transcript/session + id), has no telemetry/analytics/unique-id, uses the shared clock-sane 24h cache + a short timeout, + stays silent on any error (never blocks a session), honors `MEDSCI_NO_UPDATE_CHECK=1`, and installs + nothing — it only notifies. A version *check* now resolves the latest tag without requiring the + OS-specific download asset (`resolve_latest_tag`), so the notice works on Linux too. Settings merge + is idempotent, preserves foreign hooks/settings, removes only ours (incl. mixed entries), and + refuses to clobber an unparseable `settings.json`. Tested offline (`installers/tests/test_session_hook.py`, + 26 cases) on Ubuntu + macOS + Windows. - **Release-pipeline supply-chain hardening (self-update foundation, no user-facing change).** `release.yml` now: gates on a version-consistency check (the pushed tag must equal `CITATION.cff` == `package.json` == `metadata/distribution_manifest.json`); injects a verified diff --git a/README.md b/README.md index e5e6110..5e9cdbb 100644 --- a/README.md +++ b/README.md @@ -539,6 +539,10 @@ MedSci Skills updates often. You do **not** need GitHub, git, or the command lin - **Terminal users:** `npx medsci-skills@latest install` always installs the latest. - **Just checking:** `python3 installers/install.py --check-update` reports whether a newer version is available and installs nothing. +- **Get reminded (opt-in, Claude Code):** `python3 installers/install.py --enable-update-notify` + shows a one-line *"update available"* notice when a Claude Code session starts. It is **off by + default**, checks at most once a day, reads nothing about your session, and never installs + anything. Turn it off with `--disable-update-notify`, or silence it with `MEDSCI_NO_UPDATE_CHECK=1`. - **Claude Code plugin marketplace:** third-party marketplace **auto-update is off by default** — enable it in Claude Code or run a manual plugin update. diff --git a/docs/update_privacy.md b/docs/update_privacy.md index bb45258..406edae 100644 --- a/docs/update_privacy.md +++ b/docs/update_privacy.md @@ -34,12 +34,31 @@ Everything the updater writes stays on your computer under `~/.medsci-skills/`: Install logs are written next to the installer and **mask your home directory as `~`**. +## Opt-in session-start notice (Claude Code) + +`python3 installers/install.py --enable-update-notify` registers a Claude Code **SessionStart** hook +that prints a one-line *"update available"* notice and nothing else. It is **off by default** and is +the only thing that writes to `~/.claude/settings.json` (it merges one hook entry and preserves your +existing settings). + +The hook is built to be safe: + +- It **does not read the SessionStart input** — your working directory, transcript path, and session + id are never read or transmitted. There is no telemetry, no analytics, and no unique id. +- Its only network call is the same single GitHub version GET described above, made **at most once a + day** (a 24-hour cache), with a short timeout. On any error or timeout it stays silent, so it never + delays or blocks a session. +- It honors `MEDSCI_NO_UPDATE_CHECK=1` and installs nothing — it only *tells* you an update exists. + +Remove it any time with `python3 installers/install.py --disable-update-notify` (which removes only +that hook and leaves the rest of `settings.json` intact). + ## Checking, opting out, and uninstalling - **Check only:** `python3 installers/install.py --check-update` reports whether a newer version exists and installs nothing. -- **Skip the per-session update check** (if you opted into it): set the environment variable - `MEDSCI_NO_UPDATE_CHECK=1`. +- **Turn off the opt-in session-start notice:** `python3 installers/install.py --disable-update-notify`, + or set `MEDSCI_NO_UPDATE_CHECK=1` to silence it without removing it. - **Uninstall the updater / state:** delete the `~/.medsci-skills/` folder (and any Desktop "Update MedSci Skills" launcher you chose to create). The installed skills under `~/.claude/skills` / `~/.agents/skills` are separate and remain until you remove them. diff --git a/installers/install.py b/installers/install.py index 5ffa8f5..e2dad1b 100644 --- a/installers/install.py +++ b/installers/install.py @@ -204,6 +204,17 @@ def parse_args() -> argparse.Namespace: action="store_true", help="With your consent, also place an 'Update MedSci Skills' launcher on your Desktop.", ) + parser.add_argument( + "--enable-update-notify", + action="store_true", + help="Opt in: show a one-line 'update available' notice at Claude Code session start " + "(merges a hook into ~/.claude/settings.json; 24h-cached; no telemetry).", + ) + parser.add_argument( + "--disable-update-notify", + action="store_true", + help="Opt out: remove the session-start update-notice hook from ~/.claude/settings.json.", + ) return parser.parse_args() @@ -218,6 +229,25 @@ def main() -> int: except Exception as exc: # noqa: BLE001 print(f"MedSci Skills: update check unavailable ({exc}).", file=sys.stderr) return 1 + if args.enable_update_notify or args.disable_update_notify: + try: + import update # noqa: PLC0415 + home = medsci_txn.state_home() + if args.disable_update_notify: + r = update.unregister_session_hook(home, update.default_settings_path()) + print("Session-start update notice disabled." if r == "disabled" + else "Session-start update notice was not enabled; nothing to do.") + return 0 + # Opt-in: ensure the updater home (with the hook script) exists, then register the hook. + update.install_updater_home(REPO_ROOT, home, lambda _m: None) + r = update.register_session_hook(home, update.default_settings_path()) + print("Opted in: Claude Code will show a one-line update notice at session start " + "(24h-cached, no telemetry). Disable with: install.py --disable-update-notify" + if r == "enabled" else "Already opted in to the session-start update notice; no change.") + return 0 + except Exception as exc: # noqa: BLE001 + print(f"MedSci Skills: could not change the update-notify setting ({exc}).", file=sys.stderr) + return 1 log_lines: list[str] = [] log("MedSci Skills Installer", log_lines) log(f"Repository: {REPO_ROOT}", log_lines) diff --git a/installers/session_update_check.py b/installers/session_update_check.py new file mode 100644 index 0000000..9329e78 --- /dev/null +++ b/installers/session_update_check.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Opt-in Claude Code SessionStart notifier for MedSci Skills. + +Prints a single `{"systemMessage": ...}` line when a newer release exists, or nothing. It is OFF by +default — registered into ~/.claude/settings.json only on explicit opt-in +(`install.py --enable-update-notify`) and removed by `install.py --disable-update-notify`. + +Privacy & safety, by construction: + * Does NOT read the SessionStart stdin — your cwd, transcript path, and session id are never read + or transmitted. No telemetry, no analytics, no unique install id. + * The only network call is a single GitHub version GET, and only when the shared 24h cache is + stale. It uses a short timeout and exits SILENTLY on any error/timeout, so it never delays or + blocks a session. + * Honors `MEDSCI_NO_UPDATE_CHECK=1` (silent, no network). + * Surfaces via `systemMessage` (shown to you), not stdout context — it adds nothing to the model's + context. + +Lives next to update.py / medsci_txn.py in ~/.medsci-skills/updater/ and reuses their cached, +clock-sane version check. +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import medsci_txn # noqa: E402 +import update # noqa: E402 + +HOOK_HTTP_TIMEOUT = 4 # keep SessionStart snappy; only the rare cache-miss path waits at all + + +def main() -> int: + # Never read stdin (privacy). Never raise. Worst case: print nothing. + if os.environ.get("MEDSCI_NO_UPDATE_CHECK"): + return 0 + try: + home = medsci_txn.state_home() + inst = update.installed_version(home) + inst_v = update.parse_semver(inst) + if inst_v is None: + return 0 # not installed via the transactional installer -> say nothing + + tag = update._cache_fresh(home) # clock-sane 24h cache (shared with --check-update) + if tag is None: + try: + tag = update.resolve_latest_tag( + lambda u: update._real_get_json(u, timeout=HOOK_HTTP_TIMEOUT) + ) + update._cache_store(home, tag) + except Exception: # noqa: BLE001 - offline / slow / API error -> stay silent, never block + return 0 + + latest_v = update.parse_semver(tag[1:] if tag.startswith("v") else tag) + if latest_v and latest_v > inst_v: + msg = ( + f"MedSci Skills update available — installed {inst}, latest {tag}. " + f"Run the updater in ~/.medsci-skills/updater/ (or the 'Update MedSci Skills' Desktop " + f"launcher) to update. Set MEDSCI_NO_UPDATE_CHECK=1 to silence this notice." + ) + print(json.dumps({"systemMessage": msg, "suppressOutput": True})) + except Exception: # noqa: BLE001 - a notifier must never disrupt a session + return 0 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/installers/tests/test_session_hook.py b/installers/tests/test_session_hook.py new file mode 100644 index 0000000..5fc0916 --- /dev/null +++ b/installers/tests/test_session_hook.py @@ -0,0 +1,352 @@ +#!/usr/bin/env python3 +"""Opt-in SessionStart update-notify hook tests (PR-3, Increment 2). + +Covers (offline, network mocked): + * register/unregister settings.json MERGE — create, idempotent (no duplicate), preserve foreign + hooks/settings, remove-only-ours (incl. mixed entries), empty-container cleanup, refuse-on-malformed. + * the hook script itself — cache-first (no network when fresh), network-miss path, silent on + same-version / unknown-install / MEDSCI_NO_UPDATE_CHECK / network-error; systemMessage format; + and a subprocess smoke test proving it never reads stdin and emits valid JSON. +""" +from __future__ import annotations + +import contextlib +import io +import json +import os +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +INSTALLERS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(INSTALLERS)) +import medsci_txn # noqa: E402 +import update # noqa: E402 + +HOOK_SCRIPT = INSTALLERS / "session_update_check.py" + +passed = 0 +failed = 0 + + +def check(cond: bool, msg: str) -> None: + global passed, failed + if cond: + passed += 1 + print(f" PASS {msg}") + else: + failed += 1 + print(f" FAIL {msg}") + + +def _settings(tmp: Path) -> Path: + return tmp / ".claude" / "settings.json" + + +def _read(p: Path): + return json.loads(p.read_text(encoding="utf-8")) + + +def _count_ours(p: Path) -> int: + ss = _read(p).get("hooks", {}).get("SessionStart", []) + return sum( + 1 + for e in ss + if isinstance(e, dict) + for h in (e.get("hooks") or []) + if isinstance(h, dict) and "session_update_check.py" in (h.get("command") or "") + ) + + +# ----------------------------------------------------------------- settings merge + +def test_settings_merge() -> None: + print("settings.json register/unregister:") + with tempfile.TemporaryDirectory(prefix="medsci-hook-") as t: + tmp = Path(t) + home = tmp / "state" + sp = _settings(tmp) + + # S1 create on absent file + r = update.register_session_hook(home, sp) + check(r == "enabled", "enable on absent settings.json -> enabled") + check(sp.is_file(), "settings.json created") + check(_count_ours(sp) == 1, "exactly one of our hooks present") + cmd = _read(sp)["hooks"]["SessionStart"][0]["hooks"][0]["command"] + check("session_update_check.py" in cmd and sys.executable in cmd, "command has interpreter + script path") + + # S2 idempotent + r = update.register_session_hook(home, sp) + check(r == "already-enabled" and _count_ours(sp) == 1, "re-enable is idempotent (no duplicate)") + + # S5 disable -> removes, empty cleanup (S7) + r = update.unregister_session_hook(home, sp) + check(r == "disabled", "disable -> disabled") + check("hooks" not in _read(sp), "emptied 'hooks' container removed") + check(update.unregister_session_hook(home, sp) == "not-enabled", "second disable -> not-enabled") + + with tempfile.TemporaryDirectory(prefix="medsci-hook-") as t: + tmp = Path(t) + home = tmp / "state" + sp = _settings(tmp) + sp.parent.mkdir(parents=True) + # S3 preserve foreign settings + foreign hooks + sp.write_text(json.dumps({ + "model": "some-model", + "hooks": { + "PreToolUse": [{"hooks": [{"type": "command", "command": "echo pre"}]}], + "SessionStart": [{"hooks": [{"type": "command", "command": "echo foreign-start"}]}], + }, + }), encoding="utf-8") + update.register_session_hook(home, sp) + s = _read(sp) + check(s["model"] == "some-model", "unrelated 'model' setting preserved") + check(len(s["hooks"]["PreToolUse"]) == 1, "foreign PreToolUse hook preserved") + cmds = [h["command"] for e in s["hooks"]["SessionStart"] for h in e["hooks"]] + check("echo foreign-start" in cmds and _count_ours(sp) == 1, "foreign SessionStart hook preserved + ours added") + + # S4 disable preserves foreign + update.unregister_session_hook(home, sp) + s = _read(sp) + check(s["model"] == "some-model" and len(s["hooks"]["PreToolUse"]) == 1, "disable preserves foreign settings/hooks") + cmds = [h["command"] for e in s["hooks"]["SessionStart"] for h in e["hooks"]] + check("echo foreign-start" in cmds and _count_ours(sp) == 0, "disable kept foreign SessionStart, removed ours") + + # S6 mixed entry: ours shares an entry with a foreign hook + with tempfile.TemporaryDirectory(prefix="medsci-hook-") as t: + tmp = Path(t) + home = tmp / "state" + sp = _settings(tmp) + sp.parent.mkdir(parents=True) + sp.write_text(json.dumps({"hooks": {"SessionStart": [ + {"hooks": [ + {"type": "command", "command": "echo foreign"}, + {"type": "command", "command": f'"{sys.executable}" "{home / "updater" / "session_update_check.py"}"'}, + ]}, + ]}}), encoding="utf-8") + r = update.unregister_session_hook(home, sp) + s = _read(sp) + remaining = [h["command"] for e in s["hooks"]["SessionStart"] for h in e["hooks"]] + check(r == "disabled" and remaining == ["echo foreign"], "mixed entry: only ours removed, foreign kept") + + # S8 refuse to clobber malformed settings.json + with tempfile.TemporaryDirectory(prefix="medsci-hook-") as t: + tmp = Path(t) + home = tmp / "state" + sp = _settings(tmp) + sp.parent.mkdir(parents=True) + sp.write_text("[]", encoding="utf-8") # a JSON array, not an object + raised = False + try: + update.register_session_hook(home, sp) + except update.UpdateError: + raised = True + check(raised and sp.read_text() == "[]", "register refuses + leaves a malformed settings.json untouched") + + +# ----------------------------------------------------------------- hook script logic + +@contextlib.contextmanager +def _state(installed: str | None, cache_tag: str | None): + """A temp MEDSCI_HOME with an installed version + optional fresh cache; restores env.""" + prev_home = os.environ.get("MEDSCI_HOME") + prev_no = os.environ.get("MEDSCI_NO_UPDATE_CHECK") + os.environ.pop("MEDSCI_NO_UPDATE_CHECK", None) + with tempfile.TemporaryDirectory(prefix="medsci-hookrun-") as t: + home = Path(t) / "state" + os.environ["MEDSCI_HOME"] = str(home) + if installed is not None: + sd = home / "targets" / "claude" + sd.mkdir(parents=True) + (sd / "state.json").write_text(json.dumps({"installed_version": installed}), encoding="utf-8") + if cache_tag is not None: + home.mkdir(parents=True, exist_ok=True) + (home / "update_check.json").write_text( + json.dumps({"checked_at": time.time(), "latest_tag": cache_tag}), encoding="utf-8") + try: + yield home + finally: + for k, v in (("MEDSCI_HOME", prev_home), ("MEDSCI_NO_UPDATE_CHECK", prev_no)): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + +def _run_hook_inproc() -> str: + """Import the hook fresh and run main(), capturing stdout.""" + sys.path.insert(0, str(INSTALLERS)) + import importlib + mod = importlib.import_module("session_update_check") + importlib.reload(mod) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + mod.main() + return buf.getvalue() + + +def test_hook_logic() -> None: + print("hook script logic:") + orig_resolve = update.resolve_latest_tag + + # H1 newer via FRESH CACHE -> message, and NO network (resolve raises if called) + update.resolve_latest_tag = lambda gj: (_ for _ in ()).throw(AssertionError("network used despite fresh cache")) + try: + with _state(installed="1.0.0", cache_tag="v2.0.0"): + out = _run_hook_inproc() + ok = '"systemMessage"' in out and "update available" in out and "2.0.0" in out + check(ok, "fresh cache + newer -> systemMessage, no network") + check(json.loads(out).get("suppressOutput") is True, "output is a single valid JSON object") + finally: + update.resolve_latest_tag = orig_resolve + + # H2 same version -> silent + with _state(installed="2.0.0", cache_tag="v2.0.0"): + check(_run_hook_inproc().strip() == "", "same version -> no output") + + # H3 unknown install -> silent + with _state(installed=None, cache_tag="v2.0.0"): + check(_run_hook_inproc().strip() == "", "unknown install -> no output") + + # H4 MEDSCI_NO_UPDATE_CHECK -> silent even with newer cache + with _state(installed="1.0.0", cache_tag="v2.0.0"): + os.environ["MEDSCI_NO_UPDATE_CHECK"] = "1" + check(_run_hook_inproc().strip() == "", "MEDSCI_NO_UPDATE_CHECK=1 -> no output") + os.environ.pop("MEDSCI_NO_UPDATE_CHECK", None) + + # H5 cache miss + network newer -> message + cache stored + update.resolve_latest_tag = lambda gj: "v3.0.0" + try: + with _state(installed="1.0.0", cache_tag=None) as home: + out = _run_hook_inproc() + check("3.0.0" in out and "systemMessage" in out, "cache miss + network newer -> message") + check((home / "update_check.json").is_file(), "network result cached for next time") + finally: + update.resolve_latest_tag = orig_resolve + + # H6 cache miss + network error -> silent, no crash + update.resolve_latest_tag = lambda gj: (_ for _ in ()).throw(update.UpdateError("offline")) + try: + with _state(installed="1.0.0", cache_tag=None): + check(_run_hook_inproc().strip() == "", "cache miss + network error -> silent") + finally: + update.resolve_latest_tag = orig_resolve + + +def test_hook_subprocess_smoke() -> None: + print("hook script subprocess smoke (no stdin read, valid JSON):") + with _state(installed="1.0.0", cache_tag="v9.9.9"): + env = dict(os.environ) + # stdin is a pipe we never write to: if the hook read stdin it would hang; timeout guards that. + proc = subprocess.run( + [sys.executable, str(HOOK_SCRIPT)], + stdin=subprocess.PIPE, capture_output=True, text=True, env=env, timeout=30, + ) + check(proc.returncode == 0, "exit 0") + check('"systemMessage"' in proc.stdout and "9.9.9" in proc.stdout, "emits systemMessage for newer version") + try: + json.loads(proc.stdout) + valid = True + except Exception: + valid = False + check(valid, "stdout is a single valid JSON object") + + +def _all_commands(p: Path) -> list: + ss = _read(p).get("hooks", {}).get("SessionStart", []) + return [h.get("command") for e in ss if isinstance(e, dict) + for h in (e.get("hooks") or []) if isinstance(h, dict)] + + +def test_matcher_precision() -> None: + """The matcher must key on the home-anchored script path, not the bare filename, so it never + touches an unrelated foreign hook nor a hook pointing at a different MEDSCI_HOME.""" + print("matcher precision (no false match on substring / cross-home):") + with tempfile.TemporaryDirectory(prefix="medsci-hook-") as t: + tmp = Path(t) + home = tmp / "state" + other_home = tmp / "other-state" + sp = _settings(tmp) + sp.parent.mkdir(parents=True) + # Two decoys: (a) a foreign command that merely CONTAINS the bare filename substring; + # (b) a canonical-format command pointing at a DIFFERENT updater home. + wrapper = f'"{sys.executable}" "/opt/tools/run_session_update_check.py_wrapper" --foo' + otherhome = f'"{sys.executable}" "{other_home / "updater" / "session_update_check.py"}"' + sp.write_text(json.dumps({"hooks": {"SessionStart": [ + {"hooks": [{"type": "command", "command": wrapper}]}, + {"hooks": [{"type": "command", "command": otherhome}]}, + ]}}), encoding="utf-8") + + r = update.register_session_hook(home, sp) + check(r == "enabled", "enable ADDS ours despite a substring-colliding foreign hook") + cmds = _all_commands(sp) + ours = update.session_hook_command(home) + check(wrapper in cmds and otherhome in cmds and ours in cmds, "both decoys preserved + ours added") + + r = update.unregister_session_hook(home, sp) + cmds = _all_commands(sp) + check(r == "disabled", "disable removes ours") + check(ours not in cmds, "ours removed") + check(wrapper in cmds and otherhome in cmds, "disable did NOT delete the foreign / other-home hooks") + + +def test_resolve_latest_tag() -> None: + print("resolve_latest_tag draft/prerelease guard:") + check(update.resolve_latest_tag(lambda u: {"tag_name": "v4.7.0", "assets": []}) == "v4.7.0", + "returns tag with no asset/digest required (works on any OS)") + for bad, label in (({"tag_name": "v9.9.9", "draft": True}, "draft"), + ({"tag_name": "v9.9.9", "prerelease": True}, "prerelease")): + raised = False + try: + update.resolve_latest_tag(lambda u: bad) + except update.UpdateError: + raised = True + check(raised, f"rejects a {label} release") + raised = False + try: + update.resolve_latest_tag(lambda u: {"assets": []}) # no tag_name + except update.UpdateError: + raised = True + check(raised, "rejects a release with no tag_name") + + # The hook must use a short timeout on the (rare) cache-miss network call. + orig = update._real_get_json + captured: dict = {} + update._real_get_json = lambda url, timeout=None: (captured.update(timeout=timeout) or {"tag_name": "v3.0.0"}) + try: + with _state(installed="1.0.0", cache_tag=None): + _run_hook_inproc() + check(captured.get("timeout") == 4, "hook's cache-miss GET uses a 4s timeout") + finally: + update._real_get_json = orig + + +def test_mode_preserved() -> None: + if os.name == "nt": + print("settings.json mode preservation: skipped on Windows") + return + print("settings.json mode preservation:") + with tempfile.TemporaryDirectory(prefix="medsci-hook-") as t: + tmp = Path(t) + home = tmp / "state" + sp = _settings(tmp) + sp.parent.mkdir(parents=True) + sp.write_text("{}", encoding="utf-8") + os.chmod(sp, 0o600) + update.register_session_hook(home, sp) + check((os.stat(sp).st_mode & 0o777) == 0o600, "0600 preserved across register") + update.unregister_session_hook(home, sp) + check((os.stat(sp).st_mode & 0o777) == 0o600, "0600 preserved across unregister") + + +if __name__ == "__main__": + test_settings_merge() + test_matcher_precision() + test_resolve_latest_tag() + test_mode_preserved() + test_hook_logic() + test_hook_subprocess_smoke() + print(f"\ntest_session_hook: {passed} passed, {failed} failed") + sys.exit(1 if failed else 0) diff --git a/installers/update.py b/installers/update.py index 6f8524d..79f5495 100644 --- a/installers/update.py +++ b/installers/update.py @@ -65,10 +65,10 @@ class UpdateError(Exception): # ----------------------------------------------------------------- network (injectable) -def _real_get_json(url: str): +def _real_get_json(url: str, timeout: int = HTTP_TIMEOUT): import urllib.request req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "application/vnd.github+json"}) - with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as r: # noqa: S310 (https only, fixed host) + with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310 (https only, fixed host) return json.loads(r.read().decode("utf-8")) @@ -122,6 +122,18 @@ def resolve_latest(get_json, asset_name: str) -> dict: return {"tag": tag, "asset_name": asset_name, "url": url, "sha256": digest.split(":", 1)[1]} +def resolve_latest_tag(get_json) -> str: + """Return just the latest non-draft/non-prerelease tag — for an availability CHECK only + (installs nothing). Unlike resolve_latest it needs no OS-specific asset/digest, so the + notifier works on every OS (a Linux user who installed via npm/git can still be told a newer + version exists, even though the classroom ZIP is macOS/Windows only).""" + rel = get_json(API_LATEST) + _require(not rel.get("draft") and not rel.get("prerelease"), "latest release is a draft/prerelease") + tag = rel.get("tag_name") or "" + _require(bool(tag), "release has no tag_name") + return tag + + # ----------------------------------------------------------------- verify + safe-extract def sha256_bytes(b: bytes) -> str: @@ -386,7 +398,8 @@ def install_updater_home(source_root: Path, home: Path, log, desktop: bool = Fal shutil.rmtree(staging, ignore_errors=True) staging.mkdir(parents=True, exist_ok=True) src = source_root / "installers" - for name in ("update.py", "medsci_txn.py", "update-macos.command", "update-windows.cmd"): + for name in ("update.py", "medsci_txn.py", "update-macos.command", "update-windows.cmd", + SESSION_HOOK_SCRIPT): s = src / name if s.is_file(): shutil.copy2(s, staging / name) @@ -420,6 +433,125 @@ def _place_desktop_launcher(udir: Path, log) -> None: log(f"could not place Desktop launcher ({exc})") +# ----------------------------------------------------------------- opt-in SessionStart notify hook + +SESSION_HOOK_SCRIPT = "session_update_check.py" + + +def default_settings_path() -> Path: + """~/.claude/settings.json (Claude Code), overridable via MEDSCI_CLAUDE_SETTINGS for tests.""" + override = os.environ.get("MEDSCI_CLAUDE_SETTINGS") + return Path(override) if override else Path.home() / ".claude" / "settings.json" + + +def session_hook_command(home: Path) -> str: + """Absolute interpreter + absolute script path (quoted) so the hook runs cross-platform.""" + return f'"{sys.executable}" "{home / "updater" / SESSION_HOOK_SCRIPT}"' + + +def _load_settings(settings_path: Path): + """Parse settings.json. Absent/empty/`null` -> {}. A non-dict JSON value is returned as-is so the + caller can refuse it. Raises UpdateError only on genuinely unreadable/invalid JSON (never `[] or {}` + style collapsing, which would silently treat a JSON array as empty and clobber it).""" + if not settings_path.is_file(): + return {} + raw = settings_path.read_text(encoding="utf-8") + if not raw.strip(): + return {} + try: + obj = json.loads(raw) + except (OSError, json.JSONDecodeError) as exc: + raise UpdateError(f"cannot parse {settings_path}: {exc}; leaving it unchanged") + return {} if obj is None else obj + + +def _managed_hook_path(home: Path) -> str: + """The home-anchored script path our hook command embeds. Matching on THIS (not the bare + filename) is precise: it cannot collide with an unrelated user hook whose command merely + contains the substring 'session_update_check.py' (e.g. '.../run_session_update_check.py_wrapper'), + nor with a hook pointing at a DIFFERENT MEDSCI_HOME, and it survives the interpreter path changing + between enable and disable (we match the script, not the python).""" + return str(home / "updater" / SESSION_HOOK_SCRIPT) + + +def _hook_is_ours(h: object, home: Path) -> bool: + return (isinstance(h, dict) and isinstance(h.get("command"), str) + and _managed_hook_path(home) in h["command"]) + + +def _entry_owns_hook(entry: object, home: Path) -> bool: + return (isinstance(entry, dict) and isinstance(entry.get("hooks"), list) + and any(_hook_is_ours(h, home) for h in entry["hooks"])) + + +def _write_settings(settings_path: Path, settings: dict) -> None: + """Atomically write settings.json, preserving the destination's file mode if it already exists + (so a user who restricted it, e.g. chmod 600, does not get it silently widened to umask default).""" + prev_mode = os.stat(settings_path).st_mode & 0o777 if settings_path.is_file() else None + settings_path.parent.mkdir(parents=True, exist_ok=True) + medsci_txn.atomic_write_json(settings_path, settings) + if prev_mode is not None: + try: + os.chmod(settings_path, prev_mode) + except OSError: + pass + + +def register_session_hook(home: Path, settings_path: Path) -> str: + """Opt-in: MERGE a SessionStart update-notify hook into settings.json. Idempotent (no duplicate); + preserves every existing hook/setting. Returns 'enabled' or 'already-enabled'. Refuses (raises) + rather than clobber a settings.json it cannot parse or whose shape is unexpected.""" + settings = _load_settings(settings_path) # {} for absent/empty/null + if not isinstance(settings, dict): + raise UpdateError(f"{settings_path} is not a JSON object; leaving it unchanged") + hooks = settings.setdefault("hooks", {}) + if not isinstance(hooks, dict): + raise UpdateError(f"{settings_path} 'hooks' is not an object; leaving it unchanged") + ss = hooks.setdefault("SessionStart", []) + if not isinstance(ss, list): + raise UpdateError(f"{settings_path} 'hooks.SessionStart' is not a list; leaving it unchanged") + if any(_entry_owns_hook(e, home) for e in ss): + return "already-enabled" + ss.append({"hooks": [{"type": "command", "command": session_hook_command(home), "timeout": 10}]}) + _write_settings(settings_path, settings) + return "enabled" + + +def unregister_session_hook(home: Path, settings_path: Path) -> str: + """Opt-out: remove ONLY our SessionStart hook (even if it shares an entry with other hooks), + preserving everything else; drop emptied containers. Returns 'disabled' or 'not-enabled'.""" + if not settings_path.is_file(): + return "not-enabled" + settings = _load_settings(settings_path) # {} for empty/null; raises only if unreadable JSON + if not isinstance(settings, dict) or not isinstance(settings.get("hooks"), dict): + return "not-enabled" + hooks = settings["hooks"] + ss = hooks.get("SessionStart") + if not isinstance(ss, list): + return "not-enabled" + changed = False + new_ss: list = [] + for e in ss: + if isinstance(e, dict) and isinstance(e.get("hooks"), list): + kept = [h for h in e["hooks"] if not _hook_is_ours(h, home)] + if len(kept) != len(e["hooks"]): + changed = True + if not kept: + continue # drop a now-empty entry instead of leaving {"hooks": []} + e = {**e, "hooks": kept} + new_ss.append(e) + if not changed: + return "not-enabled" + if new_ss: + hooks["SessionStart"] = new_ss + else: + hooks.pop("SessionStart", None) + if not hooks: + settings.pop("hooks", None) + _write_settings(settings_path, settings) + return "disabled" + + # ----------------------------------------------------------------- cli def main(argv=None) -> int: diff --git a/metadata/distribution_files.json b/metadata/distribution_files.json index 52ac53f..b7f6d02 100644 --- a/metadata/distribution_files.json +++ b/metadata/distribution_files.json @@ -23,14 +23,19 @@ }, { "path": "installers/install.py", - "size": 11602, - "sha256": "7cf85ce72b0476381e6d4e93719e87a90d9cecd223c24837fb8bfca7b2dd81f2" + "size": 13319, + "sha256": "9467880b2efdc5c71ed280aa1e17e4c785fe7d80078c3666d5047fb9ac267c89" }, { "path": "installers/medsci_txn.py", "size": 14829, "sha256": "0cb950a98ffcf027c8fa090e1352f7d3052e928934bc2f3a21ce420b631866d0" }, + { + "path": "installers/session_update_check.py", + "size": 2944, + "sha256": "42f28af5ff7a7ae495e8f6994fd228bfaa37bdb92cb337e7f9be876dee3974c3" + }, { "path": "installers/update-macos.command", "size": 535, @@ -43,8 +48,8 @@ }, { "path": "installers/update.py", - "size": 19308, - "sha256": "9d8baaf4cddc189241a0e8387a4cd8790c634f373855818d26624331b17d0dbe" + "size": 25500, + "sha256": "6a632a88617889a1ac36418822b8af3f2bcab75bfa28169e99ae4fdf0b810365" }, { "path": "skills/academic-aio/SKILL.md", diff --git a/package.json b/package.json index b20b580..e6a02d7 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "installers/install.py", "installers/medsci_txn.py", "installers/update.py", + "installers/session_update_check.py", "installers/install-macos.command", "installers/install-windows.cmd", "installers/install-windows.ps1",