From 05bd636728ad0b4b26e18b5da45af8f3ed9a4e93 Mon Sep 17 00:00:00 2001 From: growlf Date: Tue, 13 Oct 2026 16:24:09 -0700 Subject: [PATCH 1/3] 444: plan for opencode upgrade check + launch handoff --- plans/444-opencode-upgrade-check.md | 94 +++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 plans/444-opencode-upgrade-check.md diff --git a/plans/444-opencode-upgrade-check.md b/plans/444-opencode-upgrade-check.md new file mode 100644 index 0000000..c730758 --- /dev/null +++ b/plans/444-opencode-upgrade-check.md @@ -0,0 +1,94 @@ +# #444 — opskit go: pre-launch opencode upgrade check + launch handoff message + +## Goal +Make `opskit go` (a) tell the operator when it hands off to opencode, and +(b) if online, check whether the installed opencode needs an upgrade and offer +to run it *before* launching — so a stale/broken opencode build can't silently +break the launched session. + +## Scope +Two bundled changes, one PR: +1. **Launch handoff message** — one flushed line immediately before the final + `os.execvpe("opencode", ...)` in `launch_project`. +2. **Upgrade check + offer** — pre-launch, daily-cached, tty-only check. + +Out of scope: auto-upgrading non-interactively; touching the published-vs-dev +release feeds beyond reading the dev channel; changing opencode itself. + +## Research +- Installed: `~/.opencode/bin/opencode` → `opencode v2.0.28`; + `~/.opencode/bin/opencode2` is a 47-byte shim. +- Channel = `anomalyco/opencode` **dev**: repo `dev` branch `package.json` = + `2.0.28`, tag `v2.0.28`. (Repo `fork:false`, default branch `dev`.) +- Published latest = `v1.19.0` (GitHub releases *and* npm `opencode-ai`) — LOWER + than installed. Comparing to it would wrongly say "up to date"/downgrade. +- `opencode upgrade`/`update` has no `--check`/`--dry-run`; it self-reports + ("already on the latest version" vs "Upgraded."). No cached latest-version + file; no plaintext update endpoint in the binary. +- The multi-minute gap after selection is opencode's own background-server + start (after exec), not opskit's pre-exec steps (skills setup + BW session are + each ≤30s). + +## Decisions +- **Detect "needed":** query the dev channel (highest `vX.Y.Z` tag via + `git ls-remote --tags`) → used for display **and** the offer. +- **Act:** run `opencode upgrade` (idempotent; opencode is its own oracle). +- **Cache:** store in `~/.opskit/`, re-check at most ~once/24h. +- **Interactive only:** prompt only on a tty; never auto-upgrade otherwise. + +## Design + +**New `opskit/_opencode.py`** (pure, testable helpers): +- `parse_version(text) -> (int,int,int) | None` — regex `v?(\d+)\.(\d+)\.(\d+)`. +- `is_newer(latest, installed) -> bool` — tuple compare; ignore `-suffix`/`^{}`. +- `parse_latest_tag(ls_remote_output) -> str | None` — accept only + `^refs/tags/v(\d+\.\d+\.\d+)$` (ignore `vscode-*`, peeled `^{}`); return max. +- `get_installed_version(runner=subprocess.run) -> str | None`. +- `get_latest_dev_version(repo, runner=subprocess.run) -> str | None` — + `git ls-remote --tags https://github.com/`, timeout=10s. +- `DEFAULT_OPENCODE_REPO = "anomalyco/opencode"`; env override + `OPSKIT_OPENCODE_REPO`. + +**Edit `opskit/_cache.py`:** +- `OPENCODE_CHECK_FILE = CACHE_DIR / "opencode-version-check.json"`. +- `read_opencode_check()` / `write_opencode_check(data)` (atomic, existing style). +- `opencode_check_is_stale(data, installed)` — stale if missing, older than + `CACHE_STALE_HOURS`, or `data["installed"] != installed`. + +**Edit `bin/opskit-go.py`:** +- `maybe_offer_opencode_upgrade()` (tty-only; cache; fetch; compare; prompt; + run `opencode upgrade`; re-read version). +- Call it at the start of `main()`. +- In `launch_project`, print + `Now starting interactive OpenCode shell...` (flushed) before `execvpe`. + +**New `tests/test_opencode_upgrade.py`** (TDD, written first). + +## Task list +1. Write `tests/test_opencode_upgrade.py` (failing) — commit first. +2. Add `opskit/_opencode.py` helpers. +3. Add cache fns to `opskit/_cache.py`. +4. Wire `maybe_offer_opencode_upgrade()` + handoff message into `bin/opskit-go.py`. +5. `make test` green. +6. Plan → `plans/444-...md` + issue #444; PR; review; merge. + +## Critique +- **SECURITY — upgrading can restart the running opencode service/session.** + A nested `opskit go` from inside opencode could kill the caller's session. + *Resolution:* prompt names the restart; tty-only; decline default ([y/N]); + non-tty never upgrades. +- **SECURITY — network/secret exposure:** `git ls-remote` is read-only, + unauthenticated, touches no secrets; nothing client-identifying is printed. +- **APPROPRIATENESS — source drift:** our tag query may not exactly match the + channel `opencode upgrade` uses. *Resolution:* the action is + `opencode upgrade` (authoritative); a wrong query can at worst mis-offer or + miss an offer, never apply a wrong action. +- **APPROPRIATENESS — channel mismatch:** max-semver could offer a major jump. + *Resolution:* operator wants standard *and* major upgrades; prompt is explicit + and declinable; repo is env-overridable. +- **STABILITY — hanging launch:** `timeout=10`, exceptions swallowed, 24h cache. +- **STABILITY — after-upgrade state:** cache invalidated when installed + differs; version re-read after upgrade. +- **MAINTAINABILITY:** pure parsing helpers; cache I/O reuses `_cache.py`. +- **EFFECTIVENESS/VALUE:** removes the silent-stale-opencode footgun and the + unexplained silent gap at handoff. From 685d7e55771a848da11a299d4a150a9bbe6f3d01 Mon Sep 17 00:00:00 2001 From: growlf Date: Tue, 13 Oct 2026 16:24:09 -0700 Subject: [PATCH 2/3] 444: tests for opencode upgrade check + launch handoff --- tests/test_opencode_upgrade.py | 232 +++++++++++++++++++++++++++++++++ 1 file changed, 232 insertions(+) create mode 100644 tests/test_opencode_upgrade.py diff --git a/tests/test_opencode_upgrade.py b/tests/test_opencode_upgrade.py new file mode 100644 index 0000000..1ec47aa --- /dev/null +++ b/tests/test_opencode_upgrade.py @@ -0,0 +1,232 @@ +"""Tests for the opskit go pre-launch opencode upgrade check (issue #444). + +Loads the real bin/opskit-go.py as a module so tests hit the shipped code, +and imports opskit._opencode / opskit._cache directly. +""" + +import importlib.util +import sys +import types +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(_ROOT)) + +from opskit import _cache, _opencode + + +def _load_go_module(): + spec = importlib.util.spec_from_file_location( + "opskit_go_upgrade", _ROOT / "bin" / "opskit-go.py" + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_go = _load_go_module() + + +class _Result: + def __init__(self, stdout="", stderr="", returncode=0): + self.stdout = stdout + self.stderr = stderr + self.returncode = returncode + + +LS_REMOTE = ( + "aaaa\trefs/tags/v2.0.27\n" + "bbbb\trefs/tags/v2.0.28\n" + "cccc\trefs/tags/v2.0.28^{}\n" + "dddd\trefs/tags/vscode-v1.0.0\n" + "eeee\trefs/tags/v2.1.0\n" + "ffff\trefs/tags/not-a-version\n" +) + + +def _raise(*_a, **_k): + raise AssertionError("should not be called") + + +class TestVersionParsing: + def test_parse_opencode_version_banner(self): + assert _opencode.parse_version("opencode v2.0.28") == (2, 0, 28) + + def test_parse_version_missing_returns_none(self): + assert _opencode.parse_version("") is None + assert _opencode.parse_version("no version here") is None + + @pytest.mark.parametrize( + "latest,installed,expected", + [ + ("2.1.0", "2.0.28", True), + ("2.0.28", "2.0.28", False), + ("1.19.0", "2.0.28", False), + ], + ) + def test_is_newer(self, latest, installed, expected): + assert _opencode.is_newer(latest, installed) is expected + + +class TestTagParsing: + def test_picks_highest_semver_tag(self): + assert _opencode.parse_latest_tag(LS_REMOTE) == "2.1.0" + + def test_ignores_vscode_and_peeled_tags(self): + out = "x\trefs/tags/vscode-v9.9.9\ny\trefs/tags/v1.2.3^{}\n" + assert _opencode.parse_latest_tag(out) is None + + def test_empty_output_returns_none(self): + assert _opencode.parse_latest_tag("") is None + + +class TestInstalledVersion: + def test_reads_opencode_version(self): + runner = lambda *a, **k: _Result(stdout="opencode v2.0.28\n") + assert _opencode.get_installed_version(runner=runner) == "2.0.28" + + def test_missing_opencode_returns_none(self): + def boom(*a, **k): + raise FileNotFoundError + + assert _opencode.get_installed_version(runner=boom) is None + + +class TestLatestDevVersion: + def test_uses_git_ls_remote(self): + seen = {} + + def runner(argv, *a, **k): + seen["argv"] = argv + return _Result(stdout=LS_REMOTE) + + assert _opencode.get_latest_dev_version(repo="o/r", runner=runner) == "2.1.0" + assert seen["argv"][:3] == ["git", "ls-remote", "--tags"] + + def test_offline_returns_none(self): + def boom(*a, **k): + raise OSError + + assert _opencode.get_latest_dev_version(runner=boom) is None + + +class TestCheckCache: + def test_roundtrip_and_staleness(self, tmp_path, monkeypatch): + monkeypatch.setattr(_cache, "OPENCODE_CHECK_FILE", tmp_path / "check.json") + assert _cache.read_opencode_check() is None + + data = { + "checked_at": datetime.now(timezone.utc).isoformat(), + "installed": "2.0.28", + "latest": "2.1.0", + } + _cache.write_opencode_check(data) + assert _cache.read_opencode_check()["latest"] == "2.1.0" + assert not _cache.opencode_check_is_stale(data, "2.0.28") + + def test_stale_when_installed_changed(self): + data = { + "checked_at": datetime.now(timezone.utc).isoformat(), + "installed": "2.0.28", + "latest": "2.1.0", + } + assert _cache.opencode_check_is_stale(data, "2.0.29") + + def test_stale_when_old_or_missing(self): + old = { + "checked_at": "2000-01-01T00:00:00+00:00", + "installed": "2.0.28", + "latest": "2.1.0", + } + assert _cache.opencode_check_is_stale(old, "2.0.28") + assert _cache.opencode_check_is_stale(None, "2.0.28") + + +def _wire(monkeypatch, *, tty=True, installed="2.0.28", cache=None, stale=True, + answer="n"): + """Patch the go module's collaborators for maybe_offer_opencode_upgrade.""" + monkeypatch.setattr(_go.sys, "stdin", types.SimpleNamespace(isatty=lambda: tty)) + monkeypatch.setattr(_go, "get_installed_version", lambda: installed) + monkeypatch.setattr(_go, "read_opencode_check", lambda: cache) + monkeypatch.setattr(_go, "opencode_check_is_stale", lambda d, i: stale) + monkeypatch.setattr(_go, "write_opencode_check", lambda d: None) + monkeypatch.setattr("builtins.input", lambda *a: answer) + + calls = {} + + def fake_run(argv, *a, **k): + calls["argv"] = argv + return _Result(returncode=0) + + monkeypatch.setattr(_go.subprocess, "run", fake_run) + return calls + + +class TestMaybeOfferUpgrade: + def test_non_tty_skips_everything(self, monkeypatch): + net = {"called": False} + + def latest(*a, **k): + net["called"] = True + return "2.1.0" + + _wire(monkeypatch, tty=False) + monkeypatch.setattr(_go, "get_latest_dev_version", latest) + monkeypatch.setattr("builtins.input", _raise) + _go.maybe_offer_opencode_upgrade() + assert net["called"] is False + + def test_offline_is_silent(self, monkeypatch, capsys): + _wire(monkeypatch, cache=None, stale=True) + monkeypatch.setattr(_go, "get_latest_dev_version", lambda *a, **k: None) + monkeypatch.setattr("builtins.input", _raise) + _go.maybe_offer_opencode_upgrade() + assert "available" not in capsys.readouterr().out + + def test_fresh_cache_skips_network(self, monkeypatch): + cache = { + "checked_at": "2020-01-01T00:00:00+00:00", + "installed": "2.0.28", + "latest": "2.1.0", + } + calls = _wire(monkeypatch, cache=cache, stale=False, answer="n") + monkeypatch.setattr(_go, "get_latest_dev_version", _raise) + _go.maybe_offer_opencode_upgrade() + assert "argv" not in calls + + def test_no_newer_version_does_not_prompt(self, monkeypatch): + _wire(monkeypatch, cache=None, stale=True) + monkeypatch.setattr(_go, "get_latest_dev_version", lambda *a, **k: "2.0.28") + monkeypatch.setattr("builtins.input", _raise) + _go.maybe_offer_opencode_upgrade() + + def test_accept_runs_opencode_upgrade(self, monkeypatch): + calls = _wire(monkeypatch, cache=None, stale=True, answer="y") + monkeypatch.setattr(_go, "get_latest_dev_version", lambda *a, **k: "2.1.0") + _go.maybe_offer_opencode_upgrade() + assert calls["argv"] == ["opencode", "upgrade"] + + def test_decline_does_not_upgrade(self, monkeypatch): + calls = _wire(monkeypatch, cache=None, stale=True, answer="n") + monkeypatch.setattr(_go, "get_latest_dev_version", lambda *a, **k: "2.1.0") + _go.maybe_offer_opencode_upgrade() + assert "argv" not in calls + + +class TestLaunchHandoffMessage: + def test_message_printed_before_exec(self, monkeypatch, capsys): + monkeypatch.setattr(_go, "run_openskill_setup", lambda: None) + monkeypatch.setattr(_go, "get_bw_session", lambda: "") + monkeypatch.setattr(_go, "update_last_used", lambda p: None) + monkeypatch.setattr(_go.os, "chdir", lambda p: None) + + def fake_exec(*a, **k): + raise SystemExit(0) + + monkeypatch.setattr(_go.os, "execvpe", fake_exec) + with pytest.raises(SystemExit): + _go.launch_project("/tmp/project") + assert "Now starting interactive OpenCode shell" in capsys.readouterr().out From 26fc0c3f85f2b8aee14b985e79ed07c01ead9368 Mon Sep 17 00:00:00 2001 From: growlf Date: Tue, 13 Oct 2026 16:27:44 -0700 Subject: [PATCH 3/3] 444: pre-launch opencode upgrade check + launch handoff --- bin/opskit-go.py | 66 +++++++++++++++++++++++++ opskit/_cache.py | 34 +++++++++++++ opskit/_opencode.py | 116 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 opskit/_opencode.py diff --git a/bin/opskit-go.py b/bin/opskit-go.py index 1916e25..f2bab37 100644 --- a/bin/opskit-go.py +++ b/bin/opskit-go.py @@ -30,6 +30,7 @@ import os import subprocess import sys +from datetime import datetime, timezone from pathlib import Path SCRIPT_DIR = Path(__file__).parent.resolve() @@ -44,6 +45,14 @@ update_last_used, get_projects_dir, get_projects, + read_opencode_check, + write_opencode_check, + opencode_check_is_stale, +) +from opskit._opencode import ( + get_installed_version, + get_latest_dev_version, + is_newer, ) @@ -90,6 +99,58 @@ def ensure_enabled(): return bool(get_projects()) +def maybe_offer_opencode_upgrade(): + """Offer to upgrade opencode before launching, when online and interactive. + + Best-effort and non-fatal: offline, no opencode, or a non-tty all skip + silently. The check is cached for CACHE_STALE_HOURS; the actual upgrade is + delegated to ``opencode upgrade`` (opencode is its own oracle). + """ + if not sys.stdin.isatty(): + return + + installed = get_installed_version() + if not installed: + return + + cache = read_opencode_check() + if cache and not opencode_check_is_stale(cache, installed): + latest = cache.get("latest") + else: + latest = get_latest_dev_version() + if latest is None: + return # offline / unavailable — skip silently + write_opencode_check({ + "checked_at": datetime.now(timezone.utc).isoformat(), + "installed": installed, + "latest": latest, + }) + + if not latest or not is_newer(latest, installed): + return + + print( + f"OpenCode {installed} -> {latest} is available " + "(upgrading restarts the opencode service)." + ) + try: + answer = input("Upgrade opencode now? [y/N] ") + except EOFError: + return + if answer.strip().lower() not in ("y", "yes"): + return + + try: + subprocess.run(["opencode", "upgrade"], check=False) + except (FileNotFoundError, OSError): + print("Error: opencode not found on PATH", file=sys.stderr) + return + + new_version = get_installed_version() + if new_version: + print(f"opencode upgraded to {new_version}.") + + def sort_projects_by_last_used(projects): """Sort projects by last-used timestamp (most recent first).""" project_map = {p["path"]: p for p in projects} @@ -292,6 +353,8 @@ def launch_project(project_path): os.chdir(project_path) env["OPSKIT_PROJECT_PATH"] = project_path + print("Now starting interactive OpenCode shell...", flush=True) + try: os.execvpe("opencode", ["opencode"], env) except FileNotFoundError: @@ -345,6 +408,9 @@ def main(): args = parser.parse_args() + # Offer an opencode upgrade first, when online (cached ~daily, tty-only). + maybe_offer_opencode_upgrade() + # First run with no enabled projects: offer to scan for OpsKit members. if not ensure_enabled(): sys.exit(0) diff --git a/opskit/_cache.py b/opskit/_cache.py index c66ecc2..f2b3d4d 100644 --- a/opskit/_cache.py +++ b/opskit/_cache.py @@ -25,6 +25,7 @@ FILE_MODE = 0o600 LAST_DISCOVERY_FILE = CACHE_DIR / "last-discovery.json" LAST_USED_FILE = CACHE_DIR / "last-used.json" +OPENCODE_CHECK_FILE = CACHE_DIR / "opencode-version-check.json" CONFIG_FILE = CACHE_DIR / "config.yml" DEFAULT_PROJECTS_DIR = Path.home() / "Projects" MAX_LAST_USED_ENTRIES = 100 @@ -236,3 +237,36 @@ def get_sort_key(project_path: str): if entry.get("project_path") == project_path: return entry.get("timestamp", "") return "" + + +# ── opencode-version-check.json operations ─────────────────────────────────── + +def read_opencode_check(): + """Read the cached opencode version check. Returns None if not found.""" + return _read_json(OPENCODE_CHECK_FILE) + + +def write_opencode_check(data: dict): + """Write the opencode version check cache atomically.""" + _write_json(OPENCODE_CHECK_FILE, data) + + +def opencode_check_is_stale(data, installed) -> bool: + """Whether the cached opencode check must be refreshed. + + Stale when the record is missing, older than CACHE_STALE_HOURS, or was + recorded against a different installed version (e.g. after an upgrade). + """ + if not data: + return True + if data.get("installed") != installed: + return True + checked_at = data.get("checked_at", "") + try: + checked = datetime.fromisoformat(checked_at) + if checked.tzinfo is None: + checked = checked.replace(tzinfo=timezone.utc) + except (ValueError, TypeError): + return True + age = (datetime.now(timezone.utc) - checked).total_seconds() + return age > CACHE_STALE_HOURS * 3600 diff --git a/opskit/_opencode.py b/opskit/_opencode.py new file mode 100644 index 0000000..c0b1ee9 --- /dev/null +++ b/opskit/_opencode.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +""" +_opencode.py — opencode version detection + upgrade-check helpers. + +Pure parsing/comparison helpers plus thin subprocess wrappers, so the +``opskit go`` pre-launch check can be unit-tested without a network or a real +opencode binary. + +The installed opencode in this environment tracks the ``anomalyco/opencode`` +*dev* channel (its ``dev`` branch / ``vX.Y.Z`` tags). The published GitHub +"latest release" and npm ``opencode-ai`` lag behind that channel, so the dev +tags are the source we compare against for display and the upgrade offer. The +upgrade itself is delegated to ``opencode upgrade``, which is its own oracle. +""" + +import os +import re +import subprocess + +DEFAULT_OPENCODE_REPO = "anomalyco/opencode" +REPO_ENV_VAR = "OPSKIT_OPENCODE_REPO" +_TIMEOUT = 10 + +_VERSION_RE = re.compile(r"v?(\d+)\.(\d+)\.(\d+)") +_TAG_RE = re.compile(r"^refs/tags/v(\d+\.\d+\.\d+)$") + + +def _as_tuple(version): + """Coerce a version string (or tuple) to a (major, minor, patch) tuple.""" + if isinstance(version, tuple): + return version + if not version: + return None + match = _VERSION_RE.search(version) + if not match: + return None + return tuple(int(part) for part in match.groups()) + + +def parse_version(text): + """Extract a (major, minor, patch) tuple from text, or None.""" + if not text: + return None + match = _VERSION_RE.search(text) + if not match: + return None + return tuple(int(part) for part in match.groups()) + + +def is_newer(latest, installed): + """True when ``latest`` is a strictly newer semver than ``installed``.""" + a = _as_tuple(latest) + b = _as_tuple(installed) + if a is None or b is None: + return False + return a > b + + +def parse_latest_tag(ls_remote_output): + """Highest plain ``vX.Y.Z`` tag from ``git ls-remote --tags`` output. + + Ignores ``vscode-*`` tags and peeled ``^{}`` entries. + """ + best = None + best_v = None + for line in (ls_remote_output or "").splitlines(): + parts = line.split() + if not parts: + continue + match = _TAG_RE.match(parts[-1]) + if not match: + continue + version = tuple(int(part) for part in match.group(1).split(".")) + if best_v is None or version > best_v: + best_v = version + best = match.group(1) + return best + + +def opencode_repo(): + """Repo whose dev tags define the upgrade channel.""" + return os.environ.get(REPO_ENV_VAR, DEFAULT_OPENCODE_REPO) + + +def get_installed_version(runner=subprocess.run): + """Installed opencode version as a string, or None if unavailable.""" + try: + result = runner( + ["opencode", "--version"], + capture_output=True, text=True, timeout=_TIMEOUT, + ) + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return None + version = parse_version((result.stdout or "") + (result.stderr or "")) + if version is None: + return None + return ".".join(str(part) for part in version) + + +def get_latest_dev_version(repo=None, runner=subprocess.run): + """Latest dev-channel version via ``git ls-remote --tags``, or None. + + Any network/tool failure (offline, blocked, timeout) returns None so the + caller can silently skip the offer. + """ + url = f"https://github.com/{repo or opencode_repo()}" + try: + result = runner( + ["git", "ls-remote", "--tags", url], + capture_output=True, text=True, timeout=_TIMEOUT, + ) + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return None + if getattr(result, "returncode", 1) != 0: + return None + return parse_latest_tag(result.stdout or "")