Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions bin/opskit-go.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
)


Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
34 changes: 34 additions & 0 deletions opskit/_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
116 changes: 116 additions & 0 deletions opskit/_opencode.py
Original file line number Diff line number Diff line change
@@ -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 "")
94 changes: 94 additions & 0 deletions plans/444-opencode-upgrade-check.md
Original file line number Diff line number Diff line change
@@ -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/<repo>`, 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.
Loading
Loading