Skip to content

Commit 9ad470d

Browse files
rainxchzedclaude
andauthored
Add komi-learn update self-update command (#8)
* Add `komi-learn update` self-update command Users had no in-CLI way to upgrade after a new PyPI release. `update` checks PyPI for a newer version and upgrades in place via the right package manager: - pipx when running from a pipx-managed venv (`pipx upgrade`) - otherwise pip into the *running* interpreter (the one the hooks import), mirroring model_install.py - if neither can be determined safely, print the command instead of guessing and risking a broken environment `--check` reports availability without upgrading; `--yes` skips the confirm. Network failures are non-fatal. After upgrading, the new version is read from a fresh subprocess (importlib.metadata is cached in-process). Also fixes a stale `__version__` (hardcoded 0.1.0 while pyproject was 0.3.0): it now derives from installed distribution metadata, so doctor and update both report the truth and the two never drift again. Tests: tests/test_update.py (25) — version compare incl. 0.10>0.9, PyPI lookup + offline/malformed, pip/pipx detection, undetectable fallback, upgrade success/failure, and all CLI routing branches. Full suite 240 passed, 1 skip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Apply 3-persona review fixes to update command Adversarial review (security + AI-eng + architect) found real issues; fixed all: Security: - check_latest now refuses HTTP redirects (no-redirect opener) and verifies the final URL is https -- the hardcoded literal only guaranteed the first hop, so a MITM could downgrade to http and feed a spoofed "newer" version. Bound the response read at 5MB (DoS guard). - pipx upgrade resolves pipx to an ABSOLUTE path via shutil.which (was unqualified "pipx" off PATH -- a planted binary could run during update); refuses if not found. Tightened _is_pipx so a bare PIPX_HOME env var no longer flips a pip install into the pipx branch -- the interpreter must actually live under it. Correctness (AI-eng): - Rewrote version comparison as a self-contained PEP 440-lite comparator and DROPPED the optional packaging dependency. The old fallback disagreed with packaging on rc/post/dev/epoch and on 1.0 vs 1.0.0, so the answer varied by environment -- a real downgrade/no-op-as-upgrade bug for our likely 0.4.0rc releases. Now one code path: pads tuples (1.0==1.0.0), strips leading v, orders dev < pre(a<b<rc) < final < post, honors epoch. - cmd_update no longer claims "upgraded to X" when the post-upgrade re-check can't confirm the version (pip exiting 0 isn't proof); says "couldn't confirm" instead. Architecture: - Deleted dead UpdateResult dataclass (exported, never constructed). - Single source of truth for the version: pyproject reads komi.__version__ via setuptools dynamic version, killing the _FALLBACK_VERSION literal drift. Build + twine check verified (wheel/sdist resolve to 0.3.0). Tests: fixed the tautological --check assert (both sides were identical and the string matched a different branch); added is_newer cases for rc/post/dev/epoch + 1.0/1.0.0 + leading-v + a total-ordering chain; added redirect-refusal, non-https-final-url, oversized-body, pipx-abs-path, pipx-not-found, and bare-PIPX_HOME-insufficient tests; added a dynamic-version drift guard. test_update.py 25 -> 51. Full suite 266 passed, 1 skip. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 65781f6 commit 9ad470d

6 files changed

Lines changed: 737 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ pip install -e .
3434

3535
```bash
3636
komi-learn doctor # check the install and what to fix
37+
komi-learn update # upgrade to the latest version (--check to only look)
3738
komi-learn status # config + how much it has learned
3839
komi-learn config # change any setting (menu, or `config set <key> <val>`)
3940
komi-learn sync # pull the latest community learnings

komi/__init__.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,23 @@
11
"""komi-learn — a continuous, zero-friction learning layer for AI agents."""
22

3-
__version__ = "0.1.0"
3+
# THE single source of truth for the version. pyproject.toml reads this literal at
4+
# build time via setuptools dynamic version ([tool.setuptools.dynamic] version =
5+
# {attr = "komi.__version__"}), so there is exactly one place to bump on a release
6+
# and the packaged metadata can never drift from this value by construction.
7+
__version__ = "0.3.0"
8+
9+
# When installed, prefer the distribution metadata — it's the ground truth of what
10+
# pip actually has on disk, which is what `komi-learn update` compares against
11+
# PyPI. For a bare source tree (no installed dist) the literal above stands in.
12+
# Because of the build-time attr binding the two agree, so this only matters for
13+
# odd editable-install states — and it can only correct toward reality, never
14+
# introduce a second hand-maintained number.
15+
try: # importlib.metadata is stdlib on py3.8+
16+
from importlib.metadata import PackageNotFoundError, version as _dist_version
17+
18+
try:
19+
__version__ = _dist_version("komi-learn")
20+
except PackageNotFoundError:
21+
pass # not installed (source tree) — keep the literal
22+
except Exception: # pragma: no cover - metadata API should always be present
23+
pass

komi/cli.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,73 @@ def cmd_login(args) -> int:
299299
return rc
300300

301301

302+
def cmd_update(args) -> int:
303+
"""Self-update: check PyPI for a newer komi-learn and upgrade in place.
304+
305+
Upgrades *this* interpreter's install (the one the hooks import), via pip or
306+
pipx depending on how komi-learn was installed. Use --check to only report
307+
whether an update is available. After upgrading, re-run `komi-learn install`
308+
is NOT required for code — but if a release adds new hook events, run it to
309+
refresh your settings."""
310+
import komi
311+
from komi import updater
312+
from komi import cli_prompt as PR
313+
314+
current = getattr(komi, "__version__", "?")
315+
_p(f"{PRODUCT}: installed {current}. Checking PyPI…")
316+
latest = updater.check_latest()
317+
if latest is None:
318+
_p(f"{PRODUCT}: couldn't reach PyPI (offline?). Try again later, or upgrade manually:")
319+
_p(f" {updater.plan_upgrade().display()}")
320+
return 1
321+
if not updater.is_newer(latest, current):
322+
_p(f"{PRODUCT}: you're on the latest version ({current}). Nothing to do.")
323+
return 0
324+
325+
_p(f"{PRODUCT}: a newer version is available — {current}{latest}.")
326+
plan = updater.plan_upgrade()
327+
328+
if getattr(args, "check", False):
329+
_p(f" to upgrade: {plan.display()}")
330+
return 0
331+
332+
if not plan.runnable:
333+
# Couldn't identify a safe package manager — never guess, just instruct.
334+
_p(f" couldn't auto-detect how {PRODUCT} was installed. Upgrade with:")
335+
_p(f" {plan.display()}")
336+
return 1
337+
338+
if not getattr(args, "yes", False):
339+
if not PR.ask_yes_no(f" Upgrade now via {plan.manager}?", default=True,
340+
summary=f"Runs: {plan.display()}"):
341+
_p(f" skipped. Upgrade anytime with: {plan.display()}")
342+
return 0
343+
344+
_p(f"\n upgrading via {plan.manager}\n")
345+
ok, detail = updater.run_upgrade(plan)
346+
if not ok:
347+
_p(f"\n{PRODUCT}: upgrade failed ({detail}). You can run it manually:")
348+
_p(f" {plan.display()}")
349+
return 1
350+
351+
# importlib.metadata is cached in this process; read the truth from a fresh one.
352+
# Only claim a confirmed version when we actually read one — a non-zero pip
353+
# exit isn't proof komi-learn reached `latest` (pip can no-op or install a
354+
# pinned older version), so never substitute `latest` and call it confirmed.
355+
new = updater.installed_version_via_subprocess()
356+
if new is None:
357+
_p(f"\n{PRODUCT}: upgrade command finished, but I couldn't confirm the "
358+
"installed version.")
359+
_p(" Check it with: komi-learn update --check")
360+
else:
361+
_p(f"\n{PRODUCT}: upgraded {current}{new}.")
362+
if updater.is_newer(latest, new):
363+
_p(f" note: PyPI shows {latest} but the install reports {new} — "
364+
"you may be in a different environment than expected.")
365+
_p(" If this release added hook events, refresh them with: komi-learn install")
366+
return 0
367+
368+
302369
def cmd_queue(args) -> int:
303370
"""Inspect + act on the global-contribution review queue (the human gate).
304371
@@ -471,6 +538,13 @@ def build_parser() -> argparse.ArgumentParser:
471538
pl = sub.add_parser("login", help="log in for free OAuth distillation (claude CLI)")
472539
pl.set_defaults(func=cmd_login)
473540

541+
pup = sub.add_parser("update", help="check PyPI and upgrade komi-learn to the latest version")
542+
pup.add_argument("--check", action="store_true",
543+
help="only report whether an update is available; don't upgrade")
544+
pup.add_argument("--yes", "-y", action="store_true",
545+
help="upgrade without the confirmation prompt")
546+
pup.set_defaults(func=cmd_update)
547+
474548
pc = sub.add_parser("curate", help="consolidate the learning library now (normally ~weekly)")
475549
pc.add_argument("--dry-run", action="store_true", help="preview changes without applying")
476550
pc.add_argument("--no-llm", action="store_true", help="prune only; don't merge clusters")

komi/updater.py

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
"""komi-learn — self-update: check PyPI for a newer release and upgrade in place.
2+
3+
`komi-learn update` should Just Work regardless of how the user installed us. The
4+
hard part isn't the PyPI check — it's upgrading *the right environment*. A blind
5+
`pip install -U` can hit the wrong interpreter, fight a pipx-managed venv, or need
6+
permissions it doesn't have. So we detect the install method first and run the
7+
command that matches it (pip-into-this-interpreter, or `pipx upgrade`). If we
8+
genuinely can't tell, we print the command instead of guessing and breaking the
9+
user's environment.
10+
11+
Everything here is best-effort and network-failure-safe: a flaky PyPI lookup never
12+
raises out of `check_latest`, it just returns ``None``.
13+
"""
14+
15+
from __future__ import annotations
16+
17+
import json
18+
import os
19+
import re
20+
import shutil
21+
import subprocess
22+
import sys
23+
import urllib.request
24+
from dataclasses import dataclass
25+
from typing import Optional
26+
27+
DIST_NAME = "komi-learn"
28+
PYPI_JSON_URL = f"https://pypi.org/pypi/{DIST_NAME}/json"
29+
_PYPI_TIMEOUT = 8 # seconds — a stuck lookup must not hang the CLI
30+
_MAX_PYPI_BYTES = 5 * 1024 * 1024 # cap the response read (one project's JSON << this)
31+
32+
33+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
34+
"""A redirect handler that refuses every redirect (returns None)."""
35+
36+
def redirect_request(self, *args, **kwargs): # noqa: D401
37+
return None
38+
39+
40+
# Module-level opener so we don't rebuild it per call. Refuses 3xx redirects.
41+
_NO_REDIRECT_OPENER = urllib.request.build_opener(_NoRedirect)
42+
43+
44+
# ── version comparison ───────────────────────────────────────────────────────
45+
#
46+
# A self-contained PEP 440-lite comparator. We deliberately do NOT depend on
47+
# `packaging`: the engine's ethos is zero required deps, and "is 0.4.0 newer than
48+
# 0.3.0" doesn't justify a runtime dependency that may or may not be present
49+
# (depending on whether the lib happened to compute the answer differently per
50+
# environment). This handles the version shapes komi-learn actually ships —
51+
# release tuples, pre-releases (a/b/rc), post/dev, and a leading epoch — with one
52+
# code path, so the answer never varies by environment.
53+
54+
# pre-release phase ranks: anything pre sorts BEFORE the plain release; post AFTER.
55+
_PRE_RANK = {"a": 0, "alpha": 0, "b": 1, "beta": 1, "rc": 2, "c": 2, "pre": 2,
56+
"preview": 2}
57+
_FINAL = 3 # a plain release (no pre/post/dev) sits between pre and post
58+
_POST = 4
59+
# .devN sorts before everything at the same release (earliest), so it gets a phase
60+
# below the lowest pre-rank.
61+
_DEV = -1
62+
63+
_RELEASE_RE = re.compile(r"^(?:(\d+)!)?(\d+(?:\.\d+)*)(.*)$")
64+
_PRE_RE = re.compile(r"[._-]?(a|b|c|rc|alpha|beta|pre|preview)[._-]?(\d*)")
65+
_POST_RE = re.compile(r"[._-]?(?:post|rev|r)[._-]?(\d*)|-(\d+)")
66+
_DEV_RE = re.compile(r"[._-]?dev[._-]?(\d*)")
67+
68+
69+
def _norm_release(rel: str) -> tuple:
70+
"""Numeric release tuple with trailing zeros stripped so 1.0 == 1.0.0."""
71+
parts = [int(p) for p in rel.split(".")]
72+
while len(parts) > 1 and parts[-1] == 0:
73+
parts.pop()
74+
return tuple(parts)
75+
76+
77+
def _version_key(v: str):
78+
"""Map a version string to a tuple that sorts per PEP 440 (for the shapes we
79+
ship). Unparseable input sorts lowest so it never spuriously beats a real
80+
release. The key shape is:
81+
(epoch, release_tuple, phase, phase_num, dev_num)
82+
where ``phase`` orders dev < pre(a<b<rc) < final < post.
83+
"""
84+
s = (v or "").strip().lower()
85+
if s[:1] == "v": # tolerate a leading v/V tag
86+
s = s[1:]
87+
m = _RELEASE_RE.match(s)
88+
if not m:
89+
return (-1, (), _DEV, -1, -1) # lowest possible
90+
epoch = int(m.group(1) or 0)
91+
release = _norm_release(m.group(2))
92+
suffix = m.group(3) or ""
93+
94+
dev = _DEV_RE.search(suffix)
95+
pre = _PRE_RE.search(suffix)
96+
post = _POST_RE.search(suffix)
97+
98+
# dev takes precedence as the earliest phase; then pre; then post; else final.
99+
if dev:
100+
phase, phase_num = _DEV, int(dev.group(1) or 0)
101+
elif pre:
102+
phase, phase_num = _PRE_RANK.get(pre.group(1), 2), int(pre.group(2) or 0)
103+
elif post:
104+
phase, phase_num = _POST, int(post.group(1) or post.group(2) or 0)
105+
else:
106+
phase, phase_num = _FINAL, 0
107+
dev_num = int(dev.group(1) or 0) if dev else 0
108+
return (epoch, release, phase, phase_num, dev_num)
109+
110+
111+
# kept as a public helper (tests + back-compat); now returns the release tuple.
112+
def _parse_version(v: str) -> tuple:
113+
"""The numeric release tuple (trailing zeros stripped), e.g. "0.4.0rc1"->(0,4).
114+
For full ordering (incl. pre/post/epoch) use :func:`is_newer`."""
115+
return _version_key(v)[1] or (0,)
116+
117+
118+
def is_newer(latest: str, current: str) -> bool:
119+
"""True if ``latest`` is a strictly newer version than ``current`` (PEP 440-lite)."""
120+
return _version_key(latest) > _version_key(current)
121+
122+
123+
# ── PyPI lookup ──────────────────────────────────────────────────────────────
124+
125+
def check_latest(*, timeout: int = _PYPI_TIMEOUT) -> Optional[str]:
126+
"""Return the latest version string on PyPI, or ``None`` if it can't be
127+
determined (offline, PyPI down, malformed payload). Never raises."""
128+
try:
129+
req = urllib.request.Request(
130+
PYPI_JSON_URL, headers={"Accept": "application/json",
131+
"User-Agent": f"{DIST_NAME}-updater"})
132+
# Refuse redirects: the hardcoded https literal only guarantees the FIRST
133+
# hop. urllib's default handler would follow a 3xx to any host/scheme,
134+
# including http — a MITM could downgrade the lookup and feed us a fake
135+
# "newer" version to coerce an upgrade. PyPI's JSON API doesn't redirect
136+
# cross-scheme here, so refusing is safe.
137+
with _NO_REDIRECT_OPENER.open(req, timeout=timeout) as resp: # nosec B310 - https, no-redirect
138+
if not (resp.geturl() or "").lower().startswith("https://"):
139+
return None
140+
# Bound the read: a hostile endpoint must not be able to stream a huge
141+
# body and OOM the CLI. One project's JSON is far under this cap.
142+
raw = resp.read(_MAX_PYPI_BYTES + 1)
143+
if len(raw) > _MAX_PYPI_BYTES:
144+
return None
145+
data = json.loads(raw.decode("utf-8"))
146+
ver = (data.get("info") or {}).get("version")
147+
return ver or None
148+
except Exception:
149+
return None
150+
151+
152+
# ── install-method detection ─────────────────────────────────────────────────
153+
154+
def _is_pipx() -> bool:
155+
"""Are we running from a pipx-managed venv? pipx installs each app into
156+
``.../pipx/venvs/<app>/``.
157+
158+
We key off the *interpreter prefix* — the one signal an attacker can't set
159+
just by exporting an env var. A bare ``PIPX_HOME`` is NOT sufficient on its
160+
own: that would let a hostile environment flip a plain pip install into the
161+
pipx branch (and then a planted ``pipx`` on PATH would run). The env var only
162+
*corroborates* a prefix that already lives under it.
163+
"""
164+
prefix = (sys.prefix or "").replace("\\", "/")
165+
low = prefix.lower()
166+
if "/pipx/venvs/" in low or "/pipx/shared" in low:
167+
return True
168+
# If PIPX_HOME is set AND our interpreter actually lives under it, trust it.
169+
pipx_home = (os.environ.get("PIPX_HOME") or "").replace("\\", "/")
170+
if pipx_home and prefix.startswith(pipx_home):
171+
return True
172+
return False
173+
174+
175+
def _pip_available() -> bool:
176+
try:
177+
import pip # noqa: F401
178+
return True
179+
except Exception:
180+
return False
181+
182+
183+
@dataclass
184+
class UpgradePlan:
185+
"""How we intend to upgrade. ``cmd`` is the argv to run; ``manager`` is for
186+
display; ``runnable`` is False when we couldn't determine a safe command (then
187+
``cmd`` is the human-facing suggestion to print, not to execute)."""
188+
manager: str
189+
cmd: list
190+
runnable: bool
191+
192+
def display(self) -> str:
193+
return " ".join(self.cmd)
194+
195+
196+
def plan_upgrade() -> UpgradePlan:
197+
"""Decide the upgrade command for *this* environment."""
198+
if _is_pipx():
199+
# pipx manages its own venv; pip -U inside it is the wrong tool. Resolve
200+
# pipx to an ABSOLUTE path (never an unqualified "pipx" off PATH — that
201+
# would let a planted binary earlier in PATH run during `update`). If we
202+
# can't find it, refuse and print the command rather than guess.
203+
pipx_path = shutil.which("pipx")
204+
if not pipx_path:
205+
return UpgradePlan("pipx", ["pipx", "upgrade", DIST_NAME], runnable=False)
206+
return UpgradePlan("pipx", [pipx_path, "upgrade", DIST_NAME], runnable=True)
207+
if _pip_available():
208+
# Upgrade into the very interpreter that's running komi-learn — this is the
209+
# one whose `import komi` the hooks use. Mirrors model_install.py.
210+
return UpgradePlan(
211+
"pip",
212+
[sys.executable, "-m", "pip", "install", "--upgrade", DIST_NAME],
213+
runnable=True,
214+
)
215+
# Couldn't find a package manager we trust to drive — hand the user a command
216+
# rather than risk corrupting a standalone/frozen install.
217+
return UpgradePlan("pip", ["pip", "install", "--upgrade", DIST_NAME], runnable=False)
218+
219+
220+
# ── upgrade execution + re-verify ────────────────────────────────────────────
221+
222+
def installed_version_via_subprocess() -> Optional[str]:
223+
"""Read komi-learn's version in a *fresh* interpreter.
224+
225+
importlib.metadata caches distribution info for the life of a process, so after
226+
an in-process pip upgrade the running interpreter still reports the OLD version.
227+
Shelling out to a clean python gets the truth post-upgrade.
228+
"""
229+
# Pass the dist name as argv (sys.argv[1]) rather than interpolating it into
230+
# the code string — keeps the snippet free of string-building even though
231+
# DIST_NAME is a trusted literal.
232+
code = (
233+
"import sys\n"
234+
"try:\n"
235+
" from importlib.metadata import version\n"
236+
" sys.stdout.write(version(sys.argv[1]))\n"
237+
"except Exception:\n"
238+
" pass\n"
239+
)
240+
try:
241+
r = subprocess.run([sys.executable, "-c", code, DIST_NAME],
242+
capture_output=True, text=True, timeout=30)
243+
out = (r.stdout or "").strip()
244+
return out or None
245+
except Exception:
246+
return None
247+
248+
249+
def run_upgrade(plan: UpgradePlan, *, timeout: int = 1200) -> tuple[bool, str]:
250+
"""Execute the upgrade command. Returns (ok, detail). Output streams to the
251+
user's terminal so they see pip's progress (and any resolver errors)."""
252+
if not plan.runnable:
253+
return False, "no runnable upgrade command for this environment"
254+
try:
255+
r = subprocess.run(plan.cmd, timeout=timeout)
256+
except FileNotFoundError:
257+
return False, f"`{plan.cmd[0]}` not found on PATH"
258+
except subprocess.TimeoutExpired:
259+
return False, "upgrade timed out"
260+
except Exception as e:
261+
return False, f"upgrade failed to launch: {e}"
262+
if r.returncode != 0:
263+
return False, f"upgrade exited {r.returncode}"
264+
return True, "upgraded"
265+
266+
267+
__all__ = [
268+
"DIST_NAME", "PYPI_JSON_URL",
269+
"check_latest", "is_newer", "plan_upgrade", "UpgradePlan",
270+
"run_upgrade", "installed_version_via_subprocess",
271+
]

0 commit comments

Comments
 (0)